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>
This commit is contained in:
thandClaude Opus 4.6 committed 2026-04-16 18:20:46 +02:00
1 parent 5c9e9254d1
commit 6fa42eb267
7 files changed
+46 -20

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
<?php <?php
define('APP_NAME', 'hyprcrd'); define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '1.9.25'); define('APP_VERSION', '1.9.26');
// Database path — writable directory, outside webroot // Database path — writable directory, outside webroot
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation // DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hyprcrd", "name": "hyprcrd",
"version": "1.9.25", "version": "1.9.26",
"description": "hyprcrd — self-hosted card manager", "description": "hyprcrd — self-hosted card manager",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
+8 -1
View File
@@ -298,11 +298,18 @@ async function pullOne(card, relayState) {
async function tick() { async function tick() {
const activeCards = getActiveCards(); const activeCards = getActiveCards();
if (!activeCards.length) return; const activeUids = activeCards.map(c => String(c.id));
// Push all active cards first. // Push all active cards first.
for (const card of activeCards) { for (const card of activeCards) {
try { await pushOne(card); } catch {} 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. // Pull: fetch all lists from relay, match by card_uid.
try { try {
const res = await hyprcrd.api('api/shopping', { method: 'GET' }); const res = await hyprcrd.api('api/shopping', { method: 'GET' });
+20 -1
View File
@@ -63,11 +63,30 @@ if ($route === '/api/shopping' && $method === 'POST') {
} }
// ── DELETE /api/shopping ───────────────────────────────────── // ── DELETE /api/shopping ─────────────────────────────────────
// Two modes:
// { card_uid: "123" } → delete one specific list
// { keep: ["123", "456"] } → delete everything EXCEPT these
if ($route === '/api/shopping' && $method === 'DELETE') { if ($route === '/api/shopping' && $method === 'DELETE') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401); if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
$input = json_decode(file_get_contents('php://input'), true) ?: []; $input = json_decode(file_get_contents('php://input'), true) ?: [];
$keep = is_array($input['keep'] ?? null) ? $input['keep'] : null;
$uid = trim((string)($input['card_uid'] ?? '')); $uid = trim((string)($input['card_uid'] ?? ''));
if ($uid === '') jsonResponse(['error' => 'card_uid required'], 400);
if ($keep !== null) {
// Bulk cleanup: keep only the listed card_uids, drop the rest.
if (count($keep)) {
$placeholders = implode(',', array_fill(0, count($keep), '?'));
$params = array_merge([$clientTenant['id']], array_map('strval', $keep));
$db->prepare("DELETE FROM shopping_state WHERE tenant_id = ? AND card_uid NOT IN ($placeholders)")
->execute($params);
} else {
$db->prepare('DELETE FROM shopping_state WHERE tenant_id = :t')
->execute(['t' => $clientTenant['id']]);
}
jsonResponse(['ok' => true]);
}
if ($uid === '') jsonResponse(['error' => 'card_uid or keep[] required'], 400);
$db->prepare('DELETE FROM shopping_state WHERE tenant_id = :t AND card_uid = :u') $db->prepare('DELETE FROM shopping_state WHERE tenant_id = :t AND card_uid = :u')
->execute(['t' => $clientTenant['id'], 'u' => $uid]); ->execute(['t' => $clientTenant['id'], 'u' => $uid]);
jsonResponse(['ok' => true]); jsonResponse(['ok' => true]);
+12 -8
View File
@@ -112,6 +112,11 @@ function showView(name) {
} else { } else {
stopShoppingPoll(); stopShoppingPoll();
} }
// Remember the active view so a page reload lands back here
// instead of defaulting to compose every time.
if (['compose', 'account', 'admin', 'chat', 'shopping'].includes(name)) {
try { localStorage.setItem('relay-last-view', name); } catch {}
}
if (name === 'admin') { if (name === 'admin') {
loadUsers(); loadUsers();
} else if (name === 'account') { } else if (name === 'account') {
@@ -279,7 +284,8 @@ function routePostLogin(status) {
showView('account'); showView('account');
toast('set your client secret to start using the relay'); toast('set your client secret to start using the relay');
} else { } else {
showView('compose'); const last = localStorage.getItem('relay-last-view') || 'compose';
showView(['compose', 'account', 'admin', 'chat', 'shopping'].includes(last) ? last : 'compose');
} }
} }
@@ -786,7 +792,7 @@ async function loadShopping(quiet = false) {
list.innerHTML = '<li class="empty">no active shopping lists</li>'; list.innerHTML = '<li class="empty">no active shopping lists</li>';
return; return;
} }
if (title) title.textContent = lists.length === 1 ? (lists[0].title || 'Shopping') : 'Shopping'; if (title) title.textContent = 'Shopping';
list.innerHTML = ''; list.innerHTML = '';
for (const entry of lists) { for (const entry of lists) {
const nodes = shoppingParseLines(entry.body); const nodes = shoppingParseLines(entry.body);
@@ -794,12 +800,10 @@ async function loadShopping(quiet = false) {
// List box wrapper // List box wrapper
const box = document.createElement('li'); const box = document.createElement('li');
box.className = 'shopping-list-box'; box.className = 'shopping-list-box';
if (lists.length > 1 || entry.title) { const hdr = document.createElement('div');
const hdr = document.createElement('div'); hdr.className = 'shopping-list-header';
hdr.className = 'shopping-list-header'; hdr.textContent = entry.title || '(untitled)';
hdr.textContent = entry.title || '(untitled)'; box.appendChild(hdr);
box.appendChild(hdr);
}
if (!hasItems) { if (!hasItems) {
const done = document.createElement('div'); const done = document.createElement('div');
done.className = 'shopping-list-done'; done.className = 'shopping-list-done';
+2 -2
View File
@@ -8,7 +8,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
<meta name="theme-color" content="#1e293b"> <meta name="theme-color" content="#1e293b">
<title>hyprcrd relay</title> <title>hyprcrd relay</title>
<link rel="stylesheet" href="fonts/phosphor.css"> <link rel="stylesheet" href="fonts/phosphor.css">
<link rel="stylesheet" href="ui/style.css"> <link rel="stylesheet" href="ui/style.css?v=<?= filemtime(__DIR__ . '/style.css') ?>">
</head> </head>
<body> <body>
@@ -263,6 +263,6 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
<div id="toast-stack"></div> <div id="toast-stack"></div>
<script src="ui/app.js"></script> <script src="ui/app.js?v=<?= filemtime(__DIR__ . '/app.js') ?>"></script>
</body> </body>
</html> </html>
+2 -6
View File
@@ -271,18 +271,14 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point
.shopping-items .empty { list-style: none; text-align: center; padding: 40px 16px; color: var(--dim); background: transparent; border: none; cursor: default; } .shopping-items .empty { list-style: none; text-align: center; padding: 40px 16px; color: var(--dim); background: transparent; border: none; cursor: default; }
.shopping-items .empty:active { transform: none; } .shopping-items .empty:active { transform: none; }
/* Multi-list stacked boxes */ /* Multi-list stacked boxes — no own border/background so items
don't look doubled. The header provides section separation. */
.shopping-list-box { .shopping-list-box {
list-style: none; list-style: none;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 10px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
} }
.shopping-list-box:active { transform: none; }
.shopping-list-header { .shopping-list-header {
font-weight: 600; font-weight: 600;
font-size: 13px; font-size: 13px;