fix: i18n completeness + UX polish (closes #73, #74) (#75)

* fix(i18n,ux): package A — missing keys, chart labels, query invalidation, admin error UX

Addresses bugs surfaced in issues #73 and #74 plus drift found during audit.

- i18n: add 21 missing translation keys (targets.* namespace was only
  partially populated after the English-first migration; common.reset and
  settings.configured were referenced but absent), plus common helpers
  (copy, copyDetails, retry, reportIssue) and admin.settingsSaveError.
- charts: rename points7Label/30Label/90Label from cryptic "7P/30P/90P"
  to "7 pts / 30 pts / 90 pts" in EN and "7 Pkt / 30 Pkt / 90 Pkt" in DE.
- queries: add src/lib/query-keys.ts as a single source of truth with
  measurement/mood/medication-dependent key sets. Wire them into
  measurement-form/list, mood-form/list, medication-form/card,
  intake-history-list, intake-timeline, medications page, and Withings
  sync in settings. Fixes dashboard not auto-refreshing after adding a
  measurement (old code only invalidated ["measurements"], not
  ["analytics"] / ["insights"] / ["insights","targets"]).
- admin: useUpdateSettings + updateUser mutations now surface real error
  messages via sonner toasts and invoke getApiErrorMessage() so saves
  fail loudly instead of silently. Success also gets a toast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(i18n): package B — locale-aware formatters throughout

Removes the hard-coded de-DE locale that lingered from the German-first era
and makes every number, date, and time locale-aware. English stays the
default per user preference; German remains selectable.

New:
- src/lib/format-locale.ts exposes makeFormatters(locale) + a cached
  Europe/Berlin timezone. Covers number, integer, percent, date, dateShort,
  dateTime, time, dateWithWeekday, monthShort. Also exports
  parseLocaleFromAcceptLanguage() for server code.
- useFormatters() hook in @/lib/i18n/context resolves from the active
  locale, memoized per render.

Migrated (all Intl.NumberFormat("de-DE") / toLocaleString("de-DE") /
Intl.DateTimeFormat("de-DE") call sites):
- src/app/page.tsx (dashboard)
- src/app/insights/page.tsx
- src/app/admin/page.tsx
- src/app/settings/page.tsx (Withings last-sync, doctor-report caller)
- src/components/charts/trend-card.tsx (+ i18n "7d"/"30d" short labels)
- src/components/charts/health-chart.tsx
- src/components/medications/medication-card.tsx
- src/components/medications/medication-form.tsx
- src/components/measurements/measurement-list.tsx
- src/components/mood/mood-list.tsx
- src/components/insights/insight-status-card.tsx (also removed 5 German
  hard-coded strings with TODO: i18n markers — now translated)
- src/components/insights/insight-advisor-card.tsx (same — 10+ strings)

Server: reminder-check / reminder-worker / umami-test switched from
"de-DE" to neutral en-GB/en-US where the string is machine-compared rather
than user-facing. Bug-report GitHub issue titles use ISO-8601 UTC now —
the repo is English-speaking.

Next.js metadata:
- OpenGraph locale from "de_DE" → "en_US" with "de_DE" as
  alternateLocale.
- Root layout now reads healthlog-locale cookie (falls back to
  Accept-Language) and sets <html lang> server-side, avoiding an
  EN-flashes-then-DE hydration gap and fixing SEO/a11y.
- I18nProvider mirrors locale to a 1-year SameSite=Lax cookie so the next
  SSR pass is correct.

Doctor-report PDF:
- generateDoctorReportPDF(data, { t, locale }) now accepts injected
  translator + locale. All section titles, column headers, BMI / BP
  classifications, mood labels, and the footer disclaimer are
  i18n-driven. File name slug switches between gesundheitsbericht / health-report.
- 40+ new doctorReport.* keys in EN + DE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ux): package C — typed passkey errors, bug-report gating, error boundary

Addresses issue #73 points 1, 5, 6 and lays groundwork for copy-paste
error reporting requested during review.

