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.