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:
1 parent
670f125627
commit
e086db0c2c
6 files changed
+143
-6
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '1.9.18');
|
define('APP_VERSION', '1.9.19');
|
||||||
|
|
||||||
// Database path — writable directory, outside webroot
|
// Database path — writable directory, outside webroot
|
||||||
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hyprcrd",
|
"name": "hyprcrd",
|
||||||
"version": "1.9.18",
|
"version": "1.9.19",
|
||||||
"description": "hyprcrd — self-hosted card manager",
|
"description": "hyprcrd — self-hosted card manager",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1148,6 +1148,40 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
exit;
|
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;
|
http_response_code(404); echo json_encode(['error' => 'not found']); exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1656,6 +1690,21 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
</table>
|
</table>
|
||||||
</section>
|
</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"><your-relay>/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://<relay>/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>
|
<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>
|
<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">
|
<table class="w-full text-xs">
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ const LAST_PUSHED_KEY = 'hyprcrd-shopping-last-pushed-ts';
|
|||||||
const LAST_SERVER_KEY = 'hyprcrd-shopping-last-server-ts';
|
const LAST_SERVER_KEY = 'hyprcrd-shopping-last-server-ts';
|
||||||
|
|
||||||
function hasEinkaufTag(card) {
|
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() {
|
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>
|
<button class="shopping-picker-close text-t-muted hover:text-t-danger"><i class="ph ph-x text-sm"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-5 py-3 text-[11px] text-t-muted leading-relaxed">
|
<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
|
Karten mit Tag <span class="text-t-accent">#einkauf</span> oder
|
||||||
fürs Telefon gewählt werden. Nur eine ist aktiv; ihr Body wird bei jedem Sync zum
|
<span class="text-t-accent">#shopping</span> können als aktive Liste fürs Telefon
|
||||||
Relay geschickt und unter <span class="text-t-accent">/shopping</span> angezeigt.
|
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>
|
||||||
<div class="flex-1 overflow-y-auto px-2 py-2" id="shopping-picker-list"></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">
|
<div class="flex items-center gap-2 px-5 py-3 border-t border-t-border">
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,6 +55,7 @@ const FILES = [
|
|||||||
'./terminal.test.js',
|
'./terminal.test.js',
|
||||||
'./theme.test.js',
|
'./theme.test.js',
|
||||||
'./chat.test.js',
|
'./chat.test.js',
|
||||||
|
'./shopping.test.js',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Await all imports so the runner can start in a deterministic state.
|
// Await all imports so the runner can start in a deterministic state.
|
||||||
|
|||||||
Reference in new issue
Block a user