diff --git a/apps/searchneu/app/catalog/[term]/[course]/page.tsx b/apps/searchneu/app/catalog/[term]/[course]/page.tsx index 46e70748..bb2aee62 100644 --- a/apps/searchneu/app/catalog/[term]/[course]/page.tsx +++ b/apps/searchneu/app/catalog/[term]/[course]/page.tsx @@ -23,7 +23,7 @@ const cachedCourse = unstable_cache(getCourse, ["banner.course"], { tags: ["banner.course"], }); -async function getTrackedSections() { +export async function getTrackedSections() { const session = await auth.api.getSession({ headers: await headers(), }); diff --git a/apps/searchneu/app/globals.css b/apps/searchneu/app/globals.css index 3a4472e0..df5c481e 100644 --- a/apps/searchneu/app/globals.css +++ b/apps/searchneu/app/globals.css @@ -308,6 +308,16 @@ url("/fonts/lato-thinitalic-webfont.woff") format("woff"); } +@layer utilities { + .shadow-neu-card { + box-shadow: + -1px 1px 3px 0px rgba(221, 221, 221, 0.1), + -3px 4px 5px 0px rgba(221, 221, 221, 0.09), + -7px 9px 7px 0px rgba(221, 221, 221, 0.05), + -12px 16px 8px 0px rgba(221, 221, 221, 0.01); + } +} + .sunset { background: linear-gradient( 180deg, diff --git a/apps/searchneu/app/notifications/layout.tsx b/apps/searchneu/app/notifications/layout.tsx new file mode 100644 index 00000000..a0775d34 --- /dev/null +++ b/apps/searchneu/app/notifications/layout.tsx @@ -0,0 +1,27 @@ +import { Header } from "@/components/navigation/Header"; +import { notificationsFlag } from "@/lib/flags"; +import { notFound } from "next/navigation"; +import { Suspense, type ReactNode } from "react"; + +export default async function Layout({ + children, +}: LayoutProps<"/notifications">) { + return ( +
+
+ + {children} + +
+ ); +} + +async function FlagCheck({ children }: { children: ReactNode }) { + const notificationsPage = await notificationsFlag(); + + if (!notificationsPage) { + notFound(); + } + + return children; +} diff --git a/apps/searchneu/app/notifications/page.tsx b/apps/searchneu/app/notifications/page.tsx new file mode 100644 index 00000000..9f950733 --- /dev/null +++ b/apps/searchneu/app/notifications/page.tsx @@ -0,0 +1,87 @@ +import { SectionTableMeetingTime } from "@/components/catalog/SectionTable"; +import { NotificationsWrapper } from "@/components/notifications/NotificationsWrapper"; +import { + db, + trackersT, + coursesT, + sectionsT, + subjectsT, + termsT, + user, +} from "@/lib/db"; +import { notificationsT } from "@sneu/db/schema"; +import { and, desc, eq, inArray, isNull } from "drizzle-orm"; +import { getSectionInfo } from "@/lib/controllers/getTrackers"; +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; + +export interface TrackerSection { + id: number; + crn: string; + faculty: string; + meetingTimes: SectionTableMeetingTime[]; + campus: string; + seatRemaining: number; + seatCapacity: number; + waitlistCapacity: number; + waitlistRemaining: number; +} + +export default async function Page() { + const session = await auth.api.getSession({ headers: await headers() }); + if (session) { + const currentUser = await db.query.user.findFirst({ + where: eq(user.id, session.user.id), + }); + + const trackers = await db.query.trackersT.findMany({ + where: and( + eq(trackersT.userId, session.user.id), + isNull(trackersT.deletedAt), + ), + }); + + const trackerIds = trackers.map((t) => t.id); + const notifications = + trackerIds.length > 0 + ? await db + .select({ + id: notificationsT.id, + crn: sectionsT.crn, + courseName: coursesT.name, + courseSubject: subjectsT.code, + courseNumber: coursesT.courseNumber, + sentAt: notificationsT.sentAt, + }) + .from(notificationsT) + .innerJoin(trackersT, eq(notificationsT.trackerId, trackersT.id)) + .innerJoin(sectionsT, eq(trackersT.sectionId, sectionsT.id)) + .innerJoin(coursesT, eq(sectionsT.courseId, coursesT.id)) + .innerJoin(subjectsT, eq(coursesT.subject, subjectsT.id)) + .where(inArray(notificationsT.trackerId, trackerIds)) + .orderBy(desc(notificationsT.sentAt)) + : []; + const trackedSectionIds = trackers.map((t) => t.sectionId); + const sections = + trackedSectionIds.length > 0 + ? await getSectionInfo(trackedSectionIds) + : []; + const terms = await db + .selectDistinct({ name: termsT.name }) + .from(sectionsT) + .innerJoin(termsT, eq(sectionsT.term, termsT.term)) + .where(inArray(sectionsT.id, trackedSectionIds)); + + return ( +
+ term.name)} + notifications={notifications} + sections={sections} + /> +
+ ); + } +} diff --git a/apps/searchneu/components/catalog/SectionTableBlocks.tsx b/apps/searchneu/components/catalog/SectionTableBlocks.tsx index c4e837af..1ac2b294 100644 --- a/apps/searchneu/components/catalog/SectionTableBlocks.tsx +++ b/apps/searchneu/components/catalog/SectionTableBlocks.tsx @@ -106,5 +106,5 @@ function formatTimeRange(startTime: number, endTime: number) { const formattedStart = `${start12Hour}:${startMinutes.toString().padStart(2, "0")}`; const formattedEnd = `${end12Hour}:${endMinutes.toString().padStart(2, "0")}`; - return `${formattedStart}${startIsPM ? "pm" : "am"} — ${formattedEnd}${endIsPM ? "pm" : "am"}`; + return `${formattedStart}${startIsPM ? "PM" : "AM"} — ${formattedEnd}${endIsPM ? "PM" : "AM"}`; } diff --git a/apps/searchneu/components/icons/BellIcon.tsx b/apps/searchneu/components/icons/BellIcon.tsx new file mode 100644 index 00000000..013ba4a0 --- /dev/null +++ b/apps/searchneu/components/icons/BellIcon.tsx @@ -0,0 +1,21 @@ +import { Bell } from "lucide-react"; + +interface BellIconProps { + className?: string; + opacity?: number; +} + +export function BellIcon({ + className = "text-r4", + opacity = 1, +}: BellIconProps) { + return ( + + ); +} diff --git a/apps/searchneu/components/navigation/Header.tsx b/apps/searchneu/components/navigation/Header.tsx index 5d2ac390..37ef5cb6 100644 --- a/apps/searchneu/components/navigation/Header.tsx +++ b/apps/searchneu/components/navigation/Header.tsx @@ -1,7 +1,13 @@ import Link from "next/link"; import { Logo } from "../icons/logo"; import { UserIcon } from "./UserMenu"; -import { faqFlag, graduateFlag, roomsFlag, schedulerFlag } from "@/lib/flags"; +import { + faqFlag, + graduateFlag, + notificationsFlag, + roomsFlag, + schedulerFlag, +} from "@/lib/flags"; import { MenuIcon, XIcon } from "lucide-react"; import { Suspense } from "react"; import { @@ -19,12 +25,14 @@ export function Header() { const enableRoomsPage = roomsFlag(); const enableSchedulerPage = schedulerFlag(); const enableGraduatePage = graduateFlag(); + const enableNotificationsPage = notificationsFlag(); const flags = { rooms: enableRoomsPage, faq: enableFaqPage, scheduler: enableSchedulerPage, graduate: enableGraduatePage, + notifications: enableNotificationsPage, }; return ( diff --git a/apps/searchneu/components/navigation/NavBar.tsx b/apps/searchneu/components/navigation/NavBar.tsx index 2230576c..1d32ca91 100644 --- a/apps/searchneu/components/navigation/NavBar.tsx +++ b/apps/searchneu/components/navigation/NavBar.tsx @@ -4,8 +4,9 @@ import { CircleQuestionMark, DoorOpen, GraduationCapIcon, + Bell, } from "lucide-react"; -import { type ReactNode, use } from "react"; +import { ReactNode, use } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { FlagValues } from "flags/react"; @@ -22,6 +23,7 @@ export function NavBar({ const faqFlag = use(flags["faq"]); const schedulerFlag = use(flags["scheduler"]); const graduateFlag = use(flags["graduate"]); + const notificationsFlag = use(flags["notifications"]); const pathname = usePathname(); @@ -90,6 +92,18 @@ export function NavBar({ )} + {notificationsFlag && ( + + + + Notifications + + + )} ); } diff --git a/apps/searchneu/components/notifications/NotificationsCourseCard.tsx b/apps/searchneu/components/notifications/NotificationsCourseCard.tsx new file mode 100644 index 00000000..a6c76eb2 --- /dev/null +++ b/apps/searchneu/components/notifications/NotificationsCourseCard.tsx @@ -0,0 +1,82 @@ +"use client"; +import NotificationsSectionCard from "./NotificationsSectionCard"; +import { Trash2 } from "lucide-react"; +import type { SectionTableMeetingTime } from "@/components/catalog/SectionTable"; + +interface Section { + crn: string; + messagesSent: number; + messageLimit: number; + isSubscribed: boolean; + meetingTimes: SectionTableMeetingTime[]; + professor: string; + location: string; + campus: string; + enrollmentSeats: { + current: number; + total: number; + }; + waitlistSeats: { + current: number; + total: number; + }; +} + +interface NotificationsCourseCardProps { + courseName: string; + courseTitle: string; + sections: Section[]; + onToggleSubscription?: (crn: string) => void; +} + +export default function NotificationsCourseCard({ + courseName, + courseTitle, + sections, + onToggleSubscription, +}: NotificationsCourseCardProps) { + const unsubscribedCount = sections.filter((s) => !s.isSubscribed).length; + const totalSections = sections.length; + + return ( +
+
+
+

+ {courseName} +

+

{courseTitle}

+
+
+ + +
+
+
+ {sections.map((section, index) => ( + onToggleSubscription?.(section.crn)} + /> + ))} +
+ + {unsubscribedCount > 0 && ( +

+ {unsubscribedCount}/{totalSections} unsubscribed sections available +

+ )} +
+ ); +} diff --git a/apps/searchneu/components/notifications/NotificationsSectionCard.tsx b/apps/searchneu/components/notifications/NotificationsSectionCard.tsx new file mode 100644 index 00000000..43998426 --- /dev/null +++ b/apps/searchneu/components/notifications/NotificationsSectionCard.tsx @@ -0,0 +1,200 @@ +"use client"; +import { BellIcon } from "@/components/icons/BellIcon"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { TrackingSwitch } from "@/components/auth/TrackingSwitch"; +import { MeetingBlocks } from "@/components/catalog/SectionTableBlocks"; +import type { SectionTableMeetingTime } from "@/components/catalog/SectionTable"; + +interface NotificationsSectionCardProps { + crn: string; + messagesSent: number; + messageLimit: number; + isSubscribed: boolean; + meetingTimes: SectionTableMeetingTime[]; + professor: string; + location: string; + campus: string; + enrollmentSeats: { + current: number; + total: number; + }; + waitlistSeats: { + current: number; + total: number; + }; + onToggleSubscription?: () => void; +} + +export default function NotificationsSectionCard({ + crn, + messagesSent, + messageLimit, + isSubscribed, + meetingTimes, + professor, + location, + campus, + enrollmentSeats, + waitlistSeats, + onToggleSubscription, +}: NotificationsSectionCardProps) { + return ( +
+
+
+ + CRN {crn} + + + +
+ +
+ { + onToggleSubscription?.(); + }} + isTermActive={true} + /> +
+
+
+ +
+ + + + + + {professor} + + + + {location} + + + + {campus} + + + + + +
+
+ ); +} + +function NotificationBells({ + messagesSent, + messageLimit, + isSubscribed, +}: { + messagesSent: number; + messageLimit: number; + isSubscribed: boolean; +}) { + const filledBells = messageLimit - messagesSent; + const emptySlots = messagesSent; + + return ( + + +
+ {Array.from({ length: emptySlots }).map((_, i) => ( +
+ + + +
+ ))} + + {Array.from({ length: filledBells }).map((_, i) => ( + + ))} +
+
+ +

+ {filledBells} notification{filledBells !== 1 ? "s" : ""} remaining +

+
+
+ ); +} + +function InfoSection({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ {label} + {children} +
+ ); +} + +function SeatCounter({ + label, + current, + total, +}: { + label: string; + current: number; + total: number; +}) { + const focused = current > 0; + return ( +
+ + {label} + +
+ + {current} + + / {total} +
+
+ ); +} diff --git a/apps/searchneu/components/notifications/NotificationsSidebar.tsx b/apps/searchneu/components/notifications/NotificationsSidebar.tsx new file mode 100644 index 00000000..1acaaaeb --- /dev/null +++ b/apps/searchneu/components/notifications/NotificationsSidebar.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { Button } from "../ui/button"; +import { Info, Trash2, CircleQuestionMark } from "lucide-react"; +import { SectionPills } from "./SectionPills"; +import { TooltipTrigger, TooltipContent, Tooltip } from "../ui/tooltip"; +import { PastNotificationCard } from "./PastNotificationCard"; +import { deleteAllTrackersAction } from "@/lib/auth/tracking-actions"; +import { useRouter } from "next/navigation"; +import { useTransition } from "react"; +import { NotificationsSidebarProps } from "./NotificationsWrapper"; + +export function NotificationsSidebar({ + subscribedCount, + totalLimit, + termNames, + notifications, +}: NotificationsSidebarProps) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + + const handleUnsubscribeAll = () => { + startTransition(async () => { + await deleteAllTrackersAction(); + router.refresh(); + }); + }; + + const formatNotificationDate = (date: Date) => { + const now = new Date(); + const isToday = date.toDateString() === now.toDateString(); + + if (isToday) { + return { + dateLabel: "Today", + time: date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + }), + isToday: true, + }; + } + + return { + dateLabel: date.toLocaleDateString("en-US"), + time: date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + }), + isToday: false, + }; + }; + + return ( +
+
+

SEMESTER

+

{termNames.join(", ")}

+
+ +
+
+

SUBSCRIBED SECTIONS

+ + + + + +

Subscribed Sections.

+
+
+
+ +

+ {subscribedCount}/{totalLimit} Sections +

+ +
+ +
+ + +
+ +
+
+

PAST NOTIFICATIONS

+ + + + + +

Past Notifications.

+
+
+
+ +
+ {notifications.map((notif) => { + const { dateLabel, time, isToday } = formatNotificationDate( + notif.sentAt, + ); + return ( + + ); + })} +
+
+
+ ); +} diff --git a/apps/searchneu/components/notifications/NotificationsView.tsx b/apps/searchneu/components/notifications/NotificationsView.tsx new file mode 100644 index 00000000..fdf33292 --- /dev/null +++ b/apps/searchneu/components/notifications/NotificationsView.tsx @@ -0,0 +1,155 @@ +"use client"; + +import NotificationsCourseCard from "./NotificationsCourseCard"; +import { TrackerSection } from "@/app/notifications/page"; + +export function NotificationsView({ + sections, +}: { + sections: TrackerSection[]; +}) { + const mockCourses = [ + { + courseName: "CHEM 2311", + courseTitle: "Organic Chemistry 1", + sections: [ + { + crn: "10563", + messagesSent: 0, + messageLimit: 3, + isSubscribed: true, + meetingTimes: [ + { + days: [3], + startTime: 1030, + endTime: 1325, + final: false, + }, + ], + professor: "Full Name Doe", + location: "Shillman Hall", + campus: "Online", + enrollmentSeats: { current: 1, total: 15 }, + waitlistSeats: { current: 1, total: 15 }, + }, + { + crn: "10564", + messagesSent: 2, + messageLimit: 3, + isSubscribed: false, + meetingTimes: [ + { + days: [1, 3, 5], + startTime: 900, + endTime: 1005, + final: false, + }, + ], + professor: "Full Name Doe", + location: "Shillman Hall", + campus: "Online", + enrollmentSeats: { current: 0, total: 15 }, + waitlistSeats: { current: 1, total: 15 }, + }, + { + crn: "10565", + messagesSent: 3, + messageLimit: 3, + isSubscribed: false, + meetingTimes: [ + { + days: [2, 4], + startTime: 1400, + endTime: 1530, + final: false, + }, + ], + professor: "Full Name Doe", + location: "Shillman Hall", + campus: "Online", + enrollmentSeats: { current: 0, total: 15 }, + waitlistSeats: { current: 0, total: 15 }, + }, + { + crn: "10566", + messagesSent: 1, + messageLimit: 3, + isSubscribed: true, + meetingTimes: [ + { + days: [1, 4], + startTime: 1600, + endTime: 1730, + final: false, + }, + ], + professor: "Another Professor", + location: "Richards Hall", + campus: "Boston", + enrollmentSeats: { current: 10, total: 25 }, + waitlistSeats: { current: 0, total: 5 }, + }, + ], + }, + { + courseName: "CS 3500", + courseTitle: "Object-Oriented Design", + sections: [ + { + crn: "20123", + messagesSent: 0, + messageLimit: 3, + isSubscribed: true, + meetingTimes: [ + { + days: [1, 3], + startTime: 1145, + endTime: 1325, + final: false, + }, + ], + professor: "Jane Smith", + location: "Snell Library", + campus: "Boston", + enrollmentSeats: { current: 5, total: 20 }, + waitlistSeats: { current: 2, total: 10 }, + }, + { + crn: "20124", + messagesSent: 2, + messageLimit: 3, + isSubscribed: false, + meetingTimes: [ + { + days: [2, 4], + startTime: 1530, + endTime: 1710, + final: false, + }, + ], + professor: "Jane Smith", + location: "Snell Library", + campus: "Boston", + enrollmentSeats: { current: 3, total: 20 }, + waitlistSeats: { current: 1, total: 10 }, + }, + ], + }, + ]; + console.log("Sections:", sections); + return ( +
+
+ {mockCourses.map((course, index) => ( + + console.log("Toggle subscription", crn) + } + /> + ))} +
+
+ ); +} diff --git a/apps/searchneu/components/notifications/NotificationsWrapper.tsx b/apps/searchneu/components/notifications/NotificationsWrapper.tsx new file mode 100644 index 00000000..cc40cfad --- /dev/null +++ b/apps/searchneu/components/notifications/NotificationsWrapper.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { NotificationsSidebar } from "./NotificationsSidebar"; +import { NotificationsView } from "./NotificationsView"; +import { TrackerSection } from "@/app/notifications/page"; + +export type NotificationsSidebarProps = { + subscribedCount: number; + totalLimit: number; + termNames: string[]; + notifications: Array<{ + id: number; + crn: string; + courseName: string; + courseSubject: string; + courseNumber: string; + sentAt: Date; + }>; +}; + +export type NotificationsProps = NotificationsSidebarProps & { + sections: TrackerSection[]; +}; + +export function NotificationsWrapper({ + subscribedCount, + totalLimit, + termNames, + notifications, + sections, +}: NotificationsProps) { + return ( +
+
+ +
+
+ +
+
+ ); +} diff --git a/apps/searchneu/components/notifications/PastNotificationCard.tsx b/apps/searchneu/components/notifications/PastNotificationCard.tsx new file mode 100644 index 00000000..8e13a06a --- /dev/null +++ b/apps/searchneu/components/notifications/PastNotificationCard.tsx @@ -0,0 +1,39 @@ +import { TooltipTrigger, Tooltip, TooltipContent } from "../ui/tooltip"; + +type PastNotificationCardProps = { + crn: string; + course: string; + dateLabel: string; + time: string; + isToday?: boolean; +}; + +export function PastNotificationCard({ + crn, + course, + dateLabel, + time, + isToday = false, +}: PastNotificationCardProps) { + return ( +
+
+ CRN {crn} + + + + {course} + + + + {course} + + +
+
+ {dateLabel} + {time} +
+
+ ); +} diff --git a/apps/searchneu/components/notifications/SectionPills.tsx b/apps/searchneu/components/notifications/SectionPills.tsx new file mode 100644 index 00000000..16a2de0d --- /dev/null +++ b/apps/searchneu/components/notifications/SectionPills.tsx @@ -0,0 +1,40 @@ +import { cn } from "@/lib/cn"; + +export function SectionPills({ + filled, + total, +}: { + filled: number; + total: number; +}) { + const safeTotal = Math.max(1, total); + const safeFilled = Math.max(0, Math.min(filled, safeTotal)); + + return ( +
+ {Array.from({ length: safeTotal }).map((_, i) => { + const isFilled = i < safeFilled; + const isFirst = i === 0; + const isLast = i === safeTotal - 1; + + const radiusClass = isFirst + ? "rounded-l-full rounded-r-[2px]" + : isLast + ? "rounded-r-full rounded-l-[2px]" + : "rounded-[2px]"; + + return ( +
+ ); + })} +
+ ); +} diff --git a/apps/searchneu/components/trackers/CourseCard.tsx b/apps/searchneu/components/trackers/CourseCard.tsx deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/searchneu/lib/auth/tracking-actions.ts b/apps/searchneu/lib/auth/tracking-actions.ts index 8fa2e6d8..00f1daa8 100644 --- a/apps/searchneu/lib/auth/tracking-actions.ts +++ b/apps/searchneu/lib/auth/tracking-actions.ts @@ -63,3 +63,24 @@ export async function deleteTrackerAction(id: number) { return { ok: true }; } + +export async function deleteAllTrackersAction() { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return { ok: false, msg: "no valid session" }; + } + + await db + .update(trackersT) + .set({ + deletedAt: new Date(), + }) + .where( + and(eq(trackersT.userId, session.user.id), isNull(trackersT.deletedAt)), + ); + + return { ok: true }; +} diff --git a/apps/searchneu/lib/controllers/getTrackers.ts b/apps/searchneu/lib/controllers/getTrackers.ts new file mode 100644 index 00000000..997deb9b --- /dev/null +++ b/apps/searchneu/lib/controllers/getTrackers.ts @@ -0,0 +1,91 @@ +import { SectionTableRoom } from "@/components/catalog/SectionTable"; +import { eq, inArray } from "drizzle-orm"; +import { cache } from "react"; +import { + db, + sectionsT, + campusesT, + meetingTimesT, + roomsT, + buildingsT, +} from "../db"; +import { TrackerSection } from "@/app/notifications/page"; + +export const getSectionInfo = cache(async (sectionIds: number[]) => { + if (sectionIds.length === 0) return []; + + const rows = await db + .select({ + id: sectionsT.id, + crn: sectionsT.crn, + faculty: sectionsT.faculty, + campus: campusesT.name, + seatRemaining: sectionsT.seatRemaining, + seatCapacity: sectionsT.seatCapacity, + waitlistCapacity: sectionsT.waitlistCapacity, + waitlistRemaining: sectionsT.waitlistRemaining, + // meeting time info + meetingTimeId: meetingTimesT.id, + days: meetingTimesT.days, + startTime: meetingTimesT.startTime, + endTime: meetingTimesT.endTime, + // room info + roomId: roomsT.id, + roomNumber: roomsT.code, + // building info + buildingId: buildingsT.id, + buildingName: buildingsT.name, + }) + .from(sectionsT) + .leftJoin(meetingTimesT, eq(sectionsT.id, meetingTimesT.sectionId)) + .leftJoin(roomsT, eq(meetingTimesT.roomId, roomsT.id)) + .leftJoin(buildingsT, eq(roomsT.buildingId, buildingsT.id)) + .innerJoin(campusesT, eq(sectionsT.campus, campusesT.id)) + .where(inArray(sectionsT.id, sectionIds)); + + const sectionMap = new Map(); + + for (const row of rows) { + let section = sectionMap.get(row.id); + + if (!section) { + section = { + id: row.id, + crn: row.crn, + faculty: row.faculty, + campus: row.campus, + seatRemaining: row.seatRemaining, + seatCapacity: row.seatCapacity, + waitlistCapacity: row.waitlistCapacity, + waitlistRemaining: row.waitlistRemaining, + meetingTimes: [], + }; + sectionMap.set(row.id, section); + } + + if (row.meetingTimeId != null) { + const room: SectionTableRoom | undefined = + row.roomId != null && row.roomNumber != null + ? { + id: row.roomId, + number: row.roomNumber, + building: + row.buildingId != null && row.buildingName != null + ? { id: row.buildingId, name: row.buildingName } + : undefined, + } + : undefined; + + section.meetingTimes.push({ + days: row.days!, + startTime: row.startTime!, + endTime: row.endTime!, + final: false, + room, + finalDate: undefined, + }); + } + } + + return Array.from(sectionMap.values()); +}); diff --git a/apps/searchneu/lib/flags.ts b/apps/searchneu/lib/flags.ts index da29df05..8d4b32f3 100644 --- a/apps/searchneu/lib/flags.ts +++ b/apps/searchneu/lib/flags.ts @@ -31,3 +31,11 @@ export const graduateFlag = flag({ return false; }, }); + +export const notificationsFlag = flag({ + key: "notifications", + description: "Enable notifications page", + decide() { + return false; + }, +});