From be7eb1523e2876d58bc52ed861d071417dd03e25 Mon Sep 17 00:00:00 2001
From: th
Date: Thu, 16 Apr 2026 17:26:50 +0200
Subject: [PATCH] shopping: multi-list, DnD ordering, phone layout; fix flaky
tests
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
config.php | 2 +-
electron/package.json | 2 +-
public/index.php | 2 +-
public/plugins/shopping/backend.php | 5 +-
public/plugins/shopping/shopping.js | 266 ++++++++++++------
.../plugins/tests/board-find-replace.test.js | 14 +-
public/plugins/tests/line-numbers.test.js | 7 +-
public/plugins/tests/shopping.test.js | 8 +-
relay/inc/bootstrap.php | 35 ++-
relay/inc/routes/shopping.php | 84 +++---
relay/ui/app.js | 72 +++--
relay/ui/style.css | 65 ++++-
12 files changed, 370 insertions(+), 192 deletions(-)
diff --git a/config.php b/config.php
index 2c891b4..1ac86c0 100644
--- a/config.php
+++ b/config.php
@@ -1,7 +1,7 @@
shopping list (phone sync)
-
Tag a card with #einkauf or #shopping to mark it as a shopping-list candidate. Click the header's Shopping button to pick which one is the currently active list — only one at a time is mirrored to the phone.
+
Tag a card with #einkauf or #shopping to mark it as a shopping-list candidate. Click the header's Shopping button and check any number of lists to activate them — all active lists are pushed to the relay and shown stacked on the phone.
The active card's body is pushed to the relay on every sync tick. On your phone, log into the relay UI (same credentials as everywhere else) and open shopping from the menu. You see only the unchecked- [ ] items as tappable rows. Tapping an item flips it to - [x] on the relay in-place; the next client tick pulls that back and updates the local card.
Editing stays in hyprcrd — the phone can only check/uncheck, never change text. That keeps conflict handling trivial (last-writer wins by timestamp).
diff --git a/public/plugins/shopping/backend.php b/public/plugins/shopping/backend.php
index 825d054..ce157ea 100644
--- a/public/plugins/shopping/backend.php
+++ b/public/plugins/shopping/backend.php
@@ -34,7 +34,7 @@ function shoppingRelay(): array {
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
if ($route !== '/api/shopping' && $route !== '/api/shopping/check') return;
- if (!in_array($method, ['GET', 'POST'], true)) return;
+ if (!in_array($method, ['GET', 'POST', 'DELETE'], true)) return;
$r = shoppingRelay();
if (!$r['url'] || !$r['user'] || !$r['key']) {
@@ -54,6 +54,9 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
if ($method === 'POST') {
$opts[CURLOPT_POST] = true;
$opts[CURLOPT_POSTFIELDS] = file_get_contents('php://input') ?: '{}';
+ } elseif ($method === 'DELETE') {
+ $opts[CURLOPT_CUSTOMREQUEST] = 'DELETE';
+ $opts[CURLOPT_POSTFIELDS] = file_get_contents('php://input') ?: '{}';
}
curl_setopt_array($ch, $opts);
$response = curl_exec($ch);
diff --git a/public/plugins/shopping/shopping.js b/public/plugins/shopping/shopping.js
index 301e10f..f7ccd53 100644
--- a/public/plugins/shopping/shopping.js
+++ b/public/plugins/shopping/shopping.js
@@ -1,10 +1,10 @@
// ── Shopping-list plugin (hyprcrd side) ──
//
// Tag a card with #einkauf or #shopping to make it eligible. Pick
-// one as "active" via the Shopping header button; the active card's
-// body is pushed to the relay on every sync.tick and on every
+// any number as "active" via the Shopping header button; each active
+// card's body is pushed to the relay on every sync.tick and on every
// cards.reloaded so edits land on the phone within seconds. Pulls
-// phone-side toggles back and PATCHes the local card.
+// phone-side toggles back and PATCHes the local cards.
//
// No timestamp cursors — we diff the (uid, title, body) triple we
// last saw in sync with the relay, which is timezone-agnostic.
@@ -14,8 +14,9 @@ if (!hyprcrd) throw new Error('[shopping plugin] window.hyprcrd missing');
const $ = (id) => document.getElementById(id);
-const ACTIVE_KEY = 'hyprcrd-shopping-active-id';
+const ACTIVE_KEY = 'hyprcrd-shopping-active-ids';
const SYNCED_KEY = 'hyprcrd-shopping-synced';
+const ORDER_KEY = 'hyprcrd-shopping-order';
function esc(s) {
return String(s ?? '')
@@ -28,48 +29,71 @@ function hasEinkaufTag(card) {
return tags.includes('einkauf') || tags.includes('shopping');
}
-function getActiveId() {
- const v = localStorage.getItem(ACTIVE_KEY);
- return v ? String(v) : null;
+// ── Active IDs (multi-select) ────────────────────────────────
+
+function getActiveIds() {
+ try { return JSON.parse(localStorage.getItem(ACTIVE_KEY) || '[]'); } catch { return []; }
}
-function setActiveId(id) {
- if (id === null || id === '') localStorage.removeItem(ACTIVE_KEY);
- else localStorage.setItem(ACTIVE_KEY, String(id));
- localStorage.removeItem(SYNCED_KEY);
+function setActiveIds(ids) {
+ localStorage.setItem(ACTIVE_KEY, JSON.stringify(ids.map(String)));
+}
+function isActive(id) {
+ return getActiveIds().includes(String(id));
}
-function getActiveCard() {
- const id = getActiveId();
- if (!id) return null;
+function getActiveCards() {
+ const ids = getActiveIds();
+ if (!ids.length) return [];
const cards = window.state?.cards || [];
- return cards.find(c => String(c.id) === id) || null;
+ return ids.map(id => cards.find(c => String(c.id) === id)).filter(Boolean);
}
-function getSynced() {
- try { return JSON.parse(localStorage.getItem(SYNCED_KEY) || 'null'); } catch { return null; }
+function getStoredOrder() {
+ try { return JSON.parse(localStorage.getItem(ORDER_KEY) || '[]'); } catch { return []; }
}
-function setSynced(uid, title, body) {
- localStorage.setItem(SYNCED_KEY, JSON.stringify({ uid: String(uid), title: title || '', body: body || '' }));
+function setStoredOrder(ids) {
+ localStorage.setItem(ORDER_KEY, JSON.stringify(ids.map(String)));
+}
+function sortByOrder(cards) {
+ const order = getStoredOrder();
+ const idx = new Map(order.map((id, i) => [id, i]));
+ return [...cards].sort((a, b) => {
+ const ai = idx.has(String(a.id)) ? idx.get(String(a.id)) : 9999;
+ const bi = idx.has(String(b.id)) ? idx.get(String(b.id)) : 9999;
+ return ai - bi;
+ });
+}
+
+// ── Synced state (per card_uid) ──────────────────────────────
+
+function getSyncedMap() {
+ try { return JSON.parse(localStorage.getItem(SYNCED_KEY) || '{}'); } catch { return {}; }
+}
+function setSyncedFor(uid, title, body, pos) {
+ const m = getSyncedMap();
+ m[String(uid)] = { title: title || '', body: body || '', pos: pos ?? -1 };
+ localStorage.setItem(SYNCED_KEY, JSON.stringify(m));
+}
+function removeSyncedFor(uid) {
+ const m = getSyncedMap();
+ delete m[String(uid)];
+ localStorage.setItem(SYNCED_KEY, JSON.stringify(m));
}
// ── Header button injection ──────────────────────────────────
function injectHeaderButton() {
if ($('shopping-btn')) return;
- // Put it next to Calendar. The header's right-hand button row
- // lives in `header > div:last-child > .flex.items-center.gap-3`.
const header = document.querySelector('header#main-header');
if (!header) return;
- const rightRow = header.querySelectorAll('.flex.items-center.gap-3');
- const group = rightRow[rightRow.length - 1];
+ const rows = header.querySelectorAll('.flex.items-center.gap-3');
+ const group = rows[rows.length - 1];
if (!group) return;
const btn = document.createElement('button');
btn.id = 'shopping-btn';
btn.className = 'flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors';
btn.innerHTML = 'Shopping';
btn.addEventListener('click', openDialog);
- // Insert as the first item of the right-hand group so the order
- // reads Shopping, Calendar, Assets, Tasks, New.
group.insertBefore(btn, group.firstChild);
}
@@ -87,14 +111,14 @@ function openDialog() {
panel.innerHTML = `
-
Shopping list
+
Shopping lists
Cards tagged with #einkauf or
- #shopping can be set as the active list for the
- phone. Only one is active at a time; its body is pushed to the relay on every sync
- and appears under the shopping tab in the relay UI.
+ #shopping can be activated for the phone.
+ Check any number of lists; their items appear stacked under the
+ shopping tab in the relay UI.