Skip to content

Commit e4e3415

Browse files
committed
Safer handling of backend date formats
and add frontend testing
1 parent a03bb7e commit e4e3415

File tree

4 files changed

+129
-16
lines changed

4 files changed

+129
-16
lines changed
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { DateTimeFormat } from "src/locale";
2+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
3+
4+
describe("DateFormatter", () => {
5+
// Keep a reference to the real Intl to restore later
6+
const RealIntl = global.Intl;
7+
const desiredTimeZone = "Europe/London";
8+
const desiredLocale = "en-GB";
9+
10+
beforeAll(() => {
11+
// Ensure Node-based libs using TZ behave deterministically
12+
try {
13+
process.env.TZ = desiredTimeZone;
14+
} catch {
15+
// ignore if not available
16+
}
17+
18+
// Mock Intl.DateTimeFormat so formatting is stable regardless of host
19+
const MockedDateTimeFormat = class extends RealIntl.DateTimeFormat {
20+
constructor(
21+
_locales?: string | string[],
22+
options?: Intl.DateTimeFormatOptions,
23+
) {
24+
super(desiredLocale, {
25+
...options,
26+
timeZone: desiredTimeZone,
27+
});
28+
}
29+
} as unknown as typeof Intl.DateTimeFormat;
30+
31+
global.Intl = {
32+
...RealIntl,
33+
DateTimeFormat: MockedDateTimeFormat,
34+
};
35+
});
36+
37+
afterAll(() => {
38+
// Restore original Intl after tests
39+
global.Intl = RealIntl;
40+
});
41+
42+
it("format date from iso date", () => {
43+
const value = "2024-01-01T00:00:00.000Z";
44+
const text = DateTimeFormat(value);
45+
expect(text).toBe("Monday, 01/01/2024, 12:00:00 am");
46+
});
47+
48+
it("format date from unix timestamp number", () => {
49+
const value = 1762476112;
50+
const text = DateTimeFormat(value);
51+
expect(text).toBe("Friday, 07/11/2025, 12:41:52 am");
52+
});
53+
54+
it("format date from unix timestamp string", () => {
55+
const value = "1762476112";
56+
const text = DateTimeFormat(value);
57+
expect(text).toBe("Friday, 07/11/2025, 12:41:52 am");
58+
});
59+
60+
it("catch bad format from string", () => {
61+
const value = "this is not a good date";
62+
const text = DateTimeFormat(value);
63+
expect(text).toBe("this is not a good date");
64+
});
65+
66+
it("catch bad format from number", () => {
67+
const value = -100;
68+
const text = DateTimeFormat(value);
69+
expect(text).toBe("-100");
70+
});
71+
72+
it("catch bad format from number as string", () => {
73+
const value = "-100";
74+
const text = DateTimeFormat(value);
75+
expect(text).toBe("-100");
76+
});
77+
});
Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,36 @@
1-
import { intlFormat, parseISO } from "date-fns";
2-
3-
const DateTimeFormat = (isoDate: string) =>
4-
intlFormat(parseISO(isoDate), {
5-
weekday: "long",
6-
year: "numeric",
7-
month: "numeric",
8-
day: "numeric",
9-
hour: "numeric",
10-
minute: "numeric",
11-
second: "numeric",
12-
hour12: true,
13-
});
1+
import { fromUnixTime, intlFormat, parseISO } from "date-fns";
2+
3+
const isUnixTimestamp = (value: unknown): boolean => {
4+
if (typeof value !== "number" && typeof value !== "string") return false;
5+
const num = Number(value);
6+
if (!Number.isFinite(num)) return false;
7+
// Check plausible Unix timestamp range: from 1970 to ~year 3000
8+
// Support both seconds and milliseconds
9+
if (num > 0 && num < 10000000000) return true; // seconds (<= 10 digits)
10+
if (num >= 10000000000 && num < 32503680000000) return true; // milliseconds (<= 13 digits)
11+
return false;
12+
};
13+
14+
const DateTimeFormat = (value: string | number): string => {
15+
if (typeof value !== "number" && typeof value !== "string") return `${value}`;
16+
17+
try {
18+
const d = isUnixTimestamp(value)
19+
? fromUnixTime(+value)
20+
: parseISO(`${value}`);
21+
return intlFormat(d, {
22+
weekday: "long",
23+
year: "numeric",
24+
month: "numeric",
25+
day: "numeric",
26+
hour: "numeric",
27+
minute: "numeric",
28+
second: "numeric",
29+
hour12: true,
30+
});
31+
} catch {
32+
return `${value}`;
33+
}
34+
};
1435

1536
export { DateTimeFormat };

frontend/src/locale/IntlProvider.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,20 @@ const getLocale = (short = false) => {
3030
if (short) {
3131
return loc.slice(0, 2);
3232
}
33+
// finally, fallback
34+
if (!loc) {
35+
loc = "en";
36+
}
3337
return loc;
3438
};
3539

3640
const cache = createIntlCache();
3741

3842
const initialMessages = loadMessages(getLocale());
39-
let intl = createIntl({ locale: getLocale(), messages: initialMessages }, cache);
43+
let intl = createIntl(
44+
{ locale: getLocale(), messages: initialMessages },
45+
cache,
46+
);
4047

4148
const changeLocale = (locale: string): void => {
4249
const messages = loadMessages(locale);
@@ -76,4 +83,12 @@ const T = ({
7683
);
7784
};
7885

79-
export { localeOptions, getFlagCodeForLocale, getLocale, createIntl, changeLocale, intl, T };
86+
export {
87+
localeOptions,
88+
getFlagCodeForLocale,
89+
getLocale,
90+
createIntl,
91+
changeLocale,
92+
intl,
93+
T,
94+
};

scripts/ci/frontend-build

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ if hash docker 2>/dev/null; then
1616
-e NODE_OPTIONS=--openssl-legacy-provider \
1717
-v "$(pwd)/frontend:/app/frontend" \
1818
-w /app/frontend "${DOCKER_IMAGE}" \
19-
sh -c "yarn install && yarn lint && yarn build && chown -R $(id -u):$(id -g) /app/frontend"
19+
sh -c "yarn install && yarn lint && yarn vitest run && yarn build && chown -R $(id -u):$(id -g) /app/frontend"
2020

2121
echo -e "${BLUE}${GREEN}Building Frontend Complete${RESET}"
2222
else

0 commit comments

Comments
 (0)