Passkey registration / login (#73.1):
- src/lib/passkey-errors.ts maps DOMException.name + SimpleWebAuthn code
  to specific i18n keys instead of swallowing every failure as
  "cancelled". Distinguishes NotAllowed/InvalidState/NotSupported/
  Security/Abort errors and falls back to a diagnostic message.
- settings passkey + login passkey flows both use describePasskeyError
  now, so origin mismatches and previous-registration collisions show
  actionable copy.
- 5 new passkey.* i18n keys in EN + DE.

Bug report (#73.5):
- New GET /api/bugreport/status reports {configured, hasToken, hasRepo,
  isAdmin}.
- /bugreport page now queries status first. When not configured it shows
  a notice (different copy for admins vs. regular users) with a direct
  link to the admin panel. Form is hidden, not silently broken.
- When POST /api/bugreport can't create issues it returns 503 instead
  of 500 with the same user-facing message.
- 4 new bugreport.* i18n keys.

Global error UX:
- src/components/error-details.tsx — reusable panel with retry + copy
  diagnostics (message, digest, stack (trimmed), URL, user-agent,
  request-id, locale, timestamp) + report-issue link. Clipboard fallback
  prompt covers insecure-context cases.
- src/app/error.tsx uses it.
- src/app/global-error.tsx is provider-free (no i18n dependency) since
  it replaces the root layout on catastrophic failures — hardcoded
  English + Dracula palette so it still looks like HealthLog.
- 2 new errorBoundary.* keys and 4 common.* keys (copy, copyDetails,
  retry, reportIssue).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(query-keys): collapse repeated invalidation blocks into invalidateKeys helper

Eleven mutation handlers repeated `Promise.all(keys.map(k => invalidate({queryKey: k})))`
verbatim. Adds `invalidateKeys(qc, keys)` to the existing query-keys factory and
adopts it everywhere, including replacing the local `invalidateMedicationRelated`
helper that medication-form.tsx had grown. Also fixes the import-sandwich that
helper introduced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: small simplifications in i18n/UX changes

- passkey-errors: drop the typeof-name dance now that error.name is already string after the instanceof Error narrow
- doctor-report-pdf: replace nested gender ternary with a small lookup map; clarify unitFor's null-as-translation-marker convention
- dashboard page: route the analytics queries through the new queryKeys factory so future invalidation stays in one place
- health-chart: add missing fmt dependency to trendInfo useMemo (introduced when formatSigned was switched to the locale-aware formatter)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): capture upstream response body in OpenAI errors

A production insights burst at 12:19 UTC failed with "OpenAI request
failed (500)". The 500 was the upstream OpenAI HTTP status (confirmed
via logs), but we had no way to tell from logs whether it was an
OpenAI incident, a model-not-found, or a response_format rejection
because the response body was thrown away.

Now the error message includes the first 500 chars of the response body
and structured fields (httpStatus, upstream, model) that Wide Event
logging can attach. No behavior change for the happy path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): address code-review + security findings

Review round produced five real issues — fix them all, plus three nits
that were cheap enough to bundle.

