import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import ReactDOM from 'react-dom'; import { api } from '../utils/api.js'; import { useToast } from '../contexts/ToastContext.jsx'; import { useAuth } from '../contexts/AuthContext.jsx'; import UserFooter from './UserFooter.jsx'; import MobileEventForm from './MobileEventForm.jsx'; import ColourPickerSheet from './ColourPickerSheet.jsx'; import MobileGroupManager from './MobileGroupManager.jsx'; // ── Utilities ───────────────────────────────────────────────────────────────── const DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December']; const SHORT_MONTHS= ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; function fmtDate(d) { return `${d.getDate()} ${SHORT_MONTHS[d.getMonth()]} ${d.getFullYear()}`; } function fmtTime(iso) { if(!iso) return ''; const d=new Date(iso); return d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}); } function fmtRange(s,e) { return `${fmtTime(s)} – ${fmtTime(e)}`; } // Convert a UTC ISO string (from Postgres TIMESTAMPTZ) to local YYYY-MM-DD for function toDateIn(iso) { if (!iso) return ''; const d = new Date(iso); const pad = n => String(n).padStart(2,'0'); return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`; } // Convert a UTC ISO string to local HH:MM for , snapped to :00 or :30 function toTimeIn(iso) { if (!iso) return ''; const d = new Date(iso); return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; } // Build an ISO string with local timezone offset so Postgres stores the right UTC value function buildISO(date, time) { if (!date || !time) return ''; // Parse as local datetime then get offset-aware ISO string const d = new Date(`${date}T${time}:00`); const pad = n => String(n).padStart(2,'0'); const off = -d.getTimezoneOffset(); const sign = off >= 0 ? '+' : '-'; const abs = Math.abs(off); return `${date}T${time}:00${sign}${pad(Math.floor(abs/60))}:${pad(abs%60)}`; } function addHours(iso, h) { const d = new Date(iso); d.setMinutes(d.getMinutes() + h * 60); const pad = n => String(n).padStart(2,'0'); return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`; } function sameDay(a,b) { return a.getFullYear()===b.getFullYear()&&a.getMonth()===b.getMonth()&&a.getDate()===b.getDate(); } function weekStart(d) { const r=new Date(d); r.setDate(d.getDate()-d.getDay()); r.setHours(0,0,0,0); return r; } function daysInMonth(y,m){ return new Date(y,m+1,0).getDate(); } const RESP_LABEL = { going:'Going', maybe:'Maybe', not_going:'Not Going' }; const RESP_COLOR = { going:'#22c55e', maybe:'#f59e0b', not_going:'#ef4444' }; const RESP_ICON = { going: (color,size=15) => ( Going ), maybe: (color,size=15) => ( Maybe ), not_going: (color,size=15) => ( Not Going ), }; const BELL_ICON = ( Awaiting your response ); // 30-minute time slots const TIME_SLOTS = (() => { const s=[]; for(let h=0;h<24;h++) for(let m of [0,30]) { const hh=String(h).padStart(2,'0'), mm=String(m).padStart(2,'0'); const disp=`${h===0?12:h>12?h-12:h}:${mm} ${h<12?'AM':'PM'}`; s.push({value:`${hh}:${mm}`,label:disp}); } return s; })(); // Returns current time rounded up to the next :00 or :30 as HH:MM function roundUpToHalfHour() { const now = new Date(); const m = now.getMinutes(); const snap = m === 0 ? 0 : m <= 30 ? 30 : 60; const snapped = new Date(now); snapped.setMinutes(snap, 0, 0); const h = String(snapped.getHours()).padStart(2,'0'); const min = String(snapped.getMinutes()).padStart(2,'0'); return `${h}:${min}`; } // Parse a typed time string (various formats) into HH:MM, or return null function parseTypedTime(raw) { if (!raw) return null; const s = raw.trim().toLowerCase(); // Try HH:MM let m = s.match(/^(\d{1,2}):(\d{2})\s*(am|pm)?$/); if (m) { let h = parseInt(m[1]), min = parseInt(m[2]); if (m[3] === 'pm' && h < 12) h += 12; if (m[3] === 'am' && h === 12) h = 0; if (h < 0 || h > 23 || min < 0 || min > 59) return null; return `${String(h).padStart(2,'0')}:${String(min).padStart(2,'0')}`; } // Try H am/pm or HH am/pm m = s.match(/^(\d{1,2})\s*(am|pm)$/); if (m) { let h = parseInt(m[1]); if (m[2] === 'pm' && h < 12) h += 12; if (m[2] === 'am' && h === 12) h = 0; if (h < 0 || h > 23) return null; return `${String(h).padStart(2,'0')}:00`; } // Try bare number 0-23 as hour m = s.match(/^(\d{1,2})$/); if (m) { const h = parseInt(m[1]); if (h < 0 || h > 23) return null; return `${String(h).padStart(2,'0')}:00`; } return null; } // Format HH:MM value as 12-hour display string function fmt12(val) { if (!val) return ''; const [hh, mm] = val.split(':').map(Number); const h = hh === 0 ? 12 : hh > 12 ? hh - 12 : hh; const ampm = hh < 12 ? 'AM' : 'PM'; return `${h}:${String(mm).padStart(2,'0')} ${ampm}`; } // ── TimeInput — free-text time entry with 5-slot scrollable dropdown ────────── function TimeInput({ value, onChange, style }) { const [open, setOpen] = useState(false); const [inputVal, setInputVal] = useState(fmt12(value)); const wrapRef = useRef(null); const listRef = useRef(null); // Keep display in sync when value changes externally useEffect(() => { setInputVal(fmt12(value)); }, [value]); // Scroll the dropdown so the selected slot is near the top useEffect(() => { if (!open || !listRef.current) return; const idx = TIME_SLOTS.findIndex(s => s.value === value); if (idx >= 0) { listRef.current.scrollTop = idx * 36 - 36; } }, [open, value]); // Close on outside click useEffect(() => { if (!open) return; const h = e => { if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h); }, [open]); const commit = (raw) => { const parsed = parseTypedTime(raw); if (parsed) { onChange(parsed); setInputVal(fmt12(parsed)); } else { // Revert to last valid value setInputVal(fmt12(value)); } setOpen(false); }; return (
setInputVal(e.target.value)} onFocus={() => setOpen(true)} onBlur={e => { // Delay so dropdown click fires first setTimeout(() => commit(e.target.value), 150); }} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commit(inputVal); } if (e.key === 'Escape') { setInputVal(fmt12(value)); setOpen(false); } }} style={{ width: '100%', cursor: 'text' }} autoComplete="new-password" placeholder="9:00 AM" /> {open && (
{TIME_SLOTS.map(s => (
{ e.preventDefault(); onChange(s.value); setInputVal(s.label); setOpen(false); }} style={{ padding: '8px 12px', fontSize: 13, cursor: 'pointer', height: 36, boxSizing: 'border-box', whiteSpace: 'nowrap', background: s.value === value ? 'var(--primary)' : 'transparent', color: s.value === value ? 'white' : 'var(--text-primary)', }} onMouseEnter={e => { if (s.value !== value) e.currentTarget.style.background = 'var(--background)'; }} onMouseLeave={e => { if (s.value !== value) e.currentTarget.style.background = 'transparent'; }} > {s.label}
))}
)}
); } // ── Mini Calendar (desktop) ─────────────────────────────────────────────────── function MiniCalendar({ selected, onChange, events=[] }) { const [cur, setCur] = useState(()=>{ const d=new Date(selected||Date.now()); d.setDate(1); return d; }); // BUG FIX: sync displayed month when selected date changes (e.g. switching Day/Week/Month view resets to today) useEffect(() => { const n = new Date(selected || Date.now()); n.setDate(1); n.setHours(0,0,0,0); setCur(prev => (prev.getFullYear()===n.getFullYear()&&prev.getMonth()===n.getMonth()) ? prev : n); }, [selected]); const y=cur.getFullYear(), m=cur.getMonth(), first=new Date(y,m,1).getDay(), total=daysInMonth(y,m), today=new Date(); const cells=[]; for(let i=0;i { const rangeStart = new Date(y, m, 1); const rangeEnd = new Date(y, m+1, 0, 23, 59, 59); const s = new Set(); for (const ev of events) { const occs = expandRecurringEvent(ev, rangeStart, rangeEnd); for (const occ of occs) { if (!occ.start_at) continue; const d = new Date(occ.start_at); if (d.getFullYear()===y && d.getMonth()===m) s.add(`${y}-${String(m+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`); } } return s; }, [events, y, m]); return (
{MONTHS[m]} {y}
{DAYS.map(d=>
{d[0]}
)} {cells.map((d,i)=>{ if(!d) return
; const date=new Date(y,m,d), isSel=selected&&sameDay(date,new Date(selected)), isToday=sameDay(date,today); const key=`${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`; return (
onChange(date)} style={{textAlign:'center',padding:'3px 2px',borderRadius:4,cursor:'pointer',background:isSel?'var(--primary)':'transparent',color:isSel?'white':isToday?'var(--primary)':'var(--text-primary)',fontWeight:isToday?700:400,position:'relative'}}> {d} {eventDates.has(key)&&!isSel&&}
); })}
); } // ── Mobile Filter Bar (Schedule view: keyword+type filters with month nav; Day view: calendar accordion) ── function MobileScheduleFilter({ selected, onMonthChange, view, eventTypes, filterKeyword, onFilterKeyword, filterTypeId, onFilterTypeId, filterAvailability=false, onFilterAvailability, onClearFromDate, eventDates=new Set(), onInputFocus, onInputBlur }) { // Day view: keep accordion calendar const [open, setOpen] = useState(false); const y=selected.getFullYear(), m=selected.getMonth(); const today=new Date(); if(view==='day') { const first=new Date(y,m,1).getDay(), total=daysInMonth(y,m); const cells=[]; for(let i=0;i {open && (
{DAYS.map(d=>
{d[0]}
)} {cells.map((d,i)=>{ if(!d) return
; const date=new Date(y,m,d), isSel=sameDay(date,selected), isToday=sameDay(date,today); const key=`${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`; return (
{const nd=new Date(y,m,d);onMonthChange(0,nd);setOpen(false);}} style={{textAlign:'center',padding:'5px 2px',borderRadius:4,cursor:'pointer',background:isSel?'var(--primary)':'transparent',color:isSel?'white':isToday?'var(--primary)':'var(--text-primary)',fontWeight:isToday&&!isSel?700:400,position:'relative'}}> {d} {eventDates.has(key)&&!isSel&&}
); })}
)}
); } // Schedule view: accordion "Filter Events" + month nav const hasFilters = filterKeyword || filterTypeId || filterAvailability; return (
{/* Month nav row — always visible */}
{MONTHS[m]} {y} {/* Filter accordion toggle */}
{/* Collapsible filter panel */} {open && (
onFilterKeyword(e.target.value)} autoComplete="new-password" onFocus={onInputFocus} onBlur={onInputBlur} placeholder="Search events…" autoComplete="new-password" autoCorrect="off" autoCapitalize="off" spellCheck={false} style={{width:'100%',padding:'7px 8px 7px 28px',border:'1px solid var(--border)',borderRadius:'var(--radius)',background:'var(--background)',color:'var(--text-primary)',fontSize:13,boxSizing:'border-box'}}/>
{hasFilters && ( )}
)}
); } // ── Event Type Popup ────────────────────────────────────────────────────────── function EventTypePopup({ userGroups, onSave, onClose, editing=null }) { const toast=useToast(); const DUR=[1,1.5,2,2.5,3,3.5,4,4.5,5]; const [name,setName]=useState(editing?.name||''); const [colour,setColour]=useState(editing?.colour||'#6366f1'); const [groupId,setGroupId]=useState(editing?.default_user_group_id||''); const [dur,setDur]=useState(editing?.default_duration_hrs||1); const [useDur,setUseDur]=useState(!!(editing?.default_duration_hrs)); const [saving,setSaving]=useState(false); const handle=async()=>{ if(!name.trim()) return toast('Name required','error'); setSaving(true); try{const body={name:name.trim(),colour,defaultUserGroupId:groupId||null,defaultDurationHrs:useDur?dur:null};const r=editing?await api.updateEventType(editing.id,body):await api.createEventType(body);onSave(r.eventType);onClose();}catch(e){toast(e.message,'error');}finally{setSaving(false);} }; return (
setName(e.target.value)} autoComplete="new-password" style={{marginTop:4}} autoFocus/>
setColour(e.target.value)} style={{marginTop:4,width:'100%',height:32,padding:2,borderRadius:4,border:'1px solid var(--border)'}}/>
{useDur&&}
); } // ── Recurrence helpers ──────────────────────────────────────────────────────── const FREQ_OPTIONS = [ { value: '', label: 'Does not repeat' }, { value: 'daily', label: 'Every day' }, { value: 'weekly', label: 'Every week' }, { value: 'monthly', label: 'Every month' }, { value: 'yearly', label: 'Every year' }, { value: 'custom', label: 'Custom…' }, ]; const DAY_PILLS = ['S','M','T','W','T','F','S']; const DAY_KEYS = ['SU','MO','TU','WE','TH','FR','SA']; function recurrenceLabel(rule) { if (!rule || !rule.freq) return 'Does not repeat'; const opt = FREQ_OPTIONS.find(o => o.value === rule.freq); if (rule.freq !== 'custom') return opt?.label || rule.freq; // Custom summary const unit = rule.interval === 1 ? rule.unit : `${rule.interval} ${rule.unit}s`; return `Every ${unit}`; } // Desktop recurrence selector — shown inline in the form function RecurrenceSelector({ value, onChange }) { // value: { freq, interval, unit, byDay, ends, endDate, endCount } or null const [showCustom, setShowCustom] = useState(false); const rule = value || {}; const handleFreqChange = (freq) => { if (freq === '') { onChange(null); return; } if (freq === 'custom') { setShowCustom(true); onChange({ freq:'custom', interval:1, unit:'week', byDay:[], ends:'never', endDate:'', endCount:13 }); return; } setShowCustom(false); onChange({ freq }); }; return (
{(rule.freq==='custom') && ( )}
); } function CustomRecurrenceFields({ rule, onChange }) { const upd = (k,v) => onChange({...rule,[k]:v}); return (
Every upd('interval',Math.max(1,parseInt(e.target.value)||1))} style={{width:60,textAlign:'center'}}/>
{(rule.unit||'week')==='week' && (
Repeats on
{DAY_PILLS.map((d,i)=>{ const key=DAY_KEYS[i], sel=(rule.byDay||[]).includes(key); return ; })}
)}
Ends
{[['never','Never'],['on','On date'],['after','After']].map(([val,lbl])=>( ))}
); } // ── Shared Row layout — defined OUTSIDE EventForm so it's stable across renders ─ function FormRow({ label, children, required }) { return (
{label}{required&& *}
{children}
); } // ── Event Form ──────────────────────────────────────────────────────────────── function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCancel, onDelete, isToolManager }) { const toast=useToast(); const _defD = selectedDate || new Date(); const _p = n => String(n).padStart(2,'0'); const def = `${_defD.getFullYear()}-${_p(_defD.getMonth()+1)}-${_p(_defD.getDate())}`; const [title,setTitle]=useState(event?.title||''); const [typeId,setTypeId]=useState(event?.event_type_id||''); const [sd,setSd]=useState(event?toDateIn(event.start_at):def); const [st,setSt]=useState(event?toTimeIn(event.start_at):roundUpToHalfHour()); const [ed,setEd]=useState(event?toDateIn(event.end_at):def); const [et,setEt]=useState(event?toTimeIn(event.end_at):(() => { const s=roundUpToHalfHour(); const d=new Date(`${def}T${s}:00`); d.setHours(d.getHours()+1); return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; })()); const [allDay,setAllDay]=useState(!!event?.all_day); const [loc,setLoc]=useState(event?.location||''); const [desc,setDesc]=useState(event?.description||''); const [pub,setPub]=useState(event?!!event.is_public:true); const [track,setTrack]=useState(!!event?.track_availability); const [grps,setGrps]=useState(new Set((event?.user_groups||[]).map(g=>g.id))); const [saving,setSaving]=useState(false); const [showTypeForm,setShowTypeForm]=useState(false); const [localTypes,setLocalTypes]=useState(eventTypes); const [recRule,setRecRule]=useState(event?.recurrence_rule||null); // Sync localTypes when parent provides updated eventTypes (e.g. after async load) // Also initialise typeId to the default event type for new events useEffect(()=>{ setLocalTypes(eventTypes); if(!event && typeId==='' && eventTypes.length>0) { const def = eventTypes.find(t=>t.is_default) || eventTypes[0]; if(def) setTypeId(String(def.id)); } },[eventTypes]); const typeRef=useRef(null); // Track whether the user has manually changed the end time (vs auto-computed) const userSetEndTime = useRef(!!event); // editing mode: treat saved end as user-set // Duration of the saved event in minutes (preserved when editing with same type) const savedDurMins = event ? (new Date(event.end_at) - new Date(event.start_at)) / 60000 : null; const prevTypeIdRef = useRef(event?.event_type_id ? String(event.event_type_id) : ''); const mountedRef = useRef(false); // skip all auto-calc effects on initial mount // When event type changes: // - Creating: always apply the type's duration to compute end time // - Editing: only apply duration if the type HAS a defined duration // (if no duration on type, keep existing saved end time) useEffect(()=>{ if(!mountedRef.current) return; // skip on initial mount if(!sd||!st) return; const typ=localTypes.find(t=>t.id===Number(typeId)); const start=buildISO(sd,st); if(!start) return; const typeChanged = typeId !== prevTypeIdRef.current; prevTypeIdRef.current = String(typeId); if(!event || typeChanged) { // New event or type change only: apply eventType duration const dur=typ?.default_duration_hrs||1; const endIso=addHours(start,dur); setEd(toDateIn(endIso)); setEt(toTimeIn(endIso)); userSetEndTime.current = false; } if(typ?.default_user_group_id&&!event) setGrps(prev=>new Set([...prev,Number(typ.default_user_group_id)])); },[typeId]); // When start date changes: recalculate end preserving duration useEffect(()=>{ if(!mountedRef.current) return; if(!sd||!st) return; const start=buildISO(sd,st); if(!start) return; const durMins = (event && savedDurMins) ? savedDurMins : (localTypes.find(t=>t.id===Number(typeId))?.default_duration_hrs||1)*60; const endIso=addHours(start,durMins/60); setEd(toDateIn(endIso)); setEt(toTimeIn(endIso)); },[sd]); // When start time changes: recompute end preserving duration useEffect(()=>{ if(!mountedRef.current) return; if(!sd||!st) return; const start=buildISO(sd,st); if(!start) return; const durMins = (event && savedDurMins) ? savedDurMins : (localTypes.find(t=>t.id===Number(typeId))?.default_duration_hrs||1)*60; setEd(toDateIn(addHours(start,durMins/60))); setEt(toTimeIn(addHours(start,durMins/60))); },[st]); // Mark mounted after all effects have registered — effects skip on initial render useEffect(()=>{ mountedRef.current = true; },[]); const toggleGrp=id=>setGrps(prev=>{const n=new Set(prev);n.has(id)?n.delete(id):n.add(id);return n;}); const groupsRequired=track; // when tracking, groups are required const handle=async()=>{ if(!title.trim()) return toast('Title required','error'); if(!allDay&&(!sd||!st||!ed||!et)) return toast('Start and end required','error'); if(groupsRequired&&grps.size===0) return toast('Select at least one group for availability tracking','error'); if(ed
{if(e.key==='Enter'&&e.target.tagName!=='TEXTAREA') e.preventDefault();}}> {/* Title */}
setTitle(e.target.value)} autoComplete="new-password" style={{fontSize:20,fontWeight:700,border:'none',borderBottom:'2px solid var(--border)',borderRadius:0,padding:'4px 0',background:'transparent',width:'100%'}}/>
{/* Event Type */}
{isToolManager&&} {showTypeForm&&{setLocalTypes(p=>[...p,et]);setShowTypeForm(false);}} onClose={()=>setShowTypeForm(false)}/>}
{/* Date/Time */}
setSd(e.target.value)} style={{width:150,flexShrink:0}}/> {!allDay&&( <> to { setEt(newEt); userSetEndTime.current=true; if(sd===ed && newEt<=st){ const d=new Date(buildISO(sd,st)); d.setDate(d.getDate()+1); const p=n=>String(n).padStart(2,'0'); setEd(`${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`); } }} style={{width:120,flexShrink:0}}/> {setEd(e.target.value);userSetEndTime.current=true;}} style={{width:150,flexShrink:0}}/> )}
Repeat:
{/* Availability */} {/* Groups — required when tracking */}
{userGroups.length===0 ?
No user groups yet
:userGroups.map(g=>( ))}

{grps.size===0 ? (groupsRequired?'At least one group required for availability tracking':'No groups — event visible to all (if public)') : `${grps.size} group${grps.size!==1?'s':''} selected`}

{/* Visibility — only shown if groups selected OR tracking */} {(grps.size>0||track) && ( )} {/* Location */} setLoc(e.target.value)} autoComplete="new-password" /> {/* Description */}