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 active
header menu → Einkaufsliste → radio select → Save
+
phone URL
https://<relay>/shopping — session-auth, mobile layout
+
sync cadence
piggybacks on the shared sync tick (see Settings → Relay)
- 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.