diff --git a/backend/src/routes/schedule.js b/backend/src/routes/schedule.js index 1c6580e..c6aa0a6 100644 --- a/backend/src/routes/schedule.js +++ b/backend/src/routes/schedule.js @@ -176,7 +176,7 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, (req, res) => { const db = getDb(); const event = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id); if (!event) return res.status(404).json({ error: 'Not found' }); - const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule } = req.body; + const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule, recurringScope } = req.body; db.prepare(`UPDATE events SET title = COALESCE(?, title), event_type_id = ?, start_at = COALESCE(?, start_at), end_at = COALESCE(?, end_at), all_day = COALESCE(?, all_day), @@ -194,6 +194,39 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, (req, res) => { recurrenceRule !== undefined ? (recurrenceRule ? JSON.stringify(recurrenceRule) : null) : event.recurrence_rule, req.params.id ); + // For recurring events: if scope='future', update all future occurrences too + if (recurringScope === 'future' && event.recurrence_rule) { + const futureEvents = db.prepare(` + SELECT id FROM events + WHERE id != ? AND created_by = ? AND recurrence_rule IS NOT NULL + AND start_at >= ? AND title = ? + `).all(req.params.id, event.created_by, event.start_at, event.title); + for (const fe of futureEvents) { + db.prepare(`UPDATE events SET + title = COALESCE(?, title), event_type_id = ?, start_at = COALESCE(?, start_at), + end_at = COALESCE(?, end_at), all_day = COALESCE(?, all_day), + location = ?, description = ?, is_public = COALESCE(?, is_public), + track_availability = COALESCE(?, track_availability), + recurrence_rule = ?, + updated_at = datetime('now') + WHERE id = ?`).run( + title?.trim() || null, eventTypeId !== undefined ? (eventTypeId || null) : event.event_type_id, + startAt || null, endAt || null, allDay !== undefined ? (allDay ? 1 : 0) : null, + location !== undefined ? (location || null) : event.location, + description !== undefined ? (description || null) : event.description, + isPublic !== undefined ? (isPublic ? 1 : 0) : null, + trackAvailability !== undefined ? (trackAvailability ? 1 : 0) : null, + recurrenceRule !== undefined ? (recurrenceRule ? JSON.stringify(recurrenceRule) : null) : event.recurrence_rule, + fe.id + ); + if (Array.isArray(userGroupIds)) { + db.prepare('DELETE FROM event_user_groups WHERE event_id = ?').run(fe.id); + for (const ugId of userGroupIds) + db.prepare('INSERT OR IGNORE INTO event_user_groups (event_id, user_group_id) VALUES (?, ?)').run(fe.id, ugId); + } + } + } + if (Array.isArray(userGroupIds)) { // Find which groups are being removed const prevGroupIds = db.prepare('SELECT user_group_id FROM event_user_groups WHERE event_id = ?') diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json index 5a0334f..70c2af0 100644 --- a/frontend/public/manifest.json +++ b/frontend/public/manifest.json @@ -22,18 +22,11 @@ "purpose": "maskable" }, { - "purpose": "maskable", + "purpose": "any maskable", "src": "/icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png" - }, - { - "src": "/icons/icon-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "any" } - ], "min_width": "320px" } \ No newline at end of file diff --git a/frontend/public/manifest.json.copy b/frontend/public/manifest.json.copy new file mode 100644 index 0000000..5a0334f --- /dev/null +++ b/frontend/public/manifest.json.copy @@ -0,0 +1,39 @@ +{ + "name": "jama", + "short_name": "jama", + "description": "Modern team messaging application", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#ffffff", + "theme_color": "#1a73e8", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-192-maskable.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "purpose": "maskable", + "src": "/icons/icon-512-maskable.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + } + + ], + "min_width": "320px" +} \ No newline at end of file diff --git a/frontend/src/components/MobileEventForm.jsx b/frontend/src/components/MobileEventForm.jsx index 8cc5c39..e427bb1 100644 --- a/frontend/src/components/MobileEventForm.jsx +++ b/frontend/src/components/MobileEventForm.jsx @@ -253,7 +253,12 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte setSaving(true); try { const body = { title:title.trim(), eventTypeId:typeId||null, startAt:allDay?`${sd}T00:00:00`:buildISO(sd,st), endAt:allDay?`${ed}T23:59:59`:buildISO(ed,et), allDay, location, description, isPublic:!isPrivate, trackAvailability:track, userGroupIds:[...groups], recurrenceRule:recRule||null }; - const r = event ? await api.updateEvent(event.id, body) : await api.createEvent(body); + let scope = 'this'; + if(event && event.recurrence_rule?.freq) { + const choice = window.confirm('This is a recurring event.\n\nOK = Update this and all future occurrences\nCancel = Update this event only'); + scope = choice ? 'future' : 'this'; + } + const r = event ? await api.updateEvent(event.id, {...body, recurringScope:scope}) : await api.createEvent(body); onSave(r.event); } catch(e) { toast(e.message,'error'); } finally { setSaving(false); } diff --git a/frontend/src/components/SchedulePage.jsx b/frontend/src/components/SchedulePage.jsx index 7c6868d..22e1622 100644 --- a/frontend/src/components/SchedulePage.jsx +++ b/frontend/src/components/SchedulePage.jsx @@ -100,7 +100,7 @@ function MiniCalendar({ selected, onChange, eventDates=new Set() }) { } // ── Mobile Filter Bar (Schedule view: keyword+type filters with month nav; Day view: calendar accordion) ── -function MobileScheduleFilter({ selected, onMonthChange, view, eventTypes, filterKeyword, onFilterKeyword, filterTypeId, onFilterTypeId, eventDates=new Set(), onInputFocus, onInputBlur }) { +function MobileScheduleFilter({ selected, onMonthChange, view, eventTypes, filterKeyword, onFilterKeyword, filterTypeId, onFilterTypeId, filterAvailability=false, onFilterAvailability, eventDates=new Set(), onInputFocus, onInputBlur }) { // Day view: keep accordion calendar const [open, setOpen] = useState(false); const y=selected.getFullYear(), m=selected.getMonth(); @@ -141,42 +141,45 @@ function MobileScheduleFilter({ selected, onMonthChange, view, eventTypes, filte ); } - // Schedule view: filter bar with month nav + keyword + event type - const hasFilters = filterKeyword || filterTypeId; + // Schedule view: accordion "Filter Events" + month nav + const hasFilters = filterKeyword || filterTypeId || filterAvailability; return (
- {/* Month nav row */} -
- + {/* Month nav row — always visible */} +
+ {MONTHS[m]} {y} - + + {/* Filter accordion toggle */} +
- {/* Filter inputs */} -
-
- - onFilterKeyword(e.target.value)} - onFocus={onInputFocus} - onBlur={onInputBlur} - placeholder="Search events…" - autoComplete="new-password" autoCorrect="off" autoCapitalize="off" spellCheck={false} - style={{width:'100%',padding:'7px 8px 7px 28px',border:'1px solid var(--border)',borderRadius:'var(--radius)',background:'var(--background)',color:'var(--text-primary)',fontSize:13,boxSizing:'border-box'}} - /> + {/* Collapsible filter panel */} + {open && ( +
+
+ + onFilterKeyword(e.target.value)} onFocus={onInputFocus} onBlur={onInputBlur} + placeholder="Search events…" autoComplete="new-password" autoCorrect="off" autoCapitalize="off" spellCheck={false} + style={{width:'100%',padding:'7px 8px 7px 28px',border:'1px solid var(--border)',borderRadius:'var(--radius)',background:'var(--background)',color:'var(--text-primary)',fontSize:13,boxSizing:'border-box'}}/> +
+ + + {hasFilters && ( + + )}
- - {hasFilters && ( - - )} -
+ )}
); } @@ -392,7 +395,16 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc if(!allDay&&(!sd||!st||!ed||!et)) return toast('Start and end required','error'); if(groupsRequired&&grps.size===0) return toast('Select at least one group for availability tracking','error'); setSaving(true); - try{const body={title:title.trim(),eventTypeId:typeId||null,startAt:allDay?`${sd}T00:00:00`:buildISO(sd,st),endAt:allDay?`${ed}T23:59:59`:buildISO(ed,et),allDay,location:loc,description:desc,isPublic:pub,trackAvailability:track,userGroupIds:[...grps],recurrenceRule:recRule||null};const r=event?await api.updateEvent(event.id,body):await api.createEvent(body);onSave(r.event);}catch(e){toast(e.message,'error');}finally{setSaving(false);} + try{ + const body={title:title.trim(),eventTypeId:typeId||null,startAt:allDay?`${sd}T00:00:00`:buildISO(sd,st),endAt:allDay?`${ed}T23:59:59`:buildISO(ed,et),allDay,location:loc,description:desc,isPublic:pub,trackAvailability:track,userGroupIds:[...grps],recurrenceRule:recRule||null}; + let scope='this'; + if(event && event.recurrence_rule?.freq) { + const choice = window.confirm('This is a recurring event.\n\nOK = Update this and all future occurrences\nCancel = Update this event only'); + scope = choice ? 'future' : 'this'; + } + const r=event?await api.updateEvent(event.id,{...body,recurringScope:scope}):await api.createEvent(body); + onSave(r.event); + }catch(e){toast(e.message,'error');}finally{setSaving(false);} }; return ( @@ -710,11 +722,11 @@ function parseKeywords(raw) { return terms; } -function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filterTypeId='', isMobile=false }) { +function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filterTypeId='', filterAvailability=false, 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; + const hasFilters = terms.length > 0 || !!filterTypeId || filterAvailability; const now = new Date(); // exact now for end-time comparison const isCurrentMonth = y === today.getFullYear() && m === today.getMonth(); // No filters: show from today (if current month) or start of month (future months) to end of month. @@ -726,6 +738,7 @@ function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filter 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(); if(!terms.some(t=>haystack.includes(t))) return false; @@ -975,18 +988,20 @@ function MonthView({ events, selectedDate, onSelect, onSelectDay }) { const cells=[]; for(let i=0;i -
+
+
{DAYS.map(d=>
{d}
)}
+
{weeks.map((week,wi)=>(
{week.map((d,di)=>{ - if(!d) return
; + if(!d) return
; const date=new Date(y,m,d), dayEvs=events.filter(e=>sameDay(new Date(e.start_at),date)), isToday=sameDay(date,today); return( -
onSelectDay(date)} style={{borderRight:'1px solid var(--border)',borderBottom:'1px solid var(--border)',height:MONTH_CELL_H,padding:'3px',cursor:'pointer',overflow:'hidden',display:'flex',flexDirection:'column'}} +
onSelectDay(date)} style={{borderRight:'1px solid var(--border)',borderBottom:'1px solid var(--border)',minHeight:MONTH_CELL_H,padding:'3px',cursor:'pointer',overflow:'hidden',display:'flex',flexDirection:'column'}} onMouseEnter={el=>el.currentTarget.style.background='var(--background)'} onMouseLeave={el=>el.currentTarget.style.background=''}>
{d}
{dayEvs.slice(0,2).map(e=>( @@ -1004,6 +1019,7 @@ function MonthView({ events, selectedDate, onSelect, onSelectDay }) { })}
))} +
); } @@ -1024,6 +1040,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel const [editingEvent, setEditingEvent] = useState(null); const [filterKeyword, setFilterKeyword] = useState(''); const [filterTypeId, setFilterTypeId] = useState(''); + const [filterAvailability, setFilterAvailability] = useState(false); const [inputFocused, setInputFocused] = useState(false); // hides footer when keyboard open on mobile const [detailEvent, setDetailEvent] = useState(null); const [loading, setLoading] = useState(true); @@ -1141,10 +1158,14 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel {eventTypes.map(t=>)} - {(filterKeyword||filterTypeId) && ( + + {(filterKeyword||filterTypeId||filterAvailability) && ( )} @@ -1181,7 +1202,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel {allowedViews.map(v => { const labels = { schedule:'Schedule', day:'Day', week:'Week', month:'Month' }; return ( - ); @@ -1199,6 +1220,8 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel onFilterKeyword={setFilterKeyword} filterTypeId={filterTypeId} onFilterTypeId={setFilterTypeId} + filterAvailability={filterAvailability} + onFilterAvailability={setFilterAvailability} onInputFocus={()=>setInputFocused(true)} onInputBlur={()=>setInputFocused(false)} eventDates={eventDates} @@ -1211,7 +1234,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel {/* Calendar or panel content */}
- {panel === 'calendar' && view === 'schedule' &&
} + {panel === 'calendar' && view === 'schedule' &&
} {panel === 'calendar' && view === 'day' && { const d=new Date(selDate); d.setDate(d.getDate()+dir); setSelDate(d); } : undefined}/>} {panel === 'calendar' && view === 'week' && } {panel === 'calendar' && view === 'month' && {setSelDate(d);setView('schedule');}}/>}