From e086db0c2cfb95f82e7a7d5571867b8179deb261 Mon Sep 17 00:00:00 2001 From: th Date: Thu, 16 Apr 2026 00:35:52 +0200 Subject: [PATCH] shopping: #shopping tag, help section, tests, client-side proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-ups: - Accept #shopping as an alias for #einkauf. Either tag now makes a card show up in the picker; the help blurb in the dialog says so explicitly. - Add a proper help-overlay section covering the trigger tags, how to pick an active list, the phone URL, sync cadence, and why phone is check-only. - Add a client-side proxy in public/index.php for /api/shopping and /api/shopping/check — shopping.js was calling api('api/...') which went to hyprcrd, but only the relay had handlers. Without this forwarder, push/pull just 404'd. Proxy mirrors the pattern used by /api/relay/push. - New shopping.test.js: asserts the proxy returns a JSON shape with `ok` (works whether or not a relay is configured), the hamburger menu toggles as expected, and the picker lists both #einkauf + #shopping cards while excluding untagged ones. Co-Authored-By: Claude Opus 4.6 (1M context) --- config.php | 2 +- electron/package.json | 2 +- public/index.php | 49 +++++++++++++++ public/js/modules/shopping.js | 10 ++-- public/plugins/tests/shopping.test.js | 85 +++++++++++++++++++++++++++ public/plugins/tests/tests.js | 1 + 6 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 public/plugins/tests/shopping.test.js diff --git a/config.php b/config.php index 7b3ed08..31871be 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn(); + $relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn(); + $relayUser = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn(); + if (!$relayUrl || !$relayKey || !$relayUser) { + echo json_encode(['ok' => false, 'error' => 'relay not configured']); exit; + } + $ch = curl_init(rtrim($relayUrl, '/') . $route); + $opts = [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'X-Relay-User: ' . $relayUser, + 'X-Relay-Key: ' . $relayKey, + ], + CURLOPT_TIMEOUT => 10, + ]; + if ($method === 'POST') { + $opts[CURLOPT_POST] = true; + $opts[CURLOPT_POSTFIELDS] = file_get_contents('php://input') ?: '{}'; + } + curl_setopt_array($ch, $opts); + $response = curl_exec($ch); + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + http_response_code($status ?: 502); + echo is_string($response) ? $response : json_encode(['ok' => false, 'error' => 'relay unreachable']); + exit; + } + http_response_code(404); echo json_encode(['error' => 'not found']); exit; } @@ -1656,6 +1690,21 @@ if (str_starts_with($route, '/api/')) { +
+

shopping list (phone sync)

+
+

Tag a card with #einkauf or #shopping to mark it as a shopping-list candidate. Open the header's menu → Einkaufsliste to pick which one is the currently active list — only one at a time is mirrored to the phone.

+

The active card's body is pushed to the relay on every sync tick. On your phone, open <your-relay>/shopping and log in with your relay credentials. The page shows 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).

+
+ + + + + +
trigger tags#einkauf or #shopping — either counts
pick activeheader menu → Einkaufsliste → radio select → Save
phone URLhttps://<relay>/shopping — session-auth, mobile layout
sync cadencepiggybacks on the shared sync tick (see Settings → Relay)
+
+

import / export

