// ── Shopping-list plugin (hyprcrd side) ── // // Tag a card with #einkauf or #shopping to make it eligible. Pick // any number as "active" via the Shopping header button; each 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 cards. // // 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-ids'; const SYNCED_KEY = 'hyprcrd-shopping-synced'; const ORDER_KEY = 'hyprcrd-shopping-order'; 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'); } // ── Active IDs (multi-select) ──────────────────────────────── function getActiveIds() { try { return JSON.parse(localStorage.getItem(ACTIVE_KEY) || '[]'); } catch { return []; } } function setActiveIds(ids) { localStorage.setItem(ACTIVE_KEY, JSON.stringify(ids.map(String))); } function isActive(id) { return getActiveIds().includes(String(id)); } function getActiveCards() { const ids = getActiveIds(); if (!ids.length) return []; const cards = window.state?.cards || []; return ids.map(id => cards.find(c => String(c.id) === id)).filter(Boolean); } function getStoredOrder() { try { return JSON.parse(localStorage.getItem(ORDER_KEY) || '[]'); } catch { return []; } } function setStoredOrder(ids) { localStorage.setItem(ORDER_KEY, JSON.stringify(ids.map(String))); } function sortByOrder(cards) { const order = getStoredOrder(); const idx = new Map(order.map((id, i) => [id, i])); return [...cards].sort((a, b) => { const ai = idx.has(String(a.id)) ? idx.get(String(a.id)) : 9999; const bi = idx.has(String(b.id)) ? idx.get(String(b.id)) : 9999; return ai - bi; }); } // ── Synced state (per card_uid) ────────────────────────────── function getSyncedMap() { try { return JSON.parse(localStorage.getItem(SYNCED_KEY) || '{}'); } catch { return {}; } } function setSyncedFor(uid, title, body, pos) { const m = getSyncedMap(); m[String(uid)] = { title: title || '', body: body || '', pos: pos ?? -1 }; localStorage.setItem(SYNCED_KEY, JSON.stringify(m)); } function removeSyncedFor(uid) { const m = getSyncedMap(); delete m[String(uid)]; localStorage.setItem(SYNCED_KEY, JSON.stringify(m)); } // ── Header button injection ────────────────────────────────── function injectHeaderButton() { if ($('shopping-btn')) return; const header = document.querySelector('header#main-header'); if (!header) return; const rows = header.querySelectorAll('.flex.items-center.gap-3'); const group = rows[rows.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); 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 lists

