diff --git a/client/src/screens/InstalledApps/index.tsx b/client/src/screens/InstalledApps/index.tsx index a8fe5edc..f784d2e3 100644 --- a/client/src/screens/InstalledApps/index.tsx +++ b/client/src/screens/InstalledApps/index.tsx @@ -83,7 +83,7 @@ import { useMakeWay } from "../../lib/useMakeWay"; import { pushNotification } from "../../state/notifications"; import { withConsolePrefix } from "../../state/roster"; import { audit } from "../../state/auditLog"; -import { useTr } from "../../state/lang"; +import { useLangStore, useTr } from "../../state/lang"; import { transferAddr, mgmtAddr, hostOf } from "../../lib/addr"; import { useImageRetry } from "../../lib/useImageRetry"; import { transferScreenBusy } from "../../lib/ps5Transfers"; @@ -362,6 +362,7 @@ export const AppCard = memo(function AppCard({ * bookkeeping around a cold start doesn't run for a foreground nudge. */ }) { const tr = useTr(); + const lang = useLangStore((s) => s.lang); const navigate = useNavigate(); const canPlay = !title.system; // On-console folder this title lives in — only folder/disc kinds carry a @@ -493,7 +494,7 @@ export const AppCard = memo(function AppCard({ {playSeconds && playSeconds > 0 ? tr( "installed_playtime_line", - { time: formatPlayTime(playSeconds), seen: formatLastSeen(lastSeenMs) }, + { time: formatPlayTime(playSeconds), seen: formatLastSeen(lastSeenMs, undefined, lang) }, `${formatPlayTime(playSeconds)} · last seen ${formatLastSeen(lastSeenMs)}`, ) : tr("installed_playtime_never", undefined, "not seen playing")} diff --git a/client/src/state/playTime.test.ts b/client/src/state/playTime.test.ts index 821fd157..58b5657d 100644 --- a/client/src/state/playTime.test.ts +++ b/client/src/state/playTime.test.ts @@ -153,6 +153,17 @@ describe("formatLastSeen", () => { expect(formatLastSeen(now - DAY, now)).toBe("yesterday"); expect(formatLastSeen(now - 5 * DAY, now)).toBe("5 days ago"); }); + it("localizes the label to the app language", () => { + expect(formatLastSeen(now, now, "es")).toBe("hoy"); + expect(formatLastSeen(now - DAY, now, "es")).toBe("ayer"); + expect(formatLastSeen(now - 5 * DAY, now, "es")).toBe("hace 5 días"); + expect(formatLastSeen(now - DAY, now, "de")).toBe("gestern"); + }); + it("falls back to English for an unknown locale tag", () => { + expect(formatLastSeen(now, now, "not a locale")).toBe("today"); + expect(formatLastSeen(now - DAY, now, "not a locale")).toBe("yesterday"); + expect(formatLastSeen(now - 5 * DAY, now, "not a locale")).toBe("5 days ago"); + }); }); describe("formatPlayTime", () => { diff --git a/client/src/state/playTime.ts b/client/src/state/playTime.ts index 879d13a3..0193ea60 100644 --- a/client/src/state/playTime.ts +++ b/client/src/state/playTime.ts @@ -239,11 +239,26 @@ export function daysSinceLastSeen( * "N days ago". Drives the Installed-Apps not-played surface (#116). The * honest phrasing is "seen" — it's when ps5upload last observed the title * running, not Sony's console-side last-played (which the app can't read - * cheaply). `now` injectable for tests. */ -export function formatLastSeen(ms: number | undefined, now = Date.now()): string { + * cheaply). `now` injectable for tests. `locale` is the app language (a BCP-47 + * tag); it defaults to English so callers without one stay deterministic. */ +export function formatLastSeen( + ms: number | undefined, + now = Date.now(), + locale = "en", +): string { const d = daysSinceLastSeen(ms, now); if (d === null) return "never"; - if (d === 0) return "today"; - if (d === 1) return "yesterday"; - return `${d} days ago`; + try { + // `numeric: "auto"` turns 0 and -1 days into "today" / "yesterday" (and + // their equivalents in other languages); older reads "N days ago". + return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format( + d === 0 ? 0 : -d, + "day", + ); + } catch { + // Unknown locale tag: fall back to English. + if (d === 0) return "today"; + if (d === 1) return "yesterday"; + return `${d} days ago`; + } }