diff --git a/backend/package.json b/backend/package.json index ce1923c..df650ae 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "jama-backend", - "version": "0.11.4", + "version": "0.11.5", "description": "TeamChat backend server", "main": "src/index.js", "scripts": { diff --git a/build.sh b/build.sh index 06c90c0..ec94744 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.11.4}" +VERSION="${1:-0.11.5}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="jama" diff --git a/frontend/package.json b/frontend/package.json index 83df088..dad2013 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jama-frontend", - "version": "0.11.4", + "version": "0.11.5", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/MobileEventForm.jsx b/frontend/src/components/MobileEventForm.jsx index 6b643ae..a25f2b6 100644 --- a/frontend/src/components/MobileEventForm.jsx +++ b/frontend/src/components/MobileEventForm.jsx @@ -204,7 +204,10 @@ function MobileRow({ icon, label, children, onPress, border=true }) { // ── Main Mobile Event Form ──────────────────────────────────────────────────── export default function MobileEventForm({ event, eventTypes, userGroups, selectedDate, onSave, onCancel, onDelete, isToolManager }) { const toast = useToast(); - const def = selectedDate ? selectedDate.toISOString().slice(0,10) : new Date().toISOString().slice(0,10); + // 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); @@ -217,6 +220,12 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte const [st, setSt] = useState(event ? toTimeIn(event.start_at) : '09:00'); const [ed, setEd] = useState(event ? toDateIn(event.end_at) : def); const [et, setEt] = useState(event ? toTimeIn(event.end_at) : '10:00'); + // 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 [allDay, setAllDay] = useState(!!event?.all_day); const [track, setTrack] = useState(!!event?.track_availability); const [isPrivate, setIsPrivate] = useState(event ? !event.is_public : false); @@ -253,25 +262,42 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte finally { setSavingType(false); } }; - // When start date or start time changes, update end date/time to maintain duration + // Auto-calculate end date/time when start date, start time, or event type changes. + // Rules: + // - New event: use eventType duration (default 1hr) + // - Editing + type changed: use new eventType duration + // - Editing + type same: use saved event duration (preserve original length) + // - Always: if end < start, advance end date by 1 day (overnight events) useEffect(() => { if(!sd||!st) return; - const typ = localTypes.find(t=>t.id===Number(typeId)); - const dur = typ?.default_duration_hrs||1; const start = buildISO(sd,st); if(!start) return; - if(event) { - // Editing: only sync end date when start date changes, preserve manual end time - setEd(toDateIn(addHours(start, 0))); + + const typeChanged = typeId !== prevTypeIdRef.current; + prevTypeIdRef.current = typeId; + + let durMins; + if(!event || typeChanged) { + // New event or type change: use eventType duration + const typ = localTypes.find(t=>t.id===Number(typeId)); + durMins = (typ?.default_duration_hrs||1) * 60; } else { - // New event: always auto-set end to start + duration - setEd(toDateIn(addHours(start,dur))); - setEt(toTimeIn(addHours(start,dur))); + // Editing with same type: preserve the saved event duration + durMins = savedDurMins || 60; } + + const endIso = addHours(start, durMins/60); + setEd(toDateIn(endIso)); + setEt(toTimeIn(endIso)); }, [sd, st, typeId]); const handle = async () => { if(!title.trim()) return toast('Title required','error'); + // Validation rules + 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'); 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 }; @@ -342,7 +368,17 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
setShowEndDate(true)} style={{ display:'flex',alignItems:'center',padding:'6px 20px 14px 56px',cursor:'pointer',borderBottom:'1px solid var(--border)' }}> {fmtDateDisplay(ed)} {!allDay && ( - { + const newEt = e.target.value; + 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=>)} )} diff --git a/frontend/src/components/ScheduleManagerModal.jsx b/frontend/src/components/ScheduleManagerModal.jsx index 6d81976..63ae52f 100644 --- a/frontend/src/components/ScheduleManagerModal.jsx +++ b/frontend/src/components/ScheduleManagerModal.jsx @@ -156,7 +156,9 @@ function EventTypePopup({ userGroups, onSave, onClose, editing = null }) { function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCancel, onDelete, isToolManager }) { const toast = useToast(); const today = new Date(); - const defaultDate = selectedDate ? selectedDate.toISOString().slice(0,10) : today.toISOString().slice(0,10); + const _defD = selectedDate || today; + const _p = n => String(n).padStart(2,'0'); + const defaultDate = `${_defD.getFullYear()}-${_p(_defD.getMonth()+1)}-${_p(_defD.getDate())}`; const [title, setTitle] = useState(event?.title || ''); const [eventTypeId, setEventTypeId] = useState(event?.event_type_id || ''); const [startDate, setStartDate] = useState(event ? toLocalDateInput(event.start_at) : defaultDate); @@ -173,23 +175,40 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc const [showTypeForm, setShowTypeForm] = useState(false); const [localEventTypes, setLocalEventTypes] = useState(eventTypes); const typeRef = useRef(null); + 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) : ''); - // Auto-update end time when event type selected with default duration + // Auto-update end time when event type or start time changes useEffect(() => { - if (!eventTypeId || event) return; + if (!startDate || !startTime) return; const et = localEventTypes.find(t => t.id === Number(eventTypeId)); - if (!et || !startDate || !startTime) return; const start = buildISO(startDate, startTime); - setEndDate(toLocalDateInput(addHours(start, et.default_duration_hrs))); - setEndTime(toLocalTimeInput(addHours(start, et.default_duration_hrs))); - if (et.default_user_group_id && !event) setSelectedGroups(prev => new Set([...prev, et.default_user_group_id])); - }, [eventTypeId]); + if (!start) return; + const typeChanged = String(eventTypeId) !== prevTypeIdRef.current; + prevTypeIdRef.current = String(eventTypeId); + if (!event || typeChanged) { + // New event or type change: apply eventType duration + const dur = et?.default_duration_hrs || 1; + setEndDate(toLocalDateInput(addHours(start, dur))); + setEndTime(toLocalTimeInput(addHours(start, dur))); + } else { + // Editing with same type: preserve saved duration + const durMins = savedDurMins || 60; + setEndDate(toLocalDateInput(addHours(start, durMins/60))); + setEndTime(toLocalTimeInput(addHours(start, durMins/60))); + } + if (et?.default_user_group_id && !event) setSelectedGroups(prev => new Set([...prev, et.default_user_group_id])); + }, [eventTypeId, startDate, startTime]); const toggleGroup = (id) => setSelectedGroups(prev => { const n=new Set(prev); n.has(id)?n.delete(id):n.add(id); return n; }); const handleSave = async () => { if (!title.trim()) return toast('Title required', 'error'); if (!allDay && (!startDate||!startTime||!endDate||!endTime)) return toast('Start and end required', 'error'); + if (endDate < startDate) return toast('End date cannot be before start date', 'error'); + if (!allDay && endDate === startDate && buildISO(endDate, endTime) <= buildISO(startDate, startTime)) return toast('End time must be after start time, or use a later end date', 'error'); setSaving(true); try { const body = { @@ -223,7 +242,14 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc setStartDate(e.target.value)} style={{ width:150 }} /> {!allDay && setStartTime(e.target.value)} style={{ width:120 }} />} to - {!allDay && setEndTime(e.target.value)} style={{ width:120 }} />} + {!allDay && { + const newEt = e.target.value; setEndTime(newEt); + if(startDate === endDate && newEt <= startTime) { + const d = new Date(buildISO(startDate, startTime)); d.setDate(d.getDate()+1); + const p = n => String(n).padStart(2,'0'); + setEndDate(`${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`); + } + }} style={{ width:120 }} />} setEndDate(e.target.value)} style={{ width:150 }} />