diff --git a/public/app.js b/public/app.js index 9aca2b5..74c15fc 100644 --- a/public/app.js +++ b/public/app.js @@ -156,7 +156,7 @@ async function openGuestList(id) { if(!isManager(e))return; const roster=e.guestRoster || [],accepted=roster.filter(g=>g.status==='yes'); $('guest-list-title').textContent='Guest list';$('guest-list-event').textContent=e.title; - $('guest-list-summary').textContent=`${accepted.reduce((sum,g)=>sum+g.participants,0)} people accepted · ${accepted.length} responses · ${roster.filter(g=>g.confirmed).reduce((sum,g)=>sum+g.participants,0)} confirmed`; + $('guest-list-summary').textContent=`${accepted.reduce((sum,g)=>sum+g.participants,0)} people accepted · ${roster.reduce((sum,g)=>sum+g.participants,0)} people total · ${roster.filter(g=>g.confirmed).reduce((sum,g)=>sum+g.participants,0)} confirmed`; const filters=$('guest-list-filters');filters.replaceChildren(); const named=e.invitationMode==='named',readOnly=e.cancelled || e.group==='Past events'; const invitationFor=guest=>guest.id!=null ? e.invitees?.find(invite=>invite.responses?.some(response=>response.id===guest.id)) : e.invitees?.find(invite=>invite.name===guest.name); diff --git a/src/bot.js b/src/bot.js index 4eb12c3..4e4800d 100644 --- a/src/bot.js +++ b/src/bot.js @@ -688,7 +688,7 @@ export class Bot { } if (action === 'g') { const active=Object.entries(e.guests).filter(([uid])=>Number(uid)!==e.owner && invitationAvailable(e,Number(uid))).map(([,g])=>g); - const unopened=Object.entries(e.invitees || {}).filter(([token])=>!active.some(g=>g.invitationToken===token)).map(([,g])=>g); + const unopened=Object.entries(e.invitees || {}).filter(([token])=>!active.some(g=>g.invitationToken===token)).map(([token,g])=>({...g,invitationToken:token})); const accepted=active.filter(g=>confirmed(e,g)); const pending=active.filter(g=>g.status==='yes' && requiresApproval(e) && g.approval!=='approved'); const payment=active.filter(g=>g.status==='yes' && !confirmed(e,g) && !pending.includes(g)); @@ -699,9 +699,11 @@ export class Bot { ['📭 Not responded',[...active.filter(g=>!hasRecordedResponse(g)),...unopened]], ['⏳ Awaiting approval',pending],...(payment.length ? [['⭐ Awaiting payment',payment]] : []) ]; - let text = `👥 ${e.title}\n${this.updatedAt(e, id)}\n\nTotal invitations: ${active.length+unopened.length}\nPeople accepted: ${accepted.reduce((sum,g)=>sum+participantCount(e,g),0)}\n`; + const people=group=>group.reduce((sum,g)=>sum+participantCount(e,g),0); + let text = `👥 ${e.title}\n${this.updatedAt(e, id)}\n\nTotal people: ${people([...active,...unopened])}\nPeople accepted: ${people(accepted)}\n`; for (const [label,group] of groups) { - text += `\n${label} (${group.length})\n`; + const count=people(group); + text += `\n${label} (${count} ${count===1?'person':'people'})\n`; text += group.length ? group.map(g => `• ${g.name}${g.status === 'yes' ? ' (' + participantCount(e, g) + ' people)' : ''}${g.comment ? ' — ' + g.comment : ''}`).join('\n') + '\n' : 'Nobody yet\n'; } return this.long(id, text, keyboard([button('↻ Refresh', `g:${eid}`),button('Back to event', `v:${eid}`)])); diff --git a/src/mini-api.js b/src/mini-api.js index 5090f22..b548f3b 100644 --- a/src/mini-api.js +++ b/src/mini-api.js @@ -88,7 +88,7 @@ export function publicEvent(e, id, username) { return { invitationMode:invitationMode(e),guestName:e.owner!==id ? e.guests[id]?.name || null : null, askPhone:asksPhone(e),askComments:asksComments(e), - ...(manager ? {guestRoster:[...Object.entries(e.guests).filter(([uid])=>Number(uid)!==e.owner).map(([uid,g])=>({id:Number(uid),name:g.name,status:g.status,responded:hasRecordedResponse(g,invitationHistory(e,g.invitationToken),Number(uid)),approval:g.status==='yes' ? requiresApproval(e) ? g.approval || 'pending' : 'approved' : null,confirmed:confirmed(e,g),participants:g.status==='yes'?participantCount(e,g):0,paymentStatus:g.payment?.status || null})),...Object.entries(e.invitees || {}).filter(([token])=>!Object.values(e.guests).some(g=>g.invitationToken===token)).map(([,g])=>({id:null,name:g.name,status:'unopened',responded:false,participants:0,confirmed:false}))]} : {}), + ...(manager ? {guestRoster:[...Object.entries(e.guests).filter(([uid])=>Number(uid)!==e.owner).map(([uid,g])=>({id:Number(uid),name:g.name,status:g.status,responded:hasRecordedResponse(g,invitationHistory(e,g.invitationToken),Number(uid)),approval:g.status==='yes' ? requiresApproval(e) ? g.approval || 'pending' : 'approved' : null,confirmed:confirmed(e,g),participants:participantCount(e,g),paymentStatus:g.payment?.status || null})),...Object.entries(e.invitees || {}).filter(([token])=>!Object.values(e.guests).some(g=>g.invitationToken===token)).map(([token,g])=>({id:null,name:g.name,status:'unopened',responded:false,participants:participantCount(e,{...g,invitationToken:token}),confirmed:false}))]} : {}), inviteMessage:e.inviteMessage || '',qrEnabled:e.qrEnabled!==false,oneTimeInvite:oneTimeInvites(e), ...(manager ? {invitees:managedInvitations(e,username),revokedInvitees:revokedInvitations(e,username),invitationsVersion:invitationsVersion(e),cohosts,cohost:cohosts[0] || null} : {}), ...(owner ? {cohostLinks:entries.map(entry=>({id:entry.id,label:entry.label,status:entry.status,createdAt:entry.createdAt,cohost:entry.cohost,url:cohostLink(e,username,entry.id),...(entry.revokedAt ? {revokedAt:entry.revokedAt}:{})})),cohostInviteUrl:cohostLink(e,username),cohostVersion:cohostVersion(e)} : {}), diff --git a/test/bot.test.js b/test/bot.test.js index 71176f6..359989b 100644 --- a/test/bot.test.js +++ b/test/bot.test.js @@ -82,20 +82,30 @@ test('guest list counts invitations, attendees and unanswered named links withou 8:{name:'Revoked',status:'yes',invitationToken:'removed'} }; await f.cb(1,`g:${e.id}`);let text=f.calls.at(-1).text; - assert.match(text,/Total invitations: 7\nPeople accepted: 5/); - assert.match(text,/Accepted \(2\)/);assert.match(text,/Awaiting approval \(0\)/); - for(const label of ['Rejected','Tentative','Respond later'])assert.ok(text.includes(label+' (1)')); - assert.match(text,/Not responded \(2\)\n• Opened\n• Unopened/);assert.doesNotMatch(text,/Revoked|• Host/); + assert.match(text,/Total people: 10\nPeople accepted: 5/); + assert.match(text,/Accepted \(5 people\)/);assert.match(text,/Awaiting approval \(0 people\)/); + for(const label of ['Rejected','Tentative','Respond later'])assert.ok(text.includes(label+' (1 person)')); + assert.match(text,/Not responded \(2 people\)\n• Opened\n• Unopened/);assert.doesNotMatch(text,/Revoked|• Host/); e.guests[6]={...e.guests[6],status:'no',responseRecorded:true};await f.cb(1,`g:${e.id}`); - text=f.calls.at(-1).text;assert.match(text,/Rejected \(2\)/);assert.match(text,/Not responded \(1\)/); + text=f.calls.at(-1).text;assert.match(text,/Rejected \(2 people\)/);assert.match(text,/Not responded \(1 person\)/); }); test('guest list keeps pending approvals and payments out of confirmed acceptance counts',async()=>{ const f=fixture(),e=await f.create();e.requireApproval=true;e.starPrice=10;e.askParticipantCount=true; e.guests={2:{name:'Confirmed',status:'yes',approval:'approved',participants:2,payment:{status:'paid'}},3:{name:'Pending',status:'yes'},4:{name:'Unpaid',status:'yes',approval:'approved'}}; await f.cb(1,`g:${e.id}`);const text=f.calls.at(-1).text; - assert.match(text,/Total invitations: 3\nPeople accepted: 2/); - for(const label of ['Accepted','Awaiting approval','Awaiting payment'])assert.ok(text.includes(label+' (1)')); + assert.match(text,/Total people: 4\nPeople accepted: 2/); + assert.ok(text.includes('Accepted (2 people)')); + for(const label of ['Awaiting approval','Awaiting payment'])assert.ok(text.includes(label+' (1 person)')); +}); + +test('guest list counts reserved people in declined, tentative and unopened invitations',async()=>{ + const f=fixture(),e=await f.create();e.invitationMode='named'; + e.invitees={a:{name:'Declined family',participants:3},b:{name:'Maybe pair',participants:2},c:{name:'Unopened group',participants:4}}; + e.guests={2:{name:'Declined family',status:'no',invitationToken:'a'},3:{name:'Maybe pair',status:'maybe',invitationToken:'b'}}; + const before=JSON.stringify(e);await f.cb(1,`g:${e.id}`);const text=f.calls.at(-1).text; + assert.match(text,/Total people: 9/);assert.match(text,/Rejected \(3 people\)/);assert.match(text,/Tentative \(2 people\)/);assert.match(text,/Not responded \(4 people\)/); + assert.equal(JSON.stringify(e),before); }); test('event card offers cancellation before optional deletion and keeps records by default',async()=>{