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()}` : '—'}
{const n=new Date(cur);n.setMonth(m-1);setCur(n);}} style={{ background:'none',border:'none',fontSize:20,cursor:'pointer',color:'var(--text-secondary)',padding:'4px 10px' }}>‹
{MONTHS[m]} {y}
{const n=new Date(cur);n.setMonth(m+1);setCur(n);}} style={{ background:'none',border:'none',fontSize:20,cursor:'pointer',color:'var(--text-secondary)',padding:'4px 10px' }}>›
{['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}
;
})}
Cancel
OK
);
}
// ── 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 (
setShowCustom(false)} style={{ background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',display:'flex',alignItems:'center',gap:6,fontSize:14 }}>
Custom recurrence
{onChange(customRule);onClose();}} style={{ background:'none',border:'none',cursor:'pointer',color:'var(--primary)',fontSize:14,fontWeight:700 }}>Done
{(customRule.unit||'week')==='week' && (
Repeats on
{DAY_PILLS.map((d,i)=>{
const key=DAY_KEYS[i], sel=(customRule.byDay||[]).includes(key);
return upd('byDay',sel?(customRule.byDay||[]).filter(x=>x!==key):[...(customRule.byDay||[]),key])} style={{ flex:1,aspectRatio:'1',borderRadius:'50%',border:'1px solid var(--border)',background:sel?'var(--primary)':'transparent',color:sel?'white':'var(--text-primary)',fontSize:12,fontWeight:600,cursor:'pointer',padding:4 }}>{d} ;
})}
)}
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()}>
,
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'}
{saving?'…':'Save'}
{/* 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 */}
{/* Overlays */}
{showStartDate &&
{setSd(v);setShowStartDate(false);}} onClose={()=>setShowStartDate(false)}/>}
{showEndDate && {setEd(v);setShowEndDate(false);}} onClose={()=>setShowEndDate(false)}/>}
{showRecurrence && {setRecRule(v);}} onClose={()=>setShowRecurrence(false)}/>}
{showScopeModal && setShowScopeModal(false)}/>}
{showTypeColourPicker && (
setShowTypeColourPicker(false)} title="Event Type Colour"/>
)}
{showAddType && (
e.target===e.currentTarget&&setShowAddType(false)}>
New Event Type
{setShowAddType(false);setNewTypeName('');setNewTypeColour('#6366f1');}} style={{ background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',fontSize:20,lineHeight:1 }}>✕
setNewTypeName(e.target.value)} autoComplete="new-password" onKeyDown={e=>e.key==='Enter'&&createEventType()}
placeholder="Type name…" autoCorrect="off" autoCapitalize="words" spellCheck={false}
style={{ width:'100%',padding:'12px 14px',border:'1px solid var(--border)',borderRadius:'var(--radius)',fontSize:16,marginBottom:12,boxSizing:'border-box',background:'var(--background)',color:'var(--text-primary)' }} />
Colour
setShowTypeColourPicker(true)} style={{ flex:1,height:40,borderRadius:'var(--radius)',border:'2px solid var(--border)',background:newTypeColour,cursor:'pointer' }}/>
{savingType?'Creating…':'Create Type'}
)}
);
}