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

+11 -163
View File
@@ -32,24 +32,15 @@ if ($route === '/api/shopping' && $method === 'POST') {
$uid = trim((string)($input['card_uid'] ?? ''));
$title = (string)($input['title'] ?? '');
$body = (string)($input['body'] ?? '');
$clientTs = trim((string)($input['updated_at'] ?? '')) ?: date('Y-m-d H:i:s');
$clientTs = trim((string)($input['updated_at'] ?? '')) ?: gmdate('Y-m-d H:i:s');
if ($uid === '') jsonResponse(['error' => 'card_uid required'], 400);
$existing = shoppingLoad($db, (int)$clientTenant['id']);
// Skip the write when the client is pushing an older version of
// the same card than what's already stored (phone toggled
// meanwhile). Still return 200 so the client doesn't retry in a
// loop — they just haven't pulled yet.
if ($existing && $existing['card_uid'] === $uid
&& strcmp($existing['updated_at_server'], $clientTs) > 0) {
jsonResponse([
'ok' => true,
'stored' => false,
'reason' => 'server newer — pull first',
'updated_at_server' => $existing['updated_at_server'],
]);
}
$now = date('Y-m-d H:i:s');
// No server-side staleness guard: the client decides whether to
// push by diffing its last-synced body against the current card,
// not by timestamp, so cross-timezone clock comparisons can't
// silently block edits. Last-writer per tick wins — phone-toggle
// races lose at most one tick of flips (rare by design).
$now = gmdate('Y-m-d H:i:s');
$db->prepare('
INSERT INTO shopping_state (tenant_id, card_uid, title, body, updated_at_client, updated_at_server)
VALUES (:t, :u, :ti, :b, :c, :s)
@@ -113,155 +104,12 @@ if ($route === '/api/shopping/check' && $method === 'POST') {
if (!$hit) jsonResponse(['error' => 'line not found (list may have been edited — reload)'], 409);
$newBody = implode("\n", $lines);
$now = date('Y-m-d H:i:s');
$now = gmdate('Y-m-d H:i:s');
$db->prepare('UPDATE shopping_state SET body = :b, updated_at_server = :s WHERE tenant_id = :t')
->execute(['b' => $newBody, 's' => $now, 't' => $sessionTenant['id']]);
jsonResponse(['ok' => true, 'updated_at_server' => $now]);
}
// ── GET /shopping ────────────────────────────────────────────
// Phone UI. Simple static shell; the JS fetches /api/shopping and
// renders only unchecked items. Tapping one POSTs to /api/shopping/check.
if ($route === '/shopping' && $method === 'GET') {
if (!$sessionTenant) {
// Bounce through the relay's session-auth flow.
header('Location: ' . dirname($_SERVER['SCRIPT_NAME']) . '/');
exit;
}
header('Content-Type: text/html; charset=utf-8');
$userLabel = htmlspecialchars($sessionTenant['username'], ENT_QUOTES);
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<meta name="theme-color" content="#0f172a">
<title>Shopping list</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: #0f172a; color: #e2e8f0; font-family: system-ui, -apple-system, sans-serif; }
header { position: sticky; top: 0; background: #0f172a; padding: 14px 16px; border-bottom: 1px solid #334155; display: flex; align-items: center; gap: 10px; }
header h1 { margin: 0; font-size: 16px; font-weight: 600; flex: 1; }
header .user { font-size: 12px; color: #64748b; }
header .refresh { background: none; border: 1px solid #334155; color: #94a3b8; padding: 6px 10px; border-radius: 6px; font-size: 12px; cursor: pointer; }
main { padding: 12px; }
.title { font-size: 13px; color: #94a3b8; margin: 0 0 12px; }
.empty { text-align: center; padding: 40px 16px; color: #64748b; }
ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; }
li { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 14px 16px; font-size: 16px; cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent; transition: transform 0.12s, opacity 0.3s, background 0.12s; }
li:active { transform: scale(0.98); background: #273446; }
li.checked { opacity: 0.35; text-decoration: line-through; }
.stale { font-size: 11px; color: #64748b; text-align: center; margin-top: 16px; }
.err { background: #7f1d1d; color: #fecaca; padding: 8px 12px; border-radius: 6px; margin-bottom: 12px; font-size: 13px; }
</style>
</head>
<body>
<header>
<h1>Shopping list</h1>
<span class="user"><?= $userLabel ?></span>
<button class="refresh" id="refresh">↻</button>
</header>
<main>
<p class="title" id="title">…</p>
<div id="err" class="err" style="display:none"></div>
<ul id="items"></ul>
<p class="stale" id="stale"></p>
</main>
<script>
(function(){
const base = location.pathname.replace(/\/shopping\/?$/, '');
const elTitle = document.getElementById('title');
const elItems = document.getElementById('items');
const elStale = document.getElementById('stale');
const elErr = document.getElementById('err');
const btnRefresh = document.getElementById('refresh');
let lastServerTs = null;
let busy = false;
function showErr(msg) {
if (!msg) { elErr.style.display = 'none'; elErr.textContent = ''; return; }
elErr.textContent = msg; elErr.style.display = 'block';
}
function parseItems(body) {
const lines = String(body || '').split(/\r\n|\n|\r/);
const items = [];
for (const l of lines) {
const m = /^\s*-\s*\[([ xX])\]\s*(.*)$/.exec(l);
if (!m) continue;
const checked = m[1].toLowerCase() === 'x';
if (!checked) items.push({ line: l, text: m[2] });
}
return items;
}
async function load() {
try {
const res = await fetch(base + '/api/shopping', { credentials: 'same-origin' });
const data = await res.json();
if (!data.ok || !data.state) {
elTitle.textContent = '';
elItems.innerHTML = '<div class="empty">no active shopping list</div>';
elStale.textContent = '';
return;
}
const { title, body, updated_at_server } = data.state;
lastServerTs = updated_at_server;
elTitle.textContent = title || '';
const items = parseItems(body);
if (!items.length) {
elItems.innerHTML = '<div class="empty">✓ all done</div>';
} else {
elItems.innerHTML = '';
for (const it of items) {
const li = document.createElement('li');
li.textContent = it.text;
li.dataset.line = it.line;
li.addEventListener('click', () => check(li));
elItems.appendChild(li);
}
}
const ago = Math.round((Date.now() - Date.parse(updated_at_server.replace(' ','T') + 'Z')) / 1000);
elStale.textContent = 'last synced: ' + (ago < 60 ? ago + 's ago' : Math.round(ago/60) + ' min ago');
showErr('');
} catch (e) {
showErr('load failed: ' + e.message);
}
}
async function check(li) {
if (busy) return;
busy = true;
li.classList.add('checked');
try {
const res = await fetch(base + '/api/shopping/check', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ line: li.dataset.line, checked: true }),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error || 'check failed');
setTimeout(() => { busy = false; load(); }, 300);
} catch (e) {
busy = false;
li.classList.remove('checked');
showErr(e.message);
}
}
btnRefresh.addEventListener('click', () => load());
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
load();
setInterval(() => { if (!document.hidden && !busy) load(); }, 15000);
})();
</script>
</body>
</html>
<?php
exit;
}
// Shopping list lives inside the relay UI as a regular view (see
// relay/ui/layout.php + app.js). No separate /shopping page — login
// and navigation are the same as for every other relay feature.