PortfolioRecommended 6 × 9PMO

Deadline radar 30/60/90

Groups all projects by when they are due to finish and flags those whose progress does not support that date.

LANDING LOAD1230 d360 d490 d5at riskwatchon track

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 landing load of the next three months appears in no list, because projects are sorted by status rather than by end date. This tile sorts by deadline: which projects are due to finish in 30, 60 and 90 days - and which of them carry a progress figure that does not match. Completed projects with an actual end date stay out. The result is the escalation list before anyone escalates.

What the tile shows you

  • Three columns for the 30, 60 and 90 day windows, each with the number of projects in it.
  • One entry per project; the color shows whether progress fits the remaining time.
  • Flagged entries are the ones whose progress is too low for the time left.

The complete code

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

TypeScript128 lines
Raw file
// Fristen-Radar 30/60/90
// Quelle: widget.query("portfolio.projects") — seitenweise über die volle Menge.
// Abgeschlossene Projekte (mit Ist-Ende) bleiben außen vor.

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 today0(): number {
  const d = new Date();
  d.setHours(0, 0, 0, 0);
  return d.getTime();
}

function daysLeft(iso: string): number | null {
  const t: number = dayStart(iso);
  if (Number.isNaN(t)) return null;
  return Math.round((t - today0()) / DAY);
}

/** Lädt bis zu MAX_PAGES Seiten à 200 Projekte über den Cursor. */
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("Lade Projekttermine …");

(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 running = loaded.items.filter((p) => !p.actualEnd || p.actualEnd === "");
  const withDays = running
    .map((p) => ({ p: p, d: daysLeft(p.planEnd) }))
    .filter((x) => x.d !== null) as { p: WidgetPortfolioProject; d: number }[];

  const buckets = [
    { label: "überfällig", color: C.red, hit: (d: number) => d < 0 },
    { label: "≤ 30 Tage", color: C.amber, hit: (d: number) => d >= 0 && d <= 30 },
    { label: "31–60 Tage", color: C.green, hit: (d: number) => d > 30 && d <= 60 },
    { label: "61–90 Tage", color: C.sub, hit: (d: number) => d > 60 && d <= 90 },
  ];

  const tiles: string = buckets.map((b) => {
    const hits = withDays.filter((x) => b.hit(x.d));
    const critical = hits.filter((x) => x.p.progress < 80).length;
    return `<div style="flex:1;background:${C.card};border-radius:8px;padding:8px 10px;min-width:0;border-top:2px solid ${b.color}">
      <div style="font-size:20px;font-weight:700;color:${hits.length > 0 ? b.color : C.sub}">${hits.length}</div>
      <div style="font-size:10px;color:${C.sub}">${b.label}</div>
      <div style="font-size:9px;color:${critical > 0 ? C.red : C.sub}">${critical} unter 80 %</div>
    </div>`;
  }).join("");

  const soon = withDays
    .filter((x) => x.d <= 90)
    .sort((a, b) => a.d - b.d)
    .slice(0, 7);

  const rows: string = soon.map((x) => {
    const color: string = x.d < 0 ? C.red : x.d <= 30 ? C.amber : C.sub;
    const pct: number = Math.min(Math.max(x.p.progress, 0), 100);
    const barColor: string = pct >= 80 ? C.green : pct >= 50 ? C.amber : C.red;
    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="width:52px;flex-shrink:0;height:5px;background:${C.line};border-radius:3px;overflow:hidden">
        <span style="display:block;height:100%;width:${pct}%;background:${barColor}"></span>
      </span>
      <span style="width:32px;flex-shrink:0;text-align:right;color:${C.sub};font-variant-numeric:tabular-nums">${pct}%</span>
      <span style="min-width:70px;text-align:right;color:${color};font-weight:600;white-space:nowrap">${x.d < 0 ? Math.abs(x.d) + " T. über" : "in " + x.d + " T."}</span>
    </div>`;
  }).join("");

  widget.el.innerHTML = `
    <div style="padding:10px 12px;color:${C.text}">
      <div style="display:flex;gap:6px;margin-bottom:10px">${tiles}</div>
      ${soon.length === 0
        ? note("In den nächsten 90 Tagen endet planmäßig kein Projekt.")
        : `<div style="font-size:10px;color:${C.sub};text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px">Nächste Endtermine</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
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.