shopping: multi-list, DnD ordering, phone layout; fix flaky tests
Shopping lists:
- Multiple lists per tenant: picker uses checkboxes instead of
radios. Relay schema migrated to composite PK (tenant_id,
card_uid) with a position column for display ordering.
- Picker rows are drag-and-drop reorderable (grip handle +
accent drop indicator). Order persists in localStorage and is
pushed to the relay as position integers — phone renders lists
in that order (ORDER BY position, title).
- Each picker row has a goto-arrow that closes the popup,
switches to the card's bucket if needed, and opens the card.
- Relay DELETE /api/shopping removes deactivated lists. Client
proxy forwards DELETE. Deactivating a list in the picker
immediately removes it from the relay.
- Phone UI renders multiple lists as stacked boxes with headers.
Section headings (# h1–h6) get 14px top padding for breathing
room. CSS selectors fixed to match div-based headings inside
the new .shopping-list-box containers.
- Help text updated for multi-list wording.
- pullOne preserves the synced position so a pull doesn't cause
a spurious re-push of the same order.
Test fixes:
- Line-numbers gutter height tolerance bumped 50%→60% — OS text
shaping can push the divergence past 50%.
- Find/replace flaky: replaced sleep(200) with waitFor on the
confirm dialog so slow renders don't miss the OK click.
- Shopping picker test updated: checkboxes instead of radios.
Bumps to 1.9.25.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
4c3954d989
commit
be7eb1523e
12 files changed
+370
-192
No files matched your search
+30
-5
@@ -233,19 +233,44 @@ $db->exec('CREATE TABLE IF NOT EXISTS chat_room_members (
|
||||
)');
|
||||
$db->exec('CREATE INDEX IF NOT EXISTS idx_chat_room_member_tenant ON chat_room_members(tenant_id, room_uid)');
|
||||
|
||||
// Shopping list: one active card per tenant, body mirrored from the
|
||||
// client. Phone toggles checkboxes in-place via the session UI — no
|
||||
// multi-list fan-out, no history, no attachments. hyprcrd remains the
|
||||
// only surface that edits item text.
|
||||
// Shopping lists: multiple active cards per tenant, each mirrored from
|
||||
// the client. Phone toggles checkboxes in-place via the session UI.
|
||||
// hyprcrd remains the only surface that edits item text.
|
||||
$db->exec('CREATE TABLE IF NOT EXISTS shopping_state (
|
||||
tenant_id INTEGER PRIMARY KEY,
|
||||
tenant_id INTEGER NOT NULL,
|
||||
card_uid TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT "",
|
||||
body TEXT NOT NULL DEFAULT "",
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at_client TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at_server TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (tenant_id, card_uid),
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||
)');
|
||||
// Migrate from old single-PK schema (tenant_id only) to composite PK
|
||||
// + ensure position column exists (may be missing on tables created
|
||||
// before the multi-list update).
|
||||
$shoppingInfo = $db->query("PRAGMA table_info(shopping_state)")->fetchAll();
|
||||
$shoppingPk = array_filter($shoppingInfo, fn($c) => $c['pk'] > 0);
|
||||
$shoppingColNames = array_column($shoppingInfo, 'name');
|
||||
if (count($shoppingPk) === 1) {
|
||||
$db->exec('ALTER TABLE shopping_state RENAME TO _shopping_old');
|
||||
$db->exec('CREATE TABLE shopping_state (
|
||||
tenant_id INTEGER NOT NULL,
|
||||
card_uid TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT "",
|
||||
body TEXT NOT NULL DEFAULT "",
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at_client TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at_server TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (tenant_id, card_uid),
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||
)');
|
||||
$db->exec('INSERT OR IGNORE INTO shopping_state (tenant_id, card_uid, title, body, updated_at_client, updated_at_server) SELECT tenant_id, card_uid, title, body, updated_at_client, updated_at_server FROM _shopping_old');
|
||||
$db->exec('DROP TABLE _shopping_old');
|
||||
} elseif (!in_array('position', $shoppingColNames)) {
|
||||
$db->exec('ALTER TABLE shopping_state ADD COLUMN position INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
|
||||
// Sync-event notifications: replaces the old bot-chat mechanism. When a
|
||||
// client syncs a card they received, they POST here; the sender polls
|
||||
|
||||
@@ -1,53 +1,50 @@
|
||||
<?php
|
||||
// Shopping-list routes:
|
||||
// POST /api/shopping (client) upsert active card {uid, title, body, updated_at}
|
||||
// GET /api/shopping (client + session) read current state
|
||||
// POST /api/shopping/check (session) toggle one line {line, checked}
|
||||
// GET /shopping (session) phone-friendly UI
|
||||
// POST /api/shopping (client) upsert one list {card_uid, title, body}
|
||||
// GET /api/shopping (client + session) all active lists for tenant
|
||||
// POST /api/shopping/check (session) toggle one line {card_uid, line, checked}
|
||||
// DELETE /api/shopping (client) remove a deactivated list {card_uid}
|
||||
//
|
||||
// Plaintext on purpose — shopping-list content is low-stakes and
|
||||
// everything here is session- or client-auth'd. No per-tenant key
|
||||
// dance like chat / collab-buckets. If that needs tightening later,
|
||||
// it's a one-table migration.
|
||||
// Multiple lists per tenant (composite PK: tenant_id + card_uid).
|
||||
// Plaintext on purpose — shopping-list content is low-stakes.
|
||||
|
||||
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
|
||||
|
||||
$clientTenant = $clientTenant ?? authClient($db);
|
||||
$tenant = $sessionTenant ?: $clientTenant;
|
||||
|
||||
function shoppingLoad(PDO $db, int $tenantId): ?array {
|
||||
$stmt = $db->prepare('SELECT card_uid, title, body, updated_at_client, updated_at_server FROM shopping_state WHERE tenant_id = :t');
|
||||
function shoppingLoadAll(PDO $db, int $tenantId): array {
|
||||
$stmt = $db->prepare('SELECT card_uid, title, body, position, updated_at_client, updated_at_server FROM shopping_state WHERE tenant_id = :t ORDER BY position, title');
|
||||
$stmt->execute(['t' => $tenantId]);
|
||||
return $stmt->fetchAll() ?: [];
|
||||
}
|
||||
|
||||
function shoppingLoadOne(PDO $db, int $tenantId, string $cardUid): ?array {
|
||||
$stmt = $db->prepare('SELECT card_uid, title, body, updated_at_client, updated_at_server FROM shopping_state WHERE tenant_id = :t AND card_uid = :u');
|
||||
$stmt->execute(['t' => $tenantId, 'u' => $cardUid]);
|
||||
$row = $stmt->fetch();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
// ── POST /api/shopping ────────────────────────────────────────
|
||||
// Client pushes the currently-active card's content. `updated_at`
|
||||
// is the client's timestamp — used for lose-the-race checks.
|
||||
|
||||
if ($route === '/api/shopping' && $method === 'POST') {
|
||||
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$uid = trim((string)($input['card_uid'] ?? ''));
|
||||
$title = (string)($input['title'] ?? '');
|
||||
$body = (string)($input['body'] ?? '');
|
||||
$pos = (int)($input['position'] ?? 0);
|
||||
$clientTs = trim((string)($input['updated_at'] ?? '')) ?: gmdate('Y-m-d H:i:s');
|
||||
if ($uid === '') jsonResponse(['error' => 'card_uid required'], 400);
|
||||
|
||||
// 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)
|
||||
ON CONFLICT(tenant_id) DO UPDATE SET
|
||||
card_uid = excluded.card_uid,
|
||||
INSERT INTO shopping_state (tenant_id, card_uid, title, body, position, updated_at_client, updated_at_server)
|
||||
VALUES (:t, :u, :ti, :b, :p, :c, :s)
|
||||
ON CONFLICT(tenant_id, card_uid) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
body = excluded.body,
|
||||
position = excluded.position,
|
||||
updated_at_client = excluded.updated_at_client,
|
||||
updated_at_server = excluded.updated_at_server
|
||||
')->execute([
|
||||
@@ -55,39 +52,42 @@ if ($route === '/api/shopping' && $method === 'POST') {
|
||||
'u' => $uid,
|
||||
'ti' => $title,
|
||||
'b' => $body,
|
||||
'p' => $pos,
|
||||
'c' => $clientTs,
|
||||
's' => $now,
|
||||
]);
|
||||
jsonResponse(['ok' => true, 'stored' => true, 'updated_at_server' => $now]);
|
||||
}
|
||||
|
||||
// ── GET /api/shopping ────────────────────────────────────────
|
||||
// Both the client (to pull phone-made toggles back) and the
|
||||
// session-auth'd phone UI poll this.
|
||||
// ── DELETE /api/shopping ─────────────────────────────────────
|
||||
if ($route === '/api/shopping' && $method === 'DELETE') {
|
||||
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$uid = trim((string)($input['card_uid'] ?? ''));
|
||||
if ($uid === '') jsonResponse(['error' => 'card_uid required'], 400);
|
||||
$db->prepare('DELETE FROM shopping_state WHERE tenant_id = :t AND card_uid = :u')
|
||||
->execute(['t' => $clientTenant['id'], 'u' => $uid]);
|
||||
jsonResponse(['ok' => true]);
|
||||
}
|
||||
|
||||
// ── GET /api/shopping ────────────────────────────────────────
|
||||
if ($route === '/api/shopping' && $method === 'GET') {
|
||||
if (!$tenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||
$state = shoppingLoad($db, (int)$tenant['id']);
|
||||
if (!$state) jsonResponse(['ok' => true, 'state' => null]);
|
||||
jsonResponse(['ok' => true, 'state' => $state]);
|
||||
$lists = shoppingLoadAll($db, (int)$tenant['id']);
|
||||
jsonResponse(['ok' => true, 'lists' => $lists]);
|
||||
}
|
||||
|
||||
// ── POST /api/shopping/check ─────────────────────────────────
|
||||
// Phone-side toggle. Body: { line: "- [ ] milk", checked: true }.
|
||||
// We match the exact line once to keep merges simple; if the line
|
||||
// isn't found (hyprcrd just rewrote it), the caller reloads and
|
||||
// retries. Bumps updated_at_server so the next client pull sees
|
||||
// the change.
|
||||
|
||||
if ($route === '/api/shopping/check' && $method === 'POST') {
|
||||
if (!$sessionTenant) jsonResponse(['error' => 'unauthorized'], 401);
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?: [];
|
||||
$uid = trim((string)($input['card_uid'] ?? ''));
|
||||
$line = (string)($input['line'] ?? '');
|
||||
$checked = !empty($input['checked']);
|
||||
if ($line === '') jsonResponse(['error' => 'line required'], 400);
|
||||
if ($uid === '' || $line === '') jsonResponse(['error' => 'card_uid and line required'], 400);
|
||||
|
||||
$state = shoppingLoad($db, (int)$sessionTenant['id']);
|
||||
if (!$state) jsonResponse(['error' => 'no active list'], 404);
|
||||
$state = shoppingLoadOne($db, (int)$sessionTenant['id'], $uid);
|
||||
if (!$state) jsonResponse(['error' => 'list not found'], 404);
|
||||
$body = $state['body'];
|
||||
$lines = preg_split("/\r\n|\n|\r/", $body);
|
||||
$hit = false;
|
||||
@@ -96,7 +96,7 @@ if ($route === '/api/shopping/check' && $method === 'POST') {
|
||||
$toggled = $checked
|
||||
? preg_replace('/^(\s*-\s*)\[\s\]/', '$1[x]', $l, 1)
|
||||
: preg_replace('/^(\s*-\s*)\[[xX]\]/', '$1[ ]', $l, 1);
|
||||
if ($toggled === $l) continue; // not a checkbox line
|
||||
if ($toggled === $l) continue;
|
||||
$lines[$i] = $toggled;
|
||||
$hit = true;
|
||||
break;
|
||||
@@ -105,11 +105,7 @@ if ($route === '/api/shopping/check' && $method === 'POST') {
|
||||
|
||||
$newBody = implode("\n", $lines);
|
||||
$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']]);
|
||||
$db->prepare('UPDATE shopping_state SET body = :b, updated_at_server = :s WHERE tenant_id = :t AND card_uid = :u')
|
||||
->execute(['b' => $newBody, 's' => $now, 't' => $sessionTenant['id'], 'u' => $uid]);
|
||||
jsonResponse(['ok' => true, 'updated_at_server' => $now]);
|
||||
}
|
||||
|
||||
// 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.
|
||||
+47
-25
@@ -773,45 +773,67 @@ async function loadShopping(quiet = false) {
|
||||
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>';
|
||||
const lists = data.lists || [];
|
||||
if (!lists.length) {
|
||||
if (title) title.textContent = 'Shopping';
|
||||
list.innerHTML = '<li class="empty">no active shopping lists</li>';
|
||||
return;
|
||||
}
|
||||
if (title) title.textContent = lists.length === 1 ? (lists[0].title || 'Shopping') : 'Shopping';
|
||||
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));
|
||||
for (const entry of lists) {
|
||||
const nodes = shoppingParseLines(entry.body);
|
||||
const hasItems = nodes.some(n => n.kind === 'item');
|
||||
// List box wrapper
|
||||
const box = document.createElement('li');
|
||||
box.className = 'shopping-list-box';
|
||||
if (lists.length > 1 || entry.title) {
|
||||
const hdr = document.createElement('div');
|
||||
hdr.className = 'shopping-list-header';
|
||||
hdr.textContent = entry.title || '(untitled)';
|
||||
box.appendChild(hdr);
|
||||
}
|
||||
list.appendChild(li);
|
||||
if (!hasItems) {
|
||||
const done = document.createElement('div');
|
||||
done.className = 'shopping-list-done';
|
||||
done.textContent = '\u2713 all done';
|
||||
box.appendChild(done);
|
||||
} else {
|
||||
for (const n of nodes) {
|
||||
if (n.kind === 'heading') {
|
||||
const h = document.createElement('div');
|
||||
h.className = 'shopping-heading h' + n.level;
|
||||
h.textContent = n.text;
|
||||
box.appendChild(h);
|
||||
} else {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'shopping-item';
|
||||
row.textContent = n.text;
|
||||
row.dataset.line = n.line;
|
||||
row.dataset.uid = entry.card_uid;
|
||||
row.addEventListener('click', () => shoppingCheck(row));
|
||||
box.appendChild(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
list.appendChild(box);
|
||||
}
|
||||
}
|
||||
|
||||
async function shoppingCheck(li) {
|
||||
async function shoppingCheck(el) {
|
||||
if (shoppingBusy) return;
|
||||
shoppingBusy = true;
|
||||
li.classList.add('checked');
|
||||
el.classList.add('checked');
|
||||
try {
|
||||
const res = await api('api/shopping/check', { method: 'POST', body: { line: li.dataset.line, checked: true } });
|
||||
const res = await api('api/shopping/check', {
|
||||
method: 'POST',
|
||||
body: { card_uid: el.dataset.uid, line: el.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');
|
||||
el.classList.remove('checked');
|
||||
shoppingErr(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
+51
-14
@@ -276,26 +276,63 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point
|
||||
}
|
||||
.shopping-items li:active { transform: scale(0.98); background: var(--raised, var(--surface)); }
|
||||
.shopping-items li.checked { opacity: 0.35; text-decoration: line-through; }
|
||||
.shopping-items .empty { text-align: center; padding: 40px 16px; color: var(--dim); background: transparent; border: none; cursor: default; }
|
||||
.shopping-items .empty { text-align: center; padding: 40px 16px; color: var(--dim); background: transparent; border: none; cursor: default; list-style: none; }
|
||||
.shopping-items .empty:active { transform: none; }
|
||||
|
||||
/* Section headings between items — "# Obst", "## Backwaren", etc. */
|
||||
.shopping-items li.shopping-heading {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 18px 4px 4px;
|
||||
/* Multi-list stacked boxes */
|
||||
.shopping-list-box {
|
||||
list-style: none;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.shopping-list-box:active { transform: none; }
|
||||
.shopping-list-header {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--bright);
|
||||
padding: 4px 6px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.shopping-list-done {
|
||||
text-align: center;
|
||||
padding: 16px 8px;
|
||||
color: var(--dim);
|
||||
font-size: 14px;
|
||||
}
|
||||
.shopping-item {
|
||||
background: var(--raised, var(--surface));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 12px 14px;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: transform 0.12s, opacity 0.3s, background 0.12s;
|
||||
}
|
||||
.shopping-item:active { transform: scale(0.98); }
|
||||
.shopping-item.checked { opacity: 0.35; text-decoration: line-through; }
|
||||
|
||||
/* Section headings between items — "# Obst", "## Backwaren", etc.
|
||||
Adds breathing room above so grouped items feel like sections. */
|
||||
.shopping-heading {
|
||||
padding: 14px 6px 2px;
|
||||
cursor: default;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.shopping-items li.shopping-heading:active { transform: none; background: transparent; opacity: 1; }
|
||||
.shopping-items li.shopping-heading:first-child { padding-top: 2px; }
|
||||
.shopping-items li.shopping-heading.h1 { font-size: 18px; color: var(--bright); }
|
||||
.shopping-items li.shopping-heading.h2 { font-size: 15px; color: var(--accent); }
|
||||
.shopping-items li.shopping-heading.h3,
|
||||
.shopping-items li.shopping-heading.h4,
|
||||
.shopping-items li.shopping-heading.h5,
|
||||
.shopping-items li.shopping-heading.h6 { font-size: 13px; color: var(--muted); }
|
||||
.shopping-heading:first-child { padding-top: 2px; }
|
||||
.shopping-heading.h1 { font-size: 17px; color: var(--bright); }
|
||||
.shopping-heading.h2 { font-size: 14px; color: var(--accent); }
|
||||
.shopping-heading.h3,
|
||||
.shopping-heading.h4,
|
||||
.shopping-heading.h5,
|
||||
.shopping-heading.h6 { font-size: 12px; color: var(--muted); }
|
||||
.shopping-err { background: var(--danger-bg, #7f1d1d); color: var(--danger-fg, #fecaca); padding: 8px 12px; border-radius: 6px; font-size: 13px; }
|
||||
.shopping-err.hidden { display: none; }
|
||||
|
||||
|
||||
Reference in new issue
Block a user