// Calendar — week grid of GHL day-pass bookings. // Wired to GET /api/v2/calendar. Falls back to a simple list if the week is // empty of bookings. function CalendarView({ onNavigate }) { // 7 columns + a time gutter does not fit a 360-450px viewport without // truncating day headers and clipping event names. Default to day view // on phone-sized screens; the user can toggle to week explicitly. const [view, setView] = React.useState( typeof window !== 'undefined' && window.innerWidth <= 480 ? 'day' : 'week' ); // weekStart is the Monday at 00:00 *gym-local* of the currently-displayed // week. We store it as a JS Date pinned to that instant; UI labels are then // formatted in GYM_TZ via toLocaleDateString({ timeZone: GYM_TZ }). const [weekStart, setWeekStart] = React.useState(() => mondayOfGymWeek(new Date())); const cal = useAsync(() => api.get('/api/v2/calendar?limit=500'), []); // Grid hours. HOUR_END=20 caps the visible day at 8 PM so the 7:30 PM // booking cutoff (BOOKING_CUTOFF in app/routers/webhooks.py) sits with // 30 min of visual margin below it. Real enforcement is in the GHL // widget; a horizontal cutoff line is drawn at 7:30 PM as a reminder. const HOUR_START = 5; const HOUR_END = 20; const PX_PER_MIN = 1.4; // Booking cutoff in gym-local minutes from midnight. Mirrors // BOOKING_CUTOFF_HOUR/MINUTE in webhooks.py. const BOOKING_CUTOFF_MIN = 19 * 60 + 30; const weekDays = React.useMemo(() => { // Identify each day in *gym-local* time. iso comes from gymIsoDay so it // matches the iso used to group bookings; labels are formatted with // timeZone: GYM_TZ so they render the gym's calendar date, not the // browser's. "Today" is whatever gym-local day contains the current // instant — independent of where the staff member is sitting. const todayIso = gymIsoDay(new Date()); return Array.from({ length: 7 }, (_, i) => { const d = new Date(weekStart.getTime() + i * 86400000); const iso = gymIsoDay(d); return { iso, d: d.toLocaleDateString(undefined, { timeZone: GYM_TZ, weekday: 'short' }), date: d.toLocaleDateString(undefined, { timeZone: GYM_TZ, month: 'short', day: 'numeric' }), today: iso === todayIso, jsDate: d, }; }); }, [weekStart]); const [focusDay, setFocusDay] = React.useState(weekDays.find(d => d.today)?.iso || weekDays[0]?.iso); // Group entries by *gym-local* day. iso and minutes are both gym-time so a // booking at 11pm ET that's 12am next-day in the browser's TZ still lands // on the gym's calendar date and at the right vertical offset. const eventsByDay = React.useMemo(() => { const map = {}; (cal.data?.entries || []).forEach(e => { const d = parseDate(e.date); if (!d) return; const iso = gymIsoDay(d); const minutes = gymMinutesOfDay(d); if (!iso || minutes == null) return; (map[iso] = map[iso] || []).push({ ...e, minutes, jsDate: d }); }); Object.values(map).forEach(arr => arr.sort((a, b) => a.minutes - b.minutes)); return map; }, [cal.data]); const days = view === 'week' ? weekDays : weekDays.filter(d => d.iso === focusDay); const gridCols = view === 'day' ? '60px 1fr' : '60px repeat(7, 1fr)'; // Stages that mean "still on the floor schedule" — anything else (DEAD, // LOST, NEW LEAD that was re-classified, etc) makes the card render in // the cancelled style even if the original GHL appointment status is // still "confirmed". Mirrors ACTIVE_BOOKING_STAGES in app/stage_log.py. const ACTIVE_STAGES = new Set(['DAY PASS SCHEDULED', 'SHOWED', 'JOINED', 'MEMBER']); const colorFor = (status, currentStage) => { const s = String(status || '').toLowerCase(); const stage = String(currentStage || '').toUpperCase(); const cancelledByGhl = s === 'cancelled' || s === 'cancel'; const cancelledByStage = stage && !ACTIVE_STAGES.has(stage); if (cancelledByGhl || cancelledByStage) return { bg:'var(--warn-soft)', fg:'var(--warn)', accent:'var(--warn)' }; if (s === 'showed' || s === 'confirmed' || stage === 'SHOWED' || stage === 'JOINED' || stage === 'MEMBER') { return { bg:'var(--green-soft)', fg:'var(--green)', accent:'var(--green)' }; } return { bg:'var(--signal-soft)', fg:'var(--signal-ink)', accent:'var(--signal)' }; }; function shiftWeek(deltaWeeks) { setWeekStart(new Date(weekStart.getTime() + deltaWeeks * 7 * 86400000)); } function gotoToday() { setWeekStart(mondayOfGymWeek(new Date())); } const rangeLabel = weekDays[0].jsDate.toLocaleDateString(undefined, { timeZone: GYM_TZ, month:'short', day:'numeric' }) + ' — ' + weekDays[6].jsDate.toLocaleDateString(undefined, { timeZone: GYM_TZ, month:'short', day:'numeric' }); const totalCount = (cal.data?.entries || []).length; return (