mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 19:45:42 -04:00
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:
4 files changed
+68
-25
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user