localise chart + make daily data reachable on touch/keyboard

This commit is contained in:
2026-05-29 23:28:15 +02:00
parent fb9c0ad25a
commit 08f28aeb93
5 changed files with 168 additions and 36 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { useTranslations } from "next-intl";
import { useTranslations, useLocale } from "next-intl";
import { useEffect, useState, useCallback } from "react";
import { BudgetEditableCard } from "@/components/dashboard/budget-editable-card";
@@ -84,42 +84,149 @@ function formatMonth(month: string, locale: string): string {
}
function UsageChart({ data }: { data: DailyUsage[] }) {
const t = useTranslations("usage");
const locale = useLocale();
// Which day's detail is shown in the readout. Defaults to the most
// recent day; hover (mouse), tap (touch) or focus (keyboard) all
// update it. The previous version put per-day numbers only in SVG
// <title> hover tooltips, which are unreachable on touch devices and
// invisible to keyboard users — this readout fixes both.
const [selected, setSelected] = useState<number | null>(null);
if (!data.length) return null;
const maxTokens = Math.max(...data.map((d) => d.inputTokens + d.outputTokens), 1);
const maxTokens = Math.max(
...data.map((d) => d.inputTokens + d.outputTokens),
1
);
const barW = Math.max(4, Math.floor(600 / data.length) - 2);
const h = 120;
const activeIndex = selected ?? data.length - 1;
const active = data[activeIndex];
const dayLabel = (iso: string) => {
const [y, m, dd] = iso.split("-").map(Number);
return new Date(y, m - 1, dd).toLocaleDateString(locale, {
month: "short",
day: "numeric",
});
};
const barAria = (d: DailyUsage) =>
`${dayLabel(d.date)}: ${fmt(d.inputTokens)} ${t("inputTokens")}, ${fmt(
d.outputTokens
)} ${t("outputTokens")}, ${chf(d.spend)}`;
return (
<div className="overflow-x-auto">
<svg
viewBox={`0 0 ${Math.max(data.length * (barW + 2), 600)} ${h + 24}`}
className="w-full h-36"
preserveAspectRatio="xMinYMid meet"
>
{data.map((d, i) => {
const total = d.inputTokens + d.outputTokens;
const totalH = (total / maxTokens) * h;
const inputH = (d.inputTokens / maxTokens) * h;
const x = i * (barW + 2);
return (
<g key={d.date}>
<title>{d.date}: {fmt(d.inputTokens)} in / {fmt(d.outputTokens)} out {chf(d.spend)}</title>
<rect x={x} y={h - totalH} width={barW} height={totalH - inputH} rx={1} fill="var(--color-accent)" opacity={0.3} />
<rect x={x} y={h - inputH} width={barW} height={inputH} rx={1} fill="var(--color-accent)" opacity={0.7} />
{i % 7 === 0 && (
<text x={x + barW / 2} y={h + 14} textAnchor="middle" fill="var(--color-text-muted)" fontSize="8">{d.date.slice(8)}</text>
)}
</g>
);
})}
</svg>
<div>
{/* Readout — the touch/keyboard-accessible equivalent of the old
hover-only tooltip. Always reflects the active day. */}
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 mb-2 text-xs">
<span className="font-medium text-text-primary">
{dayLabel(active.date)}
</span>
<span className="text-text-secondary tabular-nums">
{fmt(active.inputTokens)} {t("inputTokens")}
</span>
<span className="text-text-secondary tabular-nums">
{fmt(active.outputTokens)} {t("outputTokens")}
</span>
<span className="text-accent tabular-nums">{chf(active.spend)}</span>
</div>
<div className="overflow-x-auto">
<svg
viewBox={`0 0 ${Math.max(data.length * (barW + 2), 600)} ${h + 24}`}
className="w-full h-36"
preserveAspectRatio="xMinYMid meet"
role="group"
aria-label={t("dailyBreakdown")}
>
{data.map((d, i) => {
const total = d.inputTokens + d.outputTokens;
const totalH = (total / maxTokens) * h;
const inputH = (d.inputTokens / maxTokens) * h;
const x = i * (barW + 2);
const isActive = i === activeIndex;
return (
<g
key={d.date}
role="button"
tabIndex={0}
aria-label={barAria(d)}
aria-pressed={isActive}
className="cursor-pointer focus:outline-none"
onClick={() => setSelected(i)}
onMouseEnter={() => setSelected(i)}
onFocus={() => setSelected(i)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelected(i);
}
}}
>
<title>{barAria(d)}</title>
{/* Full-height transparent hit area so thin bars stay
easy to tap on touch screens. */}
<rect x={x} y={0} width={barW} height={h} fill="transparent" />
<rect
x={x}
y={h - totalH}
width={barW}
height={Math.max(0, totalH - inputH)}
rx={1}
fill="var(--color-accent)"
opacity={isActive ? 0.5 : 0.3}
/>
<rect
x={x}
y={h - inputH}
width={barW}
height={inputH}
rx={1}
fill="var(--color-accent)"
opacity={isActive ? 1 : 0.7}
/>
{isActive && (
<rect
x={x - 1}
y={Math.max(0, h - totalH) - 1}
width={barW + 2}
height={Math.max(2, totalH) + 1}
rx={1.5}
fill="none"
stroke="var(--color-accent)"
strokeWidth={1}
/>
)}
{i % 7 === 0 && (
<text
x={x + barW / 2}
y={h + 14}
textAnchor="middle"
fill="var(--color-text-muted)"
fontSize="8"
>
{d.date.slice(8)}
</text>
)}
</g>
);
})}
</svg>
</div>
<div className="flex items-center gap-4 text-xs text-text-muted mt-1">
<span className="flex items-center gap-1">
<span className="inline-block h-2 w-2 rounded-sm bg-accent opacity-70" /> Input
<span className="inline-block h-2 w-2 rounded-sm bg-accent opacity-70" />{" "}
{t("legendInput")}
</span>
<span className="flex items-center gap-1">
<span className="inline-block h-2 w-2 rounded-sm bg-accent opacity-30" /> Output
<span className="inline-block h-2 w-2 rounded-sm bg-accent opacity-30" />{" "}
{t("legendOutput")}
</span>
<span className="ml-auto text-text-muted/70">{t("chartHint")}</span>
</div>
</div>
);
@@ -161,6 +268,7 @@ export function UsageDisplay({
canEditBudget?: boolean;
}) {
const t = useTranslations("usage");
const locale = useLocale();
const [month, setMonth] = useState(getCurrentMonth);
const [data, setData] = useState<UsageData | null>(null);
const [loading, setLoading] = useState(true);
@@ -202,7 +310,7 @@ export function UsageDisplay({
</button>
<span className="font-display text-sm font-medium text-text-primary">
{formatMonth(month, "en")}
{formatMonth(month, locale)}
</span>
<button
onClick={() => setMonth((m) => shiftMonth(m, 1))}