localise chart + make daily data reachable on touch/keyboard
This commit is contained in:
@@ -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))}
|
||||
|
||||
Reference in New Issue
Block a user