diff --git a/public/js/modules/shopping.js b/public/js/modules/shopping.js index 1709085..4eedffc 100644 --- a/public/js/modules/shopping.js +++ b/public/js/modules/shopping.js @@ -19,7 +19,8 @@ const LAST_PUSHED_KEY = 'hyprcrd-shopping-last-pushed-ts'; const LAST_SERVER_KEY = 'hyprcrd-shopping-last-server-ts'; function hasEinkaufTag(card) { - return (card.tags || '').split(',').map(s => s.trim()).includes('einkauf'); + const tags = (card.tags || '').split(',').map(s => s.trim()); + return tags.includes('einkauf') || tags.includes('shopping'); } function getActiveId() { @@ -59,9 +60,10 @@ function openDialog() {
- Karten mit dem Tag #einkauf können als aktive Liste - fürs Telefon gewählt werden. Nur eine ist aktiv; ihr Body wird bei jedem Sync zum - Relay geschickt und unter /shopping angezeigt. + Karten mit Tag #einkauf oder + #shopping können als aktive Liste fürs Telefon + gewählt werden. Nur eine ist aktiv; ihr Body wird bei jedem Sync zum Relay geschickt + und unter /shopping angezeigt.
diff --git a/public/plugins/tests/shopping.test.js b/public/plugins/tests/shopping.test.js new file mode 100644 index 0000000..23048ea --- /dev/null +++ b/public/plugins/tests/shopping.test.js @@ -0,0 +1,85 @@ +import { describe, it, expect, $, click, sleep, csrf } from './runner.js?v=4'; + +// Shopping-list tests. The proxy route is always present; without a +// configured relay it just returns {ok:false,error:"relay not +// configured"} rather than 404, so we can still assert shape. The UI +// tests create cards with either trigger tag and verify the picker +// finds them + skips non-tagged cards. + +describe('Shopping: API proxy', () => { + it('GET /api/shopping returns an ok field', async () => { + const r = await fetch('api/shopping').then(x => x.json()); + expect(typeof r.ok).toBe('boolean'); + }); + + it('POST /api/shopping/check returns an ok field', async () => { + const r = await fetch('api/shopping/check', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() }, + body: JSON.stringify({ line: '- [ ] nope', checked: true }), + }).then(x => x.json()); + expect(typeof r.ok).toBe('boolean'); + }); +}); + +describe('Shopping: header menu', () => { + it('hamburger button + menu exist; clicking toggles visibility', async () => { + const btn = $('#header-menu-btn'); + const menu = $('#header-menu'); + expect(btn).toExist(); + expect(menu).toExist(); + // Make sure we start closed. + menu.classList.add('hidden'); + click(btn); + await sleep(40); + expect(menu.classList.contains('hidden')).toBe(false); + expect($('#menu-shopping')).toExist(); + // Outside-click closer — dispatch a click elsewhere. + document.body.click(); + await sleep(40); + expect(menu.classList.contains('hidden')).toBe(true); + }); +}); + +describe('Shopping: picker tag matching', () => { + // Creates one card per trigger tag + one untagged control, then + // opens the picker and asserts the list. Both #einkauf and + // #shopping must qualify; untagged card must not. + it('lists both #einkauf and #shopping cards, excludes others', async () => { + const token = csrf(); + const h = { 'Content-Type': 'application/json', 'X-CSRF-Token': token }; + const hDel = { 'X-CSRF-Token': token }; + let eink, shop, other; + try { + eink = await fetch('api/cards', { method: 'POST', headers: h, body: JSON.stringify({ title: '__test_shop_einkauf__', body: '- [ ] milk #einkauf' }) }).then(r => r.json()); + shop = await fetch('api/cards', { method: 'POST', headers: h, body: JSON.stringify({ title: '__test_shop_shopping__', body: '- [ ] bread #shopping' }) }).then(r => r.json()); + other = await fetch('api/cards', { method: 'POST', headers: h, body: JSON.stringify({ title: '__test_shop_control__', body: 'plain card' }) }).then(r => r.json()); + await window._reloadAll(); + + // Close any leftover overlay, then open the picker fresh. + document.querySelector('.shopping-picker-overlay')?.remove(); + $('#header-menu')?.classList.add('hidden'); + click('#header-menu-btn'); + await sleep(40); + click('#menu-shopping'); + await sleep(80); + + const overlay = document.querySelector('.shopping-picker-overlay'); + expect(overlay).toExist(); + const listText = overlay.querySelector('#shopping-picker-list').textContent; + expect(listText).toContain('__test_shop_einkauf__'); + expect(listText).toContain('__test_shop_shopping__'); + // Non-tagged control must be absent. + const controlShown = listText.includes('__test_shop_control__'); + expect(controlShown).toBe(false); + + overlay.querySelector('.shopping-picker-cancel').click(); + await sleep(40); + } finally { + for (const c of [eink, shop, other]) { + if (c?.id) await fetch(`api/cards/${c.id}`, { method: 'DELETE', headers: hDel }); + } + await window._reloadAll(); + } + }); +}); diff --git a/public/plugins/tests/tests.js b/public/plugins/tests/tests.js index c7afd95..e92f50b 100644 --- a/public/plugins/tests/tests.js +++ b/public/plugins/tests/tests.js @@ -55,6 +55,7 @@ const FILES = [ './terminal.test.js', './theme.test.js', './chat.test.js', + './shopping.test.js', ]; // Await all imports so the runner can start in a deterministic state.