diff --git a/app/(pages)/(hackers)/(hub)/schedule/page.tsx b/app/(pages)/(hackers)/(hub)/schedule/page.tsx index fe5de9b1..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 } 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,290 +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 changeActiveDay = (day: '9' | '10') => { - setActiveDay(day); - window.scrollTo({ top: 0, behavior: 'smooth' }); - }; - - 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; - }, {}); + const schedule = useScheduleData(); - return groupedByDay; - }, [personalEvents, eventData]); - - const dataToUse = - activeTab === 'personal' ? personalScheduleData : scheduleData; - - // Update the filtering logic to handle recommended events correctly - const sortedGroupedEntries = useMemo(() => { - if (!dataToUse) return []; - - // Filter events for the active day - const eventsForDay = dataToUse[activeDay] || []; - - // 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 []; - - // Sort the filtered events by start time. - const sortedEvents = [...filteredEvents].sort( - (a, b) => - new Date(a.event.start_time).getTime() - - new Date(b.event.start_time).getTime() - ); - - // 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(); - }); - }, [dataToUse, activeDay, activeFilters]); - - 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 (
-
+
+
header-grass
-
+ +
- -
-
- -
- -
- - -
-
- -
- {isInitialLoad ? ( +
+ {schedule.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. -

- -
- ) : ( - '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/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)/(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)/(hackers)/_components/Schedule/DayNavButtons.tsx b/app/(pages)/(hackers)/_components/Schedule/DayNavButtons.tsx new file mode 100644 index 00000000..30e82fb1 --- /dev/null +++ b/app/(pages)/(hackers)/_components/Schedule/DayNavButtons.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react'; +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) { + const [hoveredDay, setHoveredDay] = useState(null); + const previewDay = + hoveredDay && hoveredDay !== activeDay ? hoveredDay : activeDay; + + 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 33f27240..00000000 --- a/app/(pages)/(hackers)/_components/Schedule/ScheduleMobileControls.tsx +++ /dev/null @@ -1,150 +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); } 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) { 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,