PortfolioRecommended 5 × 11Executive management

Benefit-urgency matrix

Distributes projects across four quadrants of benefit rating and urgency, with bubble size representing the budget.

BENEFIT AND URGENCYUrgency →Benefit →Bubble size corresponds to budget

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

What it is good for

Whether the money sits in the projects with the greatest benefit cannot be read off a table. This tile shows it in one picture: each project a bubble, urgency on the horizontal, benefit on the vertical, size the budget. Both scales run from one to ten, with the dividing lines at 5.5. A large bubble in the bottom right is the finding - a lot of money in something urgent with no demonstrated benefit.

What the tile shows you

  • Urgency runs horizontally, benefit vertically, both on a scale from one to ten.
  • Each bubble is a project, and its area corresponds to the budget.
  • The top right holds what combines benefit and urgency; the bottom right, what is merely loud.

The complete code

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

TypeScript136 lines
Raw file
// Nutzen-Dringlichkeits-Matrix
// Quelle: widget.query("portfolio.projects")
// Skala von Dringlichkeit und Nutzen ist 1–10; die Trennlinien liegen bei 5,5.

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",
  quad: isDark ? "#1e293b" : "#f8fafc",
  green: "#22c55e",
  amber: "#f59e0b",
  red: "#ef4444",
  blue: "#3b82f6",
};

const MAX_PAGES = 3;

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

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("Lade Bewertungen …");

(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 rated = loaded.items.filter((p) => p.urgency > 0 && p.benefitRating > 0);
  if (rated.length === 0) {
    widget.el.innerHTML = note("Für keines der Projekte sind Dringlichkeit und Nutzen bewertet.");
    widget.done();
    return;
  }

  const W = 320, H = 210, PAD = 22;
  const maxBudget: number = Math.max.apply(null, rated.map((p) => p.budget)) || 1;

  function sx(v: number): number { return PAD + ((v - 1) / 9) * (W - PAD - 10); }
  function sy(v: number): number { return H - PAD - ((v - 1) / 9) * (H - PAD - 10); }
  function radius(budget: number): number { return 3 + Math.sqrt(Math.max(budget, 0) / maxBudget) * 9; }

  const midX: number = sx(5.5);
  const midY: number = sy(5.5);

  const dots: string = rated.map((p) => {
    const color: string = p.benefitRating >= 5.5
      ? (p.urgency >= 5.5 ? C.green : C.blue)
      : (p.urgency >= 5.5 ? C.amber : C.sub);
    return `<circle cx="${sx(p.urgency).toFixed(1)}" cy="${sy(p.benefitRating).toFixed(1)}" r="${radius(p.budget).toFixed(1)}" fill="${color}" fill-opacity="0.45" stroke="${color}" stroke-width="1" />`;
  }).join("");

  function quadrant(label: string, count: number, budget: number, color: string): string {
    return `<div style="flex:1;min-width:0">
      <div style="font-size:13px;font-weight:700;color:${color}">${count}</div>
      <div style="font-size:9px;color:${C.sub};line-height:1.25">${label}<br>${short(budget)}</div>
    </div>`;
  }

  function bucket(hiU: boolean, hiB: boolean) {
    const set = rated.filter((p) => (p.urgency >= 5.5) === hiU && (p.benefitRating >= 5.5) === hiB);
    return { count: set.length, budget: set.reduce((s, p) => s + p.budget, 0) };
  }

  const qA = bucket(true, true);
  const qB = bucket(false, true);
  const qC = bucket(true, false);
  const qD = bucket(false, false);

  widget.el.innerHTML = `
    <div style="padding:8px 12px;color:${C.text}">
      <svg viewBox="0 0 ${W} ${H}" style="width:100%;height:auto;display:block">
        <rect x="${midX}" y="10" width="${W - 10 - midX}" height="${midY - 10}" fill="${C.green}" fill-opacity="0.05" />
        <line x1="${midX}" y1="10" x2="${midX}" y2="${H - PAD}" stroke="${C.line}" stroke-width="1" stroke-dasharray="3 3" />
        <line x1="${PAD}" y1="${midY}" x2="${W - 10}" y2="${midY}" stroke="${C.line}" stroke-width="1" stroke-dasharray="3 3" />
        <line x1="${PAD}" y1="${H - PAD}" x2="${W - 10}" y2="${H - PAD}" stroke="${C.line}" stroke-width="1" />
        <line x1="${PAD}" y1="10" x2="${PAD}" y2="${H - PAD}" stroke="${C.line}" stroke-width="1" />
        ${dots}
        <text x="${W - 10}" y="${H - 6}" text-anchor="end" font-size="9" fill="${C.sub}">Dringlichkeit →</text>
        <text x="6" y="16" font-size="9" fill="${C.sub}">Nutzen ↑</text>
      </svg>
      <div style="display:flex;gap:8px;margin-top:6px;padding-top:6px;border-top:1px solid ${C.line}">
        ${quadrant("Jetzt umsetzen", qA.count, qA.budget, C.green)}
        ${quadrant("Einplanen", qB.count, qB.budget, C.blue)}
        ${quadrant("Hinterfragen", qC.count, qC.budget, C.amber)}
        ${quadrant("Zurückstellen", qD.count, qD.budget, C.sub)}
      </div>
      <div style="font-size:10px;color:${C.sub};margin-top:5px">
        ${rated.length} bewertete Projekte, Blasengröße = Budget.
        ${loaded.items.length - rated.length > 0 ? ` ${loaded.items.length - rated.length} ohne Bewertung ausgelassen.` : ""}
      </div>
    </div>`;
  widget.done();
})();

Adjusting it

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