import { useState, useEffect, useRef } from 'react'; import ReactDOM from 'react-dom'; import { api } from '../utils/api.js'; import ColourPickerSheet from './ColourPickerSheet.jsx'; import { useToast } from '../contexts/ToastContext.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']; const DAY_PILLS = ['S','M','T','W','T','F','S']; const DAY_KEYS = ['SU','MO','TU','WE','TH','FR','SA']; 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; })(); 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}`; } function parseTypedTime(raw) { if (!raw) return null; const s = raw.trim().toLowerCase(); 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')}`; } 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`; } 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; } 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}`; } // Mobile TimeInput — same behaviour as desktop but styled for mobile inline use function TimeInputMobile({ value, onChange }) { const [open, setOpen] = useState(false); const [inputVal, setInputVal] = useState(fmt12(value)); const wrapRef = useRef(null); const listRef = useRef(null); useEffect(() => { setInputVal(fmt12(value)); }, [value]); useEffect(() => { if (!open || !listRef.current) return; const idx = TIME_SLOTS.findIndex(s => s.value === value); if (idx >= 0) listRef.current.scrollTop = idx * 40 - 40; }, [open, value]); 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 setInputVal(fmt12(value)); setOpen(false); }; return (
setInputVal(e.target.value)} onFocus={() => setOpen(true)} onBlur={e => 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); } }} autoComplete="off" style={{ fontSize: 15, color: 'var(--primary)', fontWeight: 600, background: 'transparent', border: 'none', outline: 'none', cursor: 'text', width: 90 }} /> {open && (
{TIME_SLOTS.map(s => (
{ e.preventDefault(); onChange(s.value); setInputVal(s.label); setOpen(false); }} style={{ padding: '10px 14px', fontSize: 14, cursor: 'pointer', height: 40, boxSizing: 'border-box', background: s.value === value ? 'var(--primary)' : 'transparent', color: s.value === value ? 'white' : 'var(--text-primary)', }} > {s.label}
))}
)}
); } 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())}`; } function toTimeIn(iso) { if (!iso) return ''; const d = new Date(iso); return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; } function buildISO(date, time) { if (!date || !time) return ''; 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`; } // Parse YYYY-MM-DD as local midnight (appending T00:00:00 prevents new Date() treating // a bare date string as UTC, which rolls back one day for timezones behind UTC). function fmtDateDisplay(iso) { if(!iso) return ''; const d=new Date(iso+'T00:00:00'); return `${DAYS[d.getDay()]}, ${SHORT_MONTHS[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`; } function fmtTimeDisplay(slot) { const f=TIME_SLOTS.find(s=>s.value===slot); return f?f.label:slot; } 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…' }, ]; function recurrenceLabel(rule) { if (!rule || !rule.freq) return 'Does not repeat'; if (rule.freq === 'custom') { const unit = (rule.interval||1)===1 ? rule.unit : `${rule.interval} ${rule.unit}s`; return `Every ${unit}`; } return FREQ_OPTIONS.find(o=>o.value===rule.freq)?.label || rule.freq; } // ── Toggle Switch ───────────────────────────────────────────────────────────── function Toggle({ checked, onChange }) { return (
onChange(!checked)} style={{ width:44,height:24,borderRadius:12,background:checked?'var(--primary)':'var(--surface-variant)',cursor:'pointer',position:'relative',transition:'background 0.2s',flexShrink:0 }}>
); } // ── Calendar Picker Overlay ─────────────────────────────────────────────────── function CalendarPicker({ value, onChange, onClose }) { const [cur, setCur] = useState(() => { const d = new Date(value||Date.now()); d.setDate(1); return d; }); const y=cur.getFullYear(), m=cur.getMonth(), first=new Date(y,m,1).getDay(), total=new Date(y,m+1,0).getDate(), today=new Date(); const cells=[]; for(let i=0;ie.target===e.currentTarget&&onClose()}>
Select Date
{selDate ? `${SHORT_MONTHS[selDate.getMonth()]} ${selDate.getDate()}, ${selDate.getFullYear()}` : '—'}
{MONTHS[m]} {y}
{['S','M','T','W','T','F','S'].map((d,i)=>
{d}
)} {cells.map((d,i) => { if(!d) return
; const date=new Date(y,m,d); const isSel = selDate && date.toDateString()===selDate.toDateString(); const isToday = date.toDateString()===today.toDateString(); return
onChange(`${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`)} style={{ textAlign:'center',padding:'8px 4px',borderRadius:'50%',cursor:'pointer',background:isSel?'var(--primary)':'transparent',color:isSel?'white':isToday?'var(--primary)':'var(--text-primary)',fontWeight:isToday&&!isSel?700:400,fontSize:14 }}>{d}
; })}
); } // ── Recurrence Sheet ────────────────────────────────────────────────────────── function RecurrenceSheet({ value, onChange, onClose }) { const rule = value || {}; const [showCustom, setShowCustom] = useState(rule.freq==='custom'); const [customRule, setCustomRule] = useState(rule.freq==='custom' ? rule : {freq:'custom',interval:1,unit:'week',byDay:[],ends:'never',endDate:'',endCount:13}); const selectFreq = (freq) => { if(freq==='custom') { setShowCustom(true); return; } onChange(freq ? {freq} : null); onClose(); }; const upd = (k,v) => setCustomRule(r=>({...r,[k]:v})); if(showCustom) return (
Custom recurrence
Repeats every
upd('interval',Math.max(1,parseInt(e.target.value)||1))} autoComplete="new-password" style={{ width:70,textAlign:'center',fontSize:16 }}/>
{(customRule.unit||'week')==='week' && (
Repeats on
{DAY_PILLS.map((d,i)=>{ const key=DAY_KEYS[i], sel=(customRule.byDay||[]).includes(key); return ; })}
)}
Ends
{[['never','Never'],['on','On'],['after','After']].map(([val,lbl])=>(
upd('ends',val)} style={{ width:20,height:20,borderRadius:'50%',border:`2px solid ${(customRule.ends||'never')===val?'var(--primary)':'var(--border)'}`,display:'flex',alignItems:'center',justifyContent:'center',cursor:'pointer',flexShrink:0 }}> {(customRule.ends||'never')===val&&
}
{lbl} {val==='on'&&(customRule.ends||'never')==='on'&& upd('endDate',e.target.value)} autoComplete="new-password" style={{ width:150 }}/>} {val==='after'&&(customRule.ends||'never')==='after'&&<> upd('endCount',parseInt(e.target.value)||1)} autoComplete="new-password" style={{ width:64,textAlign:'center' }}/>occurrences}
))}
); return (
e.target===e.currentTarget&&onClose()}>
{FREQ_OPTIONS.map(opt=>(
selectFreq(opt.value)} style={{ display:'flex',alignItems:'center',gap:12,padding:'14px 4px',borderBottom:'1px solid var(--border)',cursor:'pointer' }}>
{(rule.freq||'')===(opt.value)&&
}
{opt.label}
))}
); } // ── Row — must be defined OUTSIDE the component to avoid focus loss ───────────── function MobileRow({ icon, label, children, onPress, border=true }) { return (
{icon}
{label &&
{label}
} {children}
); } // ── Recurring choice modal ──────────────────────────────────────────────────── function RecurringChoiceModal({ title, onConfirm, onCancel }) { const [choice, setChoice] = useState('this'); return ReactDOM.createPortal(
e.target===e.currentTarget&&onCancel()}>

