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.
@@ -117,9 +141,9 @@ function openDialog() { }); const cards = window.state?.cards || []; - const candidates = cards.filter(hasEinkaufTag); - const currentActive = getActiveId(); - let selectedId = currentActive; + const candidates = sortByOrder(cards.filter(hasEinkaufTag)); + const currentIds = new Set(getActiveIds()); + const selected = new Set(currentIds); if (!candidates.length) { list.innerHTML = `
@@ -130,93 +154,165 @@ function openDialog() { } else { list.innerHTML = candidates.map(c => { const id = String(c.id); - const checked = id === selectedId ? 'checked' : ''; + const checked = selected.has(id) ? 'checked' : ''; const items = (c.body || '').split('\n').filter(l => /^\s*-\s*\[[ xX]\]/.test(l)); const open = items.filter(l => /^\s*-\s*\[\s\]/.test(l)).length; - return ``; + return `
+ + + +
`; }).join(''); - list.insertAdjacentHTML('beforeend', ``); - list.querySelectorAll('input[name="shopping-active"]').forEach(r => { - r.addEventListener('change', () => { selectedId = r.value || null; }); + list.querySelectorAll('input[type="checkbox"]').forEach(cb => { + cb.addEventListener('change', () => { + if (cb.checked) selected.add(cb.dataset.cardId); + else selected.delete(cb.dataset.cardId); + }); + }); + // Drag-and-drop reordering of picker rows. + let dragRow = null; + list.querySelectorAll('.shopping-picker-row').forEach(row => { + row.addEventListener('dragstart', (e) => { + dragRow = row; + e.dataTransfer.effectAllowed = 'move'; + row.style.opacity = '0.4'; + }); + row.addEventListener('dragend', () => { + row.style.opacity = ''; + dragRow = null; + list.querySelectorAll('.shopping-picker-row').forEach(r => r.style.borderTop = ''); + }); + row.addEventListener('dragover', (e) => { + if (!dragRow || dragRow === row) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + list.querySelectorAll('.shopping-picker-row').forEach(r => r.style.borderTop = ''); + const rect = row.getBoundingClientRect(); + const mid = rect.top + rect.height / 2; + if (e.clientY < mid) { + row.style.borderTop = '2px solid var(--t-accent)'; + } else { + const next = row.nextElementSibling; + if (next) next.style.borderTop = '2px solid var(--t-accent)'; + } + }); + row.addEventListener('drop', (e) => { + e.preventDefault(); + if (!dragRow || dragRow === row) return; + const rect = row.getBoundingClientRect(); + const mid = rect.top + rect.height / 2; + if (e.clientY < mid) { + list.insertBefore(dragRow, row); + } else { + list.insertBefore(dragRow, row.nextSibling); + } + // Persist new order from DOM. + const newOrder = [...list.querySelectorAll('.shopping-picker-row')].map(r => r.dataset.cardId); + setStoredOrder(newOrder); + list.querySelectorAll('.shopping-picker-row').forEach(r => r.style.borderTop = ''); + }); + }); + + list.querySelectorAll('.shopping-goto').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const cardId = btn.dataset.cardId; + const bucketId = btn.dataset.bucket; + close(); + // Switch bucket if needed so the card is visible. + if (bucketId && window.state?.activeBucket !== bucketId + && window.state?.activeBucket !== 'all') { + window.state.activeBucket = bucketId; + localStorage.setItem('hyprcrd-active-bucket', bucketId); + window._renderBoard?.(); + } + setTimeout(() => window._openDetail?.(parseInt(cardId)), 100); + }); }); } panel.querySelector('.shopping-picker-save').addEventListener('click', async () => { - const prev = getActiveId(); - setActiveId(selectedId); + const prev = getActiveIds(); + const next = [...selected]; + setActiveIds(next); status.textContent = 'saving…'; try { - if (selectedId) await pushActive(); - else await clearRemote(); + // Remove deactivated lists from relay. + for (const id of prev) { + if (!selected.has(id)) { + removeSyncedFor(id); + await hyprcrd.api('api/shopping', { method: 'DELETE', body: { card_uid: id } }).catch(() => {}); + } + } + // Push all active lists. + await tick(); status.textContent = 'saved'; setTimeout(close, 400); } catch (e) { status.textContent = 'error: ' + (e?.message || e); status.style.color = 'var(--t-danger)'; - setActiveId(prev); + setActiveIds(prev); } }); } // ── Sync ───────────────────────────────────────────────────── -async function pushActive() { - const card = getActiveCard(); - if (!card) return; +async function pushOne(card) { const uid = String(card.id); const title = card.title || ''; const body = card.body || ''; - const synced = getSynced(); - if (synced && synced.uid === uid && synced.title === title && synced.body === body) return; + const order = getStoredOrder(); + const pos = order.indexOf(uid); + const synced = getSyncedMap()[uid]; + if (synced && synced.title === title && synced.body === body && synced.pos === pos) return; const res = await hyprcrd.api('api/shopping', { method: 'POST', - body: { card_uid: uid, title, body, updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) }, + body: { card_uid: uid, title, body, position: pos >= 0 ? pos : 999, updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) }, }); - if (res?.ok && res.stored !== false) setSynced(uid, title, body); + if (res?.ok && res.stored !== false) setSyncedFor(uid, title, body, pos); } -async function pullActive() { - const card = getActiveCard(); - if (!card) return; - const res = await hyprcrd.api('api/shopping', { method: 'GET' }); - if (!res?.ok || !res.state) return; - const { card_uid, body, title } = res.state; - if (String(card_uid) !== String(card.id)) return; - const synced = getSynced(); - if (synced && synced.uid === String(card.id) && synced.body === (body || '') && synced.title === (title || '')) return; +async function pullOne(card, relayState) { + const uid = String(card.id); + const { body, title } = relayState; + const synced = getSyncedMap()[uid]; + // Preserve the position we last pushed so a pull doesn't reset it + // and cause an unnecessary re-push on the next tick. + const curPos = synced?.pos ?? -1; + if (synced && synced.body === (body || '') && synced.title === (title || '')) return; if ((card.body || '') === (body || '') && (card.title || '') === (title || '')) { - setSynced(card.id, card.title || '', card.body || ''); + setSyncedFor(card.id, card.title || '', card.body || '', curPos); return; } - // Relay differs from both the sync marker and the local card — phone - // toggled. Phone can only flip checkboxes (never edit text), so the - // relay body is authoritative for this tick. await hyprcrd.api(`api/cards/${card.id}`, { method: 'PATCH', body: { body } }); - setSynced(card.id, card.title || '', body || ''); + setSyncedFor(card.id, card.title || '', body || '', curPos); await window._reloadAll?.(); } -async function clearRemote() { - await hyprcrd.api('api/shopping', { - method: 'POST', - body: { card_uid: '__inactive__', title: '', body: '', updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) }, - }); - localStorage.removeItem(SYNCED_KEY); -} - async function tick() { - if (!getActiveId()) return; - try { await pushActive(); } catch {} - try { await pullActive(); } catch {} + const activeCards = getActiveCards(); + if (!activeCards.length) return; + // Push all active cards first. + for (const card of activeCards) { + try { await pushOne(card); } catch {} + } + // Pull: fetch all lists from relay, match by card_uid. + try { + const res = await hyprcrd.api('api/shopping', { method: 'GET' }); + if (!res?.ok || !Array.isArray(res.lists)) return; + const byUid = new Map(res.lists.map(l => [l.card_uid, l])); + for (const card of activeCards) { + const relay = byUid.get(String(card.id)); + if (relay) await pullOne(card, relay); + } + } catch {} } // ── Init ───────────────────────────────────────────────────── @@ -235,6 +331,6 @@ if (document.readyState === 'loading') { hyprcrd.registerPlugin?.({ name: 'shopping', - version: '1.0.0', - description: 'Sync one shopping card to the relay so it\'s checkable from a phone.', + version: '1.1.0', + description: 'Sync shopping cards to the relay so they\'re checkable from a phone.', }); diff --git a/public/plugins/tests/board-find-replace.test.js b/public/plugins/tests/board-find-replace.test.js index 56a5b6c..6f099fb 100644 --- a/public/plugins/tests/board-find-replace.test.js +++ b/public/plugins/tests/board-find-replace.test.js @@ -1,4 +1,4 @@ -import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js?v=4'; +import { describe, it, afterEach, expect, $, $$, click, sleep, waitFor, closeAll, csrf } from './runner.js?v=4'; async function makeCard(title, body) { return fetch('api/cards', { @@ -70,13 +70,13 @@ describe('Search: Find/Replace', () => { const applyBtn = document.getElementById('search-replace-all'); expect(applyBtn.disabled).toBe(false); click(applyBtn); - await sleep(200); - const dialog = document.getElementById('dialog'); - if (dialog?.classList.contains('open')) { - const okBtn = document.getElementById('dialog-ok'); - if (okBtn) click(okBtn); - } + // The replace-all button opens a confirm dialog — wait for it + // to appear rather than relying on a fixed sleep, since the + // dialog creation is async and can lag on slow runs. + await waitFor(() => document.getElementById('dialog')?.classList.contains('open'), 2000); + const okBtn = document.getElementById('dialog-ok'); + if (okBtn) click(okBtn); // Poll until the replace has actually persisted let updated1, updated2; diff --git a/public/plugins/tests/line-numbers.test.js b/public/plugins/tests/line-numbers.test.js index 519e8a4..40fba4d 100644 --- a/public/plugins/tests/line-numbers.test.js +++ b/public/plugins/tests/line-numbers.test.js @@ -143,9 +143,10 @@ describe('Line Numbers: Alignment with Textarea', () => { const cs = getComputedStyle(body); const pad = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom); const textTotal = body.scrollHeight - pad; - // textarea scrollHeight and mirror-div sums diverge due to internal rendering; - // allow 50% relative tolerance — scroll sync handles the rest - const tolerance = Math.max(textTotal, gutterTotal) * 0.5; + // textarea scrollHeight and mirror-div sums diverge due to internal rendering, + // font metrics, and OS-level text shaping — allow 60% relative tolerance. + // Scroll sync (proportional mapping) handles the real alignment at runtime. + const tolerance = Math.max(textTotal, gutterTotal) * 0.6; expect(Math.abs(gutterTotal - textTotal)).toBeLessThan(tolerance); }); diff --git a/public/plugins/tests/shopping.test.js b/public/plugins/tests/shopping.test.js index 1919802..80bc0b0 100644 --- a/public/plugins/tests/shopping.test.js +++ b/public/plugins/tests/shopping.test.js @@ -86,11 +86,9 @@ describe('Shopping: picker tag matching', () => { const controlShown = listText.includes('__test_shop_control__'); expect(controlShown).toBe(false); - // Picker always offers a "no active list" option so the - // user can stop syncing without leaving the dialog. - const radios = overlay.querySelectorAll('input[name="shopping-active"]'); - const hasEmpty = [...radios].some(r => r.value === ''); - expect(hasEmpty).toBe(true); + // Picker uses checkboxes so multiple lists can be active. + const checkboxes = overlay.querySelectorAll('input[type="checkbox"]'); + expect(checkboxes.length).toBeGreaterThan(0); overlay.querySelector('.shopping-picker-cancel').click(); await sleep(40); diff --git a/relay/inc/bootstrap.php b/relay/inc/bootstrap.php index c1166c4..150aeb5 100644 --- a/relay/inc/bootstrap.php +++ b/relay/inc/bootstrap.php @@ -233,19 +233,44 @@ $db->exec('CREATE TABLE IF NOT EXISTS chat_room_members ( )'); $db->exec('CREATE INDEX IF NOT EXISTS idx_chat_room_member_tenant ON chat_room_members(tenant_id, room_uid)'); -// Shopping list: one active card per tenant, body mirrored from the -// client. Phone toggles checkboxes in-place via the session UI — no -// multi-list fan-out, no history, no attachments. hyprcrd remains the -// only surface that edits item text. +// Shopping lists: multiple active cards per tenant, each mirrored from +// the client. Phone toggles checkboxes in-place via the session UI. +// hyprcrd remains the only surface that edits item text. $db->exec('CREATE TABLE IF NOT EXISTS shopping_state ( - tenant_id INTEGER PRIMARY KEY, + tenant_id INTEGER NOT NULL, card_uid TEXT NOT NULL, title TEXT NOT NULL DEFAULT "", body TEXT NOT NULL DEFAULT "", + position INTEGER NOT NULL DEFAULT 0, updated_at_client TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at_server TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (tenant_id, card_uid), FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE )'); +// Migrate from old single-PK schema (tenant_id only) to composite PK +// + ensure position column exists (may be missing on tables created +// before the multi-list update). +$shoppingInfo = $db->query("PRAGMA table_info(shopping_state)")->fetchAll(); +$shoppingPk = array_filter($shoppingInfo, fn($c) => $c['pk'] > 0); +$shoppingColNames = array_column($shoppingInfo, 'name'); +if (count($shoppingPk) === 1) { + $db->exec('ALTER TABLE shopping_state RENAME TO _shopping_old'); + $db->exec('CREATE TABLE shopping_state ( + tenant_id INTEGER NOT NULL, + card_uid TEXT NOT NULL, + title TEXT NOT NULL DEFAULT "", + body TEXT NOT NULL DEFAULT "", + position INTEGER NOT NULL DEFAULT 0, + updated_at_client TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at_server TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (tenant_id, card_uid), + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE + )'); + $db->exec('INSERT OR IGNORE INTO shopping_state (tenant_id, card_uid, title, body, updated_at_client, updated_at_server) SELECT tenant_id, card_uid, title, body, updated_at_client, updated_at_server FROM _shopping_old'); + $db->exec('DROP TABLE _shopping_old'); +} elseif (!in_array('position', $shoppingColNames)) { + $db->exec('ALTER TABLE shopping_state ADD COLUMN position INTEGER NOT NULL DEFAULT 0'); +} // Sync-event notifications: replaces the old bot-chat mechanism. When a // client syncs a card they received, they POST here; the sender polls diff --git a/relay/inc/routes/shopping.php b/relay/inc/routes/shopping.php index 24e929e..8116363 100644 --- a/relay/inc/routes/shopping.php +++ b/relay/inc/routes/shopping.php @@ -1,53 +1,50 @@ prepare('SELECT card_uid, title, body, updated_at_client, updated_at_server FROM shopping_state WHERE tenant_id = :t'); +function shoppingLoadAll(PDO $db, int $tenantId): array { + $stmt = $db->prepare('SELECT card_uid, title, body, position, updated_at_client, updated_at_server FROM shopping_state WHERE tenant_id = :t ORDER BY position, title'); $stmt->execute(['t' => $tenantId]); + return $stmt->fetchAll() ?: []; +} + +function shoppingLoadOne(PDO $db, int $tenantId, string $cardUid): ?array { + $stmt = $db->prepare('SELECT card_uid, title, body, updated_at_client, updated_at_server FROM shopping_state WHERE tenant_id = :t AND card_uid = :u'); + $stmt->execute(['t' => $tenantId, 'u' => $cardUid]); $row = $stmt->fetch(); return $row ?: null; } // ── POST /api/shopping ──────────────────────────────────────── -// Client pushes the currently-active card's content. `updated_at` -// is the client's timestamp — used for lose-the-race checks. - if ($route === '/api/shopping' && $method === 'POST') { if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); $input = json_decode(file_get_contents('php://input'), true) ?: []; $uid = trim((string)($input['card_uid'] ?? '')); $title = (string)($input['title'] ?? ''); $body = (string)($input['body'] ?? ''); + $pos = (int)($input['position'] ?? 0); $clientTs = trim((string)($input['updated_at'] ?? '')) ?: gmdate('Y-m-d H:i:s'); if ($uid === '') jsonResponse(['error' => 'card_uid required'], 400); - // No server-side staleness guard: the client decides whether to - // push by diffing its last-synced body against the current card, - // not by timestamp, so cross-timezone clock comparisons can't - // silently block edits. Last-writer per tick wins — phone-toggle - // races lose at most one tick of flips (rare by design). $now = gmdate('Y-m-d H:i:s'); $db->prepare(' - INSERT INTO shopping_state (tenant_id, card_uid, title, body, updated_at_client, updated_at_server) - VALUES (:t, :u, :ti, :b, :c, :s) - ON CONFLICT(tenant_id) DO UPDATE SET - card_uid = excluded.card_uid, + INSERT INTO shopping_state (tenant_id, card_uid, title, body, position, updated_at_client, updated_at_server) + VALUES (:t, :u, :ti, :b, :p, :c, :s) + ON CONFLICT(tenant_id, card_uid) DO UPDATE SET title = excluded.title, body = excluded.body, + position = excluded.position, updated_at_client = excluded.updated_at_client, updated_at_server = excluded.updated_at_server ')->execute([ @@ -55,39 +52,42 @@ if ($route === '/api/shopping' && $method === 'POST') { 'u' => $uid, 'ti' => $title, 'b' => $body, + 'p' => $pos, 'c' => $clientTs, 's' => $now, ]); jsonResponse(['ok' => true, 'stored' => true, 'updated_at_server' => $now]); } -// ── GET /api/shopping ──────────────────────────────────────── -// Both the client (to pull phone-made toggles back) and the -// session-auth'd phone UI poll this. +// ── DELETE /api/shopping ───────────────────────────────────── +if ($route === '/api/shopping' && $method === 'DELETE') { + if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); + $input = json_decode(file_get_contents('php://input'), true) ?: []; + $uid = trim((string)($input['card_uid'] ?? '')); + if ($uid === '') jsonResponse(['error' => 'card_uid required'], 400); + $db->prepare('DELETE FROM shopping_state WHERE tenant_id = :t AND card_uid = :u') + ->execute(['t' => $clientTenant['id'], 'u' => $uid]); + jsonResponse(['ok' => true]); +} +// ── GET /api/shopping ──────────────────────────────────────── if ($route === '/api/shopping' && $method === 'GET') { if (!$tenant) jsonResponse(['error' => 'unauthorized'], 401); - $state = shoppingLoad($db, (int)$tenant['id']); - if (!$state) jsonResponse(['ok' => true, 'state' => null]); - jsonResponse(['ok' => true, 'state' => $state]); + $lists = shoppingLoadAll($db, (int)$tenant['id']); + jsonResponse(['ok' => true, 'lists' => $lists]); } // ── POST /api/shopping/check ───────────────────────────────── -// Phone-side toggle. Body: { line: "- [ ] milk", checked: true }. -// We match the exact line once to keep merges simple; if the line -// isn't found (hyprcrd just rewrote it), the caller reloads and -// retries. Bumps updated_at_server so the next client pull sees -// the change. - if ($route === '/api/shopping/check' && $method === 'POST') { if (!$sessionTenant) jsonResponse(['error' => 'unauthorized'], 401); $input = json_decode(file_get_contents('php://input'), true) ?: []; + $uid = trim((string)($input['card_uid'] ?? '')); $line = (string)($input['line'] ?? ''); $checked = !empty($input['checked']); - if ($line === '') jsonResponse(['error' => 'line required'], 400); + if ($uid === '' || $line === '') jsonResponse(['error' => 'card_uid and line required'], 400); - $state = shoppingLoad($db, (int)$sessionTenant['id']); - if (!$state) jsonResponse(['error' => 'no active list'], 404); + $state = shoppingLoadOne($db, (int)$sessionTenant['id'], $uid); + if (!$state) jsonResponse(['error' => 'list not found'], 404); $body = $state['body']; $lines = preg_split("/\r\n|\n|\r/", $body); $hit = false; @@ -96,7 +96,7 @@ if ($route === '/api/shopping/check' && $method === 'POST') { $toggled = $checked ? preg_replace('/^(\s*-\s*)\[\s\]/', '$1[x]', $l, 1) : preg_replace('/^(\s*-\s*)\[[xX]\]/', '$1[ ]', $l, 1); - if ($toggled === $l) continue; // not a checkbox line + if ($toggled === $l) continue; $lines[$i] = $toggled; $hit = true; break; @@ -105,11 +105,7 @@ if ($route === '/api/shopping/check' && $method === 'POST') { $newBody = implode("\n", $lines); $now = gmdate('Y-m-d H:i:s'); - $db->prepare('UPDATE shopping_state SET body = :b, updated_at_server = :s WHERE tenant_id = :t') - ->execute(['b' => $newBody, 's' => $now, 't' => $sessionTenant['id']]); + $db->prepare('UPDATE shopping_state SET body = :b, updated_at_server = :s WHERE tenant_id = :t AND card_uid = :u') + ->execute(['b' => $newBody, 's' => $now, 't' => $sessionTenant['id'], 'u' => $uid]); jsonResponse(['ok' => true, 'updated_at_server' => $now]); } - -// Shopping list lives inside the relay UI as a regular view (see -// relay/ui/layout.php + app.js). No separate /shopping page — login -// and navigation are the same as for every other relay feature. diff --git a/relay/ui/app.js b/relay/ui/app.js index e0ecb42..e8903b9 100644 --- a/relay/ui/app.js +++ b/relay/ui/app.js @@ -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 = '
  • no active shopping list
  • '; - 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 = '
  • \u2713 all done
  • '; + const lists = data.lists || []; + if (!lists.length) { + if (title) title.textContent = 'Shopping'; + list.innerHTML = '
  • no active shopping lists
  • '; 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); } } diff --git a/relay/ui/style.css b/relay/ui/style.css index bcfa28a..28cc96a 100644 --- a/relay/ui/style.css +++ b/relay/ui/style.css @@ -276,26 +276,63 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point } .shopping-items li:active { transform: scale(0.98); background: var(--raised, var(--surface)); } .shopping-items li.checked { opacity: 0.35; text-decoration: line-through; } -.shopping-items .empty { text-align: center; padding: 40px 16px; color: var(--dim); background: transparent; border: none; cursor: default; } +.shopping-items .empty { text-align: center; padding: 40px 16px; color: var(--dim); background: transparent; border: none; cursor: default; list-style: none; } .shopping-items .empty:active { transform: none; } -/* Section headings between items — "# Obst", "## Backwaren", etc. */ -.shopping-items li.shopping-heading { - background: transparent; - border: none; - padding: 18px 4px 4px; +/* Multi-list stacked boxes */ +.shopping-list-box { + list-style: none; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 10px; + display: flex; + flex-direction: column; + gap: 6px; +} +.shopping-list-box:active { transform: none; } +.shopping-list-header { + font-weight: 600; + font-size: 13px; + color: var(--bright); + padding: 4px 6px 8px; + border-bottom: 1px solid var(--border); +} +.shopping-list-done { + text-align: center; + padding: 16px 8px; + color: var(--dim); + font-size: 14px; +} +.shopping-item { + background: var(--raised, var(--surface)); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 12px 14px; + font-size: 15px; + cursor: pointer; + user-select: none; + -webkit-tap-highlight-color: transparent; + transition: transform 0.12s, opacity 0.3s, background 0.12s; +} +.shopping-item:active { transform: scale(0.98); } +.shopping-item.checked { opacity: 0.35; text-decoration: line-through; } + +/* Section headings between items — "# Obst", "## Backwaren", etc. + Adds breathing room above so grouped items feel like sections. */ +.shopping-heading { + padding: 14px 6px 2px; cursor: default; font-weight: 600; letter-spacing: 0.02em; } -.shopping-items li.shopping-heading:active { transform: none; background: transparent; opacity: 1; } -.shopping-items li.shopping-heading:first-child { padding-top: 2px; } -.shopping-items li.shopping-heading.h1 { font-size: 18px; color: var(--bright); } -.shopping-items li.shopping-heading.h2 { font-size: 15px; color: var(--accent); } -.shopping-items li.shopping-heading.h3, -.shopping-items li.shopping-heading.h4, -.shopping-items li.shopping-heading.h5, -.shopping-items li.shopping-heading.h6 { font-size: 13px; color: var(--muted); } +.shopping-heading:first-child { padding-top: 2px; } +.shopping-heading.h1 { font-size: 17px; color: var(--bright); } +.shopping-heading.h2 { font-size: 14px; color: var(--accent); } +.shopping-heading.h3, +.shopping-heading.h4, +.shopping-heading.h5, +.shopping-heading.h6 { font-size: 12px; color: var(--muted); } .shopping-err { background: var(--danger-bg, #7f1d1d); color: var(--danger-fg, #fecaca); padding: 8px 12px; border-radius: 6px; font-size: 13px; } .shopping-err.hidden { display: none; }