From a11c7b97effec9ccecfa51f503f3a9fa1303261c Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Mon, 29 Jun 2026 16:37:47 -0700 Subject: [PATCH] Use local deadline displays and live countdowns --- frontend/src/lib/date/utils.test.ts | 41 +++++++++++++++- frontend/src/lib/date/utils.ts | 49 ++++++++++++++++--- frontend/src/pages/home/Home.test.tsx | 2 +- frontend/src/pages/home/Home.tsx | 26 ++++++---- .../pages/home/components/LeaderboardTile.tsx | 7 +-- .../src/pages/leaderboard/Leaderboard.tsx | 8 +-- .../pages/leaderboard/LeaderboardEditor.tsx | 5 +- 7 files changed, 110 insertions(+), 28 deletions(-) diff --git a/frontend/src/lib/date/utils.test.ts b/frontend/src/lib/date/utils.test.ts index 6d7d5feb..991df8f0 100644 --- a/frontend/src/lib/date/utils.test.ts +++ b/frontend/src/lib/date/utils.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { getTimeLeft, shouldHideTimeRemaining, toDateUtc } from "./utils"; +import { + getTimeLeft, + shouldHideTimeRemaining, + toDateLocal, + toDateUtc, +} from "./utils"; describe("getTimeLeft", () => { beforeEach(() => { @@ -45,6 +50,16 @@ describe("getTimeLeft", () => { expect(result).toBe("2 days 1 hour remaining"); }); + it("calculates minutes and seconds when less than an hour remains", () => { + const mockNow = new Date("2025-03-24T00:00:00.000Z"); + vi.setSystemTime(mockNow); + + const deadline = "2025-03-24T00:30:45.000Z"; + const result = getTimeLeft(deadline); + + expect(result).toBe("30 minutes 45 seconds remaining"); + }); + it("handles datetime objects (equivalent test)", () => { // Set current time to 2025-03-24 00:00:00 UTC const mockNow = new Date("2025-03-24T00:00:00.000Z"); @@ -151,6 +166,16 @@ describe("getTimeLeft", () => { expect(result).toBe("1 day 0 hours remaining"); }); + + it("uses singular labels for 1 minute 1 second", () => { + const mockNow = new Date("2025-03-24T00:00:00.000Z"); + vi.setSystemTime(mockNow); + + const deadline = "2025-03-24T00:01:01.000Z"; + const result = getTimeLeft(deadline); + + expect(result).toBe("1 minute 1 second remaining"); + }); }); }); @@ -179,6 +204,20 @@ describe("toDateUtc", () => { }); }); +describe("toDateLocal", () => { + it("formats datetime in the requested timezone", () => { + const result = toDateLocal("2026-06-30T00:00:00Z", "America/Los_Angeles"); + + expect(result).toBe("Jun 29, 2026, 5:00 PM PDT"); + }); + + it("falls back to UTC if timezone formatting fails", () => { + const result = toDateLocal("2026-06-30T00:00:00Z", "bad/timezone"); + + expect(result).toBe("2026-06-30 00:00 UTC"); + }); +}); + describe("shouldHideTimeRemaining", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/frontend/src/lib/date/utils.ts b/frontend/src/lib/date/utils.ts index 612b6aef..3b07c810 100644 --- a/frontend/src/lib/date/utils.ts +++ b/frontend/src/lib/date/utils.ts @@ -6,13 +6,39 @@ export const toDateUtc = (raw: string) => { return dayjs(raw).utc().format("YYYY-MM-DD HH:mm"); }; +export const toDateLocal = (raw: string, timeZone?: string) => { + const date = new Date(raw); + if (Number.isNaN(date.getTime())) { + return "Invalid date"; + } + + const options: Intl.DateTimeFormatOptions = { + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + timeZoneName: "short", + }; + + if (timeZone) { + options.timeZone = timeZone; + } + + try { + return new Intl.DateTimeFormat(undefined, options).format(date); + } catch { + return `${toDateUtc(raw)} UTC`; + } +}; + /** * Calculate time left until deadline. * Returns formatted string if deadline is in the future, otherwise "ended". * Matches the Python to_time_left function. */ -export const getTimeLeft = (deadline: string): string => { - const now = dayjs().utc(); +export const getTimeLeft = (deadline: string, time: Date = new Date()): string => { + const now = dayjs(time).utc(); const deadlineDate = dayjs(deadline); // Check if the deadline is invalid or in the past @@ -24,9 +50,18 @@ export const getTimeLeft = (deadline: string): string => { return "ended"; } - const diff = deadlineDate.diff(now); - const days = Math.floor(diff / (1000 * 60 * 60 * 24)); - const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const totalSeconds = Math.floor(deadlineDate.diff(now) / 1000); + const days = Math.floor(totalSeconds / (60 * 60 * 24)); + const hours = Math.floor((totalSeconds % (60 * 60 * 24)) / (60 * 60)); + + if (days === 0 && hours === 0) { + const minutes = Math.floor((totalSeconds % (60 * 60)) / 60); + const seconds = totalSeconds % 60; + const minuteLabel = minutes === 1 ? "minute" : "minutes"; + const secondLabel = seconds === 1 ? "second" : "seconds"; + + return `${minutes} ${minuteLabel} ${seconds} ${secondLabel} remaining`; + } const dayLabel = days === 1 ? "day" : "days"; const hourLabel = hours === 1 ? "hour" : "hours"; @@ -34,8 +69,8 @@ export const getTimeLeft = (deadline: string): string => { return `${days} ${dayLabel} ${hours} ${hourLabel} remaining`; }; -export const shouldHideTimeRemaining = (deadline: string): boolean => { - const now = dayjs().utc(); +export const shouldHideTimeRemaining = (deadline: string, time: Date = new Date()): boolean => { + const now = dayjs(time).utc(); const deadlineDate = dayjs(deadline); if (!deadlineDate.isValid() || deadlineDate.isSame(now) || deadlineDate.isBefore(now)) { diff --git a/frontend/src/pages/home/Home.test.tsx b/frontend/src/pages/home/Home.test.tsx index 3340a8db..c072aef6 100644 --- a/frontend/src/pages/home/Home.test.tsx +++ b/frontend/src/pages/home/Home.test.tsx @@ -703,7 +703,7 @@ describe("Home", () => { renderWithProviders(); - const link = screen.getByRole("link"); + const link = screen.getByRole("link", { name: /test-leaderboard/i }); expect(link).toHaveAttribute("href", "/leaderboard/42"); }); diff --git a/frontend/src/pages/home/Home.tsx b/frontend/src/pages/home/Home.tsx index 6089ecc5..80879021 100644 --- a/frontend/src/pages/home/Home.tsx +++ b/frontend/src/pages/home/Home.tsx @@ -68,6 +68,7 @@ export default function Home() { const navigate = useNavigate(); const [isQuickStartOpen, setIsQuickStartOpen] = useState(false); const [isLeaderboardSelectOpen, setIsLeaderboardSelectOpen] = useState(false); + const [now, setNow] = useState(() => new Date()); const useBeta = searchParams.has("use_beta"); const forceRefresh = searchParams.has("force_refresh"); @@ -84,30 +85,35 @@ export default function Home() { call(useBeta, forceRefresh); }, [call, useBeta, forceRefresh]); + useEffect(() => { + const timer = window.setInterval(() => setNow(new Date()), 1000); + return () => window.clearInterval(timer); + }, []); + const leaderboards = data?.leaderboards || []; const activeLeaderboards = leaderboards.filter( - (lb) => !isExpired(lb.deadline) + (lb) => !isExpired(lb.deadline, now) ); const isPrivateCompetition = (lb: LeaderboardData) => lb.visibility === "closed"; const activeCompetitions = leaderboards.filter( (lb) => - !isExpired(lb.deadline) && + !isExpired(lb.deadline, now) && !isBeginnerProblem(lb.name) && !isPrivateCompetition(lb) ); const beginnerProblems = leaderboards.filter( (lb) => - !isExpired(lb.deadline) && + !isExpired(lb.deadline, now) && isBeginnerProblem(lb.name) && !isPrivateCompetition(lb) ); const privateCompetitions = leaderboards.filter( - (lb) => !isExpired(lb.deadline) && isPrivateCompetition(lb) + (lb) => !isExpired(lb.deadline, now) && isPrivateCompetition(lb) ); const closedCompetitions = leaderboards.filter((lb) => - isExpired(lb.deadline) + isExpired(lb.deadline, now) ); const handleLeaderboardSelect = (id: number) => { @@ -116,7 +122,7 @@ export default function Home() { }; const getLeaderboardTimeRemaining = (deadline: string) => { - return shouldHideTimeRemaining(deadline) ? undefined : getTimeLeft(deadline); + return shouldHideTimeRemaining(deadline, now) ? undefined : getTimeLeft(deadline, now); }; return ( @@ -267,7 +273,7 @@ export default function Home() { {activeCompetitions.map((leaderboard) => ( - + ))} @@ -332,7 +338,7 @@ export default function Home() { {beginnerProblems.map((leaderboard) => ( - + ))} @@ -351,7 +357,7 @@ export default function Home() { {privateCompetitions.map((leaderboard) => ( - + ))} @@ -367,7 +373,7 @@ export default function Home() { {closedCompetitions.map((leaderboard) => ( - + ))} diff --git a/frontend/src/pages/home/components/LeaderboardTile.tsx b/frontend/src/pages/home/components/LeaderboardTile.tsx index 0478eee1..03c94494 100644 --- a/frontend/src/pages/home/components/LeaderboardTile.tsx +++ b/frontend/src/pages/home/components/LeaderboardTile.tsx @@ -64,11 +64,12 @@ interface LeaderboardData { interface LeaderboardTileProps { leaderboard: LeaderboardData; expired?: boolean; + now?: Date; } -export default function LeaderboardTile({ leaderboard, expired }: LeaderboardTileProps) { - const timeLeft = getTimeLeft(leaderboard.deadline); - const hideTimeRemaining = shouldHideTimeRemaining(leaderboard.deadline); +export default function LeaderboardTile({ leaderboard, expired, now }: LeaderboardTileProps) { + const timeLeft = getTimeLeft(leaderboard.deadline, now); + const hideTimeRemaining = shouldHideTimeRemaining(leaderboard.deadline, now); return ( ; if (!data) return null; - const toDeadlineUTC = (raw: string) => { + const toDeadlineLocal = (raw: string) => { const verb = isExpired(raw) ? "Ended" : "Ends"; - return `${verb} ${toDateUtc(raw)} UTC`; + return `${verb} ${toDateLocal(raw)}`; }; return ( @@ -195,7 +195,7 @@ const LeaderboardContent = memo(function LeaderboardContent() {

{data.name}

- {toDeadlineUTC(data.deadline)} + {toDeadlineLocal(data.deadline)}
diff --git a/frontend/src/pages/leaderboard/LeaderboardEditor.tsx b/frontend/src/pages/leaderboard/LeaderboardEditor.tsx index 6df68373..8503b8ec 100644 --- a/frontend/src/pages/leaderboard/LeaderboardEditor.tsx +++ b/frontend/src/pages/leaderboard/LeaderboardEditor.tsx @@ -30,7 +30,7 @@ import { ErrorAlert } from "../../components/alert/ErrorAlert"; import MarkdownRenderer from "../../components/markdown-renderer/MarkdownRenderer"; import { SubmissionMode } from "../../lib/types/mode"; import { useAuthStore } from "../../lib/store/authStore"; -import { isExpired, toDateUtc } from "../../lib/date/utils"; +import { isExpired, toDateLocal } from "../../lib/date/utils"; import SubmissionHistorySection from "./components/submission-history/SubmissionHistorySection"; import { useThemeStore } from "../../lib/store/themeStore"; import { @@ -287,7 +287,8 @@ export default function LeaderboardEditor() { color={isExpired(data.deadline) ? "error.main" : "text.secondary"} sx={{ mt: 0.5 }} > - {isExpired(data.deadline) ? "Ended" : "Ends in"} {toDateUtc(data.deadline)} UTC + {isExpired(data.deadline) ? "Ended" : "Ends"}{" "} + {toDateLocal(data.deadline)}