diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/package.json b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/package.json index b2d6c562..e61195bb 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/package.json +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/package.json @@ -19,6 +19,8 @@ "dependencies": { "@tanstack/react-query": "^5.90.12", "axios": "^1.13.0", + "d3-array": "^3.2.4", + "d3-scale": "^4.0.2", "react": "^19.2.1", "react-dom": "^19.2.0", "react-error-boundary": "^6.0.0", @@ -34,6 +36,8 @@ "devDependencies": { "@biomejs/biome": "^2.3.8", "@tanstack/react-query-devtools": "^5.91.1", + "@types/d3-array": "^3.2.2", + "@types/d3-scale": "^4.0.9", "@types/node": "^24.10.2", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/pnpm-lock.yaml b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/pnpm-lock.yaml index 0d03bdab..54596339 100644 --- a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/pnpm-lock.yaml +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/pnpm-lock.yaml @@ -17,6 +17,12 @@ importers: axios: specifier: ^1.13.0 version: 1.13.2 + d3-array: + specifier: ^3.2.4 + version: 3.2.4 + d3-scale: + specifier: ^4.0.2 + version: 4.0.2 react: specifier: ^19.2.1 version: 19.2.1 @@ -57,6 +63,12 @@ importers: '@tanstack/react-query-devtools': specifier: ^5.91.1 version: 5.91.1(@tanstack/react-query@5.90.12(react@19.2.1))(react@19.2.1) + '@types/d3-array': + specifier: ^3.2.2 + version: 3.2.2 + '@types/d3-scale': + specifier: ^4.0.9 + version: 4.0.9 '@types/node': specifier: ^24.10.2 version: 24.10.2 @@ -572,6 +584,15 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -2264,6 +2285,14 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-time@3.0.4': {} + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.8': {} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/Panel.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/Panel.module.scss new file mode 100644 index 00000000..1187f67e --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/Panel.module.scss @@ -0,0 +1,60 @@ +// ©AngelaMos | 2026 +// Panel.module.scss + +.panel { + display: flex; + flex-direction: column; + border-bottom: 1px solid var(--fg-4); + min-height: 0; +} + +.head { + display: flex; + align-items: center; + justify-content: space-between; + height: 28px; + padding: 0 8px; + background: var(--bg); + border-bottom: 1px solid var(--fg-4); +} + +.title { + font-size: var(--type-label); + letter-spacing: var(--letter-spacing-label); + text-transform: uppercase; + color: var(--fg-2); +} + +.subtitle { + color: var(--fg-3); +} + +.meta { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--fg-3); +} + +.rawLink { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--fg-3); + + &:hover { + color: var(--fg-1); + } + + &:focus-visible { + outline: 1px solid var(--fg-2); + outline-offset: 1px; + } +} + +.body { + flex: 1; + overflow: auto; + padding: 4px 0; + min-height: 0; +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/Panel.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/Panel.tsx new file mode 100644 index 00000000..d9485b86 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/Panel.tsx @@ -0,0 +1,55 @@ +// ©AngelaMos | 2026 +// Panel.tsx + +import type { ReactNode } from 'react' +import { FiExternalLink } from 'react-icons/fi' +import styles from './Panel.module.scss' +import { StaleIndicator } from './shared/StaleIndicator' + +export interface PanelProps { + title: string + subtitle?: string + rawHref?: string + rawLabel?: string + isStale?: boolean + lastTickAt?: number + children: ReactNode +} + +export function Panel({ + title, + subtitle, + rawHref, + rawLabel, + isStale, + lastTickAt, + children, +}: PanelProps): React.ReactElement { + return ( +
+
+ + {title} + {subtitle && · {subtitle}} + + + + {rawHref && ( + + + + )} + +
+
{children}
+
+ ) +} + +Panel.displayName = 'Panel' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/KPI.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/KPI.module.scss new file mode 100644 index 00000000..f97a0951 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/KPI.module.scss @@ -0,0 +1,24 @@ +// ©AngelaMos | 2026 +// KPI.module.scss + +.kpi { + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px 8px; +} + +.value { + font-family: var(--font-mono); + font-size: var(--type-num-l); + font-variant-numeric: tabular-nums; + color: var(--fg-1); + line-height: 1; +} + +.label { + font-size: var(--type-label); + letter-spacing: var(--letter-spacing-label); + text-transform: uppercase; + color: var(--fg-3); +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/KPI.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/KPI.tsx new file mode 100644 index 00000000..34ed09e0 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/KPI.tsx @@ -0,0 +1,21 @@ +// ©AngelaMos | 2026 +// KPI.tsx + +import type { ReactNode } from 'react' +import styles from './KPI.module.scss' + +export interface KPIProps { + label: string + value: ReactNode +} + +export function KPI({ label, value }: KPIProps): React.ReactElement { + return ( +
+
{value}
+
{label}
+
+ ) +} + +KPI.displayName = 'KPI' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/Sparkline.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/Sparkline.tsx new file mode 100644 index 00000000..df64c136 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/Sparkline.tsx @@ -0,0 +1,49 @@ +// ©AngelaMos | 2026 +// Sparkline.tsx + +import { extent } from 'd3-array' +import { scaleLinear } from 'd3-scale' + +const DEFAULT_WIDTH = 80 +const DEFAULT_HEIGHT = 16 +const STROKE_WIDTH = 1 +const MIN_DATA_POINTS = 2 + +export interface SparklineProps { + data: number[] + width?: number + height?: number +} + +export function Sparkline({ + data, + width = DEFAULT_WIDTH, + height = DEFAULT_HEIGHT, +}: SparklineProps): React.ReactElement | null { + if (data.length < MIN_DATA_POINTS) return null + const [min, max] = extent(data) + if (min === undefined || max === undefined) return null + + const x = scaleLinear() + .domain([0, data.length - 1]) + .range([0, width]) + const y = scaleLinear().domain([min, max]).range([height, 0]) + + const path = data + .map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i)},${y(v)}`) + .join('') + + return ( + + Trend sparkline + + + ) +} + +Sparkline.displayName = 'Sparkline' diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/StaleIndicator.module.scss b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/StaleIndicator.module.scss new file mode 100644 index 00000000..f2597471 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/StaleIndicator.module.scss @@ -0,0 +1,21 @@ +// ©AngelaMos | 2026 +// StaleIndicator.module.scss + +.dot { + display: inline-block; + width: 6px; + height: 6px; + flex-shrink: 0; +} + +.live { + background: var(--ok); +} + +.stale { + background: var(--amber); +} + +.unknown { + background: var(--fg-4); +} diff --git a/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/StaleIndicator.tsx b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/StaleIndicator.tsx new file mode 100644 index 00000000..7b981905 --- /dev/null +++ b/PROJECTS/advanced/monitor-the-situation-dashboard/frontend/src/pages/panels/shared/StaleIndicator.tsx @@ -0,0 +1,29 @@ +// ©AngelaMos | 2026 +// StaleIndicator.tsx + +import styles from './StaleIndicator.module.scss' + +export interface StaleIndicatorProps { + stale?: boolean + lastTickAt?: number +} + +export function StaleIndicator({ + stale, + lastTickAt, +}: StaleIndicatorProps): React.ReactElement { + const className = + stale === undefined + ? `${styles.dot} ${styles.unknown}` + : stale + ? `${styles.dot} ${styles.stale}` + : `${styles.dot} ${styles.live}` + + const title = lastTickAt + ? `Last update ${new Date(lastTickAt).toISOString().slice(11, 19)} UTC` + : undefined + + return +} + +StaleIndicator.displayName = 'StaleIndicator'