Files
hyprcrd/public/plugins/shopping/shopping.js
T
thandClaude Opus 4.6 6fa42eb267 fix(shopping): stale-row cleanup, cache-busting, view persistence
Root cause of the doubled lists: the relay DB had stale rows from
previous activations that were never deleted. The composite PK
prevented same-uid duplicates but not leftover rows from cards that
were deactivated without a successful DELETE.

Fixes:
  - Client tick sends DELETE { keep: [active uids] } after every
    push cycle. The relay drops all rows for this tenant that aren't
    in the keep list — cleans up migration leftovers, failed deletes,
    old single-list sentinels, everything in one shot.
  - Relay DELETE endpoint accepts { keep: [...] } for bulk cleanup
    alongside the existing { card_uid } single-delete.
  - Relay layout.php adds cache-busting query strings on style.css
    and app.js via filemtime() so phone browsers pick up new code
    on deploy instead of serving stale cached versions.
  - showView persists the active view name to localStorage; after
    login the relay restores it instead of always landing on compose.
  - Shopping-list-box border/background removed — items have their
    own chrome, the box is just a flex grouper now.
  - View title is always "Shopping"; each list box keeps its own
    header with the card title. No more doubled name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:20:46 +02:00

344 lines
16 KiB
JavaScript

// ── 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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 = '<i class="ph ph-shopping-cart text-sm"></i><span class="font-pixel text-[10px]">Shopping</span>';
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 = `
<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">Shopping lists</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">
Cards tagged with <span class="text-t-accent">#einkauf</span> or
<span class="text-t-accent">#shopping</span> can be activated for the phone.
Check any number of lists; their items appear stacked under the
<span class="text-t-accent">shopping</span> tab in the relay UI.
</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 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 = `<div class="px-4 py-6 text-center text-[11px] text-t-dim">
No cards tagged <span class="text-t-accent">#einkauf</span> or
<span class="text-t-accent">#shopping</span> yet. Create a card with checkbox items
and add one of those tags — it'll show up here.
</div>`;
} 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 `<div class="shopping-picker-row flex items-start gap-3 px-3 py-2 hover:bg-t-raised" draggable="true" data-card-id="${id}">
<i class="ph ph-dots-six-vertical text-t-dim" style="margin-top:4px;cursor:grab;flex-shrink:0;"></i>
<label class="flex items-start gap-3 flex-1 min-w-0 cursor-pointer">
<input type="checkbox" data-card-id="${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} open · ${items.length} total</div>
</div>
</label>
<button class="shopping-goto text-t-dim hover:text-t-accent transition-colors" data-card-id="${id}" data-bucket="${c.bucket_id || ''}" title="go to card" style="padding:4px;margin-top:1px;"><i class="ph ph-arrow-square-out text-sm"></i></button>
</div>`;
}).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.textContent = 'saving…';
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.',
});