shopping: ship as plugin, integrate phone view into relay UI, fix sync

Restructure the shopping-list feature end to end:

  - New public/plugins/shopping/ plugin with backend.php (proxy
    routes registered via PluginManager::onApiRoute), shopping.js
    (self-contained; injects the header Shopping button, owns the
    picker dialog + sync loop via hyprcrd.hooks), plugin.json and a
    placeholder css file. Core no longer knows about shopping —
    index.php loses the static button and the proxy block, app.js
    loses the initShopping wiring, the old js/modules/shopping.js
    is gone.
  - Phone view moves out of a standalone /shopping page and into
    the relay UI as a regular `view-shopping` behind a nav-burger
    entry (relay/ui/layout.php + app.js + style.css). Same session
    auth as compose/chat/account — no separate login. Auto-refresh
    every 4 s while the view is active; no manual refresh button.
    Headings "# h1 … ###### h6" render between items for grouping.
    Orphan section headings (no open items under them) are hidden.
  - Sync: drop timestamp cursors (broke across SQLite-UTC / PHP
    timezones and silently blocked pushes after a pull). Both sides
    diff the (uid, title, body) triple instead — timezone-agnostic
    and authoritative. Client ticks push-before-pull so fresh local
    edits land on the relay before a stale pull can overwrite them.
    Relay drops its "server newer" guard and stamps server times
    with gmdate so anything left comparing to SQLite is UTC.
  - Client also ticks on cards.reloaded, not just sync.tick, so
    any edit path (editor save, checkbox toggle, context menu,
    undo) pushes the update immediately.
  - Tests: updated for the plugin (still uses the same selectors
    because the plugin injects the same ids), plus a plugin-
    registration check and "(no active list)" option assertion.
  - Help section copy updated — references the Shopping header
    button and the shopping tab in the relay UI rather than the
    gone /shopping URL.

