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
const CURRENCY = "EUR";
const TOP_N = 12;
const isDark: boolean = widget.data.theme === "dark";
const C = {
text: isDark ? "#f1f5f9" : "#1e293b",
sub: "#94a3b8",
line: isDark ? "#334155" : "#e2e8f0",
blue: "#3b82f6",
amber: "#f59e0b",
red: "#ef4444",
green: "#22c55e",
};
const MAX_PAGES = 3;
const CUR: string = new Intl.NumberFormat(navigator.language, {
style: "currency",
currency: CURRENCY,
}).formatToParts(0).filter((x) => x.type === "currency").map((x) => x.value)[0] || CURRENCY;
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 short(v: number): string {
const a: number = Math.abs(v);
if (a >= 1000000) return (v / 1000000).toLocaleString(navigator.language, { maximumFractionDigits: 1 }) + " Mio. " + CUR;
if (a >= 10000) return Math.round(v / 1000).toLocaleString(navigator.language) + " Tsd. " + CUR;
return Math.round(v).toLocaleString(navigator.language) + " " + CUR;
}
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("Berechne Budgetverteilung …");
(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 funded = loaded.items.filter((p) => p.budget > 0).sort((a, b) => b.budget - a.budget);
if (funded.length === 0) {
widget.el.innerHTML = note("Für kein Projekt ist ein Budget hinterlegt.");
widget.done();
return;
}
const total: number = funded.reduce((s, p) => s + p.budget, 0);
let running = 0;
let count80 = 0;
for (let i = 0; i < funded.length; i++) {
running += funded[i].budget;
count80 = i + 1;
if (running / total >= 0.8) break;
}
const shareOfCount: number = Math.round((count80 / funded.length) * 100);
const shown = funded.slice(0, TOP_N);
const maxBudget: number = shown[0].budget;
let cum = 0;
const rows: string = shown.map((p, i) => {
cum += p.budget;
const cumPct: number = (cum / total) * 100;
const w: number = (p.budget / maxBudget) * 100;
const inTop80: boolean = i < count80;
return `<div style="display:flex;align-items:center;gap:6px;padding:3px 0;font-size:11px">
<span style="width:96px;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:${C.text}">${esc(p.name)}</span>
<span style="flex:1;height:9px;background:${C.line};border-radius:2px;overflow:hidden;position:relative">
<span style="display:block;height:100%;width:${w.toFixed(1)}%;background:${inTop80 ? C.blue : C.sub};opacity:${inTop80 ? "1" : ".5"}"></span>
</span>
<span style="width:66px;flex-shrink:0;text-align:right;color:${C.sub};white-space:nowrap;font-variant-numeric:tabular-nums">${short(p.budget)}</span>
<span style="width:34px;flex-shrink:0;text-align:right;color:${cumPct >= 80 ? C.amber : C.sub};font-variant-numeric:tabular-nums">${cumPct.toFixed(0)}%</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:${shareOfCount <= 30 ? C.red : shareOfCount <= 50 ? C.amber : C.green}">${count80}</span>
<span style="font-size:11px;color:${C.sub}">von ${funded.length} Projekten binden 80 % des Budgets (${shareOfCount} % der Projekte, ${short(total)} gesamt)</span>
</div>
<div style="display:flex;gap:6px;font-size:9px;color:${C.sub};text-transform:uppercase;letter-spacing:.04em;padding-bottom:3px;border-bottom:1px solid ${C.line};margin-bottom:3px">
<span style="width:96px">Projekt</span><span style="flex:1">Budget</span>
<span style="width:66px;text-align:right">Betrag</span><span style="width:34px;text-align:right">kum.</span>
</div>
${rows}
${funded.length > TOP_N ? `<div style="font-size:10px;color:${C.sub};margin-top:5px">${funded.length - TOP_N} weitere Projekte mit kleinerem Budget.</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();
})();