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 + setActiveLink("/contests")} + > + Contests + { {/* Same links as above */} Interview Experiences Events + Contests Getting Started Team {!user ? ( diff --git a/src/utils/api_routes.js b/src/utils/api_routes.js index 7b49d32..99bce6b 100644 --- a/src/utils/api_routes.js +++ b/src/utils/api_routes.js @@ -10,4 +10,5 @@ export const API_ROUTES = { HOME: "/", GET_STARTED: "/getting_started", ROLES: "/users/roles", + CONTESTS: "/contests/", }; diff --git a/src/utils/contestCache.js b/src/utils/contestCache.js new file mode 100644 index 0000000..9df9ac5 --- /dev/null +++ b/src/utils/contestCache.js @@ -0,0 +1,89 @@ +const CACHE_KEY = "contest_notifications_cache_v2"; + +const getISTParts = (date = new Date()) => { + const formatter = new Intl.DateTimeFormat("en-GB", { + timeZone: "Asia/Kolkata", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + + const parts = formatter.formatToParts(date); + const read = (type) => + Number(parts.find((part) => part.type === type)?.value || 0); + + return { + year: read("year"), + month: read("month"), + day: read("day"), + hour: read("hour"), + minute: read("minute"), + second: read("second"), + }; +}; + +export const getLastRefreshBoundary = (date = new Date()) => { + const { year, month, day, hour, minute } = getISTParts(date); + let boundaryYear = year; + let boundaryMonth = month; + let boundaryDay = day; + + if (hour === 0 && minute < 1) { + const previousDay = new Date(Date.UTC(year, month - 1, day)); + previousDay.setUTCDate(previousDay.getUTCDate() - 1); + boundaryYear = previousDay.getUTCFullYear(); + boundaryMonth = previousDay.getUTCMonth() + 1; + boundaryDay = previousDay.getUTCDate(); + } + + return Date.UTC(boundaryYear, boundaryMonth - 1, boundaryDay, 0, 1, 0) - + 5.5 * 60 * 60 * 1000; +}; + +export const isContestCacheValid = (cachedAt) => { + if (!cachedAt) { + return false; + } + + const cachedTime = new Date(cachedAt).getTime(); + return cachedTime >= getLastRefreshBoundary(); +}; + +export const readContestCache = () => { + try { + const raw = localStorage.getItem(CACHE_KEY); + if (!raw) { + return null; + } + + const parsed = JSON.parse(raw); + if (!isContestCacheValid(parsed.cached_at)) { + localStorage.removeItem(CACHE_KEY); + return null; + } + + return parsed; + } catch (error) { + localStorage.removeItem(CACHE_KEY); + return null; + } +}; + +export const writeContestCache = (payload) => { + localStorage.setItem( + CACHE_KEY, + JSON.stringify({ + cached_at: payload.cached_at, + refresh_boundary: payload.refresh_boundary, + contests: payload.contests || [], + }) + ); +}; + +export const clearContestCache = () => { + localStorage.removeItem(CACHE_KEY); +}; diff --git a/src/utils/contestFilters.js b/src/utils/contestFilters.js new file mode 100644 index 0000000..77515f1 --- /dev/null +++ b/src/utils/contestFilters.js @@ -0,0 +1,19 @@ +export const shouldIncludeContest = (contest) => { + const platform = contest?.platform; + const name = (contest?.name || "").toLowerCase(); + + if (platform === "codeforces") { + return true; + } + if (platform === "codechef") { + return name.includes("starter"); + } + if (platform === "atcoder") { + return name.includes("beginner"); + } + + return true; +}; + +export const filterRelevantContests = (contests = []) => + contests.filter(shouldIncludeContest);