Both #einkauf and #shopping work as trigger tags; phone is
check-only, editing stays in hyprcrd. Version bumped to 1.9.21.

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 01:24:58 +02:00
1 parent b9d610b612
commit d052fb5f9d
14 files changed
+417 -325

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
<?php
/**
* Shopping Plugin: backend.
*
* Thin forwarder from the client to the relay's /api/shopping*
* endpoints. The phone-side view is part of the relay UI; this
* plugin just pipes hyprcrd's push/pull through the configured
* relay credentials.
*
* Routes:
* GET /api/shopping — pull active list state from relay
* POST /api/shopping — push active card {uid,title,body}
* POST /api/shopping/check — (mostly phone-only; proxied for
* completeness/tests — relay will
* 401 for client-auth callers)
*/
function shoppingJson($data, int $code = 200): void {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
function shoppingRelay(): array {
$db = Database::connect();
return [
'url' => $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: '',
'user' => $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn() ?: '',
'key' => $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn() ?: '',
];
}
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
if ($route !== '/api/shopping' && $route !== '/api/shopping/check') return;
if (!in_array($method, ['GET', 'POST'], true)) return;
$r = shoppingRelay();
if (!$r['url'] || !$r['user'] || !$r['key']) {
shoppingJson(['ok' => false, 'error' => 'relay not configured']);
}
$ch = curl_init(rtrim($r['url'], '/') . $route);
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Relay-User: ' . $r['user'],
'X-Relay-Key: ' . $r['key'],
],
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);
header('Content-Type: application/json');
echo is_string($response) ? $response : json_encode(['ok' => false, 'error' => 'relay unreachable']);
exit;
});
+10
View File
@@ -0,0 +1,10 @@
{
"name": "shopping",
"version": "1.0.0",
"description": "Ship one shopping card at a time to the relay so it's checkable from your phone. hyprcrd stays the edit surface; phone only toggles.",
"frontend": {
"js": ["shopping.js"],
"css": ["shopping.css"]
},
"backend": "backend.php"
}
+6
View File
@@ -0,0 +1,6 @@
/* Shopping plugin styles. Most of the picker dialog leans on
existing Tailwind tokens (text-t-accent, border-t-border, etc.)
so there's little needed here — only things that can't be
expressed inline on dynamically-created nodes. */
.shopping-picker-overlay { font-family: var(--t-font-mono, inherit); }
+240
View File
@@ -0,0 +1,240 @@
// ── Shopping-list plugin (hyprcrd side) ──
//
// Tag a card with #einkauf or #shopping to make it eligible. Pick
// one as "active" via the Shopping header button; the active card's
// body is pushed to the relay on every sync.tick and on every
// cards.reloaded so edits land on the phone within seconds. Pulls
// phone-side toggles back and PATCHes the local card.
//
// No timestamp cursors — we diff the (uid, title, body) triple we
// last saw in sync with the relay, which is timezone-agnostic.
const hyprcrd = window.hyprcrd;
if (!hyprcrd) throw new Error('[shopping plugin] window.hyprcrd missing');
const $ = (id) => document.getElementById(id);
const ACTIVE_KEY = 'hyprcrd-shopping-active-id';
const SYNCED_KEY = 'hyprcrd-shopping-synced';
function esc(s) {
return String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function hasEinkaufTag(card) {
const tags = (card.tags || '').split(',').map(s => s.trim());
return tags.includes('einkauf') || tags.includes('shopping');
}
function getActiveId() {
const v = localStorage.getItem(ACTIVE_KEY);
return v ? String(v) : null;
}
function setActiveId(id) {
if (id === null || id === '') localStorage.removeItem(ACTIVE_KEY);
else localStorage.setItem(ACTIVE_KEY, String(id));
localStorage.removeItem(SYNCED_KEY);
}
function getActiveCard() {
const id = getActiveId();
if (!id) return null;
const cards = window.state?.cards || [];
return cards.find(c => String(c.id) === id) || null;
}
function getSynced() {
try { return JSON.parse(localStorage.getItem(SYNCED_KEY) || 'null'); } catch { return null; }
}
function setSynced(uid, title, body) {
localStorage.setItem(SYNCED_KEY, JSON.stringify({ uid: String(uid), title: title || '', body: body || '' }));
}
// ── Header button injection ──────────────────────────────────
function injectHeaderButton() {
if ($('shopping-btn')) return;
// Put it next to Calendar. The header's right-hand button row
// lives in `header > div:last-child > .flex.items-center.gap-3`.
const header = document.querySelector('header#main-header');
if (!header) return;
const rightRow = header.querySelectorAll('.flex.items-center.gap-3');
const group = rightRow[rightRow.length - 1];
if (!group) return;
const btn = document.createElement('button');
btn.id = 'shopping-btn';
btn.className = 'flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors';
btn.innerHTML = '<i class="ph ph-shopping-cart text-sm"></i><span class="font-pixel text-[10px]">Shopping</span>';
btn.addEventListener('click', openDialog);
// Insert as the first item of the right-hand group so the order
// reads Shopping, Calendar, Assets, Tasks, New.
group.insertBefore(btn, group.firstChild);
}
// ── Picker dialog ────────────────────────────────────────────
function openDialog() {
document.querySelector('.shopping-picker-overlay')?.remove();
const overlay = document.createElement('div');
overlay.className = 'shopping-picker-overlay';
overlay.style.cssText = 'position:fixed;inset:0;background:var(--t-overlay,rgba(0,0,0,0.6));z-index:200;display:flex;align-items:center;justify-content:center;padding:24px;';
const panel = document.createElement('div');
panel.style.cssText = 'background:var(--t-surface);border:1px solid var(--t-border);box-shadow:0 8px 32px rgba(0,0,0,0.6);width:100%;max-width:520px;max-height:80vh;display:flex;flex-direction:column;';
panel.innerHTML = `
<div class="flex items-center gap-3 px-5 py-3 border-b border-t-border">
<i class="ph ph-shopping-cart text-t-accent text-base"></i>
<h2 class="font-pixel text-sm text-t-accent flex-1">Shopping list</h2>
<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">
Cards tagged with <span class="text-t-accent">#einkauf</span> or
<span class="text-t-accent">#shopping</span> can be set as the active list for the
phone. Only one is active at a time; its body is pushed to the relay on every sync
and appears under the <span class="text-t-accent">shopping</span> tab in the relay UI.
</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">
<span id="shopping-picker-status" class="font-pixel text-[10px] text-t-dim flex-1"></span>
<button class="shopping-picker-cancel px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger"><span class="font-pixel text-[10px]">Close</span></button>
<button class="shopping-picker-save px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10"><span class="font-pixel text-[10px]">Save</span></button>
</div>
`;
overlay.appendChild(panel);
document.body.appendChild(overlay);
const list = panel.querySelector('#shopping-picker-list');
const status = panel.querySelector('#shopping-picker-status');
const close = () => overlay.remove();
panel.querySelector('.shopping-picker-close').addEventListener('click', close);
panel.querySelector('.shopping-picker-cancel').addEventListener('click', close);
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
document.addEventListener('keydown', function escClose(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', escClose); }
});
const cards = window.state?.cards || [];
const candidates = cards.filter(hasEinkaufTag);
const currentActive = getActiveId();
let selectedId = currentActive;
if (!candidates.length) {
list.innerHTML = `<div class="px-4 py-6 text-center text-[11px] text-t-dim">
No cards tagged <span class="text-t-accent">#einkauf</span> or
<span class="text-t-accent">#shopping</span> yet. Create a card with checkbox items
and add one of those tags — it'll show up here.
</div>`;
} else {
list.innerHTML = candidates.map(c => {
const id = String(c.id);
const checked = id === selectedId ? 'checked' : '';
const items = (c.body || '').split('\n').filter(l => /^\s*-\s*\[[ xX]\]/.test(l));
const open = items.filter(l => /^\s*-\s*\[\s\]/.test(l)).length;
return `<label class="flex items-start gap-3 px-3 py-2 hover:bg-t-raised cursor-pointer">
<input type="radio" name="shopping-active" value="${id}" ${checked} style="margin-top:3px;">
<div class="flex-1 min-w-0">
<div class="text-xs text-t-bright font-pixel">${esc(c.title || '(untitled)')}</div>
<div class="text-[10px] text-t-dim font-pixel mt-0.5">${open} open · ${items.length} total</div>
</div>
</label>`;
}).join('');
list.insertAdjacentHTML('beforeend', `<label class="flex items-center gap-3 px-3 py-2 border-t border-t-border hover:bg-t-raised cursor-pointer mt-2">
<input type="radio" name="shopping-active" value="" ${currentActive ? '' : 'checked'} style="margin-top:0;">
<span class="text-[11px] text-t-muted italic">(no active list)</span>
</label>`);
list.querySelectorAll('input[name="shopping-active"]').forEach(r => {
r.addEventListener('change', () => { selectedId = r.value || null; });
});
}
panel.querySelector('.shopping-picker-save').addEventListener('click', async () => {
const prev = getActiveId();
setActiveId(selectedId);
status.textContent = 'saving…';
try {
if (selectedId) await pushActive();
else await clearRemote();
status.textContent = 'saved';
setTimeout(close, 400);
} catch (e) {
status.textContent = 'error: ' + (e?.message || e);
status.style.color = 'var(--t-danger)';
setActiveId(prev);
}
});
}
// ── Sync ─────────────────────────────────────────────────────
async function pushActive() {
const card = getActiveCard();
if (!card) return;
const uid = String(card.id);
const title = card.title || '';
const body = card.body || '';
const synced = getSynced();
if (synced && synced.uid === uid && synced.title === title && synced.body === body) return;
const res = await hyprcrd.api('api/shopping', {
method: 'POST',
body: { card_uid: uid, title, body, updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) },
});
if (res?.ok && res.stored !== false) setSynced(uid, title, body);
}
async function pullActive() {
const card = getActiveCard();
if (!card) return;
const res = await hyprcrd.api('api/shopping', { method: 'GET' });
if (!res?.ok || !res.state) return;
const { card_uid, body, title } = res.state;
if (String(card_uid) !== String(card.id)) return;
const synced = getSynced();
if (synced && synced.uid === String(card.id) && synced.body === (body || '') && synced.title === (title || '')) return;
if ((card.body || '') === (body || '') && (card.title || '') === (title || '')) {
setSynced(card.id, card.title || '', card.body || '');
return;
}
// Relay differs from both the sync marker and the local card — phone
// toggled. Phone can only flip checkboxes (never edit text), so the
// relay body is authoritative for this tick.
await hyprcrd.api(`api/cards/${card.id}`, { method: 'PATCH', body: { body } });
setSynced(card.id, card.title || '', body || '');
await window._reloadAll?.();
}
async function clearRemote() {
await hyprcrd.api('api/shopping', {
method: 'POST',
body: { card_uid: '__inactive__', title: '', body: '', updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) },
});
localStorage.removeItem(SYNCED_KEY);
}
async function tick() {
if (!getActiveId()) return;
try { await pushActive(); } catch {}
try { await pullActive(); } catch {}
}
// ── Init ─────────────────────────────────────────────────────
function init() {
injectHeaderButton();
hyprcrd.hooks?.on?.('sync.tick', () => { tick().catch(() => {}); });
hyprcrd.hooks?.on?.('cards.reloaded', () => { tick().catch(() => {}); });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
hyprcrd.registerPlugin?.({
name: 'shopping',
version: '1.0.0',
description: 'Sync one shopping card to the relay so it\'s checkable from a phone.',
});