From ba91fce44cb9ed09562ab3f3e4af0c13eb899b21 Mon Sep 17 00:00:00 2001 From: Ricky Stretch Date: Wed, 25 Mar 2026 13:00:43 -0400 Subject: [PATCH] v0.12.28 new modal window for event edit/delete --- CLAUDE.md | 6 +- backend/package.json | 2 +- backend/src/routes/schedule.js | 30 +++++++-- build.sh | 2 +- frontend/package.json | 2 +- frontend/src/components/MobileEventForm.jsx | 42 +++++++++--- frontend/src/components/SchedulePage.jsx | 74 ++++++++++++++++++--- frontend/src/utils/api.js | 2 +- 8 files changed, 130 insertions(+), 30 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 20c87e0..4cecb57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ **RosterChirp** is a self-hosted, closed-source, full-stack Progressive Web App for team messaging. It supports both single-tenant (selfhost) and multi-tenant (host) deployments. -**Current version:** 0.12.27 +**Current version:** 0.12.28 --- @@ -106,7 +106,7 @@ rosterchirp/ ## Version Bump — Files to Update -When bumping the version (e.g. 0.12.27 → 0.12.28), update **all three**: +When bumping the version (e.g. 0.12.28 → 0.12.29), update **all three**: ``` backend/package.json "version": "X.Y.Z" @@ -116,7 +116,7 @@ build.sh VERSION="${1:-X.Y.Z}" One-liner: ```bash -OLD=0.12.27; NEW=0.12.28 +OLD=0.12.28; NEW=0.12.29 sed -i "s/\"version\": \"$OLD\"/\"version\": \"$NEW\"/" backend/package.json frontend/package.json sed -i "s/VERSION=\"\${1:-$OLD}\"/VERSION=\"\${1:-$NEW}\"/" build.sh ``` diff --git a/backend/package.json b/backend/package.json index 2063154..ffb4d7b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-backend", - "version": "0.12.27", + "version": "0.12.28", "description": "RosterChirp backend server", "main": "src/index.js", "scripts": { diff --git a/backend/src/routes/schedule.js b/backend/src/routes/schedule.js index 0b8c924..9148862 100644 --- a/backend/src/routes/schedule.js +++ b/backend/src/routes/schedule.js @@ -273,7 +273,7 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => await applyEventUpdate(req.schema, req.params.id, fields, userGroupIds); - // Recurring future scope — update all future occurrences + // Recurring future scope — update this and all future occurrences if (recurringScope === 'future' && event.recurrence_rule) { const futureEvents = await query(req.schema, ` SELECT id FROM events WHERE id!=$1 AND created_by=$2 AND recurrence_rule IS NOT NULL @@ -282,6 +282,14 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => for (const fe of futureEvents) await applyEventUpdate(req.schema, fe.id, fields, userGroupIds); } + // Recurring all scope — update every occurrence + if (recurringScope === 'all' && event.recurrence_rule) { + const allEvents = await query(req.schema, ` + SELECT id FROM events WHERE id!=$1 AND created_by=$2 AND recurrence_rule IS NOT NULL AND title=$3 + `, [req.params.id, event.created_by, event.title]); + for (const ae of allEvents) + await applyEventUpdate(req.schema, ae.id, fields, userGroupIds); + } // Clean up availability for users removed from groups if (Array.isArray(userGroupIds)) { @@ -311,9 +319,23 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => { try { - if (!(await queryOne(req.schema, 'SELECT id FROM events WHERE id=$1', [req.params.id]))) - return res.status(404).json({ error: 'Not found' }); - await exec(req.schema, 'DELETE FROM events WHERE id=$1', [req.params.id]); + const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]); + if (!event) return res.status(404).json({ error: 'Not found' }); + const { recurringScope } = req.body || {}; + if (recurringScope === 'future' && event.recurrence_rule) { + // Delete this event and all future occurrences with same creator/title + await exec(req.schema, ` + DELETE FROM events WHERE created_by=$1 AND recurrence_rule IS NOT NULL + AND title=$2 AND start_at >= $3 + `, [event.created_by, event.title, event.start_at]); + } else if (recurringScope === 'all' && event.recurrence_rule) { + // Delete every occurrence + await exec(req.schema, ` + DELETE FROM events WHERE created_by=$1 AND recurrence_rule IS NOT NULL AND title=$2 + `, [event.created_by, event.title]); + } else { + await exec(req.schema, 'DELETE FROM events WHERE id=$1', [req.params.id]); + } res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); diff --git a/build.sh b/build.sh index 5557a67..9e6f4dc 100644 --- a/build.sh +++ b/build.sh @@ -13,7 +13,7 @@ # ───────────────────────────────────────────────────────────── set -euo pipefail -VERSION="${1:-0.12.27}" +VERSION="${1:-0.12.28}" ACTION="${2:-}" REGISTRY="${REGISTRY:-}" IMAGE_NAME="rosterchirp" diff --git a/frontend/package.json b/frontend/package.json index f25f72c..19b1eed 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "rosterchirp-frontend", - "version": "0.12.27", + "version": "0.12.28", "private": true, "scripts": { "dev": "vite", diff --git a/frontend/src/components/MobileEventForm.jsx b/frontend/src/components/MobileEventForm.jsx index a5b5bd2..11063fe 100644 --- a/frontend/src/components/MobileEventForm.jsx +++ b/frontend/src/components/MobileEventForm.jsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from 'react'; +import ReactDOM from 'react-dom'; import { api } from '../utils/api.js'; import ColourPickerSheet from './ColourPickerSheet.jsx'; import { useToast } from '../contexts/ToastContext.jsx'; @@ -319,6 +320,31 @@ function MobileRow({ icon, label, children, onPress, border=true }) { ); } +// ── Recurring choice modal ──────────────────────────────────────────────────── +function RecurringChoiceModal({ title, onConfirm, onCancel }) { + const [choice, setChoice] = useState('this'); + return ReactDOM.createPortal( +
e.target===e.currentTarget&&onCancel()}> +
+

