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;
+ },
+});