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
const CURRENCY = "EUR";
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 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;
}
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 today0(): number {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d.getTime();
}
function daysLeft(iso: string): number | null {
const t: number = dayStart(iso);
if (Number.isNaN(t)) return null;
return Math.round((t - today0()) / DAY);
}
widget.el.innerHTML = note("Lade Chancen …");
(async () => {
const res = await widget.query("project.opportunities", { limit: 200 });
if (!res.ok) {
widget.el.innerHTML = note(
res.error.code === "forbidden" ? "Keine Berechtigung für die Chancendaten."
: res.error.code === "unsupported_in_context" ? "Nur auf Projektebene verfügbar."
: "Chancen konnten nicht geladen werden.",
);
widget.done();
return;
}
const items = res.data.items;
if (items.length === 0) {
widget.el.innerHTML = note("Keine Chancen erfasst.");
widget.done();
return;
}
const withDays = items.map((o) => ({ o: o, d: daysLeft(o.windowEnd) }));
const buckets = [
{ label: "abgelaufen", color: C.sub, hit: (d: number | null) => d !== null && d < 0 },
{ label: "≤ 30 Tage", color: C.red, hit: (d: number | null) => d !== null && d >= 0 && d <= 30 },
{ label: "≤ 60 Tage", color: C.amber, hit: (d: number | null) => d !== null && d > 30 && d <= 60 },
{ label: "≤ 90 Tage", color: C.green, hit: (d: number | null) => d !== null && d > 60 && d <= 90 },
];
const tiles: string = buckets.map((b) => {
const hits = withDays.filter((x) => b.hit(x.d));
const value: number = hits.reduce((s, x) => s + x.o.benefit * (x.o.probability / 100), 0);
return `<div style="flex:1;background:${C.card};border-radius:8px;padding:8px 10px;min-width:0;border-top:2px solid ${b.color}">
<div style="font-size:18px;font-weight:700;color:${hits.length > 0 ? b.color : C.sub}">${hits.length}</div>
<div style="font-size:10px;color:${C.sub}">${b.label}</div>
<div style="font-size:9px;color:${C.sub};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${short(value)}</div>
</div>`;
}).join("");
const soon = withDays
.filter((x) => x.d !== null && x.d <= 90)
.sort((a, b) => (a.d as number) - (b.d as number))
.slice(0, 6);
const rows: string = soon.map((x) => {
const d: number = x.d as number;
const noMeasure: boolean = x.o.measureCount === 0;
const color: string = d < 0 ? C.sub : d <= 30 ? C.red : d <= 60 ? C.amber : C.green;
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.o.title)}</span>
${noMeasure ? `<span style="flex-shrink:0;background:${C.red}22;color:${C.red};font-size:9px;font-weight:700;padding:1px 5px;border-radius:8px">ohne Maßnahme</span>` : ""}
<span style="color:${C.sub};white-space:nowrap">${short(x.o.benefit * (x.o.probability / 100))}</span>
<span style="color:${color};font-weight:600;white-space:nowrap;min-width:64px;text-align:right">${d < 0 ? Math.abs(d) + " T. vorbei" : "in " + d + " T."}</span>
</div>`;
}).join("");
widget.el.innerHTML = `
<div style="padding:10px 12px;color:${C.text}">
<div style="display:flex;gap:6px;margin-bottom:10px">${tiles}</div>
${soon.length === 0
? note("In den nächsten 90 Tagen läuft kein Chancenfenster aus.")
: `<div style="font-size:10px;color:${C.sub};text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px">Nächste Fristen</div>${rows}`}
</div>`;
widget.done();
})();