shopping: ship as plugin, integrate phone view into relay UI, fix sync
Restructure the shopping-list feature end to end:
- New public/plugins/shopping/ plugin with backend.php (proxy
routes registered via PluginManager::onApiRoute), shopping.js
(self-contained; injects the header Shopping button, owns the
picker dialog + sync loop via hyprcrd.hooks), plugin.json and a
placeholder css file. Core no longer knows about shopping —
index.php loses the static button and the proxy block, app.js
loses the initShopping wiring, the old js/modules/shopping.js
is gone.
- Phone view moves out of a standalone /shopping page and into
the relay UI as a regular `view-shopping` behind a nav-burger
entry (relay/ui/layout.php + app.js + style.css). Same session
auth as compose/chat/account — no separate login. Auto-refresh
every 4 s while the view is active; no manual refresh button.
Headings "# h1 … ###### h6" render between items for grouping.
Orphan section headings (no open items under them) are hidden.
- Sync: drop timestamp cursors (broke across SQLite-UTC / PHP
timezones and silently blocked pushes after a pull). Both sides
diff the (uid, title, body) triple instead — timezone-agnostic
and authoritative. Client ticks push-before-pull so fresh local
edits land on the relay before a stale pull can overwrite them.
Relay drops its "server newer" guard and stamps server times
with gmdate so anything left comparing to SQLite is UTC.
- Client also ticks on cards.reloaded, not just sync.tick, so
any edit path (editor save, checkbox toggle, context menu,
undo) pushes the update immediately.
- Tests: updated for the plugin (still uses the same selectors
because the plugin injects the same ids), plus a plugin-
registration check and "(no active list)" option assertion.
- Help section copy updated — references the Shopping header
button and the shopping tab in the relay UI rather than the
gone /shopping URL.
Both #einkauf and #shopping work as trigger tags; phone is
check-only, editing stays in hyprcrd. Version bumped to 1.9.21.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
b9d610b612
commit
d052fb5f9d
14 files changed
+417
-325
No files matched your search
@@ -1,228 +0,0 @@
|
||||
// ── Shopping list (phone-synced card) ──
|
||||
//
|
||||
// One card flagged as #einkauf can be "active" at a time. Its body
|
||||
// is pushed to the relay on every sync.tick; the relay serves a
|
||||
// phone-friendly checklist at /shopping. Phone taps toggle items in
|
||||
// place on the relay; hyprcrd pulls those toggles back on the next
|
||||
// tick and patches the local card.
|
||||
//
|
||||
// Phone never changes item text — only check state. Text edits stay
|
||||
// in hyprcrd, which keeps conflict resolution trivial (last-writer
|
||||
// wins by timestamp; near-simultaneous edits are extremely unlikely
|
||||
// for a shopping list).
|
||||
|
||||
import { $, api, esc, getCsrf } from './util.js';
|
||||
import { state } from './state.js';
|
||||
|
||||
const ACTIVE_KEY = 'hyprcrd-shopping-active-id';
|
||||
const LAST_PUSHED_KEY = 'hyprcrd-shopping-last-pushed-ts';
|
||||
const LAST_SERVER_KEY = 'hyprcrd-shopping-last-server-ts';
|
||||
|
||||
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));
|
||||
// Reset timestamp cursors so the switch pushes fresh on next tick.
|
||||
localStorage.removeItem(LAST_PUSHED_KEY);
|
||||
localStorage.removeItem(LAST_SERVER_KEY);
|
||||
}
|
||||
|
||||
function getActiveCard() {
|
||||
const id = getActiveId();
|
||||
if (!id) return null;
|
||||
return state.cards.find(c => String(c.id) === id) || null;
|
||||
}
|
||||
|
||||
// ── 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 list</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 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 shown at <span class="text-t-accent">/shopping</span>.
|
||||
</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 esc(e) {
|
||||
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', esc); }
|
||||
});
|
||||
|
||||
const candidates = state.cards.filter(hasEinkaufTag);
|
||||
const currentActive = getActiveId();
|
||||
let selectedId = currentActive;
|
||||
|
||||
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 = 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 `<label class="flex items-start gap-3 px-3 py-2 hover:bg-t-raised cursor-pointer">
|
||||
<input type="radio" name="shopping-active" value="${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>`;
|
||||
}).join('');
|
||||
// "No active" option — explicit way to stop syncing.
|
||||
list.insertAdjacentHTML('beforeend', `<label class="flex items-center gap-3 px-3 py-2 border-t border-t-border hover:bg-t-raised cursor-pointer mt-2">
|
||||
<input type="radio" name="shopping-active" value="" ${currentActive ? '' : 'checked'} style="margin-top:0;">
|
||||
<span class="text-[11px] text-t-muted italic">(no active list)</span>
|
||||
</label>`);
|
||||
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 localTs = card.updated_at || new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||
const lastPushed = localStorage.getItem(LAST_PUSHED_KEY);
|
||||
if (lastPushed && lastPushed >= localTs) return; // nothing newer
|
||||
const res = await api('api/shopping', {
|
||||
method: 'POST',
|
||||
body: { card_uid: String(card.id), title: card.title || '', body: card.body || '', updated_at: localTs },
|
||||
});
|
||||
if (res?.ok) {
|
||||
localStorage.setItem(LAST_PUSHED_KEY, localTs);
|
||||
if (res.updated_at_server) localStorage.setItem(LAST_SERVER_KEY, res.updated_at_server);
|
||||
}
|
||||
}
|
||||
|
||||
async function pullActive() {
|
||||
const card = getActiveCard();
|
||||
if (!card) return;
|
||||
const res = await api('api/shopping', { method: 'GET' });
|
||||
if (!res?.ok || !res.state) return;
|
||||
const { card_uid, body, updated_at_server } = res.state;
|
||||
if (String(card_uid) !== String(card.id)) return; // relay still has a previous list
|
||||
const seen = localStorage.getItem(LAST_SERVER_KEY);
|
||||
if (seen && seen >= updated_at_server) return; // no news
|
||||
if ((body || '') === (card.body || '')) {
|
||||
localStorage.setItem(LAST_SERVER_KEY, updated_at_server);
|
||||
return;
|
||||
}
|
||||
// Phone toggled something — patch the local card.
|
||||
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body } });
|
||||
localStorage.setItem(LAST_SERVER_KEY, updated_at_server);
|
||||
localStorage.setItem(LAST_PUSHED_KEY, updated_at_server); // suppress immediate re-push
|
||||
await window._reloadAll?.();
|
||||
}
|
||||
|
||||
async function clearRemote() {
|
||||
// No DELETE endpoint — send an empty list that "displaces" the
|
||||
// previous active card on the relay so the phone sees nothing.
|
||||
await api('api/shopping', {
|
||||
method: 'POST',
|
||||
body: { card_uid: '__inactive__', title: '', body: '', updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) },
|
||||
});
|
||||
localStorage.removeItem(LAST_PUSHED_KEY);
|
||||
localStorage.removeItem(LAST_SERVER_KEY);
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
if (!getActiveId()) return;
|
||||
// Pull first so phone toggles land before we overwrite with a push.
|
||||
try { await pullActive(); } catch {}
|
||||
try { await pushActive(); } catch {}
|
||||
}
|
||||
|
||||
// ── Hamburger menu wiring ─────────────────────────────────────
|
||||
|
||||
function initMenu() {
|
||||
const btn = $('header-menu-btn');
|
||||
const menu = $('header-menu');
|
||||
if (!btn || !menu) return;
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
menu.classList.toggle('hidden');
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!menu.contains(e.target) && e.target !== btn) menu.classList.add('hidden');
|
||||
});
|
||||
$('menu-shopping')?.addEventListener('click', () => {
|
||||
menu.classList.add('hidden');
|
||||
openDialog();
|
||||
});
|
||||
}
|
||||
|
||||
export function initShopping() {
|
||||
initMenu();
|
||||
// Hook into the shared sync heartbeat so push/pull happens on the
|
||||
// same cadence as every other poller — one timer rules them all.
|
||||
window.hyprcrd?.hooks?.on?.('sync.tick', () => { tick().catch(() => {}); });
|
||||
// Also push right after a save of the active card so the phone
|
||||
// sees edits without waiting for the next tick.
|
||||
window.addEventListener('hyprcrd:editor-close', (e) => {
|
||||
const active = getActiveId();
|
||||
if (active && String(e?.detail?.cardId) === active) {
|
||||
// Small delay so state.cards has the post-save body.
|
||||
setTimeout(() => tick().catch(() => {}), 300);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user