fix(i18n): format date-only values in UTC and share an age helper

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Sonnet 5.5 committed 2026-10-04 17:05:33 -04:00
1 parent 5a96fd39f1
commit d42a022a30
4 files changed
+68 -25

No files matched your search

+2 -12
View File
@@ -28,23 +28,12 @@ import { Text } from "@/components/ui/text";
import { useTitleActions } from "@/hooks/use-title-actions";
import { orpc } from "@/lib/orpc";
import { addRecentlyViewed } from "@/lib/recently-viewed";
import { formatDate } from "@sofa/i18n/format";
import { calculateAge, formatDate } from "@sofa/i18n/format";
const FILMOGRAPHY_GAP = 12;
const FILMOGRAPHY_PADDING = 16;
const FILMOGRAPHY_GUTTER = FILMOGRAPHY_GAP / 2;
function calculateAge(birthday: string, deathday?: string | null): number {
const birth = new Date(birthday);
const end = deathday ? new Date(deathday) : new Date();
let age = end.getFullYear() - birth.getFullYear();
const m = end.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && end.getDate() < birth.getDate())) {
age--;
}
return age;
}
export default function PersonDetailScreen() {
const { t } = useLingui();
const { id } = useLocalSearchParams<{ id: string }>();
@@ -187,6 +176,7 @@ export default function PersonDetailScreen() {
<Text className="text-muted-foreground/60 text-sm">
{(() => {
const age = calculateAge(person.birthday, person.deathday);
if (age === null) return null;
return person.deathday ? ` (${t`died at ${age}`})` : ` (${t`age ${age}`})`;
})()}
</Text>
+1 -12
View File
@@ -5,23 +5,12 @@ import { ExpandableText } from "@/components/expandable-text";
import { Badge } from "@/components/ui/badge";
import { thumbHashToUrl } from "@/lib/thumbhash";
import type { ResolvedPerson } from "@sofa/api/schemas";
import { formatDate } from "@sofa/i18n/format";
import { calculateAge, formatDate } from "@sofa/i18n/format";
interface PersonHeroProps {
person: ResolvedPerson;
}
function calculateAge(birthday: string, deathday?: string | null): number {
const birth = new Date(birthday);
const end = deathday ? new Date(deathday) : new Date();
let age = end.getFullYear() - birth.getFullYear();
const m = end.getMonth() - birth.getMonth();
if (m < 0 || (m === 0 && end.getDate() < birth.getDate())) {
age--;
}
return age;
}
export function PersonHero({ person }: PersonHeroProps) {
const { t } = useLingui();
const age = person.birthday ? calculateAge(person.birthday, person.deathday) : null;
+29
View File
@@ -25,13 +25,42 @@ export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOpt
}
export function formatShortDate(date: Date | string): string {
// Date-only strings are parsed as UTC midnight; format in UTC to keep the calendar day.
const useUTC = typeof date === "string" && isDateOnly(date);
return new Intl.DateTimeFormat(i18n.locale, {
month: "short",
day: "numeric",
year: "numeric",
...(useUTC ? { timeZone: "UTC" } : {}),
}).format(toDate(date));
}
function dateOnlyParts(value: string): { y: number; m: number; d: number } | null {
if (!isDateOnly(value)) return null;
const [y, m, d] = value.split("-").map(Number);
return { y: y!, m: m!, d: d! };
}
/**
* Age in whole years from TMDB date-only strings ("YYYY-MM-DD"). Compares
* calendar dates directly so the result doesn't depend on the viewer's timezone.
*/
export function calculateAge(
birthday: string,
deathday?: string | null,
now: Date = new Date(),
): number | null {
const birth = dateOnlyParts(birthday);
if (!birth) return null;
const end = deathday
? dateOnlyParts(deathday)
: { y: now.getFullYear(), m: now.getMonth() + 1, d: now.getDate() };
if (!end) return null;
let age = end.y - birth.y;
if (end.m < birth.m || (end.m === birth.m && end.d < birth.d)) age--;
return age;
}
export function formatRelativeTime(date: Date | string): string {
const d = toDate(date);
const now = Date.now();
+36 -1
View File
@@ -1,10 +1,11 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("../src/index", () => ({
i18n: { locale: "en" },
}));
import {
calculateAge,
formatBytes,
formatDate,
formatList,
@@ -161,3 +162,37 @@ describe("formatList", () => {
expect(formatList(["ZIP"], { type: "disjunction" })).toBe("ZIP");
});
});
describe("date-only values west of UTC", () => {
let prevTz: string | undefined;
beforeAll(() => {
prevTz = process.env.TZ;
process.env.TZ = "America/Los_Angeles";
});
afterAll(() => {
if (prevTz === undefined) delete process.env.TZ;
else process.env.TZ = prevTz;
});
test("formatShortDate keeps the calendar day", () => {
const result = formatShortDate("2024-01-03");
expect(result).toContain("3");
expect(result).toContain("Jan");
expect(result).not.toContain("Jan 2");
});
test("calculateAge does not increment early", () => {
expect(calculateAge("1990-05-15", null, new Date(2024, 4, 14, 20, 0))).toBe(33);
expect(calculateAge("1990-05-15", null, new Date(2024, 4, 15, 9, 0))).toBe(34);
});
test("calculateAge uses deathday when present", () => {
expect(calculateAge("1950-03-10", "2000-03-09")).toBe(49);
});
test("calculateAge returns null for malformed input", () => {
expect(calculateAge("unknown")).toBeNull();
});
});