v011.24 event bug fixes
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-backend",
|
"name": "jama-backend",
|
||||||
"version": "0.11.23",
|
"version": "0.11.24",
|
||||||
"description": "TeamChat backend server",
|
"description": "TeamChat backend server",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
|||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${1:-0.11.23}"
|
VERSION="${1:-0.11.24}"
|
||||||
ACTION="${2:-}"
|
ACTION="${2:-}"
|
||||||
REGISTRY="${REGISTRY:-}"
|
REGISTRY="${REGISTRY:-}"
|
||||||
IMAGE_NAME="jama"
|
IMAGE_NAME="jama"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jama-frontend",
|
"name": "jama-frontend",
|
||||||
"version": "0.11.23",
|
"version": "0.11.24",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -20,6 +20,123 @@ const TIME_SLOTS = (() => {
|
|||||||
return s;
|
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) {
|
function toDateIn(iso) {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -29,8 +146,7 @@ function toDateIn(iso) {
|
|||||||
function toTimeIn(iso) {
|
function toTimeIn(iso) {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
const h = String(d.getHours()).padStart(2,'0'), m = d.getMinutes() < 30 ? '00' : '30';
|
return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
|
||||||
return `${h}:${m}`;
|
|
||||||
}
|
}
|
||||||
function buildISO(date, time) {
|
function buildISO(date, time) {
|
||||||
if (!date || !time) return '';
|
if (!date || !time) return '';
|
||||||
@@ -219,9 +335,9 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
|
|||||||
const [showTypeColourPicker, setShowTypeColourPicker] = useState(false);
|
const [showTypeColourPicker, setShowTypeColourPicker] = useState(false);
|
||||||
const [savingType, setSavingType] = useState(false);
|
const [savingType, setSavingType] = useState(false);
|
||||||
const [sd, setSd] = useState(event ? toDateIn(event.start_at) : def);
|
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 [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
|
// Track the saved event duration (minutes) so editing preserves it
|
||||||
const savedDurMins = event
|
const savedDurMins = event
|
||||||
? (new Date(event.end_at) - new Date(event.start_at)) / 60000
|
? (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();
|
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(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');
|
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);
|
setSaving(true);
|
||||||
try {
|
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 };
|
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' }}>
|
<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>
|
<span onClick={()=>setShowStartDate(true)} style={{ flex:1,fontSize:15,cursor:'pointer' }}>{fmtDateDisplay(sd)}</span>
|
||||||
{!allDay && (
|
{!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' }}>
|
<TimeInputMobile value={st} onChange={setSt} />
|
||||||
{TIME_SLOTS.map(s=><option key={s.value} value={s.value}>{s.label}</option>)}
|
|
||||||
</select>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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)' }}>
|
<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>
|
<span style={{ flex:1,fontSize:15,color:'var(--text-secondary)' }}>{fmtDateDisplay(ed)}</span>
|
||||||
{!allDay && (
|
{!allDay && (
|
||||||
<select value={et} onChange={e=>{
|
<TimeInputMobile value={et} onChange={newEt => {
|
||||||
const newEt = e.target.value;
|
|
||||||
setEt(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) {
|
if(sd === ed && newEt <= st) {
|
||||||
const nextDay = addHours(buildISO(sd, st), 0);
|
const nextDay = addHours(buildISO(sd, st), 0);
|
||||||
const d = new Date(nextDay); d.setDate(d.getDate()+1);
|
const d = new Date(nextDay); d.setDate(d.getDate()+1);
|
||||||
const pad = n => String(n).padStart(2,'0');
|
const pad = n => String(n).padStart(2,'0');
|
||||||
setEd(`${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`);
|
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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -27,9 +27,7 @@ function toDateIn(iso) {
|
|||||||
function toTimeIn(iso) {
|
function toTimeIn(iso) {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
const h = String(d.getHours()).padStart(2,'0');
|
return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
|
||||||
const m = d.getMinutes() < 30 ? '00' : '30';
|
|
||||||
return `${h}:${m}`;
|
|
||||||
}
|
}
|
||||||
// Build an ISO string with local timezone offset so Postgres stores the right UTC value
|
// Build an ISO string with local timezone offset so Postgres stores the right UTC value
|
||||||
function buildISO(date, time) {
|
function buildISO(date, time) {
|
||||||
@@ -91,6 +89,147 @@ const TIME_SLOTS = (() => {
|
|||||||
return s;
|
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 (
|
||||||
|
<div ref={wrapRef} style={{ position: 'relative', ...style }}>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={inputVal}
|
||||||
|
onChange={e => 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 && (
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: '100%', left: 0, zIndex: 300,
|
||||||
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)', boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
|
||||||
|
width: '100%', minWidth: 120,
|
||||||
|
maxHeight: 5 * 36, overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{TIME_SLOTS.map(s => (
|
||||||
|
<div
|
||||||
|
key={s.value}
|
||||||
|
onMouseDown={e => { 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}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Mini Calendar (desktop) ───────────────────────────────────────────────────
|
// ── Mini Calendar (desktop) ───────────────────────────────────────────────────
|
||||||
function MiniCalendar({ selected, onChange, eventDates=new Set() }) {
|
function MiniCalendar({ selected, onChange, eventDates=new Set() }) {
|
||||||
const [cur, setCur] = useState(()=>{ const d=new Date(selected||Date.now()); d.setDate(1); return d; });
|
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 [title,setTitle]=useState(event?.title||'');
|
||||||
const [typeId,setTypeId]=useState(event?.event_type_id||'');
|
const [typeId,setTypeId]=useState(event?.event_type_id||'');
|
||||||
const [sd,setSd]=useState(event?toDateIn(event.start_at):def);
|
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 [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 [allDay,setAllDay]=useState(!!event?.all_day);
|
||||||
const [loc,setLoc]=useState(event?.location||'');
|
const [loc,setLoc]=useState(event?.location||'');
|
||||||
const [desc,setDesc]=useState(event?.description||'');
|
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(groupsRequired&&grps.size===0) return toast('Select at least one group for availability tracking','error');
|
||||||
if(ed<sd) return toast('End date cannot be before start date','error');
|
if(ed<sd) return toast('End date cannot be before start date','error');
|
||||||
if(!allDay&&ed===sd&&buildISO(ed,et)<=buildISO(sd,st)) return toast('End time must be after start time, or use a later end date','error');
|
if(!allDay&&ed===sd&&buildISO(ed,et)<=buildISO(sd,st)) return toast('End time must be after start time, or use 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);
|
setSaving(true);
|
||||||
try{
|
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:pub,trackAvailability:track,userGroupIds:[...grps],recurrenceRule:recRule||null};
|
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:pub,trackAvailability:track,userGroupIds:[...grps],recurrenceRule:recRule||null};
|
||||||
@@ -468,17 +610,12 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
|||||||
<input type="date" className="input" value={sd} onChange={e => setSd(e.target.value)} style={{width:150,flexShrink:0}}/>
|
<input type="date" className="input" value={sd} onChange={e => setSd(e.target.value)} style={{width:150,flexShrink:0}}/>
|
||||||
{!allDay&&(
|
{!allDay&&(
|
||||||
<>
|
<>
|
||||||
<select className="input" value={st} onChange={e=>setSt(e.target.value)} style={{width:120,flexShrink:0}}>
|
<TimeInput value={st} onChange={setSt} style={{width:120,flexShrink:0}}/>
|
||||||
{TIME_SLOTS.map(s=><option key={s.value} value={s.value}>{s.label}</option>)}
|
|
||||||
</select>
|
|
||||||
<span style={{color:'var(--text-tertiary)',fontSize:13,flexShrink:0}}>to</span>
|
<span style={{color:'var(--text-tertiary)',fontSize:13,flexShrink:0}}>to</span>
|
||||||
<select className="input" value={et} onChange={e=>{
|
<TimeInput value={et} onChange={newEt=>{
|
||||||
const newEt=e.target.value; setEt(newEt); userSetEndTime.current=true;
|
setEt(newEt); userSetEndTime.current=true;
|
||||||
// Overnight: if end < start on same day, advance end date
|
|
||||||
if(sd===ed && newEt<=st){ const d=new Date(buildISO(sd,st)); d.setDate(d.getDate()+1); const p=n=>String(n).padStart(2,'0'); setEd(`${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`); }
|
if(sd===ed && newEt<=st){ const d=new Date(buildISO(sd,st)); d.setDate(d.getDate()+1); const p=n=>String(n).padStart(2,'0'); setEd(`${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`); }
|
||||||
}} style={{width:120,flexShrink:0}}>
|
}} style={{width:120,flexShrink:0}}/>
|
||||||
{TIME_SLOTS.map(s=><option key={s.value} value={s.value}>{s.label}</option>)}
|
|
||||||
</select>
|
|
||||||
<input type="date" className="input" value={ed} onChange={e => {setEd(e.target.value);userSetEndTime.current=true;}} style={{width:150,flexShrink:0}}/>
|
<input type="date" className="input" value={ed} onChange={e => {setEd(e.target.value);userSetEndTime.current=true;}} style={{width:150,flexShrink:0}}/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -823,8 +960,10 @@ function expandRecurringEvent(ev, rangeStart, rangeEnd) {
|
|||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always include the original even if before rangeStart
|
// Return only occurrences that fell within the range — never return the raw event
|
||||||
return occurrences.length > 0 ? occurrences : [ev];
|
// 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
|
// Expand all recurring events in a list within a date range
|
||||||
|
|||||||
Reference in New Issue
Block a user