Must-fix:
- security: error-details.tsx + global-error.tsx were copying
  window.location.href into the clipboard, which on OAuth callback
  routes (/api/withings/*, password reset) would leak code/state
  tokens into GitHub issues. Strip to pathname + searchParamCount.
- fe: query-keys.invalidateKeys now uses Promise.allSettled, so a
  transient invalidate failure doesn't abort the chain and no
  unhandled rejection leaks from void invalidateKeys(...) call sites.
- fe: resolveInitialLocale in app/layout.tsx wraps cookies()/headers()
  in try/catch — a dynamic-server error in the root layout now
  degrades to the "en" default instead of crashing into global-error.
- be: reminder-worker + reminder-check parseTimeToMinutes normalize
  "24:00" → "00:00" (some ICU builds emit that for midnight under
  en-GB). Prevents a 1440-minute comparison against schedule windows.
- security: openai-client error body excerpt redacts sk-* / Bearer
  tokens before logging, and moves the excerpt off Error.message into
  a structured field (bodyExcerpt) so log transports can filter it.
- i18n: /api/withings/connect German error message → English.
- i18n: /insights/page.tsx hardcoded "Fehler beim Laden" → t(...)

Hardening:
- /api/bugreport/status collapses to {configured, isAdmin} and adds
  a 30/min rate limit per user. Client interface updated.
- i18n/context.tsx: I18nProvider's initializer now reads the cookie
  (matches SSR) before falling back to localStorage or browser
  detection — eliminates the locale-flash hydration class.
- format.ts legacy helpers also read cookie first, matching SSR.

Tests:
- new src/lib/__tests__/format-locale.test.ts — 9 cases covering
  makeFormatters, parseLocaleFromAcceptLanguage, resolveIntlLocale
- new passkey-errors.test.ts — 8 cases covering every DOMException
  name and SimpleWebAuthn code we map
- new query-keys.test.ts — bundles + invalidateKeys allSettled
  semantics (incl. partial-failure test)

118 → 142 tests. All pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Marc-André Bombeck <marc@bombeck.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-04-18 17:15:43 +02:00
1 parent cc273216be
commit 9b59390224
46 files changed
+2570 -370

No files matched your search

+6 -15
View File
@@ -20,7 +20,7 @@ import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { formatDateShort } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
const TIME_RANGES_KEYS = [
{
@@ -216,6 +216,7 @@ export function HealthChart({
}: HealthChartProps) {
const { isAuthenticated, user } = useAuth();
const { t } = useTranslations();
const fmt = useFormatters();
const [rangePoints, setRangePoints] = useState(30);
const [showMA, setShowMA] = useState(false);
const [showTrend, setShowTrend] = useState(false);
@@ -490,25 +491,15 @@ export function HealthChart({
return zones;
}, [showBands, targetZones, yDomain]);
const formatAxisValue = (value: number) =>
new Intl.NumberFormat("de-DE", {
maximumFractionDigits: 0,
}).format(value);
const formatAxisValue = (value: number) => fmt.integer(value);
const formatTooltipValue = (value: number) =>
new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}).format(value);
const formatTooltipValue = (value: number) => fmt.number(value, 1);
const trendInfo = useMemo(() => {
if (!showTrend || !chartData?.length) return [];
const formatSigned = (value: number) => {
const formatted = new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}).format(Math.abs(value));
const formatted = fmt.number(Math.abs(value), 1);
return `${value > 0 ? "+" : value < 0 ? "-" : ""}${formatted}`;
};
@@ -545,7 +536,7 @@ export function HealthChart({
}`;
})
.filter((entry): entry is string => entry !== null);
}, [chartData, showTrend, types, unit, valueMode, t]);
}, [chartData, showTrend, types, unit, valueMode, t, fmt]);
if (!isLoading && !data?.length) return null;
+6 -6
View File
@@ -9,6 +9,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
interface TrendCardProps {
label: string;
@@ -37,6 +38,8 @@ export function TrendCard({
slope30,
icon: Icon,
}: TrendCardProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const TrendIcon =
slope30?.direction === "up"
? ArrowUp
@@ -53,10 +56,7 @@ export function TrendCard({
? "text-dracula-cyan"
: "text-muted-foreground";
const formatValue = (value: number) =>
new Intl.NumberFormat("de-DE", {
maximumFractionDigits: 1,
}).format(value);
const formatValue = (value: number) => fmt.number(value, 1);
return (
<div className="bg-card border-border rounded-xl border p-3">
@@ -77,7 +77,7 @@ export function TrendCard({
<div className="text-muted-foreground mt-1 flex gap-3 text-xs">
{avg7 !== null && (
<span>
7T:{" "}
{t("charts.avg7dShort")}:{" "}
{avg7Hint ? (
<Tooltip>
<TooltipTrigger asChild>
@@ -98,7 +98,7 @@ export function TrendCard({
)}
{avg30 !== null && (
<span>
30T:{" "}
{t("charts.avg30dShort")}:{" "}
{avg30Hint ? (
<Tooltip>
<TooltipTrigger asChild>
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useState } from "react";
import { Copy, CheckCircle2, RotateCcw, Bug } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
export interface ErrorDetailsProps {
/** The error object, whether it's a runtime Error or a typed API error. */
error: Error & { digest?: string };
/** Pass Next.js' `reset()` callback if available. */
reset?: () => void;
/** Request ID propagated from the server (x-request-id), if known. */
requestId?: string | null;
/** Extra fields to include in the diagnostic payload. */
context?: Record<string, unknown>;
/** Override the "Report bug" link target. */
reportHref?: string;
}
/**
* Structured error panel with a copy-to-clipboard payload that includes
* everything we'd need to reproduce or file an issue: message, stack digest,
* URL, user agent, locale, timestamp.
*/
export function ErrorDetails({
error,
reset,
requestId,
context,
reportHref = "/bugreport",
}: ErrorDetailsProps) {
const { t, locale } = useTranslations();
const [copied, setCopied] = useState(false);
async function handleCopy() {
// Only capture the pathname — some flows carry OAuth codes / state tokens
// in the query string (Withings connect, password reset) and we don't
// want users pasting those into public GitHub issues. Preserve the count
// for context without exposing values.
const loc =
typeof window !== "undefined" ? window.location : null;
const urlPath = loc?.pathname ?? null;
const searchParamCount = loc?.search
? new URLSearchParams(loc.search).toString().split("&").filter(Boolean)
.length
: 0;
const payload = {
message: error.message,
digest: error.digest,
name: error.name,
stack: error.stack?.split("\n").slice(0, 10).join("\n"),
requestId: requestId ?? null,
urlPath,
searchParamCount,
userAgent:
typeof navigator !== "undefined" ? navigator.userAgent : null,
locale,
timestamp: new Date().toISOString(),
context,
};
const text = JSON.stringify(payload, null, 2);
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback: open a prompt the user can copy from manually.
window.prompt(t("common.copyDetails"), text);
}
}
return (
<div className="mx-auto w-full max-w-xl space-y-4 p-6">
<h1 className="text-xl font-semibold">{t("errorBoundary.title")}</h1>
<p className="text-muted-foreground text-sm">
{t("errorBoundary.description")}
</p>
<div className="bg-muted/50 rounded-md border p-3 font-mono text-xs text-muted-foreground break-all">
{error.message || t("common.unknownError")}
</div>
<div className="flex flex-wrap items-center gap-2">
{reset && (
<Button onClick={reset} variant="outline" size="sm">
<RotateCcw className="mr-2 h-4 w-4" />
{t("common.retry")}
</Button>
)}
<Button onClick={handleCopy} variant="outline" size="sm">
{copied ? (
<CheckCircle2 className="mr-2 h-4 w-4 text-dracula-green" />
) : (
<Copy className="mr-2 h-4 w-4" />
)}
{copied ? t("common.copied") : t("common.copyDetails")}
</Button>
<Button asChild variant="outline" size="sm">
<Link href={reportHref}>
<Bug className="mr-2 h-4 w-4" />
{t("common.reportIssue")}
</Link>
</Button>
</div>
</div>
);
}
@@ -17,6 +17,7 @@ import {
ChevronUp,
Info,
} from "lucide-react";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
// ─── Types ────────────────────────────────────────────────
@@ -91,10 +92,10 @@ const CONFIDENCE_STYLES: Record<string, string> = {
gering: "text-dracula-orange",
};
const CONFIDENCE_LABELS: Record<string, string> = {
hoch: "Hoch",
mittel: "Mittel",
gering: "Gering",
const CONFIDENCE_LABEL_KEYS: Record<string, string> = {
hoch: "insights.confidenceHoch",
mittel: "insights.confidenceMittel",
gering: "insights.confidenceGering",
};
// ─── Section Separator ────────────────────────────────────
@@ -120,6 +121,8 @@ export function InsightAdvisorCard({
regenerating = false,
cachedAt,
}: InsightAdvisorCardProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const [dataQualityOpen, setDataQualityOpen] = useState(false);
const classStyle = insight
@@ -134,8 +137,7 @@ export function InsightAdvisorCard({
<CardContent className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-dracula-purple" />
<span className="ml-2 text-sm text-muted-foreground">
{/* TODO: i18n */}
Analyse wird erstellt...
{t("insights.generating")}
</span>
</CardContent>
</Card>
@@ -152,8 +154,9 @@ export function InsightAdvisorCard({
{icon ?? (
<Sparkles className="h-5 w-5 text-dracula-purple" />
)}
{/* TODO: i18n */}
<CardTitle className="text-lg">KI-Gesundheitsanalyse</CardTitle>
<CardTitle className="text-lg">
{t("insights.aiAnalysisTitle")}
</CardTitle>
</div>
</div>
<p className="text-sm text-muted-foreground">{title}</p>
@@ -168,8 +171,7 @@ export function InsightAdvisorCard({
{!error && (
<div className="flex flex-col items-center gap-3 py-6">
<p className="text-sm text-muted-foreground">
{/* TODO: i18n */}
Noch keine Analyse vorhanden.
{t("insights.noAnalysisYet")}
</p>
{onRegenerate && (
<Button
@@ -183,8 +185,7 @@ export function InsightAdvisorCard({
) : (
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
)}
{/* TODO: i18n */}
Analyse starten
{t("insights.startAnalysis")}
</Button>
)}
</div>
@@ -204,8 +205,9 @@ export function InsightAdvisorCard({
{icon ?? (
<Sparkles className="h-5 w-5 text-dracula-purple" />
)}
{/* TODO: i18n */}
<CardTitle className="text-lg">KI-Gesundheitsanalyse</CardTitle>
<CardTitle className="text-lg">
{t("insights.aiAnalysisTitle")}
</CardTitle>
</div>
<div className="flex items-center gap-2">
<Badge className={classStyle.badge}>
@@ -218,7 +220,7 @@ export function InsightAdvisorCard({
className="h-7 w-7"
onClick={onRegenerate}
disabled={regenerating}
title="Analyse aktualisieren" // TODO: i18n
title={t("insights.refreshAnalysis")}
>
{regenerating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
@@ -245,8 +247,7 @@ export function InsightAdvisorCard({
{insight.primaryRecommendation && (
<div className="rounded-md border-l-2 border-dracula-purple bg-dracula-purple/5 px-4 py-3">
<p className="mb-1 text-xs font-medium uppercase tracking-widest text-dracula-purple">
{/* TODO: i18n */}
Das Wichtigste
{t("insights.keyTakeaway")}
</p>
<p className="text-sm">{insight.primaryRecommendation}</p>
</div>
@@ -260,8 +261,7 @@ export function InsightAdvisorCard({
{/* Findings */}
{insight.findings.length > 0 && (
<div className="space-y-3">
{/* TODO: i18n */}
<SectionSeparator label="Befunde" />
<SectionSeparator label={t("insights.findingsTitle")} />
<div className="space-y-2">
{insight.findings.map((finding, i) => (
<div key={i}>
@@ -288,8 +288,7 @@ export function InsightAdvisorCard({
{/* Correlations */}
{insight.correlations.length > 0 && (
<div className="space-y-3">
{/* TODO: i18n */}
<SectionSeparator label="Zusammenhänge" />
<SectionSeparator label={t("insights.correlationsTitle")} />
<div className="flex flex-wrap gap-2">
{insight.correlations.map((corr, i) => (
<div
@@ -313,8 +312,7 @@ export function InsightAdvisorCard({
{/* Recommendations */}
{insight.recommendations.length > 0 && (
<div className="space-y-3">
{/* TODO: i18n */}
<SectionSeparator label="Empfehlungen" />
<SectionSeparator label={t("insights.recommendationsTitle")} />
<ol className="space-y-1.5 pl-0">
{insight.recommendations.map((rec, i) => (
<li key={i} className="flex gap-2 text-sm">
@@ -341,15 +339,17 @@ export function InsightAdvisorCard({
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
{/* TODO: i18n */}
<span>Datengrundlage</span>
<span>{t("insights.dataFoundation")}</span>
<span className="mx-1">·</span>
<span>
Konfidenz:{" "}
{t("insights.confidence")}:{" "}
<span
className={`font-medium ${CONFIDENCE_STYLES[insight.dataQuality.confidence] ?? ""}`}
>
{CONFIDENCE_LABELS[insight.dataQuality.confidence]}
{t(
CONFIDENCE_LABEL_KEYS[insight.dataQuality.confidence] ??
"insights.confidenceMittel",
)}
</span>
</span>
</button>
@@ -359,8 +359,7 @@ export function InsightAdvisorCard({
<p>{insight.dataQuality.coverage}</p>
{insight.dataQuality.gaps.length > 0 && (
<div>
{/* TODO: i18n */}
<p className="font-medium">Datenlücken:</p>
<p className="font-medium">{t("insights.dataGaps")}</p>
<ul className="mt-1 list-inside list-disc space-y-0.5">
{insight.dataQuality.gaps.map((gap, i) => (
<li key={i}>{gap}</li>
@@ -373,18 +372,9 @@ export function InsightAdvisorCard({
</div>
)}
{/* Cached timestamp */}
{cachedAt && (
<p className="text-xs text-muted-foreground">
{/* TODO: i18n */}
Zuletzt aktualisiert:{" "}
{new Date(cachedAt).toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
{t("insights.lastUpdated")}: {fmt.dateTime(cachedAt)}
</p>
)}
+19 -25
View File
@@ -2,6 +2,7 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Loader2 } from "lucide-react";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
// ─── Types ────────────────────────────────────────────────
@@ -26,22 +27,21 @@ export function InsightStatusCard({
updatedAt,
loading = false,
}: InsightStatusCardProps) {
// ── Loading State ─────────────────────────────────────
const { t } = useTranslations();
if (loading) {
return (
<Card>
<CardContent className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-dracula-purple" />
<span className="ml-2 text-sm text-muted-foreground">
{/* TODO: i18n */}
Wird geladen...
{t("common.loading")}
</span>
</CardContent>
</Card>
);
}
// ── No Provider State ─────────────────────────────────
if (!hasProvider) {
return (
<Card className="opacity-60">
@@ -53,15 +53,13 @@ export function InsightStatusCard({
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{/* TODO: i18n */}
KI-Provider nicht konfiguriert.
{t("insights.noProviderConfigured")}
</p>
</CardContent>
</Card>
);
}
// ── No Data State ─────────────────────────────────────
if (!text) {
return (
<Card>
@@ -73,15 +71,13 @@ export function InsightStatusCard({
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{/* TODO: i18n */}
Noch keine Analyse vorhanden.
{t("insights.noAnalysisYet")}
</p>
</CardContent>
</Card>
);
}
// ── Populated Card ────────────────────────────────────
return (
<Card className="animate-insight-in border-l-2 border-l-dracula-purple">
<CardHeader className="pb-2">
@@ -92,28 +88,26 @@ export function InsightStatusCard({
</div>
{cached && (
<span className="text-xs text-muted-foreground">
{/* TODO: i18n */}
Zwischengespeichert
{t("insights.cached")}
</span>
)}
</div>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-sm leading-relaxed text-muted-foreground">{text}</p>
{updatedAt && (
<p className="text-xs text-muted-foreground">
{/* TODO: i18n */}
Zuletzt aktualisiert:{" "}
{new Date(updatedAt).toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
)}
<LastUpdatedFooter updatedAt={updatedAt} />
</CardContent>
</Card>
);
}
function LastUpdatedFooter({ updatedAt }: { updatedAt: string | null }) {
const { t } = useTranslations();
const fmt = useFormatters();
if (!updatedAt) return null;
return (
<p className="text-xs text-muted-foreground">
{t("insights.lastUpdated")}: {fmt.dateTime(updatedAt)}
</p>
);
}
@@ -21,6 +21,7 @@ import {
import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
const MAX_COMMENT_LENGTH = 25;
@@ -191,7 +192,7 @@ export function MeasurementForm({
setDiaBp("");
setPulse("");
setNotes("");
await queryClient.invalidateQueries({ queryKey: ["measurements"] });
await invalidateKeys(queryClient, measurementDependentKeys);
toast.success(t("common.saved"));
onSuccess?.();
} catch {
@@ -63,7 +63,8 @@ import {
} from "lucide-react";
import { useState } from "react";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
const TYPE_LABEL_KEYS: Record<string, string> = {
WEIGHT: "measurements.typeWeight",
@@ -129,6 +130,7 @@ function toDateTimeLocalValue(isoString: string): string {
export function MeasurementList({ onEdit }: MeasurementListProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const [typeFilter, setTypeFilterRaw] = useState<string>("ALL");
@@ -192,7 +194,7 @@ export function MeasurementList({ onEdit }: MeasurementListProps) {
if (!res.ok) throw new Error("Delete failed");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["measurements"] });
void invalidateKeys(queryClient, measurementDependentKeys);
},
});
@@ -224,7 +226,7 @@ export function MeasurementList({ onEdit }: MeasurementListProps) {
}
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["measurements"] });
await invalidateKeys(queryClient, measurementDependentKeys);
setEditing(null);
setEditError(null);
},
@@ -322,7 +324,7 @@ export function MeasurementList({ onEdit }: MeasurementListProps) {
{data?.meta.total !== undefined && (
<span className="text-muted-foreground text-sm">
{t("measurements.measurementCount", {
count: data.meta.total.toLocaleString("de-DE"),
count: fmt.integer(data.meta.total),
})}
</span>
)}
@@ -1,6 +1,7 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -177,12 +178,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
if (!res.ok) throw new Error("Delete failed");
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["medications", medicationId, "intake"],
});
queryClient.invalidateQueries({
queryKey: ["medications", medicationId, "compliance"],
});
void invalidateKeys(queryClient, medicationDependentKeys);
},
});
@@ -210,12 +206,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
if (!res.ok) throw new Error(json.error ?? "Update failed");
},
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ["medications", medicationId, "intake"],
});
queryClient.invalidateQueries({
queryKey: ["medications", medicationId, "compliance"],
});
await invalidateKeys(queryClient, medicationDependentKeys);
setEditing(null);
setEditError(null);
},
@@ -248,12 +239,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
if (!res.ok) throw new Error(json.error ?? "Create failed");
},
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ["medications", medicationId, "intake"],
});
queryClient.invalidateQueries({
queryKey: ["medications", medicationId, "compliance"],
});
await invalidateKeys(queryClient, medicationDependentKeys);
setCreating(false);
setCreateError(null);
},
@@ -1,6 +1,7 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import { Badge } from "@/components/ui/badge";
import {
AlertDialog,
@@ -60,12 +61,7 @@ export function IntakeTimeline({
if (!res.ok) throw new Error("Delete failed");
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["medications", medicationId, "intake"],
});
queryClient.invalidateQueries({
queryKey: ["medications", medicationId, "compliance"],
});
void invalidateKeys(queryClient, medicationDependentKeys);
},
});
+5 -12
View File
@@ -18,7 +18,8 @@ import {
History,
} from "lucide-react";
import Link from "next/link";
import { useTranslations } from "@/lib/i18n/context";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
interface Schedule {
id: string;
@@ -189,6 +190,7 @@ function isLastIntakeInCurrentWindow(
export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
const queryClient = useQueryClient();
const { t } = useTranslations();
const fmt = useFormatters();
const [intakeLoading, setIntakeLoading] = useState<string | null>(null);
const { data: compliance } = useQuery({
@@ -230,12 +232,7 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
body: JSON.stringify({ skipped }),
});
if (res.ok) {
await queryClient.invalidateQueries({
queryKey: ["medications"],
});
await queryClient.invalidateQueries({
queryKey: ["gamification", "achievements"],
});
await invalidateKeys(queryClient, medicationDependentKeys);
}
} finally {
setIntakeLoading(null);
@@ -462,11 +459,7 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
];
dayLabel = weekdayLabels[nextDate.getDay()];
} else {
dayLabel = nextDate.toLocaleDateString("de-DE", {
weekday: "short",
day: "numeric",
month: "short",
});
dayLabel = fmt.dateWithWeekday(nextDate);
}
}
+13 -12
View File
@@ -48,7 +48,8 @@ import {
} from "lucide-react";
import { formatTimeWindowRange } from "@/lib/time-window-format";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import { PhaseConfigDialog } from "@/components/medications/phase-config-dialog";
// DOSE_UNITS built dynamically via t() in the component
@@ -126,7 +127,11 @@ function formatRecurrenceSummary(schedule: Schedule, t: TranslateFn): string {
return `${t("medications.scheduleIntervalPrefix")} ${dayText} - ${intervalText}`;
}
function formatNextWindowSummary(schedule: Schedule, t: TranslateFn): string {
function formatNextWindowSummary(
schedule: Schedule,
t: TranslateFn,
formatShortDate: (date: Date) => string,
): string {
const isSimpleDaily =
schedule.daysOfWeek.length === 0 && schedule.intervalWeeks === 1;
if (isSimpleDaily) {
@@ -135,12 +140,7 @@ function formatNextWindowSummary(schedule: Schedule, t: TranslateFn): string {
const nextDate = getNextRecurrenceDate(schedule);
const nextText = nextDate
? `${new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
}).format(
nextDate,
)}, ${formatTimeWindowRange(schedule.windowStart, schedule.windowEnd)}`
? `${formatShortDate(nextDate)}, ${formatTimeWindowRange(schedule.windowStart, schedule.windowEnd)}`
: "—";
return `${t("medications.nextSchedulePrefix")} ${nextText}`;
}
@@ -235,6 +235,7 @@ export function MedicationForm({
}: MedicationFormProps) {
const queryClient = useQueryClient();
const { t } = useTranslations();
const fmt = useFormatters();
const doseUnits = [
"mg", "g", "ml",
@@ -385,7 +386,7 @@ export function MedicationForm({
return;
}
await queryClient.invalidateQueries({ queryKey: ["medications"] });
await invalidateKeys(queryClient, medicationDependentKeys);
toast.success(t("common.saved"));
onSuccess?.();
} catch {
@@ -409,7 +410,7 @@ export function MedicationForm({
return;
}
await queryClient.invalidateQueries({ queryKey: ["medications"] });
await invalidateKeys(queryClient, medicationDependentKeys);
onSuccess?.();
} catch {
setError(t("medications.deleteError"));
@@ -432,7 +433,7 @@ export function MedicationForm({
return;
}
await queryClient.invalidateQueries({ queryKey: ["medications"] });
await invalidateKeys(queryClient, medicationDependentKeys);
setPurgeDialogOpen(false);
} catch {
setError(t("medications.purgeError"));
@@ -561,7 +562,7 @@ export function MedicationForm({
`${t("medications.formSchedule")} ${i + 1}`}
</span>{" "}
<span className="text-foreground/70 font-normal">
({formatNextWindowSummary(s, t)})
({formatNextWindowSummary(s, t, fmt.dateShort)})
</span>
</p>
<DropdownMenu>
+2 -1
View File
@@ -14,6 +14,7 @@ import { Label } from "@/components/ui/label";
import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, moodDependentKeys } from "@/lib/query-keys";
const MOOD_LEVELS = [
{ value: "SUPER_GUT", score: 5, labelKey: "mood.levelSuperGut" },
@@ -83,7 +84,7 @@ export function MoodForm({ onSuccess, onCancel }: MoodFormProps) {
}
resetForm();
await queryClient.invalidateQueries({ queryKey: ["mood-entries"] });
await invalidateKeys(queryClient, moodDependentKeys);
toast.success(t("common.saved"));
onSuccess?.();
} catch {
+6 -4
View File
@@ -57,7 +57,8 @@ import {
} from "lucide-react";
import { useState } from "react";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { invalidateKeys, moodDependentKeys } from "@/lib/query-keys";
const MOOD_SCORES: Record<string, number> = {
SUPER_GUT: 5,
@@ -105,6 +106,7 @@ function toDateTimeLocalValue(isoString: string): string {
export function MoodList() {
const { t } = useTranslations();
const fmt = useFormatters();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const [moodFilter, setMoodFilterRaw] = useState<string>("ALL");
@@ -168,7 +170,7 @@ export function MoodList() {
if (!res.ok) throw new Error("Delete failed");
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["mood-entries"] });
void invalidateKeys(queryClient, moodDependentKeys);
},
});
@@ -196,7 +198,7 @@ export function MoodList() {
}
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["mood-entries"] });
await invalidateKeys(queryClient, moodDependentKeys);
setEditing(null);
setEditError(null);
},
@@ -284,7 +286,7 @@ export function MoodList() {
{data?.meta.total !== undefined && (
<span className="text-muted-foreground text-sm">
{t("mood.entryCount", {
count: data.meta.total.toLocaleString("de-DE"),
count: fmt.integer(data.meta.total),
})}
</span>
)}