1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
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 `<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>`;
}
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.",
);
}
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 `<span style="color:${C.red};font-weight:600;white-space:nowrap">${d === null ? "überfällig" : Math.abs(d) + " Tage überfällig"}</span>`;
}
if (d === null) return `<span style="color:${C.sub};white-space:nowrap">kein Termin</span>`;
const color: string = d <= 7 ? C.amber : C.sub;
return `<span style="color:${color};white-space:nowrap">in ${d} Tagen</span>`;
}
const rows: string = openList.slice(0, 7).map((m) => {
const isGate: boolean = m.type === "quality-gate";
return `<div style="display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid ${C.line};font-size:11px">
<span style="width:6px;height:6px;flex-shrink:0;background:${m.status === "overdue" ? C.red : C.green};${isGate ? "" : "border-radius:50%"}"></span>
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:${C.text}">${esc(m.name)}${isGate ? ` <span style="color:${C.violet};font-size:9px">GATE</span>` : ""}</span>
<span style="color:${C.sub};white-space:nowrap">${dateShort(m.planDate)}</span>
${chip(m)}
</div>`;
}).join("");
widget.el.innerHTML = `
<div style="padding:10px 12px;color:${C.text}">
<div style="display:flex;gap:12px;margin-bottom:8px">
<div style="flex:1">
<div style="font-size:20px;font-weight:700">${done.length}<span style="font-size:12px;color:${C.sub}">/${all.length}</span></div>
<div style="font-size:10px;color:${C.sub}">erreicht</div>
</div>
<div style="flex:1">
<div style="font-size:20px;font-weight:700;color:${nextIn !== null && nextIn <= 7 ? C.amber : C.text}">${nextIn === null ? "—" : nextIn}</div>
<div style="font-size:10px;color:${C.sub}">Tage bis zum nächsten</div>
</div>
<div style="flex:1">
<div style="font-size:20px;font-weight:700;color:${overdue.length > 0 ? C.red : C.green}">${overdue.length}</div>
<div style="font-size:10px;color:${C.sub}">überfällig</div>
</div>
</div>
<div style="height:5px;background:${C.line};border-radius:3px;overflow:hidden;margin-bottom:8px">
<div style="height:100%;width:${pct}%;background:${C.green}"></div>
</div>
${rows || note("Alle Meilensteine erreicht.")}
</div>`;
widget.done();
})();