PortfolioRecommended 12 × 12Executive managementPMO

Portfolio timeline

Places every running project as a slim bar on one shared time axis, colored by schedule status, with a today line and a monthly grid.

PORTFOLIO TIMELINE18TodayJanFebMarAprMayJun

A rebuild in this website's style. The real tile picks up your tenant's fonts and color scheme.

What it is good for

There is a Gantt chart per project but none for the portfolio - which means the very picture a portfolio board needs is missing: who runs when, where does it overlap, what lands at the same time. This tile places every running project as a slim bar on a shared axis, colored by its schedule status, with a today line and a monthly grid. Bars that reach beyond the window are clipped at the edge and marked.

What the tile shows you

  • One row per project; the bar is its planned duration on the shared time axis.
  • The color of the bar is the project's schedule status.
  • The vertical line is today; the grid behind it marks the month boundaries.

The complete code

Self-contained, with no external library. Paste it into the editor, save, done.

TypeScript134 lines
Raw file
// 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();
})();

Adjusting it

ConstantDefaultMeaning
MONTHS_BACK3How many months the timeline reaches into the past.
MONTHS_AHEAD12How many months the timeline reaches into the future.
MAX_ROWS18How many project bars the tile draws at most.
MAX_PAGES3How many pages the query loads at most. Raise it if the portfolio holds more projects than the analysis covers.
  1. 1Create the widget
  2. 2Paste the code
  3. 3Set the size and save

Build your own tile

Try WORKSPACE.PM for thirty days with every feature. The templates on this page work in a trial tenant exactly as they do in production.