// ── 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) { 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)); // 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 Tag #einkauf oder #shopping 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.
`; 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 = `
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); } }); }