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
131
132
133
134
const STALE_DAYS = 30;
const DUE_DAYS = 14;
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 `<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 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 ageInDays(iso: string): number | null {
if (!iso || iso === "") return null;
const t: number = dayStart(iso);
if (Number.isNaN(t)) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
return Math.max(0, Math.round((today.getTime() - t) / DAY));
}
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("Prüfe Berichtsstände …");
(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 active = loaded.items.filter((p) => !p.actualEnd || p.actualEnd === "");
if (active.length === 0) {
widget.el.innerHTML = note("Keine laufenden Projekte im Portfolio.");
widget.done();
return;
}
const scored = active.map((p) => ({ p: p, age: ageInDays(p.reportDate) }));
const never = scored.filter((x) => x.age === null);
const stale = scored.filter((x) => x.age !== null && (x.age as number) > STALE_DAYS);
const due = scored.filter((x) => x.age !== null && (x.age as number) > DUE_DAYS && (x.age as number) <= STALE_DAYS);
const fresh = scored.filter((x) => x.age !== null && (x.age as number) <= DUE_DAYS);
const quote: number = Math.round((fresh.length / active.length) * 100);
function tile(label: string, count: number, color: string): string {
return `<div style="flex:1;background:${C.card};border-radius:8px;padding:8px 10px;min-width:0;border-top:2px solid ${color}">
<div style="font-size:20px;font-weight:700;color:${count > 0 ? color : C.sub}">${count}</div>
<div style="font-size:10px;color:${C.sub}">${label}</div>
</div>`;
}
const worst = never
.concat(stale.slice().sort((a, b) => (b.age as number) - (a.age as number)))
.concat(due.slice().sort((a, b) => (b.age as number) - (a.age as number)))
.slice(0, 7);
const rows: string = worst.map((x) => {
const age = x.age;
const color: string = age === null ? C.red : age > STALE_DAYS ? C.red : C.amber;
const txt: string = age === null ? "nie berichtet" : "vor " + age + " Tagen";
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="color:${C.sub};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:120px">${esc(x.p.projectLeadName || "ohne Leitung")}</span>
<span style="min-width:88px;text-align:right;color:${color};font-weight:600;white-space:nowrap">${txt}</span>
</div>`;
}).join("");
widget.el.innerHTML = `
<div style="padding:10px 12px;color:${C.text}">
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:8px">
<span style="font-size:26px;font-weight:700;color:${quote >= 80 ? C.green : quote >= 50 ? C.amber : C.red}">${quote} %</span>
<span style="font-size:11px;color:${C.sub}">der ${active.length} laufenden Projekte sind aktuell berichtet (≤ ${DUE_DAYS} Tage)</span>
</div>
<div style="display:flex;gap:6px;margin-bottom:10px">
${tile("aktuell", fresh.length, C.green)}
${tile("fällig", due.length, C.amber)}
${tile("überfällig", stale.length, C.red)}
${tile("nie berichtet", never.length, C.red)}
</div>
${worst.length === 0
? note("Alle Projekte sind aktuell berichtet.")
: `<div style="font-size:10px;color:${C.sub};text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px">Nachfassliste</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();
})();