diff --git a/package-lock.json b/package-lock.json index 20857327..235a05a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@code4rena/components-library", - "version": "4.1.0", + "version": "4.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@code4rena/components-library", - "version": "4.1.0", + "version": "4.2.1", "license": "ISC", "dependencies": { "clsx": "^1.2.1", diff --git a/src/lib/ContestStatus/ContestStatus.types.ts b/src/lib/ContestStatus/ContestStatus.types.ts index 95639fdc..3401ab86 100644 --- a/src/lib/ContestStatus/ContestStatus.types.ts +++ b/src/lib/ContestStatus/ContestStatus.types.ts @@ -12,3 +12,21 @@ export interface ContestStatusProps { /** HTML element identifier */ id?: string; } + +export const AuditStatus = { + Booking: "Booking", + PreAudit: "Pre-Audit", + Active: "Active", + /** Paused: The audit is in between Rolling Triage cohorts */ + Paused: "Paused", + Review: "Review", + Judging: "Judging", + PJQA: "Post-Judging QA", + JudgingComplete: "Judging Complete", + Awarding: "Awarding", + Reporting: "Reporting", + Completed: "Completed", + LostDeal: "Lost Deal", +} as const; +// Take the AuditStatus object, and make a string literal type of the values +export type AuditStatus = (typeof AuditStatus)[keyof typeof AuditStatus]; diff --git a/src/lib/ContestTile/CompactTemplate.tsx b/src/lib/ContestTile/CompactTemplate.tsx index c2344683..42f445d6 100644 --- a/src/lib/ContestTile/CompactTemplate.tsx +++ b/src/lib/ContestTile/CompactTemplate.tsx @@ -3,8 +3,8 @@ import clsx from "clsx"; import { BountyTileData, ContestSchedule, ContestTileData, ContestTileProps, ContestTileVariant } from "./ContestTile.types"; import { Status, TagSize, TagVariant } from '../types'; import { ContestStatus } from '../ContestStatus'; -import { Countdown } from './ContestTile'; -import { getDates } from '../../utils/time'; +import { ContestCountdown } from './ContestTile'; +import { getContestSchedule } from '../../utils/time'; import { Tag } from '../Tag'; import { Icon } from '../Icon'; import wolfbotIcon from "../../../public/icons/wolfbot.svg"; @@ -28,7 +28,7 @@ export default function CompactTemplate({ c4contesttile: true, compact: true }); - + return
@@ -82,7 +82,7 @@ const IsContest = ({title, isDarkTile = true, contestData, sponsorUrl, sponsorIm } if (startDate && endDate) { - const newTimelineObject = getDates(contestData.startDate, contestData.endDate); + const newTimelineObject = getContestSchedule(contestData); setContestTimelineObject(newTimelineObject); } } @@ -90,7 +90,7 @@ const IsContest = ({title, isDarkTile = true, contestData, sponsorUrl, sponsorIm useEffect(() => { if (contestData && startDate && endDate) { - const newTimelineObject = getDates(startDate, endDate); + const newTimelineObject = getContestSchedule(contestData); setContestTimelineObject(newTimelineObject); } }, [contestData]) @@ -104,14 +104,12 @@ const IsContest = ({title, isDarkTile = true, contestData, sponsorUrl, sponsorIm status={contestTimelineObject.contestStatus} /> {contestTimelineObject.contestStatus !== Status.ENDED && (
-
- )} + )} }

{contestType === "Audit + mitigation review" @@ -208,7 +206,7 @@ const IsBounty = ({title, isDarkTile = true, bountyData, sponsorUrl, sponsorImag break; } } - + return (

diff --git a/src/lib/ContestTile/ContestTile.stories.tsx b/src/lib/ContestTile/ContestTile.stories.tsx index 5ef8ed65..14eccada 100644 --- a/src/lib/ContestTile/ContestTile.stories.tsx +++ b/src/lib/ContestTile/ContestTile.stories.tsx @@ -1,7 +1,9 @@ import React, { Fragment } from "react"; +import { addDays, subDays } from "date-fns"; import { ContestTile } from "./ContestTile"; import { Meta, StoryObj } from "@storybook/react"; import { CodingLanguage, ContestEcosystem, ContestTileVariant } from "./ContestTile.types"; +import { AuditStatus } from "../types"; const meta: Meta = { component: ContestTile, @@ -36,6 +38,7 @@ const defaultArgs = { htmlId: "", contestData: { codeAccess: "public", + cohorts: [], contestType: "Open Audit", isUserCertified: false, contestId: 321, @@ -75,6 +78,25 @@ export const ContestTileUpcoming: Story = (args) => { }; +export const ContestTileUpcomingRollingTriage: Story = (args) => { + const isDark = args.variant === ContestTileVariant.DARK || args.variant === ContestTileVariant.COMPACT_DARK; + + return + + + +}; + export const ContestTileLive: Story = (args) => { const isDark = args.variant === ContestTileVariant.DARK || args.variant === ContestTileVariant.COMPACT_DARK; @@ -94,6 +116,61 @@ export const ContestTileLive: Story = (args) => { }; +export const ContestTileLiveCohort1: Story = (args) => { + const isDark = args.variant === ContestTileVariant.DARK || args.variant === ContestTileVariant.COMPACT_DARK; + + return + + + +}; +export const ContestTileLivePreCohort2: Story = (args) => { + const isDark = args.variant === ContestTileVariant.DARK || args.variant === ContestTileVariant.COMPACT_DARK; + + return + + + +}; +export const ContestTileLiveAwaitingCohort3: Story = (args) => { + const isDark = args.variant === ContestTileVariant.DARK || args.variant === ContestTileVariant.COMPACT_DARK; + + return + + + +}; + export const ContestTileEnded: Story = (args) => { const isDark = args.variant === ContestTileVariant.DARK || args.variant === ContestTileVariant.COMPACT_DARK; @@ -127,7 +204,11 @@ export const BountyTile: Story = (args) => { } ContestTileUpcoming.parameters = parameters; +ContestTileUpcomingRollingTriage.parameters = parameters; ContestTileLive.parameters = parameters; +ContestTileLiveCohort1.parameters = parameters; +ContestTileLivePreCohort2.parameters = parameters; +ContestTileLiveAwaitingCohort3.parameters = parameters; ContestTileEnded.parameters = parameters; BountyTile.parameters = parameters; @@ -136,16 +217,107 @@ ContestTileUpcoming.args = { contestData: { ...defaultArgs.contestData, startDate: "2030-07-12T18:00:00Z", - endDate: "2030-07-21T18:00:00.000Z" + endDate: "2030-07-21T18:00:00.000Z", + status: AuditStatus.PreAudit, + } +}; +ContestTileUpcomingRollingTriage.args = { + ...defaultArgs, + contestData: { + ...defaultArgs.contestData, + cohorts: [{ + name: "cohort-1", + pauseTime: addDays(Date.now(), 6).toISOString(), + resumeTime: null + }, { + name: "cohort-2", + pauseTime: addDays(Date.now(), 13).toISOString(), + resumeTime: addDays(Date.now(), 9).toISOString(), + }, { + name: "cohort-3", + pauseTime: null, + resumeTime: addDays(Date.now(), 16).toISOString(), + }], + startDate: addDays(Date.now(), 3).toISOString(), + endDate: addDays(Date.now(), 20).toISOString(), + status: AuditStatus.PreAudit, } }; + ContestTileLive.args = { ...defaultArgs, contestData: { ...defaultArgs.contestData, startDate: "2023-07-12T18:00:00Z", - endDate: "2030-07-21T18:00:00.000Z" + endDate: "2030-07-21T18:00:00.000Z", + status: AuditStatus.Active, + } +}; +ContestTileLiveCohort1.args = { + ...defaultArgs, + contestData: { + ...defaultArgs.contestData, + cohorts: [{ + name: "cohort-1", + pauseTime: addDays(Date.now(), 4).toISOString(), + resumeTime: null + }, { + name: "cohort-2", + pauseTime: addDays(Date.now(), 11).toISOString(), + resumeTime: addDays(Date.now(), 7).toISOString(), + }, { + name: "cohort-3", + pauseTime: null, + resumeTime: addDays(Date.now(), 14).toISOString(), + }], + startDate: subDays(Date.now(), 1).toISOString(), + endDate: addDays(Date.now(), 18).toISOString(), + status: AuditStatus.Active, + } +}; +ContestTileLivePreCohort2.args = { + ...defaultArgs, + contestData: { + ...defaultArgs.contestData, + cohorts: [{ + name: "cohort-1", + pauseTime: subDays(Date.now(), 1).toISOString(), + resumeTime: null + }, { + name: "cohort-2", + pauseTime: addDays(Date.now(), 6).toISOString(), + resumeTime: addDays(Date.now(), 2).toISOString(), + }, { + name: "cohort-3", + pauseTime: null, + resumeTime: addDays(Date.now(), 9).toISOString(), + }], + startDate: subDays(Date.now(), 6).toISOString(), + endDate: addDays(Date.now(), 16).toISOString(), + status: AuditStatus.Paused, + } +}; +ContestTileLiveAwaitingCohort3.args = { + ...defaultArgs, + contestData: { + ...defaultArgs.contestData, + cohorts: [{ + name: "cohort-1", + pauseTime: subDays(Date.now(), 11).toISOString(), + resumeTime: null + }, { + name: "cohort-2", + pauseTime: subDays(Date.now(), 4).toISOString(), + resumeTime: subDays(Date.now(), 8).toISOString(), + }, { + name: "cohort-3", + pauseTime: null, + resumeTime: subDays(Date.now(), 1).toISOString(), + }], + startDate: subDays(Date.now(), 16).toISOString(), + endDate: addDays(Date.now(), 6).toISOString(), + status: AuditStatus.Paused, } }; @@ -154,7 +326,8 @@ ContestTileEnded.args = { contestData: { ...defaultArgs.contestData, startDate: "2023-07-12T18:00:00Z", - endDate: "2023-07-21T18:00:00Z" + endDate: "2023-07-21T18:00:00Z", + status: AuditStatus.Review, } }; diff --git a/src/lib/ContestTile/ContestTile.tsx b/src/lib/ContestTile/ContestTile.tsx index 2261d594..a5ed2a59 100644 --- a/src/lib/ContestTile/ContestTile.tsx +++ b/src/lib/ContestTile/ContestTile.tsx @@ -6,7 +6,7 @@ import { CountdownProps, } from "./ContestTile.types"; import { getDates } from "../../utils/time"; -import { Status } from "../ContestStatus/ContestStatus.types"; +import { AuditStatus, Status } from "../ContestStatus/ContestStatus.types"; import { formatDistanceToNow, formatDistanceToNowStrict } from "date-fns"; import "./ContestTile.scss"; import CompactTemplate from "./CompactTemplate"; @@ -28,9 +28,9 @@ export const Countdown = ({ }: CountdownProps) => { const secondsInDay = 86400; const [lessThan24h, setLessThan24h] = useState(false); - const [contestTimer, setContestTimer] = useState(); + const [contestTimer, setContestTimer] = useState>(); - const getCountdownTarget = (schedule: ContestSchedule): Date => { + const getCountdownTarget = (schedule: Pick): Date => { if (schedule.contestStatus === Status.LIVE) { return schedule.end; } @@ -124,6 +124,42 @@ export const Countdown = ({ ); }; +export const ContestCountdown = ({ + schedule, + updateContestStatus +}: { + schedule: ContestSchedule, + updateContestStatus: CountdownProps["updateContestStatus"] +}) => { + let text = "Ends in "; + let start = schedule.start.toISOString(); + let end = schedule.end.toISOString(); + if (schedule.contestStatus === Status.UPCOMING) { + text = "Starts in "; + } else if (schedule.contestStatus === Status.LIVE) { + if (schedule.status === AuditStatus.Paused && schedule.resume && +schedule.resume >= Date.now()) { + text = "Next submission phase starts in "; + start = schedule.resume.toISOString(); + } else if (schedule.status === AuditStatus.Paused && schedule.resume && +schedule.resume <= Date.now()) { + // The resume time has elapsed, give a generic time for now + return ( +
+ Next submission phase starts soon +
+ ); + } else if (schedule.status === AuditStatus.Active && schedule.pause && +schedule.pause >= Date.now()) { + text = "Current submission phase ends in "; + end = schedule.pause.toISOString(); + } + } + return Countdown({ + start, + end, + text, + updateContestStatus, + }); +}; + /** * A stylized Code4rena contest tile for displaying information pertaining to upcoming, live, and finalized contests. * This component has 4 available variants. diff --git a/src/lib/ContestTile/ContestTile.types.ts b/src/lib/ContestTile/ContestTile.types.ts index 87bd0ef5..fe130e4d 100644 --- a/src/lib/ContestTile/ContestTile.types.ts +++ b/src/lib/ContestTile/ContestTile.types.ts @@ -1,5 +1,5 @@ import { ReactNode } from "react"; -import { Status } from "../ContestStatus/ContestStatus.types"; +import { Status, AuditStatus } from "../ContestStatus/ContestStatus.types"; export enum ContestTileVariant { LIGHT = "LIGHT", @@ -12,6 +12,12 @@ export type ContestEcosystem = "Algorand" | "Aptos" | "Blast" | "Cosmos" | "EVM" export type CodingLanguage = "Cairo" | "GO" | "HUFF" | "Ink" | "Move" | "Noir" | "Other" | "Rain" | "Rust" | "Rust evm" | "Solidity" | "Vyper" | "Yul"; +export interface ContestCohort { + resumeTime: string | null; + pauseTime: string | null; + name: string; +} + export interface ContestTileProps { /** An html `id` for the contest tile's wrapping div. */ htmlId?: string; @@ -53,6 +59,8 @@ export interface BountyTileData { export interface ContestTileData { /** String indicating required access for viewing contest. */ codeAccess: string; + /** Array of cohorts for Rolling Triage audits. Empty for normal audits */ + cohorts: ContestCohort[]; /** String indicating a specific categorization for the current contest. */ contestType?: string; /** Unique numerical identifier for the current contest. */ @@ -79,9 +87,10 @@ export interface ContestTileData { endDate: string; /** Boolean indicating certification status of logged in user. Required for viewing certain contests. */ isUserCertified: boolean; + status: AuditStatus; } -export interface ContestSchedule { +export interface BaseContestSchedule { contestStatus?: Status; botRaceStatus?: Status; start: Date; @@ -94,6 +103,15 @@ export interface ContestSchedule { formattedDuration: string; } +export interface ContestSchedule extends BaseContestSchedule { + status: AuditStatus; + end: Date; + /** The time the current cohort will pause. */ + pause: Date | null; + /** The time the current cohort will resume. */ + resume: Date | null; +} + export interface CountdownProps { start: string; end: string; diff --git a/src/lib/ContestTile/DefaultTemplate.tsx b/src/lib/ContestTile/DefaultTemplate.tsx index 0941e592..2d44293f 100644 --- a/src/lib/ContestTile/DefaultTemplate.tsx +++ b/src/lib/ContestTile/DefaultTemplate.tsx @@ -1,11 +1,11 @@ import React, { Fragment, useCallback, useEffect, useState } from "react"; import clsx from "clsx"; import wolfbotIcon from "../../../public/icons/wolfbot.svg"; -import { BountyTileData, ContestSchedule, ContestTileData, ContestTileProps, ContestTileVariant } from "./ContestTile.types"; +import { BaseContestSchedule, BountyTileData, ContestSchedule, ContestTileData, ContestTileProps, ContestTileVariant } from "./ContestTile.types"; import { DropdownLink, Status, TagSize, TagVariant } from "../types"; import { ContestStatus } from "../ContestStatus"; -import { Countdown } from "./ContestTile"; -import { getDates } from "../../utils/time"; +import { ContestCountdown, Countdown } from "./ContestTile"; +import { getDates, getContestSchedule } from "../../utils/time"; import { isBefore } from "date-fns"; import { Dropdown } from "../Dropdown"; import { Icon } from "../Icon"; @@ -32,7 +32,7 @@ export default function DefaultTemplate({ const [canViewContest, setCanViewContest] = useState(false); const [dropdownLinks, setDropdownLinks] = useState([]); const [contestTimelineObject, setContestTimelineObject] = useState(); - const [bountyTimelineObject, setBountyTimelineObject] = useState(); + const [bountyTimelineObject, setBountyTimelineObject] = useState(); const updateContestTileStatus = useCallback(() => { if (contestData) { @@ -41,7 +41,7 @@ export default function DefaultTemplate({ updateContestStatus(); } if (contestData.startDate) { - const newTimelineObject = getDates(contestData.startDate, contestData.endDate); + const newTimelineObject = getContestSchedule(contestData); setContestTimelineObject(newTimelineObject); } } @@ -72,9 +72,8 @@ export default function DefaultTemplate({ if (contestData) { setHasBotRace(!!contestData.botFindingsRepo); if (contestData.startDate && contestData.endDate) { - const newTimelineObject = getDates( - contestData.startDate, - contestData.endDate + const newTimelineObject = getContestSchedule( + contestData ); setContestTimelineObject(newTimelineObject); } @@ -307,7 +306,7 @@ function IsContest({

e.stopPropagation()} + onClick={(e) => e.stopPropagation()} > {title} @@ -329,7 +328,7 @@ function IsContest({ size={TagSize.NARROW} /> )} - {ecosystem && : undefined} @@ -361,15 +360,9 @@ function IsContest({ />} {contestData && contestTimelineObject && contestTimelineObject.contestStatus !== Status.ENDED && (
-
)} @@ -407,7 +400,7 @@ function IsBounty({ bountyData: BountyTileData; isDarkTile: boolean; updateBountyTileStatus?: () => void; - bountyTimelineObject?: ContestSchedule | undefined; + bountyTimelineObject?: BaseContestSchedule | undefined; }) { const { bountyUrl, amount, startDate, ecosystem, languages } = bountyData; const endDate = "2999-01-01T00:00:00Z" @@ -467,7 +460,7 @@ function IsBounty({
{description} {(ecosystem || (languages && languages.length > 0)) &&
- {ecosystem && : undefined} diff --git a/src/utils/time.test.ts b/src/utils/time.test.ts new file mode 100644 index 00000000..59b217fd --- /dev/null +++ b/src/utils/time.test.ts @@ -0,0 +1,389 @@ +import { describe, expect, test } from "@jest/globals"; +import { addHours, format } from "date-fns"; +import { DateTime } from "luxon"; +import { getContestSchedule, getCurrentCohortDates } from "./time"; +import { AuditStatus, ContestCohort, Status } from "../types"; + +describe("utils/time", () => { + describe("getCurrentCohortDates", () => { + test("gets the correct, current cohort", () => { + // First cohort + let cohorts:ContestCohort[] = [{ + name: "cohort-1", + resumeTime: null, + pauseTime: new Date(Date.now() + 1000).toISOString(), + }, { + name: "cohort-2", + resumeTime: new Date(Date.now() + 2000).toISOString(), + pauseTime: new Date(Date.now() + 3000).toISOString(), + }, { + name: "cohort-3", + resumeTime: new Date(Date.now() + 4000).toISOString(), + pauseTime: null, + }]; + let dates = getCurrentCohortDates(cohorts); + + expect(dates).toStrictEqual({ + resumeDate: null, + pauseDate: new Date(cohorts[0].pauseTime!), + }); + + // Upcoming 2nd cohort + cohorts = [{ + name: "cohort-1", + resumeTime: null, + pauseTime: new Date(Date.now() - 2000).toISOString(), + }, { + name: "cohort-2", + resumeTime: new Date(Date.now() + 1000).toISOString(), + pauseTime: new Date(Date.now() + 2000).toISOString(), + }, { + name: "cohort-3", + resumeTime: new Date(Date.now() + 3000).toISOString(), + pauseTime: null, + }]; + dates = getCurrentCohortDates(cohorts); + + expect(dates).toStrictEqual({ + resumeDate: new Date(cohorts[1].resumeTime!), + pauseDate: new Date(cohorts[1].pauseTime!), + }); + + // Active 2nd cohort + cohorts = [{ + name: "cohort-1", + resumeTime: null, + pauseTime: new Date(Date.now() - 2000).toISOString(), + }, { + name: "cohort-2", + resumeTime: new Date(Date.now() - 1000).toISOString(), + pauseTime: new Date(Date.now() + 2000).toISOString(), + }, { + name: "cohort-3", + resumeTime: new Date(Date.now() + 3000).toISOString(), + pauseTime: null, + }]; + dates = getCurrentCohortDates(cohorts); + + expect(dates).toStrictEqual({ + resumeDate: new Date(cohorts[1].resumeTime!), + pauseDate: new Date(cohorts[1].pauseTime!) + }); + + // Upcoming 3rd + cohorts = [{ + name: "cohort-1", + resumeTime: null, + pauseTime: new Date(Date.now() - 4000).toISOString(), + }, { + name: "cohort-2", + resumeTime: new Date(Date.now() - 3000).toISOString(), + pauseTime: new Date(Date.now() - 1000).toISOString(), + }, { + name: "cohort-3", + resumeTime: new Date(Date.now() + 1000).toISOString(), + pauseTime: null, + }]; + dates = getCurrentCohortDates(cohorts); + + expect(dates).toStrictEqual({ + resumeDate: new Date(cohorts[2].resumeTime!), + pauseDate: null, + }); + + // Active 3rd + cohorts = [{ + name: "cohort-1", + resumeTime: null, + pauseTime: new Date(Date.now() - 4000).toISOString(), + }, { + name: "cohort-2", + resumeTime: new Date(Date.now() - 3000).toISOString(), + pauseTime: new Date(Date.now() - 2000).toISOString(), + }, { + name: "cohort-3", + resumeTime: new Date(Date.now() - 1000).toISOString(), + pauseTime: null, + }]; + dates = getCurrentCohortDates(cohorts); + + expect(dates).toStrictEqual({ + resumeDate: new Date(cohorts[2].resumeTime!), + pauseDate: null, + }); + }); + }); + describe("getDates", () => { + test("gets the correct dates for an upcoming audit", () => { + const cohorts = [{ + name: "cohort-1", + resumeTime: null, + pauseTime: new Date(Date.now() + 2000).toISOString(), + }, { + name: "cohort-2", + resumeTime: new Date(Date.now() + 3000).toISOString(), + pauseTime: new Date(Date.now() + 4000).toISOString(), + }, { + name: "cohort-3", + resumeTime: new Date(Date.now() + 5000).toISOString(), + pauseTime: null, + }]; + const start = new Date(Date.now() + 1000); + const end = new Date(Date.now() + 6000); + + const dates = getContestSchedule({ + startDate: start.toISOString(), + endDate: end.toISOString(), + cohorts, + status: AuditStatus.PreAudit + }); + const expectedBotRaceEnd = addHours(start, 1); + expect(dates).toStrictEqual({ + botRaceEnd: expectedBotRaceEnd, + botRaceStatus: Status.UPCOMING, + contestStatus: Status.UPCOMING, + end: end, + formattedBotRaceEnd: format(expectedBotRaceEnd, "d MMM h:mm a"), + formattedDuration: "less than a minute", + formattedEnd: format(end, "d MMM h:mm a"), + formattedStart: format(start, "d MMM h:mm a"), + pause: new Date(cohorts[0].pauseTime!), + resume: null, + start: start, + status: AuditStatus.PreAudit, + timeZone: DateTime.local().toFormat("ZZZZ") + }); + }); + test("gets the correct dates for an audit with no cohorts", () => { + const cohorts:ContestCohort[] = []; + const start = new Date(Date.now() + 1000); + const end = new Date(Date.now() + 6000); + + const dates = getContestSchedule({ + startDate: start.toISOString(), + endDate: end.toISOString(), + cohorts, + status: AuditStatus.PreAudit + }); + const expectedBotRaceEnd = addHours(start, 1); + expect(dates).toStrictEqual({ + botRaceEnd: expectedBotRaceEnd, + botRaceStatus: Status.UPCOMING, + contestStatus: Status.UPCOMING, + end: end, + formattedBotRaceEnd: format(expectedBotRaceEnd, "d MMM h:mm a"), + formattedDuration: "less than a minute", + formattedEnd: format(end, "d MMM h:mm a"), + formattedStart: format(start, "d MMM h:mm a"), + pause: null, + resume: null, + start: start, + status: AuditStatus.PreAudit, + timeZone: DateTime.local().toFormat("ZZZZ") + }); + }); + test("gets the correct dates for a live audit in cohort 1", () => { + const cohorts = [{ + name: "cohort-1", + resumeTime: null, + pauseTime: new Date(Date.now() + 1000).toISOString(), + }, { + name: "cohort-2", + resumeTime: new Date(Date.now() + 2000).toISOString(), + pauseTime: new Date(Date.now() + 3000).toISOString(), + }, { + name: "cohort-3", + resumeTime: new Date(Date.now() + 4000).toISOString(), + pauseTime: null, + }]; + const start = new Date(Date.now()); + const end = new Date(Date.now() + 5000); + + const dates = getContestSchedule({ + startDate: start.toISOString(), + endDate: end.toISOString(), + cohorts, + status: AuditStatus.Active + }); + const expectedBotRaceEnd = addHours(start, 1); + expect(dates).toStrictEqual({ + botRaceEnd: expectedBotRaceEnd, + botRaceStatus: Status.LIVE, + contestStatus: Status.LIVE, + end: end, + formattedBotRaceEnd: format(expectedBotRaceEnd, "d MMM h:mm a"), + formattedDuration: "less than a minute", + formattedEnd: format(end, "d MMM h:mm a"), + formattedStart: format(start, "d MMM h:mm a"), + pause: new Date(cohorts[0].pauseTime!), + resume: null, + start: start, + status: AuditStatus.Active, + timeZone: DateTime.local().toFormat("ZZZZ") + }); + }); + test("gets the correct dates for a live audit inbetween cohort 1 and 2", () => { + const cohorts = [{ + name: "cohort-1", + resumeTime: null, + pauseTime: new Date(Date.now() - 1000).toISOString(), + }, { + name: "cohort-2", + resumeTime: new Date(Date.now() + 1000).toISOString(), + pauseTime: new Date(Date.now() + 2000).toISOString(), + }, { + name: "cohort-3", + resumeTime: new Date(Date.now() + 3000).toISOString(), + pauseTime: null, + }]; + const start = new Date(Date.now() - 2000); + const end = new Date(Date.now() + 4000); + + const dates = getContestSchedule({ + startDate: start.toISOString(), + endDate: end.toISOString(), + cohorts, + status: AuditStatus.Paused + }); + const expectedBotRaceEnd = addHours(start, 1); + expect(dates).toStrictEqual({ + botRaceEnd: expectedBotRaceEnd, + botRaceStatus: Status.LIVE, + contestStatus: Status.LIVE, + end: end, + formattedBotRaceEnd: format(expectedBotRaceEnd, "d MMM h:mm a"), + formattedDuration: "less than a minute", + formattedEnd: format(end, "d MMM h:mm a"), + formattedStart: format(start, "d MMM h:mm a"), + pause: new Date(cohorts[1].pauseTime!), + resume: new Date(cohorts[1].resumeTime!), + start: start, + status: AuditStatus.Paused, + timeZone: DateTime.local().toFormat("ZZZZ") + }); + }); + + test("gets the correct dates for a live audit in cohort 2", () => { + const cohorts = [{ + name: "cohort-1", + resumeTime: null, + pauseTime: new Date(Date.now() - 2000).toISOString(), + }, { + name: "cohort-2", + resumeTime: new Date(Date.now() - 1000).toISOString(), + pauseTime: new Date(Date.now() + 1000).toISOString(), + }, { + name: "cohort-3", + resumeTime: new Date(Date.now() + 2000).toISOString(), + pauseTime: null, + }]; + const start = new Date(Date.now() - 3000); + const end = new Date(Date.now() + 3000); + + const dates = getContestSchedule({ + startDate: start.toISOString(), + endDate: end.toISOString(), + cohorts, + status: AuditStatus.Active + }); + const expectedBotRaceEnd = addHours(start, 1); + expect(dates).toStrictEqual({ + botRaceEnd: expectedBotRaceEnd, + botRaceStatus: Status.LIVE, + contestStatus: Status.LIVE, + end: end, + formattedBotRaceEnd: format(expectedBotRaceEnd, "d MMM h:mm a"), + formattedDuration: "less than a minute", + formattedEnd: format(end, "d MMM h:mm a"), + formattedStart: format(start, "d MMM h:mm a"), + pause: new Date(cohorts[1].pauseTime!), + resume: new Date(cohorts[1].resumeTime!), + start: start, + status: AuditStatus.Active, + timeZone: DateTime.local().toFormat("ZZZZ") + }); + }); + + test("gets the correct dates for a live audit in cohort 3", () => { + const cohorts = [{ + name: "cohort-1", + resumeTime: null, + pauseTime: new Date(Date.now() - 4000).toISOString(), + }, { + name: "cohort-2", + resumeTime: new Date(Date.now() - 3000).toISOString(), + pauseTime: new Date(Date.now() - 2000).toISOString(), + }, { + name: "cohort-3", + resumeTime: new Date(Date.now() - 1000).toISOString(), + pauseTime: null, + }]; + const start = new Date(Date.now() - 5000); + const end = new Date(Date.now() + 1000); + + const dates = getContestSchedule({ + startDate: start.toISOString(), + endDate: end.toISOString(), + cohorts, + status: AuditStatus.Active + }); + const expectedBotRaceEnd = addHours(start, 1); + expect(dates).toStrictEqual({ + botRaceEnd: expectedBotRaceEnd, + botRaceStatus: Status.LIVE, + contestStatus: Status.LIVE, + end: end, + formattedBotRaceEnd: format(expectedBotRaceEnd, "d MMM h:mm a"), + formattedDuration: "less than a minute", + formattedEnd: format(end, "d MMM h:mm a"), + formattedStart: format(start, "d MMM h:mm a"), + pause: null, + resume: new Date(cohorts[2].resumeTime!), + start: start, + status: AuditStatus.Active, + timeZone: DateTime.local().toFormat("ZZZZ") + }); + }); + + test("gets the correct dates for a finished contest", () => { + const cohorts = [{ + name: "cohort-1", + resumeTime: null, + pauseTime: new Date(Date.now() - 5000).toISOString(), + }, { + name: "cohort-2", + resumeTime: new Date(Date.now() - 4000).toISOString(), + pauseTime: new Date(Date.now() - 3000).toISOString(), + }, { + name: "cohort-3", + resumeTime: new Date(Date.now() - 2000).toISOString(), + pauseTime: null, + }]; + const start = new Date(Date.now() - 6000); + const end = new Date(Date.now() - 1000); + + const dates = getContestSchedule({ + startDate: start.toISOString(), + endDate: end.toISOString(), + cohorts, + status: AuditStatus.Review + }); + const expectedBotRaceEnd = addHours(start, 1); + expect(dates).toStrictEqual({ + botRaceEnd: expectedBotRaceEnd, + botRaceStatus: Status.ENDED, + contestStatus: Status.ENDED, + end: end, + formattedBotRaceEnd: format(expectedBotRaceEnd, "d MMM h:mm a"), + formattedDuration: "less than a minute", + formattedEnd: format(end, "d MMM h:mm a"), + formattedStart: format(start, "d MMM h:mm a"), + pause: null, + resume: new Date(cohorts[2].resumeTime!), + start: start, + status: AuditStatus.Review, + timeZone: DateTime.local().toFormat("ZZZZ") + }); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/time.ts b/src/utils/time.ts index e1c3a602..28f14201 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -1,6 +1,7 @@ -import { addHours, format, formatDistance, isAfter, isBefore } from "date-fns"; -import { ContestSchedule } from "../lib/ContestTile/ContestTile.types"; +import { addHours, format, formatDistance, isAfter, isBefore, isEqual } from "date-fns"; +import { BaseContestSchedule, ContestCohort, ContestSchedule } from "../lib/ContestTile/ContestTile.types"; import { Status } from "../lib/ContestStatus/ContestStatus.types"; +import { ContestTileData } from "../lib/ContestTile/ContestTile.types"; import { DateTime } from "luxon"; function getContestStatuses( @@ -15,7 +16,13 @@ function getContestStatuses( contestStatus: Status.UPCOMING, }; } - if (isBefore(currentTime, botRaceEnd) && isAfter(currentTime, start)) { + if (isAfter(currentTime, end)) { + return { + botRaceStatus: Status.ENDED, + contestStatus: Status.ENDED, + }; + } + if (isBefore(currentTime, botRaceEnd) && (isAfter(currentTime, start) || isEqual(currentTime, start))) { return { botRaceStatus: Status.LIVE, contestStatus: Status.LIVE, @@ -28,19 +35,46 @@ function getContestStatuses( contestStatus: Status.LIVE, }; } - if (isAfter(currentTime, end)) { - return { - botRaceStatus: Status.ENDED, - contestStatus: Status.ENDED, - }; - } return { botRaceStatus: undefined, contestStatus: undefined, }; } -const getDates = (start: string, end: string): ContestSchedule => { +const getCurrentCohortDates = (cohorts: ContestCohort[]) => { + const now = Date.now(); + + const currentCohort = cohorts.sort((a, b) => { + if (a.resumeTime === null) return -1; + return new Date(a.resumeTime).getTime() - (b.resumeTime ? new Date(b.resumeTime)?.getTime() : 0); + }).find(cohort => { + return cohort.pauseTime === null || new Date(cohort.pauseTime).getTime() > now; + }); + + return { + pauseDate: currentCohort?.pauseTime ? new Date(currentCohort.pauseTime) : null, + resumeDate: currentCohort?.resumeTime ? new Date(currentCohort.resumeTime) : null, + } +}; + +const getContestSchedule = ( + contest: Pick +): ContestSchedule => { + const schedule = getDates(contest.startDate, contest.endDate); + const currentCohort = getCurrentCohortDates(contest.cohorts); + + return { + ...schedule, + pause: currentCohort.pauseDate && new Date(currentCohort.pauseDate), + resume: currentCohort.resumeDate && new Date(currentCohort.resumeDate), + status: contest.status, + }; +} + +const getDates = ( + start: string, + end: string +): BaseContestSchedule => { const startDate = new Date(start); const endDate = new Date(end); const timeZone = DateTime.local().toFormat("ZZZZ"); @@ -61,9 +95,9 @@ const getDates = (start: string, end: string): ContestSchedule => { formattedEnd: format(endDate, "d MMM h:mm a"), formattedStart: format(startDate, "d MMM h:mm a"), timeZone: timeZone, - formattedBotRaceEnd: format(botRaceEnd, "d MMMM h:mm a"), + formattedBotRaceEnd: format(botRaceEnd, "d MMM h:mm a"), formattedDuration: formatDistance(startDate, endDate), }; }; -export { getDates }; +export { getDates, getContestSchedule, getCurrentCohortDates };