feat(meetings): call log, past pagination + date filter; last-seen exact time (batch83)
#7 The past list was hard-capped at 12 (a .slice(0,12) in the client) and there was NO record of how many people were ever in a finished call — so the "only calls with >2 people" rule was impossible to apply. Added a call_history table: signaling tracks the HIGH-WATER participant count per room and logs the call when the room tears down (scheduled meetings are skipped — they already have their own row). Past meetings now follow the rules asked for: • a plain 1:1 direct call is NOT listed — unless it produced a recording/transcript (those already surface as recording entries); • a call that ever held MORE than 2 people IS listed (e.g. a 1:1 a third person joined), showing its participant count and duration; • entries are visible only to people who were actually in the call (or the group). Server-side pagination (10/page) + a from/to date filter; nothing is double-listed. #2 Last seen now shows the exact time/date, WhatsApp-style — "last seen today at 1:36 PM", "last seen yesterday at 10:15 AM", "last seen 14/07/2026 at 9:00 AM" — instead of "10 minutes ago". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+60
-18
@@ -820,7 +820,18 @@
|
||||
.gi-open{cursor:pointer;}
|
||||
.sched-wrap{width:100%;margin:1.2rem 0 0;text-align:left;}
|
||||
.sched-sec{margin-bottom:1.4rem;}
|
||||
.sched-h{font-size:.74rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin:0 0 .55rem;font-weight:700;}
|
||||
.sched-h{display:flex;align-items:center;gap:.6rem;font-size:.74rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin:0 0 .55rem;font-weight:700;}
|
||||
/* #7: past-meetings date filter + pager */
|
||||
.mtg-filter{display:inline-flex;align-items:center;gap:.35rem;margin-left:auto;text-transform:none;letter-spacing:0;}
|
||||
.mtg-filter svg{color:var(--blue);flex:0 0 auto;}
|
||||
.mtg-filter input[type=date]{border:1px solid var(--line);border-radius:8px;padding:.25rem .4rem;font:inherit;font-size:.72rem;font-weight:600;background:#fbfcfe;color:var(--ink);}
|
||||
.mtg-filter input[type=date]:focus{outline:none;border-color:var(--blue);}
|
||||
.mtg-clear{border:none;background:transparent;color:var(--muted);cursor:pointer;display:grid;place-items:center;padding:.15rem;border-radius:50%;}
|
||||
.mtg-clear:hover{background:#fee2e2;color:var(--red);}
|
||||
.mtg-pager{display:flex;align-items:center;justify-content:center;gap:.7rem;margin-top:.7rem;font-size:.76rem;color:var(--muted);}
|
||||
.mtg-pg{border:1px solid var(--line);background:var(--card);color:var(--blue);border-radius:8px;width:30px;height:28px;display:grid;place-items:center;cursor:pointer;}
|
||||
.mtg-pg:hover:not(:disabled){background:var(--blue-soft);}
|
||||
.mtg-pg:disabled{opacity:.4;cursor:default;}
|
||||
.sched-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:.7rem;}
|
||||
.sched-item{display:flex;align-items:flex-start;gap:.6rem;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:.8rem .9rem;}
|
||||
.sched-item.live{border-color:#34d399;background:#f0fdf4;}
|
||||
@@ -1037,7 +1048,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<script src="/icons.js?v=6"></script>
|
||||
<script>window.__BUILD='2026-07-14-batch82';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||
<script>window.__BUILD='2026-07-15-batch83';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
|
||||
// Emoji are rendered with the OS's own (colour) emoji font — instant, zero network.
|
||||
//
|
||||
// We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
|
||||
@@ -1564,21 +1575,18 @@ function fmtTime(ts){
|
||||
// Presence/status helpers (#7): 'incall' (auto) overrides; offline if not connected.
|
||||
function statusCls(it){ const s=it&&it.status; if(s==='incall') return 'incall'; if(!it||!it.online) return 'offline'; if(s==='away') return 'away'; if(s==='onleave') return 'onleave'; return 'active'; }
|
||||
function statusLabel(it){ const c=statusCls(it); return c==='incall'?'In a call':c==='away'?'Away':c==='onleave'?'On leave':c==='active'?'Available':lastSeenLabel(it); }
|
||||
// #2: when someone is offline, "Offline" alone is unhelpful — say when they were last around.
|
||||
// #2: when someone is offline, say exactly WHEN they were last around — WhatsApp style, i.e. an actual
|
||||
// time/date ("last seen today at 1:36 PM"), not a vague "10 minutes ago".
|
||||
function lastSeenLabel(it){
|
||||
const ts=it&&it.lastSeen; if(!ts) return 'Offline';
|
||||
const d=Date.now()-ts;
|
||||
if(d<60*1000) return 'Last seen just now';
|
||||
const mins=Math.floor(d/60000);
|
||||
if(mins<60) return 'Last seen '+mins+(mins===1?' minute ago':' minutes ago');
|
||||
const hrs=Math.floor(mins/60);
|
||||
if(hrs<24) return 'Last seen '+hrs+(hrs===1?' hour ago':' hours ago');
|
||||
const t=new Date(ts), now=new Date();
|
||||
const days=Math.floor((new Date(now.getFullYear(),now.getMonth(),now.getDate())-new Date(t.getFullYear(),t.getMonth(),t.getDate()))/86400000);
|
||||
const clock=t.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'});
|
||||
if(days===1) return 'Last seen yesterday at '+clock;
|
||||
if(days<7) return 'Last seen '+t.toLocaleDateString([],{weekday:'long'})+' at '+clock;
|
||||
return 'Last seen '+t.toLocaleDateString([],{month:'short',day:'numeric'})+' at '+clock;
|
||||
const clock=t.toLocaleTimeString([],{hour:'numeric',minute:'2-digit'});
|
||||
const dayOf=(d)=>new Date(d.getFullYear(),d.getMonth(),d.getDate()).getTime();
|
||||
const days=Math.round((dayOf(now)-dayOf(t))/86400000);
|
||||
if(days<=0) return 'last seen today at '+clock;
|
||||
if(days===1) return 'last seen yesterday at '+clock;
|
||||
if(days<7) return 'last seen '+t.toLocaleDateString([],{weekday:'long'})+' at '+clock;
|
||||
return 'last seen '+t.toLocaleDateString([],{day:'2-digit',month:'2-digit',year:'numeric'})+' at '+clock;
|
||||
}
|
||||
function avatarHTML(it, big){
|
||||
const isG=it.kind==='group';
|
||||
@@ -3602,17 +3610,27 @@ function renderMeetingLobby(){
|
||||
loadScheduledMeetings();
|
||||
}
|
||||
// Fetch + render scheduled meetings, bucketed into Running / Upcoming / Past.
|
||||
// #7: Past meetings are paged + date-filterable on the SERVER (the list used to be hard-capped at 12).
|
||||
let _mtgPage=1, _mtgFrom='', _mtgTo='', _mtgTotal=0, _mtgPageSize=10;
|
||||
async function loadScheduledMeetings(){
|
||||
const wrap=document.getElementById('schedWrap'); if(!wrap) return;
|
||||
let list; try{ list=await fetch('/api/meetings').then(r=>r.json()); }catch(_){ return; }
|
||||
if(!Array.isArray(list)||!list.length){ wrap.innerHTML='<div class="sched-empty">No meetings yet. Start one now or schedule it for later.</div>'; return; }
|
||||
const qs=new URLSearchParams();
|
||||
qs.set('page', String(_mtgPage)); qs.set('pageSize', String(_mtgPageSize));
|
||||
if(_mtgFrom){ const t=new Date(_mtgFrom); t.setHours(0,0,0,0); qs.set('from', String(t.getTime())); }
|
||||
if(_mtgTo){ const t=new Date(_mtgTo); t.setHours(23,59,59,999); qs.set('to', String(t.getTime())); }
|
||||
let res; try{ res=await fetch('/api/meetings?'+qs.toString()).then(r=>r.json()); }catch(_){ return; }
|
||||
const list=Array.isArray(res)?res:(res&&Array.isArray(res.list)?res.list:[]); // tolerate the old array shape
|
||||
_mtgTotal=(res&&res.pastTotal)||0; _mtgPageSize=(res&&res.pageSize)||_mtgPageSize;
|
||||
const filtering=!!(_mtgFrom||_mtgTo);
|
||||
if(!list.length && !filtering && !_mtgTotal){ wrap.innerHTML='<div class="sched-empty">No meetings yet. Start one now or schedule it for later.</div>'; return; }
|
||||
const running=list.filter(m=>m.status==='running');
|
||||
const upcoming=list.filter(m=>m.status==='upcoming').sort((a,b)=>a.scheduledAt-b.scheduledAt);
|
||||
const past=list.filter(m=>m.status==='past'||m.status==='cancelled').sort((a,b)=>b.scheduledAt-a.scheduledAt).slice(0,12);
|
||||
const past=list.filter(m=>m.status==='past'||m.status==='cancelled'); // server already sorted+paged
|
||||
const fmt=ts=>new Date(ts).toLocaleString([],{weekday:'short',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
|
||||
const card=m=>{
|
||||
const cancelled=m.status==='cancelled';
|
||||
const meta=[]; if(m.groupName) meta.push(pEsc(m.groupName)); meta.push(fmt(m.scheduledAt));
|
||||
if(m.participantCount) meta.push(m.participantCount+' participants'); // #7: from the call log
|
||||
if(m.durationMins) meta.push(m.durationMins+' min');
|
||||
if(m.recurrenceLabel) meta.push('🔁 '+pEsc(m.recurrenceLabel));
|
||||
if(m.status==='running') meta.push(m.inCall+' in call');
|
||||
@@ -3637,7 +3655,31 @@ async function loadScheduledMeetings(){
|
||||
};
|
||||
const byId={}; list.forEach(m=>byId[m.id]=m);
|
||||
const sec=(title,arr)=>arr.length?('<div class="sched-sec"><div class="sched-h">'+title+'</div><div class="sched-list">'+arr.map(card).join('')+'</div></div>'):'';
|
||||
wrap.innerHTML=sec('Ongoing now',running)+sec('Upcoming meetings',upcoming)+sec('Past meetings',past);
|
||||
// Past section: date range + pager. Always rendered (even with 0 results) so the filter stays reachable.
|
||||
const pages=Math.max(1, Math.ceil(_mtgTotal/_mtgPageSize));
|
||||
const pastSec='<div class="sched-sec"><div class="sched-h">Past meetings'
|
||||
+'<span class="mtg-filter">'+ic('calendar',13)
|
||||
+'<input type="date" id="mtgFrom" value="'+pEsc(_mtgFrom)+'" title="From">'
|
||||
+'<span>–</span><input type="date" id="mtgTo" value="'+pEsc(_mtgTo)+'" title="To">'
|
||||
+((_mtgFrom||_mtgTo)?'<button class="mtg-clear" id="mtgClear" title="Clear filter">'+ic('x',13)+'</button>':'')
|
||||
+'</span></div>'
|
||||
+(past.length?('<div class="sched-list">'+past.map(card).join('')+'</div>')
|
||||
:('<div class="sched-empty" style="margin:0">'+((_mtgFrom||_mtgTo)?'No meetings in that date range.':'No past meetings yet.')+'</div>'))
|
||||
+(_mtgTotal>_mtgPageSize?('<div class="mtg-pager">'
|
||||
+'<button class="mtg-pg" id="mtgPrev"'+(_mtgPage<=1?' disabled':'')+'>'+ic('chevronLeft',14)+'</button>'
|
||||
+'<span>Page '+_mtgPage+' of '+pages+' · '+_mtgTotal+' total</span>'
|
||||
+'<button class="mtg-pg" id="mtgNext"'+(_mtgPage>=pages?' disabled':'')+'>'+ic('chevronRight',14)+'</button>'
|
||||
+'</div>'):'')
|
||||
+'</div>';
|
||||
wrap.innerHTML=sec('Ongoing now',running)+sec('Upcoming meetings',upcoming)+pastSec;
|
||||
{ const f=wrap.querySelector('#mtgFrom'), t=wrap.querySelector('#mtgTo'), c=wrap.querySelector('#mtgClear');
|
||||
if(f) f.onchange=()=>{ _mtgFrom=f.value; _mtgPage=1; loadScheduledMeetings(); };
|
||||
if(t) t.onchange=()=>{ _mtgTo=t.value; _mtgPage=1; loadScheduledMeetings(); };
|
||||
if(c) c.onclick=()=>{ _mtgFrom=''; _mtgTo=''; _mtgPage=1; loadScheduledMeetings(); };
|
||||
const pv=wrap.querySelector('#mtgPrev'), nx=wrap.querySelector('#mtgNext');
|
||||
if(pv) pv.onclick=()=>{ if(_mtgPage>1){ _mtgPage--; loadScheduledMeetings(); } };
|
||||
if(nx) nx.onclick=()=>{ if(_mtgPage<pages){ _mtgPage++; loadScheduledMeetings(); } };
|
||||
}
|
||||
wrap.querySelectorAll('[data-code]').forEach(b=>b.onclick=()=>enterMeeting(b.dataset.code));
|
||||
wrap.querySelectorAll('[data-link]').forEach(b=>b.onclick=()=>{ const u=b.dataset.link; if(!u){ toast('No link'); return; } const done=()=>toast('Invite link copied'); try{ if(navigator.clipboard&&navigator.clipboard.writeText) return void navigator.clipboard.writeText(u).then(done).catch(()=>fallbackCopy(u,done)); }catch(_){} fallbackCopy(u,done); });
|
||||
wrap.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>{ const m=byId[b.dataset.edit]; if(m) openScheduleModal(m.groupId||null, m); });
|
||||
|
||||
Reference in New Issue
Block a user