diff --git a/src/App.js b/src/App.js
index 573589a..4637fa3 100644
--- a/src/App.js
+++ b/src/App.js
@@ -29,6 +29,7 @@ import ViewProfile from "./components/Profile/ViewProfile";
import ResetPassword from "./components/Profile/ResetPassword";
import ResetPasswordForm from "./components/Profile/ResetPasswordForm";
import ResetPasswordSent from "./components/Profile/ResetPasswordSent";
+import ContestNotifications from "./components/Contests/ContestNotifications";
function App() {
return (
@@ -47,6 +48,7 @@ function App() {
} />
} />
} />
+ } />
} />
} />
{
+ const relevantContests = filterRelevantContests(contests);
+ if (!platform) {
+ return relevantContests;
+ }
+ return relevantContests.filter((contest) => contest.platform === platform);
+};
+export const getContestNotifications = async (platform = null) => {
+ const cached = readContestCache();
+ if (cached) {
+ return {
+ cached_at: cached.cached_at,
+ refresh_boundary: cached.refresh_boundary,
+ contests: filterByPlatform(cached.contests, platform),
+ from_browser_cache: true,
+ };
+ }
+
+ const response = await axios.get(CONTESTS_URL);
+ const data = response.data;
+ writeContestCache(data);
+
+ return {
+ cached_at: data.cached_at,
+ refresh_boundary: data.refresh_boundary,
+ contests: filterByPlatform(data.contests || [], platform),
+ from_browser_cache: false,
+ };
+};
diff --git a/src/components/Contests/ContestNotifications.jsx b/src/components/Contests/ContestNotifications.jsx
new file mode 100644
index 0000000..8e74629
--- /dev/null
+++ b/src/components/Contests/ContestNotifications.jsx
@@ -0,0 +1,252 @@
+import React, { useEffect, useMemo, useState } from "react";
+import {
+ Badge,
+ Box,
+ Button,
+ Flex,
+ Heading,
+ Link,
+ SimpleGrid,
+ Stack,
+ Text,
+ useToast,
+} from "@chakra-ui/react";
+import useLoading from "../../hooks/useLoading";
+import Loader from "../Loader";
+import { getContestNotifications } from "../../api/contests";
+
+const PLATFORM_COLORS = {
+ codeforces: "#E53E3E", // Adjusted to match image's slightly deeper red
+ codechef: "#D69E2E", // Adjusted to match image's yellow/orange
+ atcoder: "#3182CE", // Adjusted to match image's blue
+};
+
+const PLATFORM_TEXT_COLORS = {
+ codeforces: "white",
+ codechef: "white", // Changed to white to match the image exactly
+ atcoder: "white",
+};
+
+const FILTER_OPTIONS = [
+ { value: "all", label: "All" },
+ { value: "codeforces", label: "Codeforces" },
+ { value: "codechef", label: "CodeChef" },
+ { value: "atcoder", label: "AtCoder" },
+];
+
+const formatStartTime = (isoString) => {
+ if (!isoString) {
+ return "TBD";
+ }
+
+ // Clist timestamps are UTC; treat timezone-less values as UTC before IST display.
+ const normalized = /[zZ]|[+-]\d{2}:\d{2}$/.test(isoString)
+ ? isoString
+ : `${isoString}Z`;
+ const date = new Date(normalized);
+ const parts = new Intl.DateTimeFormat("en-IN", {
+ timeZone: "Asia/Kolkata",
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: true,
+ }).formatToParts(date);
+
+ const read = (type) =>
+ parts.find((part) => part.type === type)?.value || "";
+
+ const day = Number(read("day"));
+ const suffix =
+ day >= 11 && day <= 13
+ ? "th"
+ : { 1: "st", 2: "nd", 3: "rd" }[day % 10] || "th";
+
+ // Force lowercase am/pm to match screenshot exactly
+ const dayPeriod = read("dayPeriod").toLowerCase();
+
+ return `${day}${suffix} ${read("month")}, ${read("year")} at ${read("hour")}:${read("minute")} ${dayPeriod} IST.`;
+};
+
+const renderNotificationContent = (contest) => {
+ const formattedStart =
+ contest.start_time_ist ||
+ formatStartTime(contest.start_time);
+
+ return (
+
+
+ {contest.name} will start on {formattedStart}
+
+ Contest duration is {contest.duration_text}.
+
+ Contest link:{" "}
+
+ {contest.url}
+
+
+ Happy Coding! 😃
+
+ );
+};
+
+const ContestCard = ({ contest }) => (
+
+
+
+ {contest.platform_label}
+
+
+ UPCOMING
+
+
+
+ {renderNotificationContent(contest)}
+
+);
+
+const ContestNotifications = () => {
+ const { loading, setLoading } = useLoading();
+ const [contests, setContests] = useState([]);
+ const [platform, setPlatform] = useState("all");
+ const toast = useToast();
+
+ useEffect(() => {
+ const loadContests = async () => {
+ setLoading(true);
+ try {
+ const selectedPlatform = platform === "all" ? null : platform;
+ const data = await getContestNotifications(selectedPlatform);
+ setContests(data.contests || []);
+ } catch (error) {
+ toast({
+ title: "Could not load contests",
+ description:
+ error?.response?.data?.detail ||
+ "Please verify Clist API credentials on the backend.",
+ status: "error",
+ duration: 5000,
+ isClosable: true,
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ loadContests();
+ }, [platform, setLoading, toast]);
+
+ const filteredContests = useMemo(() => contests, [contests]);
+
+ if (loading) {
+ return ;
+ }
+
+ return (
+
+
+
+ Competitive Programming Contests
+
+
+ Keep track of upcoming contests on Codeforces, CodeChef, and AtCoder.
+
+
+
+ {FILTER_OPTIONS.map((option) => {
+ const isActive = platform === option.value;
+ return (
+
+ );
+ })}
+
+
+
+ {filteredContests.length === 0 ? (
+
+
+ No upcoming contests found.
+
+
+ The backend refreshes contest data daily at 12:01 AM IST.
+
+
+ ) : (
+
+ {filteredContests.map((contest) => (
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default ContestNotifications;
\ No newline at end of file
diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx
index 5a1750b..c69111f 100644
--- a/src/components/Layout.jsx
+++ b/src/components/Layout.jsx
@@ -138,6 +138,13 @@ const Layout = () => {
>
Events
+