Cards tagged with #einkauf or #shopping can be activated for the phone. Check any number of lists; their items appear stacked 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 = sortByOrder(cards.filter(hasEinkaufTag)); const currentIds = new Set(getActiveIds()); const selected = new Set(currentIds); 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 = selected.has(id) ? '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.querySelectorAll('input[type="checkbox"]').forEach(cb => { cb.addEventListener('change', () => { if (cb.checked) selected.add(cb.dataset.cardId); else selected.delete(cb.dataset.cardId); }); }); // Drag-and-drop reordering of picker rows. let dragRow = null; list.querySelectorAll('.shopping-picker-row').forEach(row => { row.addEventListener('dragstart', (e) => { dragRow = row; e.dataTransfer.effectAllowed = 'move'; row.style.opacity = '0.4'; }); row.addEventListener('dragend', () => { row.style.opacity = ''; dragRow = null; list.querySelectorAll('.shopping-picker-row').forEach(r => r.style.borderTop = ''); }); row.addEventListener('dragover', (e) => { if (!dragRow || dragRow === row) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; list.querySelectorAll('.shopping-picker-row').forEach(r => r.style.borderTop = ''); const rect = row.getBoundingClientRect(); const mid = rect.top + rect.height / 2; if (e.clientY < mid) { row.style.borderTop = '2px solid var(--t-accent)'; } else { const next = row.nextElementSibling; if (next) next.style.borderTop = '2px solid var(--t-accent)'; } }); row.addEventListener('drop', (e) => { e.preventDefault(); if (!dragRow || dragRow === row) return; const rect = row.getBoundingClientRect(); const mid = rect.top + rect.height / 2; if (e.clientY < mid) { list.insertBefore(dragRow, row); } else { list.insertBefore(dragRow, row.nextSibling); } // Persist new order from DOM. const newOrder = [...list.querySelectorAll('.shopping-picker-row')].map(r => r.dataset.cardId); setStoredOrder(newOrder); list.querySelectorAll('.shopping-picker-row').forEach(r => r.style.borderTop = ''); }); }); list.querySelectorAll('.shopping-goto').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const cardId = btn.dataset.cardId; const bucketId = btn.dataset.bucket; close(); // Switch bucket if needed so the card is visible. if (bucketId && window.state?.activeBucket !== bucketId && window.state?.activeBucket !== 'all') { window.state.activeBucket = bucketId; localStorage.setItem('hyprcrd-active-bucket', bucketId); window._renderBoard?.(); } setTimeout(() => window._openDetail?.(parseInt(cardId)), 100); }); }); } panel.querySelector('.shopping-picker-save').addEventListener('click', async () => { const prev = getActiveIds(); const next = [...selected]; setActiveIds(next); status.innerHTML = ' saving — please wait!'; try { // Remove deactivated lists from relay. for (const id of prev) { if (!selected.has(id)) { removeSyncedFor(id); await hyprcrd.api('api/shopping', { method: 'DELETE', body: { card_uid: id } }).catch(() => {}); } } // Push all active lists. await tick(); status.textContent = 'saved'; setTimeout(close, 400); } catch (e) { status.textContent = 'error: ' + (e?.message || e); status.style.color = 'var(--t-danger)'; setActiveIds(prev); } }); } // ── Sync ───────────────────────────────────────────────────── async function pushOne(card) { const uid = String(card.id); const title = card.title || ''; const body = card.body || ''; const order = getStoredOrder(); const pos = order.indexOf(uid); const synced = getSyncedMap()[uid]; if (synced && synced.title === title && synced.body === body && synced.pos === pos) return; const res = await hyprcrd.api('api/shopping', { method: 'POST', body: { card_uid: uid, title, body, position: pos >= 0 ? pos : 999, updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) }, }); if (res?.ok && res.stored !== false) setSyncedFor(uid, title, body, pos); } async function pullOne(card, relayState) { const uid = String(card.id); const { body, title } = relayState; const synced = getSyncedMap()[uid]; // Preserve the position we last pushed so a pull doesn't reset it // and cause an unnecessary re-push on the next tick. const curPos = synced?.pos ?? -1; if (synced && synced.body === (body || '') && synced.title === (title || '')) return; if ((card.body || '') === (body || '') && (card.title || '') === (title || '')) { setSyncedFor(card.id, card.title || '', card.body || '', curPos); return; } await hyprcrd.api(`api/cards/${card.id}`, { method: 'PATCH', body: { body } }); setSyncedFor(card.id, card.title || '', body || '', curPos); await window._reloadAll?.(); } async function tick() { const activeCards = getActiveCards(); const activeUids = activeCards.map(c => String(c.id)); // Push all active cards first. for (const card of activeCards) { try { await pushOne(card); } catch {} } // Tell the relay to drop any rows that aren't in our active set. // This cleans up stale entries from previous activations that // were never explicitly deleted (old single-list era, failed // DELETEs, migration leftovers). try { await hyprcrd.api('api/shopping', { method: 'DELETE', body: { keep: activeUids } }); } catch {} // Pull: fetch all lists from relay, match by card_uid. try { const res = await hyprcrd.api('api/shopping', { method: 'GET' }); if (!res?.ok || !Array.isArray(res.lists)) return; const byUid = new Map(res.lists.map(l => [l.card_uid, l])); for (const card of activeCards) { const relay = byUid.get(String(card.id)); if (relay) await pullOne(card, relay); } } 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.1.0', description: 'Sync shopping cards to the relay so they\'re checkable from a phone.', });