Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 24 additions & 9 deletions frontend/src/__tests__/leaderboard-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
lastUpdated: 1_800_000_000 as number | null,
formatDate: vi.fn(() => "localized timestamp"),
timeAgo: vi.fn(() => "5 minutes ago"),
localizedTimestamp: vi.fn(),
}));

vi.mock("@/hooks/useLeaderboard", () => ({
Expand All @@ -22,9 +21,11 @@ vi.mock("@/hooks/useWallet", () => ({
useWallet: () => ({ publicKey: null }),
}));

vi.mock("@/utils/helpers", () => ({
formatDate: mocks.formatDate,
timeAgo: mocks.timeAgo,
vi.mock("@/components/ui/LocalizedTimestamp", () => ({
default: (props: { timestamp: number; mode?: string }) => {
mocks.localizedTimestamp(props);
return props.mode === "relative" ? "5 minutes ago" : "localized timestamp";
},
}));

import LeaderboardPage from "@/app/leaderboard/page";
Expand All @@ -35,17 +36,31 @@ describe("LeaderboardPage", () => {
mocks.lastUpdated = 1_800_000_000;
});

it("formats the last successful leaderboard refresh", () => {
it("renders relative and absolute localized refresh timestamps", () => {
render(<LeaderboardPage />);

expect(screen.getByText("Last updated: localized timestamp")).toBeInTheDocument();
expect(mocks.formatDate).toHaveBeenCalledWith(1_800_000_000);
expect(screen.getByText(/^Updated 5 minutes ago$/)).toBeInTheDocument();
expect(
screen.getByText(/^Last updated: localized timestamp$/)
).toBeInTheDocument();

const props = mocks.localizedTimestamp.mock.calls.map(([value]) => value);
expect(props).toEqual(
expect.arrayContaining([
expect.objectContaining({
timestamp: 1_800_000_000,
mode: "relative",
}),
expect.objectContaining({ timestamp: 1_800_000_000 }),
])
);
});

it("hides the label before any successful refresh", () => {
it("hides timestamp labels before any successful refresh", () => {
mocks.lastUpdated = null;
render(<LeaderboardPage />);

expect(screen.queryByText(/^Last updated:/)).not.toBeInTheDocument();
expect(mocks.localizedTimestamp).not.toHaveBeenCalled();
});
});
137 changes: 137 additions & 0 deletions frontend/src/__tests__/localized-timestamp.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import React from "react";
import { act } from "react";
import { render, screen, waitFor } from "@testing-library/react";
import { hydrateRoot, type Root } from "react-dom/client";
import { renderToString } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import LocalizedTimestamp from "@/components/ui/LocalizedTimestamp";
import { formatDate, formatTime } from "@/utils/helpers";

const timestamp = Date.UTC(2026, 6, 12, 14, 30) / 1_000;

describe("LocalizedTimestamp", () => {
it("renders deterministic UTC markup during SSR", () => {
const expected = formatDate(timestamp, "en-US", { timeZone: "UTC" });
const html = renderToString(<LocalizedTimestamp timestamp={timestamp} />);

expect(html).toContain(expected);
expect(html).toContain('data-time-zone="UTC"');
expect(html.toLowerCase()).toContain('datetime="2026-07-12t14:30:00.000z"');
});

it("hydrates UTC markup before switching to the browser locale and timezone", async () => {
const serverValue = formatDate(timestamp, "en-US", { timeZone: "UTC" });
const browserValue = formatDate(timestamp, "es-ES", {
timeZone: "Europe/Madrid",
});
const originalLanguages = Object.getOwnPropertyDescriptor(
navigator,
"languages"
);
const resolvedOptions = new Intl.DateTimeFormat().resolvedOptions();
const resolvedOptionsSpy = vi
.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions")
.mockReturnValue({
...resolvedOptions,
locale: "es-ES",
timeZone: "Europe/Madrid",
});
const container = document.createElement("div");
let root: Root | undefined;

Object.defineProperty(navigator, "languages", {
configurable: true,
value: ["es-ES"],
});
container.innerHTML = renderToString(
<LocalizedTimestamp timestamp={timestamp} />
);
document.body.appendChild(container);

try {
expect(container).toHaveTextContent(serverValue);

await act(async () => {
root = hydrateRoot(
container,
<LocalizedTimestamp timestamp={timestamp} />
);
});

await waitFor(() => expect(container).toHaveTextContent(browserValue));
expect(container.querySelector("time")).toHaveAttribute(
"data-time-zone",
"Europe/Madrid"
);
} finally {
if (root) {
await act(async () => root?.unmount());
}
container.remove();
resolvedOptionsSpy.mockRestore();
if (originalLanguages) {
Object.defineProperty(navigator, "languages", originalLanguages);
} else {
Reflect.deleteProperty(navigator, "languages");
}
}
});

it.each([
["America/New_York", "en-US"],
["Europe/Madrid", "es-ES"],
["Asia/Tokyo", "ja-JP"],
])("formats the same instant in %s", (timeZone, locale) => {
const expected = formatDate(timestamp, locale, { timeZone });

render(
<LocalizedTimestamp
timestamp={timestamp}
locale={locale}
timeZone={timeZone}
/>
);

expect(screen.getByText(expected)).toHaveAttribute(
"data-time-zone",
timeZone
);
});

it("supports a time-only view", () => {
const expected = formatTime(timestamp, "en-GB", {
timeZone: "Europe/London",
});

render(
<LocalizedTimestamp
timestamp={timestamp}
mode="time"
locale="en-GB"
timeZone="Europe/London"
/>
);

expect(screen.getByText(expected)).toBeInTheDocument();
});

it("renders relative timestamps deterministically when nowMs is supplied", () => {
const nowMs = Date.UTC(2026, 6, 12, 16, 30);

render(
<LocalizedTimestamp
timestamp={timestamp}
mode="relative"
locale="en-US"
nowMs={nowMs}
/>
);

expect(screen.getByText("2 hours ago")).toBeInTheDocument();
});

it("renders invalid input as an em dash", () => {
render(<LocalizedTimestamp timestamp={Number.NaN} />);
expect(screen.getByText("—")).toBeInTheDocument();
});
});
6 changes: 3 additions & 3 deletions frontend/src/app/leaderboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import LeaderboardTabs from "@/components/leaderboard/LeaderboardTabs";
import EmptyState from "@/components/ui/EmptyState";
import ErrorBoundary from "@/components/ui/ErrorBoundary";
import { FiAward } from "react-icons/fi";
import { timeAgo, formatDate } from "@/utils/helpers";
import LocalizedTimestamp from "@/components/ui/LocalizedTimestamp";

export default function LeaderboardPage() {
const [tab, setTab] = useState<LeaderboardTab>("top_predictors");
Expand Down Expand Up @@ -45,7 +45,7 @@ export default function LeaderboardPage() {
</p>
{!loading && lastUpdated && (
<p className="text-xs text-slate-500">
Updated {timeAgo(lastUpdated)}
Updated <LocalizedTimestamp timestamp={lastUpdated} mode="relative" />
</p>
)}
</div>
Expand All @@ -54,7 +54,7 @@ export default function LeaderboardPage() {

{lastUpdated && (
<div className="mb-3 text-right text-xs text-slate-500">
Last updated: {formatDate(lastUpdated)}
Last updated: <LocalizedTimestamp timestamp={lastUpdated} />
</div>
)}

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/leaderboard/LeaderboardTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from "react";
import type { PlayerStats } from "@/types";
import PlayerRow from "./PlayerRow";
import { FiUser } from "react-icons/fi";
import { formatDate } from "@/utils/helpers";
import LocalizedTimestamp from "@/components/ui/LocalizedTimestamp";

interface LeaderboardTableProps {
players: PlayerStats[];
Expand Down Expand Up @@ -49,7 +49,7 @@ export default function LeaderboardTable({

{lastUpdatedAt && (
<div className="mb-3 text-right text-xs text-slate-500">
Last updated: {formatDate(lastUpdatedAt)}
Last updated: <LocalizedTimestamp timestamp={lastUpdatedAt} />
</div>
)}

Expand Down
115 changes: 115 additions & 0 deletions frontend/src/components/ui/LocalizedTimestamp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"use client";

import React, { useEffect, useState } from "react";
import {
formatDate,
formatTime,
timeAgo,
toTimestampMs,
} from "@/utils/helpers";

const SERVER_LOCALE = "en-US";
const SERVER_TIME_ZONE = "UTC";

type TimestampMode = "date" | "time" | "relative";

interface FormattingContext {
locale: string;
timeZone: string;
}

export interface LocalizedTimestampProps {
timestamp: number;
mode?: TimestampMode;
locale?: string;
timeZone?: string;
options?: Intl.DateTimeFormatOptions;
className?: string;
nowMs?: number;
}

/**
* Resolve the browser's preferred locale and IANA timezone. Kept behind a
* function so importing this module during SSR never touches browser globals.
*/
export function getBrowserFormattingContext(): FormattingContext {
if (typeof navigator === "undefined") {
return { locale: SERVER_LOCALE, timeZone: SERVER_TIME_ZONE };
}

const locale = navigator.languages?.[0] || navigator.language || SERVER_LOCALE;
let timeZone = SERVER_TIME_ZONE;

try {
timeZone =
new Intl.DateTimeFormat().resolvedOptions().timeZone || SERVER_TIME_ZONE;
} catch {
// Keep the deterministic UTC fallback when Intl data is unavailable.
}

return { locale, timeZone };
}

/**
* Hydration-safe timestamp renderer.
*
* Server output and the first client render both use en-US/UTC. After mount,
* the component switches to the viewer's actual locale and timezone. This
* avoids rendering server-local time into the HTML and prevents hydration
* mismatches for users outside the server timezone.
*/
export default function LocalizedTimestamp({
timestamp,
mode = "date",
locale,
timeZone,
options = {},
className,
nowMs,
}: LocalizedTimestampProps) {
const [context, setContext] = useState<FormattingContext>(() => ({
locale: locale || SERVER_LOCALE,
timeZone: timeZone || SERVER_TIME_ZONE,
}));

useEffect(() => {
const browser = getBrowserFormattingContext();
const next = {
locale: locale || browser.locale,
timeZone: timeZone || browser.timeZone,
};

setContext((current) =>
current.locale === next.locale && current.timeZone === next.timeZone
? current
: next
);
}, [locale, timeZone]);

const timestampMs = toTimestampMs(timestamp);
if (!Number.isFinite(timestampMs)) {
return <span className={className}>—</span>;
}

let value: string;
if (mode === "relative") {
value = timeAgo(timestamp, context.locale, nowMs);
} else {
const localizedOptions = { ...options, timeZone: context.timeZone };
value =
mode === "time"
? formatTime(timestamp, context.locale, localizedOptions)
: formatDate(timestamp, context.locale, localizedOptions);
}

return (
<time
className={className}
dateTime={new Date(timestampMs).toISOString()}
data-time-zone={context.timeZone}
suppressHydrationWarning
>
{value}
</time>
);
}
Loading