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:
thandClaude Opus 4.6 committed 2026-04-16 01:24:58 +02:00
1 parent b9d610b612
commit d052fb5f9d
14 files changed
+417 -325

No files matched your search

+132 -2
View File
@@ -68,15 +68,16 @@ function showView(name) {
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
name = 'change-password';
}
['setup', 'login', 'change-password', 'compose', 'account', 'admin', 'chat'].forEach(v => {
['setup', 'login', 'change-password', 'compose', 'account', 'admin', 'chat', 'shopping'].forEach(v => {
$('view-' + v)?.classList.add('hidden');
});
$('view-' + name)?.classList.remove('hidden');
if (['compose', 'account', 'admin', 'chat'].includes(name)) {
if (['compose', 'account', 'admin', 'chat', 'shopping'].includes(name)) {
$('nav-buttons').classList.remove('hidden');
$('nav-compose').classList.toggle('active', name === 'compose');
$('nav-chat')?.classList.toggle('active', name === 'chat');
$('nav-shopping')?.classList.toggle('active', name === 'shopping');
$('nav-account').classList.toggle('active', name === 'account');
$('nav-admin').classList.toggle('active', name === 'admin');
$('nav-burger')?.classList.remove('hidden');
@@ -105,6 +106,12 @@ function showView(name) {
} else {
stopChatPoll();
}
if (name === 'shopping') {
loadShopping();
startShoppingPoll();
} else {
stopShoppingPoll();
}
if (name === 'admin') {
loadUsers();
} else if (name === 'account') {
@@ -695,6 +702,129 @@ window.deleteAllMessages = deleteAllMessages;
/** Lightweight relative-time formatter for the inbox. SQLite gives us
* `YYYY-MM-DD HH:MM:SS` (UTC). Returns "2 min ago", "today 14:32",
* or a full date for older messages. */
// ── Shopping (phone-side of the hyprcrd → relay list) ──
//
// We don't own the content — hyprcrd pushes the active card's body
// to /api/shopping and we just render the unchecked "- [ ]" lines
// as tappable rows. A tap POSTs to /api/shopping/check which flips
// that exact line to "- [x]" on disk; hyprcrd's next pull sees it.
let shoppingPollHandle = null;
let shoppingBusy = false;
function startShoppingPoll() {
stopShoppingPoll();
// 4 s is responsive enough that edits on the desktop show up
// while you're still holding the phone, and cheap enough that
// an always-on tab costs basically nothing (single indexed
// SELECT per poll).
shoppingPollHandle = setInterval(() => {
if (!document.hidden && !shoppingBusy) loadShopping(true);
}, 4000);
}
function stopShoppingPoll() {
if (shoppingPollHandle) { clearInterval(shoppingPollHandle); shoppingPollHandle = null; }
}
function shoppingErr(msg) {
const el = $('shopping-err');
if (!el) return;
if (!msg) { el.classList.add('hidden'); el.textContent = ''; return; }
el.textContent = msg;
el.classList.remove('hidden');
}
function shoppingParseLines(body) {
// Two node kinds end up on the phone:
// heading — "# h1" … "###### h6" inside the body, used to
// section the list (Obst / Backwaren / …)
// item — unchecked "- [ ] …" lines (checked ones are hidden)
// Then drop orphan headings (no open items before the next
// heading or end of list) so empty section labels don't sit
// around after everything under them is ticked off.
const nodes = [];
for (const line of String(body || '').split(/\r\n|\n|\r/)) {
const hm = /^\s*(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
if (hm) { nodes.push({ kind: 'heading', level: hm[1].length, text: hm[2] }); continue; }
const im = /^\s*-\s*\[([ xX])\]\s*(.*)$/.exec(line);
if (!im) continue;
if (im[1].toLowerCase() === 'x') continue;
nodes.push({ kind: 'item', text: im[2], line });
}
const out = [];
for (let i = 0; i < nodes.length; i++) {
if (nodes[i].kind === 'heading') {
let hasItem = false;
for (let j = i + 1; j < nodes.length; j++) {
if (nodes[j].kind === 'heading') break;
if (nodes[j].kind === 'item') { hasItem = true; break; }
}
if (!hasItem) continue;
}
out.push(nodes[i]);
}
return out;
}
async function loadShopping(quiet = false) {
if (!quiet) shoppingErr('');
const data = await api('api/shopping');
const title = $('shopping-title');
const list = $('shopping-items');
if (!list) return;
if (!data?.ok) { if (!quiet) shoppingErr(data?.error || 'load failed'); return; }
if (!data.state) {
if (title) title.textContent = '';
list.innerHTML = '<li class="empty">no active shopping list</li>';
return;
}
const { title: t, body } = data.state;
if (title) title.textContent = t || '';
const nodes = shoppingParseLines(body);
const hasItems = nodes.some(n => n.kind === 'item');
if (!hasItems) {
list.innerHTML = '<li class="empty">\u2713 all done</li>';
return;
}
list.innerHTML = '';
for (const n of nodes) {
const li = document.createElement('li');
if (n.kind === 'heading') {
li.className = 'shopping-heading h' + n.level;
li.textContent = n.text;
} else {
li.textContent = n.text;
li.dataset.line = n.line;
li.addEventListener('click', () => shoppingCheck(li));
}
list.appendChild(li);
}
}
async function shoppingCheck(li) {
if (shoppingBusy) return;
shoppingBusy = true;
li.classList.add('checked');
try {
const res = await api('api/shopping/check', { method: 'POST', body: { line: li.dataset.line, checked: true } });
if (!res?.ok) throw new Error(res?.error || 'check failed');
setTimeout(() => { shoppingBusy = false; loadShopping(true); }, 300);
} catch (e) {
shoppingBusy = false;
li.classList.remove('checked');
shoppingErr(e.message);
}
}
// Auto-refresh when the tab comes back into focus — same pattern as
// the standalone page used to have, just without a manual button.
document.addEventListener('visibilitychange', () => {
if (document.hidden) return;
if ($('view-shopping')?.classList.contains('hidden')) return;
if (shoppingBusy) return;
loadShopping(true);
});
// ── Chat ──
let chatActivePeer = null;