shopping: multi-list, DnD ordering, phone layout; fix flaky tests

Shopping lists:
  - Multiple lists per tenant: picker uses checkboxes instead of
    radios. Relay schema migrated to composite PK (tenant_id,
    card_uid) with a position column for display ordering.
  - Picker rows are drag-and-drop reorderable (grip handle +
    accent drop indicator). Order persists in localStorage and is
    pushed to the relay as position integers — phone renders lists
    in that order (ORDER BY position, title).
  - Each picker row has a goto-arrow that closes the popup,
    switches to the card's bucket if needed, and opens the card.
  - Relay DELETE /api/shopping removes deactivated lists. Client
    proxy forwards DELETE. Deactivating a list in the picker
    immediately removes it from the relay.
  - Phone UI renders multiple lists as stacked boxes with headers.
    Section headings (# h1–h6) get 14px top padding for breathing
    room. CSS selectors fixed to match div-based headings inside
    the new .shopping-list-box containers.
  - Help text updated for multi-list wording.
  - pullOne preserves the synced position so a pull doesn't cause
    a spurious re-push of the same order.

Test fixes:
  - Line-numbers gutter height tolerance bumped 50%→60% — OS text
    shaping can push the divergence past 50%.
  - Find/replace flaky: replaced sleep(200) with waitFor on the
    confirm dialog so slow renders don't miss the OK click.
  - Shopping picker test updated: checkboxes instead of radios.

Bumps to 1.9.25.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-16 17:26:50 +02:00
1 parent 4c3954d989
commit be7eb1523e
12 files changed
+370 -192

No files matched your search

+47 -25
View File
@@ -773,45 +773,67 @@ async function loadShopping(quiet = false) {
const list = $('shopping-items');
if (!list) return;
if (!data?.ok) { if (!quiet) shoppingErr(data?.error || 'load failed'); return; }
if (!data.state) {
if (title) title.textContent = '';
list.innerHTML = '<li class="empty">no active shopping list</li>';
return;
}
const { title: t, body } = data.state;
if (title) title.textContent = t || '';
const nodes = shoppingParseLines(body);
const hasItems = nodes.some(n => n.kind === 'item');
if (!hasItems) {
list.innerHTML = '<li class="empty">\u2713 all done</li>';
const lists = data.lists || [];
if (!lists.length) {
if (title) title.textContent = 'Shopping';
list.innerHTML = '<li class="empty">no active shopping lists</li>';
return;
}
if (title) title.textContent = lists.length === 1 ? (lists[0].title || 'Shopping') : 'Shopping';
list.innerHTML = '';
for (const n of nodes) {
const li = document.createElement('li');
if (n.kind === 'heading') {
li.className = 'shopping-heading h' + n.level;
li.textContent = n.text;
} else {
li.textContent = n.text;
li.dataset.line = n.line;
li.addEventListener('click', () => shoppingCheck(li));
for (const entry of lists) {
const nodes = shoppingParseLines(entry.body);
const hasItems = nodes.some(n => n.kind === 'item');
// List box wrapper
const box = document.createElement('li');
box.className = 'shopping-list-box';
if (lists.length > 1 || entry.title) {
const hdr = document.createElement('div');
hdr.className = 'shopping-list-header';
hdr.textContent = entry.title || '(untitled)';
box.appendChild(hdr);
}
list.appendChild(li);
if (!hasItems) {
const done = document.createElement('div');
done.className = 'shopping-list-done';
done.textContent = '\u2713 all done';
box.appendChild(done);
} else {
for (const n of nodes) {
if (n.kind === 'heading') {
const h = document.createElement('div');
h.className = 'shopping-heading h' + n.level;
h.textContent = n.text;
box.appendChild(h);
} else {
const row = document.createElement('div');
row.className = 'shopping-item';
row.textContent = n.text;
row.dataset.line = n.line;
row.dataset.uid = entry.card_uid;
row.addEventListener('click', () => shoppingCheck(row));
box.appendChild(row);
}
}
}
list.appendChild(box);
}
}
async function shoppingCheck(li) {
async function shoppingCheck(el) {
if (shoppingBusy) return;
shoppingBusy = true;
li.classList.add('checked');
el.classList.add('checked');
try {
const res = await api('api/shopping/check', { method: 'POST', body: { line: li.dataset.line, checked: true } });
const res = await api('api/shopping/check', {
method: 'POST',
body: { card_uid: el.dataset.uid, line: el.dataset.line, checked: true },
});
if (!res?.ok) throw new Error(res?.error || 'check failed');
setTimeout(() => { shoppingBusy = false; loadShopping(true); }, 300);
} catch (e) {
shoppingBusy = false;
li.classList.remove('checked');
el.classList.remove('checked');
shoppingErr(e.message);
}
}