{title}

+
+ {[['this','This event'],['future','This and following events'],['all','All events']].map(([val,label])=>( + + ))} +
+
+ + +
+
+
, + document.body + ); +} + // ── Main Mobile Event Form ──────────────────────────────────────────────────── export default function MobileEventForm({ event, eventTypes, userGroups, selectedDate, onSave, onCancel, onDelete, isToolManager }) { const toast = useToast(); @@ -353,6 +379,7 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte const [description, setDescription] = useState(event?.description||''); const [recRule, setRecRule] = useState(event?.recurrence_rule||null); const [saving, setSaving] = useState(false); + const [showScopeModal, setShowScopeModal] = useState(false); // Overlay state const [showStartDate, setShowStartDate] = useState(false); @@ -410,24 +437,22 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte setEt(toTimeIn(endIso)); }, [sd, st, typeId]); - const handle = async () => { + const handle = () => { 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'); - // 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'); + if(event && event.recurrence_rule?.freq) { setShowScopeModal(true); return; } + doSave('this'); + }; + const doSave = async (scope) => { + setShowScopeModal(false); 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 }; - 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'); } @@ -558,6 +583,7 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte {showStartDate && {setSd(v);setShowStartDate(false);}} onClose={()=>setShowStartDate(false)}/>} {showEndDate && {setEd(v);setShowEndDate(false);}} onClose={()=>setShowEndDate(false)}/>} {showRecurrence && {setRecRule(v);}} onClose={()=>setShowRecurrence(false)}/>} + {showScopeModal && setShowScopeModal(false)}/>} {showTypeColourPicker && ( setShowTypeColourPicker(false)} title="Event Type Colour"/> )} diff --git a/frontend/src/components/SchedulePage.jsx b/frontend/src/components/SchedulePage.jsx index d6e9ee2..c7a7aad 100644 --- a/frontend/src/components/SchedulePage.jsx +++ b/frontend/src/components/SchedulePage.jsx @@ -495,6 +495,48 @@ function FormRow({ label, children, required }) { ); } +// ── Recurring choice modal ──────────────────────────────────────────────────── +function RecurringChoiceModal({ title, onConfirm, onCancel }) { + const [choice, setChoice] = useState('this'); + return ReactDOM.createPortal( +
e.target===e.currentTarget&&onCancel()}> +
+

