shopping: #shopping tag, help section, tests, client-side proxy

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) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-16 00:35:52 +02:00
1 parent 670f125627
commit e086db0c2c
6 files changed
+143 -6

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
<?php
define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '1.9.18');
define('APP_VERSION', '1.9.19');
// Database path — writable directory, outside webroot
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hyprcrd",
"version": "1.9.18",
"version": "1.9.19",
"description": "hyprcrd — self-hosted card manager",
"main": "main.js",
"scripts": {
+49
View File
@@ -1148,6 +1148,40 @@ if (str_starts_with($route, '/api/')) {
exit;
}
// ── Shopping list (proxy to relay) ───────────────────────────
// Thin forwarder. The client (shopping.js) talks to us, we relay
// to /api/shopping with the configured X-Relay-User/-Key headers.
// Phone UI talks to the relay directly via session auth.
if (($route === '/api/shopping' || $route === '/api/shopping/check') && in_array($method, ['GET', 'POST'])) {
$relayUrl = $db->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/')) {
</table>
</section>
<section>
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-shopping-cart text-sm"></i>shopping list (phone sync)</h3>
<div class="text-xs leading-relaxed space-y-3 text-t-muted mb-3">
<p>Tag a card with <span class="text-t-accent">#einkauf</span> or <span class="text-t-accent">#shopping</span> to mark it as a shopping-list candidate. Open the header's <i class="ph ph-list-dashes text-xs"></i> menu → <span class="text-t-bright font-pixel">Einkaufsliste</span> to pick which one is the currently <span class="text-t-bright">active</span> list — only one at a time is mirrored to the phone.</p>
<p>The active card's body is pushed to the relay on every sync tick. On your phone, open <span class="text-t-accent">&lt;your-relay&gt;/shopping</span> and log in with your relay credentials. The page shows only the <span class="text-t-bright">unchecked</span> <code class="text-t-accent">- [ ]</code> items as tappable rows. Tapping an item flips it to <code class="text-t-accent">- [x]</code> on the relay in-place; the next client tick pulls that back and updates the local card.</p>
<p>Editing stays in hyprcrd — the phone can only check/uncheck, never change text. That keeps conflict handling trivial (last-writer wins by timestamp).</p>
</div>
<table class="w-full text-xs">
<tr><td class="py-1 text-t-dim w-40 font-pixel"><i class="ph ph-hash text-xs"></i> trigger tags</td><td><span class="text-t-accent">#einkauf</span> or <span class="text-t-accent">#shopping</span> — either counts</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-list-dashes text-xs"></i> pick active</td><td>header menu → Einkaufsliste → radio select → Save</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-device-mobile text-xs"></i> phone URL</td><td><code>https://&lt;relay&gt;/shopping</code> — session-auth, mobile layout</td></tr>
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-arrows-clockwise text-xs"></i> sync cadence</td><td>piggybacks on the shared sync tick (see Settings → Relay)</td></tr>
</table>
</section>
<section>
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-download-simple text-sm"></i>import / export</h3>
<table class="w-full text-xs">
+6 -4
View File
@@ -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() {
<button class="shopping-picker-close text-t-muted hover:text-t-danger"><i class="ph ph-x text-sm"></i></button>
</div>
<div class="px-5 py-3 text-[11px] text-t-muted leading-relaxed">
Karten mit dem Tag <span class="text-t-accent">#einkauf</span> 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 <span class="text-t-accent">/shopping</span> angezeigt.
Karten mit Tag <span class="text-t-accent">#einkauf</span> oder
<span class="text-t-accent">#shopping</span> 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 <span class="text-t-accent">/shopping</span> angezeigt.
</div>
<div class="flex-1 overflow-y-auto px-2 py-2" id="shopping-picker-list"></div>
<div class="flex items-center gap-2 px-5 py-3 border-t border-t-border">
+85
View File
@@ -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();
}
});
});
+1
View File
@@ -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.