// 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 (
— Floor schedule

Calendar

Week of {rangeLabel} · {totalCount} bookings in feed
{[ { k:'confirmed', l:'Confirmed' }, { k:'booked', l:'Booked' }, { k:'cancelled', l:'Cancelled' }, ].map(x => { const c = colorFor(x.k); return ( {x.l} ); })}
{days.map(d => (
{ setFocusDay(d.iso); if (view === 'week') setView('day'); }} style={{ padding:'12px 14px', borderLeft:'1px solid var(--line-2)', cursor:'pointer', background: d.today ? 'var(--signal-soft)' : 'transparent', }} >
{d.d}
{d.date} {d.today && today}
))}
{Array.from({length: HOUR_END - HOUR_START}, (_, i) => { const h = HOUR_START + i; const ampm = h >= 12 ? 'p' : 'a'; const h12 = ((h + 11) % 12) + 1; return (
{h12}{ampm}
); })}
{days.map(d => { const entries = eventsByDay[d.iso] || []; return (
{Array.from({length: HOUR_END - HOUR_START}, (_, i) => (
))} {/* Booking cutoff line at 7:30 PM. Cosmetic — GHL widget enforces the real cap; backend pings Telegram if a late booking slips through. */} {(() => { const top = (BOOKING_CUTOFF_MIN - HOUR_START * 60) * PX_PER_MIN; return (
); })()} {d.today && (() => { const mins = gymMinutesOfDay(new Date()); if (mins == null || mins < HOUR_START*60 || mins > HOUR_END*60) return null; return (
); })()} {entries.map((e, i) => { const cls = colorFor(e.status, e.current_stage); // Real duration from backend if present, else 30 min default. const startMs = e.jsDate?.getTime(); const endMs = e.end_date ? parseDate(e.end_date)?.getTime() : null; const durMin = (endMs && startMs && endMs > startMs) ? Math.round((endMs - startMs) / 60000) : 30; const offset = Math.max(0, (e.minutes - HOUR_START*60)) * PX_PER_MIN; // Min 48px so a 1-line time + 2-line name always fits without // clipping at narrow column widths (when the week view squeezes // each day to ~180px). const height = Math.max(48, durMin * PX_PER_MIN - 3); const status = e.status || 'booked'; // Cancelled-by-stage: contact moved out of DAY PASS SCHEDULED // / SHOWED / JOINED / MEMBER after the booking. Show stage in // the time line so staff knows why the card is red. const stageUpper = String(e.current_stage || '').toUpperCase(); const cancelledByStage = stageUpper && !ACTIVE_STAGES.has(stageUpper); return (
e.contact_id && onNavigate({view:'lead', id:e.contact_id})} title={`${fmtClock(e.date)} · ${cancelledByStage ? stageUpper : status} · ${e.name}`} style={{ position:'absolute', left:4, right:4, top:offset, height, background:cls.bg, borderLeft:'2px solid ' + cls.accent, borderRadius:4, padding:'5px 7px', fontSize:11.5, overflow:'hidden', display:'flex', flexDirection:'column', gap:2, cursor: e.contact_id ? 'pointer' : 'default', zIndex:2, }} >
{fmtClock(e.date)}{cancelledByStage ? ' · ' + stageUpper : ''}
{e.name}
); })} {entries.length === 0 && d.today && (
no bookings today
)}
); })}
{totalCount === 0 && !cal.loading && (
No bookings yet. Appointments booked via GHL will appear here automatically.
)}
); } Object.assign(window, { CalendarView });