WORKSPACE.PM
PortfolioEmpfohlen 12 × 12GeschäftsführungPMO

Portfolio-Zeitstrahl

Legt alle laufenden Projekte als schmale Balken auf eine gemeinsame Zeitachse, eingefärbt nach Terminampel, mit Heute-Linie und Monatsraster.

Zum Code
PORTFOLIO-ZEITSTRAHL18HeuteJanFebMärAprMaiJun

Nachbau im Stil dieser Website. Die echte Kachel übernimmt Schriften und Farbschema Ihres Mandanten.

Wofür das gut ist

Ein Gantt gibt es je Projekt, aber keinen für das Portfolio - und damit fehlt genau das Bild, das im Portfolio-Board gebraucht wird: wer läuft wann, wo überlappt es, was landet gleichzeitig. Diese Kachel legt alle laufenden Projekte als schmale Balken auf eine gemeinsame Achse, eingefärbt nach ihrer Terminampel, mit Heute-Linie und Monatsraster. Balken, die über das Zeitfenster hinausreichen, werden am Rand abgeschnitten und markiert.

Was Sie in der Kachel sehen

  • Je Zeile ein Projekt, der Balken ist seine geplante Laufzeit auf der gemeinsamen Zeitachse.
  • Die Farbe des Balkens ist die Terminampel des Projekts.
  • Die senkrechte Linie ist heute; das Raster dahinter sind die Monatsgrenzen.

Der vollständige Code

Eigenständig und ohne externe Bibliothek. In den Editor einfügen, speichern, fertig.

TypeScript134 Zeilen
Rohdatei
// 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 === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : "&quot;");
}

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>`;
}

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 += `<div style="position:absolute;left:${x.toFixed(2)}%;top:0;bottom:0;width:1px;background:${C.line}"></div>`;
    if (d.getMonth() % 3 === 0) {
      labels += `<span style="position:absolute;left:${x.toFixed(2)}%;transform:translateX(-50%);font-size:9px;color:${C.sub};white-space:nowrap">${d.toLocaleDateString(navigator.language, { month: "short", year: "2-digit" })}</span>`;
    }
  }
  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 `<div style="display:flex;align-items:center;gap:6px;height:20px">
      <span style="width:104px;flex-shrink:0;font-size:10px;color:${C.text};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(r.p.name)}</span>
      <span style="flex:1;position:relative;height:12px;background:${C.track};border-radius:3px;overflow:hidden">
        ${grid}
        <span style="position:absolute;left:${left.toFixed(2)}%;width:${width.toFixed(2)}%;top:2px;height:8px;background:${color};opacity:.35;border-radius:2px"></span>
        <span style="position:absolute;left:${left.toFixed(2)}%;width:${(width * pct / 100).toFixed(2)}%;top:2px;height:8px;background:${color};border-radius:2px"></span>
        <span style="position:absolute;left:${todayX.toFixed(2)}%;top:0;bottom:0;width:1px;background:${C.amber}"></span>
      </span>
    </div>`;
  }).join("");

  widget.el.innerHTML = `
    <div style="padding:8px 12px;color:${C.text}">
      <div style="display:flex;gap:6px;margin-bottom:2px">
        <span style="width:104px;flex-shrink:0"></span>
        <span style="flex:1;position:relative;height:12px">${labels}</span>
      </div>
      ${bars}
      <div style="display:flex;gap:10px;flex-wrap:wrap;font-size:10px;color:${C.sub};margin-top:6px;padding-top:6px;border-top:1px solid ${C.line}">
        <span>${rows.length} laufende Projekte${rows.length > MAX_ROWS ? ` (${MAX_ROWS} dargestellt)` : ""}</span>
        <span>Kräftiger Balkenteil = Fortschritt</span>
        <span>Farbe = Terminampel</span>
      </div>
    </div>`;
  widget.done();
})();

Anpassen

KonstanteVorbelegungBedeutung
MONTHS_BACK3Wie viele Monate der Zeitstrahl in die Vergangenheit reicht.
MONTHS_AHEAD12Wie viele Monate der Zeitstrahl in die Zukunft reicht.
MAX_ROWS18Wie viele Projektbalken die Kachel höchstens zeichnet.
MAX_PAGES3Wie viele Seiten die Abfrage höchstens lädt. Höher setzen, wenn das Portfolio mehr Projekte hat, als die Auswertung erfasst.
  1. 1Widget anlegen
  2. 2Code einfügen
  3. 3Größe setzen und speichern

Bauen Sie Ihre eigene Kachel

Testen Sie WORKSPACE.PM dreißig Tage lang mit allen Funktionen. Die Vorlagen dieser Seite funktionieren im Testmandanten genauso wie in der Produktivumgebung.