diff --git a/package.json b/package.json index 3ad77ef..cdac08b 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,12 @@ "test": "jest" }, "dependencies": { - "@headlessui/react": "^2.1.9", - "@headlessui/tailwindcss": "^0.2.1", "@nextui-org/date-picker": "^2.1.8", "@nextui-org/theme": "^2.2.11", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-progress": "^1.1.0", + "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", "@tanstack/react-query": "^5.51.5", "axios": "^1.7.2", @@ -32,6 +31,7 @@ "react-dom": "^18", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", + "uuid": "^11.0.3", "zustand": "^4.5.4" }, "devDependencies": { diff --git a/public/app-icons/Scooped.png b/public/app-icons/Scooped.png deleted file mode 100644 index 54f7b92..0000000 Binary files a/public/app-icons/Scooped.png and /dev/null differ diff --git a/src/app/past/page.tsx b/src/app/past/page.tsx index 4e0c800..28aa7bf 100644 --- a/src/app/past/page.tsx +++ b/src/app/past/page.tsx @@ -1,7 +1,23 @@ -export default function Past() { +"use client"; + +import AuthGuard from "@/components/authGuard/authGuard"; +import Past from "@/components/past/past"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +export default function PastPage() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, // default: true + }, + }, + }); + return ( -
-

Past Announcements

-
+ + + + + ); } diff --git a/src/components/announcement/announcementBanner.tsx b/src/components/announcement/announcementBanner.tsx index 45cf3f9..e7af974 100644 --- a/src/components/announcement/announcementBanner.tsx +++ b/src/components/announcement/announcementBanner.tsx @@ -1,4 +1,4 @@ -import CrossThick from "@/icons/CrossThickIcon"; +import CrossThick from "@/icons/crossThickIcon"; import { Announcement } from "@/models/announcement"; interface Props { diff --git a/src/components/announcement/announcementCell.tsx b/src/components/announcement/announcementCell.tsx index c0ed7a7..1f9ca61 100644 --- a/src/components/announcement/announcementCell.tsx +++ b/src/components/announcement/announcementCell.tsx @@ -1,8 +1,9 @@ -import AppIcon from "@/icons/AppIcon"; -import TertiaryButton from "../system/ButtonTertiary"; +import AppIcon from "@/icons/appIcon"; +import ButtonTertiary from "../system/button/buttonTertiary"; import { Announcement } from "@/models/announcement"; import { dateInRange, filterActiveAnnouncements, formatDate } from "@/utils/utils"; import AnnouncementIndicator from "./announcementIndicator"; +import { DateFormat } from "@/models/enums/dateFormat"; interface Props { announcement: Announcement; @@ -18,31 +19,34 @@ export default function AnnouncementCell({ announcement, onClick, onEditClick }: className="flex flex-col p-6 items-start md:items-end md:flex-row justify-center gap-6 md:gap-8 self-stretch bg-neutral-white rounded-lg border border-other-stroke relative cursor-pointer" onClick={onClick} > - +
-
+

{announcement.title}

- {" "} - {formatDate(new Date(announcement.startDate))} - {formatDate(new Date(announcement.endDate))}{" "} + {formatDate(new Date(announcement.startDate), DateFormat.SHORT)} -{" "} + {formatDate(new Date(announcement.endDate), DateFormat.SHORT)}

- {isActive ? : null} + {isActive ? ( + + ) : null}
-
- {announcement.apps.map((app) => ( - - ))} +
+
+ {announcement.apps.map((app) => ( + + ))} +
+ {dateInRange(new Date(announcement.startDate), new Date(announcement.endDate), new Date()) ? ( + + ) : null}
- {isActive ? : null} + {isActive ? ( + + ) : null}
- {dateInRange(new Date(announcement.startDate), new Date(announcement.endDate), new Date()) ? ( - - ) : null}
); } diff --git a/src/components/announcement/announcementForm.tsx b/src/components/announcement/announcementForm.tsx index 0ed169a..ae3ca62 100644 --- a/src/components/announcement/announcementForm.tsx +++ b/src/components/announcement/announcementForm.tsx @@ -1,13 +1,13 @@ -import CrossThinIcon from "@/icons/CrossThinIcon"; -import SpeakerIcon from "@/icons/SpeakerIcon"; +import CrossThinIcon from "@/icons/crossThinIcon"; +import SpeakerIcon from "@/icons/speakerIcon"; import AnnouncementBanner from "./announcementBanner"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Announcement } from "@/models/announcement"; import InputText from "../system/input/inputText"; import InputDatePicker from "../system/input/inputDatePicker"; import InputMultiSelect from "../system/input/inputMultiselect"; import InputUpload from "../system/input/inputUpload"; -import ButtonPrimary1 from "../system/ButtonPrimary1"; +import ButtonPrimary1 from "../system/button/buttonPrimary1"; import { useUserStore } from "@/stores/useUserStore"; import { addDays } from "date-fns"; @@ -46,9 +46,11 @@ export default function AnnouncementForm({ onClose, editingAnnouncement }: Props () => announcement.apps.length !== 0 && announcement.body !== "" && + announcement.endDate && announcement.endDate !== "" && announcement.imageUrl !== "" && announcement.link !== "" && + announcement.startDate && announcement.startDate !== "" && announcement.title !== "", [announcement] @@ -59,7 +61,7 @@ export default function AnnouncementForm({ onClose, editingAnnouncement }: Props if (!user) return; try { - console.log("scheduling"); + console.log("Scheduling", announcement); } catch (err) { console.error(err); } @@ -110,8 +112,8 @@ export default function AnnouncementForm({ onClose, editingAnnouncement }: Props to: new Date(announcement.endDate), }} setDateRange={(dateRange) => { - handleChange("startDate", dateRange.from); - handleChange("endDate", dateRange.to); + handleChange("startDate", dateRange?.from ?? ""); + handleChange("endDate", dateRange?.to ?? ""); }} />
@@ -123,7 +125,7 @@ export default function AnnouncementForm({ onClose, editingAnnouncement }: Props />
Apps
- handleChange("apps", apps)} /> + handleChange("apps", apps)} />
Upload Image
diff --git a/src/components/announcement/announcementModal.tsx b/src/components/announcement/announcementModal.tsx index 24173fa..1bf87d4 100644 --- a/src/components/announcement/announcementModal.tsx +++ b/src/components/announcement/announcementModal.tsx @@ -1,11 +1,12 @@ import { Announcement } from "@/models/announcement"; import AnnouncementBanner from "./announcementBanner"; -import AppIcon from "@/icons/AppIcon"; -import ButtonPrimary2 from "../system/ButtonPrimary2"; -import ButtonPrimary3 from "../system/ButtonPrimary3"; -import CrossThinIcon from "@/icons/CrossThinIcon"; +import AppIcon from "@/icons/appIcon"; +import CrossThinIcon from "@/icons/crossThinIcon"; import { dateInRange, formatDate } from "@/utils/utils"; import AnnouncementIndicator from "./announcementIndicator"; +import { DateFormat } from "@/models/enums/dateFormat"; +import ButtonSecondary2 from "../system/button/buttonSecondary2"; +import ButtonPrimary2 from "../system/button/buttonPrimary2"; interface AnnouncementModalProps { onClose: () => void; @@ -28,7 +29,10 @@ export default function AnnouncementModal({ onClose, announcement }: Announcemen

- {`${formatDate(new Date(announcement.startDate))} - ${formatDate(new Date(announcement.endDate))}`} + {`${formatDate(new Date(announcement.startDate), DateFormat.SHORT)} - ${formatDate( + new Date(announcement.endDate), + DateFormat.SHORT + )}`}

@@ -50,12 +54,12 @@ export default function AnnouncementModal({ onClose, announcement }: Announcemen
{dateInRange(new Date(announcement.startDate), new Date(announcement.endDate), new Date()) ? ( - console.log("End Live Announcement button tapped")} /> ) : ( - console.log("Delete button tapped")} /> + console.log("Delete button tapped")} /> )} diff --git a/src/components/common/navBar.tsx b/src/components/common/navBar.tsx index 3a126f2..5fd9be2 100644 --- a/src/components/common/navBar.tsx +++ b/src/components/common/navBar.tsx @@ -1,6 +1,6 @@ import appDevLogo from "@/../public/images/appdev_logo.png"; import appDevLogoName from "@/../public/images/appdev_logo_name.png"; -import ButtonSecondary2 from "../system/ButtonSecondary2"; +import ButtonSecondary2 from "../system/button/buttonSecondary2"; import { Constants } from "@/utils/constants"; import { useUserStore } from "@/stores/useUserStore"; import { useRouter } from "next/navigation"; @@ -25,7 +25,7 @@ export default function NavBar() {
User profile image - +
diff --git a/src/components/landing/landing.tsx b/src/components/landing/landing.tsx index cf47449..3df2232 100644 --- a/src/components/landing/landing.tsx +++ b/src/components/landing/landing.tsx @@ -56,7 +56,7 @@ export default function Landing() { }; return user?.name !== "" ? ( -
+
{showForm ? : null} diff --git a/src/components/landing/landingActiveSection.tsx b/src/components/landing/landingActiveSection.tsx index df71b96..a60b38e 100644 --- a/src/components/landing/landingActiveSection.tsx +++ b/src/components/landing/landingActiveSection.tsx @@ -1,4 +1,4 @@ -import CalendarArrowIcon from "@/icons/CalendarArrowIcon"; +import CalendarArrowIcon from "@/icons/calendarArrowIcon"; import { Announcement } from "@/models/announcement"; import { filterActiveAnnouncements, sortAnnouncementsByStartDate } from "@/utils/utils"; import AnnouncementModal from "../announcement/announcementModal"; diff --git a/src/components/landing/landingCreateAnnouncement.tsx b/src/components/landing/landingCreateAnnouncement.tsx index 751d0bd..f50df36 100644 --- a/src/components/landing/landingCreateAnnouncement.tsx +++ b/src/components/landing/landingCreateAnnouncement.tsx @@ -1,5 +1,5 @@ -import SpeakerIcon from "@/icons/SpeakerIcon"; -import ButtonPrimary1 from "../system/ButtonPrimary1"; +import SpeakerIcon from "@/icons/speakerIcon"; +import ButtonPrimary1 from "../system/button/buttonPrimary1"; interface Props { action: () => void; diff --git a/src/components/landing/landingPastSection.tsx b/src/components/landing/landingPastSection.tsx index 80c2558..c30075d 100644 --- a/src/components/landing/landingPastSection.tsx +++ b/src/components/landing/landingPastSection.tsx @@ -1,11 +1,12 @@ -import CalendarPlainIcon from "@/icons/CalendarPlainIcon"; +import CalendarPlainIcon from "@/icons/calendarPlainIcon"; import { Announcement } from "@/models/announcement"; import { filterPastAnnouncements, sortAnnouncementsByStartDate } from "@/utils/utils"; import AnnouncementCell from "../announcement/announcementCell"; import AnnouncementModal from "../announcement/announcementModal"; import { useState } from "react"; -import ButtonSecondary1 from "../system/ButtonSecondary1"; +import ButtonSecondary1 from "../system/button/buttonSecondary1"; import { Constants } from "@/utils/constants"; +import { useRouter } from "next/navigation"; interface Props { announcements?: Announcement[]; @@ -13,6 +14,8 @@ interface Props { } export default function LandingPastSection({ announcements, onEditClick }: Props) { + const router = useRouter(); + const pastAnnouncements = sortAnnouncementsByStartDate(filterPastAnnouncements(announcements ?? [])); const [selectedAnnouncement, setSelectedAnnouncement] = useState(null); @@ -23,6 +26,10 @@ export default function LandingPastSection({ announcements, onEditClick }: Props setSelectedAnnouncement(null); }; + const viewAllAction = () => { + router.push(Constants.pagePath.past); + }; + return (
@@ -41,9 +48,7 @@ export default function LandingPastSection({ announcements, onEditClick }: Props onClick={() => openModal(pastAnnouncements[0])} onEditClick={() => onEditClick(pastAnnouncements[0])} /> - {pastAnnouncements.length > 1 && ( - console.log("Button clicked")} /> - )} + {pastAnnouncements.length > 1 && }
@@ -57,7 +62,7 @@ export default function LandingPastSection({ announcements, onEditClick }: Props onEditClick={() => onEditClick(announcement)} /> ))} - console.log("Button clicked")} /> + ) : ( pastAnnouncements.map((announcement) => ( diff --git a/src/components/landing/landingUpcomingSection.tsx b/src/components/landing/landingUpcomingSection.tsx index 0ab5fe2..c86dfc0 100644 --- a/src/components/landing/landingUpcomingSection.tsx +++ b/src/components/landing/landingUpcomingSection.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo } from "react"; -import HourglassIcon from "@/icons/HourglassIcon"; +import HourglassIcon from "@/icons/hourglassIcon"; import AnnouncementBanner from "../announcement/announcementBanner"; import { Announcement } from "@/models/announcement"; import { calculateTimeRemaining, filterFutureAnnouncements, getEarliestAnnouncements } from "@/utils/utils"; diff --git a/src/components/past/past.tsx b/src/components/past/past.tsx new file mode 100644 index 0000000..0a8c98a --- /dev/null +++ b/src/components/past/past.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { Announcement } from "@/models/announcement"; +import { AppName } from "@/models/enums/appName"; +import { SortType } from "@/models/enums/sortType"; +import { createDummyAnnouncement } from "@/utils/dummy"; +import { ChangeEvent, useEffect, useState } from "react"; +import NavBar from "../common/navBar"; +import PageHeader from "../common/pageHeader"; +import InputSearch from "../system/input/inputSearch"; +import Divider from "../system/divider"; +import PastAnnouncementCell from "./pastAnnouncementCell"; +import Footer from "../common/footer"; +import { InputSelect } from "../system/input/inputSelect"; +import FilterIcon from "@/icons/filterIcon"; +import PastFilter from "./pastFilter"; +import { DateRange } from "react-day-picker"; +import { dateInRange } from "@/utils/utils"; + +// TODO: Replace with React Query to fetch from API +const allAnnouncements: Announcement[] = [ + createDummyAnnouncement(), + createDummyAnnouncement(new Date().toDateString(), new Date().toDateString(), [AppName.EATERY]), + createDummyAnnouncement(new Date().toDateString(), new Date().toDateString(), [AppName.EATERY, AppName.RESELL]), + createDummyAnnouncement(new Date().toDateString(), new Date().toDateString(), [ + AppName.EATERY, + AppName.RESELL, + AppName.TRANSIT, + ]), + createDummyAnnouncement(new Date().toDateString(), new Date().toDateString(), [ + AppName.EATERY, + AppName.RESELL, + AppName.TRANSIT, + AppName.COURSEGRAB, + ]), + createDummyAnnouncement(new Date().toDateString(), new Date().toDateString(), [ + AppName.EATERY, + AppName.RESELL, + AppName.TRANSIT, + AppName.COURSEGRAB, + AppName.VOLUME, + ]), + { + id: "uuidv4", + apps: [AppName.EATERY, AppName.RESELL, AppName.TRANSIT, AppName.COURSEGRAB], + body: "Get a taste of the course content, ask questions, and see if DPD is the right fit for you!", + creator: { + email: "vdb23@cornell.edu", + idToken: "idToken", + imageUrl: "https://lh3.googleusercontent.com/a/ACg8ocLSV3bTsn-XINmiSkt4FbdlzRDV0EJBc_LX-hv7gdo3LGp8cAB_=s96-c", + isAdmin: true, + name: "Vin Bui", + }, + endDate: "2024-10-16T03:00:00.000Z", + imageUrl: "https://appdev-upload.nyc3.cdn.digitaloceanspaces.com/announcements/n07chyp8.jpg", + link: "https://www.instagram.com/p/C4ExCD1rB6U", + startDate: "2024-08-15T03:00:00.000Z", + title: "Testing", + }, +]; + +export default function Past() { + const [searchText, setSearchText] = useState(""); + const [selectedSort, setSelectedSort] = useState(SortType.MOST_RECENT); + const [listedAnnouncements, setListedAnnouncements] = useState( + // Sort by most recent first + allAnnouncements.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()) + ); + const [showFilters, setShowFilters] = useState(false); + const [filterDateRange, setFilterDateRange] = useState(undefined); + const [filterApps, setFilterApps] = useState([]); + + const handleSearchChange = (event: ChangeEvent) => { + setSearchText(event.target.value); + }; + + useEffect(() => { + // Searching + const searched = allAnnouncements.filter((announcement) => { + const title = announcement.title.toLowerCase().replace(/\s/g, ""); + const search = searchText.toLowerCase().replace(/\s/g, ""); + return title.includes(search); + }); + + // Sorting + const sorted = [...searched].sort((a, b) => { + switch (selectedSort) { + case SortType.MOST_RECENT: + return new Date(b.startDate).getTime() - new Date(a.startDate).getTime(); + case SortType.OLDEST: + return new Date(a.startDate).getTime() - new Date(b.startDate).getTime(); + case SortType.TITLE_A_Z: + return a.title.localeCompare(b.title); + case SortType.TITLE_Z_A: + return b.title.localeCompare(a.title); + default: + return 0; // No sorting if selectedSort is invalid + } + }); + + // Filtering + const hasOverlap = (arr1: AppName[], arr2: AppName[]) => { + if (filterApps.length === 0) return true; + return arr1.some((item) => arr2.includes(item)); + }; + const withinRange = (announcement: Announcement) => { + const startDate = filterDateRange?.from; + const endDate = filterDateRange?.to; + if (!startDate || !endDate) return true; + + // Beginning of start date to end of end date + startDate.setHours(0, 0, 0, 0); + endDate.setHours(23, 59, 59, 999); + + return ( + dateInRange(startDate, endDate, new Date(announcement.startDate)) || + dateInRange(startDate, endDate, new Date(announcement.endDate)) + ); + }; + const filtered = sorted.filter( + (announcement) => hasOverlap(announcement.apps, filterApps) && withinRange(announcement) + ); + setListedAnnouncements(filtered); + setShowFilters(false); + }, [searchText, selectedSort, filterDateRange, filterApps]); + + return ( +
+ +
+ {/* Header */} + + + {/* Filters */} +
+ +
+
setShowFilters(true)} + > + +

+ {filterDateRange && filterApps.length !== 0 + ? "Filter - 2" + : filterDateRange || filterApps.length !== 0 + ? "Filter - 1" + : "Filter"} +

+
+ {showFilters ? ( + setShowFilters(false)} + onApply={(newDateRange, newApps) => { + setFilterDateRange(newDateRange); + setFilterApps(newApps); + }} + /> + ) : null} +
+

SORT BY

+ setSelectedSort(selected)} /> +
+
+
+ + {/* Table Header */} +

{`${listedAnnouncements.length} ${ + listedAnnouncements.length === 1 ? "announcement" : "announcements" + }`}

+
+
+
Image
+
Title
+
Start Time
+
End Time
+
Scheduler
+
Apps
+
+ {listedAnnouncements.map((announcement) => ( +
+ + +
+ ))} +
+ + {/* Table Rows */} +
+ {listedAnnouncements.map((announcement) => ( + + ))} +
+
+ + {/* Footer */} +
+
+
+
+ ); +} diff --git a/src/components/past/pastAnnouncementCell.tsx b/src/components/past/pastAnnouncementCell.tsx new file mode 100644 index 0000000..2c82981 --- /dev/null +++ b/src/components/past/pastAnnouncementCell.tsx @@ -0,0 +1,84 @@ +import AppIcon from "@/icons/appIcon"; +import { Announcement } from "@/models/announcement"; +import { AppName } from "@/models/enums/appName"; +import { DateFormat } from "@/models/enums/dateFormat"; +import { formatDate, getAppCountString } from "@/utils/utils"; + +interface Props { + announcement: Announcement; +} + +export default function PastAnnouncementCell({ announcement }: Props) { + const renderAppIcons = () => { + const appLength = announcement.apps.length; + + if (appLength === Object.keys(AppName).length) { + return

All Apps

; + } else if (appLength < 4) { + return ( +
+ {announcement.apps.map((app) => ( + + ))} +
+ ); + } else { + return ( +
+ {announcement.apps.slice(0, 3).map((app) => ( + + ))} +

{`and ${appLength - 3} more`}

+
+ ); + } + }; + + return ( + <> +
+ {announcement.title} +
{announcement.title}
+

+ {formatDate(new Date(announcement.startDate), DateFormat.SHORT_YEAR)} +

+

+ {formatDate(new Date(announcement.endDate), DateFormat.SHORT_YEAR)} +

+
+ {announcement.creator.name} +

{announcement.creator.name}

+
+

{getAppCountString(announcement.apps)}

+
{renderAppIcons()}
+
+
+ {announcement.title} +
+
+

{announcement.title}

+

+ {`${formatDate(new Date(announcement.startDate), DateFormat.SHORT)} - ${formatDate( + new Date(announcement.endDate), + DateFormat.SHORT + )}`} +

+
+
+ {announcement.creator.name} +

{`Scheduled by ${announcement.creator.name}`}

+
+
+ {announcement.apps.map((app) => ( + + ))} +
+
+
+ + ); +} diff --git a/src/components/past/pastFilter.tsx b/src/components/past/pastFilter.tsx new file mode 100644 index 0000000..f613f7a --- /dev/null +++ b/src/components/past/pastFilter.tsx @@ -0,0 +1,63 @@ +import { AppName } from "@/models/enums/appName"; +import InputDatePicker from "../system/input/inputDatePicker"; +import { DateRange } from "react-day-picker"; +import InputMultiSelect from "../system/input/inputMultiselect"; +import ButtonPrimary2 from "../system/button/buttonPrimary2"; +import ButtonPrimary3 from "../system/button/buttonPrimary3"; +import { useState } from "react"; + +interface Props { + initialDateRange: DateRange | undefined; + initialApps: AppName[]; + onCancel: () => void; + onApply: (newDateRange: DateRange | undefined, newApps: AppName[]) => void; +} + +export default function PastFilter({ initialDateRange, initialApps, onCancel, onApply }: Props) { + const [dateRange, setDateRange] = useState(initialDateRange); + const [apps, setApps] = useState(initialApps); + const [filtersReset, setFiltersReset] = useState(""); + + // Reset Filters + const resetFilters = () => { + setDateRange(undefined); + setApps([]); + + filtersReset === "" ? setFiltersReset("reset") : setFiltersReset(""); // Force re-render + }; + + return ( +
+
+
Filters
+ + {/* Date */} +
+
Date
+ +
+ + {/* Apps */} +
+
Apps
+ setApps(apps.map((str) => AppName[str.toUpperCase() as keyof typeof AppName]))} + /> +
+ + {/* CTAs */} +
+

