shopping list: phone-synced card via relay

Pick one #einkauf-tagged card as "active"; its body is pushed to the
relay on every sync.tick and rendered on a phone-friendly session-
auth'd page at /shopping. Phone shows only the open "- [ ]" items as
tappable rows — tapping flips the line to "- [x]" on the relay in
place. The next client tick pulls those toggles back and PATCHes the
local card, so hyprcrd stays the source of truth for item text.

  - Relay: shopping_state table (one row per tenant), POST/GET
    /api/shopping for content push/pull, POST /api/shopping/check
    for phone toggles, GET /shopping for the phone UI.
  - Client: hamburger menu in the header with an "Einkaufsliste"
    entry that opens a picker listing all #einkauf cards with a
    radio for the active one. Timestamp cursors in localStorage
    keep push/pull idempotent.
  - Phone can only toggle, not edit text — edit stays in hyprcrd,
    which makes the merge trivial (last-writer wins by timestamp).

Multi-card support is there (any card with #einkauf is a candidate)
but only one is active for the phone at a time, per the spec.

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:32:42 +02:00
1 parent c2485b1307
commit 670f125627
8 files changed
+523 -2

No files matched your search

+8
View File
@@ -1323,6 +1323,14 @@ if (str_starts_with($route, '/api/')) {
</div>
</div>
<div class="flex items-center gap-3">
<div class="relative" id="header-menu-wrap">
<button id="header-menu-btn" class="flex items-center gap-1 px-2 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="menu">
<i class="ph ph-list-dashes text-sm"></i>
</button>
<div id="header-menu" class="hidden absolute right-0 mt-1 min-w-[180px] bg-t-surface border border-t-border shadow-2xl z-50">
<button id="menu-shopping" class="w-full text-left px-3 py-2 text-xs text-t-muted hover:bg-t-raised hover:text-t-accent transition-colors flex items-center gap-2"><i class="ph ph-shopping-cart text-sm"></i><span class="font-pixel text-[10px]">Einkaufsliste</span></button>
</div>
</div>
<button id="calendar-btn" class="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">
<i class="ph ph-calendar-dots text-sm"></i><span class="font-pixel text-[10px]">Calendar</span>
</button>
+2
View File
@@ -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) ──
+225
View File
@@ -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 = `
<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">Einkaufsliste</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">
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.
</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 esc(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', esc); }
});
const candidates = state.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">
Keine Karten mit dem Tag <span class="text-t-accent">#einkauf</span>.
Lege eine Karte mit Checkbox-Items an und gib ihr den Tag, dann erscheint sie hier.
</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} offen · ${items.length} gesamt</div>
</div>
</label>`;
}).join('');
// "No active" option — explicit way to stop syncing.
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">(keine aktive Liste)</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 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);
}
});
}