// Meilenstein-Countdown // Quelle: widget.query("project.milestones") // Status wird vom Server abgeleitet: completed (Fortschritt 100 %), // overdue (Plantermin vergangen), sonst on-track. 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", violet: "#8b5cf6", }; const DAY = 86400000; function esc(s: string): string { return String(s).replace(/[&<>"]/g, (c: string) => c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """); } function note(text: string): string { return `
${text}
`; } function queryError(code: string): string { return note( code === "forbidden" ? "Keine Berechtigung für die Termindaten." : code === "unsupported_in_context" ? "Nur auf Projektebene verfügbar." : "Meilensteine konnten nicht geladen werden.", ); } /** "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 dayDiff(iso: string): number | null { const t: number = dayStart(iso); if (Number.isNaN(t)) return null; const today = new Date(); today.setHours(0, 0, 0, 0); return Math.round((t - today.getTime()) / DAY); } function dateShort(iso: string): string { const t: number = dayStart(iso); return Number.isNaN(t) ? "—" : new Date(t).toLocaleDateString(navigator.language, { day: "2-digit", month: "2-digit", year: "2-digit", }); } widget.el.innerHTML = note("Lade Meilensteine …"); (async () => { const res = await widget.query("project.milestones"); if (!res.ok) { widget.el.innerHTML = queryError(res.error.code); widget.done(); return; } const all = res.data.items; if (all.length === 0) { widget.el.innerHTML = note("Keine Meilensteine hinterlegt."); widget.done(); return; } const done = all.filter((m) => m.status === "completed"); const overdue = all.filter((m) => m.status === "overdue"); const openList = all .filter((m) => m.status !== "completed") .slice() .sort((a, b) => (Date.parse(a.planDate) || 0) - (Date.parse(b.planDate) || 0)); const next = openList.filter((m) => m.status !== "overdue")[0]; const nextIn: number | null = next ? dayDiff(next.planDate) : null; const pct: number = Math.round((done.length / all.length) * 100); function chip(m: WidgetMilestone): string { const d: number | null = dayDiff(m.planDate); if (m.status === "overdue") { return `${d === null ? "überfällig" : Math.abs(d) + " Tage überfällig"}`; } if (d === null) return `kein Termin`; const color: string = d <= 7 ? C.amber : C.sub; return `in ${d} Tagen`; } const rows: string = openList.slice(0, 7).map((m) => { const isGate: boolean = m.type === "quality-gate"; return `
${esc(m.name)}${isGate ? ` GATE` : ""} ${dateShort(m.planDate)} ${chip(m)}
`; }).join(""); widget.el.innerHTML = `
${done.length}/${all.length}
erreicht
${nextIn === null ? "—" : nextIn}
Tage bis zum nächsten
${overdue.length}
überfällig
${rows || note("Alle Meilensteine erreicht.")}
`; widget.done(); })();