diff --git a/backend/package.json b/backend/package.json index 1866b8b..33d7cbd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "jama-backend", - "version": "0.11.23", + "version": "0.11.24", "description": "TeamChat backend server", "main": "src/index.js", "scripts": { diff --git a/build.sh b/build.sh index fff6952..08d1a49 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.11.23}" +VERSION="${1:-0.11.24}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="jama" diff --git a/frontend/package.json b/frontend/package.json index b0e484f..5f17f43 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "jama-frontend", - "version": "0.11.23", + "version": "0.11.24", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/MobileEventForm.jsx b/frontend/src/components/MobileEventForm.jsx index d2504c4..a5b5bd2 100644 --- a/frontend/src/components/MobileEventForm.jsx +++ b/frontend/src/components/MobileEventForm.jsx @@ -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 ( +
+ 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 && ( +
+ {TIME_SLOTS.map(s => ( +
{ e.preventDefault(); onChange(s.value); setInputVal(s.label); setOpen(false); }} + style={{ + padding: '10px 14px', fontSize: 14, cursor: 'pointer', height: 40, + boxSizing: 'border-box', + background: s.value === value ? 'var(--primary)' : 'transparent', + color: s.value === value ? 'white' : 'var(--text-primary)', + }} + > + {s.label} +
+ ))} +
+ )} +
+ ); +} + function toDateIn(iso) { if (!iso) return ''; const d = new Date(iso); @@ -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
setShowStartDate(true)} style={{ flex:1,fontSize:15,cursor:'pointer' }}>{fmtDateDisplay(sd)} {!allDay && ( - + )}
@@ -371,19 +488,15 @@ 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 && ( - + }} /> )}
diff --git a/frontend/src/components/SchedulePage.jsx b/frontend/src/components/SchedulePage.jsx index 18c646b..9bebf6b 100644 --- a/frontend/src/components/SchedulePage.jsx +++ b/frontend/src/components/SchedulePage.jsx @@ -27,9 +27,7 @@ function toDateIn(iso) { function toTimeIn(iso) { if (!iso) return ''; const d = new Date(iso); - const h = String(d.getHours()).padStart(2,'0'); - const m = d.getMinutes() < 30 ? '00' : '30'; - return `${h}:${m}`; + 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) { @@ -91,6 +89,147 @@ const TIME_SLOTS = (() => { 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="new-password" + 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, eventDates=new Set() }) { const [cur, setCur] = useState(()=>{ const d=new Date(selected||Date.now()); d.setDate(1); return d; }); @@ -339,9 +478,9 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc 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):'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')}`; })()); const [allDay,setAllDay]=useState(!!event?.all_day); const [loc,setLoc]=useState(event?.location||''); const [desc,setDesc]=useState(event?.description||''); @@ -428,6 +567,9 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc if(groupsRequired&&grps.size===0) return toast('Select at least one group for availability tracking','error'); if(ed setSd(e.target.value)} style={{width:150,flexShrink:0}}/> {!allDay&&( <> - + to - + }} style={{width:120,flexShrink:0}}/> {setEd(e.target.value);userSetEndTime.current=true;}} style={{width:150,flexShrink:0}}/> )} @@ -823,8 +960,10 @@ function expandRecurringEvent(ev, rangeStart, rangeEnd) { count++; } - // Always include the original even if before rangeStart - return occurrences.length > 0 ? occurrences : [ev]; + // 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