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>
344 lines
16 KiB
JavaScript
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, '&').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 = '<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.',
|
|
});
|