i18n: migrate from German-first to English-first default locale

Switch defaultLocale from "de" to "en" and make English the code-level
language throughout. All ~145 hardcoded German strings translated:

- API error messages (52 routes) now return English
- Zod validation messages in English
- Classification/analytics functions return English categories
- Notification type labels in English
- Component strings use i18n t() calls (heatmap, mood chart,
  medication form, intake history, password strength, insights card)
- ~50 new i18n keys in both de.json and en.json

Route migration:
- /stimmung → /mood, /zielwerte → /targets (new pages)
- 301 redirects in proxy.ts for legacy URLs
- Sidebar + bottom nav updated

Bump version to 1.0.1.
This commit is contained in:
Marc-André Bombeck committed 2026-03-12 21:31:48 +01:00
1 parent 38309932f3
commit 0c0c345e82
74 files changed
+871 -402

No files matched your search

+17 -21
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useMemo, useEffect, useRef } from "react";
import { useTranslations } from "@/lib/i18n/context";
interface DailyData {
expected: number;
@@ -19,21 +20,6 @@ interface ComplianceHeatmapProps {
const CELL_SIZE = 18;
const GAP = 3;
const WEEKDAY_LABELS = ["Mo", "", "Mi", "", "Fr", "", "So"];
const MONTH_LABELS = [
"Jan",
"Feb",
"Mär",
"Apr",
"Mai",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Dez",
];
function getColor(data: DailyData): string {
if (data.expected === 0) return "var(--secondary)";
@@ -80,6 +66,7 @@ export function ComplianceHeatmap({
days = 90,
stretch = false,
}: ComplianceHeatmapProps) {
const { t } = useTranslations();
const containerRef = useRef<HTMLDivElement | null>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [tooltip, setTooltip] = useState<{
@@ -88,6 +75,9 @@ export function ComplianceHeatmap({
text: string;
} | null>(null);
const WEEKDAY_LABELS = [
t("charts.weekdays.mon"), "", t("charts.weekdays.wed"), "", t("charts.weekdays.fri"), "", t("charts.weekdays.sun"),
];
useEffect(() => {
if (!stretch) return;
const element = containerRef.current;
@@ -107,6 +97,12 @@ export function ComplianceHeatmap({
}, [stretch]);
const { cells, weeks, monthMarkers } = useMemo(() => {
const MONTH_LABELS = [
t("charts.months.jan"), t("charts.months.feb"), t("charts.months.mar"),
t("charts.months.apr"), t("charts.months.may"), t("charts.months.jun"),
t("charts.months.jul"), t("charts.months.aug"), t("charts.months.sep"),
t("charts.months.oct"), t("charts.months.nov"), t("charts.months.dec"),
];
const now = new Date();
const cellList: Array<{
dateKey: string;
@@ -162,7 +158,7 @@ export function ComplianceHeatmap({
}
return { cells: cellList, weeks: col + 1, monthMarkers: markers };
}, [dailyCompliance, days]);
}, [dailyCompliance, days, t]);
const labelWidth = stretch ? 0 : 76;
const headerHeight = 18;
@@ -244,7 +240,7 @@ export function ComplianceHeatmap({
cell.data.late !== undefined ||
cell.data.veryLate !== undefined;
const timingInfo = hasTimingData
? ` | ${cell.data.onTime ?? 0} pünktlich, ${cell.data.late ?? 0} spät, ${cell.data.veryLate ?? 0} sehr spät`
? ` | ${cell.data.onTime ?? 0} ${t("charts.heatmapOnTime")}, ${cell.data.late ?? 0} ${t("charts.heatmapLate")}, ${cell.data.veryLate ?? 0} ${t("charts.heatmapVeryLate")}`
: "";
setTooltip({
x: e.clientX,
@@ -274,10 +270,10 @@ export function ComplianceHeatmap({
style={{ marginLeft: stretch ? 0 : labelWidth }}
>
{[
{ color: "var(--dracula-green)", label: "Pünktlich" },
{ color: "var(--dracula-yellow)", label: "Spät" },
{ color: "var(--dracula-orange)", label: "Sehr spät" },
{ color: "var(--dracula-red)", label: "Verpasst" },
{ color: "var(--dracula-green)", label: t("charts.legendOnTime") },
{ color: "var(--dracula-yellow)", label: t("charts.legendLate") },
{ color: "var(--dracula-orange)", label: t("charts.legendVeryLate") },
{ color: "var(--dracula-red)", label: t("charts.legendMissed") },
].map(({ color, label }) => (
<span key={label} className="flex items-center gap-1">
<div
+11 -9
View File
@@ -56,13 +56,7 @@ const TIME_RANGES_KEYS = [
{ labelKey: "charts.pointsAllLabel", points: 0, titleKey: "charts.pointsAllTitle" },
] as const;
const MOOD_LABELS: Record<number, string> = {
1: "Lausig",
2: "Schlecht",
3: "Okay",
4: "Gut",
5: "Super",
};
// MOOD_LABELS built dynamically via t() in the component
const VALUE_BANDS = [
{ min: 1, max: 2, color: "#ff5555", opacity: 0.16 },
@@ -231,13 +225,21 @@ export function MoodChart({ title }: MoodChartProps) {
const maxPointIndex = Math.max(0, (chartData?.length ?? 1) - 1);
const moodLabels: Record<number, string> = {
1: t("charts.moodLabel1"),
2: t("charts.moodLabel2"),
3: t("charts.moodLabel3"),
4: t("charts.moodLabel4"),
5: t("charts.moodLabel5"),
};
const formatMoodTick = (value: number): string => {
return MOOD_LABELS[value] ?? String(value);
return moodLabels[value] ?? String(value);
};
const formatTooltipValue = (value: number): string => {
const rounded = Math.round(value * 10) / 10;
const label = MOOD_LABELS[Math.round(value)];
const label = moodLabels[Math.round(value)];
return label ? `${rounded} (${label})` : String(rounded);
};
+12 -11
View File
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Sparkles, RefreshCw, Loader2, AlertTriangle } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
interface InsightsOutput {
changed: string;
@@ -32,6 +33,7 @@ const CONFIDENCE_COLORS: Record<string, string> = {
export function InsightsCard() {
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const { t } = useTranslations();
const [error, setError] = useState<string | null>(null);
// Check if insights are configured
@@ -81,7 +83,7 @@ export function InsightsCard() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Sparkles className="text-dracula-purple h-5 w-5" />
<CardTitle className="text-lg">KI-Insights</CardTitle>
<CardTitle className="text-lg">{t("insights.aiInsights")}</CardTitle>
</div>
<Button
variant="ghost"
@@ -94,7 +96,7 @@ export function InsightsCard() {
) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" />
)}
{insights ? "Aktualisieren" : "Generieren"}
{insights ? t("insights.refreshButton") : t("insights.generateButton")}
</Button>
</div>
</CardHeader>
@@ -111,7 +113,7 @@ export function InsightsCard() {
<div className="flex items-center justify-center py-8">
<Loader2 className="text-dracula-purple h-6 w-6 animate-spin" />
<span className="text-muted-foreground ml-2 text-sm">
Analysiere Gesundheitsdaten...
{t("insights.analyzing")}
</span>
</div>
)}
@@ -120,19 +122,19 @@ export function InsightsCard() {
<div className="space-y-4">
<div className="flex items-center gap-2">
<Badge className={CONFIDENCE_COLORS[insights.confidence] ?? ""}>
Konfidenz: {insights.confidence}
{t("insights.confidence")}: {t(`insights.confidence_${insights.confidence}`)}
</Badge>
{generate.data?.cached && (
<Badge variant="outline" className="text-xs">
Gecacht
{t("insights.cached")}
</Badge>
)}
</div>
<Section title="Was hat sich verändert" text={insights.changed} />
<Section title="Was ist stabil" text={insights.stable} />
<Section title="Mögliche Zusammenhänge" text={insights.drivers} />
<Section title="Nächste Schritte" text={insights.nextSteps} />
<Section title={t("insights.sectionChanged")} text={insights.changed} />
<Section title={t("insights.sectionStable")} text={insights.stable} />
<Section title={t("insights.sectionDrivers")} text={insights.drivers} />
<Section title={t("insights.sectionNextSteps")} text={insights.nextSteps} />
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-muted-foreground text-xs">
@@ -145,8 +147,7 @@ export function InsightsCard() {
{!insights && !generate.isPending && !error && (
<p className="text-muted-foreground py-4 text-center text-sm">
Klicke auf &quot;Generieren&quot; für KI-basierte Insights deiner
Gesundheitsdaten.
{t("insights.generatePrompt")}
</p>
)}
</CardContent>
+2 -2
View File
@@ -17,10 +17,10 @@ import { useTranslations } from "@/lib/i18n/context";
const navItems = [
{ href: "/", tKey: "nav.dashboard", icon: Home },
{ href: "/measurements", tKey: "nav.measurements", icon: Activity },
{ href: "/stimmung", tKey: "nav.mood", icon: Waves },
{ href: "/mood", tKey: "nav.mood", icon: Waves },
{ href: "/medications", tKey: "nav.medications", icon: Pill },
{ href: "/insights", tKey: "nav.insights", icon: Lightbulb },
{ href: "/zielwerte", tKey: "nav.targets", icon: Target },
{ href: "/targets", tKey: "nav.targets", icon: Target },
{ href: "/achievements", tKey: "nav.achievements", icon: Trophy },
];
+2 -2
View File
@@ -50,10 +50,10 @@ const STORAGE_KEY = "healthlog-sidebar-collapsed";
const navItems = [
{ href: "/", tKey: "nav.dashboard", icon: Home },
{ href: "/measurements", tKey: "nav.measurements", icon: Activity },
{ href: "/stimmung", tKey: "nav.mood", icon: Waves },
{ href: "/mood", tKey: "nav.mood", icon: Waves },
{ href: "/medications", tKey: "nav.medications", icon: Pill },
{ href: "/insights", tKey: "nav.insights", icon: Lightbulb },
{ href: "/zielwerte", tKey: "nav.targets", icon: Target },
{ href: "/targets", tKey: "nav.targets", icon: Target },
{ href: "/achievements", tKey: "nav.achievements", icon: Trophy },
];
@@ -72,12 +72,7 @@ interface IntakeHistoryListProps {
onCreateOpenChange?: (open: boolean) => void;
}
const SOURCE_LABELS: Record<string, string> = {
WEB: "Webseite",
API: "Externe API",
REMINDER: "Telegram / Erinnerung",
IMPORT: "CSV-Import",
};
// SOURCE_LABELS built dynamically via t() in the component
const PAGE_SIZE = 25;
@@ -97,6 +92,13 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
const queryClient = useQueryClient();
const { t } = useTranslations();
const sourceLabels: Record<string, string> = {
WEB: t("medications.sourceWeb"),
API: t("medications.sourceApi"),
REMINDER: t("medications.sourceReminder"),
IMPORT: t("medications.sourceImport"),
};
const [page, setPage] = useState(1);
const [sortBy, setSortBy] = useState<string>("scheduledFor");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
@@ -219,7 +221,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
},
onError: (err) => {
setEditError(
err instanceof Error ? err.message : "Fehler beim Speichern",
err instanceof Error ? err.message : t("medications.saveError"),
);
},
});
@@ -257,7 +259,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
},
onError: (err) => {
setCreateError(
err instanceof Error ? err.message : "Fehler beim Speichern",
err instanceof Error ? err.message : t("medications.saveError"),
);
},
});
@@ -293,7 +295,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
await deleteMutation.mutateAsync(editing.id);
closeEdit();
} catch {
setEditError("Fehler beim Löschen");
setEditError(t("medications.deleteError"));
}
}
@@ -303,7 +305,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
const scheduledDate = new Date(editScheduledFor);
if (Number.isNaN(scheduledDate.getTime())) {
setEditError("Bitte einen gültigen Zeitpunkt wählen.");
setEditError(t("medications.invalidTimestamp"));
return;
}
@@ -323,7 +325,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
const scheduledDate = new Date(createScheduledFor);
if (Number.isNaN(scheduledDate.getTime())) {
setCreateError("Bitte einen gültigen Zeitpunkt wählen.");
setCreateError(t("medications.invalidTimestamp"));
return;
}
@@ -365,7 +367,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
if (!isAuthenticated) {
return (
<p className="text-muted-foreground text-sm">
Bitte anmelden, um Einnahmen zu sehen.
{t("medications.loginRequiredIntakes")}
</p>
);
}
@@ -387,7 +389,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
</div>
) : !data?.events.length ? (
<div className="text-muted-foreground flex h-32 items-center justify-center rounded-lg border border-dashed">
Noch keine Einnahmen vorhanden
{t("medications.noIntakesYet")}
</div>
) : (
<>
@@ -420,7 +422,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
className="w-28"
/>
<TableHead className="w-20 pr-4 text-right">
Aktionen
{t("medications.actions")}
</TableHead>
</TableRow>
</TableHeader>
@@ -438,7 +440,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
<TableCell>{getStatusBadge(event)}</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{SOURCE_LABELS[event.source] ?? event.source}
{sourceLabels[event.source] ?? event.source}
</Badge>
</TableCell>
<TableCell className="pr-4 text-right">
@@ -485,7 +487,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
<div className="flex items-center gap-1.5">
{getStatusBadge(event)}
<Badge variant="outline" className="text-xs">
{SOURCE_LABELS[event.source] ?? event.source}
{sourceLabels[event.source] ?? event.source}
</Badge>
</div>
</div>
@@ -516,7 +518,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
{totalPages > 1 && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">
Seite {page} von {totalPages}
{t("medications.pageInfo", { page, total: totalPages })}
</span>
<div className="flex gap-1">
<Button
@@ -614,7 +616,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
onClick={() => setEditDeleteDialogOpen(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
Löschen
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -628,7 +630,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
updateMutation.isPending || deleteMutation.isPending
}
>
Abbrechen
{t("common.cancel")}
</Button>
<Button
type="submit"
@@ -639,7 +641,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
{updateMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Speichern
{t("common.save")}
</Button>
</div>
</div>
@@ -659,7 +661,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
Abbrechen
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
@@ -669,7 +671,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
{deleteMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Löschen
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -737,13 +739,13 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
onClick={closeCreate}
disabled={createMutation.isPending}
>
Abbrechen
{t("common.cancel")}
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Speichern
{t("common.save")}
</Button>
</div>
</form>
@@ -800,6 +802,7 @@ function DeleteButton({
description: string;
onConfirm: () => void;
}) {
const { t } = useTranslations();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
@@ -807,7 +810,7 @@ function DeleteButton({
variant="ghost"
size="icon"
className="text-destructive h-8 w-8"
aria-label="Löschen"
aria-label={label}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
@@ -818,12 +821,12 @@ function DeleteButton({
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={onConfirm}
>
Löschen
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+18 -18
View File
@@ -51,17 +51,7 @@ import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { PhaseConfigDialog } from "@/components/medications/phase-config-dialog";
const DOSE_UNITS = [
"mg",
"g",
"ml",
"Tropfen",
"Tablette(n)",
"Kapsel(n)",
"Stück",
"IE",
"µg",
] as const;
// DOSE_UNITS built dynamically via t() in the component
type TranslateFn = (
key: string,
@@ -245,6 +235,16 @@ export function MedicationForm({
}: MedicationFormProps) {
const queryClient = useQueryClient();
const { t } = useTranslations();
const doseUnits = [
"mg", "g", "ml",
t("medications.unitDrops"),
t("medications.unitTablets"),
t("medications.unitCapsules"),
t("medications.unitPieces"),
"IE", "µg",
];
const [name, setName] = useState(initial?.name ?? "");
const initialDose = parseDose(initial?.dose ?? "");
@@ -449,7 +449,7 @@ export function MedicationForm({
id="med-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="z.B. Ibuprofen"
placeholder={t("medications.namePlaceholder")}
required
maxLength={100}
/>
@@ -508,7 +508,7 @@ export function MedicationForm({
inputMode="decimal"
value={doseAmount}
onChange={(e) => setDoseAmount(e.target.value)}
placeholder="z.B. 400"
placeholder={t("medications.dosePlaceholder")}
required
maxLength={20}
/>
@@ -527,7 +527,7 @@ export function MedicationForm({
</div>
</div>
<datalist id="dose-units">
{DOSE_UNITS.map((u) => (
{doseUnits.map((u) => (
<option key={u} value={u} />
))}
</datalist>
@@ -641,7 +641,7 @@ export function MedicationForm({
value={s.label}
className="h-8 text-xs md:text-xs"
onChange={(e) => updateSchedule(i, "label", e.target.value)}
placeholder="z.B. Morgens"
placeholder={t("medications.labelPlaceholder")}
maxLength={50}
/>
</div>
@@ -653,7 +653,7 @@ export function MedicationForm({
value={s.dose}
className="h-8 text-xs md:text-xs"
onChange={(e) => updateSchedule(i, "dose", e.target.value)}
placeholder={dose || "Standard"}
placeholder={dose || t("medications.defaultDose")}
maxLength={50}
/>
</div>
@@ -721,8 +721,8 @@ export function MedicationForm({
}`}
title={
s.daysOfWeek.includes(dayIndex)
? `${label} deaktivieren`
: `${label} aktivieren`
? t("medications.dayDeactivate", { day: label })
: t("medications.dayActivate", { day: label })
}
>
{label}
+21 -20
View File
@@ -3,32 +3,26 @@
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { translateZxcvbn } from "@/lib/zxcvbn-de";
import { useTranslations } from "@/lib/i18n/context";
interface PasswordStrengthProps {
password: string;
minLength?: number;
}
const SCORE_CONFIG = [
{ label: "Sehr schwach", color: "bg-red-500", textColor: "text-red-500" },
{ label: "Schwach", color: "bg-orange-500", textColor: "text-orange-500" },
{
label: "Akzeptabel",
color: "bg-yellow-500",
textColor: "text-yellow-500",
},
{ label: "Stark", color: "bg-green-500", textColor: "text-green-500" },
{
label: "Sehr stark",
color: "bg-emerald-600",
textColor: "text-emerald-600",
},
const SCORE_COLORS = [
{ color: "bg-red-500", textColor: "text-red-500" },
{ color: "bg-orange-500", textColor: "text-orange-500" },
{ color: "bg-yellow-500", textColor: "text-yellow-500" },
{ color: "bg-green-500", textColor: "text-green-500" },
{ color: "bg-emerald-600", textColor: "text-emerald-600" },
];
export function PasswordStrength({
password,
minLength = 12,
}: PasswordStrengthProps) {
const { t } = useTranslations();
const [result, setResult] = useState<{
score: number;
feedback: { warning: string; suggestions: string[] };
@@ -60,13 +54,20 @@ export function PasswordStrength({
if (!password) return null;
const score = result?.score ?? 0;
const config = SCORE_CONFIG[score];
const colors = SCORE_COLORS[score];
const scoreLabels = [
t("passwordStrength.veryWeak"),
t("passwordStrength.weak"),
t("passwordStrength.acceptable"),
t("passwordStrength.strong"),
t("passwordStrength.veryStrong"),
];
const tooShort = password.length < minLength;
// Collect feedback
const feedback: string[] = [];
if (tooShort) {
feedback.push(`Mindestens ${minLength} Zeichen erforderlich.`);
feedback.push(t("passwordStrength.minLength", { count: minLength }));
}
if (result?.feedback.warning) {
feedback.push(translateZxcvbn(result.feedback.warning));
@@ -84,7 +85,7 @@ export function PasswordStrength({
key={i}
className={cn(
"h-1.5 flex-1 rounded-full transition-colors",
i <= score && !tooShort ? config.color : "bg-muted",
i <= score && !tooShort ? colors.color : "bg-muted",
)}
/>
))}
@@ -92,11 +93,11 @@ export function PasswordStrength({
{/* Label */}
<div className="flex items-center justify-between">
<span className={cn("text-xs font-medium", config.textColor)}>
{tooShort ? "Zu kurz" : config.label}
<span className={cn("text-xs font-medium", colors.textColor)}>
{tooShort ? t("passwordStrength.tooShort") : scoreLabels[score]}
</span>
<span className="text-muted-foreground text-xs">
{password.length}/{minLength}+ Zeichen
{password.length}/{minLength}+ {t("passwordStrength.characters")}
</span>
</div>