ProjectRecommended 6 × 7ControllingPMOExecutive management

Earned value indicators

Derives the two classics SPI and CPI from progress, planned and actual costs of the work breakdown structure, and shows them as gauges with a plain-language reading.

EARNED VALUESPICPI0.921.04FORECAST€1.2M

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

What it is good for

'65 percent done, 80 percent of the budget spent' is two numbers, not yet a statement. Earned value analysis turns them into two dependable indicators: the schedule index SPI and the cost index CPI, both relative to the earned value of the work actually delivered. The calculation uses only work packages without children, so no parent node counts its children a second time. From the CPI, the tile also derives a forecast of the final cost.

What the tile shows you

  • On the left the schedule index SPI, on the right the cost index CPI, each with a needle and a color rather than a bare number.
  • The mark on each dial is the value 1.0 - the line between 'on plan' and 'off plan'.
  • Below, the forecast of the final cost, extrapolated from the cost index so far.

The complete code

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

TypeScript144 lines
Raw file
// Leistungswert-Ampel (Earned Value)
// Quelle: widget.query("project.containers")
// Rechenbasis sind ausschließlich Arbeitspakete ohne Unterelemente —
// Elternknoten würden ihre Kinder ein zweites Mal zählen.

const CURRENCY = "EUR"; // an die Mandantenwährung anpassen

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 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 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."
      : "Daten konnten nicht geladen werden.",
  );
}

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

widget.el.innerHTML = note("Berechne Leistungswerte …");

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

  const work = res.data.items.filter((c) => c.type === "container");
  const leaves = work.filter((c) => c.childrenCount === 0);
  const basis = leaves.length > 0 ? leaves : work;

  if (basis.length === 0) {
    widget.el.innerHTML = note("Kein Strukturplan hinterlegt.");
    widget.done();
    return;
  }

  const now: number = Date.now();
  let bac = 0, ev = 0, pv = 0, ac = 0;
  basis.forEach((c) => {
    const b: number = c.budget != null ? c.budget : (c.costPlan != null ? c.costPlan : 0);
    bac += b;
    ev += b * (c.progress / 100);
    ac += c.costActual != null ? c.costActual : 0;
    const s: number = Date.parse(c.startDate);
    const e: number = Date.parse(c.endDate);
    let frac = 1;
    if (!Number.isNaN(s) && !Number.isNaN(e) && e > s) {
      frac = Math.min(Math.max((now - s) / (e - s), 0), 1);
    } else if (!Number.isNaN(s) && now < s) {
      frac = 0;
    }
    pv += b * frac;
  });

  const spi: number | null = pv > 0 ? ev / pv : null;
  const cpi: number | null = ac > 0 ? ev / ac : null;
  const eac: number = cpi !== null && cpi > 0 ? bac / cpi : bac;

  function tone(v: number | null): string {
    if (v === null) return C.sub;
    if (v >= 0.95) return C.green;
    if (v >= 0.85) return C.amber;
    return C.red;
  }

  function fmt(v: number | null): string {
    return v === null ? "—" : v.toLocaleString(navigator.language, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
  }

  function gauge(label: string, v: number | null, hint: string): string {
    const color: string = tone(v);
    const pct: number = v === null ? 0 : (Math.min(Math.max(v, 0), 1.5) / 1.5) * 100;
    return `<div style="flex:1;background:${C.card};border-radius:8px;padding:10px 12px;min-width:0">
      <div style="font-size:10px;color:${C.sub};text-transform:uppercase;letter-spacing:.04em">${label}</div>
      <div style="font-size:26px;font-weight:700;line-height:1.15;color:${color}">${fmt(v)}</div>
      <div style="height:4px;background:${C.line};border-radius:2px;overflow:hidden;margin:6px 0 5px">
        <div style="height:100%;width:${pct.toFixed(0)}%;background:${color}"></div>
      </div>
      <div style="font-size:10px;color:${C.sub};line-height:1.3">${hint}</div>
    </div>`;
  }

  const spiText: string = spi === null ? "Keine Termindaten"
    : spi >= 1 ? Math.round((spi - 1) * 100) + " % vor Plan"
    : Math.round((1 - spi) * 100) + " % hinter Plan";

  const cpiText: string = cpi === null ? "Noch keine Ist-Kosten"
    : cpi >= 1 ? Math.round((cpi - 1) * 100) + " % günstiger als geplant"
    : Math.round((1 / cpi - 1) * 100) + " % teurer als geplant";

  const abw: number = eac - bac;

  function cell(label: string, value: string, color: string): string {
    return `<div style="flex:1;min-width:0">
      <div style="font-size:12px;font-weight:600;color:${color};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${value}</div>
      <div style="font-size:10px;color:${C.sub}">${label}</div>
    </div>`;
  }

  widget.el.innerHTML = `
    <div style="padding:10px 12px;color:${C.text}">
      <div style="display:flex;gap:8px;margin-bottom:10px">
        ${gauge("Termin (SPI)", spi, spiText)}
        ${gauge("Kosten (CPI)", cpi, cpiText)}
      </div>
      <div style="display:flex;gap:10px;padding-top:8px;border-top:1px solid ${C.line}">
        ${cell("Gesamtbudget", short(bac), C.text)}
        ${cell("Leistungswert", short(ev), C.text)}
        ${cell("Ist-Kosten", short(ac), C.text)}
        ${cell("Prognose Ende", short(eac), abw > 0 ? C.red : C.green)}
      </div>
      <div style="font-size:10px;color:${C.sub};margin-top:8px;line-height:1.35">
        Basis: ${basis.length} Arbeitspakete. Der Sollwert wird zeitanteilig aus
        Start- und Endterminen gebildet.
      </div>
    </div>`;
  widget.done();
})();

Adjusting it

ConstantDefaultMeaning
CURRENCY"EUR"Currency for monetary amounts. Set it to the tenant's currency.
  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.