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
135
136
137
138
139
140
const LAG_TOLERANCE = 20;
const REPORT_STALE_DAYS = 45;
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;
}
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 Ampeln gegen die Fakten …");
(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 now: number = Date.now();
const midnight = new Date();
midnight.setHours(0, 0, 0, 0);
const today: number = midnight.getTime();
const active = loaded.items.filter((p) => !p.actualEnd || p.actualEnd === "");
const reported = active.filter((p) => p.termin !== "" || p.kosten !== "" || p.leistung !== "");
type Finding = { name: string; lead: string; reason: string };
const findings: Finding[] = [];
active.forEach((p) => {
const end: number = dayStart(p.planEnd);
const start: number = dayStart(p.planStart);
const reasons: string[] = [];
if (p.termin === "green" && !Number.isNaN(end) && end < today) {
reasons.push("Endtermin seit " + Math.round((today - end) / DAY) + " Tagen überschritten");
}
if ((p.termin === "green" || p.leistung === "green") && !Number.isNaN(start) && !Number.isNaN(end) && end > start) {
const elapsed: number = Math.min(Math.max((now - start) / (end - start), 0), 1) * 100;
if (elapsed - p.progress > LAG_TOLERANCE) {
reasons.push(Math.round(elapsed - p.progress) + " Punkte hinter dem Zeitverlauf");
}
}
const rd: number = dayStart(p.reportDate);
if (p.termin === "green" && p.kosten === "green" && p.leistung === "green" && !Number.isNaN(rd)) {
const age: number = Math.round((today - rd) / DAY);
if (age > REPORT_STALE_DAYS) reasons.push("Bericht ist " + age + " Tage alt");
}
if (reasons.length > 0) {
findings.push({
name: p.name,
lead: p.projectLeadName || "ohne Leitung",
reason: reasons.join(" · "),
});
}
});
const quote: number = reported.length > 0 ? Math.round((findings.length / reported.length) * 100) : 0;
const rows: string = findings.slice(0, 8).map((f) => `
<div style="display:flex;align-items:flex-start;gap:8px;padding:6px 0;border-bottom:1px solid ${C.line};font-size:11px">
<span style="width:3px;align-self:stretch;flex-shrink:0;border-radius:2px;background:${C.red}"></span>
<div style="flex:1;min-width:0">
<div style="display:flex;gap:8px">
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:${C.text};font-weight:600">${esc(f.name)}</span>
<span style="color:${C.sub};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:110px">${esc(f.lead)}</span>
</div>
<div style="color:${C.amber};font-size:10px;margin-top:1px">${esc(f.reason)}</div>
</div>
</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:${findings.length === 0 ? C.green : quote >= 20 ? C.red : C.amber}">${findings.length}</span>
<span style="font-size:11px;color:${C.sub}">Projekte melden günstiger, als die Daten hergeben — von ${reported.length} berichteten</span>
</div>
${findings.length === 0
? note("Keine Widersprüche zwischen Ampel und Datenlage gefunden.")
: rows}
${findings.length > 8 ? `<div style="font-size:10px;color:${C.sub};margin-top:6px">${findings.length - 8} weitere Auffälligkeiten.</div>` : ""}
${!loaded.complete ? `<div style="font-size:10px;color:${C.sub};margin-top:4px">Auswertung über ${loaded.items.length} von ${loaded.total ?? "?"} Projekten.</div>` : ""}
</div>`;
widget.done();
})();