// ── 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, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } 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 = 'Shopping'; 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 = `

Shopping list

Cards tagged with #einkauf or #shopping 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 shopping tab in the relay UI.
`; 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 = `
No cards tagged #einkauf or #shopping yet. Create a card with checkbox items and add one of those tags — it'll show up here.
`; } 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(''); 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 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.', });