From 4faebc0d880731b8133ad53d95c644976618f14c Mon Sep 17 00:00:00 2001 From: Afifah Hadi Date: Wed, 4 Mar 2026 01:15:33 -0800 Subject: [PATCH 1/6] put may 9 and may 10 events on same page but different sections, changed nav button behavior --- app/(pages)/(hackers)/(hub)/schedule/page.tsx | 226 +++++++++++++----- .../Schedule/ScheduleMobileControls.tsx | 11 +- 2 files changed, 168 insertions(+), 69 deletions(-) diff --git a/app/(pages)/(hackers)/(hub)/schedule/page.tsx b/app/(pages)/(hackers)/(hub)/schedule/page.tsx index fe5de9b1..b3c6a740 100644 --- a/app/(pages)/(hackers)/(hub)/schedule/page.tsx +++ b/app/(pages)/(hackers)/(hub)/schedule/page.tsx @@ -1,5 +1,5 @@ 'use client'; -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useRef } from 'react'; import CalendarItem from '../../_components/Schedule/CalendarItem'; import Footer from '@components/Footer/Footer'; import Image from 'next/image'; @@ -50,10 +50,16 @@ export default function Page() { const [activeFilters, setActiveFilters] = useState(['ALL']); const [isMobileFilterOpen, setIsMobileFilterOpen] = useState(false); const [scheduleData, setScheduleData] = useState(null); + const ignoreScrollSyncUntilRef = useRef(0); + const pendingDayRef = useRef<'9' | '10' | null>(null); const changeActiveDay = (day: '9' | '10') => { setActiveDay(day); - window.scrollTo({ top: 0, behavior: 'smooth' }); + pendingDayRef.current = day; + ignoreScrollSyncUntilRef.current = Date.now() + 2000; + document + .getElementById(`day-${day}`) + ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }; const { @@ -212,13 +218,13 @@ export default function Page() { const dataToUse = activeTab === 'personal' ? personalScheduleData : scheduleData; - // Update the filtering logic to handle recommended events correctly - const sortedGroupedEntries = useMemo(() => { - if (!dataToUse) return []; - + const getGroupedEntriesForDay = ( + dayKey: '9' | '10', + dataToUse: ScheduleData | null, + activeFilters: ScheduleFilter[] + ): [string, EventDetails[]][] => { // Filter events for the active day - const eventsForDay = dataToUse[activeDay] || []; - + const eventsForDay = dataToUse?.[dayKey] ?? []; // Apply filter logic let filteredEvents = eventsForDay; @@ -241,6 +247,7 @@ export default function Page() { if (filteredEvents.length === 0) return []; // Sort the filtered events by start time. + // TODO: UPDATE THIS WITH MY CODE FROM MY OTHER TICKET const sortedEvents = [...filteredEvents].sort( (a, b) => new Date(a.event.start_time).getTime() - @@ -276,7 +283,62 @@ export default function Page() { const dateB = new Date(`${dummyDay} ${b[0]}`); return dateA.getTime() - dateB.getTime(); }); - }, [dataToUse, activeDay, activeFilters]); + }; + + // useMemo cache the result of an expensive calculation between re-renders, + // so it only runs when its dependencies change + const groupedEntriesByDay = useMemo(() => { + return { + '9': getGroupedEntriesForDay('9', dataToUse, activeFilters), + '10': getGroupedEntriesForDay('10', dataToUse, activeFilters), + }; + }, [dataToUse, activeFilters]); + + useEffect(() => { + const updateActiveDayFromScroll = () => { + if (Date.now() < ignoreScrollSyncUntilRef.current) { + if (pendingDayRef.current) { + setActiveDay(pendingDayRef.current); + } + return; + } + + pendingDayRef.current = null; + + const daySections = (['9', '10'] as const) + .map((day) => { + const section = document.getElementById(`day-${day}`); + return section + ? { day, rect: section.getBoundingClientRect() } + : null; + }) + .filter( + (section): section is { day: '9' | '10'; rect: DOMRect } => + section !== null + ); + + if (daySections.length === 0) return; + + // Flip active day when a section title reaches ~45% down viewport. + const anchor = window.innerHeight * 0.45; + let nextActiveDay: '9' | '10' = daySections[0].day; + for (const section of daySections) { + if (section.rect.top <= anchor) { + nextActiveDay = section.day; + } + } + + setActiveDay(nextActiveDay); + }; + + updateActiveDayFromScroll(); + window.addEventListener('scroll', updateActiveDayFromScroll, { + passive: true, + }); + return () => { + window.removeEventListener('scroll', updateActiveDayFromScroll); + }; + }, [activeTab, activeFilters, groupedEntriesByDay]); const toggleFilter = (label: ScheduleFilter) => { if (label === 'ALL') { @@ -311,15 +373,18 @@ export default function Page() { ); return ( -
-
+
+
header-grass
-
+
@@ -391,89 +456,120 @@ export default function Page() {
-
+
{isInitialLoad ? (

loading...

- ) : sortedGroupedEntries.length > 0 ? ( - sortedGroupedEntries.map(([timeKey, events]) => ( -
-
- {timeKey} -
-
- {events.map((eventDetail) => ( - - handleAddToSchedule(eventDetail.event._id || '') - } - onRemoveFromSchedule={() => - handleRemoveFromSchedule(eventDetail.event._id || '') - } - /> - ))} -
-
- )) ) : ( - isInitialLoad && ( -
- {activeTab === 'personal' ? ( -
-

- No events in your personal schedule yet. -

- + (['9', '10'] as const).map((dayKey) => { + const dayEntries = groupedEntriesByDay[dayKey]; + const dayTitle = dayKey === '9' ? 'May 9' : 'May 10'; + + return ( +
+
+ {dayTitle}
- ) : ( - 'No events found for this day and filter(s).' - )} -
- ) +
+ + {dayEntries.length > 0 ? ( + dayEntries.map(([timeKey, events]) => ( +
+
+ {timeKey} +
+
+ {events.map((eventDetail) => ( + + handleAddToSchedule(eventDetail.event._id || '') + } + onRemoveFromSchedule={() => + handleRemoveFromSchedule( + eventDetail.event._id || '' + ) + } + /> + ))} +
+
+ )) + ) : ( +
+ {activeTab === 'personal' ? ( +
+

+ No events in your personal schedule yet. +

+ +
+ ) : ( + 'No events found for this day and filter(s).' + )} +
+ )} + + ); + }) )}
diff --git a/app/(pages)/(hackers)/_components/Schedule/ScheduleMobileControls.tsx b/app/(pages)/(hackers)/_components/Schedule/ScheduleMobileControls.tsx index 33f27240..b8a2ce9e 100644 --- a/app/(pages)/(hackers)/_components/Schedule/ScheduleMobileControls.tsx +++ b/app/(pages)/(hackers)/_components/Schedule/ScheduleMobileControls.tsx @@ -45,16 +45,19 @@ export default function ScheduleMobileControls({ ); @@ -113,8 +116,8 @@ export default function ScheduleMobileControls({ {!isMobileFilterOpen && (
- {renderDayButton('9', 'MAY 9')} - {renderDayButton('10', 'MAY 10')} + {renderDayButton('9', 'May 9')} + {renderDayButton('10', 'May 10')}
)}
From 05d8e468cce1125d92a106f38c17c75e069e7b39 Mon Sep 17 00:00:00 2001 From: reehals Date: Wed, 25 Feb 2026 18:50:29 -0800 Subject: [PATCH 2/6] Add my code for home page schedule fetching --- .../HomeHacking/ScheduleSneakPeek.tsx | 70 ++++++++++++++++--- .../_hooks/useScheduleSneakPeekData.ts | 58 +++++++++------ 2 files changed, 98 insertions(+), 30 deletions(-) diff --git a/app/(pages)/(hackers)/_components/HomeHacking/ScheduleSneakPeek.tsx b/app/(pages)/(hackers)/_components/HomeHacking/ScheduleSneakPeek.tsx index 8e1a571f..5223d0d1 100644 --- a/app/(pages)/(hackers)/_components/HomeHacking/ScheduleSneakPeek.tsx +++ b/app/(pages)/(hackers)/_components/HomeHacking/ScheduleSneakPeek.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useState, useEffect, useMemo } from 'react'; import Link from 'next/link'; import Image from 'next/image'; import CalendarItem from '@pages/(hackers)/_components/Schedule/CalendarItem'; @@ -32,6 +33,43 @@ function SectionLabel({ label }: { label: string }) { ); } +function CountdownLabel({ targetTime }: { targetTime: number }) { + const [timeLeft, setTimeLeft] = useState({ + hours: 0, + minutes: 0, + seconds: 0, + }); + + useEffect(() => { + const calculateTimeLeft = () => { + const difference = targetTime - new Date().getTime(); + if (difference <= 0) { + return { hours: 0, minutes: 0, seconds: 0 }; + } + return { + hours: Math.floor(difference / (1000 * 60 * 60)), + minutes: Math.floor((difference / (1000 * 60)) % 60), + seconds: Math.floor((difference / 1000) % 60), + }; + }; + + setTimeLeft(calculateTimeLeft()); + const timer = setInterval(() => { + setTimeLeft(calculateTimeLeft()); + }, 1000); + + return () => clearInterval(timer); + }, [targetTime]); + + const label = `IN ${timeLeft.hours + .toString() + .padStart(2, '0')}:${timeLeft.minutes + .toString() + .padStart(2, '0')}:${timeLeft.seconds.toString().padStart(2, '0')}`; + + return ; +} + function Panel({ title, liveEvents, @@ -63,6 +101,20 @@ function Panel({ /> )); + const upcomingGroups = useMemo(() => { + const groups: { startTime: number; entries: EventEntry[] }[] = []; + for (const entry of upcomingEvents) { + const startTime = new Date(entry.event.start_time).getTime(); + const existing = groups.find((g) => g.startTime === startTime); + if (existing) { + existing.entries.push(entry); + } else { + groups.push({ startTime, entries: [entry] }); + } + } + return groups.sort((a, b) => a.startTime - b.startTime); + }, [upcomingEvents]); + return (

@@ -81,16 +133,14 @@ function Panel({ )}

- -
- {upcomingEvents.length > 0 ? ( - renderEventItems(upcomingEvents, 'upcoming') - ) : ( -

- No events starting in the next 30 minutes. -

- )} -
+ {upcomingGroups.map((group) => ( +
+ +
+ {renderEventItems(group.entries, `upcoming-${group.startTime}`)} +
+
+ ))}
); } diff --git a/app/(pages)/_hooks/useScheduleSneakPeekData.ts b/app/(pages)/_hooks/useScheduleSneakPeekData.ts index 812fd457..0c36e7ae 100644 --- a/app/(pages)/_hooks/useScheduleSneakPeekData.ts +++ b/app/(pages)/_hooks/useScheduleSneakPeekData.ts @@ -5,10 +5,7 @@ import Event from '@typeDefs/event'; import { useEvents } from '@hooks/useEvents'; import { usePersonalEvents } from '@hooks/usePersonalEvents'; import useActiveUser from '@pages/_hooks/useActiveUser'; -import { - isScheduleEventLive, - startsScheduleEventInNextMs, -} from '@pages/(hackers)/_components/Schedule/scheduleTime'; +import { isScheduleEventLive } from '@pages/(hackers)/_components/Schedule/scheduleTime'; export interface EventEntry { event: Event; @@ -16,8 +13,6 @@ export interface EventEntry { inPersonalSchedule: boolean; } -const THIRTY_MIN_MS = 30 * 60 * 1000; - const toSorted = (events: EventEntry[]): EventEntry[] => [...events].sort( (a, b) => @@ -25,6 +20,20 @@ const toSorted = (events: EventEntry[]): EventEntry[] => new Date(b.event.start_time).getTime() ); +/** Returns only the events starting at the single nearest future start time. */ +const getNextBatchEvents = (entries: EventEntry[], now: Date): EventEntry[] => { + const future = entries.filter( + (e) => new Date(e.event.start_time).getTime() > now.getTime() + ); + if (future.length === 0) return []; + const earliest = Math.min( + ...future.map((e) => new Date(e.event.start_time).getTime()) + ); + return future.filter( + (e) => new Date(e.event.start_time).getTime() === earliest + ); +}; + export function useScheduleSneakPeekData() { const { user } = useActiveUser('/'); const { eventData } = useEvents(user); @@ -63,29 +72,38 @@ export function useScheduleSneakPeekData() { return () => clearInterval(interval); }, []); - const filteredLists = useMemo( - () => ({ + const filteredLists = useMemo(() => { + // GENERAL (and MEALS) events have no add button and must never appear in + // Your Schedule. Filter them out so they always stay in Happening Now. + const schedulablePersonalEntries = personalEventEntries.filter( + (e) => e.event.type !== 'GENERAL' && e.event.type !== 'MEALS' + ); + + // Only exclude events that will actually appear in Your Schedule. + const scheduledIds = new Set( + schedulablePersonalEntries.map((e) => e.event._id) + ); + const happeningNowEntries = allEventEntries.filter( + (e) => !scheduledIds.has(e.event._id) + ); + + return { liveAll: toSorted( - allEventEntries.filter((entry) => isScheduleEventLive(entry.event, now)) - ), - upcomingAll: toSorted( - allEventEntries.filter((entry) => - startsScheduleEventInNextMs(entry.event, THIRTY_MIN_MS, now) + happeningNowEntries.filter((entry) => + isScheduleEventLive(entry.event, now) ) ), + upcomingAll: toSorted(getNextBatchEvents(happeningNowEntries, now)), livePersonal: toSorted( - personalEventEntries.filter((entry) => + schedulablePersonalEntries.filter((entry) => isScheduleEventLive(entry.event, now) ) ), upcomingPersonal: toSorted( - personalEventEntries.filter((entry) => - startsScheduleEventInNextMs(entry.event, THIRTY_MIN_MS, now) - ) + getNextBatchEvents(schedulablePersonalEntries, now) ), - }), - [allEventEntries, personalEventEntries, now] - ); + }; + }, [allEventEntries, personalEventEntries, now]); return { ...filteredLists, From 3f079d96e95e427c51adfc43a36da388a5cb66bc Mon Sep 17 00:00:00 2001 From: reehals Date: Wed, 25 Feb 2026 18:56:39 -0800 Subject: [PATCH 3/6] The first comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The actual fixes I forgot to commit 💀 --- .../(hackers)/_components/2025IndexHero/NextSchedule.tsx | 4 +++- app/(pages)/_hooks/useNextSchedule.ts | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/(pages)/(hackers)/_components/2025IndexHero/NextSchedule.tsx b/app/(pages)/(hackers)/_components/2025IndexHero/NextSchedule.tsx index 6489d42f..87da431d 100644 --- a/app/(pages)/(hackers)/_components/2025IndexHero/NextSchedule.tsx +++ b/app/(pages)/(hackers)/_components/2025IndexHero/NextSchedule.tsx @@ -8,6 +8,7 @@ import { useEvents } from '@hooks/useEvents'; import CalendarItem from '../Schedule/CalendarItem'; import Event from '@typeDefs/event'; import TimeTracker from './TimeTracker'; +import { getScheduleEventEndTime } from '../Schedule/scheduleTime'; import star_icon from '@public/hackers/hero/star.svg'; import styles from './NextSchedule.module.scss'; @@ -47,8 +48,9 @@ export default function NextSchedule() { personalEvents.length > 0 ) { const now = new Date(); + // Include events that haven't ended yet (covers both currently happening and future events) const upcomingEvents = personalEvents.filter( - (event) => new Date(event.start_time) > now + (event) => getScheduleEventEndTime(event).getTime() > now.getTime() ); if (upcomingEvents.length > 0) { diff --git a/app/(pages)/_hooks/useNextSchedule.ts b/app/(pages)/_hooks/useNextSchedule.ts index 22688d13..d1101065 100644 --- a/app/(pages)/_hooks/useNextSchedule.ts +++ b/app/(pages)/_hooks/useNextSchedule.ts @@ -5,6 +5,7 @@ import { usePersonalEvents } from '@hooks/usePersonalEvents'; import Event from '@typeDefs/event'; import useActiveUser from '@pages/_hooks/useActiveUser'; import { useEvents } from '@hooks/useEvents'; +import { getScheduleEventEndTime } from '@pages/(hackers)/_components/Schedule/scheduleTime'; export interface NextEventData { event: Event | null; @@ -45,9 +46,9 @@ export function useNextSchedule() { ) { const now = new Date(); - // Find the next upcoming event (the one with the closest start time in the future) + // Include events that haven't ended yet (covers both currently happening and future events) const upcomingEvents = personalEvents.filter( - (event) => new Date(event.start_time) > now + (event) => getScheduleEventEndTime(event).getTime() > now.getTime() ); if (upcomingEvents.length > 0) { From 3e534aa09edbb7dc0b19ebd425906daf2f936cd0 Mon Sep 17 00:00:00 2001 From: Afifah Hadi Date: Tue, 3 Mar 2026 22:54:56 -0800 Subject: [PATCH 4/6] fixed second sub issue --- app/(pages)/(hackers)/(hub)/schedule/page.tsx | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/app/(pages)/(hackers)/(hub)/schedule/page.tsx b/app/(pages)/(hackers)/(hub)/schedule/page.tsx index b3c6a740..e63e4f28 100644 --- a/app/(pages)/(hackers)/(hub)/schedule/page.tsx +++ b/app/(pages)/(hackers)/(hub)/schedule/page.tsx @@ -247,12 +247,36 @@ export default function Page() { if (filteredEvents.length === 0) return []; // Sort the filtered events by start time. - // TODO: UPDATE THIS WITH MY CODE FROM MY OTHER TICKET - const sortedEvents = [...filteredEvents].sort( - (a, b) => - new Date(a.event.start_time).getTime() - - new Date(b.event.start_time).getTime() - ); + const sortedEvents = [...filteredEvents].sort((a, b) => { + const startA = new Date(a.event.start_time).getTime(); + const startB = new Date(b.event.start_time).getTime(); + + // compare start times + if (startA != startB) { + return startA - startB; + } + + // if events have same start time, get end times + const endA = a.event.end_time + ? new Date(a.event.end_time).getTime() + : null; + const endB = b.event.end_time + ? new Date(b.event.end_time).getTime() + : null; + + // if one event doesn't have an end time, that one should go first + if (endA == null && endB != null) return -1; + if (endA != null && endB == null) return 1; + + // both have end times, the event ending first goes first + if (endA != null && endB != null && endA != endB) { + return endA - endB; + } + + // else if neither events have end times, or end times are equal, + // then those two events are equal and can be scheduled either way + return 0; + }); // Group events by their start time (converted to PDT). const groups = sortedEvents.reduce( From e0b7940b4503597582714eb6a4fabe7826457598 Mon Sep 17 00:00:00 2001 From: Afifah Hadi Date: Wed, 4 Mar 2026 15:18:55 -0800 Subject: [PATCH 5/6] refactored page.tsx and split into reusable components --- app/(pages)/(hackers)/(hub)/schedule/page.tsx | 541 ++---------------- .../_components/Schedule/DayNavButtons.tsx | 37 ++ .../_components/Schedule/DaySection.tsx | 87 +++ .../_components/Schedule/ScheduleControls.tsx | 151 +++++ .../Schedule/ScheduleMobileControls.tsx | 153 ----- .../_components/Schedule/constants.ts | 8 + .../_components/Schedule/groupedEntries.ts | 89 +++ .../Schedule/hooks/useActiveDaySync.ts | 105 ++++ .../Schedule/hooks/useScheduleData.ts | 242 ++++++++ .../(hackers)/_components/Schedule/types.ts | 14 + app/(pages)/_globals/globals.scss | 3 + 11 files changed, 769 insertions(+), 661 deletions(-) create mode 100644 app/(pages)/(hackers)/_components/Schedule/DayNavButtons.tsx create mode 100644 app/(pages)/(hackers)/_components/Schedule/DaySection.tsx create mode 100644 app/(pages)/(hackers)/_components/Schedule/ScheduleControls.tsx delete mode 100644 app/(pages)/(hackers)/_components/Schedule/ScheduleMobileControls.tsx create mode 100644 app/(pages)/(hackers)/_components/Schedule/constants.ts create mode 100644 app/(pages)/(hackers)/_components/Schedule/groupedEntries.ts create mode 100644 app/(pages)/(hackers)/_components/Schedule/hooks/useActiveDaySync.ts create mode 100644 app/(pages)/(hackers)/_components/Schedule/hooks/useScheduleData.ts create mode 100644 app/(pages)/(hackers)/_components/Schedule/types.ts diff --git a/app/(pages)/(hackers)/(hub)/schedule/page.tsx b/app/(pages)/(hackers)/(hub)/schedule/page.tsx index e63e4f28..0d84ff8d 100644 --- a/app/(pages)/(hackers)/(hub)/schedule/page.tsx +++ b/app/(pages)/(hackers)/(hub)/schedule/page.tsx @@ -1,15 +1,9 @@ 'use client'; -import { useState, useEffect, useMemo, useRef } from 'react'; -import CalendarItem from '../../_components/Schedule/CalendarItem'; + import Footer from '@components/Footer/Footer'; import Image from 'next/image'; import headerGrass from '@public/hackers/schedule/header_grass.svg'; -import Event from '@typeDefs/event'; -import { ScheduleFilter } from '@typeDefs/filters'; -import { Button } from '@pages/_globals/components/ui/button'; -import Filters from '@pages/(hackers)/_components/Schedule/Filters'; -import ScheduleMobileControls from '@pages/(hackers)/_components/Schedule/ScheduleMobileControls'; - +import ScheduleControls from '@pages/(hackers)/_components/Schedule/ScheduleControls'; import { Tooltip, TooltipContent, @@ -17,376 +11,14 @@ import { TooltipTrigger, } from '@globals/components/ui/tooltip'; import TooltipCow from '@public/index/schedule/vocal_angel_cow.svg'; -import useActiveUser from '@pages/_hooks/useActiveUser'; -import { usePersonalEvents } from '@hooks/usePersonalEvents'; -import { useEvents } from '@hooks/useEvents'; - -export interface EventDetails { - event: Event; - attendeeCount?: number; - inPersonalSchedule?: boolean; - isRecommended?: boolean; -} - -interface ScheduleData { - [dayKey: string]: EventDetails[]; -} +import DaySection from '@pages/(hackers)/_components/Schedule/DaySection'; +import { DAY_KEYS } from '@pages/(hackers)/_components/Schedule/constants'; +import { useScheduleData } from '@pages/(hackers)/_components/Schedule/hooks/useScheduleData'; export default function Page() { - const { user, loading: userLoading } = useActiveUser('/'); - - // Pass the user to useEvents - const { - eventData, - isLoading: eventsLoading, - error: eventsError, - refreshEvents, - } = useEvents(user); - - const [activeTab, setActiveTab] = useState<'schedule' | 'personal'>( - 'schedule' - ); - const [activeDay, setActiveDay] = useState<'9' | '10'>('9'); - const [activeFilters, setActiveFilters] = useState(['ALL']); - const [isMobileFilterOpen, setIsMobileFilterOpen] = useState(false); - const [scheduleData, setScheduleData] = useState(null); - const ignoreScrollSyncUntilRef = useRef(0); - const pendingDayRef = useRef<'9' | '10' | null>(null); - - const changeActiveDay = (day: '9' | '10') => { - setActiveDay(day); - pendingDayRef.current = day; - ignoreScrollSyncUntilRef.current = Date.now() + 2000; - document - .getElementById(`day-${day}`) - ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }; - - const { - personalEvents, - isLoading: personalEventsLoading, - error: personalEventsError, - addToPersonalSchedule, - removeFromPersonalSchedule, - isInPersonalSchedule, - refreshPersonalEvents, - } = usePersonalEvents(user?._id || ''); - - // Function to handle adding to personal schedule with loading state - const handleAddToSchedule = async (eventId: string) => { - const success = await addToPersonalSchedule(eventId); - - if (success) { - // If successful, update both tabs - await refreshPersonalEvents(); - await refreshEvents(); - - // Also update the main schedule data if we're on the schedule tab - if (activeTab === 'schedule') { - // Update the schedule data to reflect the change - if (scheduleData) { - const newScheduleData = { ...scheduleData }; - - // Mark the event as in personal schedule - Object.keys(newScheduleData).forEach((dayKey) => { - newScheduleData[dayKey] = newScheduleData[dayKey].map((item) => { - if (item.event._id === eventId) { - return { ...item, inPersonalSchedule: true }; - } - return item; - }); - }); - - setScheduleData(newScheduleData); - } - } - } - }; - - // Function to handle removing from personal schedule with loading state - const handleRemoveFromSchedule = async (eventId: string) => { - const success = await removeFromPersonalSchedule(eventId); - - if (success) { - // If successful, update both tabs - await refreshPersonalEvents(); - await refreshEvents(); - - // Also update the main schedule data if we're on the schedule tab - if (activeTab === 'schedule') { - // Update the schedule data to reflect the change - if (scheduleData) { - const newScheduleData = { ...scheduleData }; - - // Mark the event as not in personal schedule - Object.keys(newScheduleData).forEach((dayKey) => { - newScheduleData[dayKey] = newScheduleData[dayKey].map((item) => { - if (item.event._id === eventId) { - return { ...item, inPersonalSchedule: false }; - } - return item; - }); - }); - - setScheduleData(newScheduleData); - } - } - } - }; - - // Force refresh events when user data changes - useEffect(() => { - if (user && !userLoading) { - refreshEvents(); - } - }, [user, userLoading, refreshEvents]); - - // Update the existing useEffect - simplify to just set the schedule data without virtual events - useEffect(() => { - if (!eventsLoading && !personalEventsLoading) { - // Group events by day key - "09" or "10". - const groupedByDay = eventData.reduce( - (acc: ScheduleData, eventWithCount) => { - const event = eventWithCount.event; - const dayKey = event.start_time.toLocaleString('en-US', { - timeZone: 'America/Los_Angeles', - day: 'numeric', - }); - if (!acc[dayKey]) { - acc[dayKey] = []; - } - - // Check if this event is in the user's personal schedule - const isPersonal = isInPersonalSchedule(event._id || ''); - - acc[dayKey].push({ - event, - attendeeCount: eventWithCount.attendeeCount, - inPersonalSchedule: isPersonal, - isRecommended: eventWithCount.isRecommended, - }); - return acc; - }, - {} - ); - - setScheduleData(groupedByDay); - } - }, [ - eventData, - personalEvents, - isInPersonalSchedule, - personalEventsLoading, - eventsLoading, - ]); - - useEffect(() => { - if (activeTab === 'personal') { - refreshPersonalEvents(); - } - }, [activeTab, refreshPersonalEvents]); - - // Format personal events data - const personalScheduleData = useMemo(() => { - // Return empty object instead of null to avoid loading state - if (!personalEvents?.length) return {}; - - const groupedByDay = personalEvents.reduce((acc: ScheduleData, event) => { - const dayKey = event.start_time.toLocaleString('en-US', { - timeZone: 'America/Los_Angeles', - day: 'numeric', - }); - if (!acc[dayKey]) { - acc[dayKey] = []; - } - - // Find the attendee count for this event from eventData - const eventWithCount = eventData.find((e) => e.event._id === event._id); - - acc[dayKey].push({ - event, - attendeeCount: eventWithCount?.attendeeCount || 0, - inPersonalSchedule: true, - isRecommended: eventWithCount?.isRecommended || false, - }); - return acc; - }, {}); - - return groupedByDay; - }, [personalEvents, eventData]); - - const dataToUse = - activeTab === 'personal' ? personalScheduleData : scheduleData; - - const getGroupedEntriesForDay = ( - dayKey: '9' | '10', - dataToUse: ScheduleData | null, - activeFilters: ScheduleFilter[] - ): [string, EventDetails[]][] => { - // Filter events for the active day - const eventsForDay = dataToUse?.[dayKey] ?? []; - // Apply filter logic - let filteredEvents = eventsForDay; - - if (activeFilters.length > 0 && !activeFilters.includes('ALL')) { - filteredEvents = eventsForDay.filter((eventDetail) => { - // Special handling for RECOMMENDED filter - if (activeFilters.includes('RECOMMENDED')) { - // If user wants recommended events and this one is recommended, include it - if (eventDetail.isRecommended) { - return true; - } - } - - // Regular type filtering for other filters - return activeFilters.includes(eventDetail.event.type); - }); - } - - // If no events found after filtering, return empty array - if (filteredEvents.length === 0) return []; + const schedule = useScheduleData(); - // Sort the filtered events by start time. - const sortedEvents = [...filteredEvents].sort((a, b) => { - const startA = new Date(a.event.start_time).getTime(); - const startB = new Date(b.event.start_time).getTime(); - - // compare start times - if (startA != startB) { - return startA - startB; - } - - // if events have same start time, get end times - const endA = a.event.end_time - ? new Date(a.event.end_time).getTime() - : null; - const endB = b.event.end_time - ? new Date(b.event.end_time).getTime() - : null; - - // if one event doesn't have an end time, that one should go first - if (endA == null && endB != null) return -1; - if (endA != null && endB == null) return 1; - - // both have end times, the event ending first goes first - if (endA != null && endB != null && endA != endB) { - return endA - endB; - } - - // else if neither events have end times, or end times are equal, - // then those two events are equal and can be scheduled either way - return 0; - }); - - // Group events by their start time (converted to PDT). - const groups = sortedEvents.reduce( - (acc: { [key: string]: EventDetails[] }, ed) => { - const pstDate = new Date( - ed.event.start_time.toLocaleString('en-US', { - timeZone: 'America/Los_Angeles', - }) - ); - const timeKey = pstDate.toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - hour12: true, - }); - if (!acc[timeKey]) { - acc[timeKey] = []; - } - acc[timeKey].push(ed); - return acc; - }, - {} - ); - - // Sort the grouped entries by time. - return Object.entries(groups).sort((a, b) => { - const dummyDay = '01/01/2000'; - const dateA = new Date(`${dummyDay} ${a[0]}`); - const dateB = new Date(`${dummyDay} ${b[0]}`); - return dateA.getTime() - dateB.getTime(); - }); - }; - - // useMemo cache the result of an expensive calculation between re-renders, - // so it only runs when its dependencies change - const groupedEntriesByDay = useMemo(() => { - return { - '9': getGroupedEntriesForDay('9', dataToUse, activeFilters), - '10': getGroupedEntriesForDay('10', dataToUse, activeFilters), - }; - }, [dataToUse, activeFilters]); - - useEffect(() => { - const updateActiveDayFromScroll = () => { - if (Date.now() < ignoreScrollSyncUntilRef.current) { - if (pendingDayRef.current) { - setActiveDay(pendingDayRef.current); - } - return; - } - - pendingDayRef.current = null; - - const daySections = (['9', '10'] as const) - .map((day) => { - const section = document.getElementById(`day-${day}`); - return section - ? { day, rect: section.getBoundingClientRect() } - : null; - }) - .filter( - (section): section is { day: '9' | '10'; rect: DOMRect } => - section !== null - ); - - if (daySections.length === 0) return; - - // Flip active day when a section title reaches ~45% down viewport. - const anchor = window.innerHeight * 0.45; - let nextActiveDay: '9' | '10' = daySections[0].day; - for (const section of daySections) { - if (section.rect.top <= anchor) { - nextActiveDay = section.day; - } - } - - setActiveDay(nextActiveDay); - }; - - updateActiveDayFromScroll(); - window.addEventListener('scroll', updateActiveDayFromScroll, { - passive: true, - }); - return () => { - window.removeEventListener('scroll', updateActiveDayFromScroll); - }; - }, [activeTab, activeFilters, groupedEntriesByDay]); - - const toggleFilter = (label: ScheduleFilter) => { - if (label === 'ALL') { - setActiveFilters(['ALL']); - return; - } - - const withoutAll = activeFilters.filter((id) => id !== 'ALL'); - - if (withoutAll.includes(label)) { - const nextFilters = withoutAll.filter((id) => id !== label); - setActiveFilters(nextFilters.length > 0 ? nextFilters : ['ALL']); - return; - } - - setActiveFilters([...withoutAll, label]); - }; - - // Loading state only when initially loading data, not when performing add/remove actions (requested by design) - const isInitialLoad = userLoading; - - const isError = personalEventsError || eventsError; - - if (isError) + if (schedule.isError) return (
-
+ +
- -
-
- -
- -
- - -
-
-
- {isInitialLoad ? ( + {schedule.isInitialLoad ? (

loading...

) : ( - (['9', '10'] as const).map((dayKey) => { - const dayEntries = groupedEntriesByDay[dayKey]; - const dayTitle = dayKey === '9' ? 'May 9' : 'May 10'; - - return ( -
-
- {dayTitle} -
-
- - {dayEntries.length > 0 ? ( - dayEntries.map(([timeKey, events]) => ( -
-
- {timeKey} -
-
- {events.map((eventDetail) => ( - - handleAddToSchedule(eventDetail.event._id || '') - } - onRemoveFromSchedule={() => - handleRemoveFromSchedule( - eventDetail.event._id || '' - ) - } - /> - ))} -
-
- )) - ) : ( -
- {activeTab === 'personal' ? ( -
-

- No events in your personal schedule yet. -

- -
- ) : ( - 'No events found for this day and filter(s).' - )} -
- )} -
- ); - }) + DAY_KEYS.map((dayKey) => ( + schedule.setActiveTab('schedule')} + onAddToSchedule={schedule.handleAddToSchedule} + onRemoveFromSchedule={schedule.handleRemoveFromSchedule} + /> + )) )}
+
diff --git a/app/(pages)/(hackers)/_components/Schedule/DayNavButtons.tsx b/app/(pages)/(hackers)/_components/Schedule/DayNavButtons.tsx new file mode 100644 index 00000000..ac83a878 --- /dev/null +++ b/app/(pages)/(hackers)/_components/Schedule/DayNavButtons.tsx @@ -0,0 +1,37 @@ +import { DAY_KEYS, DAY_LABELS, DayKey } from './constants'; + +interface DayNavButtonsProps { + activeDay: DayKey; + onSelectDay: (day: DayKey) => void; + className?: string; + buttonClassName?: string; +} + +export default function DayNavButtons({ + activeDay, + onSelectDay, + className, + buttonClassName, +}: DayNavButtonsProps) { + return ( +
+ {DAY_KEYS.map((dayKey) => ( + + ))} +
+ ); +} diff --git a/app/(pages)/(hackers)/_components/Schedule/DaySection.tsx b/app/(pages)/(hackers)/_components/Schedule/DaySection.tsx new file mode 100644 index 00000000..572b7bd1 --- /dev/null +++ b/app/(pages)/(hackers)/_components/Schedule/DaySection.tsx @@ -0,0 +1,87 @@ +import { Button } from '@pages/_globals/components/ui/button'; +import CalendarItem from './CalendarItem'; +import { DAY_LABELS, DayKey } from './constants'; +import { GroupedDayEntries } from './types'; + +interface DaySectionProps { + dayKey: DayKey; + entries: GroupedDayEntries; + activeTab: 'schedule' | 'personal'; + onSwitchToScheduleTab: () => void; + onAddToSchedule: (eventId: string) => void; + onRemoveFromSchedule: (eventId: string) => void; +} + +export default function DaySection({ + dayKey, + entries, + activeTab, + onSwitchToScheduleTab, + onAddToSchedule, + onRemoveFromSchedule, +}: DaySectionProps) { + const dayTitle = DAY_LABELS[dayKey].replace(/^MAY/, 'May'); + + return ( +
+
+ {dayTitle} +
+
+ + {entries.length > 0 ? ( + entries.map(([timeKey, events]) => ( +
+
+ {timeKey} +
+
+ {events.map((eventDetail) => ( + + onAddToSchedule(eventDetail.event._id || '') + } + onRemoveFromSchedule={() => + onRemoveFromSchedule(eventDetail.event._id || '') + } + /> + ))} +
+
+ )) + ) : ( +
+ {activeTab === 'personal' ? ( +
+

No events in your personal schedule yet.

+ +
+ ) : ( + 'No events found for this day and filter(s).' + )} +
+ )} +
+ ); +} diff --git a/app/(pages)/(hackers)/_components/Schedule/ScheduleControls.tsx b/app/(pages)/(hackers)/_components/Schedule/ScheduleControls.tsx new file mode 100644 index 00000000..69b7b03a --- /dev/null +++ b/app/(pages)/(hackers)/_components/Schedule/ScheduleControls.tsx @@ -0,0 +1,151 @@ +import Image from 'next/image'; +import { pageFilters, ScheduleFilter } from '@typeDefs/filters'; +import { useEffect, useState } from 'react'; +import DayNavButtons from './DayNavButtons'; +import Filters from './Filters'; +import { DayKey } from './constants'; + +const MOBILE_FILTER_BG_DEFAULT = '#F3F3FC'; +const MOBILE_FILTER_TEXT_DEFAULT = '#3F3F3F'; +const MOBILE_FILTER_BG_SELECTED = '#3F3F3F'; +const MOBILE_FILTER_TEXT_SELECTED = '#FAFAFF'; + +interface ScheduleControlsProps { + activeDay: DayKey; + changeActiveDay: (day: DayKey) => void; + activeFilters: ScheduleFilter[]; + toggleFilter: (label: ScheduleFilter) => void; + isMobileFilterOpen: boolean; + setIsMobileFilterOpen: ( + value: boolean | ((prev: boolean) => boolean) + ) => void; +} + +export default function ScheduleControls({ + activeDay, + changeActiveDay, + activeFilters, + toggleFilter, + isMobileFilterOpen, + setIsMobileFilterOpen, +}: ScheduleControlsProps) { + const hasSelectedFilters = activeFilters.some((filter) => filter !== 'ALL'); + const selectedFilterDots = activeFilters.filter((filter) => filter !== 'ALL'); + const [isScrolled, setIsScrolled] = useState(false); + + useEffect(() => { + const handleScroll = () => { + setIsScrolled(window.scrollY > 110); + }; + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); + }, []); + + return ( + <> +
+
+
+ +
+ + {!isMobileFilterOpen && ( + + )} +
+ + {isMobileFilterOpen && ( +
+ {pageFilters.map((filter) => ( + + ))} +
+ )} +
+ +
+
+ +
+ +
+ +
+
+ + ); +} diff --git a/app/(pages)/(hackers)/_components/Schedule/ScheduleMobileControls.tsx b/app/(pages)/(hackers)/_components/Schedule/ScheduleMobileControls.tsx deleted file mode 100644 index b8a2ce9e..00000000 --- a/app/(pages)/(hackers)/_components/Schedule/ScheduleMobileControls.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import Image from 'next/image'; -import { pageFilters, ScheduleFilter } from '@typeDefs/filters'; -import { useEffect, useState } from 'react'; - -const MOBILE_FILTER_BG_DEFAULT = '#F3F3FC'; -const MOBILE_FILTER_TEXT_DEFAULT = '#3F3F3F'; -const MOBILE_FILTER_BG_SELECTED = '#3F3F3F'; -const MOBILE_FILTER_TEXT_SELECTED = '#FAFAFF'; - -interface ScheduleMobileControlsProps { - activeDay: '9' | '10'; - changeActiveDay: (day: '9' | '10') => void; - activeFilters: ScheduleFilter[]; - toggleFilter: (label: ScheduleFilter) => void; - isMobileFilterOpen: boolean; - setIsMobileFilterOpen: ( - value: boolean | ((prev: boolean) => boolean) - ) => void; -} - -export default function ScheduleMobileControls({ - activeDay, - changeActiveDay, - activeFilters, - toggleFilter, - isMobileFilterOpen, - setIsMobileFilterOpen, -}: ScheduleMobileControlsProps) { - const hasSelectedFilters = activeFilters.some((filter) => filter !== 'ALL'); - const selectedFilterDots = activeFilters.filter((filter) => filter !== 'ALL'); - - const [isScrolled, setIsScrolled] = useState(false); - - // Allows for filter button to disspear when user scroll down - useEffect(() => { - const handleScroll = () => { - // If scrolled more than 110px, hide filter button - setIsScrolled(window.scrollY > 110); - }; - window.addEventListener('scroll', handleScroll); - return () => window.removeEventListener('scroll', handleScroll); - }, []); - - const renderDayButton = (day: '9' | '10', label: string) => ( - - ); - - return ( -
-
-
- -
- - {!isMobileFilterOpen && ( -
- {renderDayButton('9', 'May 9')} - {renderDayButton('10', 'May 10')} -
- )} -
- - {isMobileFilterOpen && ( -
- {pageFilters.map((filter) => ( - - ))} -
- )} -
- ); -} diff --git a/app/(pages)/(hackers)/_components/Schedule/constants.ts b/app/(pages)/(hackers)/_components/Schedule/constants.ts new file mode 100644 index 00000000..0ed45a57 --- /dev/null +++ b/app/(pages)/(hackers)/_components/Schedule/constants.ts @@ -0,0 +1,8 @@ +export const DAY_KEYS = ['9', '10'] as const; + +export type DayKey = (typeof DAY_KEYS)[number]; + +export const DAY_LABELS: Record = { + '9': 'MAY 9', + '10': 'MAY 10', +}; diff --git a/app/(pages)/(hackers)/_components/Schedule/groupedEntries.ts b/app/(pages)/(hackers)/_components/Schedule/groupedEntries.ts new file mode 100644 index 00000000..5fb7b437 --- /dev/null +++ b/app/(pages)/(hackers)/_components/Schedule/groupedEntries.ts @@ -0,0 +1,89 @@ +import { ScheduleFilter } from '@typeDefs/filters'; +import { DAY_KEYS, DayKey } from './constants'; +import { EventDetails, GroupedDayEntries, ScheduleData } from './types'; + +export const getGroupedEntriesForDay = ( + dayKey: DayKey, + dataToUse: ScheduleData | null, + activeFilters: ScheduleFilter[] +): GroupedDayEntries => { + const eventsForDay = dataToUse?.[dayKey] ?? []; + let filteredEvents = eventsForDay; + + if (activeFilters.length > 0 && !activeFilters.includes('ALL')) { + filteredEvents = eventsForDay.filter((eventDetail) => { + if (activeFilters.includes('RECOMMENDED') && eventDetail.isRecommended) { + return true; + } + + return activeFilters.includes(eventDetail.event.type); + }); + } + + if (filteredEvents.length === 0) return []; + + const sortedEvents = [...filteredEvents].sort((a, b) => { + const startA = new Date(a.event.start_time).getTime(); + const startB = new Date(b.event.start_time).getTime(); + + if (startA !== startB) { + return startA - startB; + } + + const endA = a.event.end_time ? new Date(a.event.end_time).getTime() : null; + const endB = b.event.end_time ? new Date(b.event.end_time).getTime() : null; + + if (endA === null && endB !== null) return -1; + if (endA !== null && endB === null) return 1; + + if (endA !== null && endB !== null && endA !== endB) { + return endA - endB; + } + + return 0; + }); + + const groups = sortedEvents.reduce( + (acc: Record, eventDetails) => { + const localizedStart = new Date( + eventDetails.event.start_time.toLocaleString('en-US', { + timeZone: 'America/Los_Angeles', + }) + ); + + const timeKey = localizedStart.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + + if (!acc[timeKey]) { + acc[timeKey] = []; + } + + acc[timeKey].push(eventDetails); + return acc; + }, + {} + ); + + return Object.entries(groups).sort((a, b) => { + const dummyDay = '01/01/2000'; + const dateA = new Date(`${dummyDay} ${a[0]}`); + const dateB = new Date(`${dummyDay} ${b[0]}`); + return dateA.getTime() - dateB.getTime(); + }); +}; + +export const buildGroupedEntriesByDay = ( + dataToUse: ScheduleData | null, + activeFilters: ScheduleFilter[] +): Record => { + return DAY_KEYS.reduce( + (acc, dayKey) => { + acc[dayKey] = getGroupedEntriesForDay(dayKey, dataToUse, activeFilters); + return acc; + }, + {} as Record + ); +}; diff --git a/app/(pages)/(hackers)/_components/Schedule/hooks/useActiveDaySync.ts b/app/(pages)/(hackers)/_components/Schedule/hooks/useActiveDaySync.ts new file mode 100644 index 00000000..9c15e995 --- /dev/null +++ b/app/(pages)/(hackers)/_components/Schedule/hooks/useActiveDaySync.ts @@ -0,0 +1,105 @@ +'use client'; + +import { useCallback, useEffect, useRef } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; +import { DAY_KEYS, DayKey } from '../constants'; + +interface UseActiveDaySyncOptions { + activeDay: DayKey; + setActiveDay: Dispatch>; + dayKeys?: readonly DayKey[]; + anchorRatio?: number; + clickLockMs?: number; + syncSignal?: unknown; +} + +export function useActiveDaySync({ + activeDay, + setActiveDay, + dayKeys = DAY_KEYS, + anchorRatio = 0.45, + clickLockMs = 900, + syncSignal, +}: UseActiveDaySyncOptions) { + const ignoreScrollSyncUntilRef = useRef(0); + const pendingDayRef = useRef(null); + + const updateActiveDayFromScroll = useCallback(() => { + const anchor = window.innerHeight * anchorRatio; + const pendingDay = pendingDayRef.current; + + if (pendingDay) { + const pendingSection = document.getElementById(`day-${pendingDay}`); + + if (pendingSection) { + const pendingRect = pendingSection.getBoundingClientRect(); + const pendingReached = + pendingRect.top <= anchor + 8 && pendingRect.bottom > anchor; + + if (pendingReached) { + setActiveDay((prev) => (prev === pendingDay ? prev : pendingDay)); + pendingDayRef.current = null; + ignoreScrollSyncUntilRef.current = 0; + return; + } + } + + if (Date.now() < ignoreScrollSyncUntilRef.current) { + setActiveDay((prev) => (prev === pendingDay ? prev : pendingDay)); + return; + } + + pendingDayRef.current = null; + } + + const daySections = dayKeys + .map((day) => { + const section = document.getElementById(`day-${day}`); + return section ? { day, rect: section.getBoundingClientRect() } : null; + }) + .filter( + (section): section is { day: DayKey; rect: DOMRect } => section !== null + ); + + if (daySections.length === 0) return; + + let nextActiveDay = daySections[0].day; + for (const section of daySections) { + if (section.rect.top <= anchor) { + nextActiveDay = section.day; + } + } + + setActiveDay((prev) => (prev === nextActiveDay ? prev : nextActiveDay)); + }, [anchorRatio, dayKeys, setActiveDay]); + + useEffect(() => { + updateActiveDayFromScroll(); + window.addEventListener('scroll', updateActiveDayFromScroll, { + passive: true, + }); + + return () => { + window.removeEventListener('scroll', updateActiveDayFromScroll); + }; + }, [updateActiveDayFromScroll]); + + useEffect(() => { + updateActiveDayFromScroll(); + }, [activeDay, syncSignal, updateActiveDayFromScroll]); + + const changeActiveDay = useCallback( + (day: DayKey) => { + pendingDayRef.current = day; + ignoreScrollSyncUntilRef.current = Date.now() + clickLockMs; + setActiveDay((prev) => (prev === day ? prev : day)); + + document + .getElementById(`day-${day}`) + ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, + [clickLockMs, setActiveDay] + ); + + return { changeActiveDay }; +} diff --git a/app/(pages)/(hackers)/_components/Schedule/hooks/useScheduleData.ts b/app/(pages)/(hackers)/_components/Schedule/hooks/useScheduleData.ts new file mode 100644 index 00000000..cb1b9982 --- /dev/null +++ b/app/(pages)/(hackers)/_components/Schedule/hooks/useScheduleData.ts @@ -0,0 +1,242 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; +import useActiveUser from '@pages/_hooks/useActiveUser'; +import { useEvents } from '@hooks/useEvents'; +import { usePersonalEvents } from '@hooks/usePersonalEvents'; +import { ScheduleFilter } from '@typeDefs/filters'; +import { DAY_KEYS, DayKey } from '../constants'; +import { buildGroupedEntriesByDay } from '../groupedEntries'; +import { ScheduleData } from '../types'; +import { useActiveDaySync } from './useActiveDaySync'; + +interface UseScheduleDataResult { + activeTab: 'schedule' | 'personal'; + setActiveTab: Dispatch>; + activeDay: DayKey; + setActiveDay: Dispatch>; + activeFilters: ScheduleFilter[]; + toggleFilter: (label: ScheduleFilter) => void; + isMobileFilterOpen: boolean; + setIsMobileFilterOpen: ( + value: boolean | ((prev: boolean) => boolean) + ) => void; + groupedEntriesByDay: ReturnType; + handleAddToSchedule: (eventId: string) => Promise; + handleRemoveFromSchedule: (eventId: string) => Promise; + isInitialLoad: boolean; + isError: boolean; + changeActiveDay: (day: DayKey) => void; +} + +const getDayKeyInPacific = (date: Date) => + date.toLocaleString('en-US', { + timeZone: 'America/Los_Angeles', + day: 'numeric', + }); + +export function useScheduleData(): UseScheduleDataResult { + const { user, loading: userLoading } = useActiveUser('/'); + + const { + eventData, + isLoading: eventsLoading, + error: eventsError, + refreshEvents, + } = useEvents(user); + + const { + personalEvents, + isLoading: personalEventsLoading, + error: personalEventsError, + addToPersonalSchedule, + removeFromPersonalSchedule, + isInPersonalSchedule, + refreshPersonalEvents, + } = usePersonalEvents(user?._id || ''); + + const [activeTab, setActiveTab] = useState<'schedule' | 'personal'>( + 'schedule' + ); + const [activeDay, setActiveDay] = useState('9'); + const [activeFilters, setActiveFilters] = useState(['ALL']); + const [isMobileFilterOpen, setIsMobileFilterOpen] = useState(false); + const [scheduleData, setScheduleData] = useState(null); + + const patchScheduleMembership = ( + eventId: string, + inPersonalSchedule: boolean + ) => { + setScheduleData((prev) => { + if (!prev) return prev; + + const next = { ...prev }; + Object.keys(next).forEach((dayKey) => { + next[dayKey] = next[dayKey].map((item) => { + if (item.event._id === eventId) { + return { ...item, inPersonalSchedule }; + } + return item; + }); + }); + + return next; + }); + }; + + const handleAddToSchedule = async (eventId: string) => { + const success = await addToPersonalSchedule(eventId); + + if (success) { + await refreshPersonalEvents(); + await refreshEvents(); + + if (activeTab === 'schedule') { + patchScheduleMembership(eventId, true); + } + } + }; + + const handleRemoveFromSchedule = async (eventId: string) => { + const success = await removeFromPersonalSchedule(eventId); + + if (success) { + await refreshPersonalEvents(); + await refreshEvents(); + + if (activeTab === 'schedule') { + patchScheduleMembership(eventId, false); + } + } + }; + + useEffect(() => { + if (user && !userLoading) { + refreshEvents(); + } + }, [user, userLoading, refreshEvents]); + + useEffect(() => { + if (!eventsLoading && !personalEventsLoading) { + const groupedByDay = eventData.reduce( + (acc: ScheduleData, eventWithCount) => { + const event = eventWithCount.event; + const dayKey = getDayKeyInPacific(event.start_time); + + if (!acc[dayKey]) { + acc[dayKey] = []; + } + + const isPersonal = isInPersonalSchedule(event._id || ''); + + acc[dayKey].push({ + event, + attendeeCount: eventWithCount.attendeeCount, + inPersonalSchedule: isPersonal, + isRecommended: eventWithCount.isRecommended, + }); + return acc; + }, + {} + ); + + setScheduleData(groupedByDay); + } + }, [ + eventData, + personalEvents, + isInPersonalSchedule, + personalEventsLoading, + eventsLoading, + ]); + + useEffect(() => { + if (activeTab === 'personal') { + refreshPersonalEvents(); + } + }, [activeTab, refreshPersonalEvents]); + + const personalScheduleData = useMemo(() => { + if (!personalEvents?.length) return {}; + + const groupedByDay = personalEvents.reduce((acc: ScheduleData, event) => { + const dayKey = getDayKeyInPacific(event.start_time); + if (!acc[dayKey]) { + acc[dayKey] = []; + } + + const eventWithCount = eventData.find((e) => e.event._id === event._id); + + acc[dayKey].push({ + event, + attendeeCount: eventWithCount?.attendeeCount || 0, + inPersonalSchedule: true, + isRecommended: eventWithCount?.isRecommended || false, + }); + return acc; + }, {}); + + return groupedByDay; + }, [personalEvents, eventData]); + + const dataToUse = + activeTab === 'personal' ? personalScheduleData : scheduleData; + + const groupedEntriesByDay = useMemo( + () => buildGroupedEntriesByDay(dataToUse, activeFilters), + [dataToUse, activeFilters] + ); + + const syncSignal = useMemo( + () => + `${activeTab}:${activeFilters.join(',')}:${DAY_KEYS.map( + (dayKey) => groupedEntriesByDay[dayKey].length + ).join(',')}`, + [activeTab, activeFilters, groupedEntriesByDay] + ); + + const { changeActiveDay } = useActiveDaySync({ + activeDay, + setActiveDay, + dayKeys: DAY_KEYS, + syncSignal, + }); + + const toggleFilter = (label: ScheduleFilter) => { + if (label === 'ALL') { + setActiveFilters(['ALL']); + return; + } + + const withoutAll = activeFilters.filter((id) => id !== 'ALL'); + + if (withoutAll.includes(label)) { + const nextFilters = withoutAll.filter((id) => id !== label); + setActiveFilters(nextFilters.length > 0 ? nextFilters : ['ALL']); + return; + } + + setActiveFilters([...withoutAll, label]); + }; + + const isInitialLoad = userLoading; + const isError = Boolean(personalEventsError || eventsError); + + return { + activeTab, + setActiveTab, + activeDay, + setActiveDay, + activeFilters, + toggleFilter, + isMobileFilterOpen, + setIsMobileFilterOpen, + groupedEntriesByDay, + handleAddToSchedule, + handleRemoveFromSchedule, + isInitialLoad, + isError, + changeActiveDay, + }; +} diff --git a/app/(pages)/(hackers)/_components/Schedule/types.ts b/app/(pages)/(hackers)/_components/Schedule/types.ts new file mode 100644 index 00000000..23d62e05 --- /dev/null +++ b/app/(pages)/(hackers)/_components/Schedule/types.ts @@ -0,0 +1,14 @@ +import Event from '@typeDefs/event'; + +export interface EventDetails { + event: Event; + attendeeCount?: number; + inPersonalSchedule?: boolean; + isRecommended?: boolean; +} + +export interface ScheduleData { + [dayKey: string]: EventDetails[]; +} + +export type GroupedDayEntries = [string, EventDetails[]][]; diff --git a/app/(pages)/_globals/globals.scss b/app/(pages)/_globals/globals.scss index 9c5e1ce5..41fbc74a 100644 --- a/app/(pages)/_globals/globals.scss +++ b/app/(pages)/_globals/globals.scss @@ -108,6 +108,9 @@ box-sizing: border-box; padding: 0; margin: 0; +} + +body { font-family: var(--font-jakarta); } From 48bb9909d5a436a65874f8458fab45a07f9f891c Mon Sep 17 00:00:00 2001 From: Afifah Hadi Date: Wed, 4 Mar 2026 19:59:38 -0800 Subject: [PATCH 6/6] added day nav button animation --- .../_components/Schedule/DayNavButtons.tsx | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/app/(pages)/(hackers)/_components/Schedule/DayNavButtons.tsx b/app/(pages)/(hackers)/_components/Schedule/DayNavButtons.tsx index ac83a878..30e82fb1 100644 --- a/app/(pages)/(hackers)/_components/Schedule/DayNavButtons.tsx +++ b/app/(pages)/(hackers)/_components/Schedule/DayNavButtons.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { DAY_KEYS, DAY_LABELS, DayKey } from './constants'; interface DayNavButtonsProps { @@ -13,23 +14,36 @@ export default function DayNavButtons({ className, buttonClassName, }: DayNavButtonsProps) { + const [hoveredDay, setHoveredDay] = useState(null); + const previewDay = + hoveredDay && hoveredDay !== activeDay ? hoveredDay : activeDay; + return (
{DAY_KEYS.map((dayKey) => ( ))}