diff --git a/public/app-icons/CourseGrab.png b/public/app-icons/CourseGrab.png new file mode 100644 index 0000000..f40bc2b Binary files /dev/null and b/public/app-icons/CourseGrab.png differ diff --git a/public/app-icons/Eatery.png b/public/app-icons/Eatery.png new file mode 100644 index 0000000..43b72bf Binary files /dev/null and b/public/app-icons/Eatery.png differ diff --git a/public/app-icons/Resell.png b/public/app-icons/Resell.png new file mode 100644 index 0000000..0b27ff4 Binary files /dev/null and b/public/app-icons/Resell.png differ diff --git a/public/app-icons/Scooped.png b/public/app-icons/Scooped.png new file mode 100644 index 0000000..54f7b92 Binary files /dev/null and b/public/app-icons/Scooped.png differ diff --git a/public/app-icons/Transit.png b/public/app-icons/Transit.png new file mode 100644 index 0000000..70390b5 Binary files /dev/null and b/public/app-icons/Transit.png differ diff --git a/public/app-icons/Uplift.png b/public/app-icons/Uplift.png new file mode 100644 index 0000000..58ae2c2 Binary files /dev/null and b/public/app-icons/Uplift.png differ diff --git a/public/app-icons/Volume.png b/public/app-icons/Volume.png new file mode 100644 index 0000000..f0f33d0 Binary files /dev/null and b/public/app-icons/Volume.png differ diff --git a/src/app/page.tsx b/src/app/page.tsx index 26b7516..4f8ba83 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,4 +1,5 @@ -import UpcomingAnnouncements from "@/components/landing/UpcomingAnnouncements"; +import ActiveAnnouncements from "@/components/landing/ActiveAnnouncements"; +import ActiveCell from "@/components/landing/ActiveCell"; import { Announcement } from "@/models/Announcement"; import { AppName } from "@/models/AppName"; @@ -16,19 +17,39 @@ const pastAnn: Announcement = { title: "Demo Day", }; -const ann1: Announcement = { +const liveAnn: Announcement = { id: "65f3c6c85ec12921d8bbd0e3", apps: [AppName.UPLIFT], body: "Short body.", - endDate: new Date("2024-08-16T03:00:00Z"), + endDate: new Date("2024-08-25T19:15:00Z"), imageUrl: - "https://kidszoo.org/wp-content/uploads/2017/06/IMG_2034-2-scaled.jpg", + "https://runningscaredsite.wordpress.com/wp-content/uploads/2016/01/running-scared-chicken.jpeg?w=640", link: "https://www.instagram.com/p/C4ft4SyOaUj/", - startDate: new Date("2024-08-10T23:42:20Z"), + startDate: new Date("2024-07-10T23:42:20Z"), title: "Title", }; -const ann2: Announcement = { +const liveAnn2: Announcement = { + id: "65f3c6c85ec12921d8bbd0e3", + apps: [ + AppName.TRANSIT, + AppName.EATERY, + AppName.SCOOPED, + AppName.COURSEGRAB, + AppName.RESELL, + AppName.UPLIFT, + AppName.VOLUME, + ], + body: "Come ASAP.", + endDate: new Date("2024-08-28T19:15:00Z"), + imageUrl: + "https://cdn.britannica.com/55/174255-050-526314B6/brown-Guernsey-cow.jpg", + link: "https://www.instagram.com/p/C4ft4SyOaUj/", + startDate: new Date("2024-08-01T23:42:20Z"), + title: "Happening now.", +}; + +const futureAnn: Announcement = { id: "65f3c6c85ec12921d8bbd0e3", apps: [AppName.EATERY, AppName.RESELL, AppName.UPLIFT, AppName.TRANSIT], body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", @@ -40,31 +61,29 @@ const ann2: Announcement = { title: "Very, Very, Very Big Announcement.", }; -const ann3: Announcement = { +const futureAnn2: Announcement = { id: "65f3c6c85ec12921d8bbd0e3", apps: [AppName.RESELL], body: "Starting at the same time as ...", - endDate: new Date("2024-08-16T03:00:00Z"), + endDate: new Date("2025-08-09T18:57:00Z"), imageUrl: "https://kidszoo.org/wp-content/uploads/2017/06/IMG_2034-2-scaled.jpg", link: "https://www.instagram.com/p/C4ft4SyOaUj/", - startDate: new Date("2024-08-10T23:42:20Z"), + startDate: new Date("2025-08-08T19:24:20Z"), title: "Announcement", }; export default function Landing() { return ( -
+
{/* empty state (there are no announcements) */} - - {/* empty state (there are no upcoming announcements, only past announcements) */} - - {/* only one announcement, which is upcoming */} - - {/* multiple upcoming announcements with different start times, and one past announcement */} - - {/* multiple upcoming announcements with the same start time, and one past announcemnet */} - + + {/* empty state (there are only past announcements) */} + + {/* multiple announcements, including ones that are currently live and ones that are in the future */} +
); } diff --git a/src/components/landing/ActiveAnnouncements.tsx b/src/components/landing/ActiveAnnouncements.tsx new file mode 100644 index 0000000..694eb50 --- /dev/null +++ b/src/components/landing/ActiveAnnouncements.tsx @@ -0,0 +1,72 @@ +"use client"; + +import CalendarArrowIcon from "@/icons/CalendarArrowIcon"; +import { Announcement } from "@/models/Announcement"; +import { NO_ANNOUNCEMENTS_MESSAGE } from "@/utils/constants"; +import { + calculateTimeRemaining, + filterActiveAnnouncements, + sortAnnouncementsByStartDate, +} from "@/utils/utils"; +import ActiveCell from "./ActiveCell"; +import { useState, useEffect } from "react"; + +interface Props { + announcements: Announcement[]; +} + +export default function ActiveAnnouncements({ announcements }: Props) { + const activeAnnouncements = sortAnnouncementsByStartDate( + filterActiveAnnouncements(announcements) + ); + + const [timeRemaining, setTimeRemaining] = useState({ + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + }); + + useEffect(() => { + if (activeAnnouncements.length === 0) return; + + const firstAnnouncement = activeAnnouncements[0]; + const startDate = new Date(firstAnnouncement.startDate); + + const updateCountdown = () => { + setTimeRemaining(calculateTimeRemaining(startDate)); + }; + + updateCountdown(); + const interval = setInterval(updateCountdown, 1000); + + return () => clearInterval(interval); + }, [announcements]); + + return ( +
+
+ +
+

+ Active Announcements +

+

+ Current and upcoming announcements. +

+
+
+ {activeAnnouncements.length > 0 ? ( +
+ {activeAnnouncements.map((announcement) => ( + + ))} +
+ ) : ( +

+ {NO_ANNOUNCEMENTS_MESSAGE} +

+ )} +
+ ); +} diff --git a/src/components/landing/ActiveCell.tsx b/src/components/landing/ActiveCell.tsx new file mode 100644 index 0000000..be10a9d --- /dev/null +++ b/src/components/landing/ActiveCell.tsx @@ -0,0 +1,70 @@ +"use client"; + +import AppIcon from "@/icons/AppIcon"; +import EditIcon from "@/icons/EditIcon"; +import TertiaryButton from "../shared/TertiaryButton"; +import { Announcement } from "@/models/Announcement"; +import { dateInRange, formatDate } from "@/utils/utils"; +import { useEffect, useState } from "react"; +import LiveIndicator from "../shared/LiveIndicator"; + +interface Props { + announcement: Announcement; +} + +export default function ActiveCell({ announcement }: Props) { + const [currentDate, setCurrentDate] = useState(new Date()); + + useEffect(() => { + const intervalId = setInterval(() => { + setCurrentDate(new Date()); + }, 500); + + return () => clearInterval(intervalId); + }, []); + + return ( +
+ +
+
+
+

+ {announcement.title} +

+

+ {" "} + {formatDate(announcement.startDate)} -{" "} + {formatDate(announcement.endDate)}{" "} +

+
+ console.log("Button clicked")} + className="max-md:hidden" + /> +
+
+ {announcement.apps.map((app) => ( + + ))} +
+ console.log("Button clicked")} + className="md:hidden" + /> +
+ {dateInRange( + currentDate, + announcement.startDate, + announcement.endDate + ) ? ( + + ) : null} +
+ ); +} diff --git a/src/components/shared/LiveIndicator.tsx b/src/components/shared/LiveIndicator.tsx new file mode 100644 index 0000000..c365bd4 --- /dev/null +++ b/src/components/shared/LiveIndicator.tsx @@ -0,0 +1,8 @@ +export default function LiveIndicator() { + return ( +
+
+
LIVE
+
+ ); +} diff --git a/src/components/shared/TertiaryButton.tsx b/src/components/shared/TertiaryButton.tsx new file mode 100644 index 0000000..ae1191f --- /dev/null +++ b/src/components/shared/TertiaryButton.tsx @@ -0,0 +1,21 @@ +"use client"; + +import EditIcon from "@/icons/EditIcon"; + +interface Props { + text: string; + action: () => void; + className?: string +} + +export default function TertiaryButton({ text, action, className }: Props) { + return ( + + ); +} diff --git a/src/icons/AppIcon.tsx b/src/icons/AppIcon.tsx new file mode 100644 index 0000000..f40351e --- /dev/null +++ b/src/icons/AppIcon.tsx @@ -0,0 +1,31 @@ +import { AppName } from "@/models/AppName"; +import { IconProps } from "@/models/IconProps"; + +interface Props extends IconProps { + appName: AppName; +} + +export default function AppIcon({ appName, className }: Props) { + const getAppSrc = (appName: AppName): string => { + switch (appName) { + case AppName.TRANSIT: + return "/app-icons/Transit.png"; + case AppName.EATERY: + return "/app-icons/Eatery.png"; + case AppName.RESELL: + return "/app-icons/Resell.png"; + case AppName.COURSEGRAB: + return "/app-icons/CourseGrab.png"; + case AppName.VOLUME: + return "/app-icons/Volume.png"; + case AppName.UPLIFT: + return "/app-icons/Uplift.png"; + case AppName.SCOOPED: + return "/app-icons/Scooped.png"; + default: + throw new Error(`No icon found for app name: ${appName}`); + } + }; + + return ; +} diff --git a/src/icons/CalendarArrowIcon.tsx b/src/icons/CalendarArrowIcon.tsx new file mode 100644 index 0000000..ab22cdf --- /dev/null +++ b/src/icons/CalendarArrowIcon.tsx @@ -0,0 +1,15 @@ +import { IconProps } from "@/models/IconProps"; + +export default function ({ className }: IconProps) { + return ( + + + + ); +} diff --git a/src/icons/EditIcon.tsx b/src/icons/EditIcon.tsx new file mode 100644 index 0000000..da46cfa --- /dev/null +++ b/src/icons/EditIcon.tsx @@ -0,0 +1,26 @@ +import { IconProps } from "@/models/IconProps"; + +export default function ({ className }: IconProps) { + return ( + + + + + ); +} diff --git a/src/models/AppName.ts b/src/models/AppName.ts index 6321044..8d3f176 100644 --- a/src/models/AppName.ts +++ b/src/models/AppName.ts @@ -5,4 +5,5 @@ export enum AppName { COURSEGRAB = "coursegrab", VOLUME = "volume", UPLIFT = "uplift", + SCOOPED = "scooped", } diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 7805ed5..cb1b3d5 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -14,6 +14,20 @@ export const filterFutureAnnouncements = ( return announcements.filter((announcement) => announcement.startDate > date); }; +/** + * Filters active announcements, which are either current or upcoming, based on their endDate. + * + * @param announcements - The announcements to filter. + * @param date - Optional. The date to compare against each announcement's startDate. If not provided, the current date and time when the function is called will be used. + * @returns An array of Announcement objects where each endDate is greater than the current date. + */ +export const filterActiveAnnouncements = ( + announcements: Announcement[], + date: Date = new Date() +): Announcement[] => { + return announcements.filter((announcement) => announcement.endDate > date); +}; + /** * Sorts an array of announcements by their startDate in ascending order. * @@ -73,3 +87,36 @@ export const calculateTimeRemaining = (startDate: Date) => { seconds: seconds % 60, }; }; + +/** + * Returns whether the given date is in the range of a given start and end date. + * + * @param startDate - A date, must be before [endDate]. + * @param endDate - A date, must be after [startDate]. + * @param targetDate - Optional. The date that is to be checked if it's in range of [startDate] and [endDate]. If not provided, the current date and time when the function is called will be used. + * @returns A boolean stating if the current date is greater than or equal to startDate and less than or equal to endDate. + */ +export const dateInRange = ( + startDate: Date, + endDate: Date, + targetDate: Date = new Date() +) => { + return targetDate >= startDate && targetDate <= endDate; +}; + +/** + * Formats a Date object into a string in the M/D 00:00 AM/PM format. + * + * @param date - A date. + * @returns A string representing [date] in the above format. + */ +export const formatDate = (date: Date) => { + const month = date.getMonth() + 1; + const day = date.getDate(); + const time = date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + return `${month}/${day} ${time}`; +}; diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 8faf6ff..aeb8ef2 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -4,6 +4,9 @@ import { sortAnnouncementsByStartDate, calculateTimeRemaining, getEarliestAnnouncements, + dateInRange, + formatDate, + filterActiveAnnouncements, } from "../src/utils/utils"; import { Announcement } from "@/models/Announcement"; @@ -22,10 +25,10 @@ const announcements: Announcement[] = [ id: "2", apps: [AppName.RESELL, AppName.COURSEGRAB], body: "Announcement 2", - endDate: new Date("2024-08-20T00:00:00Z"), + endDate: new Date("2024-08-30T00:00:00Z"), imageUrl: "image2.jpg", link: "link2", - startDate: new Date("2024-08-10T00:00:00Z"), + startDate: new Date("2024-08-20T00:00:00Z"), title: "Announcement 2", }, { @@ -108,7 +111,7 @@ describe("Utils", () => { describe("calculateTimeRemaining", () => { it("should calculate the days remaining until the given start date", () => { - const startDate = new Date(Date.now() + 3 * 86400000); // 3 days from now + const startDate = new Date(Date.now() + 3 * 86400001); // 3 days from now const result = calculateTimeRemaining(startDate); expect(result.days).toBe(3); }); @@ -177,4 +180,112 @@ describe("Utils", () => { expect(result.length).toBe(0); }); }); + + describe("filterActiveAnnouncements", () => { + it("should filter announcements to only include those with an end date in the future", () => { + const result = filterActiveAnnouncements( + announcements, + new Date(2024, 11, 1) + ); + expect(result.length).toBe(1); + expect(result[0].id).toBe("3"); + }); + it("should filter announcements to only include those with an end date in the future (all announcements have concluded)", () => { + const result = filterActiveAnnouncements( + duplicateStartAnnouncements, + new Date(2026, 11, 1) + ); + expect(result.length).toBe(0); + }); + it("should filter announcements to only include those with an end date in the future (includes active announcement)", () => { + const result = filterActiveAnnouncements( + announcements, + new Date(2024, 7, 25) + ); + expect(result.length).toBe(2); + expect(result[0].id).toBe("2"); + expect(result[1].id).toBe("3"); + }); + }); + + describe("dateInRange", () => { + it("should return true as first date is in the range set by the second two dates", () => { + const result = dateInRange( + new Date("2024-07-01T14:30:00"), + new Date("2024-07-30T14:30:00"), + new Date("2024-07-15T14:30:00") + ); + expect(result).toBe(true); + }); + it("the first date is before the range of the second two dates (not in range)", () => { + const result = dateInRange( + new Date("2024-07-01T14:30:00"), + new Date("2024-07-30T14:30:00"), + new Date("2024-07-01T12:30:00") + ); + expect(result).toBe(false); + }); + it("the first date is after the range of the second two dates (not in range)", () => { + const result = dateInRange( + new Date("2024-07-01T14:30:00"), + new Date("2024-07-30T14:30:00"), + new Date("2024-07-30T16:30:00") + ); + expect(result).toBe(false); + }); + it("the given date is exactly the same date as the earlier bound of the range (in range)", () => { + const result = dateInRange( + new Date("2024-07-01T14:30:00"), + new Date("2024-07-30T14:30:00"), + new Date("2024-07-01T14:30:00") + ); + expect(result).toBe(true); + }); + it("the given date is exactly the same date as the later bound of the range (in range)", () => { + const result = dateInRange( + new Date("2024-07-01T14:30:00"), + new Date("2024-07-30T14:30:00"), + new Date("2024-07-30T14:30:00") + ); + expect(result).toBe(true); + }); + }); + + describe("formatDate", () => { + it("should format the date correctly for a date in the middle of the year", () => { + const date = new Date("2024-07-15T14:30:00"); + const result = formatDate(date); + expect(result).toBe("7/15 2:30 PM"); + }); + + it("should format the date correctly for a date at the beginning of the year", () => { + const date = new Date("2024-01-01T00:00:00"); + const result = formatDate(date); + expect(result).toBe("1/1 12:00 AM"); + }); + + it("should format the date correctly for a date at the end of the year", () => { + const date = new Date("2024-12-31T23:59:59"); + const result = formatDate(date); + expect(result).toBe("12/31 11:59 PM"); + }); + + it("should format the date correctly for a single-digit month and day", () => { + const date = new Date("2024-03-05T07:05:00"); + const result = formatDate(date); + expect(result).toBe("3/5 7:05 AM"); + }); + + it("should format the date correctly for noon", () => { + const date = new Date("2024-06-15T12:00:00"); + const result = formatDate(date); + expect(result).toBe("6/15 12:00 PM"); + }); + + it("should format the date correctly for midnight", () => { + const date = new Date("2024-09-22T00:00:00"); + const result = formatDate(date); + expect(result).toBe("9/22 12:00 AM"); + }); + }); });