import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import ReactDOM from 'react-dom';
import { api } from '../utils/api.js';
import { useToast } from '../contexts/ToastContext.jsx';
import { useAuth } from '../contexts/AuthContext.jsx';
import UserFooter from './UserFooter.jsx';
import MobileEventForm from './MobileEventForm.jsx';
import ColourPickerSheet from './ColourPickerSheet.jsx';
import MobileGroupManager from './MobileGroupManager.jsx';
// ── Utilities ─────────────────────────────────────────────────────────────────
const DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December'];
const SHORT_MONTHS= ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function fmtDate(d) { return `${d.getDate()} ${SHORT_MONTHS[d.getMonth()]} ${d.getFullYear()}`; }
function fmtTime(iso) { if(!iso) return ''; const d=new Date(iso); return d.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}); }
function fmtRange(s,e) { return `${fmtTime(s)} – ${fmtTime(e)}`; }
// Convert a UTC ISO string (from Postgres TIMESTAMPTZ) to local YYYY-MM-DD for
function toDateIn(iso) {
if (!iso) return '';
const d = new Date(iso);
const pad = n => String(n).padStart(2,'0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
}
// Convert a UTC ISO string to local HH:MM for , snapped to :00 or :30
function toTimeIn(iso) {
if (!iso) return '';
const d = new Date(iso);
return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
}
// Build an ISO string with local timezone offset so Postgres stores the right UTC value
function buildISO(date, time) {
if (!date || !time) return '';
// Parse as local datetime then get offset-aware ISO string
const d = new Date(`${date}T${time}:00`);
const pad = n => String(n).padStart(2,'0');
const off = -d.getTimezoneOffset();
const sign = off >= 0 ? '+' : '-';
const abs = Math.abs(off);
return `${date}T${time}:00${sign}${pad(Math.floor(abs/60))}:${pad(abs%60)}`;
}
function addHours(iso, h) {
const d = new Date(iso); d.setMinutes(d.getMinutes() + h * 60);
const pad = n => String(n).padStart(2,'0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
}
function sameDay(a,b) { return a.getFullYear()===b.getFullYear()&&a.getMonth()===b.getMonth()&&a.getDate()===b.getDate(); }
function weekStart(d) { const r=new Date(d); r.setDate(d.getDate()-d.getDay()); r.setHours(0,0,0,0); return r; }
function daysInMonth(y,m){ return new Date(y,m+1,0).getDate(); }
const RESP_LABEL = { going:'Going', maybe:'Maybe', not_going:'Not Going' };
const RESP_COLOR = { going:'#22c55e', maybe:'#f59e0b', not_going:'#ef4444' };
const RESP_ICON = {
going: (color,size=15) => (
),
maybe: (color,size=15) => (
),
not_going: (color,size=15) => (
),
};
const BELL_ICON = (
);
// 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 (
{/* Availability */}
{/* Groups — required when tracking */}
{userGroups.length===0
?
No user groups yet
:userGroups.map(g=>(
))}
{grps.size===0
? (groupsRequired?'At least one group required for availability tracking':'No groups — event visible to all (if public)')
: `${grps.size} group${grps.size!==1?'s':''} selected`}
{/* Visibility — only shown if groups selected OR tracking */}
{(grps.size>0||track) && (
)}
{/* Location */}
setLoc(e.target.value)} autoComplete="new-password" />
{/* Description */}
);
}
// ── 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;
// Start from original and step forward
while (count < maxOccurrences) {
// Check end conditions
if (endDate && cur > endDate) break;
if (endCount && occurrences.length >= 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) {
const dayNum = DAY_MAP[dayKey];
const occ = new Date(weekStart);
occ.setDate(weekStart.getDate() + dayNum);
occ.setHours(origStart.getHours(), origStart.getMinutes(), origStart.getSeconds());
if (occ >= rangeStart && occ <= rangeEnd) {
if (!endDate || occ <= endDate) {
const occEnd = new Date(occ.getTime() + durMs);
occurrences.push({...ev, start_at: occ.toISOString(), end_at: occEnd.toISOString(), _virtual: true});
}
}
}
cur = step(cur);
} else {
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 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
);
})}>;
}
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 scrollRef = useRef(null);
const touchRef = useRef({ x:0, y:0 });
useEffect(()=>{ if(scrollRef.current) scrollRef.current.scrollTop = 7 * HOUR_H; },[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(