mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
Give every locale its own strings on the non-prompt surfaces
Four locales received English on surfaces whose translations were already complete, because the resolving code was a de/en binary. - Medication reminder push and the Telegram inline-keyboard labels admitted only de/en, so es/fr/it/pl got the English template on every channel. Route the resolver through a shared coerceLocale(). - The Coach's trailing "reply now in X" clause said English for any non-German locale, overriding the correct system-prompt directive. Read the language name from a shared map instead. - Coach refusal copy was a de/en constant pair, answering four locales in English at the one turn the Coach is declining to help. Move the copy into the bundles; the EN/DE constants now derive from them. - The sleep-stage chart formatted axis ticks as en-US for four locales; the cycle calendar and the device-score tile followed the *browser* locale rather than the app locale. - Three components carried a byte-identical duration formatter that branched on German. Lift it to one key-driven helper. Polish needs a third plural tier: 2-4 takes a "few" form a two-form scheme cannot express, so "2 godzin temu" read as "2 of-hours ago". Add a pluralKey() helper driven by Intl.PluralRules and a Few tier to the relative-time and stale-hint keys. The five locales whose rules have no "few" category resolve exactly as before by construction, and their Few values mirror Other. Teach the reverse-coverage guard to expand a pluralKey() base into its three tiers.
This commit is contained in:
1 parent
624ce3292c
commit
c23f2545cc
24 files changed
+543
-143
No files matched your search
@@ -10,6 +10,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { pluralKey } from "@/lib/i18n/plural";
|
||||
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
|
||||
import {
|
||||
getTrendSentiment,
|
||||
@@ -130,7 +131,7 @@ export function TrendCard({
|
||||
emptyHint = null,
|
||||
valueAdornment = null,
|
||||
}: TrendCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
// v1.4.33 F5 — pick a single signal to drive both the arrow and the
|
||||
@@ -370,20 +371,14 @@ export function TrendCard({
|
||||
}
|
||||
if (staleDays <= 60) {
|
||||
const weeks = Math.floor(staleDays / 7);
|
||||
return t(
|
||||
weeks === 1
|
||||
? "dashboard.staleHintWeeksOne"
|
||||
: "dashboard.staleHintWeeksOther",
|
||||
{ count: weeks },
|
||||
);
|
||||
return t(pluralKey("dashboard.staleHintWeeks", weeks, locale), {
|
||||
count: weeks,
|
||||
});
|
||||
}
|
||||
const months = Math.floor(staleDays / 30);
|
||||
return t(
|
||||
months === 1
|
||||
? "dashboard.staleHintMonthsOne"
|
||||
: "dashboard.staleHintMonthsOther",
|
||||
{ count: months },
|
||||
);
|
||||
return t(pluralKey("dashboard.staleHintMonths", months, locale), {
|
||||
count: months,
|
||||
});
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import type { CalendarDay } from "./types";
|
||||
import {
|
||||
FERTILE_HUE,
|
||||
@@ -30,9 +32,17 @@ import {
|
||||
* grid is never colour-only. WCAG 2.5.5 — each cell is ≥ 40 px.
|
||||
*/
|
||||
|
||||
/** Monday-first short weekday labels, localized via Intl (no i18n key). */
|
||||
function shortWeekdays(): string[] {
|
||||
const fmt = new Intl.DateTimeFormat(undefined, { weekday: "short" });
|
||||
/**
|
||||
* Monday-first short weekday labels, localized via Intl (no i18n key).
|
||||
*
|
||||
* Takes the APP locale explicitly. It used to pass `undefined`, which resolves
|
||||
* to the browser's locale — invisible whenever the two agree, and wrong for
|
||||
* every user who reads the app in a language their browser is not set to.
|
||||
*/
|
||||
function shortWeekdays(locale: Locale): string[] {
|
||||
const fmt = new Intl.DateTimeFormat(resolveIntlLocale(locale), {
|
||||
weekday: "short",
|
||||
});
|
||||
// 2024-01-01 is a Monday — walk 7 days for a Monday-first header row.
|
||||
return Array.from({ length: 7 }, (_, i) =>
|
||||
fmt.format(new Date(2024, 0, 1 + i)),
|
||||
@@ -71,8 +81,8 @@ export function CycleCalendar({
|
||||
onSelectDay,
|
||||
className,
|
||||
}: CycleCalendarProps) {
|
||||
const { t } = useTranslations();
|
||||
const weekdays = useMemo(() => shortWeekdays(), []);
|
||||
const { t, locale } = useTranslations();
|
||||
const weekdays = useMemo(() => shortWeekdays(locale), [locale]);
|
||||
const byDate = useMemo(() => new Map(days.map((d) => [d.date, d])), [days]);
|
||||
|
||||
// Anchor the visible month on today's month.
|
||||
@@ -104,7 +114,7 @@ export function CycleCalendar({
|
||||
return cells;
|
||||
}, [monthAnchor]);
|
||||
|
||||
const monthLabel = monthAnchor.toLocaleDateString(undefined, {
|
||||
const monthLabel = monthAnchor.toLocaleDateString(resolveIntlLocale(locale), {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@ export function HistoryRail({
|
||||
className,
|
||||
hideHeading = false,
|
||||
}: HistoryRailProps) {
|
||||
const { t } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
const [filter, setFilter] = useState<string>("");
|
||||
// v1.30.2 (QoL H1) — the search box now drives a SERVER-side query
|
||||
// (title-only substring, see the route doc comment), so debounce the
|
||||
@@ -242,7 +242,7 @@ export function HistoryRail({
|
||||
<span className="truncate">{c.title}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{formatRelativeTime(c.updatedAt, t)}
|
||||
{formatRelativeTime(c.updatedAt, t, locale)}
|
||||
</span>
|
||||
</button>
|
||||
{/* v1.4.27 R3d MB2 — drop the hover-only `opacity-0`
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ComponentType } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { LearningGate } from "@/components/ui/learning-gate";
|
||||
@@ -72,7 +73,7 @@ export function DeviceScoreTile({
|
||||
learningThreshold = 3,
|
||||
className,
|
||||
}: DeviceScoreTileProps) {
|
||||
const { t } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
|
||||
const count = summary?.count ?? 0;
|
||||
@@ -83,7 +84,7 @@ export function DeviceScoreTile({
|
||||
const isLearning = count < learningThreshold;
|
||||
|
||||
const fmt = (value: number) =>
|
||||
value.toLocaleString(undefined, {
|
||||
value.toLocaleString(resolveIntlLocale(locale), {
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDurationMinutes } from "@/lib/i18n/duration";
|
||||
|
||||
/**
|
||||
* v1.28.x — the discreet "sources disagree" marker, extracted from
|
||||
@@ -29,15 +30,6 @@ export interface SleepSourceDiscrepancyDto {
|
||||
}[];
|
||||
}
|
||||
|
||||
export function formatMinutes(total: number, locale: string): string {
|
||||
const hours = Math.floor(total / 60);
|
||||
const mins = Math.round(total - hours * 60);
|
||||
if (locale === "de") {
|
||||
return hours > 0 ? `${hours} Std. ${mins} Min.` : `${mins} Min.`;
|
||||
}
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Localised label keys per `MeasurementSource` — the same map the
|
||||
* source-priority settings surface uses (`sources-section.tsx`), so both
|
||||
@@ -110,7 +102,7 @@ export function SleepSourceDiscrepancyMarker({
|
||||
discrepancy,
|
||||
align = "start",
|
||||
}: SleepSourceDiscrepancyMarkerProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const { t } = useTranslations();
|
||||
if (!discrepancy) return null;
|
||||
return (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
@@ -137,7 +129,7 @@ export function SleepSourceDiscrepancyMarker({
|
||||
{discrepancy.sources
|
||||
.map(
|
||||
(b) =>
|
||||
`${sourceDisplayName(b.source, b.deviceType, t)} ${formatMinutes(b.asleepMinutes, locale)}`,
|
||||
`${sourceDisplayName(b.source, b.deviceType, t)} ${formatDurationMinutes(b.asleepMinutes, t)}`,
|
||||
)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDurationMinutes } from "@/lib/i18n/duration";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4c → W3f — sleep-stage composition chart.
|
||||
@@ -97,15 +100,6 @@ export const STAGE_COLORS: Record<string, string> = {
|
||||
IN_BED: "var(--chart-inbed)", // muted blue-grey — pre-asleep
|
||||
};
|
||||
|
||||
function formatMinutes(total: number, locale: string): string {
|
||||
const hours = Math.floor(total / 60);
|
||||
const mins = Math.round(total - hours * 60);
|
||||
if (locale === "de") {
|
||||
return hours > 0 ? `${hours} Std. ${mins} Min.` : `${mins} Min.`;
|
||||
}
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Berlin-tz day key (YYYY-MM-DD) as a short x-axis tick.
|
||||
* 7-day window → "Mon" / "Tue" / …; 14-day → "M 10" / "T 11" / …;
|
||||
@@ -123,13 +117,13 @@ function formatMinutes(total: number, locale: string): string {
|
||||
function formatDayTick(
|
||||
dayKey: string,
|
||||
window: WindowSize,
|
||||
locale: string,
|
||||
locale: Locale,
|
||||
): string {
|
||||
const [y, m, d] = dayKey.split("-").map(Number);
|
||||
if (!y || !m || !d) return dayKey;
|
||||
const date = new Date(Date.UTC(y, m - 1, d));
|
||||
if (window === 7) {
|
||||
return date.toLocaleDateString(locale === "de" ? "de-DE" : "en-US", {
|
||||
return date.toLocaleDateString(resolveIntlLocale(locale), {
|
||||
timeZone: "UTC",
|
||||
weekday: "short",
|
||||
});
|
||||
@@ -137,7 +131,7 @@ function formatDayTick(
|
||||
if (window === 14) {
|
||||
return `${date.getUTCDate()}.`;
|
||||
}
|
||||
return date.toLocaleDateString(locale === "de" ? "de-DE" : "en-US", {
|
||||
return date.toLocaleDateString(resolveIntlLocale(locale), {
|
||||
timeZone: "UTC",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
@@ -322,7 +316,7 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
|
||||
{stageLabels[stage] ?? stage}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatMinutes(minutes, locale)} · {pct}%
|
||||
{formatDurationMinutes(minutes, t)} · {pct}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -330,7 +324,7 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
|
||||
{totalNight > 0 && (
|
||||
<div className="border-border mt-1 flex items-center justify-between gap-3 border-t pt-1 font-medium">
|
||||
<span>{t("insights.sleep.headlineTitle")}</span>
|
||||
<span>{formatMinutes(totalNight, locale)}</span>
|
||||
<span>{formatDurationMinutes(totalNight, t)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -100,6 +100,7 @@ import {
|
||||
INSIGHTS_OVERVIEW_PATH,
|
||||
subPageSlugForType,
|
||||
} from "@/lib/insights/sub-page-metric";
|
||||
import { formatDurationMinutes } from "@/lib/i18n/duration";
|
||||
|
||||
/**
|
||||
* v1.4.37 W7c — cumulative HK types whose list view collapses to one
|
||||
@@ -140,19 +141,6 @@ interface Measurement {
|
||||
awakenings?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.11.5 — format a minutes total as an "8h 12m" / "8 Std. 12 Min."
|
||||
* sleep headline.
|
||||
*/
|
||||
function formatSleepMinutes(total: number, locale: string): string {
|
||||
const hours = Math.floor(total / 60);
|
||||
const mins = Math.round(total - hours * 60);
|
||||
if (locale === "de") {
|
||||
return hours > 0 ? `${hours} Std. ${mins} Min.` : `${mins} Min.`;
|
||||
}
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||
}
|
||||
|
||||
/** v1.11.5 — i18n labels for the sleep stages shown in the drill-down. */
|
||||
function sleepStageLabel(
|
||||
stage: string | null | undefined,
|
||||
@@ -287,7 +275,7 @@ export function MeasurementList({
|
||||
lockedType,
|
||||
initialType,
|
||||
}: MeasurementListProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { isAuthenticated } = useAuth();
|
||||
// v1.28.42 (H3) — the desktop table and the mobile card list used to BOTH
|
||||
@@ -1023,7 +1011,7 @@ export function MeasurementList({
|
||||
TIME ASLEEP as "8h 12m" + a nap caption. */}
|
||||
{isSleep ? (
|
||||
<>
|
||||
{formatSleepMinutes(m.value, locale)}
|
||||
{formatDurationMinutes(m.value, t)}
|
||||
<SleepNightCaption m={m} />
|
||||
</>
|
||||
) : (
|
||||
@@ -1228,7 +1216,7 @@ export function MeasurementList({
|
||||
"78.4 kg" no longer truncates to "78".
|
||||
v1.11.5 — sleep rows render TIME ASLEEP. */}
|
||||
{isSleep ? (
|
||||
formatSleepMinutes(m.value, locale)
|
||||
formatDurationMinutes(m.value, t)
|
||||
) : (
|
||||
<>
|
||||
{isGrouped
|
||||
@@ -1590,7 +1578,7 @@ function DayDrillDown({
|
||||
unit: string;
|
||||
layout: "desktop" | "mobile";
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.measurementDrilldown(type, dayKey),
|
||||
@@ -1652,7 +1640,7 @@ function DayDrillDown({
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{isSleep
|
||||
? formatSleepMinutes(s.value, locale)
|
||||
? formatDurationMinutes(s.value, t)
|
||||
: `${s.value} ${unit}`}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1673,9 +1661,7 @@ function DayDrillDown({
|
||||
: formatDateTime(s.measuredAt)}
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{isSleep
|
||||
? formatSleepMinutes(s.value, locale)
|
||||
: `${s.value} ${unit}`}
|
||||
{isSleep ? formatDurationMinutes(s.value, t) : `${s.value} ${unit}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in new issue
Block a user