mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
* 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:
46 files changed
+2570
-370
No files matched your search
+23
-3
@@ -48,7 +48,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { PasswordStrength } from "@/components/ui/password-strength";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function PasswordInput(props: React.ComponentProps<typeof Input>) {
|
||||
@@ -203,6 +203,7 @@ function useAdminSettings() {
|
||||
|
||||
function useUpdateSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
return useMutation({
|
||||
mutationFn: async (data: Record<string, unknown>) => {
|
||||
const res = await fetch("/api/admin/settings", {
|
||||
@@ -210,10 +211,20 @@ function useUpdateSettings() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
if (!res.ok) {
|
||||
throw new Error(await getApiErrorMessage(res));
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "settings"] });
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.settingsSaveError"),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -235,6 +246,7 @@ async function getApiErrorMessage(response: Response): Promise<string> {
|
||||
|
||||
function SystemStatusSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ["admin", "status"],
|
||||
@@ -283,7 +295,7 @@ function SystemStatusSection({ id }: { id: string }) {
|
||||
<StatusItem
|
||||
icon={Activity}
|
||||
label={t("admin.measurementsCount")}
|
||||
value={status.counts.measurements.toLocaleString("de-DE")}
|
||||
value={fmt.integer(status.counts.measurements)}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Key}
|
||||
@@ -1875,6 +1887,14 @@ function UserManagementSection({
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
|
||||
setEditingUser(null);
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.settingsSaveError"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
website: settings.umamiWebsiteId,
|
||||
hostname: appUrl.hostname,
|
||||
screen: "1920x1080",
|
||||
language: "de-DE",
|
||||
language: "en-US",
|
||||
title: "HealthLog Monitoring Test",
|
||||
url: "/admin",
|
||||
referrer: "",
|
||||
|
||||
@@ -11,7 +11,9 @@ export const dynamic = "force-dynamic";
|
||||
function parseTimeToMinutes(value: string): number {
|
||||
const [h, m] = value.split(":").map(Number);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(m)) return 0;
|
||||
return h * 60 + m;
|
||||
// Normalize "24:00" → "00:00" (some ICU builds emit that for midnight).
|
||||
const hours = h === 24 ? 0 : h;
|
||||
return hours * 60 + m;
|
||||
}
|
||||
|
||||
const dayLabels = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
|
||||
@@ -71,7 +73,7 @@ export const POST = apiHandler(async () => {
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
const currentTime = now.toLocaleTimeString("de-DE", {
|
||||
const currentTime = now.toLocaleTimeString("en-GB", {
|
||||
timeZone: userTz,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
|
||||
@@ -47,9 +47,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const ghRepo = appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO; // e.g. "owner/repo"
|
||||
|
||||
if (!ghToken || !ghRepo) {
|
||||
// 503 instead of 500: the client keys off status to render the "not
|
||||
// configured" screen. The user also sees a clear message rather than
|
||||
// "Internal server error".
|
||||
return apiError(
|
||||
"Bug report not configured (GitHub issue token/repository missing in admin settings)",
|
||||
500,
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,9 +66,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const { description, screenshot } = parsed.data;
|
||||
|
||||
const dateStr = new Date().toLocaleString("de-DE", {
|
||||
timeZone: "Europe/Berlin",
|
||||
});
|
||||
// GitHub issues are maintainer-facing and the repo's primary language is English.
|
||||
// Use an ISO-8601 timestamp so titles are unambiguous and sortable.
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().replace("T", " ").slice(0, 16) + " UTC";
|
||||
const title = `Bug Report – ${dateStr}`;
|
||||
|
||||
// Sanitize user-provided content for GitHub markdown
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Returns whether bug report submission is configured (GitHub token + repo).
|
||||
*
|
||||
* The bug report page uses this to gate its UI so users don't hit a silent
|
||||
* 500 when the admin hasn't set up the GitHub integration. Only two booleans
|
||||
* escape: a single `configured` flag and `isAdmin` so the page can render
|
||||
* the admin-specific copy + admin link. Token/repo granularity is kept
|
||||
* server-side (log annotation only) so we don't hand regular users a probe.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Light rate limit — the endpoint is cheap, but no reason to let a logged-in
|
||||
// client hammer Postgres in a loop.
|
||||
const rl = await checkRateLimit(
|
||||
`bugreport-status:${user.id}`,
|
||||
30,
|
||||
60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Rate limit exceeded", 429);
|
||||
}
|
||||
|
||||
const appSettings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { githubIssueTokenEncrypted: true, githubIssueRepo: true },
|
||||
});
|
||||
|
||||
const hasToken = Boolean(
|
||||
appSettings?.githubIssueTokenEncrypted || process.env.GITHUB_ISSUE_TOKEN,
|
||||
);
|
||||
const hasRepo = Boolean(
|
||||
appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO,
|
||||
);
|
||||
const configured = hasToken && hasRepo;
|
||||
|
||||
annotate({
|
||||
action: { name: "bugreport.status" },
|
||||
meta: { configured, has_token: hasToken, has_repo: hasRepo },
|
||||
});
|
||||
|
||||
return apiSuccess({ configured, isAdmin: user.role === "ADMIN" });
|
||||
});
|
||||
@@ -17,7 +17,7 @@ export const GET = apiHandler(async () => {
|
||||
const creds = await getUserWithingsCredentials(user.id);
|
||||
if (!creds) {
|
||||
return apiError(
|
||||
"Bitte zuerst Withings Client-ID und Client-Secret in den Einstellungen hinterlegen.",
|
||||
"Please configure your Withings Client ID and Client Secret in Settings first.",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export default function LoginPage() {
|
||||
@@ -81,8 +82,9 @@ export default function LoginPage() {
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||
router.push(getRedirectTarget());
|
||||
} catch {
|
||||
setError(t("auth.passkeyFailed"));
|
||||
} catch (err) {
|
||||
const { key, params } = describePasskeyError(err);
|
||||
setError(t(key, params));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
+81
-11
@@ -1,14 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2, Upload, X, CheckCircle2, ImageIcon, Bug } from "lucide-react";
|
||||
import {
|
||||
Loader2,
|
||||
Upload,
|
||||
X,
|
||||
CheckCircle2,
|
||||
ImageIcon,
|
||||
Bug,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
interface BugReportStatus {
|
||||
configured: boolean;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export default function BugReportPage() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const [description, setDescription] = useState("");
|
||||
const [screenshot, setScreenshot] = useState<string | null>(null);
|
||||
const [screenshotName, setScreenshotName] = useState<string | null>(null);
|
||||
@@ -18,7 +35,17 @@ export default function BugReportPage() {
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data: status, isLoading: statusLoading } = useQuery({
|
||||
queryKey: queryKeys.bugreportStatus(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/bugreport/status");
|
||||
if (!res.ok) throw new Error("Failed to load status");
|
||||
const json = await res.json();
|
||||
return json.data as BugReportStatus;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
async function handleScreenshot(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -30,10 +57,7 @@ export default function BugReportPage() {
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setResult({
|
||||
type: "error",
|
||||
message: t("bugreport.tooLarge"),
|
||||
});
|
||||
setResult({ type: "error", message: t("bugreport.tooLarge") });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,10 +92,7 @@ export default function BugReportPage() {
|
||||
|
||||
const json = await res.json();
|
||||
if (res.ok) {
|
||||
setResult({
|
||||
type: "success",
|
||||
message: t("bugreport.success"),
|
||||
});
|
||||
setResult({ type: "success", message: t("bugreport.success") });
|
||||
setDescription("");
|
||||
removeScreenshot();
|
||||
} else {
|
||||
@@ -100,6 +121,56 @@ export default function BugReportPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (statusLoading) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl space-y-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("bugreport.title")}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">{t("common.loading")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status && !status.configured) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("bugreport.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("bugreport.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border-border flex gap-3 rounded-xl border-l-4 border-l-orange-500 p-5">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-orange-500" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">
|
||||
{t("bugreport.notConfiguredTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{status.isAdmin
|
||||
? t("bugreport.notConfiguredAdmin")
|
||||
: t("bugreport.notConfiguredUser")}
|
||||
</p>
|
||||
{status.isAdmin && (
|
||||
<Button asChild size="sm" variant="outline" className="mt-2">
|
||||
<Link href="/admin#bug-reports">
|
||||
{t("bugreport.openAdminSettings")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl space-y-6">
|
||||
<div>
|
||||
@@ -119,7 +190,6 @@ export default function BugReportPage() {
|
||||
id="bug-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder=""
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={5000}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { ErrorDetails } from "@/components/error-details";
|
||||
|
||||
export default function AppError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Surface to any client-side error tracker that's already hooked up.
|
||||
if (typeof window !== "undefined") {
|
||||
const g = window as typeof window & {
|
||||
__healthlog_onError?: (err: Error & { digest?: string }) => void;
|
||||
};
|
||||
g.__healthlog_onError?.(error);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return <ErrorDetails error={error} reset={reset} />;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Root-level error boundary. Renders when even the root layout itself fails,
|
||||
* so it cannot rely on any providers (i18n, query, auth). Keep it static,
|
||||
* in English, and self-contained.
|
||||
*/
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
async function handleCopy() {
|
||||
// Strip query string — OAuth callback routes carry sensitive tokens.
|
||||
const loc =
|
||||
typeof window !== "undefined" ? window.location : null;
|
||||
const payload = {
|
||||
message: error.message,
|
||||
digest: error.digest,
|
||||
name: error.name,
|
||||
stack: error.stack?.split("\n").slice(0, 10).join("\n"),
|
||||
urlPath: loc?.pathname ?? null,
|
||||
userAgent:
|
||||
typeof navigator !== "undefined" ? navigator.userAgent : null,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
const text = JSON.stringify(payload, null, 2);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
window.prompt("Copy details", text);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
style={{
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
backgroundColor: "#1e1f29",
|
||||
color: "#f8f8f2",
|
||||
margin: 0,
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "24px",
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 560 }}>
|
||||
<h1 style={{ fontSize: 20, marginBottom: 12 }}>
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p style={{ color: "#d0d0d0", marginBottom: 16, fontSize: 14 }}>
|
||||
A critical error occurred. You can retry or copy the details for
|
||||
support.
|
||||
</p>
|
||||
<pre
|
||||
style={{
|
||||
background: "#282a36",
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
overflow: "auto",
|
||||
marginBottom: 16,
|
||||
border: "1px solid #44475a",
|
||||
}}
|
||||
>
|
||||
{error.message || "Unknown error"}
|
||||
</pre>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<button
|
||||
onClick={reset}
|
||||
style={{
|
||||
background: "#bd93f9",
|
||||
color: "#282a36",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
padding: "8px 14px",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
style={{
|
||||
background: "transparent",
|
||||
color: "#f8f8f2",
|
||||
border: "1px solid #44475a",
|
||||
borderRadius: 6,
|
||||
padding: "8px 14px",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Copy details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
+13
-15
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -222,15 +222,12 @@ function getRangeHint(
|
||||
unit: string,
|
||||
config: RangeDisplayConfig,
|
||||
t: (key: string) => string,
|
||||
formatNumber: (value: number, fractionDigits?: number) => string,
|
||||
): React.ReactNode | undefined {
|
||||
const range = config.range;
|
||||
if (!range) return undefined;
|
||||
|
||||
const format = (value: number) =>
|
||||
new Intl.NumberFormat("de-DE", {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
const format = (value: number) => formatNumber(value, 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -496,6 +493,7 @@ function getMedicationComplianceSectionStatus(input: {
|
||||
export default function InsightsPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const STRENGTH_LABELS: Record<string, string> = {
|
||||
stark: t("insights.strengthStrong"),
|
||||
@@ -508,7 +506,7 @@ export default function InsightsPage() {
|
||||
queryKey: ["insights", "comprehensive"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/insights/comprehensive");
|
||||
if (!res.ok) throw new Error("Fehler beim Laden");
|
||||
if (!res.ok) throw new Error(t("insights.loadError"));
|
||||
const json = await res.json();
|
||||
return json.data as ComprehensiveData;
|
||||
},
|
||||
@@ -838,8 +836,8 @@ export default function InsightsPage() {
|
||||
avg30={w?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(w?.avg7, { range: weightRange })}
|
||||
avg30ColorClass={getRangeColorClass(w?.avg30, { range: weightRange })}
|
||||
avg7Hint={getRangeHint("kg", { range: weightRange }, t)}
|
||||
avg30Hint={getRangeHint("kg", { range: weightRange }, t)}
|
||||
avg7Hint={getRangeHint("kg", { range: weightRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("kg", { range: weightRange }, t, fmt.number)}
|
||||
slope30={w?.slope30 ?? null}
|
||||
icon={Activity}
|
||||
/>
|
||||
@@ -853,8 +851,8 @@ export default function InsightsPage() {
|
||||
avg30ColorClass={getRangeColorClass(sys?.avg30, {
|
||||
range: bpSysRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpSysRange }, t)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpSysRange }, t)}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpSysRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpSysRange }, t, fmt.number)}
|
||||
slope30={sys?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
/>
|
||||
@@ -868,8 +866,8 @@ export default function InsightsPage() {
|
||||
avg30ColorClass={getRangeColorClass(dia?.avg30, {
|
||||
range: bpDiaRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpDiaRange }, t)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpDiaRange }, t)}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpDiaRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpDiaRange }, t, fmt.number)}
|
||||
slope30={dia?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
/>
|
||||
@@ -885,8 +883,8 @@ export default function InsightsPage() {
|
||||
avg30ColorClass={getRangeColorClass(p?.avg30, {
|
||||
range: pulseDisplayRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t)}
|
||||
avg30Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t)}
|
||||
avg7Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t, fmt.number)}
|
||||
slope30={p?.slope30 ?? null}
|
||||
icon={TrendingUp}
|
||||
/>
|
||||
|
||||
+25
-3
@@ -1,10 +1,29 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
import { headers, cookies } from "next/headers";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/providers";
|
||||
import { AuthShell } from "@/components/layout/auth-shell";
|
||||
import { MonitoringBootstrap } from "@/components/monitoring/bootstrap";
|
||||
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
|
||||
async function resolveInitialLocale(): Promise<Locale> {
|
||||
// Both cookies() and headers() can throw (DynamicServerError, etc.) —
|
||||
// fall back to the default so a locale hiccup never crashes the root
|
||||
// layout into global-error.tsx.
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const cookieLocale = cookieStore.get("healthlog-locale")?.value;
|
||||
if (cookieLocale && (locales as readonly string[]).includes(cookieLocale)) {
|
||||
return cookieLocale as Locale;
|
||||
}
|
||||
const headerList = await headers();
|
||||
return parseLocaleFromAcceptLanguage(headerList.get("accept-language"));
|
||||
} catch {
|
||||
return "en";
|
||||
}
|
||||
}
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-sans",
|
||||
@@ -31,7 +50,8 @@ export const metadata: Metadata = {
|
||||
description:
|
||||
"Personal health tracking — weight, blood pressure, pulse, mood, medication compliance",
|
||||
type: "website",
|
||||
locale: "de_DE",
|
||||
locale: "en_US",
|
||||
alternateLocale: ["de_DE"],
|
||||
siteName: "HealthLog",
|
||||
},
|
||||
twitter: {
|
||||
@@ -62,8 +82,10 @@ export default async function RootLayout({
|
||||
? ((await headers()).get("x-nonce") ?? undefined)
|
||||
: undefined;
|
||||
|
||||
const initialLocale = await resolveInitialLocale();
|
||||
|
||||
return (
|
||||
<html lang="de" suppressHydrationWarning>
|
||||
<html lang={initialLocale} suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
suppressHydrationWarning
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { MedicationForm } from "@/components/medications/medication-form";
|
||||
import { MedicationCard } from "@/components/medications/medication-card";
|
||||
@@ -366,7 +367,7 @@ function IntakeImportDialog({
|
||||
: ""),
|
||||
);
|
||||
setResultType("success");
|
||||
queryClient.invalidateQueries({ queryKey: ["medications"] });
|
||||
void invalidateKeys(queryClient, medicationDependentKeys);
|
||||
} else {
|
||||
setResult(json.error || t("medications.importFailed"));
|
||||
setResultType("error");
|
||||
|
||||
+16
-17
@@ -36,7 +36,8 @@ const MoodChart = dynamic(
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import {
|
||||
@@ -66,7 +67,7 @@ function getHourForTimeZone(timeZone?: string): number {
|
||||
if (!timeZone) return now.getHours();
|
||||
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("de-DE", {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
hour: "2-digit",
|
||||
hour12: false,
|
||||
timeZone,
|
||||
@@ -98,15 +99,12 @@ function getRangeHint(
|
||||
unit: string,
|
||||
config: RangeDisplayConfig,
|
||||
t: (key: string) => string,
|
||||
formatNumber: (value: number, fractionDigits?: number) => string,
|
||||
): React.ReactNode | undefined {
|
||||
const range = config.range;
|
||||
if (!range) return undefined;
|
||||
|
||||
const format = (value: number) =>
|
||||
new Intl.NumberFormat("de-DE", {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
const format = (value: number) => formatNumber(value, 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -136,10 +134,11 @@ function getRangeHint(
|
||||
export default function DashboardPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [quickEntryDialog, setQuickEntryDialog] = useState<"measurement" | "mood" | null>(null);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["analytics"],
|
||||
queryKey: queryKeys.analytics(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/analytics");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
@@ -150,7 +149,7 @@ export default function DashboardPage() {
|
||||
});
|
||||
|
||||
const { data: moodData } = useQuery({
|
||||
queryKey: ["mood-analytics"],
|
||||
queryKey: queryKeys.moodAnalytics(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/mood/analytics");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
@@ -334,8 +333,8 @@ export default function DashboardPage() {
|
||||
avg30={w?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(w?.avg7, { range: weightRange })}
|
||||
avg30ColorClass={getRangeColorClass(w?.avg30, { range: weightRange })}
|
||||
avg7Hint={getRangeHint("kg", { range: weightRange }, t)}
|
||||
avg30Hint={getRangeHint("kg", { range: weightRange }, t)}
|
||||
avg7Hint={getRangeHint("kg", { range: weightRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("kg", { range: weightRange }, t, fmt.number)}
|
||||
slope30={w?.slope30 ?? null}
|
||||
icon={Activity}
|
||||
/>
|
||||
@@ -349,8 +348,8 @@ export default function DashboardPage() {
|
||||
avg30ColorClass={getRangeColorClass(sys?.avg30, {
|
||||
range: bpSysRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpSysRange }, t)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpSysRange }, t)}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpSysRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpSysRange }, t, fmt.number)}
|
||||
slope30={sys?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
/>
|
||||
@@ -364,8 +363,8 @@ export default function DashboardPage() {
|
||||
avg30ColorClass={getRangeColorClass(dia?.avg30, {
|
||||
range: bpDiaRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpDiaRange }, t)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpDiaRange }, t)}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpDiaRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpDiaRange }, t, fmt.number)}
|
||||
slope30={dia?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
/>
|
||||
@@ -381,8 +380,8 @@ export default function DashboardPage() {
|
||||
avg30ColorClass={getRangeColorClass(p?.avg30, {
|
||||
range: pulseDisplayRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t)}
|
||||
avg30Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t)}
|
||||
avg7Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t, fmt.number)}
|
||||
slope30={p?.slope30 ?? null}
|
||||
icon={TrendingUp}
|
||||
/>
|
||||
|
||||
@@ -54,6 +54,8 @@ import {
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
|
||||
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
|
||||
function PasswordInput(props: React.ComponentProps<typeof Input>) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
@@ -208,8 +210,9 @@ export default function SettingsPage() {
|
||||
);
|
||||
setPasskeyMsgType("error");
|
||||
}
|
||||
} catch {
|
||||
setPasskeyMsg(t("settings.passkeyRegistrationCancelled"));
|
||||
} catch (err) {
|
||||
const { key, params } = describePasskeyError(err);
|
||||
setPasskeyMsg(t(key, params));
|
||||
setPasskeyMsgType("error");
|
||||
} finally {
|
||||
setPasskeyLoading(false);
|
||||
@@ -891,7 +894,7 @@ function WithingsSection({
|
||||
: t("settings.withingsSyncResult", { count: json.data.imported }),
|
||||
);
|
||||
setSyncMsgType("success");
|
||||
queryClient.invalidateQueries({ queryKey: ["measurements"] });
|
||||
void invalidateKeys(queryClient, measurementDependentKeys);
|
||||
} else {
|
||||
setSyncMsg(json.error || t("settings.withingsSyncFailed"));
|
||||
setSyncMsgType("error");
|
||||
@@ -1535,7 +1538,7 @@ function ApiTokensSection({
|
||||
/* ─────────────────────── Export Section ─────────────────────── */
|
||||
|
||||
function ExportSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [generatingReport, setGeneratingReport] = useState(false);
|
||||
|
||||
@@ -1573,10 +1576,9 @@ function ExportSection({ id }: { id: string }) {
|
||||
const { generateDoctorReportPDF } = await import(
|
||||
"@/lib/doctor-report-pdf"
|
||||
);
|
||||
const doc = generateDoctorReportPDF(json.data);
|
||||
doc.save(
|
||||
`gesundheitsbericht-${new Date().toISOString().slice(0, 10)}.pdf`,
|
||||
);
|
||||
const doc = generateDoctorReportPDF(json.data, { t, locale });
|
||||
const fileSlug = locale === "de" ? "gesundheitsbericht" : "health-report";
|
||||
doc.save(`${fileSlug}-${new Date().toISOString().slice(0, 10)}.pdf`);
|
||||
} finally {
|
||||
setGeneratingReport(false);
|
||||
}
|
||||
@@ -2536,7 +2538,7 @@ function MoodLogSection({ t }: { t: (key: string) => string }) {
|
||||
{status.lastSyncedAt && (
|
||||
<span>
|
||||
{t("settings.moodLogLastSync")}:{" "}
|
||||
{new Date(status.lastSyncedAt).toLocaleString("de-DE")}
|
||||
{formatDateTime(status.lastSyncedAt)}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
makeFormatters,
|
||||
parseLocaleFromAcceptLanguage,
|
||||
resolveIntlLocale,
|
||||
} from "../format-locale";
|
||||
|
||||
describe("resolveIntlLocale", () => {
|
||||
it("maps short locales to BCP-47 tags", () => {
|
||||
expect(resolveIntlLocale("de")).toBe("de-DE");
|
||||
expect(resolveIntlLocale("en")).toBe("en-US");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLocaleFromAcceptLanguage", () => {
|
||||
it("returns en for null / empty", () => {
|
||||
expect(parseLocaleFromAcceptLanguage(null)).toBe("en");
|
||||
expect(parseLocaleFromAcceptLanguage("")).toBe("en");
|
||||
});
|
||||
|
||||
it("returns de for primary German tag", () => {
|
||||
expect(parseLocaleFromAcceptLanguage("de-DE,en;q=0.9")).toBe("de");
|
||||
expect(parseLocaleFromAcceptLanguage("de")).toBe("de");
|
||||
expect(parseLocaleFromAcceptLanguage("de-CH")).toBe("de");
|
||||
});
|
||||
|
||||
it("returns en for English or unknown locales", () => {
|
||||
expect(parseLocaleFromAcceptLanguage("en-US,en;q=0.9")).toBe("en");
|
||||
expect(parseLocaleFromAcceptLanguage("fr-FR,fr;q=0.9")).toBe("en");
|
||||
expect(parseLocaleFromAcceptLanguage("*")).toBe("en");
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeFormatters", () => {
|
||||
const deFmt = makeFormatters("de");
|
||||
const enFmt = makeFormatters("en");
|
||||
const sample = new Date("2026-04-18T14:30:00Z"); // 16:30 Europe/Berlin (CEST)
|
||||
|
||||
it("formats numbers with regional decimal separators", () => {
|
||||
expect(deFmt.number(70.5, 1)).toMatch(/70,5/);
|
||||
expect(enFmt.number(70.5, 1)).toMatch(/70\.5/);
|
||||
});
|
||||
|
||||
it("formats integers with thousands separators", () => {
|
||||
expect(deFmt.integer(1234567)).toMatch(/1[.\u202f]234[.\u202f]567/);
|
||||
expect(enFmt.integer(1234567)).toBe("1,234,567");
|
||||
});
|
||||
|
||||
it("formats percent", () => {
|
||||
expect(deFmt.percent(0.835, 1)).toMatch(/83,5\s*%/);
|
||||
expect(enFmt.percent(0.835, 1)).toMatch(/83\.5%/);
|
||||
});
|
||||
|
||||
it("formats date in Europe/Berlin", () => {
|
||||
// Both should include day/month/year; locale-specific order differs.
|
||||
expect(deFmt.date(sample)).toContain("2026");
|
||||
expect(enFmt.date(sample)).toContain("2026");
|
||||
});
|
||||
|
||||
it("formats 24h time in Europe/Berlin regardless of locale", () => {
|
||||
expect(deFmt.time(sample)).toBe("16:30");
|
||||
expect(enFmt.time(sample)).toBe("16:30");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describePasskeyError } from "../passkey-errors";
|
||||
|
||||
function domException(name: string, message = ""): Error {
|
||||
const err = new Error(message);
|
||||
err.name = name;
|
||||
return err;
|
||||
}
|
||||
|
||||
describe("describePasskeyError", () => {
|
||||
it("returns generic failure for non-Error inputs", () => {
|
||||
expect(describePasskeyError(undefined)).toEqual({
|
||||
key: "settings.passkeyRegistrationFailed",
|
||||
});
|
||||
expect(describePasskeyError("just a string")).toEqual({
|
||||
key: "settings.passkeyRegistrationFailed",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps NotAllowedError to cancelled (the common cancel/timeout case)", () => {
|
||||
expect(describePasskeyError(domException("NotAllowedError"))).toEqual({
|
||||
key: "settings.passkeyRegistrationCancelled",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps InvalidStateError to already-registered", () => {
|
||||
expect(describePasskeyError(domException("InvalidStateError"))).toEqual({
|
||||
key: "settings.passkeyAlreadyRegistered",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps NotSupportedError to not-supported", () => {
|
||||
expect(describePasskeyError(domException("NotSupportedError"))).toEqual({
|
||||
key: "settings.passkeyNotSupported",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps SecurityError to security-blocked", () => {
|
||||
expect(describePasskeyError(domException("SecurityError"))).toEqual({
|
||||
key: "settings.passkeySecurityBlocked",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps AbortError to timeout", () => {
|
||||
expect(describePasskeyError(domException("AbortError"))).toEqual({
|
||||
key: "settings.passkeyTimeout",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps SimpleWebAuthn ERROR_INVALID_RP_ID to security-blocked", () => {
|
||||
const err = new Error("bad rp");
|
||||
(err as Error & { code?: string }).code = "ERROR_INVALID_RP_ID";
|
||||
expect(describePasskeyError(err)).toEqual({
|
||||
key: "settings.passkeySecurityBlocked",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to unknown with a message param", () => {
|
||||
const result = describePasskeyError(new Error("mystery failure"));
|
||||
expect(result.key).toBe("settings.passkeyUnknownError");
|
||||
expect(result.params).toEqual({ message: "mystery failure" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
queryKeys,
|
||||
measurementDependentKeys,
|
||||
moodDependentKeys,
|
||||
medicationDependentKeys,
|
||||
invalidateKeys,
|
||||
} from "../query-keys";
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
|
||||
describe("queryKeys factory", () => {
|
||||
it("returns stable tuples for simple keys", () => {
|
||||
expect(queryKeys.measurements()).toEqual(["measurements"]);
|
||||
expect(queryKeys.analytics()).toEqual(["analytics"]);
|
||||
expect(queryKeys.moodEntries()).toEqual(["mood-entries"]);
|
||||
});
|
||||
|
||||
it("includes locale in insights status keys", () => {
|
||||
expect(queryKeys.insightsGeneralStatus("en")).toEqual([
|
||||
"insights",
|
||||
"general-status",
|
||||
"en",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dependent-key bundles", () => {
|
||||
it("measurementDependentKeys invalidates analytics/insights/targets", () => {
|
||||
const keyStrings = measurementDependentKeys.map((k) =>
|
||||
JSON.stringify(k),
|
||||
);
|
||||
expect(keyStrings).toContain(JSON.stringify(["measurements"]));
|
||||
expect(keyStrings).toContain(JSON.stringify(["analytics"]));
|
||||
expect(keyStrings).toContain(JSON.stringify(["insights"]));
|
||||
expect(keyStrings).toContain(JSON.stringify(["insights", "targets"]));
|
||||
expect(keyStrings).toContain(
|
||||
JSON.stringify(["gamification", "achievements"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("moodDependentKeys bundle covers mood + analytics + targets", () => {
|
||||
const keyStrings = moodDependentKeys.map((k) => JSON.stringify(k));
|
||||
expect(keyStrings).toContain(JSON.stringify(["mood-entries"]));
|
||||
expect(keyStrings).toContain(JSON.stringify(["mood-analytics"]));
|
||||
expect(keyStrings).toContain(JSON.stringify(["insights"]));
|
||||
});
|
||||
|
||||
it("medicationDependentKeys bundle covers medications + analytics + achievements", () => {
|
||||
const keyStrings = medicationDependentKeys.map((k) => JSON.stringify(k));
|
||||
expect(keyStrings).toContain(JSON.stringify(["medications"]));
|
||||
expect(keyStrings).toContain(JSON.stringify(["analytics"]));
|
||||
expect(keyStrings).toContain(
|
||||
JSON.stringify(["gamification", "achievements"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidateKeys", () => {
|
||||
it("calls invalidateQueries for every key in the bundle", async () => {
|
||||
const invalidate = vi.fn().mockResolvedValue(undefined);
|
||||
const client = { invalidateQueries: invalidate } as unknown as QueryClient;
|
||||
const keys: QueryKey[] = [["a"], ["b", "c"], ["d"]];
|
||||
|
||||
await invalidateKeys(client, keys);
|
||||
|
||||
expect(invalidate).toHaveBeenCalledTimes(3);
|
||||
expect(invalidate).toHaveBeenNthCalledWith(1, { queryKey: ["a"] });
|
||||
expect(invalidate).toHaveBeenNthCalledWith(2, { queryKey: ["b", "c"] });
|
||||
expect(invalidate).toHaveBeenNthCalledWith(3, { queryKey: ["d"] });
|
||||
});
|
||||
|
||||
it("continues on partial failure (allSettled semantics)", async () => {
|
||||
const invalidate = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error("boom"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const client = { invalidateQueries: invalidate } as unknown as QueryClient;
|
||||
const keys: QueryKey[] = [["a"], ["b"], ["c"]];
|
||||
|
||||
const results = await invalidateKeys(client, keys);
|
||||
|
||||
expect(invalidate).toHaveBeenCalledTimes(3);
|
||||
expect(results[0].status).toBe("fulfilled");
|
||||
expect(results[1].status).toBe("rejected");
|
||||
expect(results[2].status).toBe("fulfilled");
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,26 @@ export class OpenAIClient implements AIProvider {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`OpenAI request failed (${res.status})`);
|
||||
// Pull as much of the body as we can so upstream incidents (OpenAI 5xx,
|
||||
// model-not-found, quota exceeded) are diagnosable from logs instead of
|
||||
// surfacing as an opaque "OpenAI request failed (500)". Strip anything
|
||||
// that looks like an API key from the excerpt before logging.
|
||||
const rawBody = await res.text().catch(() => "");
|
||||
const bodyExcerpt = rawBody
|
||||
.slice(0, 500)
|
||||
.replace(/sk-[A-Za-z0-9_-]{8,}/g, "sk-***redacted***")
|
||||
.replace(/Bearer\s+[A-Za-z0-9_.-]+/gi, "Bearer ***redacted***");
|
||||
const err = new Error(`OpenAI request failed (${res.status})`);
|
||||
// Keep the body in a structured field rather than the message so
|
||||
// Error.message stays short and the excerpt lands in dedicated log
|
||||
// fields (bodyExcerpt) that can be filtered/truncated centrally.
|
||||
Object.assign(err, {
|
||||
httpStatus: res.status,
|
||||
upstream: "openai",
|
||||
model: this.config.model,
|
||||
bodyExcerpt,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
+160
-94
@@ -1,9 +1,15 @@
|
||||
/**
|
||||
* Client-side PDF generation for doctor reports.
|
||||
* Uses jspdf + jspdf-autotable for professional medical-style reports.
|
||||
*
|
||||
* Every string and every number/date is driven by an injected `t()` and
|
||||
* locale-aware formatters so the exported PDF matches the user's UI language.
|
||||
* Call sites must pass `{ t, locale }` (use the ones from `useTranslations()`
|
||||
* / `useFormatters()`).
|
||||
*/
|
||||
import { jsPDF } from "jspdf";
|
||||
import autoTable from "jspdf-autotable";
|
||||
import { makeFormatters } from "./format-locale";
|
||||
import type { Locale } from "./i18n/config";
|
||||
|
||||
interface ReportData {
|
||||
period: { days: number; since: string };
|
||||
@@ -40,109 +46,130 @@ interface ReportData {
|
||||
} | null;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
WEIGHT: "Körpergewicht",
|
||||
BLOOD_PRESSURE_SYS: "Systolischer Blutdruck",
|
||||
BLOOD_PRESSURE_DIA: "Diastolischer Blutdruck",
|
||||
PULSE: "Ruhepuls",
|
||||
BODY_FAT: "Körperfettanteil",
|
||||
SLEEP_DURATION: "Schlafdauer",
|
||||
ACTIVITY_STEPS: "Aktivität (Schritte)",
|
||||
type T = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
export interface DoctorReportOptions {
|
||||
t: T;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
const TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
WEIGHT: "doctorReport.typeWeight",
|
||||
BLOOD_PRESSURE_SYS: "doctorReport.typeBpSys",
|
||||
BLOOD_PRESSURE_DIA: "doctorReport.typeBpDia",
|
||||
PULSE: "doctorReport.typePulse",
|
||||
BODY_FAT: "doctorReport.typeBodyFat",
|
||||
SLEEP_DURATION: "doctorReport.typeSleep",
|
||||
ACTIVITY_STEPS: "doctorReport.typeSteps",
|
||||
};
|
||||
|
||||
const TYPE_UNITS: Record<string, string> = {
|
||||
const TYPE_UNIT_KEYS: Record<string, string | null> = {
|
||||
WEIGHT: "kg",
|
||||
BLOOD_PRESSURE_SYS: "mmHg",
|
||||
BLOOD_PRESSURE_DIA: "mmHg",
|
||||
PULSE: "bpm",
|
||||
BODY_FAT: "%",
|
||||
SLEEP_DURATION: "h",
|
||||
ACTIVITY_STEPS: "Schritte",
|
||||
ACTIVITY_STEPS: null, // translated unit
|
||||
};
|
||||
|
||||
const MOOD_LABELS: Record<number, string> = {
|
||||
1: "Sehr schlecht",
|
||||
2: "Schlecht",
|
||||
3: "Neutral",
|
||||
4: "Gut",
|
||||
5: "Sehr gut",
|
||||
const MOOD_LABEL_KEYS: Record<number, string> = {
|
||||
1: "doctorReport.moodAwful",
|
||||
2: "doctorReport.moodBad",
|
||||
3: "doctorReport.moodNeutral",
|
||||
4: "doctorReport.moodGood",
|
||||
5: "doctorReport.moodGreat",
|
||||
};
|
||||
|
||||
function fmt(value: number, decimals = 1): string {
|
||||
return value.toFixed(decimals).replace(".", ",");
|
||||
function getBmiClassificationKey(bmi: number): string {
|
||||
if (bmi < 18.5) return "doctorReport.bmiUnderweight";
|
||||
if (bmi < 25) return "doctorReport.bmiNormal";
|
||||
if (bmi < 30) return "doctorReport.bmiOverweight";
|
||||
if (bmi < 35) return "doctorReport.bmiObeseGrade1";
|
||||
if (bmi < 40) return "doctorReport.bmiObeseGrade2";
|
||||
return "doctorReport.bmiObeseGrade3";
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
function getBpClassificationKey(sys: number, dia: number): string {
|
||||
if (sys < 120 && dia < 80) return "doctorReport.bpOptimal";
|
||||
if (sys < 130 && dia < 85) return "doctorReport.bpNormal";
|
||||
if (sys < 140 && dia < 90) return "doctorReport.bpHighNormal";
|
||||
if (sys < 160 && dia < 100) return "doctorReport.bpHypertensionGrade1";
|
||||
if (sys < 180 && dia < 110) return "doctorReport.bpHypertensionGrade2";
|
||||
return "doctorReport.bpHypertensionGrade3";
|
||||
}
|
||||
|
||||
function getBmiClassification(bmi: number): string {
|
||||
if (bmi < 18.5) return "Untergewicht";
|
||||
if (bmi < 25) return "Normalgewicht";
|
||||
if (bmi < 30) return "Übergewicht (Präadipositas)";
|
||||
if (bmi < 35) return "Adipositas Grad I";
|
||||
if (bmi < 40) return "Adipositas Grad II";
|
||||
return "Adipositas Grad III";
|
||||
}
|
||||
export function generateDoctorReportPDF(
|
||||
data: ReportData,
|
||||
options: DoctorReportOptions,
|
||||
): jsPDF {
|
||||
const { t, locale } = options;
|
||||
const formatters = makeFormatters(locale);
|
||||
const num = (value: number, decimals = 1) =>
|
||||
formatters.number(value, decimals);
|
||||
const fmtDate = (iso: string) => formatters.date(iso);
|
||||
|
||||
function getBpClassification(sys: number, dia: number): string {
|
||||
if (sys < 120 && dia < 80) return "Optimal";
|
||||
if (sys < 130 && dia < 85) return "Normal";
|
||||
if (sys < 140 && dia < 90) return "Hochnormal";
|
||||
if (sys < 160 && dia < 100) return "Hypertonie Grad 1";
|
||||
if (sys < 180 && dia < 110) return "Hypertonie Grad 2";
|
||||
return "Hypertonie Grad 3";
|
||||
}
|
||||
const unitFor = (type: string): string => {
|
||||
// Map entry === null means the unit needs translation (e.g. ACTIVITY_STEPS).
|
||||
const staticUnit = TYPE_UNIT_KEYS[type];
|
||||
if (staticUnit === null && type === "ACTIVITY_STEPS") {
|
||||
return t("doctorReport.unitSteps");
|
||||
}
|
||||
return staticUnit ?? "";
|
||||
};
|
||||
|
||||
export function generateDoctorReportPDF(data: ReportData): jsPDF {
|
||||
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const margin = 20;
|
||||
let y = margin;
|
||||
|
||||
// ── Header ──────────────────────────────────────────────────────────────
|
||||
doc.setFontSize(22);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text("Gesundheitsbericht", margin, y);
|
||||
doc.text(t("doctorReport.title"), margin, y);
|
||||
y += 8;
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(100, 100, 100);
|
||||
doc.text("HealthLog — Persönlicher Gesundheitsbericht", margin, y);
|
||||
doc.text(t("doctorReport.subtitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
// Divider
|
||||
doc.setDrawColor(200, 200, 200);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.line(margin, y, pageWidth - margin, y);
|
||||
y += 8;
|
||||
|
||||
// ── Patient info ────────────────────────────────────────────────────────
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(80, 80, 80);
|
||||
const patientInfo: string[] = [];
|
||||
if (data.patient.username) patientInfo.push(`Patient: ${data.patient.username}`);
|
||||
if (data.patient.dateOfBirth) {
|
||||
patientInfo.push(`Geburtsdatum: ${formatDate(data.patient.dateOfBirth)}`);
|
||||
if (data.patient.username) {
|
||||
patientInfo.push(`${t("doctorReport.patient")}: ${data.patient.username}`);
|
||||
}
|
||||
if (data.patient.gender) {
|
||||
if (data.patient.dateOfBirth) {
|
||||
patientInfo.push(
|
||||
`Geschlecht: ${data.patient.gender === "MALE" ? "Männlich" : data.patient.gender === "FEMALE" ? "Weiblich" : "Divers"}`,
|
||||
`${t("doctorReport.dateOfBirth")}: ${fmtDate(data.patient.dateOfBirth)}`,
|
||||
);
|
||||
}
|
||||
if (data.patient.gender) {
|
||||
const genderKeys: Record<string, string> = {
|
||||
MALE: "doctorReport.genderMale",
|
||||
FEMALE: "doctorReport.genderFemale",
|
||||
};
|
||||
const genderKey = genderKeys[data.patient.gender] ?? "doctorReport.genderOther";
|
||||
patientInfo.push(`${t("doctorReport.gender")}: ${t(genderKey)}`);
|
||||
}
|
||||
if (data.patient.heightCm) {
|
||||
patientInfo.push(`Körpergröße: ${data.patient.heightCm} cm`);
|
||||
patientInfo.push(
|
||||
`${t("doctorReport.height")}: ${data.patient.heightCm} cm`,
|
||||
);
|
||||
}
|
||||
patientInfo.push(
|
||||
`Berichtszeitraum: ${formatDate(data.period.since)} — ${formatDate(new Date().toISOString())}`,
|
||||
`${t("doctorReport.period")}: ${fmtDate(data.period.since)} — ${fmtDate(new Date().toISOString())}`,
|
||||
);
|
||||
patientInfo.push(
|
||||
`${t("doctorReport.createdOn")}: ${fmtDate(new Date().toISOString())}`,
|
||||
);
|
||||
patientInfo.push(`Erstellt am: ${formatDate(new Date().toISOString())}`);
|
||||
|
||||
for (const line of patientInfo) {
|
||||
doc.text(line, margin, y);
|
||||
@@ -150,11 +177,10 @@ export function generateDoctorReportPDF(data: ReportData): jsPDF {
|
||||
}
|
||||
y += 4;
|
||||
|
||||
// ── Vital Signs Summary ─────────────────────────────────────────────────
|
||||
doc.setFontSize(14);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text("Vitalparameter — Übersicht", margin, y);
|
||||
doc.text(t("doctorReport.vitalsTitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
const vitalRows: string[][] = [];
|
||||
@@ -169,20 +195,30 @@ export function generateDoctorReportPDF(data: ReportData): jsPDF {
|
||||
for (const type of vitalTypes) {
|
||||
const s = data.stats[type];
|
||||
if (!s) continue;
|
||||
const unit = unitFor(type);
|
||||
vitalRows.push([
|
||||
TYPE_LABELS[type] || type,
|
||||
`${fmt(s.latest)} ${TYPE_UNITS[type] || ""}`,
|
||||
`${fmt(s.avg)} ${TYPE_UNITS[type] || ""}`,
|
||||
`${fmt(s.min)}`,
|
||||
`${fmt(s.max)}`,
|
||||
`${s.count}`,
|
||||
t(TYPE_LABEL_KEYS[type] ?? ""),
|
||||
`${num(s.latest)} ${unit}`.trim(),
|
||||
`${num(s.avg)} ${unit}`.trim(),
|
||||
num(s.min),
|
||||
num(s.max),
|
||||
String(s.count),
|
||||
]);
|
||||
}
|
||||
|
||||
if (vitalRows.length > 0) {
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [["Parameter", "Aktuell", "Ø Zeitraum", "Min", "Max", "n"]],
|
||||
head: [
|
||||
[
|
||||
t("doctorReport.colParameter"),
|
||||
t("doctorReport.colCurrent"),
|
||||
t("doctorReport.colAvgPeriod"),
|
||||
t("doctorReport.colMin"),
|
||||
t("doctorReport.colMax"),
|
||||
t("doctorReport.colN"),
|
||||
],
|
||||
],
|
||||
body: vitalRows,
|
||||
theme: "grid",
|
||||
styles: {
|
||||
@@ -200,48 +236,49 @@ export function generateDoctorReportPDF(data: ReportData): jsPDF {
|
||||
alternateRowStyles: { fillColor: [252, 252, 252] },
|
||||
margin: { left: margin, right: margin },
|
||||
});
|
||||
y = (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY + 8;
|
||||
y =
|
||||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
// ── Blood Pressure Classification ────────────────────────────────────────
|
||||
const sysStat = data.stats.BLOOD_PRESSURE_SYS;
|
||||
const diaStat = data.stats.BLOOD_PRESSURE_DIA;
|
||||
if (sysStat && diaStat) {
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text("Blutdruck — ESC/ESH-Klassifikation (2018)", margin, y);
|
||||
doc.text(t("doctorReport.bpClassificationTitle"), margin, y);
|
||||
y += 5;
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "normal");
|
||||
const bpClass = getBpClassification(sysStat.avg, diaStat.avg);
|
||||
const bpClass = t(getBpClassificationKey(sysStat.avg, diaStat.avg));
|
||||
doc.text(
|
||||
`Durchschnittlicher Blutdruck: ${fmt(sysStat.avg, 0)}/${fmt(diaStat.avg, 0)} mmHg — Klassifikation: ${bpClass}`,
|
||||
`${t("doctorReport.avgBp")}: ${num(sysStat.avg, 0)}/${num(diaStat.avg, 0)} mmHg — ${t("doctorReport.classification")}: ${bpClass}`,
|
||||
margin,
|
||||
y,
|
||||
);
|
||||
y += 8;
|
||||
}
|
||||
|
||||
// ── BMI ──────────────────────────────────────────────────────────────────
|
||||
if (data.bmi) {
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text("Body-Mass-Index (BMI)", margin, y);
|
||||
doc.text(t("doctorReport.bmiTitle"), margin, y);
|
||||
y += 5;
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.text(
|
||||
`BMI: ${fmt(data.bmi)} kg/m² — Klassifikation: ${getBmiClassification(data.bmi)} (WHO)`,
|
||||
t("doctorReport.bmiRow", {
|
||||
bmi: num(data.bmi, 1),
|
||||
class: t(getBmiClassificationKey(data.bmi)),
|
||||
}),
|
||||
margin,
|
||||
y,
|
||||
);
|
||||
y += 8;
|
||||
}
|
||||
|
||||
// ── Medication Compliance ────────────────────────────────────────────────
|
||||
const complianceEntries = Object.entries(data.compliance);
|
||||
if (complianceEntries.length > 0) {
|
||||
if (y > 240) {
|
||||
@@ -251,18 +288,33 @@ export function generateDoctorReportPDF(data: ReportData): jsPDF {
|
||||
|
||||
doc.setFontSize(14);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text("Medikamenten-Compliance", margin, y);
|
||||
doc.text(t("doctorReport.complianceTitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
const compRows = complianceEntries.map(([name, c]) => {
|
||||
const rate = c.total > 0 ? ((c.taken / c.total) * 100).toFixed(1) : "—";
|
||||
return [name, `${c.taken}`, `${c.skipped}`, `${c.missed}`, `${c.total}`, `${rate}%`];
|
||||
const rate =
|
||||
c.total > 0 ? `${num((c.taken / c.total) * 100, 1)}%` : "—";
|
||||
return [
|
||||
name,
|
||||
String(c.taken),
|
||||
String(c.skipped),
|
||||
String(c.missed),
|
||||
String(c.total),
|
||||
rate,
|
||||
];
|
||||
});
|
||||
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [
|
||||
["Medikament", "Eingenommen", "Übersprungen", "Verpasst", "Gesamt", "Compliance-Rate"],
|
||||
[
|
||||
t("doctorReport.colMedication"),
|
||||
t("doctorReport.colTaken"),
|
||||
t("doctorReport.colSkipped"),
|
||||
t("doctorReport.colMissed"),
|
||||
t("doctorReport.colTotal"),
|
||||
t("doctorReport.colComplianceRate"),
|
||||
],
|
||||
],
|
||||
body: compRows,
|
||||
theme: "grid",
|
||||
@@ -281,11 +333,11 @@ export function generateDoctorReportPDF(data: ReportData): jsPDF {
|
||||
alternateRowStyles: { fillColor: [252, 252, 252] },
|
||||
margin: { left: margin, right: margin },
|
||||
});
|
||||
y = (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY + 8;
|
||||
y =
|
||||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
// ── Mood ─────────────────────────────────────────────────────────────────
|
||||
if (data.mood) {
|
||||
if (y > 240) {
|
||||
doc.addPage();
|
||||
@@ -294,13 +346,18 @@ export function generateDoctorReportPDF(data: ReportData): jsPDF {
|
||||
|
||||
doc.setFontSize(14);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text("Stimmungsverlauf", margin, y);
|
||||
doc.text(t("doctorReport.moodTitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.text(
|
||||
`Durchschnitt: ${fmt(data.mood.avg)} / 5,0 | Einträge: ${data.mood.count} | Bereich: ${data.mood.min} – ${data.mood.max}`,
|
||||
t("doctorReport.moodSummary", {
|
||||
avg: num(data.mood.avg, 1),
|
||||
count: data.mood.count,
|
||||
min: num(data.mood.min, 0),
|
||||
max: num(data.mood.max, 0),
|
||||
}),
|
||||
margin,
|
||||
y,
|
||||
);
|
||||
@@ -308,15 +365,23 @@ export function generateDoctorReportPDF(data: ReportData): jsPDF {
|
||||
|
||||
const distRows = Object.entries(data.mood.distribution).map(
|
||||
([score, count]) => [
|
||||
MOOD_LABELS[Number(score)] || score,
|
||||
`${count}`,
|
||||
`${data.mood!.count > 0 ? ((count / data.mood!.count) * 100).toFixed(1) : 0}%`,
|
||||
t(MOOD_LABEL_KEYS[Number(score)] ?? "doctorReport.moodNeutral"),
|
||||
String(count),
|
||||
data.mood!.count > 0
|
||||
? `${num((count / data.mood!.count) * 100, 1)}%`
|
||||
: "0%",
|
||||
],
|
||||
);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [["Stimmung", "Anzahl", "Anteil"]],
|
||||
head: [
|
||||
[
|
||||
t("doctorReport.colMood"),
|
||||
t("doctorReport.colCount"),
|
||||
t("doctorReport.colShare"),
|
||||
],
|
||||
],
|
||||
body: distRows,
|
||||
theme: "grid",
|
||||
styles: {
|
||||
@@ -333,34 +398,35 @@ export function generateDoctorReportPDF(data: ReportData): jsPDF {
|
||||
},
|
||||
margin: { left: margin, right: margin },
|
||||
});
|
||||
y = (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY + 8;
|
||||
y =
|
||||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
// ── Footer / Disclaimer ──────────────────────────────────────────────────
|
||||
const addFooter = (pageDoc: jsPDF) => {
|
||||
const pageHeight = pageDoc.internal.pageSize.getHeight();
|
||||
pageDoc.setFontSize(7);
|
||||
pageDoc.setFont("helvetica", "italic");
|
||||
pageDoc.setTextColor(140, 140, 140);
|
||||
pageDoc.text(
|
||||
"Dieser Bericht wurde automatisch aus selbst erfassten Daten generiert und dient ausschließlich zur Unterstützung",
|
||||
t("doctorReport.footerDisclaimer1"),
|
||||
margin,
|
||||
pageHeight - 14,
|
||||
);
|
||||
pageDoc.text(
|
||||
"des Arzt-Patienten-Gesprächs. Er ersetzt keine ärztliche Diagnose. Korrelationen implizieren keine Kausalität.",
|
||||
t("doctorReport.footerDisclaimer2"),
|
||||
margin,
|
||||
pageHeight - 10,
|
||||
);
|
||||
pageDoc.text(
|
||||
`Quelle: HealthLog | Zeitzone: Europe/Berlin | Erstellt: ${new Date().toLocaleString("de-DE", { timeZone: "Europe/Berlin" })}`,
|
||||
t("doctorReport.footerSource", {
|
||||
timestamp: formatters.dateTime(new Date()),
|
||||
}),
|
||||
margin,
|
||||
pageHeight - 6,
|
||||
);
|
||||
};
|
||||
|
||||
// Add footer to all pages
|
||||
const totalPages = doc.getNumberOfPages();
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
doc.setPage(i);
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Locale-aware formatters.
|
||||
*
|
||||
* HealthLog's UI language is user-configurable (DE/EN). Numbers, dates, and
|
||||
* times must render in the active locale — not in the hard-coded de-DE that
|
||||
* lingered from the German-first era.
|
||||
*
|
||||
* Call via the `useFormatters()` client hook. For server-only code paths
|
||||
* (jobs, API routes, PDF generation) pass the locale explicitly through
|
||||
* `makeFormatters(locale)`.
|
||||
*
|
||||
* Timezone is fixed to Europe/Berlin for display (user expectation). UTC is
|
||||
* preserved in the database.
|
||||
*/
|
||||
|
||||
import type { Locale } from "./i18n/config";
|
||||
|
||||
export const DISPLAY_TIMEZONE = "Europe/Berlin";
|
||||
|
||||
/**
|
||||
* Map our short locale ("de" / "en") to a full BCP-47 tag suitable for
|
||||
* `Intl.*`. When we add locales, extend this map — do not rely on the short
|
||||
* tag directly since browsers differ in how they handle "en" vs "en-US".
|
||||
*/
|
||||
export const INTL_LOCALE_MAP: Record<Locale, string> = {
|
||||
de: "de-DE",
|
||||
en: "en-US",
|
||||
};
|
||||
|
||||
export function resolveIntlLocale(locale: Locale): string {
|
||||
return INTL_LOCALE_MAP[locale] ?? "en-US";
|
||||
}
|
||||
|
||||
type DateInput = Date | string | number;
|
||||
|
||||
function asDate(value: DateInput): Date {
|
||||
return value instanceof Date ? value : new Date(value);
|
||||
}
|
||||
|
||||
export interface Formatters {
|
||||
/** Integer or decimal with the active locale's decimal separator. */
|
||||
number: (value: number, fractionDigits?: number) => string;
|
||||
/** Same as `number` but thousands-grouped. */
|
||||
integer: (value: number) => string;
|
||||
/** Percentage, e.g. 0.835 → "83,5 %" in DE / "83.5%" in EN. */
|
||||
percent: (value: number, fractionDigits?: number) => string;
|
||||
/** Short date, e.g. "19.02.2026" / "02/19/2026". */
|
||||
date: (value: DateInput) => string;
|
||||
/** Short date without year, e.g. "19.02." / "Feb 19". */
|
||||
dateShort: (value: DateInput) => string;
|
||||
/** Full date + time in 24h. */
|
||||
dateTime: (value: DateInput) => string;
|
||||
/** Time only in 24h, e.g. "14:30". */
|
||||
time: (value: DateInput) => string;
|
||||
/** Short weekday + date, e.g. "Do., 19.02." / "Thu, Feb 19". */
|
||||
dateWithWeekday: (value: DateInput) => string;
|
||||
/** For axis labels: 3-letter month abbreviation. */
|
||||
monthShort: (value: DateInput) => string;
|
||||
}
|
||||
|
||||
export function makeFormatters(locale: Locale): Formatters {
|
||||
const intlLocale = resolveIntlLocale(locale);
|
||||
const tz = DISPLAY_TIMEZONE;
|
||||
|
||||
return {
|
||||
number: (value, fractionDigits) =>
|
||||
new Intl.NumberFormat(intlLocale, {
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
}).format(value),
|
||||
|
||||
integer: (value) =>
|
||||
new Intl.NumberFormat(intlLocale, { maximumFractionDigits: 0 }).format(
|
||||
value,
|
||||
),
|
||||
|
||||
percent: (value, fractionDigits = 0) =>
|
||||
new Intl.NumberFormat(intlLocale, {
|
||||
style: "percent",
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
}).format(value),
|
||||
|
||||
date: (value) =>
|
||||
asDate(value).toLocaleDateString(intlLocale, {
|
||||
timeZone: tz,
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
}),
|
||||
|
||||
dateShort: (value) =>
|
||||
asDate(value).toLocaleDateString(intlLocale, {
|
||||
timeZone: tz,
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
}),
|
||||
|
||||
dateTime: (value) =>
|
||||
asDate(value).toLocaleString(intlLocale, {
|
||||
timeZone: tz,
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}),
|
||||
|
||||
time: (value) =>
|
||||
asDate(value).toLocaleTimeString(intlLocale, {
|
||||
timeZone: tz,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}),
|
||||
|
||||
dateWithWeekday: (value) =>
|
||||
asDate(value).toLocaleDateString(intlLocale, {
|
||||
timeZone: tz,
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
}),
|
||||
|
||||
monthShort: (value) =>
|
||||
asDate(value).toLocaleDateString(intlLocale, {
|
||||
timeZone: tz,
|
||||
month: "short",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse locale from a `Accept-Language` header or `healthlog-locale` cookie.
|
||||
* Server-side helper for jobs, API routes, and PDF generation where there is
|
||||
* no React context.
|
||||
*/
|
||||
export function parseLocaleFromAcceptLanguage(header: string | null): Locale {
|
||||
if (!header) return "en";
|
||||
const primary = header.split(",")[0]?.trim().toLowerCase() ?? "";
|
||||
if (primary.startsWith("de")) return "de";
|
||||
return "en";
|
||||
}
|
||||
+46
-42
@@ -1,63 +1,67 @@
|
||||
/**
|
||||
* Consistent date/time formatting for the HealthLog UI.
|
||||
* All functions use de-DE locale, Europe/Berlin timezone, 24h format.
|
||||
* Legacy locale-aware date/time helpers.
|
||||
*
|
||||
* These originally hard-coded `de-DE`. They now defer to `makeFormatters`
|
||||
* using a best-effort read of `healthlog-locale` from cookie → localStorage,
|
||||
* falling back to `en`. New code should prefer `useFormatters()` from
|
||||
* `@/lib/i18n/context` (client) or `makeFormatters(locale)` from
|
||||
* `@/lib/format-locale` (server).
|
||||
*
|
||||
* SSR caveat: on the server `activeLocale()` always returns `en` because
|
||||
* `document` is undefined. All current call sites render their formatted
|
||||
* strings only after a `useQuery` fetch — i.e. post-hydration — so there is
|
||||
* no hydration-mismatch path today. If you add a new caller that renders
|
||||
* pre-fetch data (e.g. a static prop), migrate it to `useFormatters()` to
|
||||
* stay consistent with the server-rendered locale.
|
||||
*/
|
||||
|
||||
const TIMEZONE = "Europe/Berlin";
|
||||
const LOCALE = "de-DE";
|
||||
import { makeFormatters, DISPLAY_TIMEZONE } from "./format-locale";
|
||||
import type { Locale } from "./i18n/config";
|
||||
|
||||
/** "19.02.2026, 14:30" */
|
||||
export { DISPLAY_TIMEZONE };
|
||||
|
||||
function activeLocale(): Locale {
|
||||
if (typeof document === "undefined") return "en";
|
||||
// Prefer the cookie (what SSR used) over localStorage to stay in sync
|
||||
// across hydration. Falls back to localStorage for backwards compat.
|
||||
const cookieMatch = document.cookie.match(
|
||||
/(?:^|;\s*)healthlog-locale=([^;]+)/,
|
||||
);
|
||||
const fromCookie = cookieMatch?.[1];
|
||||
if (fromCookie === "de" || fromCookie === "en") return fromCookie;
|
||||
const fromStorage = window.localStorage?.getItem("healthlog-locale");
|
||||
return fromStorage === "de" ? "de" : "en";
|
||||
}
|
||||
|
||||
function formatters() {
|
||||
return makeFormatters(activeLocale());
|
||||
}
|
||||
|
||||
/** Locale-aware "19.02.2026, 14:30" or "02/19/2026, 2:30 PM". */
|
||||
export function formatDateTime(date: Date | string): string {
|
||||
return new Date(date).toLocaleString(LOCALE, {
|
||||
timeZone: TIMEZONE,
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
return formatters().dateTime(date);
|
||||
}
|
||||
|
||||
/** "19.02.2026" */
|
||||
/** Locale-aware "19.02.2026" or "02/19/2026". */
|
||||
export function formatDate(date: Date | string): string {
|
||||
return new Date(date).toLocaleDateString(LOCALE, {
|
||||
timeZone: TIMEZONE,
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
return formatters().date(date);
|
||||
}
|
||||
|
||||
/** "19.02." or "19.02.2026" if includeYear */
|
||||
/** Locale-aware short date without year unless `includeYear`. */
|
||||
export function formatDateShort(
|
||||
date: Date | string,
|
||||
includeYear = false,
|
||||
): string {
|
||||
return new Date(date).toLocaleDateString(LOCALE, {
|
||||
timeZone: TIMEZONE,
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
...(includeYear ? { year: "numeric" } : {}),
|
||||
});
|
||||
const f = formatters();
|
||||
return includeYear ? f.date(date) : f.dateShort(date);
|
||||
}
|
||||
|
||||
/** "14:30" */
|
||||
/** Locale-aware "14:30". */
|
||||
export function formatTime(date: Date | string): string {
|
||||
return new Date(date).toLocaleTimeString(LOCALE, {
|
||||
timeZone: TIMEZONE,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
return formatters().time(date);
|
||||
}
|
||||
|
||||
/** "Do., 19.02." */
|
||||
/** Locale-aware short weekday + date. */
|
||||
export function formatDateWithWeekday(date: Date | string): string {
|
||||
return new Date(date).toLocaleDateString(LOCALE, {
|
||||
timeZone: TIMEZONE,
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
});
|
||||
return formatters().dateWithWeekday(date);
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { locales, defaultLocale, type Locale } from "./config";
|
||||
import { makeFormatters, type Formatters } from "../format-locale";
|
||||
|
||||
import deMessages from "../../../messages/de.json";
|
||||
import enMessages from "../../../messages/en.json";
|
||||
@@ -53,8 +55,23 @@ function detectSystemLocale(): Locale {
|
||||
: defaultLocale;
|
||||
}
|
||||
|
||||
function readLocaleCookie(): Locale | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const match = document.cookie.match(/(?:^|;\s*)healthlog-locale=([^;]+)/);
|
||||
const value = match?.[1];
|
||||
if (value && (locales as readonly string[]).includes(value)) {
|
||||
return value as Locale;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSavedLocale(): Locale | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
// Cookie wins over localStorage: the root layout already rendered the
|
||||
// server HTML using the cookie, so matching the cookie on first client
|
||||
// render avoids a hydration flash of the wrong language.
|
||||
const fromCookie = readLocaleCookie();
|
||||
if (fromCookie) return fromCookie;
|
||||
const saved = localStorage.getItem("healthlog-locale");
|
||||
if (saved && (locales as readonly string[]).includes(saved)) {
|
||||
return saved as Locale;
|
||||
@@ -71,13 +88,19 @@ export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
if ((locales as readonly string[]).includes(newLocale)) {
|
||||
setLocaleState(newLocale);
|
||||
localStorage.setItem("healthlog-locale", newLocale);
|
||||
// Also mirror to cookie so SSR (layout, metadata) renders in the
|
||||
// user's language. 1-year expiry, Lax samesite, not HttpOnly so the
|
||||
// client continues to own it.
|
||||
document.cookie = `healthlog-locale=${newLocale}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
|
||||
document.documentElement.lang = newLocale;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Set HTML lang on mount
|
||||
// Keep the HTML lang and the cookie in sync with the active locale on
|
||||
// mount. The cookie acts as the SSR handoff for the next request.
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = locale;
|
||||
document.cookie = `healthlog-locale=${locale}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
|
||||
}, [locale]);
|
||||
|
||||
const t = useCallback(
|
||||
@@ -120,3 +143,13 @@ export function useTranslations() {
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale-aware formatters tied to the active UI locale. Use for every number,
|
||||
* date, and time rendered in the UI so regional conventions (70,5 vs 70.5,
|
||||
* 19.02.2026 vs Feb 19, 2026) follow the user's language choice.
|
||||
*/
|
||||
export function useFormatters(): Formatters {
|
||||
const { locale } = useTranslations();
|
||||
return useMemo(() => makeFormatters(locale), [locale]);
|
||||
}
|
||||
@@ -46,7 +46,11 @@ import {
|
||||
function parseTimeToMinutes(value: string): number {
|
||||
const [h, m] = value.split(":").map(Number);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(m)) return 0;
|
||||
return h * 60 + m;
|
||||
// Some Node ICU builds render midnight as "24:00" via toLocaleTimeString.
|
||||
// Normalize so comparisons against schedule windows that wrap midnight
|
||||
// don't produce a 1440-minute value.
|
||||
const hours = h === 24 ? 0 : h;
|
||||
return hours * 60 + m;
|
||||
}
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL!;
|
||||
@@ -259,7 +263,7 @@ async function handleReminderCheck(jobs: Job<ReminderCheckPayload>[]) {
|
||||
userTz,
|
||||
);
|
||||
|
||||
const currentTime = now.toLocaleTimeString("de-DE", {
|
||||
const currentTime = now.toLocaleTimeString("en-GB", {
|
||||
timeZone: userTz,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Map WebAuthn / SimpleWebAuthn errors to actionable i18n keys.
|
||||
*
|
||||
* The browser rejects passkey prompts for several reasons that feel
|
||||
* indistinguishable to the user. We preserve the real reason so
|
||||
* "registration cancelled" no longer masks "your origin isn't HTTPS" or
|
||||
* "this device is already registered".
|
||||
*/
|
||||
|
||||
export interface PasskeyErrorMessage {
|
||||
/** i18n key for a user-facing explanation. */
|
||||
key: string;
|
||||
/** Params for interpolation (e.g. {message} for the unknown-error case). */
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function describePasskeyError(error: unknown): PasskeyErrorMessage {
|
||||
if (!(error instanceof Error)) {
|
||||
return { key: "settings.passkeyRegistrationFailed" };
|
||||
}
|
||||
|
||||
// SimpleWebAuthn wraps DOMException into WebAuthnError with a `code`
|
||||
// property, but the underlying DOMException name is the stable signal.
|
||||
switch (error.name) {
|
||||
case "NotAllowedError":
|
||||
// The user cancelled, or the OS-level prompt timed out. Prefer the
|
||||
// cancelled copy — it matches what the user did in the common case.
|
||||
return { key: "settings.passkeyRegistrationCancelled" };
|
||||
case "InvalidStateError":
|
||||
return { key: "settings.passkeyAlreadyRegistered" };
|
||||
case "NotSupportedError":
|
||||
return { key: "settings.passkeyNotSupported" };
|
||||
case "SecurityError":
|
||||
return { key: "settings.passkeySecurityBlocked" };
|
||||
case "AbortError":
|
||||
return { key: "settings.passkeyTimeout" };
|
||||
}
|
||||
|
||||
// SimpleWebAuthn WebAuthnError surfaces a `code`, e.g. "ERROR_INVALID_RP_ID"
|
||||
const code = (error as { code?: string }).code;
|
||||
if (code === "ERROR_INVALID_RP_ID" || code === "ERROR_INVALID_DOMAIN") {
|
||||
return { key: "settings.passkeySecurityBlocked" };
|
||||
}
|
||||
|
||||
return {
|
||||
key: "settings.passkeyUnknownError",
|
||||
params: { message: error.message || "unknown" },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Centralized TanStack Query key factory.
|
||||
*
|
||||
* Every useQuery/invalidateQueries call should go through this factory so that
|
||||
* mutations invalidate the right consumers. Hard-coded string arrays drifted in
|
||||
* the past (e.g. ["measurements"] didn't invalidate ["analytics"] on the
|
||||
* dashboard), so treat this file as the single source of truth.
|
||||
*/
|
||||
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
|
||||
export const queryKeys = {
|
||||
auth: () => ["auth"] as const,
|
||||
authRegistrationStatus: () => ["auth", "registration-status"] as const,
|
||||
|
||||
measurements: () => ["measurements"] as const,
|
||||
moodEntries: () => ["mood-entries"] as const,
|
||||
|
||||
analytics: () => ["analytics"] as const,
|
||||
moodAnalytics: () => ["mood-analytics"] as const,
|
||||
|
||||
insightsRoot: () => ["insights"] as const,
|
||||
insightsComprehensive: () => ["insights", "comprehensive"] as const,
|
||||
insightsTargets: () => ["insights", "targets"] as const,
|
||||
insightsGeneralStatus: (locale: string) =>
|
||||
["insights", "general-status", locale] as const,
|
||||
insightsBpStatus: (locale: string) =>
|
||||
["insights", "blood-pressure-status", locale] as const,
|
||||
insightsWeightStatus: (locale: string) =>
|
||||
["insights", "weight-status", locale] as const,
|
||||
insightsPulseStatus: (locale: string) =>
|
||||
["insights", "pulse-status", locale] as const,
|
||||
insightsBmiStatus: (locale: string) =>
|
||||
["insights", "bmi-status", locale] as const,
|
||||
insightsMoodStatus: (locale: string) =>
|
||||
["insights", "mood-status", locale] as const,
|
||||
insightsMedicationComplianceStatus: (locale: string) =>
|
||||
["insights", "medication-compliance-status", locale] as const,
|
||||
|
||||
medications: () => ["medications"] as const,
|
||||
medicationDetail: (id: string) => ["medications", id] as const,
|
||||
medicationComplianceChart: (medicationId: string) =>
|
||||
["compliance-chart-inline", medicationId] as const,
|
||||
medicationPhaseConfig: (medicationId: string) =>
|
||||
["phase-config", medicationId] as const,
|
||||
medicationIntakeSummary: () => ["medications", "intake-summary"] as const,
|
||||
|
||||
gamificationAchievements: () => ["gamification", "achievements"] as const,
|
||||
|
||||
passkeys: () => ["passkeys"] as const,
|
||||
|
||||
notificationsPreferences: () => ["notifications", "preferences"] as const,
|
||||
|
||||
settingsGlobalServices: () => ["settings", "global-services"] as const,
|
||||
settingsNtfy: () => ["settings", "ntfy"] as const,
|
||||
|
||||
tokens: () => ["tokens"] as const,
|
||||
telegram: () => ["telegram"] as const,
|
||||
telegramSettings: () => ["telegram", "settings"] as const,
|
||||
withings: () => ["withings"] as const,
|
||||
|
||||
adminSettings: () => ["admin", "settings"] as const,
|
||||
adminStatus: () => ["admin", "status"] as const,
|
||||
adminUsers: () => ["admin", "users"] as const,
|
||||
adminTokens: () => ["admin", "tokens"] as const,
|
||||
adminAuditLog: (filter: unknown) => ["admin", "audit-log", filter] as const,
|
||||
|
||||
bugreportStatus: () => ["bugreport", "status"] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Keys that should be invalidated when a measurement is created, updated or
|
||||
* deleted. Kept here so dashboards, insights, and targets always stay in sync.
|
||||
*/
|
||||
export const measurementDependentKeys = [
|
||||
queryKeys.measurements(),
|
||||
queryKeys.analytics(),
|
||||
queryKeys.insightsRoot(),
|
||||
queryKeys.insightsTargets(),
|
||||
queryKeys.gamificationAchievements(),
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys that should be invalidated when a mood entry is created, updated or
|
||||
* deleted.
|
||||
*/
|
||||
export const moodDependentKeys = [
|
||||
queryKeys.moodEntries(),
|
||||
queryKeys.moodAnalytics(),
|
||||
queryKeys.insightsRoot(),
|
||||
queryKeys.insightsTargets(),
|
||||
queryKeys.gamificationAchievements(),
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys invalidated when medications change (CRUD or intake).
|
||||
*/
|
||||
export const medicationDependentKeys = [
|
||||
queryKeys.medications(),
|
||||
queryKeys.analytics(),
|
||||
queryKeys.insightsRoot(),
|
||||
queryKeys.insightsTargets(),
|
||||
queryKeys.medicationIntakeSummary(),
|
||||
queryKeys.gamificationAchievements(),
|
||||
];
|
||||
|
||||
/**
|
||||
* Invalidate every key in the bundle in parallel. Use this from mutation
|
||||
* `onSuccess` handlers so the call site stays a one-liner instead of repeating
|
||||
* `Promise.all(keys.map(...))` everywhere.
|
||||
*
|
||||
* Uses `allSettled` so one transient network failure doesn't abort subsequent
|
||||
* invalidations (cache would otherwise be left half-stale) and so the `void
|
||||
* invalidateKeys(...)` fire-and-forget pattern in delete handlers never
|
||||
* surfaces an unhandled rejection.
|
||||
*/
|
||||
export function invalidateKeys(
|
||||
queryClient: QueryClient,
|
||||
keys: readonly QueryKey[],
|
||||
): Promise<PromiseSettledResult<unknown>[]> {
|
||||
return Promise.allSettled(
|
||||
keys.map((queryKey) => queryClient.invalidateQueries({ queryKey })),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user