Skip to content
Merged
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
41 changes: 40 additions & 1 deletion frontend/src/lib/date/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
});
});
});

Expand Down Expand Up @@ -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();
Expand Down
49 changes: 42 additions & 7 deletions frontend/src/lib/date/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,18 +50,27 @@ 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";

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)) {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/home/Home.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -703,7 +703,7 @@ describe("Home", () => {

renderWithProviders(<Home />);

const link = screen.getByRole("link");
const link = screen.getByRole("link", { name: /test-leaderboard/i });
expect(link).toHaveAttribute("href", "/leaderboard/42");
});

Expand Down
26 changes: 16 additions & 10 deletions frontend/src/pages/home/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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) => {
Expand All @@ -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 (
Expand Down Expand Up @@ -267,7 +273,7 @@ export default function Home() {
<Grid container spacing={3}>
{activeCompetitions.map((leaderboard) => (
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 4 }} key={leaderboard.id}>
<LeaderboardTile leaderboard={leaderboard} />
<LeaderboardTile leaderboard={leaderboard} now={now} />
</Grid>
))}
</Grid>
Expand Down Expand Up @@ -332,7 +338,7 @@ export default function Home() {
<Grid container spacing={3}>
{beginnerProblems.map((leaderboard) => (
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 4 }} key={leaderboard.id}>
<LeaderboardTile leaderboard={leaderboard} />
<LeaderboardTile leaderboard={leaderboard} now={now} />
</Grid>
))}
</Grid>
Expand All @@ -351,7 +357,7 @@ export default function Home() {
<Grid container spacing={3}>
{privateCompetitions.map((leaderboard) => (
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 4 }} key={leaderboard.id}>
<LeaderboardTile leaderboard={leaderboard} />
<LeaderboardTile leaderboard={leaderboard} now={now} />
</Grid>
))}
</Grid>
Expand All @@ -367,7 +373,7 @@ export default function Home() {
<Grid container spacing={3}>
{closedCompetitions.map((leaderboard) => (
<Grid size={{ xs: 12, sm: 6, md: 4, lg: 4 }} key={leaderboard.id}>
<LeaderboardTile leaderboard={leaderboard} expired />
<LeaderboardTile leaderboard={leaderboard} expired now={now} />
</Grid>
))}
</Grid>
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/pages/home/components/LeaderboardTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Card
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/pages/leaderboard/Leaderboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import Grid from "@mui/material/Grid";
import { memo, useCallback, useEffect, useState } from "react";
import { fetchLeaderBoard, searchUsers } from "../../api/api";
import { fetcherApiCallback } from "../../lib/hooks/useApi";
import { isExpired, toDateUtc } from "../../lib/date/utils";
import { isExpired, toDateLocal } from "../../lib/date/utils";
import RankingsList from "./components/RankingLists";
import CodeBlock from "../../components/codeblock/CodeBlock";
import MarkdownRenderer from "../../components/markdown-renderer/MarkdownRenderer";
Expand Down Expand Up @@ -182,9 +182,9 @@ const LeaderboardContent = memo(function LeaderboardContent() {
if (error) return <ErrorAlert status={errorStatus} message={error} />;
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 (
Expand All @@ -195,7 +195,7 @@ const LeaderboardContent = memo(function LeaderboardContent() {
<Stack direction="row" alignItems="baseline" spacing={2}>
<h1 style={{ margin: 0 }}>{data.name}</h1>
<Typography variant="body2" color="text.secondary">
{toDeadlineUTC(data.deadline)}
{toDeadlineLocal(data.deadline)}
</Typography>
</Stack>
<Stack direction="row" spacing={1}>
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/pages/leaderboard/LeaderboardEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)}
</Typography>
</Box>
<Stack direction="row" spacing={1} alignItems="center">
Expand Down
Loading