{title}

{[['this','This event'],['future','This and following events'],['all','All events']].map(([val,label])=>( ))}
, document.body ); } // ── Main Mobile Event Form ──────────────────────────────────────────────────── export default function MobileEventForm({ event, eventTypes, userGroups, selectedDate, onSave, onCancel, onDelete, isToolManager, userId }) { const toast = useToast(); // Use local date for default, not UTC slice (avoids off-by-one for UTC- timezones) const defDate = selectedDate || new Date(); const _pad = n => String(n).padStart(2,'0'); const def = `${defDate.getFullYear()}-${_pad(defDate.getMonth()+1)}-${_pad(defDate.getDate())}`; const [title, setTitle] = useState(event?.title||''); const [typeId, setTypeId] = useState(event?.event_type_id ? String(event.event_type_id) : ''); const [localTypes, setLocalTypes] = useState(eventTypes); const [showAddType, setShowAddType] = useState(false); const [newTypeName, setNewTypeName] = useState(''); const [newTypeColour, setNewTypeColour] = useState('#6366f1'); const [showTypeColourPicker, setShowTypeColourPicker] = useState(false); const [savingType, setSavingType] = useState(false); 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')}`; })()); // Track the saved event duration (minutes) so editing preserves it const savedDurMins = event ? (new Date(event.end_at) - new Date(event.start_at)) / 60000 : null; // Track previous typeId so we can detect a type change vs start time change const prevTypeIdRef = useRef(event?.event_type_id ? String(event.event_type_id) : ''); const mountedRef = useRef(false); const [allDay, setAllDay] = useState(!!event?.all_day); const [track, setTrack] = useState(!!event?.track_availability); const [isPrivate, setIsPrivate] = useState(event ? !event.is_public : !isToolManager); const [groups, setGroups] = useState(new Set((event?.user_groups||[]).map(g=>g.id))); const [location, setLocation] = useState(event?.location||''); const [description, setDescription] = useState(event?.description||''); const [recRule, setRecRule] = useState(event?.recurrence_rule||null); const [saving, setSaving] = useState(false); const [showScopeModal, setShowScopeModal] = useState(false); // Overlay state const [showStartDate, setShowStartDate] = useState(false); const [showEndDate, setShowEndDate] = useState(false); const [showRecurrence, setShowRecurrence] = useState(false); const [showGroups, setShowGroups] = useState(false); // Sync and initialise typeId 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 createEventType = async () => { if(!newTypeName.trim()) return; setSavingType(true); try { const r = await api.createEventType({ name: newTypeName.trim(), colour: newTypeColour }); setLocalTypes(prev => [...prev, r.eventType]); setTypeId(String(r.eventType.id)); setNewTypeName(''); setShowAddType(false); } catch(e) { toast(e.message, 'error'); } finally { setSavingType(false); } }; // Mark mounted after first render useEffect(() => { mountedRef.current = true; }, []); // Auto-calculate end date/time ONLY when start date, start time, or type actually changes. // Skips initial mount so edit mode fields are never overwritten on open. useEffect(() => { if(!mountedRef.current) return; // skip initial mount — never auto-change on open if(!sd||!st) return; const start = buildISO(sd,st); if(!start) return; const typeChanged = typeId !== prevTypeIdRef.current; prevTypeIdRef.current = typeId; let durMins; if(!event || typeChanged) { // New event or explicit type change: use eventType duration const typ = localTypes.find(t=>t.id===Number(typeId)); durMins = (typ?.default_duration_hrs||1) * 60; } else { // Editing start date/time with same type: preserve saved duration durMins = savedDurMins || 60; } const endIso = addHours(start, durMins/60); setEd(toDateIn(endIso)); setEt(toTimeIn(endIso)); }, [sd, st, typeId]); const handle = () => { if(!title.trim()) return toast('Title required','error'); if(!isToolManager && groups.size === 0) return toast('Select at least one group','error'); const startMs = new Date(buildISO(sd, allDay?'00:00':st)).getTime(); const endMs = new Date(buildISO(ed, allDay?'23:59':et)).getTime(); if(ed < sd) return toast('End date cannot be before start date','error'); if(!allDay && endMs <= startMs && ed === sd) return toast('End time must be after start time, or set a later end date','error'); if(!event && !allDay && new Date(buildISO(sd,st)) < new Date()) return toast('Start date and time cannot be in the past','error'); if(!event && allDay && sd < toDateIn(new Date().toISOString())) return toast('Start date cannot be in the past','error'); if(event && event.recurrence_rule?.freq) { setShowScopeModal(true); return; } doSave('this'); }; const doSave = async (scope) => { setShowScopeModal(false); setSaving(true); try { const body = { title:title.trim(), eventTypeId:typeId||null, startAt:allDay?buildISO(sd,'00:00'):buildISO(sd,st), endAt:allDay?buildISO(ed,'23:59'):buildISO(ed,et), allDay, location, description, isPublic:isToolManager?!isPrivate:false, trackAvailability:track, userGroupIds:[...groups], recurrenceRule:recRule||null }; let r; if (event) { const updateBody = { ...body, recurringScope: scope }; if (event._virtual) updateBody.occurrenceStart = event.start_at; r = await api.updateEvent(event.id, updateBody); } else { r = await api.createEvent(body); } onSave(r.event); } catch(e) { toast(e.message,'error'); } finally { setSaving(false); } }; const currentType = eventTypes.find(t=>t.id===Number(typeId)); return (
{/* Header */}
{event ? 'Edit Event' : 'New Event'}
{/* form wrapper suppresses Chrome Android's autofill chip bar; autoComplete="new-password" on individual inputs is ignored by Chrome but respected on the form element */}
e.preventDefault()} style={{ flex:1,overflowY:'auto' }}> {/* Title */}
setTitle(e.target.value)} autoComplete="new-password" placeholder="Add title" autoCorrect="off" autoCapitalize="sentences" spellCheck={false} style={{ width:'100%',border:'none',background:'transparent',fontSize:22,fontWeight:700,color:'var(--text-primary)',outline:'none' }}/>
{/* Event Type */} } label="Event Type">
{isToolManager && ( )}
{/* All-day toggle */}
All day
{/* Start date/time */}
setShowStartDate(true)} style={{ flex:1,fontSize:15,cursor:'pointer' }}>{fmtDateDisplay(sd)} {!allDay && ( )}
{/* End date/time */}
setShowEndDate(true)} style={{ display:'flex',alignItems:'center',padding:'6px 20px 14px 56px',cursor:'pointer',borderBottom:'1px solid var(--border)' }}> {fmtDateDisplay(ed)} {!allDay && ( { setEt(newEt); if(sd === ed && newEt <= st) { const nextDay = addHours(buildISO(sd, st), 0); const d = new Date(nextDay); d.setDate(d.getDate()+1); const pad = n => String(n).padStart(2,'0'); setEd(`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`); } }} /> )}
{/* Recurrence */} } onPress={()=>setShowRecurrence(true)}> {recurrenceLabel(recRule)} {/* Track Availability */}
Track Availability
{/* Groups */}
setShowGroups(!showGroups)} style={{ display:'flex',alignItems:'center',padding:'14px 20px',borderBottom:'1px solid var(--border)',cursor:'pointer' }}> {groups.size>0 ? `${groups.size} group${groups.size!==1?'s':''} selected` : 'Add Groups'}
{showGroups && userGroups.map(g=>( ))}
{/* Private Event — tool managers can toggle; regular users always private */}
Private Event {isToolManager ? : Always private }
{/* Location */} }> setLocation(e.target.value)} autoComplete="new-password" placeholder="Add location" autoCorrect="off" autoCapitalize="off" spellCheck={false} style={{ width:'100%',border:'none',background:'transparent',fontSize:15,color:'var(--text-primary)',outline:'none' }}/> {/* Description */} } border={false}>