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 = `${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 @@ - - - - - + + 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 %} - - +
+
+ {% 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 %} -
-
{{ 'dashboard.empty'|trans }}
- {{ 'action.new'|trans }} -
-{% 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 %} -
+