{title}

+
+ {[['this','This event'],['future','This and following events'],['all','All events']].map(([val,label])=>( + + ))} +
+
+ + +
+
+
, + document.body + ); +} + +// ── Confirm modal (non-recurring delete) ────────────────────────────────────── +function ConfirmModal({ title, message, confirmLabel='Delete', onConfirm, onCancel }) { + return ReactDOM.createPortal( +
e.target===e.currentTarget&&onCancel()}> +
+

{title}

+

{message}

+
+ + +
+
+
, + document.body + ); +} + // ── Event Form ──────────────────────────────────────────────────────────────── function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCancel, onDelete, isToolManager }) { const toast=useToast(); @@ -517,6 +559,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc const [showTypeForm,setShowTypeForm]=useState(false); const [localTypes,setLocalTypes]=useState(eventTypes); const [recRule,setRecRule]=useState(event?.recurrence_rule||null); + const [showScopeModal,setShowScopeModal]=useState(false); // Sync localTypes when parent provides updated eventTypes (e.g. after async load) // Also initialise typeId to the default event type for new events useEffect(()=>{ @@ -587,23 +630,22 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc const toggleGrp=id=>setGrps(prev=>{const n=new Set(prev);n.has(id)?n.delete(id):n.add(id);return n;}); const groupsRequired=track; // when tracking, groups are required - const handle=async()=>{ + const handle=()=>{ if(!title.trim()) return toast('Title required','error'); 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'); if(ed{ + setShowScopeModal(false); 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: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);} @@ -715,6 +757,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc + {showScopeModal&&setShowScopeModal(false)}/>} ); } @@ -1483,15 +1526,18 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel }; const handleSaved = () => { load(); setPanel('calendar'); setEditingEvent(null); }; - const handleDelete = async e => { - if (!confirm(`Delete "${e.title}"?`)) return; + const [deleteTarget, setDeleteTarget] = useState(null); + const handleDelete = (e) => setDeleteTarget(e); + const doDelete = async (scope = 'this') => { + const e = deleteTarget; + setDeleteTarget(null); try { - await api.deleteEvent(e.id); + await api.deleteEvent(e.id, scope); toast('Deleted','success'); setPanel('calendar'); setEditingEvent(null); setDetailEvent(null); - load(); // reload list so deleted event disappears immediately + load(); } catch(err) { toast(err.message,'error'); } }; @@ -1679,6 +1725,12 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel )} + {/* Delete confirmation modals */} + {deleteTarget && deleteTarget.recurrence_rule?.freq + ? setDeleteTarget(null)}/> + : deleteTarget && doDelete('this')} onCancel={()=>setDeleteTarget(null)}/> + } + {/* Fixed overlays — position:fixed so they escape layout, can live anywhere in tree */} {isMobile && mobilePanel === 'groupManager' && (
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 0ca2efd..11f1641 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -117,7 +117,7 @@ export const api = { getEvent: (id) => req('GET', `/schedule/${id}`), createEvent: (body) => req('POST', '/schedule', body), // body may include recurrenceRule: {freq,interval,byDay,ends,endDate,endCount} updateEvent: (id, body) => req('PATCH', `/schedule/${id}`, body), - deleteEvent: (id) => req('DELETE', `/schedule/${id}`), + deleteEvent: (id, scope = 'this') => req('DELETE', `/schedule/${id}`, { recurringScope: scope }), setAvailability: (id, response) => req('PUT', `/schedule/${id}/availability`, { response }), deleteAvailability: (id) => req('DELETE', `/schedule/${id}/availability`), getPendingAvailability: () => req('GET', '/schedule/me/pending'),