add the option for the user to add a note to their availability

This commit is contained in:
2026-03-28 19:54:01 -04:00
parent a43d067e61
commit 1ed9d9d95e
4 changed files with 95 additions and 24 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE event_availability ADD COLUMN IF NOT EXISTS note VARCHAR(20);

View File

@@ -247,7 +247,7 @@ router.get('/:id', authMiddleware, async (req, res) => {
`, [event.id, req.user.id]));
if (event.track_availability && (itm || isMember)) {
event.availability = await query(req.schema, `
SELECT ea.response, ea.updated_at, u.id AS user_id, u.name, u.display_name, u.avatar
SELECT ea.response, ea.note, ea.updated_at, u.id AS user_id, u.name, u.display_name, u.avatar
FROM event_availability ea JOIN users u ON u.id=ea.user_id WHERE ea.event_id=$1
`, [req.params.id]);
if (itm) {
@@ -259,8 +259,9 @@ router.get('/:id', authMiddleware, async (req, res) => {
event.no_response_count = assignedIds.filter(id => !respondedIds.has(id)).length;
}
}
const mine = await queryOne(req.schema, 'SELECT response FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
const mine = await queryOne(req.schema, 'SELECT response, note FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
event.my_response = mine?.response || null;
event.my_note = mine?.note || null;
res.json({ event });
} catch (e) { res.status(500).json({ error: e.message }); }
});
@@ -394,8 +395,9 @@ router.put('/:id/availability', authMiddleware, async (req, res) => {
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.track_availability) return res.status(400).json({ error: 'Availability tracking not enabled' });
const { response } = req.body;
const { response, note } = req.body;
if (!['going','maybe','not_going'].includes(response)) return res.status(400).json({ error: 'Invalid response' });
const trimmedNote = note ? String(note).trim().slice(0, 20) : null;
const itm = await isToolManagerFn(req.schema, req.user);
const inGroup = await queryOne(req.schema, `
SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
@@ -403,10 +405,20 @@ router.put('/:id/availability', authMiddleware, async (req, res) => {
`, [event.id, req.user.id]);
if (!inGroup && !itm) return res.status(403).json({ error: 'You are not assigned to this event' });
await exec(req.schema, `
INSERT INTO event_availability (event_id,user_id,response,updated_at) VALUES ($1,$2,$3,NOW())
ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, updated_at=NOW()
`, [event.id, req.user.id, response]);
res.json({ success: true, response });
INSERT INTO event_availability (event_id,user_id,response,note,updated_at) VALUES ($1,$2,$3,$4,NOW())
ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, note=$4, updated_at=NOW()
`, [event.id, req.user.id, response, trimmedNote]);
res.json({ success: true, response, note: trimmedNote });
} catch (e) { res.status(500).json({ error: e.message }); }
});
router.patch('/:id/availability/note', authMiddleware, async (req, res) => {
try {
const existing = await queryOne(req.schema, 'SELECT response FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
if (!existing) return res.status(404).json({ error: 'No availability response found' });
const trimmedNote = req.body.note ? String(req.body.note).trim().slice(0, 20) : null;
await exec(req.schema, 'UPDATE event_availability SET note=$1, updated_at=NOW() WHERE event_id=$2 AND user_id=$3', [trimmedNote, req.params.id, req.user.id]);
res.json({ success: true, note: trimmedNote });
} catch (e) { res.status(500).json({ error: e.message }); }
});

View File

@@ -768,23 +768,45 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isToolManager, userId }) {
const toast=useToast();
const [myResp,setMyResp]=useState(event.my_response);
const [myNote,setMyNote]=useState(event.my_note||'');
const [noteInput,setNoteInput]=useState(event.my_note||'');
const [noteSaving,setNoteSaving]=useState(false);
const [avail,setAvail]=useState(event.availability||[]);
const [expandedNotes,setExpandedNotes]=useState(new Set());
// Sync when parent reloads event after availability change
useEffect(()=>{setMyResp(event.my_response);setAvail(event.availability||[]);},[event]);
useEffect(()=>{
setMyResp(event.my_response);
setAvail(event.availability||[]);
setMyNote(event.my_note||'');
setNoteInput(event.my_note||'');
},[event]);
const counts={going:0,maybe:0,not_going:0};
avail.forEach(r=>{if(counts[r.response]!==undefined)counts[r.response]++;});
const isPast = !event.all_day && event.end_at && new Date(event.end_at) < new Date();
const noteChanged = noteInput.trim() !== myNote.trim();
const handleResp=async resp=>{
const prev=myResp;
const next=myResp===resp?null:resp;
setMyResp(next); // optimistic update
try{
if(prev===resp){await api.deleteAvailability(event.id);}else{await api.setAvailability(event.id,resp);}
if(prev===resp){await api.deleteAvailability(event.id);}else{await api.setAvailability(event.id,resp,noteInput.trim()||null);}
onAvailabilityChange?.(next); // triggers parent re-fetch to update avail list
}catch(e){setMyResp(prev);toast(e.message,'error');} // rollback on error
};
const handleNoteSave=async()=>{
if(!myResp) return; // no response row to attach note to
setNoteSaving(true);
try{
await api.setAvailabilityNote(event.id,noteInput.trim()||null);
setMyNote(noteInput.trim());
onAvailabilityChange?.(myResp); // re-fetch to update responses list
}catch(e){toast(e.message,'error');}finally{setNoteSaving(false);}
};
const toggleNote=id=>setExpandedNotes(prev=>{const s=new Set(prev);s.has(id)?s.delete(id):s.add(id);return s;});
return ReactDOM.createPortal(
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&onClose()}>
<div className="modal" style={{maxWidth:520,maxHeight:'88vh',overflowY:'auto'}}>
@@ -827,13 +849,31 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
{isPast ? (
<p style={{fontSize:13,color:'var(--text-tertiary)',marginBottom:16}}>Past event availability is read-only.</p>
) : (
<div style={{display:'flex',gap:8,marginBottom:16}}>
<>
<div style={{display:'flex',gap:8,marginBottom:12}}>
{Object.entries(RESP_LABEL).map(([key,label])=>(
<button key={key} onClick={()=>handleResp(key)} style={{flex:1,padding:'9px 4px',borderRadius:'var(--radius)',border:`2px solid ${RESP_COLOR[key]}`,background:myResp===key?RESP_COLOR[key]:'transparent',color:myResp===key?'white':RESP_COLOR[key],fontSize:13,fontWeight:600,cursor:'pointer',transition:'all 0.15s'}}>
{myResp===key?'✓ ':''}{label}
</button>
))}
</div>
<div style={{display:'flex',gap:8,alignItems:'center',marginBottom:16}}>
<input
type="text"
value={noteInput}
onChange={e=>setNoteInput(e.target.value.slice(0,20))}
placeholder="Add a note (optional)"
maxLength={20}
style={{flex:1,padding:'7px 10px',borderRadius:'var(--radius)',border:'1px solid var(--border)',background:'var(--surface)',color:'var(--text-primary)',fontSize:13,outline:'none'}}
/>
<span style={{fontSize:11,color:'var(--text-tertiary)',flexShrink:0,minWidth:32,textAlign:'right'}}>{noteInput.length}/20</span>
{myResp&&noteChanged&&(
<button onClick={handleNoteSave} disabled={noteSaving} className="btn btn-primary btn-sm" style={{flexShrink:0}}>
{noteSaving?'…':'Save'}
</button>
)}
</div>
</>
)}
{(isToolManager||avail.length>0)&&(
<>
@@ -844,13 +884,30 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
</div>
{avail.length>0&&(
<div style={{border:'1px solid var(--border)',borderRadius:'var(--radius)',overflow:'hidden'}}>
{avail.map(r=>(
<div key={r.user_id} style={{display:'flex',alignItems:'center',gap:10,padding:'8px 12px',borderBottom:'1px solid var(--border)',fontSize:13}}>
{avail.map(r=>{
const hasNote=!!(r.note&&r.note.trim());
const expanded=expandedNotes.has(r.user_id);
return(
<div key={r.user_id} style={{borderBottom:'1px solid var(--border)'}}>
<div
style={{display:'flex',alignItems:'center',gap:10,padding:'8px 12px',fontSize:13,cursor:hasNote?'pointer':'default'}}
onClick={hasNote?()=>toggleNote(r.user_id):undefined}
>
<span style={{width:9,height:9,borderRadius:'50%',background:RESP_COLOR[r.response],flexShrink:0,display:'inline-block'}}/>
<span style={{flex:1}}>{r.display_name||r.name}</span>
{hasNote&&(
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2.5" style={{flexShrink:0,transition:'transform 0.15s',transform:expanded?'rotate(180deg)':'rotate(0deg)'}}><polyline points="6 9 12 15 18 9"/></svg>
)}
<span style={{color:RESP_COLOR[r.response],fontSize:12,fontWeight:600}}>{RESP_LABEL[r.response]}</span>
</div>
))}
{hasNote&&expanded&&(
<div style={{padding:'0 12px 10px 31px',fontSize:12,color:'var(--text-secondary)',fontStyle:'italic'}}>
{r.note}
</div>
)}
</div>
);
})}
</div>
)}
</>

View File

@@ -119,7 +119,8 @@ export const api = {
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, scope = 'this') => req('DELETE', `/schedule/${id}`, { recurringScope: scope }),
setAvailability: (id, response) => req('PUT', `/schedule/${id}/availability`, { response }),
setAvailability: (id, response, note) => req('PUT', `/schedule/${id}/availability`, { response, note }),
setAvailabilityNote: (id, note) => req('PATCH', `/schedule/${id}/availability/note`, { note }),
deleteAvailability: (id) => req('DELETE', `/schedule/${id}/availability`),
getPendingAvailability: () => req('GET', '/schedule/me/pending'),
bulkAvailability: (responses) => req('POST', '/schedule/me/bulk-availability', { responses }),