Installed apps: localize the "last seen" label (today / yesterday / N days ago) (#454)

formatLastSeen returned hard-coded English words ("today", "yesterday",
"N days ago") that were then substituted into the translated line
installed_playtime_line, so every non-English UI showed e.g. Spanish
"visto por ultima vez yesterday".

Format the day count with Intl.RelativeTimeFormat in the app language
(numeric: "auto"), so 0 / 1 days read as today / yesterday and their
equivalents in each locale (hoy / ayer / hace 5 dias, heute / gestern, ...).
This covers every shipped locale without new i18n keys.

- formatLastSeen takes an optional `locale` (default "en"), so existing
  callers and the English output are unchanged. An invalid tag falls back
  to the previous English strings.
- AppCard passes the active language from useLangStore.
- Tests: Spanish and German labels, and the invalid-tag fallback.

The "never" label (a title with play time but no last-seen timestamp) is
still English; it has no Intl equivalent and is left out of this change.

Not run locally: npm run validate / vitest / tsc / eslint (no Node toolchain
on the machine used). Intl outputs were checked in a browser console.
This commit is contained in:
Enrique Wood authored and GitHub committed 2026-10-14 19:33:36 -07:00
1 parent 01afdf8926
commit 16e48cb243
3 files changed
+34 -7

No files matched your search

+3 -2
View File
@@ -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")}
+11
View File
@@ -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", () => {
+20 -5
View File
@@ -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`;
}
}