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)}