From 41272bd9ce16ac2870421cea5c6a8c82ab0ce4e2 Mon Sep 17 00:00:00 2001
From: th
Date: Wed, 15 Apr 2026 21:16:45 +0200
Subject: [PATCH] board: spinner during first load instead of "no cards found"
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An empty board on first load blurting "no cards found" was alarming
on a board the user knew had cards. Track a cardsLoaded flag that
flips on the first /api/cards (meta or full) response — or on a
cached-cards hydration — and show a centred spinner placeholder
while it's still false. The "no cards found" copy only shows once
we've actually heard back and the set is genuinely empty.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
config.php | 2 +-
electron/package.json | 2 +-
public/js/app.js | 10 ++++++++--
public/js/modules/board.js | 15 ++++++++++++---
public/js/modules/state.js | 5 +++++
5 files changed, 27 insertions(+), 7 deletions(-)
diff --git a/config.php b/config.php
index ea6a849..616d4cb 100644
--- a/config.php
+++ b/config.php
@@ -1,7 +1,7 @@
{
await loadBuckets();
state.cards = await api('api/cards') || [];
+ state.cardsLoaded = true;
renderBoard();
refreshCalendar();
// Let plugins / modules react to a full card reload (detail view checks
@@ -86,10 +87,14 @@ function setCachedCards(cards) {
}
async function loadCards() {
- // Phase 0: Show cached cards instantly (from previous session)
+ // Phase 0: Show cached cards instantly (from previous session).
+ // A hit here is enough to flip cardsLoaded — fresh meta will
+ // overwrite state.cards a moment later, but the spinner→content
+ // transition already happened.
const cached = getCachedCards();
if (cached.length) {
state.cards = cached.map(c => ({ ...c, body: null }));
+ state.cardsLoaded = true;
updateSortUI();
renderBoard();
}
@@ -97,6 +102,7 @@ async function loadCards() {
// Phase 1: Load metadata → update board with fresh titles/tags/dates
const meta = await api('api/cards/meta');
if (!meta) return;
+ state.cardsLoaded = true;
state.cards = meta.map(c => ({ ...c, body: null }));
setCachedCards(meta);
updateSortUI();
@@ -158,7 +164,7 @@ importFile?.addEventListener('change', async e => {
if (!res.ok) throw new Error(text || `HTTP ${res.status}`);
const data = JSON.parse(text);
if (data.error) throw new Error(data.error);
- await loadBuckets(); state.cards = await api('api/cards'); renderBoard();
+ await loadBuckets(); state.cards = await api('api/cards'); state.cardsLoaded = true; renderBoard();
} catch (err) {
await showDialog({ title: 'import failed', message: err.message || 'Unknown error', danger: true, okLabel: 'OK', cancelLabel: '' });
}
diff --git a/public/js/modules/board.js b/public/js/modules/board.js
index a27d804..53a7719 100644
--- a/public/js/modules/board.js
+++ b/public/js/modules/board.js
@@ -587,9 +587,18 @@ export function renderBoard() {
board.style.display = 'flex';
board.style.alignItems = 'center';
board.style.justifyContent = 'center';
- board.innerHTML = ``;
+ // Distinguish "genuinely empty" from "haven't heard back yet".
+ // Blurting "no cards found" during the initial load is scary
+ // on boards the user knows aren't empty.
+ if (!state.cardsLoaded) {
+ board.innerHTML = ``;
+ } else {
+ board.innerHTML = ``;
+ }
const fc = $('footer-count');
if (fc) fc.textContent = `0 cards · ${state.cards.length} total`;
return;
diff --git a/public/js/modules/state.js b/public/js/modules/state.js
index cb60024..9c1e83b 100644
--- a/public/js/modules/state.js
+++ b/public/js/modules/state.js
@@ -6,6 +6,11 @@ export const state = {
// Data
cards: [],
buckets: [],
+ // True once we've got a real response from api/cards (or the
+ // cached-cards hydration). Until then the board shows a spinner
+ // instead of the "no cards found" placeholder — an empty board on
+ // first load is an honest "still fetching" state, not a real empty.
+ cardsLoaded: false,
// Filters & view — activeBucket is persisted across reloads via
// localStorage. Numeric ids are stringified; the special filters
|