// Ampel-Widerspruchsdetektor
// Quelle: widget.query("portfolio.projects")
// Ampelwerte kommen aus dem jeweils letzten Report: "green" | "yellow" | "red" | ""
const LAG_TOLERANCE = 20; // Prozentpunkte Rückstand, ab denen Grün unglaubwürdig wird
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 `
${text}
`;
}
/** "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;
}
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[] = [];
// 1. Grün gemeldet, Endtermin trotzdem vorbei.
if (p.termin === "green" && !Number.isNaN(end) && end < today) {
reasons.push("Endtermin seit " + Math.round((today - end) / DAY) + " Tagen überschritten");
}
// 2. Grün gemeldet, Fortschritt hinkt dem Zeitverlauf hinterher.
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");
}
}
// 3. Alles grün, aber der Bericht ist alt.
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) => `
${esc(f.name)}
${esc(f.lead)}
${esc(f.reason)}
`).join("");
widget.el.innerHTML = `
${findings.length}
Projekte melden günstiger, als die Daten hergeben — von ${reported.length} berichteten
${findings.length === 0
? note("Keine Widersprüche zwischen Ampel und Datenlage gefunden.")
: rows}
${findings.length > 8 ? `
${findings.length - 8} weitere Auffälligkeiten.
` : ""}
${!loaded.complete ? `
Auswertung über ${loaded.items.length} von ${loaded.total ?? "?"} Projekten.
` : ""}
`;
widget.done();
})();