PortfolioRecommended 6 × 9PMOQuality and auditExecutive management

Status light contradiction detector

Looks for projects reported as green even though the end date, the progress or the report age says otherwise.

CONTRADICTIONS TO THE STATUS LIGHT4reported!End date exceeded-12 d!Progress behind elapsed time-34 %!Report out of date62 dThe data does not support the report

A rebuild in this website's style. The real tile picks up your tenant's fonts and color scheme.

What it is good for

Fair-weather reporting rarely surfaces, because nobody holds the report against the data. That is exactly what this tile does: it takes the green light from the most recent report and checks three counter-indications - has the end date passed, does progress lag far behind the elapsed share of time, is the report itself too old to prove anything. How far the lag may go and when a report counts as stale are thresholds at the top of the code.

What the tile shows you

  • On the left, the project's reported status light exactly as the last report states it.
  • On the right, the counter-indications from the data, each with its magnitude.
  • At the bottom, the conclusion: the data does not support the reported color.

The complete code

Self-contained, with no external library. Paste it into the editor, save, done.

TypeScript140 lines
Raw file
// Ampel-Widerspruchsdetektor
// Quelle: widget.query("portfolio.projects")
// Ampelwerte kommen aus dem jeweils letzten Report: "green" | "yellow" | "red" | ""

const LAG_TOLERANCE = 20; // Prozentpunkte Rückstand, ab denen Grün unglaubwürdig wird
const REPORT_STALE_DAYS = 45;

const isDark: boolean = widget.data.theme === "dark";
const C = {
  text: isDark ? "#f1f5f9" : "#1e293b",
  sub: "#94a3b8",
  card: isDark ? "#1e293b" : "#f8fafc",
  line: isDark ? "#334155" : "#e2e8f0",
  green: "#22c55e",
  amber: "#f59e0b",
  red: "#ef4444",
};

const DAY = 86400000;
const MAX_PAGES = 3;

function esc(s: string): string {
  return String(s).replace(/[&<>"]/g, (c: string) =>
    c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : "&quot;");
}

function note(text: string): string {
  return `<div style="display:flex;align-items:center;justify-content:center;height:100%;min-height:60px;padding:12px;font-size:12px;color:${C.sub};text-align:center">${text}</div>`;
}

/** "YYYY-MM-DD" als LOKALE Mitternacht lesen. Date.parse würde reine
 *  Datumsangaben als UTC interpretieren — Tagesdifferenzen kippen dann je
 *  nach Zeitzone um einen Tag. */
function dayStart(iso: string): number {
  const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso || "");
  return m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime() : NaN;
}

async function loadProjects() {
  const items: WidgetPortfolioProject[] = [];
  let cursor: string | undefined = undefined;
  let total: number | null = null;
  for (let page = 0; page < MAX_PAGES; page++) {
    const res = await widget.query("portfolio.projects", { limit: 200, cursor: cursor });
    if (!res.ok) return { error: res.error.code, items: items, total: total, complete: false };
    if (total === null) total = res.data.total;
    res.data.items.forEach((p) => items.push(p));
    if (!res.data.nextCursor) return { error: null, items: items, total: total, complete: true };
    cursor = res.data.nextCursor;
  }
  return { error: null, items: items, total: total, complete: false };
}

widget.el.innerHTML = note("Prüfe Ampeln gegen die Fakten …");

