// Portfolio-Zeitstrahl
// Quelle: widget.query("portfolio.projects")
// Zeitfenster: 3 Monate zurück bis 12 Monate voraus; Balken werden am
// Fensterrand abgeschnitten und mit einem Pfeil markiert.
const MONTHS_BACK = 3;
const MONTHS_AHEAD = 12;
const MAX_ROWS = 18;
const isDark: boolean = widget.data.theme === "dark";
const C = {
text: isDark ? "#f1f5f9" : "#1e293b",
sub: "#94a3b8",
line: isDark ? "#334155" : "#e2e8f0",
track: isDark ? "#1e293b" : "#f1f5f9",
green: "#22c55e",
amber: "#f59e0b",
red: "#ef4444",
blue: "#3b82f6",
};
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}
`;
}
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("Baue Zeitstrahl …");
(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 = new Date();
const from = new Date(now.getFullYear(), now.getMonth() - MONTHS_BACK, 1).getTime();
const to = new Date(now.getFullYear(), now.getMonth() + MONTHS_AHEAD, 1).getTime();
const span: number = to - from;
function pos(t: number): number {
return Math.min(Math.max((t - from) / span, 0), 1) * 100;
}
const rows = loaded.items
.filter((p) => !p.actualEnd || p.actualEnd === "")
.map((p) => ({ p: p, s: Date.parse(p.planStart), e: Date.parse(p.planEnd) }))
.filter((r) => !Number.isNaN(r.s) && !Number.isNaN(r.e) && r.e >= from && r.s <= to)
.sort((a, b) => a.s - b.s);
if (rows.length === 0) {
widget.el.innerHTML = note("Keine laufenden Projekte im Zeitfenster.");
widget.done();
return;
}
// Monatsraster (Beschriftung nur zum Quartalsanfang, sonst wird es eng).
let grid = "";
let labels = "";
for (let m = -MONTHS_BACK; m <= MONTHS_AHEAD; m++) {
const d = new Date(now.getFullYear(), now.getMonth() + m, 1);
const x: number = pos(d.getTime());
grid += ``;
if (d.getMonth() % 3 === 0) {
labels += `${d.toLocaleDateString(navigator.language, { month: "short", year: "2-digit" })}`;
}
}
const todayX: number = pos(now.getTime());
function barColor(p: WidgetPortfolioProject): string {
if (p.termin === "red") return C.red;
if (p.termin === "yellow") return C.amber;
if (p.termin === "green") return C.green;
return C.blue;
}
const bars: string = rows.slice(0, MAX_ROWS).map((r) => {
const left: number = pos(r.s);
const right: number = pos(r.e);
const width: number = Math.max(right - left, 0.8);
const color: string = barColor(r.p);
const pct: number = Math.min(Math.max(r.p.progress, 0), 100);
return `
${esc(r.p.name)}
${grid}
`;
}).join("");
widget.el.innerHTML = `
${labels}
${bars}
${rows.length} laufende Projekte${rows.length > MAX_ROWS ? ` (${MAX_ROWS} dargestellt)` : ""}
Kräftiger Balkenteil = Fortschritt
Farbe = Terminampel
`;
widget.done();
})();