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(s
to) 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
-
+
+
+
{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 (
-