Files
Stirling-PDF/frontend/portal/src/components/UsageAreaChart.tsx
T
Reece Browne dffc292888 I18n on portal (#6761)
## Overview

Internationalizes the **developer portal**, which previously had **zero
i18n** — every string was hardcoded across ~118 components. Rather than
stand up a parallel system, this shares the **editor's** existing i18n
setup (same TOML locale format, same Crowdin pipeline), then converts
every portal surface to `react-i18next` and adds a CI guard so coverage
can't regress.

## What's included

### 🔗 Shared i18n core (`@shared/i18n`)
- Extracts the editor's `TomlBackend` (HTTP loader for
`public/locales/{lng}/translation.toml`) and language metadata/helpers
(the 42-language list, RTL set, `LanguageSource` priority, code
normalizers) into `frontend/shared/i18n/`.
- The **editor** now imports and re-exports these from `@shared/i18n` —
its 20+ consumers are unchanged. Its local `tomlBackend.ts` is deleted.
- The **portal** builds its own i18next instance from the shared core,
with **en-US as the source of truth** and the same
`/locales/{lng}/translation.toml` layout.

### 🌍 Full portal coverage
- Every view and component converted to `t()` — all feature areas (home,
pipelines, sources, infrastructure, usage, documents, agent-builder,
editor-admin, policies, users, docs, catalogue, components view) plus
app shell, nav, modals, and the home/domain widgets.
- **1108 keys across ~30 namespaces** in
`portal/public/locales/en-US/translation.toml`, grouped by feature;
shared strings under `[common]`. Plurals use i18next count forms;
dynamic labels (nav, settings sections, status badges) use template keys
against populated tables.
- Data-driven strings (values from `@portal/api/*` mocks, enum/id
values, code samples) are intentionally left untranslated — they're
data, not UI chrome.

###  CI coverage guard
- `portal/scripts/check-i18n.mjs` fails if any static `t("key")` in
portal source is missing from the en-US locale. Wired into
`frontend:check` and `frontend:check:all`, so missed keys break CI. This
mirrors the editor's `missingTranslations` test for the portal, which
has no vitest harness of its own.

## Testing
- `task frontend:check:all` passes locally (typecheck all variants,
lint, format, **portal i18n guard**, builds, tests, storybook).
- Every static `t()` key verified to resolve in the locale (1108 keys /
186 source files); all dynamic key prefixes map to populated tables.
- Runtime sweep of all 12 portal routes shows **no unresolved keys** on
screen; nav labels, plurals, and array-backed copy all render real text.

## Follow-ups (not in this PR)
- **Crowdin** — register `frontend/portal/public/locales/` as a source
so portal strings flow through the same translation pipeline as the
editor (an ops step on the Crowdin side; there's no Crowdin config in
the repo).
- Only `en-US` is populated; other languages will arrive via the
pipeline.
2026-06-22 12:57:12 +00:00

289 lines
8.6 KiB
TypeScript

