fix: last-seen backfill, report-style date filter, hover overflow (batch84)

#1 Last seen still read "Offline": last_seen is a NEW column, so every existing user was NULL
   until they happened to reconnect. Backfill it from the newest message each user sent (best
   evidence we already have), and also stamp it on message send — not just on connect/disconnect
   — so it stays fresh if a socket never closes cleanly.
#3 Past-meetings filter is a report filter now: presets (Today, Yesterday, Last 7/30 days, This
   month, Year to date, All time) plus a Custom range that uses the SAME branded calendar popup
   as the scheduler. The raw <input type=date> looked foreign and behaved differently per browser.
#4 Hover action row overflowed off-panel on SHORT received messages: it's wider than the bubble
   and was anchored to the bubble's right edge, so it ran off the left. Received bubbles now
   anchor it from the left (growing into the empty space); own messages keep the right anchor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 01:02:59 +05:30
parent 6fa261b68f
commit fad8a52da4
3 changed files with 91 additions and 13 deletions
+8
View File
@@ -228,6 +228,14 @@ try { db.exec('ALTER TABLE users ADD COLUMN bizgaze_user_id TEXT'); } catch (e)
// #2: when this user was last connected (stamped on connect + on their last socket closing), so contacts
// can show "last seen 10 minutes ago" instead of a bare "Offline".
try { db.exec('ALTER TABLE users ADD COLUMN last_seen INTEGER'); } catch (e) { /* exists */ }
// Backfill: the column is new, so every existing user was NULL and therefore still read as a bare
// "Offline" until they happened to reconnect. Seed it from the last message they sent — the best
// evidence we already have of when they were last around. Only fills rows that are still NULL.
try {
db.exec(`UPDATE users SET last_seen = (
SELECT MAX(created_at) FROM messages WHERE messages.sender_id = users.id
) WHERE last_seen IS NULL AND EXISTS (SELECT 1 FROM messages WHERE messages.sender_id = users.id)`);
} catch (e) { /* messages table may not exist yet on a fresh db */ }
// External (non-Connect) invitee emails on a scheduled meeting (#4) — JSON array. They get an emailed
// guest join link instead of an in-app invite.
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN guest_emails TEXT'); } catch (e) { /* exists */ }
+82 -13
View File
@@ -535,7 +535,11 @@
overlaps the bubble slightly so there's no dead gap — on a short message the icons can't float
off into empty space where the hover (and the buttons) would vanish before you can click. */
/* #4: sit FULLY above the bubble (was top:-14px, which overlapped the first line of text). */
.msg-actions{position:absolute;bottom:calc(100% - 2px);right:6px;z-index:6;display:flex;align-items:center;gap:1px;background:var(--card);border:1px solid var(--line);border-radius:999px;padding:2px 3px;box-shadow:0 4px 12px rgba(20,30,60,.2);opacity:0;pointer-events:none;transition:opacity .12s;}
.msg-actions{position:absolute;bottom:calc(100% - 2px);right:6px;z-index:6;display:flex;align-items:center;gap:1px;white-space:nowrap;background:var(--card);border:1px solid var(--line);border-radius:999px;padding:2px 3px;box-shadow:0 4px 12px rgba(20,30,60,.2);opacity:0;pointer-events:none;transition:opacity .12s;}
/* The row is wider than a SHORT bubble. Anchored right, a received ("them") bubble pushed it off the
left edge of the panel. Anchor received messages from the LEFT so it grows into the empty space. */
.bubble.them .msg-actions{left:0;right:auto;}
.bubble.mine .msg-actions{right:0;left:auto;}
.bubble:hover .msg-actions,.bubble.show-actions .msg-actions{opacity:1;pointer-events:auto;}
.msg-actions button{position:static;width:26px;height:26px;border:none;background:none;border-radius:50%;display:grid;place-items:center;cursor:pointer;color:var(--blue);opacity:1;pointer-events:auto;box-shadow:none;padding:0;transition:background .12s;}
.msg-actions button:hover{background:var(--blue-soft);}
@@ -824,8 +828,11 @@
/* #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-filter select,.mtg-date{border:1px solid var(--line);border-radius:8px;padding:.28rem .45rem;font:inherit;font-size:.72rem;font-weight:600;background:#fbfcfe;color:var(--ink);cursor:pointer;}
.mtg-filter select:focus,.mtg-date:focus{outline:none;border-color:var(--blue);}
.mtg-date:hover{border-color:var(--blue);color:var(--blue);}
.mtg-dash{color:var(--muted);}
.date-pop{position:fixed;z-index:9760;display:block;} /* branded calendar popup (same look as the scheduler) */
.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);}
@@ -1048,7 +1055,7 @@
</head>
<body>
<script src="/icons.js?v=6"></script>
<script>window.__BUILD='2026-07-15-batch83';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-15-batch84';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
@@ -3611,13 +3618,70 @@ function renderMeetingLobby(){
}
// 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;
// This is a report, so it gets report-style presets plus a custom range using the SAME branded calendar
// as the scheduler (a raw <input type=date> looked out of place and behaved differently per browser).
let _mtgPage=1, _mtgFrom=null, _mtgTo=null, _mtgPreset='all', _mtgTotal=0, _mtgPageSize=10;
const MTG_PRESETS=[
{k:'all', label:'All time'},
{k:'today', label:'Today'},
{k:'yesterday',label:'Yesterday'},
{k:'7d', label:'Last 7 days'},
{k:'30d', label:'Last 30 days'},
{k:'month', label:'This month'},
{k:'ytd', label:'Year to date'},
{k:'custom', label:'Custom range…'},
];
function applyMtgPreset(k){
_mtgPreset=k;
const now=new Date();
const sod=(d)=>{ const x=new Date(d); x.setHours(0,0,0,0); return x.getTime(); };
const eod=(d)=>{ const x=new Date(d); x.setHours(23,59,59,999); return x.getTime(); };
const daysAgo=(n)=>{ const x=new Date(now); x.setDate(x.getDate()-n); return x; };
if(k==='all'){ _mtgFrom=null; _mtgTo=null; }
else if(k==='today'){ _mtgFrom=sod(now); _mtgTo=eod(now); }
else if(k==='yesterday'){ const y=daysAgo(1); _mtgFrom=sod(y); _mtgTo=eod(y); }
else if(k==='7d'){ _mtgFrom=sod(daysAgo(6)); _mtgTo=eod(now); }
else if(k==='30d'){ _mtgFrom=sod(daysAgo(29)); _mtgTo=eod(now); }
else if(k==='month'){ _mtgFrom=sod(new Date(now.getFullYear(),now.getMonth(),1)); _mtgTo=eod(now); }
else if(k==='ytd'){ _mtgFrom=sod(new Date(now.getFullYear(),0,1)); _mtgTo=eod(now); }
// 'custom' keeps whatever from/to are already set
_mtgPage=1; loadScheduledMeetings();
}
const mtgDateLbl=(ts)=>ts?new Date(ts).toLocaleDateString([],{month:'short',day:'numeric',year:'numeric'}):'Any';
// A compact branded calendar popup — same look/behaviour as the scheduler's picker.
function openDatePop(anchor, current, onPick){
document.querySelectorAll('.date-pop').forEach(x=>x.remove());
const base=current?new Date(current):new Date();
let viewY=base.getFullYear(), viewM=base.getMonth();
const pop=document.createElement('div'); pop.className='pick-pop date-pop';
const D1=['S','M','T','W','T','F','S'];
const draw=()=>{
const first=new Date(viewY,viewM,1), startDow=first.getDay(), dim=new Date(viewY,viewM+1,0).getDate();
const selTs=current?new Date(current).setHours(0,0,0,0):0;
let h='<div class="cal-head"><button type="button" class="cal-nav" data-nav="-1">'+ic('chevronLeft',16)+'</button>'
+'<b>'+first.toLocaleDateString([],{month:'long',year:'numeric'})+'</b>'
+'<button type="button" class="cal-nav" data-nav="1">'+ic('chevronRight',16)+'</button></div><div class="cal-grid">'
+D1.map(d=>'<span class="cal-dow">'+d+'</span>').join('');
for(let i=0;i<startDow;i++) h+='<span></span>';
for(let d=1;d<=dim;d++){ const ts=new Date(viewY,viewM,d).setHours(0,0,0,0);
h+='<button type="button" class="cal-day'+(ts===selTs?' sel':'')+'" data-ts="'+ts+'">'+d+'</button>'; }
pop.innerHTML=h+'</div>';
pop.querySelectorAll('.cal-nav').forEach(b=>b.onclick=(e)=>{ e.stopPropagation(); viewM+=(+b.dataset.nav); if(viewM<0){viewM=11;viewY--;} if(viewM>11){viewM=0;viewY++;} draw(); });
pop.querySelectorAll('.cal-day').forEach(b=>b.onclick=()=>{ pop.remove(); onPick(+b.dataset.ts); });
};
document.body.appendChild(pop); draw();
const r=anchor.getBoundingClientRect();
pop.style.left=Math.max(8,Math.min(r.left, window.innerWidth-pop.offsetWidth-8))+'px';
pop.style.top=Math.min(r.bottom+6, window.innerHeight-pop.offsetHeight-8)+'px';
const close=(e)=>{ if(!pop.contains(e.target) && e.target!==anchor){ pop.remove(); document.removeEventListener('mousedown',close); } };
setTimeout(()=>document.addEventListener('mousedown',close),0);
}
async function loadScheduledMeetings(){
const wrap=document.getElementById('schedWrap'); if(!wrap) 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())); }
if(_mtgFrom) qs.set('from', String(_mtgFrom));
if(_mtgTo) qs.set('to', String(_mtgTo));
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;
@@ -3657,10 +3721,13 @@ async function loadScheduledMeetings(){
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>'):'';
// 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 custom=_mtgPreset==='custom';
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">'
+'<select id="mtgPreset">'+MTG_PRESETS.map(p=>'<option value="'+p.k+'"'+(_mtgPreset===p.k?' selected':'')+'>'+pEsc(p.label)+'</option>').join('')+'</select>'
+(custom?('<button type="button" class="mtg-date" id="mtgFromBtn">'+pEsc(mtgDateLbl(_mtgFrom))+'</button>'
+'<span class="mtg-dash"></span>'
+'<button type="button" class="mtg-date" id="mtgToBtn">'+pEsc(mtgDateLbl(_mtgTo))+'</button>'):'')
+((_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>')
@@ -3672,10 +3739,12 @@ async function loadScheduledMeetings(){
+'</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 ps=wrap.querySelector('#mtgPreset');
if(ps) ps.onchange=()=>{ if(ps.value==='custom'){ _mtgPreset='custom'; loadScheduledMeetings(); } else applyMtgPreset(ps.value); };
const fb=wrap.querySelector('#mtgFromBtn'), tb=wrap.querySelector('#mtgToBtn');
if(fb) fb.onclick=(e)=>{ e.stopPropagation(); openDatePop(fb, _mtgFrom, (ts)=>{ _mtgFrom=ts; if(_mtgTo && _mtgTo<ts) _mtgTo=null; _mtgPage=1; loadScheduledMeetings(); }); };
if(tb) tb.onclick=(e)=>{ e.stopPropagation(); openDatePop(tb, _mtgTo, (ts)=>{ const end=new Date(ts); end.setHours(23,59,59,999); _mtgTo=end.getTime(); _mtgPage=1; loadScheduledMeetings(); }); };
const c=wrap.querySelector('#mtgClear'); if(c) c.onclick=()=>applyMtgPreset('all');
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(); } };
+1
View File
@@ -1453,6 +1453,7 @@ route('POST', '/api/messages', async (req, res) => {
const a = R.attachments.byId(attachmentId);
if (!a || a.team_id !== u.team_id || a.uploader_id !== u.id) return json(res, 400, { error: 'invalid attachment' });
}
try { R.users.touchSeen(u.id); } catch (_) {} // #2: keep "last seen" fresh on activity, not just on disconnect
const id = A.id();
if (group) {
if (!R.conversations.isMember(group, u.id)) return json(res, 403, { error: 'not a member of this group' });