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:
1 parent
5c9e9254d1
commit
6fa42eb267
7 files changed
+46
-20
No files matched your search
+1
-1
@@ -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,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": {
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user