// 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 === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """); } function note(text: string): string { return `
${text}
`; } /** "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 `
${hits.length}
${b.label}
${critical} unter 80 %
`; }).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 `
${esc(x.p.name)} ${pct}% ${x.d < 0 ? Math.abs(x.d) + " T. über" : "in " + x.d + " T."}
`; }).join(""); widget.el.innerHTML = `
${tiles}
${soon.length === 0 ? note("In den nächsten 90 Tagen endet planmäßig kein Projekt.") : `
Nächste Endtermine
${rows}`} ${!loaded.complete ? `
Auswertung über ${loaded.items.length} von ${loaded.total ?? "?"} Projekten.
` : ""}
`; widget.done(); })();