import { useState, useEffect, useCallback, useRef } 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)}`; } function toDateIn(iso) { return iso?iso.slice(0,10):''; } function toTimeIn(iso) { if(!iso) return ''; const d=new Date(iso); const h=String(d.getHours()).padStart(2,'0'), m=d.getMinutes()<30?'00':'30'; return `${h}:${m}`; } function buildISO(d,t) { return d&&t?`${d}T${t}:00`:''; } function addHours(iso,h){ const d=new Date(iso); d.setMinutes(d.getMinutes()+h*60); // Return local datetime string (YYYY-MM-DDTHH:MM:SS) — NOT toISOString() which shifts to UTC 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; })(); // ── Mini Calendar (desktop) ─────────────────────────────────────────────────── function MiniCalendar({ selected, onChange, eventDates=new Set() }) { const [cur, setCur] = useState(()=>{ const d=new Date(selected||Date.now()); d.setDate(1); return d; }); 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
{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, eventDates=new Set() }) { // 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: filter bar with month nav + keyword + event type const hasFilters = filterKeyword || filterTypeId; return (
{/* Month nav row */}
{MONTHS[m]} {y}
{/* Filter inputs */}
onFilterKeyword(e.target.value)} placeholder="Search events…" 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)} 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 def=selectedDate?selectedDate.toISOString().slice(0,10):new Date().toISOString().slice(0,10); 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):'09:00'); const [ed,setEd]=useState(event?toDateIn(event.end_at):def); const [et,setEt]=useState(event?toTimeIn(event.end_at):'10:00'); 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 // 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(!sd||!st) return; const typ=localTypes.find(t=>t.id===Number(typeId)); const start=buildISO(sd,st); if(!start) return; if(!event) { // Creating new event — always apply duration (default 1hr) const dur=typ?.default_duration_hrs||1; setEd(toDateIn(addHours(start,dur))); setEt(toTimeIn(addHours(start,dur))); userSetEndTime.current = false; } else { // Editing — only update end time if the new type has an explicit duration if(typ?.default_duration_hrs) { setEd(toDateIn(addHours(start,typ.default_duration_hrs))); setEt(toTimeIn(addHours(start,typ.default_duration_hrs))); userSetEndTime.current = false; } // else: keep existing saved end time — do nothing } if(typ?.default_user_group_id&&!event) setGrps(prev=>new Set([...prev,Number(typ.default_user_group_id)])); },[typeId]); // When start date changes: match end date (both modes) unless user set it manually useEffect(()=>{ if(!userSetEndTime.current) setEd(sd); },[sd]); // When start time changes: recompute end using current duration offset useEffect(()=>{ if(!sd||!st) return; if(userSetEndTime.current) return; // user already picked a specific end time — respect it const typ=localTypes.find(t=>t.id===Number(typeId)); const dur=typ?.default_duration_hrs||1; const start=buildISO(sd,st); if(start){ setEd(toDateIn(addHours(start,dur))); setEt(toTimeIn(addHours(start,dur))); } },[st]); 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'); setSaving(true); try{const body={title:title.trim(),eventTypeId:typeId||null,startAt:allDay?`${sd}T00:00:00`:buildISO(sd,st),endAt:allDay?`${ed}T23:59:59`:buildISO(ed,et),allDay,location:loc,description:desc,isPublic:pub,trackAvailability:track,userGroupIds:[...grps],recurrenceRule:recRule||null};const r=event?await api.updateEvent(event.id,body):await api.createEvent(body);onSave(r.event);}catch(e){toast(e.message,'error');}finally{setSaving(false);} }; return (
{if(e.key==='Enter'&&e.target.tagName!=='TEXTAREA') e.preventDefault();}}> {/* Title */}
setTitle(e.target.value)} 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 {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)}/> {/* Description */}