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 { useSocket } from '../contexts/SocketContext.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="off"
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 (
{const n=new Date(cur);n.setMonth(m-1);setCur(n);}}>‹
{MONTHS[m]} {y}
{const n=new Date(cur);n.setMonth(m+1);setCur(n);}}>›
{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
setOpen(v=>!v)} style={{display:'flex',alignItems:'center',justifyContent:'space-between',width:'100%',padding:'10px 16px',background:'none',border:'none',cursor:'pointer',fontSize:14,fontWeight:600,color:'var(--text-primary)'}}>
{MONTHS[m]} {y}
▼
{open && (
onMonthChange(-1)}>‹
onMonthChange(1)}>›
{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 */}
onMonthChange(-1)} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',fontSize:18,padding:'6px 8px',lineHeight:1}}>‹
{MONTHS[m]} {y}
onMonthChange(1)} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',fontSize:18,padding:'6px 8px',lineHeight:1}}>›
{/* Filter accordion toggle */}
setOpen(v=>!v)} style={{background:'none',border:'none',cursor:'pointer',display:'flex',alignItems:'center',gap:4,padding:'6px 8px',color:hasFilters?'var(--primary)':'var(--text-secondary)',fontSize:12,fontWeight:600}}>
{hasFilters ? 'Filtered' : 'Filter'}
▼
{/* Collapsible filter panel */}
{open && (
onFilterKeyword(e.target.value)} autoComplete="off" onFocus={onInputFocus} onBlur={onInputBlur}
placeholder="Search events…" 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'}}/>
onFilterTypeId(e.target.value)}
style={{width:'100%',padding:'7px 8px',border:'1px solid var(--border)',borderRadius:'var(--radius)',background:'var(--background)',color:'var(--text-primary)',fontSize:13,marginBottom:8}}>
All event types
{eventTypes.map(t=>{t.name} )}
onFilterAvailability(e.target.checked)} style={{accentColor:'var(--primary)',width:14,height:14}}/>
Requires Availability
{hasFilters && (
{onFilterKeyword('');onFilterTypeId('');onFilterAvailability(false);onClearFromDate?.();}} style={{fontSize:12,color:'var(--error)',background:'none',border:'none',cursor:'pointer',padding:0}}>✕ Clear all filters
)}
)}
);
}
// ── 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 (
);
}
// ── 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 (
handleFreqChange(e.target.value)} style={{marginBottom: (rule.freq==='custom'||showCustom) ? 12 : 0}}>
{FREQ_OPTIONS.map(o=>{o.label} )}
{(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))} autoComplete="off" style={{width:60,textAlign:'center'}}/>
upd('unit',e.target.value)} style={{flex:1}}>
{['day','week','month','year'].map(u=>{u}{(rule.interval||1)>1?'s':''} )}
{(rule.unit||'week')==='week' && (
Repeats on
{DAY_PILLS.map((d,i)=>{
const key=DAY_KEYS[i], sel=(rule.byDay||[]).includes(key);
return upd('byDay',sel?(rule.byDay||[]).filter(x=>x!==key):[...(rule.byDay||[]),key])} style={{width:32,height:32,borderRadius:'50%',border:'1px solid var(--border)',background:sel?'var(--primary)':'transparent',color:sel?'white':'var(--text-primary)',fontSize:11,fontWeight:600,cursor:'pointer'}}>{d} ;
})}
)}
Ends
{[['never','Never'],['on','On date'],['after','After']].map(([val,lbl])=>(
upd('ends',val)}/>
{lbl}
{val==='on' && (rule.ends||'never')==='on' && upd('endDate',e.target.value)} autoComplete="off" style={{marginLeft:8,flex:1}}/>}
{val==='after' && (rule.ends||'never')==='after' && <> upd('endCount',parseInt(e.target.value)||1)} autoComplete="off" style={{width:64,textAlign:'center',marginLeft:8}}/>occurrences >}
))}
);
}
// ── Shared Row layout — defined OUTSIDE EventForm so it's stable across renders ─
function FormRow({ label, children, required }) {
return (
{label}{required&& * }
{children}
);
}
// ── Recurring choice modal ────────────────────────────────────────────────────
function RecurringChoiceModal({ title, onConfirm, onCancel }) {
const [choice, setChoice] = useState('this');
return ReactDOM.createPortal(
e.target===e.currentTarget&&onCancel()}>
,
document.body
);
}
// ── Confirm modal (non-recurring delete) ──────────────────────────────────────
function ConfirmModal({ title, message, confirmLabel='Delete', onConfirm, onCancel }) {
return ReactDOM.createPortal(
e.target===e.currentTarget&&onCancel()}>
{title}
{message}
Cancel
{confirmLabel}
,
document.body
);
}
// ── Event Form ────────────────────────────────────────────────────────────────
function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCancel, onDelete, isToolManager, userId }) {
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:!!isToolManager);
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);
const [showScopeModal,setShowScopeModal]=useState(false);
// 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 || !isToolManager; // tracking requires groups; non-managers always require groups
const handle=()=>{
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','error');
if(ed{
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:loc,description:desc,isPublic:isToolManager?pub:false,trackAvailability:track,userGroupIds:[...grps],recurrenceRule:recRule||null};
const r=event?await api.updateEvent(event.id,{...body,recurringScope:scope}):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)} autoComplete="off" autoCorrect="off" autoCapitalize="sentences" style={{fontSize:20,fontWeight:700,border:'none',borderBottom:'2px solid var(--border)',borderRadius:0,padding:'4px 0',background:'transparent',width:'100%'}}/>
{/* Event Type */}
setTypeId(e.target.value)} style={{flex:1}}>
— Select type —
{localTypes.map(t=>{t.name} )}
{isToolManager&&setShowTypeForm(v=>!v)}>{showTypeForm?'Cancel':'+ Type'} }
{showTypeForm&&{setLocalTypes(p=>[...p,et]);setShowTypeForm(false);}} onClose={()=>setShowTypeForm(false)}/>}
{/* Date/Time */}
{/* Availability */}
{setTrack(e.target.checked);if(!e.target.checked) setPub(true);}}/>
Track availability for assigned groups
{/* Groups — required when tracking */}
{userGroups.length===0
?
No user groups yet
:userGroups.map(g=>(
toggleGrp(g.id)} style={{accentColor:'var(--primary)'}}/>
{g.name}
))}
{grps.size===0
? (groupsRequired?'At least one group required':'No groups — event visible to all (if public)')
: `${grps.size} group${grps.size!==1?'s':''} selected`}
{/* Visibility — only tool managers can set; regular users always create private events */}
{isToolManager && (grps.size>0||track) && (
setPub(!e.target.checked)}/>
Viewable by selected groups only (private)
)}
{/* Location */}
setLoc(e.target.value)} autoComplete="off" autoCorrect="off" autoCapitalize="off" />
{/* Description */}
{saving?'Saving…':event?'Save Changes':'Create Event'}
Cancel
{event&&(isToolManager||(userId&&event.created_by===userId))&&onDelete(event)}>Delete }
{showScopeModal&&setShowScopeModal(false)}/>}
>
);
}
// ── Event Detail Modal ────────────────────────────────────────────────────────
function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isToolManager, userId }) {
const toast=useToast();
const [myResp,setMyResp]=useState(event.my_response);
const [myNote,setMyNote]=useState(event.my_note||'');
const [noteInput,setNoteInput]=useState(event.my_note||'');
const [noteSaving,setNoteSaving]=useState(false);
const [avail,setAvail]=useState(event.availability||[]);
const [expandedNotes,setExpandedNotes]=useState(new Set());
// Sync when parent reloads event after availability change
useEffect(()=>{
setMyResp(event.my_response);
setAvail(event.availability||[]);
setMyNote(event.my_note||'');
setNoteInput(event.my_note||'');
},[event]);
const counts={going:0,maybe:0,not_going:0};
avail.forEach(r=>{if(counts[r.response]!==undefined)counts[r.response]++;});
const isPast = !!event.end_at && new Date(event.end_at) < new Date();
const noteChanged = noteInput.trim() !== myNote.trim();
const handleResp=async resp=>{
const prev=myResp;
const next=myResp===resp?null:resp;
setMyResp(next); // optimistic update
try{
if(prev===resp){await api.deleteAvailability(event.id);}else{await api.setAvailability(event.id,resp,noteInput.trim()||null);}
onAvailabilityChange?.(next); // triggers parent re-fetch to update avail list
}catch(e){setMyResp(prev);toast(e.message,'error');} // rollback on error
};
const handleNoteSave=async()=>{
if(!myResp) return; // no response row to attach note to
setNoteSaving(true);
try{
await api.setAvailabilityNote(event.id,noteInput.trim()||null);
setMyNote(noteInput.trim());
onAvailabilityChange?.(myResp); // re-fetch to update responses list
}catch(e){toast(e.message,'error');}finally{setNoteSaving(false);}
};
const toggleNote=id=>setExpandedNotes(prev=>{const s=new Set(prev);s.has(id)?s.delete(id):s.add(id);return s;});
return ReactDOM.createPortal(
e.target===e.currentTarget&&onClose()}>
{event.event_type&&}
{event.title}
{event.event_type?.name&&{event.event_type.name} }
{event.is_public
? Public Event
: Private Event }
{(isToolManager||(userId&&event.created_by===userId))&&!isPast&&{onClose();onEdit();}}>Edit }
{fmtDate(new Date(event.start_at))}{!event.all_day&&` · ${fmtRange(event.start_at,event.end_at)}`}
{event.recurrence_rule?.freq&&(
{recurrenceLabel(event.recurrence_rule)}
)}
{event.location&&
}
{event.description&&
{event.description}
}
{(event.user_groups||[]).length>0&&
{event.user_groups.map(g=>g.name).join(', ')}
}
{!!event.track_availability&&(
Your Availability
{isPast ? (
Past event — availability is read-only.
) : (
<>
{Object.entries(RESP_LABEL).map(([key,label])=>(
handleResp(key)} style={{flex:1,padding:'9px 4px',borderRadius:'var(--radius)',border:`2px solid ${RESP_COLOR[key]}`,background:myResp===key?RESP_COLOR[key]:'transparent',color:myResp===key?'white':RESP_COLOR[key],fontSize:13,fontWeight:600,cursor:'pointer',transition:'all 0.15s'}}>
{myResp===key?'✓ ':''}{label}
))}
setNoteInput(e.target.value.slice(0,20))}
placeholder="Add a note (optional)"
maxLength={20}
style={{flex:1,minWidth:0,padding:'7px 10px',borderRadius:'var(--radius)',border:'1px solid var(--border)',background:'var(--surface)',color:'var(--text-primary)',fontSize:13,outline:'none'}}
/>
{noteInput.length}/20
{myResp&¬eChanged&&(
{noteSaving?'…':'Save'}
)}
>
)}
{(isToolManager||avail.length>0)&&(
<>
Responses
{Object.entries(counts).map(([k,n])=>{n} {RESP_LABEL[k]} )}
{isToolManager&&{event.no_response_count||0} No response }
{avail.length>0&&(
{avail.map(r=>{
const hasNote=!!(r.note&&r.note.trim());
const expanded=expandedNotes.has(r.user_id);
return(
toggleNote(r.user_id):undefined}
>
{r.display_name||r.name}
{hasNote&&(
)}
{RESP_LABEL[r.response]}
{hasNote&&expanded&&(
{r.note}
)}
);
})}
)}
>
)}
)}
,
document.body
);
}
// ── Event Types Panel ─────────────────────────────────────────────────────────
function EventTypesPanel({ eventTypes, userGroups, onUpdated, isMobile=false }) {
const toast=useToast();
const [editingType,setEditingType]=useState(null);
const [showForm,setShowForm]=useState(false);
// Mobile bottom sheet state
const [sheetMode,setSheetMode]=useState(null); // null | 'create' | 'edit'
const [sheetName,setSheetName]=useState('');
const [sheetColour,setSheetColour]=useState('#6366f1');
const [showColourPicker,setShowColourPicker]=useState(false);
const [sheetSaving,setSheetSaving]=useState(false);
const openCreateSheet=()=>{setSheetName('');setSheetColour('#6366f1');setSheetMode('create');};
const openEditSheet=(et)=>{setSheetName(et.name);setSheetColour(et.colour);setEditingType(et);setSheetMode('edit');};
const closeSheet=()=>{setSheetMode(null);setEditingType(null);};
const saveSheet=async()=>{
if(!sheetName.trim()) return;
setSheetSaving(true);
try{
if(sheetMode==='create') await api.createEventType({name:sheetName.trim(),colour:sheetColour});
else await api.updateEventType(editingType.id,{name:sheetName.trim(),colour:sheetColour});
onUpdated(); closeSheet();
}catch(e){} finally{setSheetSaving(false);}
};
const handleDel=async et=>{
if(!confirm(`Delete "${et.name}"?`)) return;
try{await api.deleteEventType(et.id);toast('Deleted','success');onUpdated();}catch(e){toast(e.message,'error');}
};
return (
Event Types
isMobile?openCreateSheet():(setShowForm(v=>!v),setEditingType(null))}>+ New Type
{!isMobile&&showForm&&!editingType&&onUpdated()} onClose={()=>setShowForm(false)}/>}
{eventTypes.map(et=>(
{et.name}
{et.default_duration_hrs&&{et.default_duration_hrs}hr default }
{!et.is_protected?(
isMobile?openEditSheet(et):(setEditingType(et),setShowForm(true))}>Edit
{!isMobile&&showForm&&editingType?.id===et.id&&{onUpdated();setShowForm(false);setEditingType(null);}} onClose={()=>{setShowForm(false);setEditingType(null);}}/>}
handleDel(et)}>Delete
):{et.is_default?'Default':'Protected'} }
))}
{/* Mobile bottom sheet for create/edit event type */}
{isMobile && sheetMode && (
e.target===e.currentTarget&&closeSheet()}>
{sheetMode==='create'?'New Event Type':'Edit Event Type'}
✕
setSheetName(e.target.value)} autoComplete="off" autoCorrect="off" onKeyDown={e=>e.key==='Enter'&&saveSheet()} placeholder="Type name…"
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
setShowColourPicker(true)} style={{flex:1,height:40,borderRadius:'var(--radius)',border:'2px solid var(--border)',background:sheetColour,cursor:'pointer'}}/>
{sheetSaving?'Saving…':'Save'}
)}
{showColourPicker && (
setShowColourPicker(false)} title="Event Type Colour"/>
)}
);
}
// ── Bulk Import Panel ─────────────────────────────────────────────────────────
function BulkImportPanel({ onImported, onCancel }) {
const toast=useToast();
const [rows,setRows]=useState(null);
const [skipped,setSkipped]=useState(new Set());
const [saving,setSaving]=useState(false);
const handleFile=async e=>{const file=e.target.files[0];if(!file)return;try{const r=await api.importPreview(file);if(r.error)return toast(r.error,'error');setRows(r.rows);setSkipped(new Set(r.rows.filter(r=>r.duplicate||r.error).map(r=>r.row)));}catch{toast('Upload failed','error');}};
const handleImport=async()=>{setSaving(true);try{const toImport=rows.filter(r=>!skipped.has(r.row)&&!r.error);const{imported}=await api.importConfirm(toImport);toast(`${imported} event${imported!==1?'s':''} imported`,'success');onImported();}catch(e){toast(e.message,'error');}finally{setSaving(false);}};
return (
Bulk Event Import
CSV: Event Title, start_date (YYYY-MM-DD), start_time (HH:MM), event_location, event_type, default_duration
{rows&&(<>
{['','Row','Title','Start','End','Type','Dur','Status'].map(h=>{h} )} {rows.map(r=>( setSkipped(p=>{const n=new Set(p);n.has(r.row)?n.delete(r.row):n.add(r.row);return n;})}/>{r.row} {r.title} {r.startAt?.slice(0,16).replace('T',' ')} {r.endAt?.slice(0,16).replace('T',' ')} {r.typeName} {r.durHrs}hr {r.error?{r.error} :r.duplicate?⚠ Duplicate :✓ Ready } ))}
{saving?'Importing…':`Import ${rows.filter(r=>!skipped.has(r.row)&&!r.error).length} events`} Cancel
>)}
);
}
// ── Calendar Views ────────────────────────────────────────────────────────────
// Parse keyword string into array of terms.
// Quoted phrases ("foo bar") count as one term; space-separated words are individual OR terms.
// ── Recurring event expansion ─────────────────────────────────────────────────
// Generates virtual occurrences from a recurring event's rule.
// Returns array of cloned event objects with adjusted start_at / end_at.
function expandRecurringEvent(ev, rangeStart, rangeEnd) {
const rule = ev.recurrence_rule;
if (!rule || !rule.freq) return [ev];
const origStart = new Date(ev.start_at);
const origEnd = new Date(ev.end_at);
const durMs = origEnd - origStart;
const occurrences = [];
let cur = new Date(origStart);
let count = 0;
const maxOccurrences = 500; // safety cap
// Step size based on freq/unit
const freq = rule.freq === 'custom' ? rule.unit : rule.freq.replace('ly','').replace('dai','day').replace('week','week').replace('month','month').replace('year','year');
const interval = rule.interval || 1;
const step = (d) => {
const n = new Date(d);
if (freq === 'day' || rule.freq === 'daily') n.setDate(n.getDate() + interval);
else if (freq === 'week' || rule.freq === 'weekly') n.setDate(n.getDate() + 7 * interval);
else if (freq === 'month' || rule.freq === 'monthly') n.setMonth(n.getMonth() + interval);
else if (freq === 'year' || rule.freq === 'yearly') n.setFullYear(n.getFullYear() + interval);
else n.setDate(n.getDate() + 7); // fallback weekly
return n;
};
// For weekly with byDay, generate per-day occurrences
const byDay = rule.byDay && rule.byDay.length > 0 ? rule.byDay : null;
const DAY_MAP = {SU:0,MO:1,TU:2,WE:3,TH:4,FR:5,SA:6};
// Determine end condition
const endDate = rule.ends === 'on' && rule.endDate ? new Date(rule.endDate + 'T23:59:59') : null;
const endCount = rule.ends === 'after' ? (rule.endCount || 13) : null;
// totalOcc counts ALL occurrences from origStart regardless of range,
// so endCount is respected even when rangeStart is after the event's start.
let totalOcc = 0;
// Start from original and step forward
while (count < maxOccurrences) {
// Check end conditions
if (endDate && cur > endDate) break;
if (endCount && totalOcc >= endCount) break;
if (cur > rangeEnd) break;
if (byDay && (rule.freq === 'weekly' || freq === 'week')) {
// Emit one occurrence per byDay in this week
const weekStart = new Date(cur);
weekStart.setDate(cur.getDate() - cur.getDay()); // Sunday of this week
for (const dayKey of byDay) {
if (endCount && totalOcc >= endCount) break;
const dayNum = DAY_MAP[dayKey];
const occ = new Date(weekStart);
occ.setDate(weekStart.getDate() + dayNum);
occ.setHours(origStart.getHours(), origStart.getMinutes(), origStart.getSeconds());
if (!endDate || occ <= endDate) {
totalOcc++;
if (occ >= rangeStart && occ <= rangeEnd) {
const occEnd = new Date(occ.getTime() + durMs);
occurrences.push({...ev, start_at: occ.toISOString(), end_at: occEnd.toISOString(), _virtual: true});
}
}
}
cur = step(cur);
} else {
totalOcc++;
if (cur >= rangeStart && cur <= rangeEnd) {
const occEnd = new Date(cur.getTime() + durMs);
occurrences.push({...ev, start_at: cur.toISOString(), end_at: occEnd.toISOString(), _virtual: cur.toISOString() !== ev.start_at});
}
cur = step(cur);
}
count++;
}
// Return only occurrences that fell within the range — never return the raw event
// as a fallback, since it may be before rangeStart (a past recurring event that
// has no future occurrences in this window should simply not appear).
return occurrences;
}
// Expand all recurring events in a list within a date range
function expandEvents(events, rangeStart, rangeEnd) {
const result = [];
for (const ev of events) {
if (ev.recurrence_rule?.freq) {
const expanded = expandRecurringEvent(ev, rangeStart, rangeEnd);
result.push(...expanded);
} else {
result.push(ev);
}
}
// Sort by start_at
result.sort((a,b) => new Date(a.start_at) - new Date(b.start_at));
return result;
}
// Parse keyword string into match descriptors.
// Quoted terms ("mount") -> exact whole-word match only.
// Unquoted terms (mount) -> word-boundary prefix: term must start a word,
// so "mount" matches "mountain" but "mounte" does not.
function parseKeywords(raw) {
const terms = [];
const re = /"([^"]+)"|(\S+)/g;
let match;
while((match = re.exec(raw)) !== null) {
if (match[1] !== undefined) {
terms.push({ term: match[1].toLowerCase(), exact: true });
} else {
terms.push({ term: match[2].toLowerCase(), exact: false });
}
}
return terms;
}
function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filterTypeId='', filterAvailability=false, filterFromDate=null, isMobile=false }) {
const y=selectedDate.getFullYear(), m=selectedDate.getMonth();
const today=new Date(); today.setHours(0,0,0,0);
const todayRef = useRef(null);
useEffect(()=>{
if(todayRef.current) todayRef.current.scrollIntoView({ block:'start', behavior:'instant' });
},[selectedDate.getFullYear(), selectedDate.getMonth()]);
const terms=parseKeywords(filterKeyword);
const hasFilters = terms.length > 0 || !!filterTypeId || filterAvailability;
// Only keyword/availability filters should shift the date window to today-onwards.
// Type filter is for browsing within the current time window, not jumping to future-only.
const hasDateShiftingFilters = terms.length > 0 || filterAvailability;
// Expand recurring events over a wide range (2 years forward)
const farFuture = new Date(today); farFuture.setFullYear(farFuture.getFullYear()+2);
const expandedEvents = expandEvents(events, new Date(y,m,1), farFuture);
const now = new Date(); // exact now for end-time comparison
const isCurrentMonth = y === today.getFullYear() && m === today.getMonth();
// from/to logic:
// - filterFromDate set (mini-calendar click): show from that date to end of its month
// - keyword/availability filters: show from today to far future (find upcoming matches)
// - type filter only: use normal month window (same events, just filtered by type)
// - no filters: show full month, including past events in grey
let from, to;
if (filterFromDate) {
const fd = new Date(filterFromDate); fd.setHours(0,0,0,0);
from = fd;
to = new Date(fd.getFullYear(), fd.getMonth()+1, 0, 23, 59, 59);
} else if (hasDateShiftingFilters) {
from = today;
to = new Date(9999,11,31);
} else {
// Full month — start of month to end of month, past events included (shown grey)
from = new Date(y,m,1);
to = new Date(y,m+1,0,23,59,59);
}
const filtered=expandedEvents.filter(e=>{
const s=new Date(e.start_at);
if(sto) return false;
if(filterTypeId && String(e.event_type_id)!==String(filterTypeId)) return false;
if(filterAvailability && !e.track_availability) return false;
if(terms.length>0) {
const haystack=[e.title||'',e.location||'',e.description||''].join(' ').toLowerCase();
const matches = ({ term, exact }) => {
if (exact) {
// Quoted: whole-word match only — term must be surrounded by word boundaries
return new RegExp('\\b' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b').test(haystack);
} else {
// Unquoted: prefix-of-word match — term must appear at the start of a word
return new RegExp('\\b' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).test(haystack);
}
};
if(!terms.some(matches)) return false;
}
return true;
});
const emptyMsg = hasFilters
? 'No events match your filters'
: new Date(y,m+1,0) < today
? `No events — ${MONTHS[m]} ${y} is in the past`
: `No events in ${MONTHS[m]} ${y}`;
if(!filtered.length) return {emptyMsg}
;
let todayMarked = false;
return <>{filtered.map(e=>{
const s=new Date(e.start_at);
const end=new Date(e.end_at);
const sDay=new Date(s); sDay.setHours(0,0,0,0);
const isFirstTodayOrFuture = !todayMarked && sDay >= today;
if(isFirstTodayOrFuture) todayMarked = true;
const isPast = !e.all_day && end < now; // event fully ended
const col = isPast ? '#9ca3af' : (e.event_type?.colour||'#9ca3af');
const textColor = isPast ? 'var(--text-tertiary)' : 'var(--text-primary)';
const subColor = isPast ? 'var(--text-tertiary)' : 'var(--text-secondary)';
// Use CSS media query breakpoint logic — compact below 640px regardless of isMobile prop
// so responsive desktop doesn't compact when there's plenty of room
const compact = isMobile; // isMobile is only true on genuine mobile, not responsive desktop
const rowPad=compact?'12px 14px':'14px 20px';
const rowGap=compact?10:20;
const datW=compact?36:44; const datFs=compact?20:22; const datSFs=compact?10:11;
const timeW=compact?80:100; const timeGap=compact?5:8; const timeFs=compact?11:13;
const dotSz=compact?8:10;
const availIcon = !!e.track_availability && (
e.my_response
? RESP_ICON[e.my_response](isPast ? '#9ca3af' : RESP_COLOR[e.my_response])
: isPast
?
: BELL_ICON
);
return(
onSelect(e)} style={{display:'flex',alignItems:'center',gap:rowGap,padding:rowPad,borderBottom:'1px solid var(--border)',cursor:'pointer',opacity:isPast?0.7:1}} onMouseEnter={el=>el.currentTarget.style.background='var(--background)'} onMouseLeave={el=>el.currentTarget.style.background=''}>
{/* Date column */}
{s.getDate()}
{SHORT_MONTHS[s.getMonth()]}
{DAYS[s.getDay()]}
{/* Time + dot column */}
{e.all_day?All day :{fmtTime(e.start_at)} – {fmtTime(e.end_at)} }
{/* Title + meta column */}
{e.title}
{availIcon}
{(e.event_type?.name||e.location) && (
{e.event_type?.name&&{e.event_type.name}{e.location?' ·':''} }
{e.location&&{e.location} }
)}
);
})}>;
}
const HOUR_H = 52; // px per hour row
const DAY_START = 0; // show from midnight
const DAY_END = 24; // to midnight
function eventTopOffset(startDate) {
const h=startDate.getHours(), m=startDate.getMinutes();
return (h - DAY_START)*HOUR_H + (m/60)*HOUR_H;
}
function eventHeightPx(startDate, endDate) {
const diffMs=endDate-startDate;
const diffHrs=diffMs/(1000*60*60);
return Math.max(diffHrs*HOUR_H, HOUR_H*0.4); // min 40% of one hour row
}
// Compute column assignments for events that overlap in time.
// Returns array of {event, col, totalCols} where col 0..totalCols-1.
function layoutEvents(evs) {
if (!evs.length) return [];
const sorted = [...evs].sort((a,b) => new Date(a.start_at) - new Date(b.start_at));
const cols = []; // each col is array of events placed there
const result = [];
for (const e of sorted) {
const eStart = new Date(e.start_at), eEnd = new Date(e.end_at);
// Find first column where this event doesn't overlap with the last event
let placed = false;
for (let ci = 0; ci < cols.length; ci++) {
const lastInCol = cols[ci][cols[ci].length - 1];
if (new Date(lastInCol.end_at) <= eStart) {
cols[ci].push(e);
result.push({ event: e, col: ci });
placed = true;
break;
}
}
if (!placed) {
cols.push([e]);
result.push({ event: e, col: cols.length - 1 });
}
}
// Determine totalCols for each event = max cols among overlapping group
for (const item of result) {
const eStart = new Date(item.event.start_at), eEnd = new Date(item.event.end_at);
let maxCol = item.col;
for (const other of result) {
const oStart = new Date(other.event.start_at), oEnd = new Date(other.event.end_at);
if (oStart < eEnd && oEnd > eStart) maxCol = Math.max(maxCol, other.col);
}
item.totalCols = maxCol + 1;
}
return result;
}
function DayView({ events: rawEvents, selectedDate, onSelect, onSwipe }) {
const dayStart = new Date(selectedDate); dayStart.setHours(0,0,0,0);
const dayEnd = new Date(selectedDate); dayEnd.setHours(23,59,59,999);
const events = expandEvents(rawEvents, dayStart, dayEnd);
const hours=Array.from({length:DAY_END - DAY_START},(_,i)=>i+DAY_START);
const day=events.filter(e=>sameDay(new Date(e.start_at),selectedDate));
const allDayEvs=day.filter(e=>e.all_day);
const timedEvs=day.filter(e=>!e.all_day);
const tzOff=-new Date().getTimezoneOffset();
const tzLabel=`GMT${tzOff>=0?'+':'-'}${String(Math.floor(Math.abs(tzOff)/60)).padStart(2,'0')}`;
const scrollRef = useRef(null);
const touchRef = useRef({ x:0, y:0 });
useEffect(()=>{
if(!scrollRef.current) return;
const now = new Date();
const topPx = Math.max(0, now.getHours() * HOUR_H + (now.getMinutes() / 60) * HOUR_H - 2 * HOUR_H);
scrollRef.current.scrollTop = topPx;
},[selectedDate]);
const fmtHour = h => h===0?'12 AM':h<12?`${h} AM`:h===12?'12 PM':`${h-12} PM`;
const handleTouchStart = e => { touchRef.current = { x:e.touches[0].clientX, y:e.touches[0].clientY }; };
const handleTouchEnd = e => {
const dx = e.changedTouches[0].clientX - touchRef.current.x;
const dy = Math.abs(e.changedTouches[0].clientY - touchRef.current.y);
// Only trigger horizontal swipe if clearly horizontal (dx > dy) and > 60px
// and not from left edge (< 30px = OS back gesture)
if(Math.abs(dx) > 60 && Math.abs(dx) > dy * 1.5 && touchRef.current.x > 30) {
onSwipe?.(dx < 0 ? 1 : -1); // left = next day, right = prev day
}
};
return(
{DAYS[selectedDate.getDay()]}
{selectedDate.getDate()}
{tzLabel}
{allDayEvs.map(e=>(
onSelect(e)} style={{background:e.event_type?.colour||'#6366f1',color:'white',borderRadius:3,padding:'2px 6px',fontSize:12,fontWeight:600,cursor:'pointer',whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>
{e.title}
))}
{hours.map(h=>(
{fmtHour(h)}
))}
{layoutEvents(timedEvs).map(({event:e,col,totalCols})=>{
const s=new Date(e.start_at), en=new Date(e.end_at);
const top=eventTopOffset(s), height=eventHeightPx(s,en);
return(
onSelect(e)} style={{
position:'absolute',
left: `calc(64px + ${col / totalCols * 100}% - ${col * 64 / totalCols}px)`,
right: `calc(${(totalCols - col - 1) / totalCols * 100}% - ${(totalCols - col - 1) * 64 / totalCols}px + 4px)`,
top, height,
background:e.event_type?.colour||'#6366f1', color:'white',
borderRadius:5, padding:'3px 6px', cursor:'pointer',
fontSize:11, fontWeight:600, overflow:'hidden',
boxShadow:'0 1px 3px rgba(0,0,0,0.2)',
zIndex: col,
}}>
{e.title}
{height>28&&
{fmtRange(e.start_at,e.end_at)}
}
);
})}
);
}
function WeekView({ events: rawEvents, selectedDate, onSelect }) {
const _ws = weekStart(selectedDate);
const _we = new Date(_ws); _we.setDate(_we.getDate()+6); _we.setHours(23,59,59,999);
const events = expandEvents(rawEvents, _ws, _we);
const ws=weekStart(selectedDate), days=Array.from({length:7},(_,i)=>{const d=new Date(ws);d.setDate(d.getDate()+i);return d;});
const hours=Array.from({length:DAY_END - DAY_START},(_,i)=>i+DAY_START), today=new Date();
const tzOff=-new Date().getTimezoneOffset();
const tzLabel=`GMT${tzOff>=0?'+':'-'}${String(Math.floor(Math.abs(tzOff)/60)).padStart(2,'0')}`;
const scrollRef = useRef(null);
const touchRef = useRef({ x:0, y:0 });
useEffect(()=>{
if(!scrollRef.current) return;
const now = new Date();
const topPx = Math.max(0, now.getHours() * HOUR_H + (now.getMinutes() / 60) * HOUR_H - 2 * HOUR_H);
scrollRef.current.scrollTop = topPx;
},[selectedDate]);
const fmtHour = h => h===0?'12 AM':h<12?`${h} AM`:h===12?'12 PM':`${h-12} PM`;
const handleTouchStart = e => { touchRef.current = { x:e.touches[0].clientX, y:e.touches[0].clientY }; };
const handleTouchEnd = e => {
const dx = e.changedTouches[0].clientX - touchRef.current.x;
const dy = Math.abs(e.changedTouches[0].clientY - touchRef.current.y);
// Only trigger horizontal swipe if clearly horizontal (dx > dy) and > 60px
// and not from left edge (< 30px = OS back gesture)
if(Math.abs(dx) > 60 && Math.abs(dx) > dy * 1.5 && touchRef.current.x > 30) {
onSwipe?.(dx < 0 ? 1 : -1); // left = next day, right = prev day
}
};
return(
{/* Day headers */}
{days.map((d,i)=>
{DAYS[d.getDay()]} {d.getDate()}
)}
{/* All-day row */}
{tzLabel}
{days.map((d,di)=>{
const adEvs=events.filter(e=>e.all_day&&sameDay(new Date(e.start_at),d));
return(
{adEvs.map(e=>(
onSelect(e)} style={{background:e.event_type?.colour||'#6366f1',color:'white',borderRadius:3,padding:'2px 4px',fontSize:10,fontWeight:600,cursor:'pointer',whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>
{e.title}
))}
);
})}
{/* Scrollable time grid */}
{/* Time labels column */}
{hours.map(h=>(
{fmtHour(h)}
))}
{/* Day columns */}
{days.map((d,di)=>{
const dayEvs=events.filter(e=>!e.all_day&&sameDay(new Date(e.start_at),d));
return(
{hours.map(h=>
)}
{layoutEvents(dayEvs).map(({event:e,col,totalCols})=>{
const s=new Date(e.start_at),en=new Date(e.end_at);
const top=eventTopOffset(s), height=eventHeightPx(s,en);
const pctLeft = `${col / totalCols * 100}%`;
const pctWidth = `calc(${100 / totalCols}% - 4px)`;
return(
onSelect(e)} style={{
position:'absolute', top, height,
left: pctLeft, width: pctWidth,
background:e.event_type?.colour||'#6366f1',color:'white',
borderRadius:3,padding:'2px 4px',cursor:'pointer',
fontSize:11,fontWeight:600,overflow:'hidden',
boxShadow:'0 1px 2px rgba(0,0,0,0.2)',
zIndex: col,
}}>
{e.title}
{height>26&&
{fmtTime(e.start_at)}-{fmtTime(e.end_at)}
}
);
})}
);
})}
);
}
const MONTH_CELL_H = 90; // fixed cell height in px
function MonthView({ events: rawEvents, selectedDate, onSelect, onSelectDay }) {
const y=selectedDate.getFullYear(), m=selectedDate.getMonth(), first=new Date(y,m,1).getDay(), total=daysInMonth(y,m), today=new Date();
const monthStart = new Date(y,m,1), monthEnd = new Date(y,m+1,0,23,59,59,999);
const events = expandEvents(rawEvents, monthStart, monthEnd);
const cells=[]; for(let i=0;i
{weeks.map((week,wi)=>(
{week.map((d,di)=>{
if(!d) return
;
const date=new Date(y,m,d), dayEvs=events.filter(e=>sameDay(new Date(e.start_at),date)), isToday=sameDay(date,today);
return(
onSelectDay(date)} style={{borderRight:'1px solid var(--border)',borderBottom:'1px solid var(--border)',minHeight:MONTH_CELL_H,padding:'3px',cursor:'pointer',overflow:'hidden',display:'flex',flexDirection:'column'}}
onMouseEnter={el=>el.currentTarget.style.background='var(--background)'} onMouseLeave={el=>el.currentTarget.style.background=''}>
{d}
{dayEvs.slice(0,2).map(e=>(
{ev.stopPropagation();onSelect(e);}} style={{
background:e.event_type?.colour||'#6366f1',color:'white',
borderRadius:3,padding:'1px 4px',fontSize:11,marginBottom:1,cursor:'pointer',
whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis',flexShrink:0,
}}>
{e.all_day?All Day: :{fmtTime(e.start_at)} }{e.title}
))}
{dayEvs.length>2&&
+{dayEvs.length-2} more
}
);
})}
))}
);
}
// ── Main Schedule Page ────────────────────────────────────────────────────────
export default function SchedulePage({ isToolManager, isMobile, onProfile, onHelp, onAbout }) {
const { user } = useAuth();
const toast = useToast();
const { socket } = useSocket();
// Mobile: only day + schedule views
const allowedViews = isMobile ? ['schedule','day'] : ['schedule','day','week','month'];
const [view, setView] = useState('schedule');
const [selDate, setSelDate] = useState(new Date());
const [events, setEvents] = useState([]);
const [eventTypes, setEventTypes] = useState([]);
const [userGroups, setUserGroups] = useState([]);
const [panel, setPanel] = useState('calendar');
const [editingEvent, setEditingEvent] = useState(null);
const [filterKeyword, setFilterKeyword] = useState('');
const [filterTypeId, setFilterTypeId] = useState('');
const [filterAvailability, setFilterAvailability] = useState(false);
const [filterFromDate, setFilterFromDate] = useState(null); // set by mini-calendar click
const [inputFocused, setInputFocused] = useState(false); // hides footer when keyboard open on mobile
const [detailEvent, setDetailEvent] = useState(null);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [mobilePanel, setMobilePanel] = useState(null); // null | 'eventForm' | 'groupManager'
const createRef = useRef(null);
const contentRef = useRef(null);
const load = useCallback(() => {
const ugPromise = isToolManager ? api.getUserGroups() : api.getMyScheduleGroups();
Promise.all([api.getEvents(), api.getEventTypes(), ugPromise])
.then(([ev,et,ug]) => { setEvents(ev.events||[]); setEventTypes(et.eventTypes||[]); setUserGroups(ug.groups||[]); setLoading(false); })
.catch(() => setLoading(false));
}, [isToolManager]);
useEffect(() => { load(); }, [load]);
// Re-fetch when removed from a user group (private event visibility may change)
useEffect(() => {
if (!socket) return;
socket.on('schedule:refresh', load);
return () => socket.off('schedule:refresh', load);
}, [socket, load]);
// Reset scroll to top on date/view change; schedule view scrolls to today via ScheduleView's own effect
useEffect(() => { if (contentRef.current && view !== 'schedule') contentRef.current.scrollTop = 0; }, [selDate, view]);
useEffect(() => {
if (!createOpen) return;
const h = e => { if (createRef.current && !createRef.current.contains(e.target)) setCreateOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [createOpen]);
const eventDates = new Set(events.map(e => {
if (!e.start_at) return null;
const d = new Date(e.start_at);
const pad = n => String(n).padStart(2,'0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
}).filter(Boolean));
const navDate = dir => {
const d = new Date(selDate);
if (view==='day') d.setDate(d.getDate()+dir);
else if (view==='week') d.setDate(d.getDate()+dir*7);
else {
d.setDate(1); // prevent overflow (e.g. Jan 31 + 1 month = Mar 3 without this)
d.setMonth(d.getMonth()+dir);
// Month nav: clear mini-calendar filter and show full month
setFilterFromDate(null);
setFilterKeyword('');
setFilterTypeId('');
setFilterAvailability(false);
}
setSelDate(d);
};
const navLabel = () => {
if (view==='day') return `${DAYS[selDate.getDay()]} ${selDate.getDate()} ${MONTHS[selDate.getMonth()]} ${selDate.getFullYear()}`;
if (view==='week') { const ws=weekStart(selDate),we=new Date(ws); we.setDate(we.getDate()+6); return `${SHORT_MONTHS[ws.getMonth()]} ${ws.getDate()} – ${SHORT_MONTHS[we.getMonth()]} ${we.getDate()} ${we.getFullYear()}`; }
return `${MONTHS[selDate.getMonth()]} ${selDate.getFullYear()}`; // schedule + month
};
const openDetail = async e => {
try { const { event } = await api.getEvent(e.id); setDetailEvent(event); } catch { toast('Failed to load event','error'); }
};
const handleSaved = () => { load(); setPanel('calendar'); setEditingEvent(null); };
const [deleteTarget, setDeleteTarget] = useState(null);
const handleDelete = (e) => setDeleteTarget(e);
const doDelete = async (scope = 'this') => {
const e = deleteTarget;
setDeleteTarget(null);
try {
await api.deleteEvent(e.id, scope);
toast('Deleted','success');
setPanel('calendar');
setEditingEvent(null);
setDetailEvent(null);
load();
} catch(err) { toast(err.message,'error'); }
};
if (loading) return Loading schedule…
;
// ── Sidebar width matches Messages sidebar (320px) ────────────────────────
const SIDEBAR_W = isMobile ? 0 : 320;
return (
{/* Left panel — matches sidebar width */}
{!isMobile && (
Team Schedule
{/* Create button — visible to all users */}
setCreateOpen(v=>!v)} style={{ width:'100%', justifyContent:'center', gap:8 }}>
Create Event
{isToolManager && }
{createOpen && (
{[
['Event', ()=>{setPanel('eventForm');setEditingEvent(null);setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}],
...(isToolManager ? [
['Event Type', ()=>{setPanel('eventTypes');setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}],
['Bulk Event Import', ()=>{setPanel('bulkImport');setCreateOpen(false);}],
] : []),
].map(([label,action])=>(
e.currentTarget.style.background='var(--background)'} onMouseLeave={e=>e.currentTarget.style.background=''}>{label}
))}
)}
{/* Mini calendar */}
Filter Events
{
setSelDate(d);
setPanel('calendar');
setFilterFromDate(d);
setFilterKeyword('');
setFilterTypeId('');
setFilterAvailability(false);
}} events={events}/>
{/* List view filters — only shown in Schedule list view */}
{view==='schedule' && panel==='calendar' && (
)}
)}
{/* Right panel + mobile bottom bar — column flex so bottom bar stays at bottom */}
{/* View toolbar */}
{/* Mobile title + create */}
{isMobile && (
Team Schedule
)}
{!isMobile && (
<>
setSelDate(new Date())}>Today
navDate(-1)} style={{ fontSize:16, padding:'2px 8px' }}>‹
navDate(1)} style={{ fontSize:16, padding:'2px 8px' }}>›
{navLabel()}
>
)}
{/* View switcher */}
{allowedViews.map(v => {
const labels = { schedule:'Schedule', day:'Day', week:'Week', month:'Month' };
return (
{setView(v);setPanel('calendar');setSelDate(new Date());setFilterKeyword('');setFilterTypeId('');setFilterAvailability(false);setFilterFromDate(null);}} style={{ padding:'4px 10px', borderRadius:5, border:'none', cursor:'pointer', fontSize:12, fontWeight:600, background:view===v?'var(--surface)':'transparent', color:view===v?'var(--text-primary)':'var(--text-tertiary)', boxShadow:view===v?'0 1px 3px rgba(0,0,0,0.1)':'none', transition:'all 0.15s', whiteSpace:'nowrap' }}>
{labels[v]}
);
})}
{/* Mobile filter bar — Schedule view: filters + month nav; Day view: calendar accordion */}
{isMobile && panel === 'calendar' && (
{ setFilterKeyword(val); if (!val) setFilterFromDate(null); }}
filterTypeId={filterTypeId}
onFilterTypeId={setFilterTypeId}
filterAvailability={filterAvailability}
onFilterAvailability={setFilterAvailability}
onClearFromDate={() => setFilterFromDate(null)}
onInputFocus={()=>setInputFocused(true)}
onInputBlur={()=>setInputFocused(false)}
eventDates={eventDates}
onMonthChange={(dir, exactDate) => {
if(exactDate) { setSelDate(exactDate); }
else { const d=new Date(selDate); d.setDate(1); d.setMonth(d.getMonth()+dir); setFilterFromDate(null); setSelDate(d); }
}} />
)}
{/* Calendar or panel content */}
{panel === 'calendar' && view === 'schedule' &&
}
{panel === 'calendar' && view === 'day' &&
{ const d=new Date(selDate); d.setDate(d.getDate()+dir); setSelDate(d); } : undefined}/>}
{panel === 'calendar' && view === 'week' && }
{panel === 'calendar' && view === 'month' && {setSelDate(d);setView('day');}}/>}
{panel === 'eventForm' && !isMobile && (
{editingEvent?'Edit Event':'New Event'}
{setPanel('calendar');setEditingEvent(null);setFilterKeyword('');setFilterTypeId('');}} onDelete={handleDelete}/>
)}
{panel === 'eventTypes' && isToolManager && (
Event Types
setPanel('calendar')}>← Back
)}
{panel === 'bulkImport' && isToolManager && (
Bulk Event Import
setPanel('calendar')}>← Back
{load();setPanel('calendar');}} onCancel={()=>setPanel('calendar')}/>
)}
{/* Mobile bottom bar — hidden when keyboard open to avoid being pushed up */}
{isMobile && !inputFocused && (
)}
{/* Delete confirmation modals */}
{deleteTarget && deleteTarget.recurrence_rule?.freq
?
setDeleteTarget(null)}/>
: deleteTarget && doDelete('this')} onCancel={()=>setDeleteTarget(null)}/>
}
{/* Fixed overlays — position:fixed so they escape layout, can live anywhere in tree */}
{isMobile && mobilePanel === 'groupManager' && (
setMobilePanel(null)}/>
)}
{panel === 'eventForm' && isMobile && (
{setPanel('calendar');setEditingEvent(null);setFilterKeyword('');setFilterTypeId('');}}
onDelete={handleDelete} />
)}
{/* Mobile FAB — same position as Messages newchat-fab */}
{isMobile && panel === 'calendar' && (
{
if (isToolManager) { setCreateOpen(v=>!v); }
else { setPanel('eventForm'); setEditingEvent(null); setFilterKeyword(''); setFilterTypeId(''); }
}}>
{isToolManager && createOpen && (
{[['Event', ()=>{setPanel('eventForm');setEditingEvent(null);setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}],
['Event Type', ()=>{setPanel('eventTypes');setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}],
].map(([label,action])=>(
e.currentTarget.style.background='var(--background)'} onMouseLeave={e=>e.currentTarget.style.background=''}>{label}
))}
)}
)}
{/* Event detail modal */}
{detailEvent && (
setDetailEvent(null)}
onEdit={() => { setEditingEvent(detailEvent); setPanel('eventForm'); setDetailEvent(null); }}
onAvailabilityChange={(resp) => {
// Update the list so the "awaiting response" dot disappears immediately
setEvents(prev => prev.map(e => e.id === detailEvent.id ? {...e, my_response: resp} : e));
openDetail(detailEvent);
}} />
)}
);
}