ProjectRecommended 6 × 10Project managementProject team

Work breakdown progress tree

Shows the work breakdown structure as an indented list with WBS code, progress bar and a counter of overdue tasks per element.

WORK BREAKDOWN58%178%1.1100%1.2342%255%2.161%2.1.1512%

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

What it is good for

An overall progress of 58 percent does not say which branch is slowing things down. This tile renders the work breakdown structure as an indented list - WBS code, progress bar, and the number of overdue tasks for each element. The order follows the WBS code in natural sorting, so 1.2 comes before 1.10. Within seconds it is clear where the backlog actually sits.

What the tile shows you

  • The indentation reflects the level in the structure, with the WBS code in front.
  • The bar is the element's progress, including its children.
  • The red counter names the overdue tasks on that specific element.

The complete code

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

TypeScript114 lines
Raw file
// Strukturplan-Fortschrittsbaum
// Quelle: widget.query("project.containers")
// Reihenfolge über den PSP-Code (natürliche Sortierung: 1.2 vor 1.10).

const isDark: boolean = widget.data.theme === "dark";
const C = {
  text: isDark ? "#f1f5f9" : "#1e293b",
  sub: "#94a3b8",
  line: isDark ? "#334155" : "#e2e8f0",
  green: "#22c55e",
  amber: "#f59e0b",
  red: "#ef4444",
  blue: "#3b82f6",
};

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

function queryError(code: string): string {
  return note(
    code === "forbidden" ? "Keine Berechtigung für den Strukturplan."
      : code === "unsupported_in_context" ? "Nur auf Projektebene verfügbar."
      : "Strukturplan konnte nicht geladen werden.",
  );
}

function pspKey(code: string): number[] {
  return String(code).split(".").map((part) => {
    const n: number = parseInt(part, 10);
    return Number.isNaN(n) ? 0 : n;
  });
}

function pspCompare(a: string, b: string): number {
  const ka = pspKey(a), kb = pspKey(b);
  const len: number = Math.max(ka.length, kb.length);
  for (let i = 0; i < len; i++) {
    const d: number = (ka[i] || 0) - (kb[i] || 0);
    if (d !== 0) return d;
  }
  return String(a).localeCompare(String(b));
}

widget.el.innerHTML = note("Lade Strukturplan …");

(async () => {
  const res = await widget.query("project.containers", { limit: 300 });
  if (!res.ok) {
    widget.el.innerHTML = queryError(res.error.code);
    widget.done();
    return;
  }

  const items = res.data.items.filter((c) => c.type === "container");
  if (items.length === 0) {
    widget.el.innerHTML = note("Kein Strukturplan hinterlegt.");
    widget.done();
    return;
  }

  const byId = new Map<string, WidgetContainer>();
  items.forEach((c) => byId.set(c.id, c));

  function depth(c: WidgetContainer): number {
    let d = 0;
    let cur: WidgetContainer | undefined = c;
    // Schutz gegen zyklische Daten: maximal 8 Ebenen hochlaufen.
    while (cur && cur.parentId && d < 8) {
      cur = byId.get(cur.parentId);
      if (!cur) break;
      d++;
    }
    return d;
  }

  const sorted = items.slice().sort((a, b) => pspCompare(a.pspCode, b.pspCode));

  const rows: string = sorted.map((c) => {
    const d: number = depth(c);
    const pct: number = Math.min(Math.max(c.progress, 0), 100);
    const bar: string = c.overdueTaskCount > 0 ? C.red : pct >= 100 ? C.green : C.blue;
    const weight: string = d === 0 ? "600" : "400";
    return `<div style="display:flex;align-items:center;gap:6px;padding:3px 0;font-size:11px;padding-left:${d * 12}px">
      <span style="color:${C.sub};font-variant-numeric:tabular-nums;white-space:nowrap;min-width:34px">${esc(c.pspCode)}</span>
      <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:${C.text};font-weight:${weight}">${esc(c.name)}</span>
      ${c.overdueTaskCount > 0 ? `<span style="flex-shrink:0;background:${C.red}22;color:${C.red};font-size:9px;font-weight:700;padding:1px 5px;border-radius:8px">${c.overdueTaskCount} überfällig</span>` : ""}
      <span style="width:64px;flex-shrink:0;height:5px;background:${C.line};border-radius:3px;overflow:hidden">
        <span style="display:block;height:100%;width:${pct}%;background:${bar}"></span>
      </span>
      <span style="width:30px;flex-shrink:0;text-align:right;color:${C.sub};font-variant-numeric:tabular-nums">${pct}%</span>
    </div>`;
  }).join("");

  const overdueTotal: number = items.reduce((s, c) => s + c.overdueTaskCount, 0);
  const openTotal: number = items.reduce((s, c) => s + c.openTaskCount, 0);

  widget.el.innerHTML = `
    <div style="padding:8px 12px;color:${C.text}">
      <div style="display:flex;gap:12px;font-size:10px;color:${C.sub};padding-bottom:6px;margin-bottom:4px;border-bottom:1px solid ${C.line}">
        <span>${items.length} Elemente</span>
        <span>${openTotal} offene Aufgaben</span>
        ${overdueTotal > 0 ? `<span style="color:${C.red};font-weight:600">${overdueTotal} überfällig</span>` : ""}
        ${res.data.truncated ? `<span style="margin-left:auto">Auszug</span>` : ""}
      </div>
      ${rows}
    </div>`;
  widget.done();
})();
  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.