// 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();
})();