v0.12.31 multiple UI changes

This commit is contained in:
2026-03-27 10:19:52 -04:00
parent d6a37d5948
commit 97f1dace4f
10 changed files with 174 additions and 69 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "rosterchirp-backend", "name": "rosterchirp-backend",
"version": "0.12.30", "version": "0.12.31",
"description": "RosterChirp backend server", "description": "RosterChirp backend server",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {

View File

@@ -177,6 +177,20 @@ router.delete('/event-types/:id', authMiddleware, teamManagerMiddleware, async (
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
// ── User's own groups (for regular users creating events) ─────────────────────
router.get('/my-groups', authMiddleware, async (req, res) => {
try {
const groups = await query(req.schema, `
SELECT ug.id, ug.name FROM user_groups ug
JOIN user_group_members ugm ON ugm.user_group_id = ug.id
WHERE ugm.user_id = $1
ORDER BY ug.name ASC
`, [req.user.id]);
res.json({ groups });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// ── Events ──────────────────────────────────────────────────────────────────── // ── Events ────────────────────────────────────────────────────────────────────
router.get('/', authMiddleware, async (req, res) => { router.get('/', authMiddleware, async (req, res) => {
@@ -244,31 +258,55 @@ router.get('/:id', authMiddleware, async (req, res) => {
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => { router.post('/', authMiddleware, async (req, res) => {
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 } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'Title required' }); if (!title?.trim()) return res.status(400).json({ error: 'Title required' });
if (!startAt || !endAt) return res.status(400).json({ error: 'Start and end time required' }); if (!startAt || !endAt) return res.status(400).json({ error: 'Start and end time required' });
try { try {
const itm = await isToolManagerFn(req.schema, req.user);
const groupIds = Array.isArray(userGroupIds) ? userGroupIds : [];
if (!itm) {
// Regular users: must select at least one group they belong to; event always private
if (!groupIds.length) return res.status(400).json({ error: 'Select at least one group' });
for (const ugId of groupIds) {
const member = await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id=$2', [req.user.id, ugId]);
if (!member) return res.status(403).json({ error: 'You can only assign groups you belong to' });
}
}
const effectiveIsPublic = itm ? (isPublic !== false) : false;
const r = await queryResult(req.schema, ` const r = await queryResult(req.schema, `
INSERT INTO events (title,event_type_id,start_at,end_at,all_day,location,description,is_public,track_availability,recurrence_rule,created_by) INSERT INTO events (title,event_type_id,start_at,end_at,all_day,location,description,is_public,track_availability,recurrence_rule,created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id
`, [title.trim(), eventTypeId||null, startAt, endAt, !!allDay, location||null, description||null, `, [title.trim(), eventTypeId||null, startAt, endAt, !!allDay, location||null, description||null,
isPublic!==false, !!trackAvailability, recurrenceRule||null, req.user.id]); effectiveIsPublic, !!trackAvailability, recurrenceRule||null, req.user.id]);
const eventId = r.rows[0].id; const eventId = r.rows[0].id;
for (const ugId of (Array.isArray(userGroupIds) ? userGroupIds : [])) for (const ugId of groupIds)
await exec(req.schema, 'INSERT INTO event_user_groups (event_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [eventId, ugId]); await exec(req.schema, 'INSERT INTO event_user_groups (event_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [eventId, ugId]);
if (Array.isArray(userGroupIds) && userGroupIds.length > 0) if (groupIds.length > 0)
await postEventNotification(req.schema, eventId, req.user.id, false); await postEventNotification(req.schema, eventId, req.user.id, false);
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]); const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
res.json({ event: await enrichEvent(req.schema, event) }); res.json({ event: await enrichEvent(req.schema, event) });
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => { router.patch('/:id', authMiddleware, async (req, res) => {
try { try {
const event = await queryOne(req.schema, 'SELECT * 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' }); if (!event) return res.status(404).json({ error: 'Not found' });
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule, recurringScope } = req.body; const itm = await isToolManagerFn(req.schema, req.user);
if (!itm && event.created_by !== req.user.id) return res.status(403).json({ error: 'Access denied' });
let { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule, recurringScope } = req.body;
if (!itm) {
// Regular users editing their own event: force private, validate group membership
isPublic = false;
if (Array.isArray(userGroupIds)) {
if (!userGroupIds.length) return res.status(400).json({ error: 'Select at least one group' });
for (const ugId of userGroupIds) {
const member = await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id=$2', [req.user.id, ugId]);
if (!member) return res.status(403).json({ error: 'You can only assign groups you belong to' });
}
}
}
const fields = { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, recurrenceRule, origEvent: event }; const fields = { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, recurrenceRule, origEvent: event };
await applyEventUpdate(req.schema, req.params.id, fields, userGroupIds); await applyEventUpdate(req.schema, req.params.id, fields, userGroupIds);
@@ -317,10 +355,12 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => { router.delete('/:id', authMiddleware, async (req, res) => {
try { try {
const event = await queryOne(req.schema, 'SELECT * 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' }); if (!event) return res.status(404).json({ error: 'Not found' });
const itm = await isToolManagerFn(req.schema, req.user);
if (!itm && event.created_by !== req.user.id) return res.status(403).json({ error: 'Access denied' });
const { recurringScope } = req.body || {}; const { recurringScope } = req.body || {};
if (recurringScope === 'future' && event.recurrence_rule) { if (recurringScope === 'future' && event.recurrence_rule) {
// Delete this event and all future occurrences with same creator/title // Delete this event and all future occurrences with same creator/title

View File

@@ -13,7 +13,7 @@
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
set -euo pipefail set -euo pipefail
VERSION="${1:-0.12.30}" VERSION="${1:-0.12.31}"
ACTION="${2:-}" ACTION="${2:-}"
REGISTRY="${REGISTRY:-}" REGISTRY="${REGISTRY:-}"
IMAGE_NAME="rosterchirp" IMAGE_NAME="rosterchirp"

View File

@@ -1,6 +1,6 @@
{ {
"name": "rosterchirp-frontend", "name": "rosterchirp-frontend",
"version": "0.12.30", "version": "0.12.31",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -39,6 +39,18 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
return () => window.removeEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize);
}, []); }, []);
// On mobile, when the soft keyboard opens the visual viewport shrinks but the
// messages-container scroll position stays where it was, leaving the latest
// messages hidden behind the keyboard. Scroll to bottom whenever the visual
// viewport resizes (keyboard appear/dismiss) so the last message stays visible.
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const onVVResize = () => scrollToBottom();
vv.addEventListener('resize', onVVResize);
return () => vv.removeEventListener('resize', onVVResize);
}, [scrollToBottom]);
useEffect(() => { useEffect(() => {
api.getSettings().then(({ settings }) => { api.getSettings().then(({ settings }) => {
setIconGroupInfo(settings.icon_groupinfo || ''); setIconGroupInfo(settings.icon_groupinfo || '');
@@ -339,7 +351,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
This channel is read-only This channel is read-only
</div> </div>
) : ( ) : (
<MessageInput group={group} currentUser={currentUser} onSend={handleSend} socket={socket} replyTo={replyTo} onCancelReply={() => setReplyTo(null)} onTyping={() => {}} onTextChange={val => onHasTextChange?.(!!val.trim())} /> <MessageInput group={group} currentUser={currentUser} onSend={handleSend} socket={socket} replyTo={replyTo} onCancelReply={() => setReplyTo(null)} onTyping={() => {}} onTextChange={val => onHasTextChange?.(!!val.trim())} onInputFocus={() => scrollToBottom()} />
)} )}
</div> </div>
{showInfo && ( {showInfo && (

View File

@@ -12,7 +12,7 @@ function isEmojiOnly(str) {
return emojiRegex.test(str.trim()); return emojiRegex.test(str.trim());
} }
export default function MessageInput({ group, replyTo, onCancelReply, onSend, onTyping, onTextChange, onlineUserIds = new Set() }) { export default function MessageInput({ group, replyTo, onCancelReply, onSend, onTyping, onTextChange, onInputFocus, onlineUserIds = new Set() }) {
const [text, setText] = useState(''); const [text, setText] = useState('');
const [imageFile, setImageFile] = useState(null); const [imageFile, setImageFile] = useState(null);
const [imagePreview, setImagePreview] = useState(null); const [imagePreview, setImagePreview] = useState(null);
@@ -380,10 +380,11 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
<textarea <textarea
ref={inputRef} ref={inputRef}
className="msg-input" className="msg-input"
placeholder={`Message ${group?.name || ''}...`} placeholder="Text message"
value={text} value={text}
onChange={handleChange} onChange={handleChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onFocus={onInputFocus}
rows={1} rows={1}
style={{ resize: 'none' }} /> style={{ resize: 'none' }} />
</div> </div>

View File

@@ -346,7 +346,7 @@ function RecurringChoiceModal({ title, onConfirm, onCancel }) {
} }
// ── Main Mobile Event Form ──────────────────────────────────────────────────── // ── Main Mobile Event Form ────────────────────────────────────────────────────
export default function MobileEventForm({ event, eventTypes, userGroups, selectedDate, onSave, onCancel, onDelete, isToolManager }) { export default function MobileEventForm({ event, eventTypes, userGroups, selectedDate, onSave, onCancel, onDelete, isToolManager, userId }) {
const toast = useToast(); const toast = useToast();
// Use local date for default, not UTC slice (avoids off-by-one for UTC- timezones) // Use local date for default, not UTC slice (avoids off-by-one for UTC- timezones)
const defDate = selectedDate || new Date(); const defDate = selectedDate || new Date();
@@ -373,7 +373,7 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
const mountedRef = useRef(false); const mountedRef = useRef(false);
const [allDay, setAllDay] = useState(!!event?.all_day); const [allDay, setAllDay] = useState(!!event?.all_day);
const [track, setTrack] = useState(!!event?.track_availability); const [track, setTrack] = useState(!!event?.track_availability);
const [isPrivate, setIsPrivate] = useState(event ? !event.is_public : false); const [isPrivate, setIsPrivate] = useState(event ? !event.is_public : !isToolManager);
const [groups, setGroups] = useState(new Set((event?.user_groups||[]).map(g=>g.id))); const [groups, setGroups] = useState(new Set((event?.user_groups||[]).map(g=>g.id)));
const [location, setLocation] = useState(event?.location||''); const [location, setLocation] = useState(event?.location||'');
const [description, setDescription] = useState(event?.description||''); const [description, setDescription] = useState(event?.description||'');
@@ -439,6 +439,7 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
const handle = () => { const handle = () => {
if(!title.trim()) return toast('Title required','error'); if(!title.trim()) return toast('Title required','error');
if(!isToolManager && groups.size === 0) return toast('Select at least one group','error');
const startMs = new Date(buildISO(sd, allDay?'00:00':st)).getTime(); const startMs = new Date(buildISO(sd, allDay?'00:00':st)).getTime();
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');
@@ -452,7 +453,7 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
setShowScopeModal(false); setShowScopeModal(false);
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:isToolManager?!isPrivate:false, trackAvailability:track, userGroupIds:[...groups], recurrenceRule:recRule||null };
const r = event ? await api.updateEvent(event.id, {...body, recurringScope:scope}) : await api.createEvent(body); const r = event ? await api.updateEvent(event.id, {...body, recurringScope:scope}) : await api.createEvent(body);
onSave(r.event); onSave(r.event);
} catch(e) { toast(e.message,'error'); } } catch(e) { toast(e.message,'error'); }
@@ -554,11 +555,14 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
))} ))}
</div> </div>
{/* Private Event */} {/* Private Event — tool managers can toggle; regular users always private */}
<div style={{ display:'flex',alignItems:'center',padding:'14px 20px',borderBottom:'1px solid var(--border)' }}> <div style={{ display:'flex',alignItems:'center',padding:'14px 20px',borderBottom:'1px solid var(--border)' }}>
<span style={{ color:'var(--text-tertiary)',width:20,textAlign:'center',marginRight:16 }}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span> <span style={{ color:'var(--text-tertiary)',width:20,textAlign:'center',marginRight:16 }}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
<span style={{ flex:1,fontSize:15 }}>Private Event</span> <span style={{ flex:1,fontSize:15 }}>Private Event</span>
<Toggle checked={isPrivate} onChange={setIsPrivate}/> {isToolManager
? <Toggle checked={isPrivate} onChange={setIsPrivate}/>
: <span style={{ fontSize:13,color:'var(--text-tertiary)' }}>Always private</span>
}
</div> </div>
{/* Location */} {/* Location */}
@@ -572,7 +576,7 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
</MobileRow> </MobileRow>
{/* Delete */} {/* Delete */}
{event && isToolManager && ( {event && (isToolManager || (userId && event.created_by === userId)) && (
<div style={{ padding:'16px 20px' }}> <div style={{ padding:'16px 20px' }}>
<button onClick={()=>onDelete(event)} style={{ width:'100%',padding:'14px',border:'1px solid var(--error)',borderRadius:'var(--radius)',background:'transparent',color:'var(--error)',fontSize:15,fontWeight:600,cursor:'pointer' }}>Delete Event</button> <button onClick={()=>onDelete(event)} style={{ width:'100%',padding:'14px',border:'1px solid var(--error)',borderRadius:'var(--radius)',background:'transparent',color:'var(--error)',fontSize:15,fontWeight:600,cursor:'pointer' }}>Delete Event</button>
</div> </div>

View File

@@ -538,7 +538,7 @@ function ConfirmModal({ title, message, confirmLabel='Delete', onConfirm, onCanc
} }
// ── Event Form ──────────────────────────────────────────────────────────────── // ── Event Form ────────────────────────────────────────────────────────────────
function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCancel, onDelete, isToolManager }) { function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCancel, onDelete, isToolManager, userId }) {
const toast=useToast(); const toast=useToast();
const _defD = selectedDate || new Date(); const _defD = selectedDate || new Date();
const _p = n => String(n).padStart(2,'0'); const _p = n => String(n).padStart(2,'0');
@@ -552,7 +552,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
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||'');
const [pub,setPub]=useState(event?!!event.is_public:true); const [pub,setPub]=useState(event?!!event.is_public:!!isToolManager);
const [track,setTrack]=useState(!!event?.track_availability); const [track,setTrack]=useState(!!event?.track_availability);
const [grps,setGrps]=useState(new Set((event?.user_groups||[]).map(g=>g.id))); const [grps,setGrps]=useState(new Set((event?.user_groups||[]).map(g=>g.id)));
const [saving,setSaving]=useState(false); const [saving,setSaving]=useState(false);
@@ -628,12 +628,12 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
useEffect(()=>{ mountedRef.current = true; },[]); useEffect(()=>{ mountedRef.current = true; },[]);
const toggleGrp=id=>setGrps(prev=>{const n=new Set(prev);n.has(id)?n.delete(id):n.add(id);return n;}); 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 groupsRequired = track || !isToolManager; // tracking requires groups; non-managers always require groups
const handle=()=>{ const handle=()=>{
if(!title.trim()) return toast('Title required','error'); if(!title.trim()) return toast('Title required','error');
if(!allDay&&(!sd||!st||!ed||!et)) return toast('Start and end 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(groupsRequired&&grps.size===0) return toast('Select at least one group','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');
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 && new Date(buildISO(sd,st)) < new Date()) return toast('Start date and time cannot be in the past','error');
@@ -645,7 +645,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
setShowScopeModal(false); setShowScopeModal(false);
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:isToolManager?pub:false,trackAvailability:track,userGroupIds:[...grps],recurrenceRule:recRule||null};
const r=event?await api.updateEvent(event.id,{...body,recurringScope:scope}):await api.createEvent(body); const r=event?await api.updateEvent(event.id,{...body,recurringScope:scope}):await api.createEvent(body);
onSave(r.event); onSave(r.event);
}catch(e){toast(e.message,'error');}finally{setSaving(false);} }catch(e){toast(e.message,'error');}finally{setSaving(false);}
@@ -725,14 +725,14 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
</div> </div>
<p style={{fontSize:11,color:groupsRequired&&grps.size===0?'var(--error)':'var(--text-tertiary)',marginTop:4}}> <p style={{fontSize:11,color:groupsRequired&&grps.size===0?'var(--error)':'var(--text-tertiary)',marginTop:4}}>
{grps.size===0 {grps.size===0
? (groupsRequired?'At least one group required for availability tracking':'No groups — event visible to all (if public)') ? (groupsRequired?'At least one group required':'No groups — event visible to all (if public)')
: `${grps.size} group${grps.size!==1?'s':''} selected`} : `${grps.size} group${grps.size!==1?'s':''} selected`}
</p> </p>
</div> </div>
</FormRow> </FormRow>
{/* Visibility — only shown if groups selected OR tracking */} {/* Visibility — only tool managers can set; regular users always create private events */}
{(grps.size>0||track) && ( {isToolManager && (grps.size>0||track) && (
<FormRow label="Visibility"> <FormRow label="Visibility">
<label style={{display:'flex',alignItems:'center',gap:10,fontSize:13,cursor:'pointer',paddingTop:6}}> <label style={{display:'flex',alignItems:'center',gap:10,fontSize:13,cursor:'pointer',paddingTop:6}}>
<input type="checkbox" checked={!pub} onChange={e=>setPub(!e.target.checked)}/> <input type="checkbox" checked={!pub} onChange={e=>setPub(!e.target.checked)}/>
@@ -754,7 +754,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
<div style={{display:'flex',gap:8,marginTop:8}}> <div style={{display:'flex',gap:8,marginTop:8}}>
<button className="btn btn-primary btn-sm" onClick={handle} disabled={saving}>{saving?'Saving…':event?'Save Changes':'Create Event'}</button> <button className="btn btn-primary btn-sm" onClick={handle} disabled={saving}>{saving?'Saving…':event?'Save Changes':'Create Event'}</button>
<button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button> <button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button>
{event&&isToolManager&&<button className="btn btn-sm" style={{marginLeft:'auto',background:'var(--error)',color:'white'}} onClick={()=>onDelete(event)}>Delete</button>} {event&&(isToolManager||(userId&&event.created_by===userId))&&<button className="btn btn-sm" style={{marginLeft:'auto',background:'var(--error)',color:'white'}} onClick={()=>onDelete(event)}>Delete</button>}
</div> </div>
</div> </div>
</div> </div>
@@ -764,7 +764,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
} }
// ── Event Detail Modal ──────────────────────────────────────────────────────── // ── Event Detail Modal ────────────────────────────────────────────────────────
function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isToolManager }) { function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isToolManager, userId }) {
const toast=useToast(); const toast=useToast();
const [myResp,setMyResp]=useState(event.my_response); const [myResp,setMyResp]=useState(event.my_response);
const [avail,setAvail]=useState(event.availability||[]); const [avail,setAvail]=useState(event.availability||[]);
@@ -800,7 +800,7 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
</div> </div>
</div> </div>
<div style={{display:'flex',gap:6,flexShrink:0}}> <div style={{display:'flex',gap:6,flexShrink:0}}>
{isToolManager&&<button className="btn btn-secondary btn-sm" onClick={()=>{onClose();onEdit();}}>Edit</button>} {(isToolManager||(userId&&event.created_by===userId))&&<button className="btn btn-secondary btn-sm" onClick={()=>{onClose();onEdit();}}>Edit</button>}
<button className="btn-icon" onClick={onClose}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button> <button className="btn-icon" onClick={onClose}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div> </div>
</div> </div>
@@ -1081,6 +1081,10 @@ function parseKeywords(raw) {
function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filterTypeId='', filterAvailability=false, filterFromDate=null, isMobile=false }) { function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filterTypeId='', filterAvailability=false, filterFromDate=null, isMobile=false }) {
const y=selectedDate.getFullYear(), m=selectedDate.getMonth(); const y=selectedDate.getFullYear(), m=selectedDate.getMonth();
const today=new Date(); today.setHours(0,0,0,0); const today=new Date(); today.setHours(0,0,0,0);
const todayRef = useRef(null);
useEffect(()=>{
if(todayRef.current) todayRef.current.scrollIntoView({ block:'start', behavior:'instant' });
},[selectedDate.getFullYear(), selectedDate.getMonth()]);
const terms=parseKeywords(filterKeyword); const terms=parseKeywords(filterKeyword);
const hasFilters = terms.length > 0 || !!filterTypeId || filterAvailability; const hasFilters = terms.length > 0 || !!filterTypeId || filterAvailability;
// Only keyword/availability filters should shift the date window to today-onwards. // Only keyword/availability filters should shift the date window to today-onwards.
@@ -1135,9 +1139,13 @@ function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filter
? `No events — ${MONTHS[m]} ${y} is in the past` ? `No events — ${MONTHS[m]} ${y} is in the past`
: `No events in ${MONTHS[m]} ${y}`; : `No events in ${MONTHS[m]} ${y}`;
if(!filtered.length) return <div style={{textAlign:'center',padding:'60px 20px',color:'var(--text-tertiary)',fontSize:14}}>{emptyMsg}</div>; if(!filtered.length) return <div style={{textAlign:'center',padding:'60px 20px',color:'var(--text-tertiary)',fontSize:14}}>{emptyMsg}</div>;
let todayMarked = false;
return <>{filtered.map(e=>{ return <>{filtered.map(e=>{
const s=new Date(e.start_at); const s=new Date(e.start_at);
const end=new Date(e.end_at); const end=new Date(e.end_at);
const sDay=new Date(s); sDay.setHours(0,0,0,0);
const isFirstTodayOrFuture = !todayMarked && sDay >= today;
if(isFirstTodayOrFuture) todayMarked = true;
const isPast = !e.all_day && end < now; // event fully ended const isPast = !e.all_day && end < now; // event fully ended
const col = isPast ? '#9ca3af' : (e.event_type?.colour||'#9ca3af'); const col = isPast ? '#9ca3af' : (e.event_type?.colour||'#9ca3af');
const textColor = isPast ? 'var(--text-tertiary)' : 'var(--text-primary)'; const textColor = isPast ? 'var(--text-tertiary)' : 'var(--text-primary)';
@@ -1158,7 +1166,7 @@ function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filter
: BELL_ICON : BELL_ICON
); );
return( return(
<div key={`${e.id}-${e.start_at}`} onClick={()=>onSelect(e)} style={{display:'flex',alignItems:'center',gap:rowGap,padding:rowPad,borderBottom:'1px solid var(--border)',cursor:'pointer',opacity:isPast?0.7:1}} onMouseEnter={el=>el.currentTarget.style.background='var(--background)'} onMouseLeave={el=>el.currentTarget.style.background=''}> <div key={`${e.id}-${e.start_at}`} ref={isFirstTodayOrFuture ? todayRef : null} onClick={()=>onSelect(e)} style={{display:'flex',alignItems:'center',gap:rowGap,padding:rowPad,borderBottom:'1px solid var(--border)',cursor:'pointer',opacity:isPast?0.7:1}} onMouseEnter={el=>el.currentTarget.style.background='var(--background)'} onMouseLeave={el=>el.currentTarget.style.background=''}>
{/* Date column */} {/* Date column */}
<div style={{width:datW,textAlign:'center',flexShrink:0}}> <div style={{width:datW,textAlign:'center',flexShrink:0}}>
<div style={{fontSize:datFs,fontWeight:700,lineHeight:1,color:textColor}}>{s.getDate()}</div> <div style={{fontSize:datFs,fontWeight:700,lineHeight:1,color:textColor}}>{s.getDate()}</div>
@@ -1254,7 +1262,12 @@ function DayView({ events: rawEvents, selectedDate, onSelect, onSwipe }) {
const tzLabel=`GMT${tzOff>=0?'+':'-'}${String(Math.floor(Math.abs(tzOff)/60)).padStart(2,'0')}`; const tzLabel=`GMT${tzOff>=0?'+':'-'}${String(Math.floor(Math.abs(tzOff)/60)).padStart(2,'0')}`;
const scrollRef = useRef(null); const scrollRef = useRef(null);
const touchRef = useRef({ x:0, y:0 }); const touchRef = useRef({ x:0, y:0 });
useEffect(()=>{ if(scrollRef.current) scrollRef.current.scrollTop = 7 * HOUR_H; },[selectedDate]); useEffect(()=>{
if(!scrollRef.current) return;
const now = new Date();
const topPx = Math.max(0, now.getHours() * HOUR_H + (now.getMinutes() / 60) * HOUR_H - 2 * HOUR_H);
scrollRef.current.scrollTop = topPx;
},[selectedDate]);
const fmtHour = h => h===0?'12 AM':h<12?`${h} AM`:h===12?'12 PM':`${h-12} PM`; const fmtHour = h => h===0?'12 AM':h<12?`${h} AM`:h===12?'12 PM':`${h-12} PM`;
const handleTouchStart = e => { touchRef.current = { x:e.touches[0].clientX, y:e.touches[0].clientY }; }; const handleTouchStart = e => { touchRef.current = { x:e.touches[0].clientX, y:e.touches[0].clientY }; };
const handleTouchEnd = e => { const handleTouchEnd = e => {
@@ -1325,7 +1338,12 @@ function WeekView({ events: rawEvents, selectedDate, onSelect }) {
const tzLabel=`GMT${tzOff>=0?'+':'-'}${String(Math.floor(Math.abs(tzOff)/60)).padStart(2,'0')}`; const tzLabel=`GMT${tzOff>=0?'+':'-'}${String(Math.floor(Math.abs(tzOff)/60)).padStart(2,'0')}`;
const scrollRef = useRef(null); const scrollRef = useRef(null);
const touchRef = useRef({ x:0, y:0 }); const touchRef = useRef({ x:0, y:0 });
useEffect(()=>{ if(scrollRef.current) scrollRef.current.scrollTop = 7 * HOUR_H; },[selectedDate]); useEffect(()=>{
if(!scrollRef.current) return;
const now = new Date();
const topPx = Math.max(0, now.getHours() * HOUR_H + (now.getMinutes() / 60) * HOUR_H - 2 * HOUR_H);
scrollRef.current.scrollTop = topPx;
},[selectedDate]);
const fmtHour = h => h===0?'12 AM':h<12?`${h} AM`:h===12?'12 PM':`${h-12} PM`; const fmtHour = h => h===0?'12 AM':h<12?`${h} AM`:h===12?'12 PM':`${h-12} PM`;
const handleTouchStart = e => { touchRef.current = { x:e.touches[0].clientX, y:e.touches[0].clientY }; }; const handleTouchStart = e => { touchRef.current = { x:e.touches[0].clientX, y:e.touches[0].clientY }; };
const handleTouchEnd = e => { const handleTouchEnd = e => {
@@ -1476,7 +1494,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
const contentRef = useRef(null); const contentRef = useRef(null);
const load = useCallback(() => { const load = useCallback(() => {
const ugPromise = isToolManager ? api.getUserGroups() : Promise.resolve({ groups: [] }); const ugPromise = isToolManager ? api.getUserGroups() : api.getMyScheduleGroups();
Promise.all([api.getEvents(), api.getEventTypes(), ugPromise]) Promise.all([api.getEvents(), api.getEventTypes(), ugPromise])
.then(([ev,et,ug]) => { setEvents(ev.events||[]); setEventTypes(et.eventTypes||[]); setUserGroups(ug.groups||[]); setLoading(false); }) .then(([ev,et,ug]) => { setEvents(ev.events||[]); setEventTypes(et.eventTypes||[]); setUserGroups(ug.groups||[]); setLoading(false); })
.catch(() => setLoading(false)); .catch(() => setLoading(false));
@@ -1484,8 +1502,8 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
// Reset scroll to top whenever the selected date or view changes // Reset scroll to top on date/view change; schedule view scrolls to today via ScheduleView's own effect
useEffect(() => { if (contentRef.current) contentRef.current.scrollTop = 0; }, [selDate, view]); useEffect(() => { if (contentRef.current && view !== 'schedule') contentRef.current.scrollTop = 0; }, [selDate, view]);
useEffect(() => { useEffect(() => {
if (!createOpen) return; if (!createOpen) return;
@@ -1556,26 +1574,27 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
<div style={{ padding:'16px 16px 0' }}> <div style={{ padding:'16px 16px 0' }}>
<div style={{ fontSize:16, fontWeight:700, marginBottom:12, color:'var(--text-primary)' }}>Team Schedule</div> <div style={{ fontSize:16, fontWeight:700, marginBottom:12, color:'var(--text-primary)' }}>Team Schedule</div>
{/* Create button — styled like new-chat-btn */} {/* Create button — visible to all users */}
{isToolManager && ( <div style={{ position:'relative', marginBottom:12 }} ref={createRef}>
<div style={{ position:'relative', marginBottom:12 }} ref={createRef}> <button className="newchat-btn" onClick={() => setCreateOpen(v=>!v)} style={{ width:'100%', justifyContent:'center', gap:8 }}>
<button className="newchat-btn" onClick={() => setCreateOpen(v=>!v)} style={{ width:'100%', justifyContent:'center', gap:8 }}> Create Event
Create Event {isToolManager && <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="6 9 12 15 18 9"/></svg>}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="6 9 12 15 18 9"/></svg> </button>
</button> {createOpen && (
{createOpen && ( <div style={{ position:'absolute', top:'100%', left:0, right:0, zIndex:100, background:'var(--surface-variant)', border:'1px solid var(--border)', borderRadius:'var(--radius)', marginTop:4, boxShadow:'0 4px 16px rgba(0,0,0,0.18)' }}>
<div style={{ position:'absolute', top:'100%', left:0, right:0, zIndex:100, background:'var(--surface-variant)', border:'1px solid var(--border)', borderRadius:'var(--radius)', marginTop:4, boxShadow:'0 4px 16px rgba(0,0,0,0.18)' }}> {[
{[['Event', ()=>{setPanel('eventForm');setEditingEvent(null);setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}], ['Event', ()=>{setPanel('eventForm');setEditingEvent(null);setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}],
...(isToolManager ? [
['Event Type', ()=>{setPanel('eventTypes');setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}], ['Event Type', ()=>{setPanel('eventTypes');setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}],
['Bulk Event Import', ()=>{setPanel('bulkImport');setCreateOpen(false);}] ['Bulk Event Import', ()=>{setPanel('bulkImport');setCreateOpen(false);}],
].map(([label,action])=>( ] : []),
<button key={label} onClick={action} style={{display:'block',width:'100%',padding:'9px 16px',textAlign:'left',fontSize:14,background:'none',border:'none',cursor:'pointer',color:'var(--text-primary)'}} ].map(([label,action])=>(
onMouseEnter={e=>e.currentTarget.style.background='var(--background)'} onMouseLeave={e=>e.currentTarget.style.background=''}>{label}</button> <button key={label} onClick={action} style={{display:'block',width:'100%',padding:'9px 16px',textAlign:'left',fontSize:14,background:'none',border:'none',cursor:'pointer',color:'var(--text-primary)'}}
))} onMouseEnter={e=>e.currentTarget.style.background='var(--background)'} onMouseLeave={e=>e.currentTarget.style.background=''}>{label}</button>
</div> ))}
)} </div>
</div> )}
)} </div>
</div> </div>
{/* Mini calendar */} {/* Mini calendar */}
@@ -1691,10 +1710,10 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
{panel === 'calendar' && view === 'week' && <WeekView events={events} selectedDate={selDate} onSelect={openDetail}/>} {panel === 'calendar' && view === 'week' && <WeekView events={events} selectedDate={selDate} onSelect={openDetail}/>}
{panel === 'calendar' && view === 'month' && <MonthView events={events} selectedDate={selDate} onSelect={openDetail} onSelectDay={d=>{setSelDate(d);setView('day');}}/>} {panel === 'calendar' && view === 'month' && <MonthView events={events} selectedDate={selDate} onSelect={openDetail} onSelectDay={d=>{setSelDate(d);setView('day');}}/>}
{panel === 'eventForm' && isToolManager && !isMobile && ( {panel === 'eventForm' && !isMobile && (
<div style={{ padding:28, maxWidth:1024 }}> <div style={{ padding:28, maxWidth:1024 }}>
<h2 style={{ fontSize:17, fontWeight:700, marginBottom:24 }}>{editingEvent?'Edit Event':'New Event'}</h2> <h2 style={{ fontSize:17, fontWeight:700, marginBottom:24 }}>{editingEvent?'Edit Event':'New Event'}</h2>
<EventForm event={editingEvent} userGroups={userGroups} eventTypes={eventTypes} selectedDate={selDate} isToolManager={isToolManager} <EventForm event={editingEvent} userGroups={userGroups} eventTypes={eventTypes} selectedDate={selDate} isToolManager={isToolManager} userId={user.id}
onSave={handleSaved} onCancel={()=>{setPanel('calendar');setEditingEvent(null);setFilterKeyword('');setFilterTypeId('');}} onDelete={handleDelete}/> onSave={handleSaved} onCancel={()=>{setPanel('calendar');setEditingEvent(null);setFilterKeyword('');setFilterTypeId('');}} onDelete={handleDelete}/>
</div> </div>
)} )}
@@ -1739,7 +1758,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
<MobileGroupManager onClose={() => setMobilePanel(null)}/> <MobileGroupManager onClose={() => setMobilePanel(null)}/>
</div> </div>
)} )}
{panel === 'eventForm' && isToolManager && isMobile && ( {panel === 'eventForm' && isMobile && (
<div style={{ position:'fixed', top:0, left:0, right:0, bottom:0, zIndex:40, background:'var(--background)', display:'flex', flexDirection:'column' }}> <div style={{ position:'fixed', top:0, left:0, right:0, bottom:0, zIndex:40, background:'var(--background)', display:'flex', flexDirection:'column' }}>
<MobileEventForm <MobileEventForm
event={editingEvent} event={editingEvent}
@@ -1747,6 +1766,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
eventTypes={eventTypes} eventTypes={eventTypes}
selectedDate={selDate} selectedDate={selDate}
isToolManager={isToolManager} isToolManager={isToolManager}
userId={user.id}
onSave={handleSaved} onSave={handleSaved}
onCancel={()=>{setPanel('calendar');setEditingEvent(null);setFilterKeyword('');setFilterTypeId('');}} onCancel={()=>{setPanel('calendar');setEditingEvent(null);setFilterKeyword('');setFilterTypeId('');}}
onDelete={handleDelete} /> onDelete={handleDelete} />
@@ -1754,14 +1774,17 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
)} )}
{/* Mobile FAB — same position as Messages newchat-fab */} {/* Mobile FAB — same position as Messages newchat-fab */}
{isMobile && isToolManager && panel === 'calendar' && ( {isMobile && panel === 'calendar' && (
<div ref={createRef} style={{ position:'fixed', bottom:80, right:16, zIndex:30 }}> <div ref={createRef} style={{ position:'fixed', bottom:80, right:16, zIndex:30 }}>
<button className="newchat-fab" style={{ position:'static' }} onClick={() => setCreateOpen(v=>!v)}> <button className="newchat-fab" style={{ position:'static' }} onClick={() => {
if (isToolManager) { setCreateOpen(v=>!v); }
else { setPanel('eventForm'); setEditingEvent(null); setFilterKeyword(''); setFilterTypeId(''); }
}}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" width="24" height="24"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" width="24" height="24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> <path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg> </svg>
</button> </button>
{createOpen && ( {isToolManager && createOpen && (
<div style={{ position:'absolute', bottom:'calc(100% + 8px)', right:0, zIndex:100, background:'var(--surface-variant)', border:'1px solid var(--border)', borderRadius:'var(--radius)', boxShadow:'0 -4px 16px rgba(0,0,0,0.15)', minWidth:180 }}> <div style={{ position:'absolute', bottom:'calc(100% + 8px)', right:0, zIndex:100, background:'var(--surface-variant)', border:'1px solid var(--border)', borderRadius:'var(--radius)', boxShadow:'0 -4px 16px rgba(0,0,0,0.15)', minWidth:180 }}>
{[['Event', ()=>{setPanel('eventForm');setEditingEvent(null);setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}], {[['Event', ()=>{setPanel('eventForm');setEditingEvent(null);setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}],
['Event Type', ()=>{setPanel('eventTypes');setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}], ['Event Type', ()=>{setPanel('eventTypes');setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}],
@@ -1779,6 +1802,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
<EventDetailModal <EventDetailModal
event={detailEvent} event={detailEvent}
isToolManager={isToolManager} isToolManager={isToolManager}
userId={user.id}
onClose={() => setDetailEvent(null)} onClose={() => setDetailEvent(null)}
onEdit={() => { setEditingEvent(detailEvent); setPanel('eventForm'); setDetailEvent(null); }} onEdit={() => { setEditingEvent(detailEvent); setPanel('eventForm'); setDetailEvent(null); }}
onAvailabilityChange={(resp) => { onAvailabilityChange={(resp) => {

View File

@@ -447,27 +447,50 @@ export default function Chat() {
setActiveGroupId(id); setActiveGroupId(id);
if (isMobile) { if (isMobile) {
setShowSidebar(false); setShowSidebar(false);
// Push a history entry so swipe-back returns to sidebar instead of exiting the app // The mount sentinel covers the first back gesture — no extra push needed here
window.history.pushState({ rosterchirpChatOpen: true }, '');
} }
// Clear notifications and unread count for this group // Clear notifications and unread count for this group
setNotifications(prev => prev.filter(n => n.groupId !== id)); setNotifications(prev => prev.filter(n => n.groupId !== id));
setUnreadGroups(prev => { const next = new Map(prev); next.delete(id); return next; }); setUnreadGroups(prev => { const next = new Map(prev); next.delete(id); return next; });
}; };
// Handle browser back gesture on mobile — return to sidebar instead of exiting // Establish one history sentinel on mount (mobile only) so back gestures are
// always interceptable without accumulating extra entries.
useEffect(() => { useEffect(() => {
const handlePopState = (e) => { if (window.innerWidth < 768) {
if (isMobile && activeGroupId) { window.history.replaceState({ rc: 'chat' }, '');
}
}, []);
// Handle browser back gesture on mobile — step through the navigation hierarchy:
// chat open → list view for the current page → Messages → exit app
useEffect(() => {
const handlePopState = () => {
if (!isMobile) return;
if (activeGroupId) {
// Close the open chat, stay on the current page's list (chat or groupmessages)
setShowSidebar(true); setShowSidebar(true);
setActiveGroupId(null); setActiveGroupId(null);
// Push another entry so subsequent back gestures are also intercepted setChatHasText(false);
window.history.pushState({ rosterchirpChatOpen: true }, ''); window.history.pushState({ rc: 'chat' }, '');
return;
} }
if (page !== 'chat') {
// On a secondary page (groupmessages / users / groups / schedule / hostpanel)
// — return to the default Messages page
setPage('chat');
window.history.pushState({ rc: 'chat' }, '');
return;
}
// Already at root (Messages list, no chat open) — let the browser handle
// it so the next gesture actually exits the PWA. Don't re-push.
}; };
window.addEventListener('popstate', handlePopState); window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState);
}, [isMobile, activeGroupId]); }, [isMobile, activeGroupId, page]);
// Update page title AND PWA app badge with total unread count // Update page title AND PWA app badge with total unread count
useEffect(() => { useEffect(() => {

View File

@@ -106,6 +106,7 @@ export const api = {
updateTeamSettings: (body) => req('PATCH', '/settings/team', body), updateTeamSettings: (body) => req('PATCH', '/settings/team', body),
// Schedule Manager // Schedule Manager
getMyScheduleGroups: () => req('GET', '/schedule/my-groups'),
getEventTypes: () => req('GET', '/schedule/event-types'), getEventTypes: () => req('GET', '/schedule/event-types'),
createEventType: (body) => req('POST', '/schedule/event-types', body), createEventType: (body) => req('POST', '/schedule/event-types', body),
updateEventType: (id, body) => req('PATCH', `/schedule/event-types/${id}`, body), updateEventType: (id, body) => req('PATCH', `/schedule/event-types/${id}`, body),