import { useMemo, useRef, useState, type KeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import type { UsagePoint } from "@portal/api/home";
import "@portal/components/UsageAreaChart.css";
interface UsageAreaChartProps {
data: UsagePoint[];
/** Headline metric shown above the chart (e.g. total 30d, current value). */
totalLabel?: string;
totalValue?: string;
deltaPct?: number;
}
const PADDING = { top: 16, right: 20, bottom: 28, left: 32 } as const;
const VIEW = { width: 800, height: 240 } as const;
function formatTick(date: string): string {
return new Date(date).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
function formatNumber(value: number): string {
if (value >= 1000) {
return `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
}
return value.toLocaleString();
}
export function UsageAreaChart({
data,
totalLabel: totalLabelProp,
totalValue,
deltaPct,
}: UsageAreaChartProps) {
const { t } = useTranslation();
const totalLabel = totalLabelProp ?? t("usageChart.defaultLabel");
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
const svgRef = useRef<SVGSVGElement | null>(null);
const { points, areaPath, linePath, yMax, yTicks, xTickIndices } =
useMemo(() => {
if (data.length === 0) {
return {
points: [] as Array<{ x: number; y: number; raw: UsagePoint }>,
areaPath: "",
linePath: "",
yMax: 0,
yTicks: [] as number[],
xTickIndices: [] as number[],
};
}
const max = Math.max(...data.map((d) => d.value));
// Round yMax up to a "nice" number for ticks. Floor at 500 so an
// all-zero series doesn't divide by zero (which would NaN every point).
const niceMax = Math.max(Math.ceil(max / 500) * 500, 500);
const yTicksCalc = [0, niceMax * 0.5, niceMax];
const innerW = VIEW.width - PADDING.left - PADDING.right;
const innerH = VIEW.height - PADDING.top - PADDING.bottom;
const xStep = innerW / Math.max(data.length - 1, 1);
const pts = data.map((raw, i) => ({
x: PADDING.left + i * xStep,
y: PADDING.top + innerH - (raw.value / niceMax) * innerH,
raw,
}));
const linePathStr = pts
.map(
(p, i) =>
`${i === 0 ? "M" : "L"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`,
)
.join(" ");
const baseY = PADDING.top + innerH;
const areaPathStr = `${linePathStr} L ${pts[pts.length - 1].x.toFixed(2)} ${baseY} L ${pts[0].x.toFixed(2)} ${baseY} Z`;
// X ticks: 5 evenly spread.
const xIdx: number[] = [];
const tickCount = 5;
for (let t = 0; t < tickCount; t++) {
xIdx.push(Math.round((t * (data.length - 1)) / (tickCount - 1)));
}
return {
points: pts,
areaPath: areaPathStr,
linePath: linePathStr,
yMax: niceMax,
yTicks: yTicksCalc,
xTickIndices: xIdx,
};
}, [data]);
function onMouseMove(e: React.MouseEvent<SVGSVGElement>) {
if (!svgRef.current || points.length === 0) return;
const rect = svgRef.current.getBoundingClientRect();
const xRatio = (e.clientX - rect.left) / rect.width;
const svgX = xRatio * VIEW.width;
// Find nearest point
let nearestIdx = 0;
let nearestDist = Infinity;
for (let i = 0; i < points.length; i++) {
const d = Math.abs(points[i].x - svgX);
if (d < nearestDist) {
nearestDist = d;
nearestIdx = i;
}
}
setHoverIndex(nearestIdx);
}
const hovered = hoverIndex !== null ? points[hoverIndex] : null;
function onKeyDown(e: KeyboardEvent<SVGSVGElement>) {
if (points.length === 0) return;
if (e.key === "ArrowRight") {
e.preventDefault();
setHoverIndex((idx) =>
idx === null ? 0 : Math.min(points.length - 1, idx + 1),
);
} else if (e.key === "ArrowLeft") {
e.preventDefault();
setHoverIndex((idx) =>
idx === null ? points.length - 1 : Math.max(0, idx - 1),
);
} else if (e.key === "Home") {
e.preventDefault();
setHoverIndex(0);
} else if (e.key === "End") {
e.preventDefault();
setHoverIndex(points.length - 1);
} else if (e.key === "Escape") {
setHoverIndex(null);
}
}
const displayTotal =
totalValue ?? data.reduce((sum, p) => sum + p.value, 0).toLocaleString();
return (
<div className="portal-chart">
<header className="portal-chart__head">
<div>
<div className="portal-chart__label">{totalLabel}</div>
<div className="portal-chart__value">{displayTotal}</div>
</div>
{deltaPct !== undefined && (
<div
className={
"portal-chart__delta " + (deltaPct >= 0 ? "is-up" : "is-down")
}
>
<span aria-hidden>{deltaPct >= 0 ? "↑" : "↓"}</span>
{t("usageChart.delta", {
pct: Math.abs(Math.round(deltaPct * 100)),
})}
</div>
)}
</header>
<svg
ref={svgRef}
viewBox={`0 0 ${VIEW.width} ${VIEW.height}`}
preserveAspectRatio="none"
role="img"
aria-label={totalLabel}
className="portal-chart__svg"
tabIndex={points.length > 0 ? 0 : -1}
onMouseMove={onMouseMove}
onMouseLeave={() => setHoverIndex(null)}
onKeyDown={onKeyDown}
onBlur={() => setHoverIndex(null)}
>
<defs>
<linearGradient id="chart-fill" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor="var(--color-blue)"
stopOpacity="0.32"
/>
<stop offset="100%" stopColor="var(--color-blue)" stopOpacity="0" />
</linearGradient>
</defs>
{/* Y gridlines + labels */}
{yTicks.map((tick) => {
const innerH = VIEW.height - PADDING.top - PADDING.bottom;
const y = PADDING.top + innerH - (tick / yMax) * innerH;
return (
<g key={tick}>
<line
x1={PADDING.left}
x2={VIEW.width - PADDING.right}
y1={y}
y2={y}
className="portal-chart__gridline"
/>
<text
x={PADDING.left - 6}
y={y + 4}
className="portal-chart__axis-label"
textAnchor="end"
>
{formatNumber(tick)}
</text>
</g>
);
})}
{/* Area + line */}
<path d={areaPath} fill="url(#chart-fill)" />
<path d={linePath} className="portal-chart__line" />
{/* X ticks */}
{xTickIndices.map((idx) => {
const p = points[idx];
if (!p) return null;
return (
<text
key={idx}
x={p.x}
y={VIEW.height - PADDING.bottom + 18}
className="portal-chart__axis-label"
textAnchor="middle"
>
{formatTick(p.raw.date)}
</text>
);
})}
{/* Hover scrub + dot + tooltip */}
{hovered && (
<>
<line
x1={hovered.x}
x2={hovered.x}
y1={PADDING.top}
y2={VIEW.height - PADDING.bottom}
className="portal-chart__scrub"
/>
<circle
cx={hovered.x}
cy={hovered.y}
r={4}
className="portal-chart__dot"
/>
</>
)}
</svg>
{hovered && (
<div
className="portal-chart__tooltip"
style={{
left: `${(hovered.x / VIEW.width) * 100}%`,
}}
>
<div className="portal-chart__tooltip-date">
{new Date(hovered.raw.date).toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
})}
</div>
<div className="portal-chart__tooltip-value">
{t("usageChart.docsValue", {
value: hovered.raw.value.toLocaleString(),
})}
</div>
</div>
)}
<div className="sr-only" aria-live="polite" aria-atomic="true">
{hovered
? t("usageChart.srAnnounce", {
date: new Date(hovered.raw.date).toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
}),
value: hovered.raw.value.toLocaleString(),
})
: ""}
</div>
</div>
);
}