ProjectRecommended 6 × 8Project managementControlling

Cost curve (S-curve)

Plots accumulated actual bookings over time against the budget and a linear target curve. Shows at a glance whether money is draining faster than planned.

ACTUAL€840.0KJanFebMarAprMayJunBudgetPlanActual

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

What it is good for

'Are we within budget' is a question a remaining balance only answers at the end of the project, and by then it is too late. This tile overlays three lines: the budget as a ceiling, the linear target between start and end date, and the bookings that have actually accumulated. Wherever the actual curve runs above the target, money is draining faster than planned - visible in the first third of the project rather than the last.

What the tile shows you

  • The dashed horizontal is the project budget; the pale straight line is the linear target between start and end date.
  • The bold curve is the cumulative actual bookings, and its end point is today's position.
  • Where the actual curve sits above the target line, more money has drained than the elapsed share of time would justify.

The complete code

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

TypeScript134 lines
Raw file
// Kostenverlauf (S-Kurve)
// Quellen: widget.data.project (Termine, Budget) + widget.query("project.costs")
// project.costs erfordert das Kostenrecht — sonst erscheint ein Hinweistext.

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

const isDark: boolean = widget.data.theme === "dark";
const C = {
  text: isDark ? "#f1f5f9" : "#1e293b",
  sub: "#94a3b8",
  line: isDark ? "#334155" : "#e2e8f0",
  blue: "#3b82f6",
  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 die Kostendaten."
      : code === "unsupported_in_context" ? "In diesem Dashboard nicht verfügbar."
      : code === "timeout" ? "Zeitüberschreitung beim Laden."
      : "Kostendaten 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;
}

const p = widget.data.project;
if (!p) {
  widget.el.innerHTML = note("Nur auf Projektebene verfügbar.");
  widget.done();
  return;
}

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

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

  const totals = res.data.totals;
  const budget: number = totals.budget > 0 ? totals.budget : p.budget;

  const sorted = res.data.bookings
    .map((b) => ({ t: Date.parse(b.date), v: b.amount }))
    .filter((b) => !Number.isNaN(b.t))
    .sort((a, b) => a.t - b.t);

  if (sorted.length === 0) {
    widget.el.innerHTML = note("Noch keine Kostenbuchungen erfasst.");
    widget.done();
    return;
  }

  let acc = 0;
  const curve = sorted.map((b) => { acc += b.v; return { t: b.t, v: acc }; });

  const t0: number = Date.parse(p.planStart) || curve[0].t;
  const t1: number = Date.parse(p.planEnd) || curve[curve.length - 1].t;
  const span: number = Math.max(t1 - t0, 86400000);
  const yMax: number = Math.max(budget, acc) * 1.08 || 1;

  const W = 320, H = 140, PL = 4, PR = 4, PT = 8, PB = 14;
  function px(t: number): string {
    return (PL + Math.min(Math.max((t - t0) / span, 0), 1) * (W - PL - PR)).toFixed(1);
  }
  function py(v: number): string {
    return (H - PB - Math.min(Math.max(v / yMax, 0), 1) * (H - PT - PB)).toFixed(1);
  }

  const pts = [{ t: t0, v: 0 }].concat(curve);
  const path: string = pts.map((pt, i) => `${i === 0 ? "M" : "L"} ${px(pt.t)} ${py(pt.v)}`).join(" ");
  const last = curve[curve.length - 1];
  const area: string = `${path} L ${px(last.t)} ${py(0)} L ${px(t0)} ${py(0)} Z`;

  const now: number = Date.now();
  const used: number = budget > 0 ? Math.round((totals.actual / budget) * 100) : 0;
  const rest: number = budget - totals.actual;
  const restColor: string = rest < 0 ? C.red : used >= 90 ? C.amber : C.green;

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

  function legend(color: string, label: string): string {
    return `<span style="white-space:nowrap"><span style="display:inline-block;width:12px;height:2px;background:${color};vertical-align:middle;margin-right:4px"></span>${label}</span>`;
  }

  widget.el.innerHTML = `
    <div style="padding:10px 12px;color:${C.text}">
      <div style="display:flex;gap:10px;margin-bottom:8px">
        ${kpi("Budget", short(budget), C.text)}
        ${kpi("Ist", short(totals.actual), C.blue)}
        ${kpi("Verbraucht", used + " %", used > 100 ? C.red : C.text)}
        ${kpi("Rest", short(rest), restColor)}
      </div>
      <svg viewBox="0 0 ${W} ${H}" style="width:100%;height:auto;display:block">
        <line x1="${PL}" y1="${py(budget)}" x2="${W - PR}" y2="${py(budget)}" stroke="${C.red}" stroke-width="1" stroke-dasharray="4 3" />
        <line x1="${px(t0)}" y1="${py(0)}" x2="${px(t1)}" y2="${py(budget)}" stroke="${C.sub}" stroke-width="1" stroke-dasharray="2 3" />
        ${now > t0 && now < t1 ? `<line x1="${px(now)}" y1="${PT}" x2="${px(now)}" y2="${H - PB}" stroke="${C.amber}" stroke-width="1" />` : ""}
        <path d="${area}" fill="${C.blue}" fill-opacity="0.15" />
        <path d="${path}" fill="none" stroke="${C.blue}" stroke-width="2" stroke-linejoin="round" />
        <line x1="${PL}" y1="${H - PB}" x2="${W - PR}" y2="${H - PB}" stroke="${C.line}" stroke-width="1" />
      </svg>
      <div style="display:flex;gap:10px;flex-wrap:wrap;font-size:10px;color:${C.sub};margin-top:6px">
        ${legend(C.blue, "Ist kumuliert")}
        ${legend(C.sub, "linearer Sollverlauf")}
        ${legend(C.red, "Budget")}
      </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.