diff --git a/CHANGELOG.md b/CHANGELOG.md
index ed92199..beb964d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,10 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Adopt itk-dev/entity-bundle: ULID identifiers + shared blamable/timestampable
* [PR-10](https://github.com/itk-dev/itk-project-database/pull/10)
Correct docker compose setup.
+* [PR-4](https://github.com/itk-dev/itk-project-database/pull/4)
+ Add Chart.js graphs to the dashboard
* [PR-3](https://github.com/itk-dev/itk-project-database/pull/3)
Add real-time activity feed with autosave
-* [PR-10](https://github.com/itk-dev/itk-project-database/pull/10)
- Correct docker compose setup.
* [PR-2](https://github.com/itk-dev/itk-project-database/pull/2)
Add Symfony UX Turbo and stimulus.
* [PR-1](https://github.com/itk-dev/itk-project-database/pull/1)
diff --git a/assets/controllers/dashboard_viz_controller.js b/assets/controllers/dashboard_viz_controller.js
new file mode 100644
index 0000000..4313e75
--- /dev/null
+++ b/assets/controllers/dashboard_viz_controller.js
@@ -0,0 +1,704 @@
+import { Controller } from "@hotwired/stimulus";
+import Chart from "chart.js/auto";
+
+/*
+ * Renders the dashboard visualisations (heatmap, collaboration panel and the
+ * Chart.js charts) from a single JSON data island. Charts are built lazily the
+ * first time they scroll into view, so their entrance animation plays when seen
+ * rather than off-screen at page load. The live broadcast replaces the data
+ * island; a MutationObserver feeds the new numbers to the existing charts so
+ * they animate the delta instead of re-mounting from zero on every save.
+ */
+// Palette from the itk-workspace prototype-ds v1 tokens (teal-led brand), with
+// variations where more distinct hues were needed.
+const DEPT_COLORS = [
+ "#007ba6",
+ "#89bd23",
+ "#ee0043",
+ "#f5b800",
+ "#00a5cd",
+ "#73bc99",
+];
+const STATUS_COLORS = [
+ "#adb5bd",
+ "#00a5cd",
+ "#008d3d",
+ "#f5b800",
+ "#005876",
+ "#e44930",
+];
+const FUNDING_COLORS = ["#007ba6", "#008d3d", "#f5b800", "#ee0043", "#adb5bd"];
+const TEAL = "#007ba6";
+// borderWidth must have a numeric base: it is not in the bar animation group,
+// so Chart.js animates it from its current value on hover — undefined would
+// crash the interpolator ("this._fn is not a function").
+const BAR_HOVER = {
+ borderWidth: 0,
+ hoverBorderColor: "rgba(15, 19, 21, .35)",
+ hoverBorderWidth: 2,
+};
+const MONTHS = [
+ "jan",
+ "feb",
+ "mar",
+ "apr",
+ "maj",
+ "jun",
+ "jul",
+ "aug",
+ "sep",
+ "okt",
+ "nov",
+ "dec",
+];
+
+export default class extends Controller {
+ static targets = [
+ "source",
+ "heatmap",
+ "collab",
+ "statusByDept",
+ "statusDist",
+ "funding",
+ "budget",
+ "reach",
+ "timeline",
+ ];
+
+ connect() {
+ this.reduce = window.matchMedia(
+ "(prefers-reduced-motion: reduce)",
+ ).matches;
+ Chart.defaults.font.family = getComputedStyle(document.body).fontFamily;
+ Chart.defaults.font.size = 12;
+ Chart.defaults.color = "#52606e";
+ Chart.defaults.borderColor = "#eef1f5";
+ Chart.defaults.plugins.legend.labels.usePointStyle = true;
+ Chart.defaults.plugins.legend.labels.boxWidth = 8;
+ Chart.defaults.plugins.legend.labels.padding = 12;
+ Chart.defaults.plugins.tooltip.cornerRadius = 8;
+ Chart.defaults.plugins.tooltip.padding = 10;
+ // Mutate the existing animation defaults rather than replacing the object:
+ // replacing it drops internal keys Chart.js needs to resolve the tooltip/
+ // hover interpolator, which throws "this._fn is not a function" on hover.
+ Chart.defaults.animation.duration = this.reduce ? 0 : 800;
+ Chart.defaults.animation.easing = "easeOutQuart";
+ // Pointer cursor whenever the mouse is over a hoverable data element.
+ Chart.defaults.onHover = (event, elements) => {
+ const target = event.native && event.native.target;
+ if (target) {
+ target.style.cursor = elements.length ? "pointer" : "default";
+ }
+ };
+
+ this.charts = {};
+ this.viz = this.read();
+ if (!this.viz) {
+ return;
+ }
+ this.collabSeen = false;
+ this.buildHeatmap();
+ this.renderCollab();
+ this.lazyCharts();
+ // Heatmap and collaboration render eagerly, so hold their entrance
+ // animation until they actually scroll into view (otherwise it plays
+ // off-screen at load and is never seen).
+ this.revealOnView(this.heatmapTarget, () =>
+ this.heatmapTarget.classList.remove("heat--paused"),
+ );
+ this.revealOnView(this.collabTarget, () => this.revealCollab());
+
+ this.observer = new MutationObserver(() => this.refresh());
+ this.observer.observe(this.sourceTarget, {
+ childList: true,
+ characterData: true,
+ subtree: true,
+ });
+ }
+
+ disconnect() {
+ this.observer && this.observer.disconnect();
+ this.chartObserver && this.chartObserver.disconnect();
+ (this.revealObservers || []).forEach((o) => o.disconnect());
+ Object.values(this.charts).forEach((c) => c.destroy());
+ this.charts = {};
+ }
+
+ // Run fn the first time el scrolls into view (immediately if reduced motion
+ // or no IntersectionObserver). Used to defer eager entrance animations.
+ revealOnView(el, fn) {
+ if (this.reduce || !("IntersectionObserver" in window)) {
+ fn();
+ return;
+ }
+ const obs = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ fn();
+ obs.unobserve(entry.target);
+ }
+ });
+ },
+ { rootMargin: "0px 0px -12% 0px", threshold: 0.15 },
+ );
+ obs.observe(el);
+ (this.revealObservers = this.revealObservers || []).push(obs);
+ }
+
+ read() {
+ try {
+ return JSON.parse(this.sourceTarget.textContent);
+ } catch (e) {
+ return null;
+ }
+ }
+
+ refresh() {
+ const next = this.read();
+ if (!next) {
+ return;
+ }
+ this.viz = next;
+ this.updateHeatmap();
+ this.renderCollab();
+ this.updateCharts();
+ }
+
+ // ---- Lazy chart building (animate on scroll into view) -------------
+ lazyCharts() {
+ const specs = [
+ [
+ "statusByDept",
+ this.statusByDeptTarget,
+ () => this.buildStatusByDept(),
+ ],
+ ["statusDist", this.statusDistTarget, () => this.buildStatusDist()],
+ ["funding", this.fundingTarget, () => this.buildFunding()],
+ ["budget", this.budgetTarget, () => this.buildBudget()],
+ ["timeline", this.timelineTarget, () => this.buildTimeline()],
+ ["reach", this.reachTarget, () => this.buildReach()],
+ ];
+
+ // A single bad chart config must not take down the whole dashboard; log
+ // which one failed so it can be fixed without blanking the rest.
+ const build = (spec) => {
+ try {
+ spec[2]();
+ } catch (e) {
+ console.error(
+ `dashboard-viz: chart "${spec[0]}" failed to render`,
+ e,
+ );
+ }
+ };
+
+ if (this.reduce || !("IntersectionObserver" in window)) {
+ specs.forEach(build);
+ return;
+ }
+
+ this.chartObserver = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (!entry.isIntersecting) {
+ return;
+ }
+ const spec = specs.find((s) => s[1] === entry.target);
+ if (spec) {
+ build(spec);
+ this.chartObserver.unobserve(entry.target);
+ }
+ });
+ },
+ { rootMargin: "0px 0px -10% 0px", threshold: 0.18 },
+ );
+
+ specs.forEach((s) => this.chartObserver.observe(s[1]));
+ }
+
+ // ---- Heatmap -------------------------------------------------------
+ buildHeatmap() {
+ const d = this.viz;
+ const el = this.heatmapTarget;
+ el.style.gridTemplateColumns = `minmax(120px, 168px) repeat(${d.categories.length}, minmax(40px, 1fr))`;
+ if (!this.reduce) {
+ el.classList.add("heat--paused");
+ }
+ el.innerHTML = "";
+ el.appendChild(document.createElement("div"));
+
+ this.heatColHeads = d.categories.map((c) => {
+ const head = document.createElement("div");
+ head.className = "heat__collabel";
+ head.innerHTML = `★ samarbejde ${this.esc(c.label)} `;
+ el.appendChild(head);
+ return head;
+ });
+
+ this.heatCells = d.departments.map((dep) => {
+ const label = document.createElement("div");
+ label.className = "heat__rowlabel";
+ label.textContent = dep.label;
+ el.appendChild(label);
+ return d.categories.map(() => {
+ const cell = document.createElement("div");
+ cell.className = "heat__cell";
+ el.appendChild(cell);
+ return cell;
+ });
+ });
+
+ this.updateHeatmap(true);
+ }
+
+ updateHeatmap(initial = false) {
+ const d = this.viz;
+ let max = 1;
+ d.heatmap.forEach((row) =>
+ row.forEach((v) => {
+ if (v > max) max = v;
+ }),
+ );
+
+ d.heatmap.forEach((row, di) =>
+ row.forEach((v, ci) => {
+ const cell = this.heatCells[di][ci];
+ cell.textContent = v === 0 ? "·" : v;
+ cell.title = `${d.departments[di].label} · ${d.categories[ci].label}: ${v}`;
+ const col = this.colorFor(v, max);
+ if (col) {
+ cell.classList.remove("is-zero");
+ cell.style.background = col.bg;
+ cell.style.color = col.fg;
+ } else {
+ cell.classList.add("is-zero");
+ cell.style.background = "";
+ cell.style.color = "";
+ }
+ if (initial && !this.reduce) {
+ cell.style.animationDelay =
+ (di * d.categories.length + ci) * 14 + "ms";
+ }
+ }),
+ );
+
+ d.categories.forEach((c, ci) => {
+ const depts = d.heatmap.reduce(
+ (n, row) => n + (row[ci] > 0 ? 1 : 0),
+ 0,
+ );
+ const head = this.heatColHeads[ci];
+ head.classList.toggle("is-synergy", depts >= 3);
+ head.querySelector(".heat__synbadge").hidden = depts < 3;
+ });
+ }
+
+ colorFor(count, max) {
+ if (count <= 0) {
+ return null;
+ }
+ const l = 84 - (count / max) * 52;
+ return { bg: `hsl(193 78% ${l}%)`, fg: l < 56 ? "#fff" : "#202423" };
+ }
+
+ // ---- Collaboration -------------------------------------------------
+ renderCollab() {
+ const el = this.collabTarget;
+ el.innerHTML = "";
+ if (!this.viz.collaboration.length) {
+ el.innerHTML =
+ '
Ingen tværgående temaer endnu — kategorisér initiativer for at finde sammenfald.
';
+ return;
+ }
+ // Hold the entrance paused until the panel scrolls into view; once seen,
+ // (re)renders from live updates animate immediately.
+ const pause = !this.reduce && !this.collabSeen;
+ this.viz.collaboration.forEach((o) => {
+ const chips = o.inits
+ .map(
+ (i) =>
+ `${this.esc(i.title)} · ${this.esc(i.deptLabel)} `,
+ )
+ .join("");
+ const div = document.createElement("div");
+ div.className =
+ "opp" +
+ (this.reduce ? "" : " opp--in") +
+ (pause ? " opp--paused" : "");
+ div.innerHTML = `
+ ${this.esc(o.theme)}
+ ${o.rank === "high" ? "Højt potentiale" : "Muligt"}
+
+ ${o.departmentCount} afdelinger · ${o.initiativeCount} initiativer
+ ${chips}
+
`;
+ el.appendChild(div);
+ });
+ if (!this.reduce && !pause) {
+ this.fillMeters();
+ }
+ }
+
+ revealCollab() {
+ this.collabSeen = true;
+ this.collabTarget
+ .querySelectorAll(".opp--paused")
+ .forEach((o) => o.classList.remove("opp--paused"));
+ this.fillMeters();
+ }
+
+ fillMeters() {
+ if (this.reduce) {
+ return;
+ }
+ requestAnimationFrame(() =>
+ requestAnimationFrame(() => {
+ this.collabTarget
+ .querySelectorAll(".meter > i")
+ .forEach((i) => {
+ i.style.width = i.dataset.w + "%";
+ });
+ }),
+ );
+ }
+
+ deptIndex(key) {
+ return this.viz.departments.findIndex((d) => d.key === key);
+ }
+
+ deptColor(key) {
+ const i = this.deptIndex(key);
+ return i >= 0 ? DEPT_COLORS[i % DEPT_COLORS.length] : "#64748b";
+ }
+
+ esc(s) {
+ const d = document.createElement("div");
+ d.textContent = s;
+ return d.innerHTML;
+ }
+
+ // ---- Charts --------------------------------------------------------
+ buildStatusByDept() {
+ const d = this.viz;
+ this.charts.statusByDept = new Chart(this.statusByDeptTarget, {
+ type: "bar",
+ data: {
+ labels: d.departments.map((x) => x.label),
+ datasets: d.statuses.map((s, i) => ({
+ label: s.label,
+ data: d.statusByDept[i],
+ backgroundColor: STATUS_COLORS[i % STATUS_COLORS.length],
+ borderRadius: 4,
+ borderSkipped: false,
+ ...BAR_HOVER,
+ })),
+ },
+ options: {
+ indexAxis: "y",
+ responsive: true,
+ maintainAspectRatio: false,
+ interaction: { mode: "index", intersect: false, axis: "y" },
+ scales: {
+ x: {
+ stacked: true,
+ beginAtZero: true,
+ ticks: { precision: 0 },
+ grid: { color: "#f1f4f8" },
+ },
+ y: { stacked: true, grid: { display: false } },
+ },
+ plugins: { legend: { position: "bottom" } },
+ },
+ });
+ }
+
+ buildStatusDist() {
+ const d = this.viz;
+ this.charts.statusDist = new Chart(this.statusDistTarget, {
+ type: "polarArea",
+ data: {
+ labels: d.statuses.map((s) => s.label),
+ datasets: [
+ {
+ data: d.statusDistribution,
+ backgroundColor: d.statuses.map(
+ (_, i) => STATUS_COLORS[i % STATUS_COLORS.length],
+ ),
+ borderColor: "#fff",
+ borderWidth: 1,
+ hoverOffset: 12,
+ hoverBorderColor: "#fff",
+ hoverBorderWidth: 2,
+ },
+ ],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { position: "right" } },
+ scales: {
+ r: {
+ ticks: { display: false, backdropColor: "transparent" },
+ grid: { color: "#e9ecef" },
+ angleLines: { color: "#e9ecef" },
+ },
+ },
+ },
+ });
+ }
+
+ buildFunding() {
+ const d = this.viz;
+ this.charts.funding = new Chart(this.fundingTarget, {
+ type: "doughnut",
+ data: {
+ labels: d.fundings.map((f) => f.label),
+ datasets: [
+ {
+ data: d.fundingCount,
+ backgroundColor: d.fundings.map(
+ (_, i) => FUNDING_COLORS[i % FUNDING_COLORS.length],
+ ),
+ borderWidth: 3,
+ borderColor: "#fff",
+ hoverOffset: 12,
+ hoverBorderWidth: 3,
+ },
+ ],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ cutout: "60%",
+ plugins: { legend: { position: "right" } },
+ },
+ });
+ }
+
+ buildBudget() {
+ const d = this.viz;
+ const kr = new Intl.NumberFormat("da-DK", {
+ notation: "compact",
+ maximumFractionDigits: 1,
+ });
+ this.charts.budget = new Chart(this.budgetTarget, {
+ type: "bar",
+ data: {
+ labels: d.departments.map((x) => x.label),
+ datasets: [
+ {
+ label: "Budget",
+ data: d.budgetByDept,
+ backgroundColor: d.departments.map(
+ (_, i) => DEPT_COLORS[i % DEPT_COLORS.length],
+ ),
+ borderRadius: 6,
+ borderSkipped: false,
+ ...BAR_HOVER,
+ },
+ ],
+ },
+ options: {
+ indexAxis: "y",
+ responsive: true,
+ maintainAspectRatio: false,
+ scales: {
+ x: {
+ beginAtZero: true,
+ grid: { color: "#f1f4f8" },
+ ticks: { callback: (v) => kr.format(v) + " kr" },
+ },
+ y: { grid: { display: false } },
+ },
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ callbacks: {
+ label: (c) => kr.format(c.parsed.x) + " kr",
+ },
+ },
+ },
+ },
+ });
+ }
+
+ buildReach() {
+ const d = this.viz;
+ this.charts.reach = new Chart(this.reachTarget, {
+ type: "radar",
+ data: {
+ labels: d.reach.map((r) => r.label),
+ datasets: [
+ {
+ label: "Afdelinger",
+ data: d.reach.map((r) => r.depts),
+ backgroundColor: "rgba(0, 123, 166, .18)",
+ borderColor: TEAL,
+ borderWidth: 2,
+ pointBackgroundColor: TEAL,
+ pointBorderColor: "#fff",
+ pointRadius: 3,
+ pointHitRadius: 12,
+ pointHoverRadius: 9,
+ pointHoverBackgroundColor: "#fff",
+ pointHoverBorderColor: TEAL,
+ pointHoverBorderWidth: 2,
+ hoverBorderWidth: 3,
+ },
+ ],
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: false,
+ scales: {
+ r: {
+ beginAtZero: true,
+ suggestedMax: d.departments.length,
+ ticks: {
+ stepSize: 1,
+ showLabelBackdrop: false,
+ color: "#868e96",
+ },
+ grid: { color: "#e9ecef" },
+ angleLines: { color: "#e9ecef" },
+ pointLabels: { font: { size: 11 }, color: "#495057" },
+ },
+ },
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ callbacks: { label: (c) => c.parsed.r + " afdelinger" },
+ },
+ },
+ },
+ });
+ }
+
+ buildTimeline() {
+ const t = this.timelineData();
+ this.charts.timeline = new Chart(this.timelineTarget, {
+ type: "bar",
+ data: {
+ labels: t.labels,
+ datasets: [
+ {
+ data: t.spans,
+ backgroundColor: t.colors,
+ borderRadius: 5,
+ borderSkipped: false,
+ barThickness: 16,
+ ...BAR_HOVER,
+ },
+ ],
+ },
+ options: {
+ indexAxis: "y",
+ responsive: true,
+ maintainAspectRatio: false,
+ scales: {
+ x: {
+ min: 0,
+ max: t.max,
+ ticks: {
+ stepSize: 2,
+ callback: (v) => this.monthLabel(t.base, v),
+ },
+ grid: { color: "#f1f4f8" },
+ },
+ y: { grid: { display: false } },
+ },
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ callbacks: {
+ label: (c) =>
+ `${t.depts[c.dataIndex]} · ${this.monthLabel(t.base, c.raw[0])} – ${this.monthLabel(t.base, c.raw[1])}`,
+ },
+ },
+ },
+ },
+ });
+ }
+
+ timelineData() {
+ const toMonth = (s) => {
+ const p = s.split("-").map(Number);
+ return p[0] * 12 + (p[1] - 1);
+ };
+ const items = this.viz.timeline.map((x) => ({
+ s: toMonth(x.start),
+ e: toMonth(x.end),
+ title: x.title,
+ dept: x.dept,
+ }));
+ let base = Infinity,
+ maxEnd = 0;
+ items.forEach((i) => {
+ if (i.s < base) base = i.s;
+ });
+ if (!isFinite(base)) base = 0;
+ items.forEach((i) => {
+ if (i.e - base > maxEnd) maxEnd = i.e - base;
+ });
+ return {
+ base,
+ max: Math.max(maxEnd + 1, 6),
+ labels: items.map((i) => i.title),
+ spans: items.map((i) => [i.s - base, i.e - base]),
+ colors: items.map((i) => this.deptColor(i.dept)),
+ depts: items.map((i) => {
+ const idx = this.deptIndex(i.dept);
+ return idx >= 0 ? this.viz.departments[idx].label : "";
+ }),
+ };
+ }
+
+ monthLabel(base, offset) {
+ const m = base + Math.round(offset);
+ return (
+ MONTHS[((m % 12) + 12) % 12] +
+ " " +
+ String(Math.floor(m / 12)).slice(2)
+ );
+ }
+
+ // ---- Live updates: feed new data, let Chart.js animate the delta ---
+ updateCharts() {
+ const d = this.viz;
+ const c = this.charts;
+ if (c.statusByDept) {
+ d.statuses.forEach((s, i) => {
+ c.statusByDept.data.datasets[i].data = d.statusByDept[i];
+ });
+ c.statusByDept.update();
+ }
+ if (c.statusDist) {
+ c.statusDist.data.datasets[0].data = d.statusDistribution;
+ c.statusDist.update();
+ }
+ if (c.funding) {
+ c.funding.data.datasets[0].data = d.fundingCount;
+ c.funding.update();
+ }
+ if (c.budget) {
+ c.budget.data.datasets[0].data = d.budgetByDept;
+ c.budget.update();
+ }
+ if (c.reach) {
+ c.reach.data.labels = d.reach.map((r) => r.label);
+ c.reach.data.datasets[0].data = d.reach.map((r) => r.depts);
+ c.reach.update();
+ }
+ if (c.timeline) {
+ const t = this.timelineData();
+ c.timeline.data.labels = t.labels;
+ c.timeline.data.datasets[0].data = t.spans;
+ c.timeline.data.datasets[0].backgroundColor = t.colors;
+ c.timeline.options.scales.x.max = t.max;
+ c.timeline.options.scales.x.ticks.callback = (v) =>
+ this.monthLabel(t.base, v);
+ c.timeline.update();
+ }
+ }
+}
diff --git a/assets/styles/app.css b/assets/styles/app.css
index 7a54848..894cf14 100644
--- a/assets/styles/app.css
+++ b/assets/styles/app.css
@@ -441,95 +441,328 @@ h3 {
margin-bottom: var(--itk-space-6);
}
-.stat-card {
+.kpi {
+ position: relative;
background-color: var(--itk-surface);
border: 1px solid var(--itk-slate-200);
border-radius: var(--itk-radius-3);
box-shadow: var(--itk-shadow-1);
- padding: var(--itk-space-5);
- border-top: 3px solid var(--itk-blue);
+ padding: var(--itk-space-4) var(--itk-space-5);
+ overflow: hidden;
}
-.stat-card--accent {
- border-top-color: var(--itk-red);
+.kpi::after {
+ content: "";
+ position: absolute;
+ inset: 0 auto 0 0;
+ width: 4px;
+ background: var(--kpi-accent, var(--itk-blue));
}
-.stat-card--green {
- border-top-color: var(--itk-green);
+.kpi--teal {
+ --kpi-accent: #007ba6;
}
-
-.stat-card--lime {
- border-top-color: var(--itk-lime);
+.kpi--green {
+ --kpi-accent: #008d3d;
+}
+.kpi--cyan {
+ --kpi-accent: #00a5cd;
+}
+.kpi--amber {
+ --kpi-accent: #f5b800;
}
-.stat-card__label {
+.kpi__label {
font-size: var(--itk-text-sm);
color: var(--itk-slate-500);
font-weight: 500;
}
-.stat-card__value {
+.kpi__value {
font-size: var(--itk-text-2xl);
- font-weight: 700;
- line-height: 1.1;
+ font-weight: 800;
+ line-height: 1.05;
margin-top: var(--itk-space-2);
- font-feature-settings: "tnum";
+ letter-spacing: -0.02em;
+ font-variant-numeric: tabular-nums;
}
-.stat-card__meta {
+.kpi__hint {
font-size: var(--itk-text-xs);
color: var(--itk-slate-500);
margin-top: var(--itk-space-1);
}
-.dashboard-grid {
+.viz-grid {
display: grid;
- grid-template-columns: 1.4fr 1fr;
+ grid-template-columns: repeat(12, 1fr);
gap: var(--itk-space-5);
+ align-items: start;
+}
+
+.viz-grid > .span-5 {
+ grid-column: span 5;
+}
+.viz-grid > .span-6 {
+ grid-column: span 6;
+}
+.viz-grid > .span-7 {
+ grid-column: span 7;
+}
+.viz-grid > .span-12 {
+ grid-column: span 12;
+}
+
+@media (max-width: 920px) {
+ .viz-grid > [class*="span-"] {
+ grid-column: span 12;
+ }
+}
+
+.card__sub {
+ font-size: var(--itk-text-sm);
+ color: var(--itk-slate-500);
+ margin: 0 0 var(--itk-space-4);
+}
+
+.feed-scroll {
+ height: 230px;
+ overflow-y: auto;
+}
+
+.chart-box {
+ position: relative;
+ width: 100%;
+}
+
+.chart-box.h-280 {
+ height: 280px;
+}
+.chart-box.h-300 {
+ height: 300px;
+}
+.chart-box.h-320 {
+ height: 320px;
+}
+.chart-box.h-360 {
+ height: 360px;
}
-.dashboard-col {
+.heat-scroll {
+ overflow-x: auto;
+}
+
+.heat {
+ display: grid;
+ gap: 6px;
+ min-width: 560px;
+}
+
+.heat__collabel {
display: flex;
flex-direction: column;
- gap: var(--itk-space-5);
+ align-items: center;
+ justify-content: flex-end;
+ gap: 4px;
+ text-align: center;
+ font-size: var(--itk-text-xs);
+ font-weight: 600;
+ color: var(--itk-slate-600);
+ padding-bottom: 4px;
+}
+
+.heat__collabel.is-synergy {
+ color: var(--itk-blue);
}
-.card--activity {
+.heat__synbadge {
+ background: rgba(0, 123, 166, 0.12);
+ color: var(--itk-blue);
+ border-radius: var(--itk-radius-pill);
+ padding: 2px 7px;
+ font-size: 10px;
+ font-weight: 700;
+}
+
+.heat__rowlabel {
display: flex;
- flex-direction: column;
+ align-items: center;
+ padding-right: 10px;
+ font-size: var(--itk-text-sm);
+ font-weight: 600;
+ color: var(--itk-slate-700);
}
-@media (max-width: 900px) {
- .dashboard-grid {
- grid-template-columns: 1fr;
+.heat__cell {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 42px;
+ border-radius: 8px;
+ font-size: var(--itk-text-sm);
+ font-weight: 700;
+ transition:
+ background-color 0.4s ease,
+ color 0.4s ease,
+ transform 0.15s ease;
+ animation: heat-pop 0.5s cubic-bezier(0.22, 1, 0.36, 1) backwards;
+}
+
+.heat--paused .heat__cell {
+ animation-play-state: paused;
+}
+
+.heat__cell:hover {
+ transform: scale(1.08);
+ box-shadow: var(--itk-shadow-1);
+}
+
+.heat__cell.is-zero {
+ background: var(--itk-slate-50);
+ border: 1px dashed var(--itk-slate-200);
+ color: var(--itk-slate-300);
+ font-weight: 500;
+}
+
+@keyframes heat-pop {
+ from {
+ opacity: 0;
+ transform: scale(0.6);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
}
}
-.status-bars {
+.collab {
display: flex;
flex-direction: column;
- gap: var(--itk-space-3);
- padding: var(--itk-space-5);
+ gap: 12px;
}
-.status-bar__head {
+.collab-empty {
+ color: var(--itk-slate-500);
+ font-size: var(--itk-text-sm);
+ margin: 0;
+}
+
+.opp {
+ border: 1px solid var(--itk-slate-200);
+ border-radius: var(--itk-radius-2);
+ padding: 13px 14px;
+ background: var(--itk-surface);
+}
+
+.opp--in {
+ animation: rise 0.5s cubic-bezier(0.22, 1, 0.36, 1) backwards;
+}
+.opp--paused {
+ animation-play-state: paused;
+}
+
+@keyframes rise {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.opp__top {
display: flex;
+ align-items: center;
justify-content: space-between;
- font-size: var(--itk-text-sm);
- margin-bottom: var(--itk-space-1);
+ gap: 10px;
+ margin-bottom: 4px;
+}
+
+.opp__theme {
+ font-weight: 700;
+ font-size: var(--itk-text-md);
}
-.status-bar__track {
+.opp__rank {
+ font-size: 11px;
+ font-weight: 700;
+ padding: 3px 8px;
+ border-radius: var(--itk-radius-pill);
+}
+
+.rank-high {
+ background: #ecfdf3;
+ color: #067647;
+}
+.rank-med {
+ background: #fffaeb;
+ color: #b54708;
+}
+
+.opp__meta {
+ font-size: var(--itk-text-xs);
+ color: var(--itk-slate-500);
+ margin-bottom: 9px;
+}
+
+.opp__chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 10px;
+}
+
+.chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: var(--itk-text-xs);
+ background: var(--itk-slate-50);
+ border: 1px solid var(--itk-slate-200);
+ border-radius: var(--itk-radius-pill);
+ padding: 4px 10px;
+ max-width: 100%;
+}
+
+.chip .dot {
+ width: 8px;
height: 8px;
- background-color: var(--itk-slate-100);
+ border-radius: 50%;
+ flex: none;
+}
+.chip b {
+ font-weight: 600;
+}
+.chip span {
+ color: var(--itk-slate-500);
+}
+
+.meter {
+ height: 7px;
border-radius: var(--itk-radius-pill);
+ background: var(--itk-slate-100);
overflow: hidden;
}
-.status-bar__fill {
+.meter > i {
+ display: block;
height: 100%;
- background-color: var(--itk-blue);
border-radius: var(--itk-radius-pill);
+ background: linear-gradient(90deg, var(--itk-blue), #0f766e);
+ transition: width 1.1s cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .heat__cell,
+ .opp--in {
+ animation: none;
+ }
+ .heat__cell,
+ .meter > i {
+ transition: none;
+ }
}
.recent-list {
@@ -626,6 +859,59 @@ h3 {
justify-content: flex-end;
}
+.cell-actions form {
+ display: flex;
+ margin: 0;
+}
+
+.btn-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 30px;
+ height: 30px;
+ border: 0;
+ background: none;
+ color: var(--itk-slate-500);
+ cursor: pointer;
+ transition: color 0.12s ease;
+}
+
+.btn-icon:hover {
+ color: var(--itk-ink);
+ text-decoration: none;
+}
+
+.btn-icon--danger:hover {
+ color: var(--itk-red);
+}
+
+.btn-icon svg {
+ width: 16px;
+ height: 16px;
+}
+
+.table th:last-child,
+.table td:last-child {
+ position: sticky;
+ right: 0;
+ background-color: var(--itk-surface);
+ box-shadow: -8px 0 10px -8px rgba(16, 24, 40, 0.08);
+}
+
+.table tbody td:last-child {
+ z-index: 1;
+}
+
+.table tbody tr:hover td:last-child {
+ background-color: var(--itk-slate-50);
+}
+
+.table thead th:last-child {
+ background-color: var(--itk-slate-50);
+ z-index: 2;
+}
+
.badge {
display: inline-flex;
align-items: center;
@@ -709,15 +995,20 @@ h3 {
gap: var(--itk-space-4);
}
-.filters__actions {
+.filters__search {
display: flex;
+ align-items: flex-end;
gap: var(--itk-space-3);
- margin-top: var(--itk-space-4);
- flex-wrap: wrap;
+ margin-bottom: var(--itk-space-4);
}
-.filters__search {
- grid-column: 1 / -1;
+.filters__search .form-row {
+ flex: 1;
+ margin-bottom: 0;
+}
+
+.filters__search .btn {
+ line-height: 1.55;
}
.form {
@@ -1299,62 +1590,9 @@ textarea {
}
}
-.activity-feed-wrap {
- position: relative;
- flex: 1;
- min-height: 16rem;
-}
-
.activity-feed {
- position: absolute;
- inset: 0;
display: flex;
flex-direction: column;
- overflow-y: auto;
-}
-
-.activity-item {
- display: flex;
- align-items: flex-start;
- gap: var(--itk-space-3);
- padding: var(--itk-space-3) var(--itk-space-5);
- border-bottom: 1px solid var(--itk-slate-100);
-}
-
-.activity-item:last-child {
- border-bottom: none;
-}
-
-.activity-item__dot {
- margin-top: 6px;
- width: 8px;
- height: 8px;
- border-radius: 50%;
- background: var(--itk-slate-300);
- flex: none;
-}
-
-.activity-item--created .activity-item__dot {
- background: var(--itk-green);
-}
-
-.activity-item--updated .activity-item__dot {
- background: var(--itk-blue);
-}
-
-.activity-item--deleted .activity-item__dot {
- background: var(--itk-accent);
-}
-
-.activity-item__body {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
-
-.activity-item__time {
- font-size: var(--itk-text-xs);
- color: var(--itk-slate-500);
}
@keyframes activity-flash {
@@ -1370,7 +1608,7 @@ textarea {
}
}
-.activity-feed > .activity-item:first-child {
+.activity-feed > .recent-item:first-child {
animation: activity-flash 1.2s ease-out;
}
@@ -1393,7 +1631,7 @@ textarea {
animation: none;
}
- .activity-feed > .activity-item:first-child {
+ .activity-feed > .recent-item:first-child {
animation: none;
}
}
diff --git a/importmap.php b/importmap.php
index d322546..4c60e81 100644
--- a/importmap.php
+++ b/importmap.php
@@ -27,6 +27,8 @@
'@hotwired/stimulus' => ['version' => '3.2.2'],
'@symfony/stimulus-bundle' => ['path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js'],
'@hotwired/turbo' => ['version' => '8.0.23'],
+ 'chart.js/auto' => ['version' => '4.5.1'],
+ '@kurkle/color' => ['version' => '0.3.4'],
'tom-select' => ['version' => '2.6.1'],
'@orchidjs/sifter' => ['version' => '1.1.0'],
'@orchidjs/unicode-variants' => ['version' => '1.1.2'],
diff --git a/src/Controller/DashboardController.php b/src/Controller/DashboardController.php
index 63fce77..8d63386 100644
--- a/src/Controller/DashboardController.php
+++ b/src/Controller/DashboardController.php
@@ -4,9 +4,8 @@
namespace App\Controller;
-use App\Enum\Status;
-use App\Repository\ContactRepository;
use App\Repository\InitiativeRepository;
+use App\Service\DashboardData;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -14,14 +13,11 @@
class DashboardController extends AbstractController
{
#[Route('/', name: 'app_dashboard', methods: ['GET'])]
- public function index(InitiativeRepository $initiatives, ContactRepository $contacts): Response
+ public function index(InitiativeRepository $initiatives, DashboardData $dashboardData): Response
{
return $this->render('dashboard/index.html.twig', [
- 'total' => $initiatives->countAll(),
- 'byStatus' => $initiatives->countByStatus(),
- 'statuses' => Status::cases(),
'recent' => $initiatives->findRecent(8),
- 'contactCount' => $contacts->count([]),
+ 'viz' => $dashboardData->build(),
]);
}
}
diff --git a/src/Repository/InitiativeRepository.php b/src/Repository/InitiativeRepository.php
index faf7adf..0ca45e6 100644
--- a/src/Repository/InitiativeRepository.php
+++ b/src/Repository/InitiativeRepository.php
@@ -143,4 +143,24 @@ public function findRecent(int $limit = 5): array
->getQuery()
->getResult();
}
+
+ /**
+ * @return array>
+ */
+ public function dashboardRows(): array
+ {
+ return $this->createQueryBuilder('i')
+ ->select(
+ 'i.title',
+ 'i.category',
+ 'i.status',
+ 'i.organizationalAnchoring',
+ 'i.budget',
+ 'i.funding',
+ 'i.timePeriodStart',
+ 'i.timePeriodEnd',
+ )
+ ->getQuery()
+ ->getArrayResult();
+ }
}
diff --git a/src/Service/ActivityPublisher.php b/src/Service/ActivityPublisher.php
index 199de0a..26942ff 100644
--- a/src/Service/ActivityPublisher.php
+++ b/src/Service/ActivityPublisher.php
@@ -6,9 +6,6 @@
use App\Entity\Initiative;
use App\Entity\User;
-use App\Enum\Status;
-use App\Repository\ContactRepository;
-use App\Repository\InitiativeRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
@@ -18,8 +15,8 @@
/**
* Pushes a single Mercure payload describing an initiative change to every
* connected client: a Turbo Stream that prepends the change to the live activity
- * feed and refreshes the dashboard's stats, recent list and status bars. The
- * counts are recomputed here so the broadcast reflects the state after flush.
+ * feed and refreshes the dashboard's KPIs and visualisations. The aggregates are
+ * recomputed here so the broadcast reflects the state after flush.
*/
final class ActivityPublisher
{
@@ -32,8 +29,7 @@ public function __construct(
private readonly HubInterface $hub,
private readonly Environment $twig,
private readonly UrlGeneratorInterface $urlGenerator,
- private readonly InitiativeRepository $initiatives,
- private readonly ContactRepository $contacts,
+ private readonly DashboardData $dashboardData,
private readonly LoggerInterface $logger,
) {
}
@@ -52,11 +48,7 @@ public function publish(string $action, Initiative $initiative, ?User $actor): v
'url' => $url,
'actor' => $actor?->getName(),
'at' => new \DateTimeImmutable(),
- 'total' => $this->initiatives->countAll(),
- 'byStatus' => $this->initiatives->countByStatus(),
- 'statuses' => Status::cases(),
- 'recent' => $this->initiatives->findRecent(8),
- 'contactCount' => $this->contacts->count([]),
+ 'viz' => $this->dashboardData->build(),
]);
try {
diff --git a/src/Service/DashboardData.php b/src/Service/DashboardData.php
new file mode 100644
index 0000000..ced8720
--- /dev/null
+++ b/src/Service/DashboardData.php
@@ -0,0 +1,248 @@
+
+ */
+ public function build(): array
+ {
+ $departments = OrganizationalAnchoring::cases();
+ $categories = Category::cases();
+ $statuses = Status::cases();
+ $fundings = Funding::cases();
+
+ $deptIndex = $this->index($departments);
+ $catIndex = $this->index($categories);
+ $statusIndex = $this->index($statuses);
+ $fundingIndex = $this->index($fundings);
+
+ $heatmap = $this->zeroMatrix(\count($departments), \count($categories));
+ $statusByDept = $this->zeroMatrix(\count($statuses), \count($departments));
+ $statusDistribution = array_fill(0, \count($statuses), 0);
+ $budgetByDept = array_fill(0, \count($departments), 0);
+ $fundingCount = array_fill(0, \count($fundings), 0);
+ $reachByDept = array_fill(0, \count($categories), []);
+
+ /** @var array>> $titlesByCategoryDept */
+ $titlesByCategoryDept = [];
+ $timeline = [];
+ $total = 0;
+ $deptsSeen = [];
+
+ foreach ($this->initiatives->dashboardRows() as $row) {
+ ++$total;
+ $dept = $this->enumValue($row['organizationalAnchoring'] ?? null);
+ $cat = $this->enumValue($row['category'] ?? null);
+ $status = $this->enumValue($row['status'] ?? null);
+ $di = null !== $dept ? ($deptIndex[$dept] ?? null) : null;
+ $ci = null !== $cat ? ($catIndex[$cat] ?? null) : null;
+ $si = null !== $status ? ($statusIndex[$status] ?? null) : null;
+
+ if (null !== $di) {
+ $deptsSeen[$di] = true;
+ }
+ if (null !== $di && null !== $ci) {
+ ++$heatmap[$di][$ci];
+ $reachByDept[$ci][$di] = true;
+ $titlesByCategoryDept[$cat][$dept][] = (string) $row['title'];
+ }
+ if (null !== $si) {
+ ++$statusDistribution[$si];
+ if (null !== $di) {
+ ++$statusByDept[$si][$di];
+ }
+ }
+ if (null !== $di && null !== ($row['budget'] ?? null)) {
+ $budgetByDept[$di] += (int) $row['budget'];
+ }
+ foreach (($row['funding'] ?? []) as $f) {
+ $fi = $fundingIndex[$f] ?? null;
+ if (null !== $fi) {
+ ++$fundingCount[$fi];
+ }
+ }
+ if (($row['timePeriodStart'] ?? null) instanceof \DateTimeInterface
+ && ($row['timePeriodEnd'] ?? null) instanceof \DateTimeInterface) {
+ $timeline[] = [
+ 'title' => (string) $row['title'],
+ 'dept' => $dept,
+ 'start' => $row['timePeriodStart']->format('Y-m-d'),
+ 'end' => $row['timePeriodEnd']->format('Y-m-d'),
+ ];
+ }
+ }
+
+ usort($timeline, static fn (array $a, array $b): int => strcmp($a['start'], $b['start']));
+
+ $inProgress = $statusDistribution[$statusIndex[Status::Active->value]] ?? 0;
+ $collaborationCount = 0;
+ foreach ($categories as $category) {
+ if (\count($titlesByCategoryDept[$category->value] ?? []) >= 2) {
+ ++$collaborationCount;
+ }
+ }
+
+ return [
+ 'kpis' => [
+ 'total' => $total,
+ 'inProgress' => $inProgress,
+ 'departments' => \count($deptsSeen),
+ 'departmentsTotal' => \count($departments),
+ 'collaboration' => $collaborationCount,
+ ],
+ 'departments' => $this->labelled($departments),
+ 'categories' => $this->labelled($categories),
+ 'statuses' => $this->labelled($statuses),
+ 'fundings' => $this->labelled($fundings),
+ 'heatmap' => $heatmap,
+ 'statusByDept' => $statusByDept,
+ 'statusDistribution' => $statusDistribution,
+ 'budgetByDept' => $budgetByDept,
+ 'fundingCount' => $fundingCount,
+ 'reach' => $this->reach($categories, $reachByDept),
+ 'collaboration' => $this->collaboration($categories, $departments, $titlesByCategoryDept),
+ 'timeline' => \array_slice($timeline, 0, 10),
+ ];
+ }
+
+ /**
+ * Cross-department themes: a category worked on in two or more departments
+ * is a candidate for "sammenfald". Ranked by how broadly it spans.
+ *
+ * @param list $categories
+ * @param list $departments
+ * @param array>> $titlesByCategoryDept
+ *
+ * @return list>
+ */
+ private function collaboration(array $categories, array $departments, array $titlesByCategoryDept): array
+ {
+ $deptLabel = [];
+ foreach ($departments as $d) {
+ $deptLabel[$d->value] = $this->t($d->labelKey());
+ }
+
+ $opportunities = [];
+ foreach ($categories as $category) {
+ $byDept = $titlesByCategoryDept[$category->value] ?? [];
+ if (\count($byDept) < 2) {
+ continue;
+ }
+
+ $departmentsInvolved = [];
+ $inits = [];
+ $total = 0;
+ foreach ($byDept as $deptValue => $titles) {
+ $total += \count($titles);
+ $departmentsInvolved[] = ['key' => $deptValue, 'label' => $deptLabel[$deptValue], 'count' => \count($titles)];
+ $inits[] = ['title' => $titles[0], 'deptKey' => $deptValue, 'deptLabel' => $deptLabel[$deptValue]];
+ }
+
+ $distinctDepts = \count($byDept);
+ $strength = min(100, $distinctDepts * 22 + min($total, 8) * 4);
+ $opportunities[] = [
+ 'theme' => $this->t($category->labelKey()),
+ 'themeKey' => $category->value,
+ 'departmentCount' => $distinctDepts,
+ 'initiativeCount' => $total,
+ 'strength' => $strength,
+ 'rank' => $strength >= 70 ? 'high' : 'med',
+ 'departments' => $departmentsInvolved,
+ 'inits' => \array_slice($inits, 0, 4),
+ ];
+ }
+
+ usort($opportunities, static fn (array $a, array $b): int => $b['strength'] <=> $a['strength']);
+
+ return \array_slice($opportunities, 0, 5);
+ }
+
+ /**
+ * @param list $categories
+ * @param list> $reachByDept
+ *
+ * @return list>
+ */
+ private function reach(array $categories, array $reachByDept): array
+ {
+ $reach = [];
+ foreach ($categories as $ci => $category) {
+ $reach[] = ['label' => $this->t($category->labelKey()), 'depts' => \count($reachByDept[$ci])];
+ }
+
+ // Kept in category order (not sorted by count) so the radar axes stay stable
+ // across live updates rather than rotating when a count changes.
+ return $reach;
+ }
+
+ /**
+ * @param list<\BackedEnum&\App\Enum\TranslatableEnum> $cases
+ *
+ * @return list
+ */
+ private function labelled(array $cases): array
+ {
+ return array_map(fn ($case): array => ['key' => $case->value, 'label' => $this->t($case->labelKey())], $cases);
+ }
+
+ /**
+ * @param list<\BackedEnum> $cases
+ *
+ * @return array
+ */
+ private function index(array $cases): array
+ {
+ $map = [];
+ foreach ($cases as $i => $case) {
+ $map[$case->value] = $i;
+ }
+
+ return $map;
+ }
+
+ /**
+ * @return list>
+ */
+ private function zeroMatrix(int $rows, int $cols): array
+ {
+ return array_fill(0, $rows, array_fill(0, $cols, 0));
+ }
+
+ private function enumValue(mixed $value): ?string
+ {
+ if ($value instanceof \BackedEnum) {
+ return (string) $value->value;
+ }
+
+ return null !== $value ? (string) $value : null;
+ }
+
+ private function t(string $key): string
+ {
+ return $this->translator->trans($key);
+ }
+}
diff --git a/templates/activity/_broadcast.html.twig b/templates/activity/_broadcast.html.twig
index b3430da..8da6933 100644
--- a/templates/activity/_broadcast.html.twig
+++ b/templates/activity/_broadcast.html.twig
@@ -8,9 +8,6 @@
{{ include('dashboard/_stats.html.twig') }}
-
- {{ include('dashboard/_recent.html.twig') }}
-
-
- {{ include('dashboard/_status_bars.html.twig') }}
+
+ {{ include('dashboard/_viz_json.html.twig') }}
diff --git a/templates/activity/_item.html.twig b/templates/activity/_item.html.twig
index 4e63a41..8adc949 100644
--- a/templates/activity/_item.html.twig
+++ b/templates/activity/_item.html.twig
@@ -1,11 +1,9 @@
-
-
-
-
- {%- if actor %}{{ actor }} {% endif -%}
- {{ ('activity.action.' ~ action)|trans }}
- {% if url %}{{ title }} {% else %}{{ title }} {% endif %}
-
-
{{ at|date('d.m.Y H:i') }}
+
+
+ {% if url %}
{{ title }} {% else %}
{{ title }} {% endif %}
+
+ {%- if actor %}{{ actor }} · {% endif -%}
+ {{ ('activity.action.' ~ action)|trans }} · {{ at|date('d.m.Y H:i') }}
+
diff --git a/templates/dashboard/_recent.html.twig b/templates/dashboard/_recent.html.twig
deleted file mode 100644
index 302d973..0000000
--- a/templates/dashboard/_recent.html.twig
+++ /dev/null
@@ -1,23 +0,0 @@
-{% if recent is empty %}
-
-{% else %}
-
- {% for initiative in recent %}
-
-
-
{{ initiative.title }}
-
- {%- if initiative.organizationalAnchoring %}{{ initiative.organizationalAnchoring.labelKey|trans }} · {% endif -%}
- {{ initiative.createdAt|date('d.m.Y') }}
-
-
-
- {% if initiative.status %}{{ initiative.status.labelKey|trans }} {% endif %}
-
-
- {% endfor %}
-
-{% endif %}
diff --git a/templates/dashboard/_stats.html.twig b/templates/dashboard/_stats.html.twig
index 2f4744c..536e4c1 100644
--- a/templates/dashboard/_stats.html.twig
+++ b/templates/dashboard/_stats.html.twig
@@ -1,8 +1,20 @@
-
-
{{ 'dashboard.total'|trans }}
-
{{ total }}
+
+
{{ 'dashboard.total'|trans }}
+
{{ viz.kpis.total }}
+
{{ 'dashboard.total_hint'|trans }}
-
-
{{ 'nav.contacts'|trans }}
-
{{ contactCount }}
+
+
{{ 'dashboard.in_progress'|trans }}
+
{{ viz.kpis.inProgress }}
+
{{ 'dashboard.in_progress_hint'|trans }}
+
+
+
{{ 'dashboard.departments_involved'|trans }}
+
{{ viz.kpis.departments }}
+
{{ 'dashboard.departments_involved_hint'|trans }}
+
+
+
{{ 'dashboard.collaboration_count'|trans }}
+
{{ viz.kpis.collaboration }}
+
{{ 'dashboard.collaboration_count_hint'|trans }}
diff --git a/templates/dashboard/_status_bars.html.twig b/templates/dashboard/_status_bars.html.twig
deleted file mode 100644
index 4459e6f..0000000
--- a/templates/dashboard/_status_bars.html.twig
+++ /dev/null
@@ -1,16 +0,0 @@
-{% set maxCount = 1 %}
-{% for status in statuses %}
- {% set maxCount = max(maxCount, byStatus[status.value]|default(0)) %}
-{% endfor %}
-{% for status in statuses %}
- {% set count = byStatus[status.value]|default(0) %}
-
-
- {{ status.labelKey|trans }}
- {{ count }}
-
-
-
-{% endfor %}
diff --git a/templates/dashboard/_viz_json.html.twig b/templates/dashboard/_viz_json.html.twig
new file mode 100644
index 0000000..1839124
--- /dev/null
+++ b/templates/dashboard/_viz_json.html.twig
@@ -0,0 +1 @@
+{{- viz|json_encode(constant('JSON_HEX_TAG') b-or constant('JSON_HEX_AMP') b-or constant('JSON_HEX_APOS') b-or constant('JSON_HEX_QUOT'))|raw -}}
diff --git a/templates/dashboard/index.html.twig b/templates/dashboard/index.html.twig
index d5e5daa..1b6dcfe 100644
--- a/templates/dashboard/index.html.twig
+++ b/templates/dashboard/index.html.twig
@@ -3,7 +3,7 @@
{% block title %}{{ 'dashboard.title'|trans }} · {{ 'app.name'|trans }}{% endblock %}
{% block body %}
-