// ── 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.',
});