diff --git a/public/js/app.js b/public/js/app.js
index 094620b..15ed114 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -21,6 +21,7 @@ import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar
import { loadSettings as loadCaldavSettings } from './modules/caldav.js';
import { startBackgroundSync, triggerSync, loadRelaySettings } from './modules/sync.js';
import { openPluginSettings } from './modules/plugin-settings.js';
+import { initShopping } from './modules/shopping.js';
// ── Global API (for inline onclick handlers and cross-module calls) ──
@@ -217,6 +218,7 @@ async function checkAuth() {
loadBuckets();
loadCards();
startBackgroundSync();
+ initShopping();
}
// ── Keyboard shortcuts (central registry) ──
diff --git a/public/js/modules/shopping.js b/public/js/modules/shopping.js
new file mode 100644
index 0000000..1709085
--- /dev/null
+++ b/public/js/modules/shopping.js
@@ -0,0 +1,225 @@
+// ── Shopping list (phone-synced card) ──
+//
+// One card flagged as #einkauf can be "active" at a time. Its body
+// is pushed to the relay on every sync.tick; the relay serves a
+// phone-friendly checklist at /shopping. Phone taps toggle items in
+// place on the relay; hyprcrd pulls those toggles back on the next
+// tick and patches the local card.
+//
+// Phone never changes item text — only check state. Text edits stay
+// in hyprcrd, which keeps conflict resolution trivial (last-writer
+// wins by timestamp; near-simultaneous edits are extremely unlikely
+// for a shopping list).
+
+import { $, api, esc, getCsrf } from './util.js';
+import { state } from './state.js';
+
+const ACTIVE_KEY = 'hyprcrd-shopping-active-id';
+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');
+}
+
+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));
+ // Reset timestamp cursors so the switch pushes fresh on next tick.
+ localStorage.removeItem(LAST_PUSHED_KEY);
+ localStorage.removeItem(LAST_SERVER_KEY);
+}
+
+function getActiveCard() {
+ const id = getActiveId();
+ if (!id) return null;
+ return state.cards.find(c => String(c.id) === id) || null;
+}
+
+// ── 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 = `
+
+
+
Einkaufsliste
+
+
+
+ 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.
+
+ Keine Karten mit dem Tag #einkauf.
+ Lege eine Karte mit Checkbox-Items an und gib ihr den Tag, dann erscheint sie hier.
+
`;
+ } 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 ``;
+ }).join('');
+ // "No active" option — explicit way to stop syncing.
+ list.insertAdjacentHTML('beforeend', ``);
+ 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 localTs = card.updated_at || new Date().toISOString().replace('T', ' ').slice(0, 19);
+ const lastPushed = localStorage.getItem(LAST_PUSHED_KEY);
+ if (lastPushed && lastPushed >= localTs) return; // nothing newer
+ const res = await api('api/shopping', {
+ method: 'POST',
+ body: { card_uid: String(card.id), title: card.title || '', body: card.body || '', updated_at: localTs },
+ });
+ if (res?.ok) {
+ localStorage.setItem(LAST_PUSHED_KEY, localTs);
+ if (res.updated_at_server) localStorage.setItem(LAST_SERVER_KEY, res.updated_at_server);
+ }
+}
+
+async function pullActive() {
+ const card = getActiveCard();
+ if (!card) return;
+ const res = await api('api/shopping', { method: 'GET' });
+ if (!res?.ok || !res.state) return;
+ const { card_uid, body, updated_at_server } = res.state;
+ if (String(card_uid) !== String(card.id)) return; // relay still has a previous list
+ const seen = localStorage.getItem(LAST_SERVER_KEY);
+ if (seen && seen >= updated_at_server) return; // no news
+ if ((body || '') === (card.body || '')) {
+ localStorage.setItem(LAST_SERVER_KEY, updated_at_server);
+ return;
+ }
+ // Phone toggled something — patch the local card.
+ await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body } });
+ localStorage.setItem(LAST_SERVER_KEY, updated_at_server);
+ localStorage.setItem(LAST_PUSHED_KEY, updated_at_server); // suppress immediate re-push
+ await window._reloadAll?.();
+}
+
+async function clearRemote() {
+ // No DELETE endpoint — send an empty list that "displaces" the
+ // previous active card on the relay so the phone sees nothing.
+ await api('api/shopping', {
+ method: 'POST',
+ body: { card_uid: '__inactive__', title: '', body: '', updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) },
+ });
+ localStorage.removeItem(LAST_PUSHED_KEY);
+ localStorage.removeItem(LAST_SERVER_KEY);
+}
+
+async function tick() {
+ if (!getActiveId()) return;
+ // Pull first so phone toggles land before we overwrite with a push.
+ try { await pullActive(); } catch {}
+ try { await pushActive(); } catch {}
+}
+
+// ── Hamburger menu wiring ─────────────────────────────────────
+
+function initMenu() {
+ const btn = $('header-menu-btn');
+ const menu = $('header-menu');
+ if (!btn || !menu) return;
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ menu.classList.toggle('hidden');
+ });
+ document.addEventListener('click', (e) => {
+ if (!menu.contains(e.target) && e.target !== btn) menu.classList.add('hidden');
+ });
+ $('menu-shopping')?.addEventListener('click', () => {
+ menu.classList.add('hidden');
+ openDialog();
+ });
+}
+
+export function initShopping() {
+ initMenu();
+ // Hook into the shared sync heartbeat so push/pull happens on the
+ // same cadence as every other poller — one timer rules them all.
+ window.hyprcrd?.hooks?.on?.('sync.tick', () => { tick().catch(() => {}); });
+ // Also push right after a save of the active card so the phone
+ // sees edits without waiting for the next tick.
+ window.addEventListener('hyprcrd:editor-close', (e) => {
+ const active = getActiveId();
+ if (active && String(e?.detail?.cardId) === active) {
+ // Small delay so state.cards has the post-save body.
+ setTimeout(() => tick().catch(() => {}), 300);
+ }
+ });
+}
diff --git a/relay/inc/bootstrap.php b/relay/inc/bootstrap.php
index 1f63f58..c1166c4 100644
--- a/relay/inc/bootstrap.php
+++ b/relay/inc/bootstrap.php
@@ -233,6 +233,20 @@ $db->exec('CREATE TABLE IF NOT EXISTS chat_room_members (
)');
$db->exec('CREATE INDEX IF NOT EXISTS idx_chat_room_member_tenant ON chat_room_members(tenant_id, room_uid)');
+// Shopping list: one active card per tenant, body mirrored from the
+// client. Phone toggles checkboxes in-place via the session UI — no
+// multi-list fan-out, no history, no attachments. hyprcrd remains the
+// only surface that edits item text.
+$db->exec('CREATE TABLE IF NOT EXISTS shopping_state (
+ tenant_id INTEGER PRIMARY KEY,
+ card_uid TEXT NOT NULL,
+ title TEXT NOT NULL DEFAULT "",
+ body TEXT NOT NULL DEFAULT "",
+ updated_at_client TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at_server TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
+)');
+
// Sync-event notifications: replaces the old bot-chat mechanism. When a
// client syncs a card they received, they POST here; the sender polls
// GET /api/sync-events and surfaces each as a transient toast. Rows are
diff --git a/relay/inc/routes/shopping.php b/relay/inc/routes/shopping.php
new file mode 100644
index 0000000..ec27062
--- /dev/null
+++ b/relay/inc/routes/shopping.php
@@ -0,0 +1,267 @@
+prepare('SELECT card_uid, title, body, updated_at_client, updated_at_server FROM shopping_state WHERE tenant_id = :t');
+ $stmt->execute(['t' => $tenantId]);
+ $row = $stmt->fetch();
+ return $row ?: null;
+}
+
+// ── POST /api/shopping ────────────────────────────────────────
+// Client pushes the currently-active card's content. `updated_at`
+// is the client's timestamp — used for lose-the-race checks.
+
+if ($route === '/api/shopping' && $method === 'POST') {
+ if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
+ $input = json_decode(file_get_contents('php://input'), true) ?: [];
+ $uid = trim((string)($input['card_uid'] ?? ''));
+ $title = (string)($input['title'] ?? '');
+ $body = (string)($input['body'] ?? '');
+ $clientTs = trim((string)($input['updated_at'] ?? '')) ?: date('Y-m-d H:i:s');
+ if ($uid === '') jsonResponse(['error' => 'card_uid required'], 400);
+
+ $existing = shoppingLoad($db, (int)$clientTenant['id']);
+ // Skip the write when the client is pushing an older version of
+ // the same card than what's already stored (phone toggled
+ // meanwhile). Still return 200 so the client doesn't retry in a
+ // loop — they just haven't pulled yet.
+ if ($existing && $existing['card_uid'] === $uid
+ && strcmp($existing['updated_at_server'], $clientTs) > 0) {
+ jsonResponse([
+ 'ok' => true,
+ 'stored' => false,
+ 'reason' => 'server newer — pull first',
+ 'updated_at_server' => $existing['updated_at_server'],
+ ]);
+ }
+ $now = date('Y-m-d H:i:s');
+ $db->prepare('
+ INSERT INTO shopping_state (tenant_id, card_uid, title, body, updated_at_client, updated_at_server)
+ VALUES (:t, :u, :ti, :b, :c, :s)
+ ON CONFLICT(tenant_id) DO UPDATE SET
+ card_uid = excluded.card_uid,
+ title = excluded.title,
+ body = excluded.body,
+ updated_at_client = excluded.updated_at_client,
+ updated_at_server = excluded.updated_at_server
+ ')->execute([
+ 't' => $clientTenant['id'],
+ 'u' => $uid,
+ 'ti' => $title,
+ 'b' => $body,
+ 'c' => $clientTs,
+ 's' => $now,
+ ]);
+ jsonResponse(['ok' => true, 'stored' => true, 'updated_at_server' => $now]);
+}
+
+// ── GET /api/shopping ────────────────────────────────────────
+// Both the client (to pull phone-made toggles back) and the
+// session-auth'd phone UI poll this.
+
+if ($route === '/api/shopping' && $method === 'GET') {
+ if (!$tenant) jsonResponse(['error' => 'unauthorized'], 401);
+ $state = shoppingLoad($db, (int)$tenant['id']);
+ if (!$state) jsonResponse(['ok' => true, 'state' => null]);
+ jsonResponse(['ok' => true, 'state' => $state]);
+}
+
+// ── POST /api/shopping/check ─────────────────────────────────
+// Phone-side toggle. Body: { line: "- [ ] milk", checked: true }.
+// We match the exact line once to keep merges simple; if the line
+// isn't found (hyprcrd just rewrote it), the caller reloads and
+// retries. Bumps updated_at_server so the next client pull sees
+// the change.
+
+if ($route === '/api/shopping/check' && $method === 'POST') {
+ if (!$sessionTenant) jsonResponse(['error' => 'unauthorized'], 401);
+ $input = json_decode(file_get_contents('php://input'), true) ?: [];
+ $line = (string)($input['line'] ?? '');
+ $checked = !empty($input['checked']);
+ if ($line === '') jsonResponse(['error' => 'line required'], 400);
+
+ $state = shoppingLoad($db, (int)$sessionTenant['id']);
+ if (!$state) jsonResponse(['error' => 'no active list'], 404);
+ $body = $state['body'];
+ $lines = preg_split("/\r\n|\n|\r/", $body);
+ $hit = false;
+ foreach ($lines as $i => $l) {
+ if ($l !== $line) continue;
+ $toggled = $checked
+ ? preg_replace('/^(\s*-\s*)\[\s\]/', '$1[x]', $l, 1)
+ : preg_replace('/^(\s*-\s*)\[[xX]\]/', '$1[ ]', $l, 1);
+ if ($toggled === $l) continue; // not a checkbox line
+ $lines[$i] = $toggled;
+ $hit = true;
+ break;
+ }
+ if (!$hit) jsonResponse(['error' => 'line not found (list may have been edited — reload)'], 409);
+
+ $newBody = implode("\n", $lines);
+ $now = date('Y-m-d H:i:s');
+ $db->prepare('UPDATE shopping_state SET body = :b, updated_at_server = :s WHERE tenant_id = :t')
+ ->execute(['b' => $newBody, 's' => $now, 't' => $sessionTenant['id']]);
+ jsonResponse(['ok' => true, 'updated_at_server' => $now]);
+}
+
+// ── GET /shopping ────────────────────────────────────────────
+// Phone UI. Simple static shell; the JS fetches /api/shopping and
+// renders only unchecked items. Tapping one POSTs to /api/shopping/check.
+
+if ($route === '/shopping' && $method === 'GET') {
+ if (!$sessionTenant) {
+ // Bounce through the relay's session-auth flow.
+ header('Location: ' . dirname($_SERVER['SCRIPT_NAME']) . '/');
+ exit;
+ }
+ header('Content-Type: text/html; charset=utf-8');
+ $userLabel = htmlspecialchars($sessionTenant['username'], ENT_QUOTES);
+ ?>
+
+
+
+
+
+
+Einkaufsliste
+
+
+
+
+
Einkaufsliste
+ = $userLabel ?>
+
+
+
+
…
+
+
+
+
+
+
+
+ 'not found'], 404);
}
// ── UI ─────────────────────────────────────────────────────────────
+// Shopping UI lives outside /api/ so phones can bookmark a clean URL.
+// File has both /api/shopping* and /shopping handlers; they short-
+// circuit on match, so loading it here for the non-API path is safe.
+require __DIR__ . '/inc/routes/shopping.php';
require __DIR__ . '/ui/layout.php';