localise chart + make daily data reachable on touch/keyboard
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { BudgetEditableCard } from "@/components/dashboard/budget-editable-card";
|
import { BudgetEditableCard } from "@/components/dashboard/budget-editable-card";
|
||||||
|
|
||||||
@@ -84,42 +84,149 @@ function formatMonth(month: string, locale: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UsageChart({ data }: { data: DailyUsage[] }) {
|
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;
|
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 barW = Math.max(4, Math.floor(600 / data.length) - 2);
|
||||||
const h = 120;
|
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 (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div>
|
||||||
<svg
|
{/* Readout — the touch/keyboard-accessible equivalent of the old
|
||||||
viewBox={`0 0 ${Math.max(data.length * (barW + 2), 600)} ${h + 24}`}
|
hover-only tooltip. Always reflects the active day. */}
|
||||||
className="w-full h-36"
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 mb-2 text-xs">
|
||||||
preserveAspectRatio="xMinYMid meet"
|
<span className="font-medium text-text-primary">
|
||||||
>
|
{dayLabel(active.date)}
|
||||||
{data.map((d, i) => {
|
</span>
|
||||||
const total = d.inputTokens + d.outputTokens;
|
<span className="text-text-secondary tabular-nums">
|
||||||
const totalH = (total / maxTokens) * h;
|
{fmt(active.inputTokens)} {t("inputTokens")}
|
||||||
const inputH = (d.inputTokens / maxTokens) * h;
|
</span>
|
||||||
const x = i * (barW + 2);
|
<span className="text-text-secondary tabular-nums">
|
||||||
return (
|
{fmt(active.outputTokens)} {t("outputTokens")}
|
||||||
<g key={d.date}>
|
</span>
|
||||||
<title>{d.date}: {fmt(d.inputTokens)} in / {fmt(d.outputTokens)} out — {chf(d.spend)}</title>
|
<span className="text-accent tabular-nums">{chf(active.spend)}</span>
|
||||||
<rect x={x} y={h - totalH} width={barW} height={totalH - inputH} rx={1} fill="var(--color-accent)" opacity={0.3} />
|
</div>
|
||||||
<rect x={x} y={h - inputH} width={barW} height={inputH} rx={1} fill="var(--color-accent)" opacity={0.7} />
|
|
||||||
{i % 7 === 0 && (
|
<div className="overflow-x-auto">
|
||||||
<text x={x + barW / 2} y={h + 14} textAnchor="middle" fill="var(--color-text-muted)" fontSize="8">{d.date.slice(8)}</text>
|
<svg
|
||||||
)}
|
viewBox={`0 0 ${Math.max(data.length * (barW + 2), 600)} ${h + 24}`}
|
||||||
</g>
|
className="w-full h-36"
|
||||||
);
|
preserveAspectRatio="xMinYMid meet"
|
||||||
})}
|
role="group"
|
||||||
</svg>
|
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">
|
<div className="flex items-center gap-4 text-xs text-text-muted mt-1">
|
||||||
<span className="flex items-center gap-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>
|
||||||
<span className="flex items-center gap-1">
|
<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>
|
||||||
|
<span className="ml-auto text-text-muted/70">{t("chartHint")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -161,6 +268,7 @@ export function UsageDisplay({
|
|||||||
canEditBudget?: boolean;
|
canEditBudget?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("usage");
|
const t = useTranslations("usage");
|
||||||
|
const locale = useLocale();
|
||||||
const [month, setMonth] = useState(getCurrentMonth);
|
const [month, setMonth] = useState(getCurrentMonth);
|
||||||
const [data, setData] = useState<UsageData | null>(null);
|
const [data, setData] = useState<UsageData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -202,7 +310,7 @@ export function UsageDisplay({
|
|||||||
←
|
←
|
||||||
</button>
|
</button>
|
||||||
<span className="font-display text-sm font-medium text-text-primary">
|
<span className="font-display text-sm font-medium text-text-primary">
|
||||||
{formatMonth(month, "en")}
|
{formatMonth(month, locale)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMonth((m) => shiftMonth(m, 1))}
|
onClick={() => setMonth((m) => shiftMonth(m, 1))}
|
||||||
|
|||||||
@@ -144,7 +144,10 @@
|
|||||||
"discord": "Aktivieren Sie den Entwicklermodus in Discord (Erweiterte Einstellungen), Rechtsklick auf Ihren Namen → Benutzer-ID kopieren, und hier einfügen. Weitere Benutzer können Sie später auf der Mandantenseite hinzufügen.",
|
"discord": "Aktivieren Sie den Entwicklermodus in Discord (Erweiterte Einstellungen), Rechtsklick auf Ihren Namen → Benutzer-ID kopieren, und hier einfügen. Weitere Benutzer können Sie später auf der Mandantenseite hinzufügen.",
|
||||||
"threema": "Die 8 Zeichen, die in Ihrer Threema-App unter Einstellungen → Meine Threema-ID angezeigt werden. Sobald Ihr Mandant freigegeben ist und Threema aktiviert wurde, können Sie aus diesem Account heraus mit dem Assistenten chatten. Weitere autorisierte IDs können später auf der Mandantenseite hinzugefügt werden."
|
"threema": "Die 8 Zeichen, die in Ihrer Threema-App unter Einstellungen → Meine Threema-ID angezeigt werden. Sobald Ihr Mandant freigegeben ist und Threema aktiviert wurde, können Sie aus diesem Account heraus mit dem Assistenten chatten. Weitere autorisierte IDs können später auf der Mandantenseite hinzugefügt werden."
|
||||||
},
|
},
|
||||||
"connectCta": "Assistenten verbinden"
|
"connectCta": "Assistenten verbinden",
|
||||||
|
"packagesIncompleteHint": "Bitte ergänzen Sie die erforderlichen Angaben für: {packages}",
|
||||||
|
"setupProgress": "Einrichtungsfortschritt",
|
||||||
|
"setupStepsComplete": "{done} von {total} Schritten"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -227,7 +230,10 @@
|
|||||||
"budgetCadence_1mo": "Monatlich",
|
"budgetCadence_1mo": "Monatlich",
|
||||||
"budgetCadence_1y": "Jährlich",
|
"budgetCadence_1y": "Jährlich",
|
||||||
"budgetInvalid": "Bitte einen positiven Betrag eingeben.",
|
"budgetInvalid": "Bitte einen positiven Betrag eingeben.",
|
||||||
"budgetSaveFailed": "Budget konnte nicht gespeichert werden. Bitte erneut versuchen."
|
"budgetSaveFailed": "Budget konnte nicht gespeichert werden. Bitte erneut versuchen.",
|
||||||
|
"legendInput": "Input",
|
||||||
|
"legendOutput": "Output",
|
||||||
|
"chartHint": "Für Details auf einen Balken tippen"
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
|
|||||||
@@ -144,7 +144,10 @@
|
|||||||
"discord": "Enable Developer Mode in Discord (Advanced settings), right-click your name → Copy User ID, and paste it here. You can add more users later from the tenant page.",
|
"discord": "Enable Developer Mode in Discord (Advanced settings), right-click your name → Copy User ID, and paste it here. You can add more users later from the tenant page.",
|
||||||
"threema": "The 8 characters shown in your Threema app under Settings → My Threema ID. Once your tenant is approved and Threema is enabled, you'll be able to chat with the assistant from this account. More authorized IDs can be added later from the tenant page."
|
"threema": "The 8 characters shown in your Threema app under Settings → My Threema ID. Once your tenant is approved and Threema is enabled, you'll be able to chat with the assistant from this account. More authorized IDs can be added later from the tenant page."
|
||||||
},
|
},
|
||||||
"connectCta": "Connect your assistant"
|
"connectCta": "Connect your assistant",
|
||||||
|
"packagesIncompleteHint": "Add the required details for: {packages}",
|
||||||
|
"setupProgress": "Setup progress",
|
||||||
|
"setupStepsComplete": "{done} of {total} steps"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -227,7 +230,10 @@
|
|||||||
"budgetCadence_1mo": "Monthly",
|
"budgetCadence_1mo": "Monthly",
|
||||||
"budgetCadence_1y": "Yearly",
|
"budgetCadence_1y": "Yearly",
|
||||||
"budgetInvalid": "Please enter a positive amount.",
|
"budgetInvalid": "Please enter a positive amount.",
|
||||||
"budgetSaveFailed": "Could not save budget. Please try again."
|
"budgetSaveFailed": "Could not save budget. Please try again.",
|
||||||
|
"legendInput": "Input",
|
||||||
|
"legendOutput": "Output",
|
||||||
|
"chartHint": "Tap a bar for that day"
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
|
|||||||
@@ -144,7 +144,10 @@
|
|||||||
"discord": "Activez le mode développeur dans Discord (paramètres avancés), clic-droit sur votre nom → Copier l'ID utilisateur, puis collez-le ici. Vous pourrez ajouter d'autres utilisateurs plus tard depuis la page du tenant.",
|
"discord": "Activez le mode développeur dans Discord (paramètres avancés), clic-droit sur votre nom → Copier l'ID utilisateur, puis collez-le ici. Vous pourrez ajouter d'autres utilisateurs plus tard depuis la page du tenant.",
|
||||||
"threema": "Les 8 caractères affichés dans votre app Threema sous Réglages → Mon identifiant Threema. Une fois votre tenant approuvé et Threema activé, vous pourrez discuter avec l'assistant depuis ce compte. D'autres ID autorisés peuvent être ajoutés plus tard depuis la page du tenant."
|
"threema": "Les 8 caractères affichés dans votre app Threema sous Réglages → Mon identifiant Threema. Une fois votre tenant approuvé et Threema activé, vous pourrez discuter avec l'assistant depuis ce compte. D'autres ID autorisés peuvent être ajoutés plus tard depuis la page du tenant."
|
||||||
},
|
},
|
||||||
"connectCta": "Connecter votre assistant"
|
"connectCta": "Connecter votre assistant",
|
||||||
|
"packagesIncompleteHint": "Complétez les informations requises pour : {packages}",
|
||||||
|
"setupProgress": "Progression de la configuration",
|
||||||
|
"setupStepsComplete": "{done} sur {total} étapes"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Tableau de bord",
|
"title": "Tableau de bord",
|
||||||
@@ -227,7 +230,10 @@
|
|||||||
"budgetCadence_1mo": "Mensuelle",
|
"budgetCadence_1mo": "Mensuelle",
|
||||||
"budgetCadence_1y": "Annuelle",
|
"budgetCadence_1y": "Annuelle",
|
||||||
"budgetInvalid": "Veuillez saisir un montant positif.",
|
"budgetInvalid": "Veuillez saisir un montant positif.",
|
||||||
"budgetSaveFailed": "Impossible d'enregistrer le budget. Veuillez réessayer."
|
"budgetSaveFailed": "Impossible d'enregistrer le budget. Veuillez réessayer.",
|
||||||
|
"legendInput": "Entrée",
|
||||||
|
"legendOutput": "Sortie",
|
||||||
|
"chartHint": "Touchez une barre pour le détail"
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
|
|||||||
@@ -144,7 +144,10 @@
|
|||||||
"discord": "Attivi la Modalità sviluppatore in Discord (Impostazioni avanzate), clic destro sul suo nome → Copia ID utente, poi incolli qui. Potrà aggiungere altri utenti in seguito dalla pagina del tenant.",
|
"discord": "Attivi la Modalità sviluppatore in Discord (Impostazioni avanzate), clic destro sul suo nome → Copia ID utente, poi incolli qui. Potrà aggiungere altri utenti in seguito dalla pagina del tenant.",
|
||||||
"threema": "Gli 8 caratteri mostrati nella sua app Threema in Impostazioni → Il mio ID Threema. Una volta approvato il suo tenant e attivato Threema, potrà chattare con l'assistente da questo account. Altri ID autorizzati possono essere aggiunti in seguito dalla pagina del tenant."
|
"threema": "Gli 8 caratteri mostrati nella sua app Threema in Impostazioni → Il mio ID Threema. Una volta approvato il suo tenant e attivato Threema, potrà chattare con l'assistente da questo account. Altri ID autorizzati possono essere aggiunti in seguito dalla pagina del tenant."
|
||||||
},
|
},
|
||||||
"connectCta": "Collega il tuo assistente"
|
"connectCta": "Collega il tuo assistente",
|
||||||
|
"packagesIncompleteHint": "Completa i dettagli richiesti per: {packages}",
|
||||||
|
"setupProgress": "Avanzamento configurazione",
|
||||||
|
"setupStepsComplete": "{done} di {total} passaggi"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
@@ -227,7 +230,10 @@
|
|||||||
"budgetCadence_1mo": "Mensile",
|
"budgetCadence_1mo": "Mensile",
|
||||||
"budgetCadence_1y": "Annuale",
|
"budgetCadence_1y": "Annuale",
|
||||||
"budgetInvalid": "Inserisca un importo positivo.",
|
"budgetInvalid": "Inserisca un importo positivo.",
|
||||||
"budgetSaveFailed": "Impossibile salvare il budget. Riprova."
|
"budgetSaveFailed": "Impossibile salvare il budget. Riprova.",
|
||||||
|
"legendInput": "Input",
|
||||||
|
"legendOutput": "Output",
|
||||||
|
"chartHint": "Tocca una barra per i dettagli"
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"save": "Salvi",
|
"save": "Salvi",
|
||||||
|
|||||||
Reference in New Issue
Block a user