fix(meetings): hide empty 1:1 direct calls; date from/to validation + field alignment (batch85)

#3 The 136 "Direct Call" cards: calls.js writes a scheduled_meetings row for EVERY call
   ("Direct Call" / "Group call"), which is separate from the call_history table. Those rows
   came through the scheduled-meetings path and my earlier filter never touched them. Now an
   auto call-history row is shown in Past ONLY if it produced a recording/transcript OR the
   call ever held >2 people (peak from the call log); plain 1:1s with neither are dropped.
   Removed the now-redundant callRows synthesis. Recordings stay attached (rows WITH a
   recording are always kept).
#4 Date range: from ≤ to enforced by disabling out-of-range days in each picker (can't pick a
   from after to, or a to before from). Filter controls share one height/baseline so the
   calendar icon, preset dropdown and date fields align cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 13:19:12 +05:30
parent fad8a52da4
commit f708cc2f12
2 changed files with 37 additions and 34 deletions
+21 -13
View File
@@ -826,12 +826,15 @@
.sched-sec{margin-bottom:1.4rem;} .sched-sec{margin-bottom:1.4rem;}
.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;} .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 */ /* #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;} /* #4: all controls share one height/baseline so the icon, preset and date fields line up cleanly */
.mtg-filter svg{color:var(--blue);flex:0 0 auto;} .mtg-filter{display:inline-flex;align-items:center;gap:.4rem;margin-left:auto;text-transform:none;letter-spacing:0;}
.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>svg{color:var(--blue);flex:0 0 auto;}
.mtg-filter select:focus,.mtg-date:focus{outline:none;border-color:var(--blue);} .mtg-filter select,.mtg-date{height:30px;box-sizing:border-box;display:inline-flex;align-items:center;border:1px solid var(--line);border-radius:8px;padding:0 .55rem;font:inherit;font-size:.74rem;font-weight:600;background:#fbfcfe;color:var(--ink);cursor:pointer;line-height:1;}
.mtg-date:hover{border-color:var(--blue);color:var(--blue);} .mtg-filter select{appearance:auto;}
.mtg-dash{color:var(--muted);} .mtg-filter select:focus,.mtg-date:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 2px var(--blue-soft);}
.mtg-date{gap:.35rem;} .mtg-date:hover{border-color:var(--blue);color:var(--blue);}
.mtg-dash{color:var(--muted);font-weight:600;}
.mtg-clear{width:30px;height:30px;box-sizing:border-box;}
.date-pop{position:fixed;z-index:9760;display:block;} /* branded calendar popup (same look as the scheduler) */ .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{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-clear:hover{background:#fee2e2;color:var(--red);}
@@ -1055,7 +1058,7 @@
</head> </head>
<body> <body>
<script src="/icons.js?v=6"></script> <script src="/icons.js?v=6"></script>
<script>window.__BUILD='2026-07-15-batch84';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD); <script>window.__BUILD='2026-07-15-batch85';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. // 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 // We used to run Twemoji over every emoji, which swapped each one for an <img> pulled INDIVIDUALLY from
@@ -3648,8 +3651,12 @@ function applyMtgPreset(k){
_mtgPage=1; loadScheduledMeetings(); _mtgPage=1; loadScheduledMeetings();
} }
const mtgDateLbl=(ts)=>ts?new Date(ts).toLocaleDateString([],{month:'short',day:'numeric',year:'numeric'}):'Any'; 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. // A compact branded calendar popup — same look/behaviour as the scheduler's picker. opts.min/opts.max
function openDatePop(anchor, current, onPick){ // (ms) disable out-of-range days so an invalid range (from > to) can't be picked (#4).
function openDatePop(anchor, current, onPick, opts){
opts=opts||{};
const minDay=opts.min?new Date(opts.min).setHours(0,0,0,0):null;
const maxDay=opts.max?new Date(opts.max).setHours(0,0,0,0):null;
document.querySelectorAll('.date-pop').forEach(x=>x.remove()); document.querySelectorAll('.date-pop').forEach(x=>x.remove());
const base=current?new Date(current):new Date(); const base=current?new Date(current):new Date();
let viewY=base.getFullYear(), viewM=base.getMonth(); let viewY=base.getFullYear(), viewM=base.getMonth();
@@ -3664,10 +3671,11 @@ function openDatePop(anchor, current, onPick){
+D1.map(d=>'<span class="cal-dow">'+d+'</span>').join(''); +D1.map(d=>'<span class="cal-dow">'+d+'</span>').join('');
for(let i=0;i<startDow;i++) h+='<span></span>'; 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); 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>'; } const disabled=(minDay!==null&&ts<minDay)||(maxDay!==null&&ts>maxDay);
h+='<button type="button" class="cal-day'+(ts===selTs?' sel':'')+'"'+(disabled?' disabled':'')+' data-ts="'+ts+'">'+d+'</button>'; }
pop.innerHTML=h+'</div>'; 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-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); }); pop.querySelectorAll('.cal-day:not([disabled])').forEach(b=>b.onclick=()=>{ pop.remove(); onPick(+b.dataset.ts); });
}; };
document.body.appendChild(pop); draw(); document.body.appendChild(pop); draw();
const r=anchor.getBoundingClientRect(); const r=anchor.getBoundingClientRect();
@@ -3742,8 +3750,8 @@ async function loadScheduledMeetings(){
{ const ps=wrap.querySelector('#mtgPreset'); { const ps=wrap.querySelector('#mtgPreset');
if(ps) ps.onchange=()=>{ if(ps.value==='custom'){ _mtgPreset='custom'; loadScheduledMeetings(); } else applyMtgPreset(ps.value); }; if(ps) ps.onchange=()=>{ if(ps.value==='custom'){ _mtgPreset='custom'; loadScheduledMeetings(); } else applyMtgPreset(ps.value); };
const fb=wrap.querySelector('#mtgFromBtn'), tb=wrap.querySelector('#mtgToBtn'); 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(fb) fb.onclick=(e)=>{ e.stopPropagation(); openDatePop(fb, _mtgFrom, (ts)=>{ _mtgFrom=ts; _mtgPage=1; loadScheduledMeetings(); }, {max:_mtgTo}); }; // #4: from ≤ to
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(); }); }; 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(); }, {min:_mtgFrom}); };
const c=wrap.querySelector('#mtgClear'); if(c) c.onclick=()=>applyMtgPreset('all'); const c=wrap.querySelector('#mtgClear'); if(c) c.onclick=()=>applyMtgPreset('all');
const pv=wrap.querySelector('#mtgPrev'), nx=wrap.querySelector('#mtgNext'); const pv=wrap.querySelector('#mtgPrev'), nx=wrap.querySelector('#mtgNext');
if(pv) pv.onclick=()=>{ if(_mtgPage>1){ _mtgPage--; loadScheduledMeetings(); } }; if(pv) pv.onclick=()=>{ if(_mtgPage>1){ _mtgPage--; loadScheduledMeetings(); } };
+15 -20
View File
@@ -1244,26 +1244,21 @@ route('GET', '/api/meetings', async (req, res) => {
// • a plain 1:1 direct call is NOT listed (it's a call, not a meeting) — UNLESS it produced a // • a plain 1:1 direct call is NOT listed (it's a call, not a meeting) — UNLESS it produced a
// recording/transcript, which the `synth` entries above already cover; // recording/transcript, which the `synth` entries above already cover;
// • a call that ever held MORE THAN 2 people IS listed (e.g. a 1:1 that a third person joined). // • a call that ever held MORE THAN 2 people IS listed (e.g. a 1:1 that a third person joined).
// Rooms already represented by a scheduled meeting or a recording entry are skipped, so nothing doubles. // #3/#7: calls.js writes a scheduled_meetings row for EVERY call ("Direct Call" for 1:1, "Group call"
const takenRooms = new Set([...schedByRoom.keys(), ...[...unsched.values()].map((l) => l[0].room).filter(Boolean)]); // for group). Those are the 136 "Direct Call" cards. Peak participant count per room comes from the
const callRows = []; // call log. Rule: an auto call-history row is shown in Past ONLY if it produced a recording/transcript
for (const c of R.callHistory.forTeam(u.team_id)) { // OR the call ever held MORE than 2 people. Plain 1:1s with neither are dropped. Real, user-scheduled
if (c.peak <= 2) continue; // 1:1 (or nobody) → not a meeting // meetings (any other title) are never filtered.
if (c.room && takenRooms.has(c.room)) continue; // already listed above const peakByRoom = {};
let uids = []; try { uids = JSON.parse(c.uids || '[]'); } catch (_) {} for (const c of R.callHistory.forTeam(u.team_id)) { if (c.room) peakByRoom[c.room] = Math.max(peakByRoom[c.room] || 0, c.peak || 0); }
const canSee = uids.includes(u.id) || (c.group_id && R.conversations.isMember(c.group_id, u.id)); const CALL_TITLES = new Set(['Direct Call', 'Group call']);
if (!canSee) continue; // only people who were actually in it const keptRows = rows.filter((m) => {
let parts = []; try { parts = JSON.parse(c.participants || '[]'); } catch (_) {} if (!CALL_TITLES.has(m.title)) return true; // a real scheduled meeting
callRows.push({ if (m.recordings && m.recordings.length) return true; // has a recording / transcript
id: 'call-' + c.id, roomCode: c.room || '', title: c.title || (c.group_id ? 'Group call' : 'Meeting'), const peak = peakByRoom[m.roomCode] || 0;
description: '', scheduledAt: c.started_at, endedAt: c.ended_at, groupId: c.group_id || null, if (peak > 2) { m.participantCount = peak; m.invited = []; return true; } // grew past 2 people
groupName: c.group_id ? ((R.conversations.byId(c.group_id) || {}).name || 'Group') : null, return false; // nothing to show → hide (#3)
createdBy: null, createdByName: '', canManage: false, isHost: false,
invited: parts, participantCount: c.peak,
durationMins: Math.max(1, Math.round((c.ended_at - c.started_at) / 60000)),
status: 'past', inCall: 0, recordings: [],
}); });
}
// Date filter + pagination apply to PAST only (running/upcoming are small and always returned whole). // Date filter + pagination apply to PAST only (running/upcoming are small and always returned whole).
const q = new URLSearchParams(req.url.split('?')[1] || ''); const q = new URLSearchParams(req.url.split('?')[1] || '');
@@ -1271,7 +1266,7 @@ route('GET', '/api/meetings', async (req, res) => {
const to = Number(q.get('to')) || 0; const to = Number(q.get('to')) || 0;
const page = Math.max(1, Number(q.get('page')) || 1); const page = Math.max(1, Number(q.get('page')) || 1);
const pageSize = Math.min(50, Math.max(5, Number(q.get('pageSize')) || 10)); const pageSize = Math.min(50, Math.max(5, Number(q.get('pageSize')) || 10));
const all = rows.concat(synth, callRows); const all = keptRows.concat(synth);
const live2 = all.filter((m) => m.status !== 'past'); const live2 = all.filter((m) => m.status !== 'past');
let past = all.filter((m) => m.status === 'past'); let past = all.filter((m) => m.status === 'past');
if (from) past = past.filter((m) => m.scheduledAt >= from); if (from) past = past.filter((m) => m.scheduledAt >= from);