(async () => {
  const loaded = await loadProjects();
  if (loaded.error) {
    widget.el.innerHTML = note(
      loaded.error === "forbidden" ? "Keine Berechtigung für die Portfoliodaten."
        : loaded.error === "unsupported_in_context" ? "Nur auf Portfolioebene verfügbar."
        : "Projekte konnten nicht geladen werden.",
    );
    widget.done();
    return;
  }

  const now: number = Date.now();
  const midnight = new Date();
  midnight.setHours(0, 0, 0, 0);
  const today: number = midnight.getTime();

  const active = loaded.items.filter((p) => !p.actualEnd || p.actualEnd === "");
  const reported = active.filter((p) => p.termin !== "" || p.kosten !== "" || p.leistung !== "");

  type Finding = { name: string; lead: string; reason: string };
  const findings: Finding[] = [];

  active.forEach((p) => {
    const end: number = dayStart(p.planEnd);
    const start: number = dayStart(p.planStart);
    const reasons: string[] = [];

    // 1. Grün gemeldet, Endtermin trotzdem vorbei.
    if (p.termin === "green" && !Number.isNaN(end) && end < today) {
      reasons.push("Endtermin seit " + Math.round((today - end) / DAY) + " Tagen überschritten");
    }

    // 2. Grün gemeldet, Fortschritt hinkt dem Zeitverlauf hinterher.
    if ((p.termin === "green" || p.leistung === "green") && !Number.isNaN(start) && !Number.isNaN(end) && end > start) {
      const elapsed: number = Math.min(Math.max((now - start) / (end - start), 0), 1) * 100;
      if (elapsed - p.progress > LAG_TOLERANCE) {
        reasons.push(Math.round(elapsed - p.progress) + " Punkte hinter dem Zeitverlauf");
      }
    }

    // 3. Alles grün, aber der Bericht ist alt.
    const rd: number = dayStart(p.reportDate);
    if (p.termin === "green" && p.kosten === "green" && p.leistung === "green" && !Number.isNaN(rd)) {
      const age: number = Math.round((today - rd) / DAY);
      if (age > REPORT_STALE_DAYS) reasons.push("Bericht ist " + age + " Tage alt");
    }

    if (reasons.length > 0) {
      findings.push({
        name: p.name,
        lead: p.projectLeadName || "ohne Leitung",
        reason: reasons.join(" · "),
      });
    }
  });

  const quote: number = reported.length > 0 ? Math.round((findings.length / reported.length) * 100) : 0;

  const rows: string = findings.slice(0, 8).map((f) => `
    <div style="display:flex;align-items:flex-start;gap:8px;padding:6px 0;border-bottom:1px solid ${C.line};font-size:11px">
      <span style="width:3px;align-self:stretch;flex-shrink:0;border-radius:2px;background:${C.red}"></span>
      <div style="flex:1;min-width:0">
        <div style="display:flex;gap:8px">
          <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:${C.text};font-weight:600">${esc(f.name)}</span>
          <span style="color:${C.sub};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:110px">${esc(f.lead)}</span>
        </div>
        <div style="color:${C.amber};font-size:10px;margin-top:1px">${esc(f.reason)}</div>
      </div>
    </div>`).join("");

  widget.el.innerHTML = `
    <div style="padding:10px 12px;color:${C.text}">
      <div style="display:flex;align-items:baseline;gap:8px;margin-bottom:8px">
        <span style="font-size:26px;font-weight:700;color:${findings.length === 0 ? C.green : quote >= 20 ? C.red : C.amber}">${findings.length}</span>
        <span style="font-size:11px;color:${C.sub}">Projekte melden günstiger, als die Daten hergeben — von ${reported.length} berichteten</span>
      </div>
      ${findings.length === 0
        ? note("Keine Widersprüche zwischen Ampel und Datenlage gefunden.")
        : rows}
      ${findings.length > 8 ? `<div style="font-size:10px;color:${C.sub};margin-top:6px">${findings.length - 8} weitere Auffälligkeiten.</div>` : ""}
      ${!loaded.complete ? `<div style="font-size:10px;color:${C.sub};margin-top:4px">Auswertung über ${loaded.items.length} von ${loaded.total ?? "?"} Projekten.</div>` : ""}
    </div>`;
  widget.done();
})();

Adjusting it

ConstantDefaultMeaning
LAG_TOLERANCE20How many percentage points progress may lag behind elapsed time before a green status light counts as implausible.
REPORT_STALE_DAYS45After how many days a status report is too old to still support a green status light.
MAX_PAGES3How many pages the query loads at most. Raise it if the portfolio holds more projects than the analysis covers.
  1. 1Create the widget
  2. 2Paste the code
  3. 3Set the size and save

Build your own tile

Try WORKSPACE.PM for thirty days with every feature. The templates on this page work in a trial tenant exactly as they do in production.