v011.24 event bug fixes

This commit is contained in:
2026-03-22 17:28:30 -04:00
parent b72ce57544
commit 344ca70b64
5 changed files with 284 additions and 32 deletions

View File

@@ -20,6 +20,123 @@ const TIME_SLOTS = (() => {
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 (
<div ref={wrapRef} style={{ position: 'relative', display: 'inline-block' }}>
<input
value={inputVal}
onChange={e => 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="new-password"
style={{ fontSize: 15, color: 'var(--primary)', fontWeight: 600, background: 'transparent', border: 'none', outline: 'none', cursor: 'text', width: 90 }}
/>
{open && (
<div
ref={listRef}
style={{
position: 'fixed', zIndex: 400,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
width: 130, maxHeight: 5 * 40, overflowY: 'auto',
}}
>
{TIME_SLOTS.map(s => (
<div
key={s.value}
onMouseDown={e => { 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}
</div>
))}
</div>
)}
</div>
);
}
function toDateIn(iso) {
if (!iso) return '';
const d = new Date(iso);
@@ -29,8 +146,7 @@ function toDateIn(iso) {
function toTimeIn(iso) {
if (!iso) return '';
const d = new Date(iso);
const h = String(d.getHours()).padStart(2,'0'), m = d.getMinutes() < 30 ? '00' : '30';
return `${h}:${m}`;
return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
}
function buildISO(date, time) {
if (!date || !time) return '';
@@ -219,9 +335,9 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
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) : '09:00');
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) : '10:00');
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
@@ -301,6 +417,9 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
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');
// No past start times for new events
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');
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:!isPrivate, trackAvailability:track, userGroupIds:[...groups], recurrenceRule:recRule||null };
@@ -361,9 +480,7 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
<div style={{ display:'flex',alignItems:'center',padding:'12px 20px 6px 56px' }}>
<span onClick={()=>setShowStartDate(true)} style={{ flex:1,fontSize:15,cursor:'pointer' }}>{fmtDateDisplay(sd)}</span>
{!allDay && (
<select value={st} onChange={e=>setSt(e.target.value)} style={{ fontSize:15,color:'var(--primary)',fontWeight:600,background:'transparent',border:'none',outline:'none',cursor:'pointer' }}>
{TIME_SLOTS.map(s=><option key={s.value} value={s.value}>{s.label}</option>)}
</select>
<TimeInputMobile value={st} onChange={setSt} />
)}
</div>
@@ -371,19 +488,15 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
<div onClick={()=>setShowEndDate(true)} style={{ display:'flex',alignItems:'center',padding:'6px 20px 14px 56px',cursor:'pointer',borderBottom:'1px solid var(--border)' }}>
<span style={{ flex:1,fontSize:15,color:'var(--text-secondary)' }}>{fmtDateDisplay(ed)}</span>
{!allDay && (
<select value={et} onChange={e=>{
const newEt = e.target.value;
<TimeInputMobile value={et} onChange={newEt => {
setEt(newEt);
// If end time is earlier than start time on the same day, roll end date to next day
if(sd === ed && newEt <= st) {
const nextDay = addHours(buildISO(sd, st), 0);
const d = new Date(nextDay); d.setDate(d.getDate()+1);
const pad = n => String(n).padStart(2,'0');
setEd(`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`);
}
}} onClick={e=>e.stopPropagation()} style={{ fontSize:15,color:'var(--primary)',fontWeight:600,background:'transparent',border:'none',outline:'none' }}>
{TIME_SLOTS.map(s=><option key={s.value} value={s.value}>{s.label}</option>)}
</select>
}} />
)}
</div>