PortfolioRecommended 6 × 9PMO

Reporting discipline

Works out how many days ago each project's last status report was written and puts the laggards and never-reported projects on top.

DAYS SINCE LAST REPORT2never62 d41 d22 d8 ddue

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

What it is good for

The PMO's follow-up list writes itself here. The tile computes the age of each project's last status report in days and sorts descending, so the laggards come first. Projects that have never had a report are the harder case and are called out separately rather than quietly passing as 'zero days'. Two thresholds at the top of the code decide when a report is due and when it is overdue.

What the tile shows you

  • One row per project, sorted by the age of its last status report.
  • The bar is the report age in days; the dashed line is the due threshold.
  • Projects without any report at all sit at the very top and are marked as such.

The complete code

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

TypeScript134 lines
Raw file
// Berichtsdisziplin
// Quelle: widget.query("portfolio.projects") — reportDate ist leer,
// wenn zum Projekt noch nie ein Bericht erstellt wurde.

const STALE_DAYS = 30;  // ab hier gilt ein Bericht als überfällig
const DUE_DAYS = 14;    // ab hier wird er fällig

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;
}

function ageInDays(iso: string): number | null {
  if (!iso || iso === "") return null;
  const t: number = dayStart(iso);
  if (Number.isNaN(t)) return null;
  const today = new Date();
  today.setHours(0, 0, 0, 0);
  return Math.max(0, Math.round((today.getTime() - t) / DAY));
}

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 Berichtsstände …");

(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 active = loaded.items.filter((p) => !p.actualEnd || p.actualEnd === "");
  if (active.length === 0) {
    widget.el.innerHTML = note("Keine laufenden Projekte im Portfolio.");
    widget.done();
    return;
  }

  const scored = active.map((p) => ({ p: p, age: ageInDays(p.reportDate) }));
  const never = scored.filter((x) => x.age === null);
  const stale = scored.filter((x) => x.age !== null && (x.age as number) > STALE_DAYS);
  const due = scored.filter((x) => x.age !== null && (x.age as number) > DUE_DAYS && (x.age as number) <= STALE_DAYS);
  const fresh = scored.filter((x) => x.age !== null && (x.age as number) <= DUE_DAYS);

  const quote: number = Math.round((fresh.length / active.length) * 100);

  function tile(label: string, count: number, color: string): string {
    return `<div style="flex:1;background:${C.card};border-radius:8px;padding:8px 10px;min-width:0;border-top:2px solid ${color}">
      <div style="font-size:20px;font-weight:700;color:${count > 0 ? color : C.sub}">${count}</div>
      <div style="font-size:10px;color:${C.sub}">${label}</div>
    </div>`;
  }

  // Sortierung: nie berichtet zuerst, dann nach Alter absteigend.
  const worst = never
    .concat(stale.slice().sort((a, b) => (b.age as number) - (a.age as number)))
    .concat(due.slice().sort((a, b) => (b.age as number) - (a.age as number)))
    .slice(0, 7);

  const rows: string = worst.map((x) => {
    const age = x.age;
    const color: string = age === null ? C.red : age > STALE_DAYS ? C.red : C.amber;
    const txt: string = age === null ? "nie berichtet" : "vor " + age + " Tagen";
    return `<div style="display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid ${C.line};font-size:11px">
      <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:${C.text}">${esc(x.p.name)}</span>
      <span style="color:${C.sub};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:120px">${esc(x.p.projectLeadName || "ohne Leitung")}</span>
      <span style="min-width:88px;text-align:right;color:${color};font-weight:600;white-space:nowrap">${txt}</span>
    </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:${quote >= 80 ? C.green : quote >= 50 ? C.amber : C.red}">${quote} %</span>
        <span style="font-size:11px;color:${C.sub}">der ${active.length} laufenden Projekte sind aktuell berichtet (≤ ${DUE_DAYS} Tage)</span>
      </div>
      <div style="display:flex;gap:6px;margin-bottom:10px">
        ${tile("aktuell", fresh.length, C.green)}
        ${tile("fällig", due.length, C.amber)}
        ${tile("überfällig", stale.length, C.red)}
        ${tile("nie berichtet", never.length, C.red)}
      </div>
      ${worst.length === 0
        ? note("Alle Projekte sind aktuell berichtet.")
        : `<div style="font-size:10px;color:${C.sub};text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px">Nachfassliste</div>${rows}`}
      ${!loaded.complete ? `<div style="font-size:10px;color:${C.sub};margin-top:6px">Auswertung über ${loaded.items.length} von ${loaded.total ?? "?"} Projekten.</div>` : ""}
    </div>`;
  widget.done();
})();

Adjusting it

ConstantDefaultMeaning
STALE_DAYS30After how many days without a report a project counts as overdue.
DUE_DAYS14After how many days without a report a project is flagged as due.
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.