From 26f95ba0790edd307c461da76fe29e3b336318c1 Mon Sep 17 00:00:00 2001
From: th
Date: Tue, 14 Apr 2026 22:39:32 +0200
Subject: [PATCH] collab per-card unread, detail stale banner, image sync race
fix
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- per-card unread dot on board (collab-buckets): backend tracks
last_seen_at per card, poll exposes unread_cards, frontend decorates
matching board cards; cleared on any open path via detail.opened hook
- detail.opened + cards.reloaded hooks on the hyprcrd event bus
- stale banner in detail view with manual Reload when the currently
viewed card got updated by a peer — no content swap under the user
- collab image sync: push attachments BEFORE card (race fix) + pull-side
self-heal scans every collab card for missing uploads each poll
- relay user deletion now purges chat_messages sent by deleted user so
ghost threads stop appearing for their peers
- calendar header nav buttons styled as square bordered buttons that
honor --ui-radius-button; title bar widened to 280px so font variants
don't wrap
- .toolbar-btn now uses var(--ui-radius-button) instead of hardcoded 3px
Co-Authored-By: Claude Opus 4.6 (1M context)
---
config.php | 2 +-
electron/package.json | 2 +-
public/css/style.css | 32 ++++-
public/index.php | 8 +-
public/js/app.js | 11 +-
public/js/modules/detail.js | 75 ++++++++++
public/plugins/collab-buckets/backend.php | 135 ++++++++++++++----
.../plugins/collab-buckets/collab-buckets.css | 18 +++
.../plugins/collab-buckets/collab-buckets.js | 71 ++++++++-
relay/inc/routes/admin.php | 5 +
10 files changed, 324 insertions(+), 35 deletions(-)
diff --git a/config.php b/config.php
index 1c7befb..44d8609 100644
--- a/config.php
+++ b/config.php
@@ -1,7 +1,7 @@
Today
Sync
-
-
-
-
+
+
+
+
diff --git a/public/js/app.js b/public/js/app.js
index 721cd87..5fcac02 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -41,7 +41,16 @@ window._getSelectedIds = getSelectedIds;
window._clearSelection = clearSelection;
window._refreshTasks = refreshTasksView;
window.openFindBar = openFindBar;
-window._reloadAll = async () => { await loadBuckets(); state.cards = await api('api/cards') || []; renderBoard(); refreshCalendar(); };
+window._reloadAll = async () => {
+ await loadBuckets();
+ state.cards = await api('api/cards') || [];
+ renderBoard();
+ refreshCalendar();
+ // Let plugins / modules react to a full card reload (detail view checks
+ // whether the currently viewed card is now stale; collab-buckets uses it
+ // to refresh its decorators).
+ window.hyprcrd?.hooks?.emit?.('cards.reloaded');
+};
// Exposed for plugins that need to read the shared state (e.g. collab-buckets
// reads `state.buckets` in its MutationObserver to find collab_uid mapping).
window.state = state;
diff --git a/public/js/modules/detail.js b/public/js/modules/detail.js
index 0711e7e..7c3345e 100644
--- a/public/js/modules/detail.js
+++ b/public/js/modules/detail.js
@@ -72,9 +72,83 @@ export function openDetail(id, options = {}) {
renderSidebar(card);
showContent(card);
+ dismissStaleBanner();
detail.classList.add('open');
+
+ // Let plugins (collab-buckets unread-dot, etc.) react to the open.
+ window.hyprcrd?.hooks?.emit?.('detail.opened', { id: card.id });
}
+// ── Remote-update detection ──
+//
+// When a foreign edit arrives for the card the user is currently viewing,
+// the pull replaces state.cards but the open detail view keeps rendering
+// the stale object it captured on open. Instead of force-re-rendering
+// under the user's finger (jarring when they're mid-read), surface a
+// banner with a manual Reload button.
+
+let staleBannerEl = null;
+
+function dismissStaleBanner() {
+ if (staleBannerEl) { staleBannerEl.remove(); staleBannerEl = null; }
+}
+
+function showStaleBanner(fresh) {
+ dismissStaleBanner();
+ const host = detail.querySelector('#detail-controls')?.parentElement;
+ if (!host) return;
+ const bar = document.createElement('div');
+ bar.className = 'detail-stale-banner';
+ bar.innerHTML = `
+
+ This card was updated${fresh?.updated_at ? ' ' + timeAgo(fresh.updated_at) : ''}.
+
+
+ Reload
+
+
+ `;
+ host.insertBefore(bar, host.querySelector('#detail-controls').nextSibling);
+ staleBannerEl = bar;
+ bar.querySelector('.detail-stale-reload').addEventListener('click', () => {
+ const current = state.cards.find(c => c.id == state.viewingCard?.id);
+ if (current) {
+ state.viewingCard = current;
+ renderSidebar(current);
+ showContent(current);
+ }
+ dismissStaleBanner();
+ });
+ bar.querySelector('.detail-stale-dismiss').addEventListener('click', dismissStaleBanner);
+}
+
+function timeAgo(iso) {
+ const t = new Date(iso).getTime();
+ if (!isFinite(t)) return '';
+ const s = Math.max(0, (Date.now() - t) / 1000);
+ if (s < 60) return `${Math.floor(s)}s ago`;
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
+ return `${Math.floor(s / 86400)}d ago`;
+}
+
+// After every full card reload, check whether the card we're viewing is
+// newer in the fresh state. If so, show the banner. Dismiss it if the
+// detail view closed in the meantime.
+window.hyprcrd?.hooks?.on?.('cards.reloaded', () => {
+ if (!detail.classList.contains('open') || !state.viewingCard) {
+ dismissStaleBanner();
+ return;
+ }
+ const viewingId = state.viewingCard.id;
+ const fresh = state.cards.find(c => c.id == viewingId);
+ if (!fresh) return; // card deleted — separate UX, skip banner
+ const old = state.viewingCard;
+ if ((fresh.updated_at || '') > (old.updated_at || '')) {
+ showStaleBanner(fresh);
+ }
+});
+
function clearMorphGhost() {
if (morphTimer) {
clearTimeout(morphTimer);
@@ -370,6 +444,7 @@ export function closeDetail(options = {}) {
closeImageFullscreen();
detail.classList.remove('open', 'zen');
+ dismissStaleBanner();
removeZenHandles();
document.documentElement.classList.remove('detail-zen-open');
document.body.classList.remove('detail-zen-open');
diff --git a/public/plugins/collab-buckets/backend.php b/public/plugins/collab-buckets/backend.php
index fd1b2a3..14c933a 100644
--- a/public/plugins/collab-buckets/backend.php
+++ b/public/plugins/collab-buckets/backend.php
@@ -49,10 +49,20 @@
bucket_uid TEXT NOT NULL,
last_push_at TEXT,
last_remote_at TEXT,
+ last_seen_at TEXT,
FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
)
");
$db->exec("CREATE INDEX IF NOT EXISTS idx_collab_card_state_uid ON collab_card_state(relay_uid)");
+ // Per-card unread tracking (added 2026-04-14). Existing deployments need
+ // the column added explicitly.
+ $ccsCols = array_column($db->query('PRAGMA table_info(collab_card_state)')->fetchAll(), 'name');
+ if (!in_array('last_seen_at', $ccsCols)) {
+ $db->exec("ALTER TABLE collab_card_state ADD COLUMN last_seen_at TEXT");
+ // Backfill: everything existing gets seen=now so we don't flood the
+ // user with a board full of red dots after they deploy this.
+ $db->exec("UPDATE collab_card_state SET last_seen_at = datetime('now')");
+ }
// Tombstone outbox: populated by a BEFORE DELETE trigger on the cards
// table. The poll's push phase drains it and sends each entry as a
@@ -449,12 +459,13 @@ function collabSyncCards(PDO $db): array {
$assignUid = $db->prepare('UPDATE cards SET relay_uid = :u WHERE id = :id');
$stateUpsert = $db->prepare("
- INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at)
- VALUES (:cid, :ru, :bu, :push, COALESCE((SELECT last_remote_at FROM collab_card_state WHERE card_id = :cid2), NULL))
+ INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at, last_seen_at)
+ VALUES (:cid, :ru, :bu, :push, COALESCE((SELECT last_remote_at FROM collab_card_state WHERE card_id = :cid2), NULL), :push2)
ON CONFLICT(card_id) DO UPDATE SET
relay_uid = excluded.relay_uid,
bucket_uid = excluded.bucket_uid,
- last_push_at = excluded.last_push_at
+ last_push_at = excluded.last_push_at,
+ last_seen_at = excluded.last_seen_at
");
foreach ($dirty as $card) {
@@ -467,6 +478,31 @@ function collabSyncCards(PDO $db): array {
$payload = collabEncryptPayload(collabBuildCardPayload($card));
if ($payload === null) continue; // no relay_key or openssl failure
+ // Attachments FIRST, card second. If a peer polls between the two
+ // HTTP calls, they'd see the new card body with an `uploads/xyz.png`
+ // reference but the attachment wouldn't be on the relay yet — their
+ // fetch returns empty and the image renders broken permanently
+ // (the pull short-circuits on the card's next poll because
+ // updated_at doesn't change). Pushing attachments first guarantees
+ // the relay's state is consistent: if the card is visible, its
+ // images are fetchable.
+ $body = $card['body'] ?? '';
+ if ($body && preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $body, $fm)) {
+ foreach (array_unique($fm[1]) as $fn) {
+ $localPath = UPLOAD_DIR . '/' . $fn;
+ if (!is_file($localPath)) continue;
+ $bytes = file_get_contents($localPath);
+ if ($bytes === false) continue;
+ $encoded = collabEncryptPayload(['raw' => base64_encode($bytes)]);
+ if ($encoded === null) continue;
+ collabRelayCall('/api/collab-attachments', 'POST', [
+ 'bucket_uid' => $card['bucket_uid'],
+ 'filename' => $fn,
+ 'data' => $encoded,
+ ]);
+ }
+ }
+
$res = collabRelayCall('/api/collab-cards', 'POST', [
'bucket_uid' => $card['bucket_uid'],
'card_uid' => $relayUid,
@@ -491,27 +527,9 @@ function collabSyncCards(PDO $db): array {
'ru' => $relayUid,
'bu' => $card['bucket_uid'],
'push' => $res['updated_at'] ?? $card['updated_at'],
+ 'push2'=> $res['updated_at'] ?? $card['updated_at'],
]);
$stats['pushed']++;
-
- // Push referenced local attachments to the bucket (encrypted,
- // member-scoped). Extract `uploads/.` from the body.
- $body = $card['body'] ?? '';
- if ($body && preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $body, $fm)) {
- foreach (array_unique($fm[1]) as $fn) {
- $localPath = UPLOAD_DIR . '/' . $fn;
- if (!is_file($localPath)) continue;
- $bytes = file_get_contents($localPath);
- if ($bytes === false) continue;
- $encoded = collabEncryptPayload(['raw' => base64_encode($bytes)]);
- if ($encoded === null) continue;
- collabRelayCall('/api/collab-attachments', 'POST', [
- 'bucket_uid' => $card['bucket_uid'],
- 'filename' => $fn,
- 'data' => $encoded,
- ]);
- }
- }
}
// ── 3. Pull deltas ─────────────────────────────────────
@@ -629,21 +647,30 @@ function collabSyncCards(PDO $db): array {
$localId = (int)$db->lastInsertId();
}
- // Record the remote state so we don't push it back on the next poll
+ // Record the remote state so we don't push it back on the next
+ // poll. last_seen_at semantics: self-authored updates (our own
+ // echo) mark as seen; foreign-authored updates leave last_seen_at
+ // untouched so the red dot fires on the board.
+ $seenAt = $foreignAuthor ? null : $c['updated_at'];
$db->prepare("
- INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at)
- VALUES (:cid, :ru, :bu, :p, :r)
+ INSERT INTO collab_card_state (card_id, relay_uid, bucket_uid, last_push_at, last_remote_at, last_seen_at)
+ VALUES (:cid, :ru, :bu, :p, :r, :s)
ON CONFLICT(card_id) DO UPDATE SET
relay_uid = excluded.relay_uid,
bucket_uid = excluded.bucket_uid,
last_push_at = excluded.last_push_at,
- last_remote_at = excluded.last_remote_at
+ last_remote_at = excluded.last_remote_at,
+ last_seen_at = CASE
+ WHEN excluded.last_seen_at IS NOT NULL THEN excluded.last_seen_at
+ ELSE collab_card_state.last_seen_at
+ END
")->execute([
'cid' => $localId,
'ru' => $c['card_uid'],
'bu' => $c['bucket_uid'],
'p' => $c['updated_at'],
'r' => $c['updated_at'],
+ 's' => $seenAt,
]);
$stats['pulled']++;
if ($foreignAuthor) $unreadBuckets[$c['bucket_uid']] = true;
@@ -701,6 +728,40 @@ function collabSyncCards(PDO $db): array {
}
if ($maxTs !== $since) collabSaveSetting('collab_cards_last_remote_at', $maxTs);
+
+ // Self-heal phase: walk every card in a collab bucket and try to fetch
+ // any `uploads/xxx.png` reference whose file isn't on disk. Covers the
+ // historical race where a card body arrived before its attachment, as
+ // well as any one-off fetch failure (network blip, timeout) that left
+ // a card with a broken image reference. Normal-case cost is trivial:
+ // one SQL, one `file_exists()` per reference, no HTTP when everything
+ // is already in place.
+ $missing = $db->query("
+ SELECT c.id, c.body, b.collab_uid AS bucket_uid
+ FROM cards c
+ INNER JOIN buckets b ON b.id = c.bucket_id AND b.collab_uid IS NOT NULL
+ WHERE c.body LIKE '%uploads/%'
+ ")->fetchAll();
+ $uploadDir = UPLOAD_DIR;
+ if (!is_dir($uploadDir)) @mkdir($uploadDir, 0755, true);
+ foreach ($missing as $mc) {
+ if (!preg_match_all('#uploads/([a-f0-9]+\.\w+)#', $mc['body'] ?? '', $fm)) continue;
+ foreach (array_unique($fm[1]) as $fn) {
+ $dest = $uploadDir . '/' . $fn;
+ if (file_exists($dest)) continue;
+ $attRes = collabRelayCall('/api/collab-attachments?bucket_uid=' . urlencode($mc['bucket_uid']) . '&filename=' . urlencode($fn));
+ if (!($attRes['ok'] ?? false)) continue;
+ foreach (($attRes['attachments'] ?? []) as $a) {
+ if (($a['filename'] ?? '') !== $fn) continue;
+ $decoded = collabDecryptPayload($a['data'] ?? '');
+ if ($decoded && isset($decoded['raw'])) {
+ $bytes = base64_decode($decoded['raw']);
+ if ($bytes !== false) file_put_contents($dest, $bytes);
+ }
+ }
+ }
+ }
+
return $stats;
}
@@ -897,6 +958,16 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
// Any pending outbox entries still waiting for a reachable relay
$outboxPending = (int)$db->query('SELECT COUNT(*) FROM collab_bucket_outbox')->fetchColumn();
+ // Per-card unread set: cards whose local updated_at is newer than
+ // their last_seen_at (or never seen). Frontend uses this to draw
+ // the little red dot on the board card.
+ $unreadCards = array_map('intval', $db->query("
+ SELECT cs.card_id
+ FROM collab_card_state cs
+ JOIN cards c ON c.id = cs.card_id
+ WHERE cs.last_seen_at IS NULL OR c.updated_at > cs.last_seen_at
+ ")->fetchAll(PDO::FETCH_COLUMN));
+
collabJson([
'ok' => true,
'self' => collabRelaySettings()['user'],
@@ -907,6 +978,7 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
'locks' => $locks,
'outbox_processed' => $outboxProcessed,
'outbox_pending' => $outboxPending,
+ 'unread_cards' => $unreadCards,
]);
}
@@ -921,6 +993,17 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
collabJson(['ok' => true]);
}
+ // ── Mark a single collab card as seen (clears its red dot) ────
+ if ($route === '/api/collab-buckets/mark-card-seen' && $method === 'POST') {
+ $input = json_decode(file_get_contents('php://input'), true) ?: [];
+ $cardId = (int)($input['card_id'] ?? 0);
+ if ($cardId <= 0) collabJson(['ok' => false, 'error' => 'card_id required'], 400);
+ Database::connect()
+ ->prepare("UPDATE collab_card_state SET last_seen_at = datetime('now') WHERE card_id = :c")
+ ->execute(['c' => $cardId]);
+ collabJson(['ok' => true]);
+ }
+
// ── Proxy: relay tenants (for the create-bucket member picker) ──
// The relay's /api/tenants returns {tenants, self} without an `ok` field,
// so we wrap it here to match the shape the frontend expects.
diff --git a/public/plugins/collab-buckets/collab-buckets.css b/public/plugins/collab-buckets/collab-buckets.css
index 4dc2cb9..247f1bf 100644
--- a/public/plugins/collab-buckets/collab-buckets.css
+++ b/public/plugins/collab-buckets/collab-buckets.css
@@ -359,3 +359,21 @@ body.anim-slow .bucket-tab.bucket-tab-collab.pulsing {
.collab-conflict-toast.collab-lock-toast b {
color: var(--t-accent2);
}
+
+/* ── Per-card unread indicator ──
+ Mirror of the bucket-tab red dot: a card with a foreign edit we haven't
+ opened yet gets a small glowing dot pinned to the top-right corner. */
+.board-card.collab-card-unread { position: relative; }
+.board-card.collab-card-unread .collab-card-dot {
+ position: absolute;
+ top: -3px;
+ right: -3px;
+ width: 9px;
+ height: 9px;
+ border-radius: 50%;
+ background: var(--t-danger);
+ border: 1px solid var(--t-bg);
+ box-shadow: 0 0 6px color-mix(in srgb, var(--t-danger) 60%, transparent);
+ pointer-events: none;
+ z-index: 3;
+}
diff --git a/public/plugins/collab-buckets/collab-buckets.js b/public/plugins/collab-buckets/collab-buckets.js
index 283533a..55a6b82 100644
--- a/public/plugins/collab-buckets/collab-buckets.js
+++ b/public/plugins/collab-buckets/collab-buckets.js
@@ -24,6 +24,8 @@ const $ = (id) => document.getElementById(id);
// uid -> collab bucket meta (from /api/collab-buckets)
let collabByUid = new Map();
+// card_id (number) -> true for cards with foreign edits we haven't opened yet
+let unreadCards = new Set();
let pollHandle = null;
const POLL_INTERVAL_MS = 30000;
@@ -92,6 +94,12 @@ async function pollOnce() {
const n = res.outbox_processed;
showQueuedToast(`${n} queued bucket${n === 1 ? '' : 's'} synced to the relay.`);
}
+ // Per-card unread list: cards with foreign edits we haven't opened
+ // yet. Decorated on the board via decorateBoardUnread().
+ if (Array.isArray(res.unread_cards)) {
+ unreadCards = new Set(res.unread_cards.map(Number));
+ decorateBoardUnread();
+ }
// Phase 4: edit-lock list comes back with the poll
if (Array.isArray(res.locks)) {
const next = new Map();
@@ -638,12 +646,71 @@ function decorateBoardLocks() {
}
}
+/**
+ * Walk every rendered board card and add / remove the "red dot" unread
+ * indicator for cards with foreign edits we haven't opened yet.
+ * Idempotent — safe to re-run on every board re-render.
+ */
+function decorateBoardUnread() {
+ const board = document.getElementById('board');
+ if (!board) return;
+ for (const el of board.querySelectorAll('.board-card[data-id]')) {
+ const cid = parseInt(el.dataset.id);
+ const isUnread = unreadCards.has(cid);
+ el.classList.toggle('collab-card-unread', isUnread);
+ let dot = el.querySelector('.collab-card-dot');
+ if (isUnread && !dot) {
+ dot = document.createElement('span');
+ dot.className = 'collab-card-dot';
+ el.appendChild(dot);
+ } else if (!isUnread && dot) {
+ dot.remove();
+ }
+ }
+}
+
+// Clear a card's unread state locally + on the backend. Called from
+// - board click (piggy-backing on the natural open-card interaction)
+// - detail.opened hook (when the detail view opens via any path:
+// keyboard shortcut, related-card link, backlink, search result, etc.)
+function clearCardUnread(cid) {
+ cid = parseInt(cid);
+ if (!cid || !unreadCards.has(cid)) return;
+ unreadCards.delete(cid);
+ const el = document.querySelector(`#board .board-card[data-id="${cid}"]`);
+ if (el) {
+ el.classList.remove('collab-card-unread');
+ el.querySelector('.collab-card-dot')?.remove();
+ }
+ hyprcrd.api('api/collab-buckets/mark-card-seen', {
+ method: 'POST', body: { card_id: cid },
+ }).catch(() => {});
+}
+
+function setupCardClickInterceptor() {
+ const board = document.getElementById('board');
+ if (board) {
+ board.addEventListener('click', (e) => {
+ const el = e.target.closest('.board-card[data-id]');
+ if (el) clearCardUnread(el.dataset.id);
+ });
+ }
+ // Detail view has its own open path (keyboard shortcuts, backlinks,
+ // related-card navigation) that doesn't route through a board-card
+ // click. Listen for the explicit detail.opened hook so every open
+ // method clears the dot.
+ hyprcrd.hooks?.on?.('detail.opened', (data) => clearCardUnread(data?.id));
+}
+
// Re-decorate whenever the board re-renders (reuses the existing #board
// MutationObserver pattern).
function setupBoardObserver() {
const board = document.getElementById('board');
if (!board) return;
- const obs = new MutationObserver(() => decorateBoardLocks());
+ const obs = new MutationObserver(() => {
+ decorateBoardLocks();
+ decorateBoardUnread();
+ });
obs.observe(board, { childList: true, subtree: false });
}
@@ -899,12 +966,14 @@ async function init() {
}
setupBarObserver();
setupClickInterceptor();
+ setupCardClickInterceptor();
setupBoardObserver();
await loadCollabMeta();
await pollOnce();
await loadCollabMeta();
decorateBucketBar();
decorateBoardLocks();
+ decorateBoardUnread();
startPolling();
}
diff --git a/relay/inc/routes/admin.php b/relay/inc/routes/admin.php
index 48573c5..ddfe6e2 100644
--- a/relay/inc/routes/admin.php
+++ b/relay/inc/routes/admin.php
@@ -82,6 +82,11 @@ if (preg_match('#^/api/users/(\d+)$#', $route, $m) && $method === 'DELETE') {
if ($id === (int)$sessionTenant['id']) {
jsonResponse(['error' => 'cannot delete yourself'], 400);
}
+ // chat_messages.recipient_id cascades via FK, but sender_id is ON DELETE
+ // SET NULL — that leaves sender_username populated so the deleted user
+ // keeps showing up as a chat thread for their peers. Purge those rows
+ // explicitly BEFORE the tenant delete, while sender_id is still matchable.
+ $db->prepare('DELETE FROM chat_messages WHERE sender_id = :id')->execute(['id' => $id]);
$db->prepare('DELETE FROM tenants WHERE id = :id')->execute(['id' => $id]);
jsonResponse(['ok' => true]);
}