+ Reset Filters +

+
+ + onApply(dateRange, apps)} className="py-2" textStyle="label" /> +
+
+
+
+ ); +} diff --git a/src/components/system/ButtonSecondary1.tsx b/src/components/system/ButtonSecondary1.tsx deleted file mode 100644 index def5d85..0000000 --- a/src/components/system/ButtonSecondary1.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import CaretRightIcon from "@/icons/CaretRightIcon"; - -interface Props { - text: string; - action: () => void; - disabled?: boolean; - className?: string; -} - -export default function ButtonPrimary1({ text, action, disabled = false, className }: Props) { - const handleClick = (event: React.MouseEvent) => { - event.stopPropagation(); // Prevent event from bubbling up - action(); - }; - - return ( - - ); -} diff --git a/src/components/system/ButtonPrimary1.tsx b/src/components/system/button/buttonPrimary1.tsx similarity index 59% rename from src/components/system/ButtonPrimary1.tsx rename to src/components/system/button/buttonPrimary1.tsx index ad3e47b..fe7a6c7 100644 --- a/src/components/system/ButtonPrimary1.tsx +++ b/src/components/system/button/buttonPrimary1.tsx @@ -1,13 +1,16 @@ -import CaretRightIcon from "@/icons/CaretRightIcon"; +"use client"; + +import CaretRightIcon from "@/icons/caretRightIcon"; interface Props { text: string; action: () => void; disabled?: boolean; className?: string; + textStyle?: string; } -export default function ButtonPrimary1({ text, action, disabled = false, className }: Props) { +export default function ButtonPrimary1({ text, action, disabled = false, className, textStyle }: Props) { const handleClick = (event: React.MouseEvent) => { event.stopPropagation(); // Prevent event from bubbling up action(); @@ -15,13 +18,19 @@ export default function ButtonPrimary1({ text, action, disabled = false, classNa return ( ); diff --git a/src/components/system/ButtonPrimary2.tsx b/src/components/system/button/buttonPrimary2.tsx similarity index 57% rename from src/components/system/ButtonPrimary2.tsx rename to src/components/system/button/buttonPrimary2.tsx index 6eb8460..0f6bb90 100644 --- a/src/components/system/ButtonPrimary2.tsx +++ b/src/components/system/button/buttonPrimary2.tsx @@ -5,21 +5,18 @@ interface Props { action: () => void; disabled?: boolean; className?: string; + textStyle?: string; } -export default function ButtonPrimary2({ text, action, disabled = false, className }: Props) { +export default function ButtonPrimary2({ text, action, disabled = false, className, textStyle }: Props) { const handleClick = (event: React.MouseEvent) => { event.stopPropagation(); // Prevent event from bubbling up action(); }; return ( - ); } diff --git a/src/components/system/ButtonPrimary3.tsx b/src/components/system/button/buttonPrimary3.tsx similarity index 65% rename from src/components/system/ButtonPrimary3.tsx rename to src/components/system/button/buttonPrimary3.tsx index 4567368..0e86949 100644 --- a/src/components/system/ButtonPrimary3.tsx +++ b/src/components/system/button/buttonPrimary3.tsx @@ -5,9 +5,10 @@ interface Props { action: () => void; disabled?: boolean; className?: string; + textStyle?: string; } -export default function ButtonPrimary3({ text, action, disabled = false, className }: Props) { +export default function ButtonPrimary3({ text, action, disabled = false, className, textStyle }: Props) { const handleClick = (event: React.MouseEvent) => { event.stopPropagation(); // Prevent event from bubbling up action(); @@ -15,11 +16,11 @@ export default function ButtonPrimary3({ text, action, disabled = false, classNa return ( ); } diff --git a/src/components/system/button/buttonSecondary1.tsx b/src/components/system/button/buttonSecondary1.tsx new file mode 100644 index 0000000..ae96718 --- /dev/null +++ b/src/components/system/button/buttonSecondary1.tsx @@ -0,0 +1,29 @@ +"use client"; + +import CaretRightIcon from "@/icons/caretRightIcon"; + +interface Props { + text: string; + action: () => void; + disabled?: boolean; + className?: string; + textStyle?: string; +} + +export default function ButtonSecondary1({ text, action, disabled = false, className, textStyle }: Props) { + const handleClick = (event: React.MouseEvent) => { + event.stopPropagation(); // Prevent event from bubbling up + action(); + }; + + return ( + + ); +} diff --git a/src/components/system/ButtonSecondary2.tsx b/src/components/system/button/buttonSecondary2.tsx similarity index 56% rename from src/components/system/ButtonSecondary2.tsx rename to src/components/system/button/buttonSecondary2.tsx index f232c7d..dd603f2 100644 --- a/src/components/system/ButtonSecondary2.tsx +++ b/src/components/system/button/buttonSecondary2.tsx @@ -1,19 +1,22 @@ +"use client"; + interface Props { text: string; action: () => void; disabled?: boolean; className?: string; + textStyle?: string; } -export default function ButtonSecondary2({ text, action, disabled = false, className }: Props) { +export default function ButtonSecondary2({ text, action, disabled = false, className, textStyle }: Props) { const handleClick = (event: React.MouseEvent) => { event.stopPropagation(); // Prevent event from bubbling up action(); }; return ( - ); } diff --git a/src/components/system/ButtonTertiary.tsx b/src/components/system/button/buttonTertiary.tsx similarity index 51% rename from src/components/system/ButtonTertiary.tsx rename to src/components/system/button/buttonTertiary.tsx index 8bc6fed..e9b299b 100644 --- a/src/components/system/ButtonTertiary.tsx +++ b/src/components/system/button/buttonTertiary.tsx @@ -1,13 +1,16 @@ -import EditIcon from "@/icons/EditIcon"; +"use client"; + +import EditIcon from "@/icons/editIcon"; interface Props { text: string; action: () => void; - className?: string; disabled?: boolean; + className?: string; + textStyle?: string; } -export default function TertiaryButton({ text, action, className, disabled = false }: Props) { +export default function TertiaryButton({ text, action, disabled = false, className, textStyle }: Props) { const handleClick = (event: React.MouseEvent) => { event.stopPropagation(); // Prevent event from bubbling up action(); @@ -15,12 +18,12 @@ export default function TertiaryButton({ text, action, className, disabled = fal return ( ); } diff --git a/src/components/system/divider.tsx b/src/components/system/divider.tsx new file mode 100644 index 0000000..74cf54d --- /dev/null +++ b/src/components/system/divider.tsx @@ -0,0 +1,14 @@ +interface Props { + style: "horizontal" | "vertical"; + className?: string; +} + +export default function Divider({ style, className }: Props) { + if (style === "horizontal") { + return
; + } else if (style === "vertical") { + return
; + } else { + return null; + } +} diff --git a/src/components/system/input/inputDatePicker.tsx b/src/components/system/input/inputDatePicker.tsx index 8fc49a1..8cbc138 100644 --- a/src/components/system/input/inputDatePicker.tsx +++ b/src/components/system/input/inputDatePicker.tsx @@ -1,6 +1,6 @@ "use client"; -import { addDays, format } from "date-fns"; +import { format } from "date-fns"; import { CalendarIcon } from "lucide-react"; import { DateRange } from "react-day-picker"; @@ -11,15 +11,14 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { useEffect, useState } from "react"; interface Props { - value: DateRange; - setDateRange: (dateRange: DateRange) => void; + value: DateRange | undefined; + setDateRange: (dateRange: DateRange | undefined) => void; } export default function InputDatePicker({ value, setDateRange }: Props) { const [date, setDate] = useState(value); useEffect(() => { - if (!date) return; setDateRange(date); }, [date]); diff --git a/src/components/system/input/inputMultiselect.tsx b/src/components/system/input/inputMultiselect.tsx index 0ec2a73..0d66273 100644 --- a/src/components/system/input/inputMultiselect.tsx +++ b/src/components/system/input/inputMultiselect.tsx @@ -20,10 +20,10 @@ const options = [ interface Props { value: string[]; - setApps: (apps: string[]) => void; + setValues: (values: string[]) => void; } -export default function InputMultiSelect({ value, setApps }: Props) { +export default function InputMultiSelect({ value, setValues }: Props) { const [open, setOpen] = useState(false); const [selectedValues, setSelectedValues] = useState(value); @@ -39,7 +39,7 @@ export default function InputMultiSelect({ value, setApps }: Props) { ); useEffect(() => { - setApps(selectedValues); + setValues(selectedValues); }, [selectedValues]); return ( diff --git a/src/components/system/input/inputSearch.tsx b/src/components/system/input/inputSearch.tsx new file mode 100644 index 0000000..a383d96 --- /dev/null +++ b/src/components/system/input/inputSearch.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { Input } from "@/components/ui/input"; +import SearchIcon from "@/icons/searchIcon"; +import { ChangeEvent } from "react"; + +interface Props { + text: string; + placeholder: string; + onChange: (event: ChangeEvent) => void; +} + +export default function InputSearch({ text, placeholder, onChange }: Props) { + return ( +
+ + +
+ ); +} diff --git a/src/components/system/input/inputSelect.tsx b/src/components/system/input/inputSelect.tsx new file mode 100644 index 0000000..a0dba98 --- /dev/null +++ b/src/components/system/input/inputSelect.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { SortType } from "@/models/enums/sortType"; + +interface Props { + selected: SortType; + setSelected: (selected: SortType) => void; +} + +export function InputSelect({ selected, setSelected }: Props) { + return ( + + ); +} diff --git a/src/components/system/input/inputText.tsx b/src/components/system/input/inputText.tsx index f4a51ca..343dff2 100644 --- a/src/components/system/input/inputText.tsx +++ b/src/components/system/input/inputText.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Input } from "@/components/ui/input"; interface Props { @@ -11,7 +13,7 @@ export default function InputText({ name, placeholder, value, onChange }: Props) return (
{name}
- +
); } diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 0000000..9704469 --- /dev/null +++ b/src/components/ui/select.tsx @@ -0,0 +1,144 @@ +"use client"; + +import * as React from "react"; +import * as SelectPrimitive from "@radix-ui/react-select"; +import { Check, ChevronDown, ChevronUp } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/src/icons/AppIcon.tsx b/src/icons/AppIcon.tsx index b58433c..b430dde 100644 --- a/src/icons/AppIcon.tsx +++ b/src/icons/AppIcon.tsx @@ -1,4 +1,4 @@ -import { AppName } from "@/models/appName"; +import { AppName } from "@/models/enums/appName"; import { IconProps } from "@/models/props/iconProps"; interface Props extends IconProps { @@ -20,8 +20,6 @@ export default function AppIcon({ appName, className }: Props) { 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}`); } diff --git a/src/icons/CheckIcon.tsx b/src/icons/CheckIcon.tsx deleted file mode 100644 index 473b464..0000000 --- a/src/icons/CheckIcon.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { IconProps } from "@/models/props/iconProps"; - -export default function ({ className }: IconProps) { - return ( - - - - ); -} diff --git a/src/icons/ChevronDownIcon.tsx b/src/icons/ChevronDownIcon.tsx deleted file mode 100644 index ea6d0c1..0000000 --- a/src/icons/ChevronDownIcon.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { IconProps } from "@/models/props/iconProps"; - -export default function ({ className }: IconProps) { - return ( - - - - ); -} diff --git a/src/icons/filterIcon.tsx b/src/icons/filterIcon.tsx new file mode 100644 index 0000000..acdf092 --- /dev/null +++ b/src/icons/filterIcon.tsx @@ -0,0 +1,9 @@ +import { IconProps } from "@/models/props/iconProps"; + +export default function ({ className }: IconProps) { + return ( + + + + ); +} diff --git a/src/icons/searchIcon.tsx b/src/icons/searchIcon.tsx new file mode 100644 index 0000000..f7f3663 --- /dev/null +++ b/src/icons/searchIcon.tsx @@ -0,0 +1,13 @@ +import { IconProps } from "@/models/props/iconProps"; + +export default function ({ className }: IconProps) { + return ( + + + + ); +} diff --git a/src/models/announcement.ts b/src/models/announcement.ts index b42c70f..027d235 100644 --- a/src/models/announcement.ts +++ b/src/models/announcement.ts @@ -1,4 +1,4 @@ -import { AppName } from "./appName"; +import { AppName } from "./enums/appName"; import { User } from "./user"; export interface Announcement { diff --git a/src/models/appName.ts b/src/models/enums/appName.ts similarity index 88% rename from src/models/appName.ts rename to src/models/enums/appName.ts index 819f582..7ee07b3 100644 --- a/src/models/appName.ts +++ b/src/models/enums/appName.ts @@ -31,9 +31,4 @@ export enum AppName { * The Uplift app. */ UPLIFT = "uplift", - - /** - * The Scooped app. - */ - SCOOPED = "scooped", } diff --git a/src/models/enums/dateFormat.ts b/src/models/enums/dateFormat.ts new file mode 100644 index 0000000..8b243b0 --- /dev/null +++ b/src/models/enums/dateFormat.ts @@ -0,0 +1,13 @@ +/** + * Enum representing different date formatting options. + */ +export enum DateFormat { + /** + * Format: "M/D 0:00 AM/PM" + */ + SHORT, + /** + * Format: "M/D/Y 0:00 AM/PM" (Y has 2 digits) + */ + SHORT_YEAR, +} diff --git a/src/models/enums/sortType.ts b/src/models/enums/sortType.ts new file mode 100644 index 0000000..bd637c7 --- /dev/null +++ b/src/models/enums/sortType.ts @@ -0,0 +1,24 @@ +/** + * Represents the different sorting options available for a list of announcements. + */ +export enum SortType { + /** + * Sort announcements by most recent first. + */ + MOST_RECENT = "Most Recent", + + /** + * Sort announcements by oldest first. + */ + OLDEST = "Oldest", + + /** + * Sort announcements by title in ascending alphabetical order (A-Z). + */ + TITLE_A_Z = "Title A to Z", + + /** + * Sort announcements by title in descending alphabetical order (Z-A). + */ + TITLE_Z_A = "Title Z to A", +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 7d4d469..845a588 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -9,6 +9,7 @@ export const Constants = { default: "/", landing: "/landing", login: "/login", + past: "/past", }, queryKey: { fetchAnnouncements: "fetchAnnouncements", diff --git a/src/utils/dummy.ts b/src/utils/dummy.ts new file mode 100644 index 0000000..e6dfd3d --- /dev/null +++ b/src/utils/dummy.ts @@ -0,0 +1,45 @@ +import { Announcement } from "@/models/announcement"; +import { AppName } from "@/models/enums/appName"; +import { User } from "@/models/user"; +import { v4 as uuidv4 } from "uuid"; + +/** + * Creates a dummy announcement object for testing or development purposes. + * + * @param startDate The start date of the announcement. Defaults to August 15th, 2024. + * @param endDate The end date of the announcement. Defaults to October 16th, 2024. + * @param apps An array of app names relevant to the announcement. Defaults to all app names. + * @returns A dummy announcement object. + */ +export function createDummyAnnouncement( + startDate: string = "2024-08-15T03:00:00.000Z", + endDate: string = "2024-10-16T03:00:00.000Z", + apps: AppName[] = [ + AppName.EATERY, + AppName.TRANSIT, + AppName.UPLIFT, + AppName.COURSEGRAB, + AppName.VOLUME, + AppName.RESELL, + ] +): Announcement { + const creator: User = { + email: "vdb23@cornell.edu", + idToken: "idToken", + imageUrl: "https://lh3.googleusercontent.com/a/ACg8ocLSV3bTsn-XINmiSkt4FbdlzRDV0EJBc_LX-hv7gdo3LGp8cAB_=s96-c", + isAdmin: true, + name: "Vin Bui", + }; + + return { + id: uuidv4(), + apps, + body: "Get a taste of the course content, ask questions, and see if DPD is the right fit for you!", + creator, + endDate, + imageUrl: "https://appdev-upload.nyc3.cdn.digitaloceanspaces.com/announcements/n07chyp8.jpg", + link: "https://www.instagram.com/p/C4ExCD1rB6U", + startDate, + title: "DPD Lecture 0", + }; +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 824f60e..b1d9093 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,4 +1,6 @@ import { Announcement } from "@/models/announcement"; +import { AppName } from "@/models/enums/appName"; +import { DateFormat } from "@/models/enums/dateFormat"; /** * Filters out announcements whose startDate is in the past. @@ -100,18 +102,61 @@ export const dateInRange = (startDate: Date, endDate: Date, targetDate: Date = n }; /** - * Formats a Date object into a string in the M/D 00:00 AM/PM format. + * Formats a date object according to the specified DateFormat. * - * @param date - A date. - * @returns A string representing [date] in the above format. + * @param date - The date object to format. + * @param format - The desired date format (from DateFormat enum). + * @returns The formatted date string. + * + * @example + * const date = new Date('2024-09-17T05:25:00'); + * const formattedDate1 = formatDate(date, DateFormat.SHORT); // "9/17 5:25 AM" + * const formattedDate2 = formatDate(date, DateFormat.SHORT_YEAR); // "9/17/24 5:25 AM" + */ +export const formatDate = (date: Date, format: DateFormat): string => { + let options: Intl.DateTimeFormatOptions; + + switch (format) { + case DateFormat.SHORT: + options = { + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }; + break; + case DateFormat.SHORT_YEAR: + options = { + year: "2-digit", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: true, + }; + break; + default: + throw new Error(`Invalid DateFormat: ${format}`); + } + + return new Intl.DateTimeFormat("en-US", options).format(date).replace(",", ""); +}; + +/** + * Generates a string describing the number of apps provided. + * + * @param apps An array of AppName enum values. + * @returns A string representing the number of apps: "All Apps", "1 App", or "2 Apps". */ -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}`; +export const getAppCountString = (apps: AppName[]): string => { + const appCount = apps.length; + switch (appCount) { + case Object.keys(AppName).length: + return "All Apps"; + case 1: + return "1 App"; + default: + return `${appCount} Apps`; + } }; diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 862cb5d..bee5dda 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -1,6 +1,7 @@ import { Announcement } from "@/models/announcement"; -import { AppName } from "@/models/appName"; -import { User } from "@/models/user"; +import { AppName } from "@/models/enums/appName"; +import { DateFormat } from "@/models/enums/dateFormat"; +import { createDummyAnnouncement } from "@/utils/dummy"; import { calculateTimeRemaining, dateInRange, @@ -8,92 +9,25 @@ import { filterFutureAnnouncements, filterPastAnnouncements, formatDate, + getAppCountString, getEarliestAnnouncements, sortAnnouncementsByStartDate, } from "../src/utils/utils"; -const creator: User = { - email: "vdb23@cornell.edu", - idToken: "idToken", - imageUrl: "https://lh3.googleusercontent.com/a/ACg8ocLSV3bTsn-XINmiSkt4FbdlzRDV0EJBc_LX-hv7gdo3LGp8cAB_=s96-c", - isAdmin: true, - name: "Vin Bui", -}; - const announcements: Announcement[] = [ - { - id: "1", - apps: [AppName.EATERY], - body: "Announcement 1", - creator, - endDate: "2024-04-01T00:00:00Z", - imageUrl: "image1.jpg", - link: "link1", - startDate: "2024-03-15T00:00:00Z", - title: "Announcement 1", - }, - { - id: "2", - apps: [AppName.RESELL, AppName.COURSEGRAB], - body: "Announcement 2", - creator, - endDate: "2024-08-30T00:00:00Z", - imageUrl: "image2.jpg", - link: "link2", - startDate: "2024-08-20T00:00:00Z", - title: "Announcement 2", - }, - { - id: "3", - apps: [AppName.UPLIFT], - body: "Announcement 3", - creator, - endDate: "2025-03-30T00:00:00Z", - imageUrl: "image3.jpg", - link: "link3", - startDate: "2025-03-25T00:00:00Z", - title: "Announcement 3", - }, + createDummyAnnouncement("2024-03-15T00:00:00Z", "2024-04-01T00:00:00Z", [AppName.EATERY]), + createDummyAnnouncement("2024-08-20T00:00:00Z", "2024-08-30T00:00:00Z", [AppName.RESELL, AppName.COURSEGRAB]), + createDummyAnnouncement("2025-03-25T00:00:00Z", "2025-03-30T00:00:00Z", [AppName.UPLIFT]), ]; const duplicateStartAnnouncements: Announcement[] = [ - { - id: "4", - apps: [AppName.EATERY], - body: "Announcement 4", - creator, - endDate: "2024-05-01T00:00:00Z", - imageUrl: "image4.jpg", - link: "link4", - startDate: "2024-03-15T00:00:00Z", - title: "Announcement 4", - }, ...announcements, + createDummyAnnouncement("2024-03-15T00:00:00Z", "2024-05-01T00:00:00Z", [AppName.UPLIFT]), ]; const noFutureAnnouncements: Announcement[] = [ - { - id: "0", - apps: [AppName.EATERY], - body: "Announcement 1", - creator, - endDate: "2024-05-01T00:00:00Z", - imageUrl: "image4.jpg", - link: "link4", - startDate: "2024-03-15T00:00:00Z", - title: "Announcement 4", - }, - { - id: "1", - apps: [AppName.EATERY], - body: "Announcement 2", - creator, - endDate: "2023-11-12T00:00:00Z", - imageUrl: "image4.jpg", - link: "link4", - startDate: "2023-05-30T00:00:00Z", - title: "Announcement 4", - }, + createDummyAnnouncement("2024-03-15T00:00:00Z", "2024-05-01T00:00:00Z"), + createDummyAnnouncement("2023-05-30T00:00:00Z", "2023-11-12T00:00:00Z"), ]; describe("Utils", () => { @@ -101,8 +35,8 @@ describe("Utils", () => { it("should filter announcements to only include those with a start date in the future", () => { const result = filterFutureAnnouncements(announcements, new Date(2024, 7, 1)); expect(result.length).toBe(2); - expect(result[0].id).toBe("2"); - expect(result[1].id).toBe("3"); + expect(result[0].id).toBe(announcements[1].id); + expect(result[1].id).toBe(announcements[2].id); }); it("should filter out past announcements (no future announcements)", () => { const result = filterFutureAnnouncements(noFutureAnnouncements, new Date(2024, 7, 1)); @@ -113,9 +47,9 @@ describe("Utils", () => { describe("sortAnnouncementsByStartDate", () => { it("should sort announcements by their start date in ascending order", () => { const result = sortAnnouncementsByStartDate(announcements); - expect(result[0].id).toBe("1"); - expect(result[1].id).toBe("2"); - expect(result[2].id).toBe("3"); + expect(result[0].id).toBe(announcements[0].id); + expect(result[1].id).toBe(announcements[1].id); + expect(result[2].id).toBe(announcements[2].id); }); }); @@ -173,14 +107,14 @@ describe("Utils", () => { it("should return an array with the announcement with the earliest start date", () => { const result = getEarliestAnnouncements(announcements); expect(result.length).toBe(1); - expect(result[0].id).toBe("1"); + expect(result[0].id).toBe(announcements[0].id); }); it("should return an array with multiple announcements if they have the same earliest start date", () => { const result = getEarliestAnnouncements(duplicateStartAnnouncements); expect(result.length).toBe(2); - expect(result[0].id).toBe("4"); - expect(result[1].id).toBe("1"); + expect(result[0].id).toBe(duplicateStartAnnouncements[0].id); + expect(result[1].id).toBe(duplicateStartAnnouncements[1].id); }); it("should return an empty array if there are no announcements", () => { @@ -193,7 +127,7 @@ describe("Utils", () => { 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"); + expect(result[0].id).toBe(announcements[2].id); }); 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)); @@ -202,8 +136,8 @@ describe("Utils", () => { 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"); + expect(result[0].id).toBe(announcements[1].id); + expect(result[1].id).toBe(announcements[2].id); }); }); @@ -253,37 +187,37 @@ describe("Utils", () => { 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); + const result = formatDate(date, DateFormat.SHORT); 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); + const result = formatDate(date, DateFormat.SHORT); 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); + const result = formatDate(date, DateFormat.SHORT); 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); + const result = formatDate(date, DateFormat.SHORT); 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); + const result = formatDate(date, DateFormat.SHORT); 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); + const result = formatDate(date, DateFormat.SHORT); expect(result).toBe("9/22 12:00 AM"); }); }); @@ -292,8 +226,8 @@ describe("Utils", () => { it("should filter announcements to only include those with an end date in the past", () => { const result = filterPastAnnouncements(announcements, new Date(2024, 11, 1)); expect(result.length).toBe(2); - expect(result[0].id).toBe("1"); - expect(result[1].id).toBe("2"); + expect(result[0].id).toBe(announcements[0].id); + expect(result[1].id).toBe(announcements[1].id); }); it("all announcements are in the future (returns empty array)", () => { @@ -315,8 +249,30 @@ describe("Utils", () => { it("one input announcement has the exact same end date as the date it's being compared to (should not be in the output)", () => { const result = filterPastAnnouncements(announcements, new Date("2025-03-30T00:00:00Z")); expect(result.length).toBe(2); - expect(result[0].id).toBe("1"); - expect(result[1].id).toBe("2"); + expect(result[0].id).toBe(announcements[0].id); + expect(result[1].id).toBe(announcements[1].id); + }); + }); + + describe("getAppCountString", () => { + it("should output all apps", () => { + const result = getAppCountString(createDummyAnnouncement().apps); + expect(result).toBe("All Apps"); + }); + + it("should output 1 app", () => { + const result = getAppCountString( + createDummyAnnouncement(new Date().toDateString(), new Date().toDateString(), [AppName.RESELL]).apps + ); + expect(result).toBe("1 App"); + }); + + it("should output multiple but not all apps", () => { + const result = getAppCountString( + createDummyAnnouncement(new Date().toDateString(), new Date().toDateString(), [AppName.EATERY, AppName.RESELL]) + .apps + ); + expect(result).toBe("2 Apps"); }); }); }); diff --git a/yarn.lock b/yarn.lock index 5c96235..d63b6d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -309,22 +309,13 @@ "@floating-ui/core" "^1.6.0" "@floating-ui/utils" "^0.2.8" -"@floating-ui/react-dom@^2.0.0", "@floating-ui/react-dom@^2.1.2": +"@floating-ui/react-dom@^2.0.0": version "2.1.2" resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.2.tgz#a1349bbf6a0e5cb5ded55d023766f20a4d439a31" integrity sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A== dependencies: "@floating-ui/dom" "^1.0.0" -"@floating-ui/react@^0.26.16": - version "0.26.28" - resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.26.28.tgz#93f44ebaeb02409312e9df9507e83aab4a8c0dc7" - integrity sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw== - dependencies: - "@floating-ui/react-dom" "^2.1.2" - "@floating-ui/utils" "^0.2.8" - tabbable "^6.0.0" - "@floating-ui/utils@^0.2.8": version "0.2.8" resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.8.tgz#21a907684723bbbaa5f0974cf7730bd797eb8e62" @@ -371,21 +362,6 @@ dependencies: tslib "2" -"@headlessui/react@^2.1.9": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-2.2.0.tgz#a8e32f0899862849a1ce1615fa280e7891431ab7" - integrity sha512-RzCEg+LXsuI7mHiSomsu/gBJSjpupm6A1qIZ5sWjd7JhARNlMiSA4kKfJpCKwU9tE+zMRterhhrP74PvfJrpXQ== - dependencies: - "@floating-ui/react" "^0.26.16" - "@react-aria/focus" "^3.17.1" - "@react-aria/interactions" "^3.21.3" - "@tanstack/react-virtual" "^3.8.1" - -"@headlessui/tailwindcss@^0.2.1": - version "0.2.1" - resolved "https://registry.yarnpkg.com/@headlessui/tailwindcss/-/tailwindcss-0.2.1.tgz#1becc201f69358a40e08bd676acc234b2cabe6e4" - integrity sha512-2+5+NZ+RzMyrVeCZOxdbvkUSssSxGvcUxphkIfSVLpRiKsj+/63T2TOL9dBYMXVfj/CGr6hMxSRInzXv6YY7sA== - "@img/sharp-darwin-arm64@0.33.5": version "0.33.5" resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz#ef5b5a07862805f1e8145a377c8ba6e98813ca08" @@ -1106,6 +1082,11 @@ resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== +"@radix-ui/number@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.0.tgz#1e95610461a09cdf8bb05c152e76ca1278d5da46" + integrity sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ== + "@radix-ui/primitive@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.0.1.tgz#e46f9958b35d10e9f6dc71c497305c22e3e55dbd" @@ -1125,6 +1106,16 @@ dependencies: "@radix-ui/react-primitive" "2.0.0" +"@radix-ui/react-collection@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.1.0.tgz#f18af78e46454a2360d103c2251773028b7724ed" + integrity sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw== + dependencies: + "@radix-ui/react-compose-refs" "1.1.0" + "@radix-ui/react-context" "1.1.0" + "@radix-ui/react-primitive" "2.0.0" + "@radix-ui/react-slot" "1.1.0" + "@radix-ui/react-compose-refs@1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz#7ed868b66946aa6030e580b1ffca386dd4d21989" @@ -1195,6 +1186,11 @@ aria-hidden "^1.1.1" react-remove-scroll "2.6.0" +"@radix-ui/react-direction@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.1.0.tgz#a7d39855f4d077adc2a1922f9c353c5977a09cdc" + integrity sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg== + "@radix-ui/react-dismissable-layer@1.0.5": version "1.0.5" resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz#3f98425b82b9068dfbab5db5fff3df6ebf48b9d4" @@ -1357,6 +1353,33 @@ "@radix-ui/react-context" "1.1.0" "@radix-ui/react-primitive" "2.0.0" +"@radix-ui/react-select@^2.1.2": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-select/-/react-select-2.1.2.tgz#2346e118966db793940f6a866fd4cc5db2cc275e" + integrity sha512-rZJtWmorC7dFRi0owDmoijm6nSJH1tVw64QGiNIZ9PNLyBDtG+iAq+XGsya052At4BfarzY/Dhv9wrrUr6IMZA== + dependencies: + "@radix-ui/number" "1.1.0" + "@radix-ui/primitive" "1.1.0" + "@radix-ui/react-collection" "1.1.0" + "@radix-ui/react-compose-refs" "1.1.0" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-direction" "1.1.0" + "@radix-ui/react-dismissable-layer" "1.1.1" + "@radix-ui/react-focus-guards" "1.1.1" + "@radix-ui/react-focus-scope" "1.1.0" + "@radix-ui/react-id" "1.1.0" + "@radix-ui/react-popper" "1.2.0" + "@radix-ui/react-portal" "1.1.2" + "@radix-ui/react-primitive" "2.0.0" + "@radix-ui/react-slot" "1.1.0" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-controllable-state" "1.1.0" + "@radix-ui/react-use-layout-effect" "1.1.0" + "@radix-ui/react-use-previous" "1.1.0" + "@radix-ui/react-visually-hidden" "1.1.0" + aria-hidden "^1.1.1" + react-remove-scroll "2.6.0" + "@radix-ui/react-slot@1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab" @@ -1426,6 +1449,11 @@ resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz#3c2c8ce04827b26a39e442ff4888d9212268bd27" integrity sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w== +"@radix-ui/react-use-previous@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz#d4dd37b05520f1d996a384eb469320c2ada8377c" + integrity sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og== + "@radix-ui/react-use-rect@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz#13b25b913bd3e3987cc9b073a1a164bb1cf47b88" @@ -1440,6 +1468,13 @@ dependencies: "@radix-ui/react-use-layout-effect" "1.1.0" +"@radix-ui/react-visually-hidden@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.0.tgz#ad47a8572580f7034b3807c8e6740cd41038a5a2" + integrity sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ== + dependencies: + "@radix-ui/react-primitive" "2.0.0" + "@radix-ui/rect@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.0.tgz#f817d1d3265ac5415dadc67edab30ae196696438" @@ -1511,7 +1546,7 @@ "@react-types/shared" "^3.26.0" "@swc/helpers" "^0.5.0" -"@react-aria/focus@3.19.0", "@react-aria/focus@^3.17.1", "@react-aria/focus@^3.19.0": +"@react-aria/focus@3.19.0", "@react-aria/focus@^3.19.0": version "3.19.0" resolved "https://registry.yarnpkg.com/@react-aria/focus/-/focus-3.19.0.tgz#82b9a5b83f023b943a7970df3d059f49d61df05d" integrity sha512-hPF9EXoUQeQl1Y21/rbV2H4FdUR2v+4/I0/vB+8U3bT1CJ+1AFj1hc/rqx2DqEwDlEwOHN+E4+mRahQmlybq0A== @@ -1547,7 +1582,7 @@ "@react-types/shared" "^3.26.0" "@swc/helpers" "^0.5.0" -"@react-aria/interactions@3.22.5", "@react-aria/interactions@^3.21.3", "@react-aria/interactions@^3.22.5": +"@react-aria/interactions@3.22.5", "@react-aria/interactions@^3.22.5": version "3.22.5" resolved "https://registry.yarnpkg.com/@react-aria/interactions/-/interactions-3.22.5.tgz#9cd8c93b8b6988f1d315d3efb450119d1432bbb8" integrity sha512-kMwiAD9E0TQp+XNnOs13yVJghiy8ET8L0cbkeuTgNI96sOAp/63EJ1FSrDf17iD8sdjt41LafwX/dKXW9nCcLQ== @@ -1810,18 +1845,6 @@ dependencies: "@tanstack/query-core" "5.62.7" -"@tanstack/react-virtual@^3.8.1": - version "3.11.1" - resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.11.1.tgz#708a5e27041f8678f78b96228376f7a35b6a29fa" - integrity sha512-orn2QNe5tF6SqjucHJ6cKTKcRDe3GG7bcYqPNn72Yejj7noECdzgAyRfGt2pGDPemhYim3d1HIR/dgruCnLfUA== - dependencies: - "@tanstack/virtual-core" "3.10.9" - -"@tanstack/virtual-core@3.10.9": - version "3.10.9" - resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.10.9.tgz#55710c92b311fdaa8d8c66682a0dbdd684bc77c4" - integrity sha512-kBknKOKzmeR7lN+vSadaKWXaLS0SZZG+oqpQ/k80Q6g9REn6zRHS/ZYdrIzHnpHgy/eWs00SujveUN/GJT2qTw== - "@tsconfig/node10@^1.0.7": version "1.0.11" resolved "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz" @@ -4163,11 +4186,6 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== -tabbable@^6.0.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97" - integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew== - tailwind-merge@^1.14.0: version "1.14.0" resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-1.14.0.tgz#e677f55d864edc6794562c63f5001f45093cdb8b" @@ -4360,6 +4378,11 @@ util-deprecate@^1.0.2: resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== +uuid@^11.0.3: + version "11.0.3" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.0.3.tgz#248451cac9d1a4a4128033e765d137e2b2c49a3d" + integrity sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg== + v8-compile-cache-lib@^3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz"