v2.0.9 — sync toast split, relay group chat UI, find/replace flaky fix

- sync: the post-sync toast lumped relay pulls into the CalDAV stats
  and always labelled the banner "CalDAV". Split into two separate
  toasts (CalDAV cloud-arrow + Relay broadcast icon), each only shown
  when its numbers are non-zero. Banners now stack vertically so both
  can appear together on a sync that pulled on both sides.
- relay UI: the session chat view was 1:1-only. Group rooms (which
  the client API already supports) now surface as first-class threads
  in the sidebar — sorted chronologically with 1:1 peers, tagged
  "ROOM", with a users-three icon. Sending posts to `room_uid`
  instead of `recipient`; history shows the sender name above each
  incoming message so it's clear who's talking in the room.
  Relay backend: `/api/chat/threads` returns rooms alongside 1:1
  threads; `/api/chat/history` accepts `?room_uid=`; `/api/chat/send`
  accepts `room_uid` in the body.
- tests: bump "replace all updates card bodies" poll budget from 4s
  to 10s and poll both updated cards. Flaky on cold runs because a
  fresh PHP worker + SQLite WAL warm-up plus two sequential PATCH
  calls can just barely exceed 4 seconds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-18 18:28:00 +02:00
1 parent 4aca21812e
commit 87ebcd156b
6 files changed
+175 -51

No files matched your search

+21 -9
View File
@@ -59,14 +59,16 @@ export async function triggerSync() {
if (res.ok) {
const s = res.stats || {};
const rp = relayRes?.pulled || 0;
const total = { pushed: s.pushed || 0, pulled: (s.pulled || 0) + rp, deleted: s.deleted || 0 };
const caldavStats = { pushed: s.pushed || 0, pulled: s.pulled || 0, deleted: s.deleted || 0 };
const relayStats = { pulled: rp };
const allLog = res.log || [];
const hasErrors = allLog.some(l => l.status === 'error');
const msg = `pushed:${total.pushed} pulled:${total.pulled} del:${total.deleted}${rp ? ' relay:' + rp : ''}`;
const msg = `pushed:${caldavStats.pushed} pulled:${caldavStats.pulled + rp} del:${caldavStats.deleted}${rp ? ' relay:' + rp : ''}`;
if (syncStatus) { syncStatus.textContent = msg; syncStatus.style.color = hasErrors ? 'var(--t-danger)' : 'var(--t-accent)'; }
await window._reloadAll?.();
showLog(allLog);
showSyncNotification(total);
showSyncNotification('CalDAV', 'ph-cloud-arrow-down', caldavStats);
showSyncNotification('Relay', 'ph-broadcast', relayStats);
} else {
if (syncStatus) { syncStatus.textContent = res.error || 'sync failed'; syncStatus.style.color = 'var(--t-danger)'; }
}
@@ -99,19 +101,29 @@ export function autoSync() {
// ── Sync notifications ──────────────────────────────────────────
function showSyncNotification(stats) {
/** Per-source sync toast. Caller picks the label ("CalDAV" vs "Relay")
* and icon so the user knows which pipeline just finished, instead of
* the old always-CalDAV banner that lumped relay traffic in silently. */
let syncNoteOffset = 0;
function showSyncNotification(label, icon, stats) {
const s = stats || {};
const parts = [];
if (s.pulled) parts.push(`${s.pulled} received`);
if (s.pushed) parts.push(`${s.pushed} sent`);
if (s.pulled) parts.push(`${s.pulled} received`);
if (s.pushed) parts.push(`${s.pushed} sent`);
if (s.deleted) parts.push(`${s.deleted} deleted`);
if (!parts.length) return;
const bottom = 60 + syncNoteOffset * 48;
syncNoteOffset++;
const note = document.createElement('div');
note.style.cssText = 'position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent);border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:300px;';
note.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent);display:flex;align-items:center;gap:6px;"><i class="ph ph-cloud-arrow-down"></i>CalDAV: ${parts.join(', ')}</div>`;
note.style.cssText = `position:fixed;bottom:${bottom}px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent);border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:300px;`;
note.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent);display:flex;align-items:center;gap:6px;"><i class="ph ${icon}"></i>${label}: ${parts.join(', ')}</div>`;
document.body.appendChild(note);
setTimeout(() => { note.style.transition = 'opacity 0.3s'; note.style.opacity = '0'; setTimeout(() => note.remove(), 350); }, 4000);
setTimeout(() => {
note.style.transition = 'opacity 0.3s';
note.style.opacity = '0';
setTimeout(() => { note.remove(); syncNoteOffset = Math.max(0, syncNoteOffset - 1); }, 350);
}, 4000);
}
// One toast per incoming sync event: "alice synced: Card Title".
@@ -78,14 +78,17 @@ describe('Search: Find/Replace', () => {
const okBtn = document.getElementById('dialog-ok');
if (okBtn) click(okBtn);
// Poll until the replace has actually persisted
// Poll until both replaces have persisted. Budget is generous
// (~10s) because a cold PHP run plus two sequential PATCH calls
// can easily blow past the original 4-second window — the retry
// always passes because opcache + SQLite WAL are warm by then.
let updated1, updated2;
for (let i = 0; i < 40; i++) {
for (let i = 0; i < 100; i++) {
await sleep(100);
updated1 = await getCard(c1.id);
if (updated1?.body === 'goodbye world goodbye') break;
updated2 = await getCard(c2.id);
if (updated1?.body === 'goodbye world goodbye' && updated2?.body === 'goodbye there') break;
}
updated2 = await getCard(c2.id);
expect(updated1.body).toBe('goodbye world goodbye');
expect(updated2.body).toBe('goodbye there');
});