diff --git a/messages/de.json b/messages/de.json index 890fe581f..f46fcbbc0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8015,7 +8015,9 @@ "noneUnlockedYet": "Noch keine Erfolge freigeschaltet.", "pointsValue": "{points} Punkte", "metricCount": "{count}", - "metricDays": "{count} Tage", + "metricDaysOne": "{count} Tag", + "metricDaysFew": "{count} Tage", + "metricDaysOther": "{count} Tage", "metricPercent": "{count}%", "badges": { "intakeTotal1": { @@ -9929,7 +9931,7 @@ "loadError": "Diese Episode konnte nicht geladen werden.", "backToList": "Zurück zum Journal", "detail": { - "note": "Note" + "note": "Notiz" }, "edit": { "title": "Episode bearbeiten", @@ -12019,8 +12021,7 @@ "title": "Praxen und Ärzte", "description": "Dein eigenes Adressbuch — einmal tippen, überall auswählen.", "linkLabel": "Adressbuch", - "add": "Eintrag", - "searchPlaceholder": "Name, Praxis oder Fachrichtung suchen", + "searchPlaceholder": "Name, Praxis oder Fach suchen", "loadError": "Adressbuch konnte nicht geladen werden.", "emptyTitle": "Noch kein Eintrag", "emptyDescription": "Lege eine Praxis an, damit du sie beim Termin auswählen kannst.", diff --git a/messages/en.json b/messages/en.json index c502bab8a..d4d5ba3b5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8015,7 +8015,9 @@ "noneUnlockedYet": "No achievements unlocked yet.", "pointsValue": "{points} points", "metricCount": "{count}", - "metricDays": "{count} days", + "metricDaysOne": "{count} day", + "metricDaysFew": "{count} days", + "metricDaysOther": "{count} days", "metricPercent": "{count}%", "badges": { "intakeTotal1": { @@ -12019,7 +12021,6 @@ "title": "Practices and doctors", "description": "Your own address book — type a practice once, pick it everywhere.", "linkLabel": "Address book", - "add": "Entry", "searchPlaceholder": "Search name, practice or specialty", "loadError": "The address book could not be loaded.", "emptyTitle": "No entry yet", diff --git a/messages/es.json b/messages/es.json index 759191ed4..d9e70476c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8015,7 +8015,9 @@ "noneUnlockedYet": "Aún no has desbloqueado ningún logro.", "pointsValue": "{points} puntos", "metricCount": "{count}", - "metricDays": "{count} días", + "metricDaysOne": "{count} día", + "metricDaysFew": "{count} días", + "metricDaysOther": "{count} días", "metricPercent": "{count}%", "badges": { "intakeTotal1": { @@ -9929,7 +9931,7 @@ "loadError": "No se pudo cargar este episodio.", "backToList": "Volver al diario", "detail": { - "note": "Note" + "note": "Nota" }, "edit": { "title": "Editar episodio", @@ -12019,7 +12021,6 @@ "title": "Consultas y médicos", "description": "Tu propia agenda: escribe una consulta una vez y elígela en todas partes.", "linkLabel": "Agenda", - "add": "Entrada", "searchPlaceholder": "Buscar nombre, consulta o especialidad", "loadError": "No se pudo cargar la agenda.", "emptyTitle": "Todavía no hay entradas", diff --git a/messages/fr.json b/messages/fr.json index 198f10fce..ee4cb1ec6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8015,7 +8015,9 @@ "noneUnlockedYet": "Aucune réussite débloquée pour l’instant.", "pointsValue": "{points} points", "metricCount": "{count}", - "metricDays": "{count} jours", + "metricDaysOne": "{count} jour", + "metricDaysFew": "{count} jours", + "metricDaysOther": "{count} jours", "metricPercent": "{count}%", "badges": { "intakeTotal1": { @@ -12019,7 +12021,6 @@ "title": "Cabinets et médecins", "description": "Votre carnet d'adresses : saisissez un cabinet une fois, choisissez-le partout.", "linkLabel": "Carnet d'adresses", - "add": "Entrée", "searchPlaceholder": "Rechercher un nom, un cabinet ou une spécialité", "loadError": "Impossible de charger le carnet d'adresses.", "emptyTitle": "Aucune entrée", diff --git a/messages/it.json b/messages/it.json index d6dad2aac..62fbdb924 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8015,7 +8015,9 @@ "noneUnlockedYet": "Nessun obiettivo ancora sbloccato.", "pointsValue": "{points} punti", "metricCount": "{count}", - "metricDays": "{count} giorni", + "metricDaysOne": "{count} giorno", + "metricDaysFew": "{count} giorni", + "metricDaysOther": "{count} giorni", "metricPercent": "{count}%", "badges": { "intakeTotal1": { @@ -9929,7 +9931,7 @@ "loadError": "Impossibile caricare questo episodio.", "backToList": "Torna al diario", "detail": { - "note": "Note" + "note": "Nota" }, "edit": { "title": "Modifica episodio", @@ -12019,7 +12021,6 @@ "title": "Studi e medici", "description": "La tua rubrica: scrivi uno studio una volta e selezionalo ovunque.", "linkLabel": "Rubrica", - "add": "Voce", "searchPlaceholder": "Cerca nome, studio o specializzazione", "loadError": "Impossibile caricare la rubrica.", "emptyTitle": "Nessuna voce", diff --git a/messages/ko.json b/messages/ko.json index edf1f1df2..e0a23c4be 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8015,7 +8015,9 @@ "noneUnlockedYet": "아직 달성한 업적이 없어요.", "pointsValue": "{points}포인트", "metricCount": "{count}", - "metricDays": "{count}일", + "metricDaysOne": "{count}일", + "metricDaysFew": "{count}일", + "metricDaysOther": "{count}일", "metricPercent": "{count}%", "badges": { "intakeTotal1": { @@ -12019,7 +12021,6 @@ "title": "병원과 의사", "description": "나만의 주소록이에요 — 병원을 한 번만 입력하면 어디서든 골라 쓸 수 있어요.", "linkLabel": "주소록", - "add": "항목", "searchPlaceholder": "이름, 병원, 진료과 검색", "loadError": "주소록을 불러오지 못했어요.", "emptyTitle": "아직 항목이 없어요", diff --git a/messages/pl.json b/messages/pl.json index b060b3a48..5029c9cbf 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8015,7 +8015,9 @@ "noneUnlockedYet": "Nie odblokowano jeszcze żadnego osiągnięcia.", "pointsValue": "{points} punktów", "metricCount": "{count}", - "metricDays": "{count} dni", + "metricDaysOne": "{count} dzień", + "metricDaysFew": "{count} dni", + "metricDaysOther": "{count} dni", "metricPercent": "{count}%", "badges": { "intakeTotal1": { @@ -9929,7 +9931,7 @@ "loadError": "Nie udało się wczytać tego epizodu.", "backToList": "Powrót do dziennika", "detail": { - "note": "Note" + "note": "Notatka" }, "edit": { "title": "Edytuj epizod", @@ -12019,7 +12021,6 @@ "title": "Gabinety i lekarze", "description": "Twoja własna książka adresowa — wpisz gabinet raz, wybieraj go wszędzie.", "linkLabel": "Książka adresowa", - "add": "Wpis", "searchPlaceholder": "Szukaj nazwiska, gabinetu lub specjalizacji", "loadError": "Nie udało się wczytać książki adresowej.", "emptyTitle": "Brak wpisów", diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index 257acee02..6c0fe99b0 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -35,7 +35,7 @@ import { PageHeader } from "@/components/ui/page-header"; import { PageAuthGate } from "@/components/ui/page-auth-gate"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { useAuth } from "@/hooks/use-auth"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { formatDate } from "@/lib/format"; import { useAchievementsQuery } from "@/lib/queries/use-achievements-query"; import { @@ -81,12 +81,13 @@ const CATEGORY_LABEL_KEY: Record = { function formatMetric( format: AchievementProgress["format"], value: number, - t: (key: string, params?: Record) => string, + i18n: Pick, "t" | "tCount">, ) { if (format === "percent") - return t("achievements.metricPercent", { count: value }); - if (format === "days") return t("achievements.metricDays", { count: value }); - return t("achievements.metricCount", { count: value }); + return i18n.t("achievements.metricPercent", { count: value }); + // "1 Tag", not "1 Tage": the day count takes the plural form. + if (format === "days") return i18n.tCount("achievements.metricDays", value); + return i18n.t("achievements.metricCount", { count: value }); } /** @@ -127,9 +128,11 @@ export function groupByCategory( interface AchievementCardProps { achievement: AchievementProgress; t: ReturnType["t"]; + tCount: ReturnType["tCount"]; } -function AchievementCard({ achievement, t }: AchievementCardProps) { +function AchievementCard({ achievement, t, tCount }: AchievementCardProps) { + const i18n = { t, tCount }; const Icon = iconMap[achievement.icon] ?? Star; const unlocked = achievement.unlocked; @@ -191,7 +194,7 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {

{t(achievement.titleKey)}

-

+

{t(achievement.descriptionKey)}

@@ -215,8 +218,16 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {

{t("achievements.criterionHint", { - current: formatMetric(achievement.format, achievement.current, t), - target: formatMetric(achievement.format, achievement.target, t), + current: formatMetric( + achievement.format, + achievement.current, + i18n, + ), + target: formatMetric( + achievement.format, + achievement.target, + i18n, + ), })}

{/* v1.4.33 IW9 — aria-label so the bar carries an accessible name. */} @@ -224,8 +235,16 @@ function AchievementCard({ achievement, t }: AchievementCardProps) { value={achievement.progressPercent} className="h-1.5" aria-label={t("achievements.criterionHint", { - current: formatMetric(achievement.format, achievement.current, t), - target: formatMetric(achievement.format, achievement.target, t), + current: formatMetric( + achievement.format, + achievement.current, + i18n, + ), + target: formatMetric( + achievement.format, + achievement.target, + i18n, + ), })} />
@@ -259,7 +278,9 @@ function AchievementCard({ achievement, t }: AchievementCardProps) { export default function AchievementsPage() { const { user, isAuthenticated, isLoading: authLoading } = useAuth(); - const { t } = useTranslations(); + const { t, tCount } = useTranslations(); + const fmt = useFormatters(); + const i18n = { t, tCount }; // v1.18.0 — the achievements module gate. When the account has the // module turned off the whole page disappears: an empty state stands in @@ -335,9 +356,9 @@ export default function AchievementsPage() { {t("achievements.points")}

- {summary?.earnedPoints ?? 0} + {fmt.integer(summary?.earnedPoints ?? 0)} - / {summary?.totalPoints ?? 0} + / {fmt.integer(summary?.totalPoints ?? 0)}

{t(summary.nextAchievement.titleKey)}

-

+

{t(summary.nextAchievement.descriptionKey)}

@@ -381,13 +402,13 @@ export default function AchievementsPage() { {formatMetric( summary.nextAchievement.format, summary.nextAchievement.current, - t, + i18n, )}{" "} /{" "} {formatMetric( summary.nextAchievement.format, summary.nextAchievement.target, - t, + i18n, )}{" "} ({summary.nextAchievement.progressPercent}%)

@@ -449,6 +470,7 @@ export default function AchievementsPage() { key={achievement.id} achievement={achievement} t={t} + tCount={tCount} /> ))} diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index d42ffa50f..386510a8f 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -219,7 +219,9 @@ function CoachConversationsBody({ readOnly }: { readOnly: boolean }) { className={cn( "group relative flex flex-wrap items-center gap-1.5 rounded-xl px-3 py-2.5", "text-sm transition-colors", - "text-muted-foreground hover:bg-muted/40 hover:text-foreground", + // The title is the reader's own content: foreground, + // with the timestamp below as the muted meta line. + "text-foreground hover:bg-muted/40", )} > ) : null; diff --git a/src/components/records/profile-summary.tsx b/src/components/records/profile-summary.tsx index ac2e5f407..e6e33f8d1 100644 --- a/src/components/records/profile-summary.tsx +++ b/src/components/records/profile-summary.tsx @@ -130,7 +130,9 @@ export function ProfileSummaryPage() { }); return ( -
+ // The shell owns the page frame (gutter, width, top space) and the + //
landmark; this page only sets its rhythm. +
{summary.isPending ? (

@@ -141,6 +143,6 @@ export function ProfileSummaryPage() { void summary.refetch()} /> ) : null} {summary.data ? : null} -

+ ); } diff --git a/src/components/ui/__tests__/empty-state.test.tsx b/src/components/ui/__tests__/empty-state.test.tsx index 1247e09bd..e24cb92ab 100644 --- a/src/components/ui/__tests__/empty-state.test.tsx +++ b/src/components/ui/__tests__/empty-state.test.tsx @@ -46,4 +46,13 @@ describe("", () => { expect(card).toContain("border-dashed"); expect(plain).not.toContain("border-dashed"); }); + + it("insets the card variant's content from its border, not the plain one", () => { + const card = renderToStaticMarkup(); + const plain = renderToStaticMarkup( + , + ); + expect(card).toMatch(/class="[^"]*\bpx-4\b/); + expect(plain).not.toMatch(/\bpx-4\b/); + }); }); diff --git a/src/components/ui/empty-state.tsx b/src/components/ui/empty-state.tsx index 06fdc35d5..3f736a7ed 100644 --- a/src/components/ui/empty-state.tsx +++ b/src/components/ui/empty-state.tsx @@ -64,6 +64,9 @@ function EmptyState({ className={cn( "flex flex-col items-center justify-center text-center", size === "compact" ? "gap-2 py-4" : "gap-3 py-8", + // The card variant paints a border, so its content insets from it: + // a full-width phone CTA otherwise ran edge to edge into the frame. + variant === "card" && "px-4", )} > {icon ? (