Files
hyprcrd/relay/inc/routes/shopping.php
T
thandClaude Opus 4.6 be7eb1523e 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>
2026-04-16 17:26:50 +02:00

112 lines
5.3 KiB
PHP

<?php
// Shopping-list routes:
// 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}
//
// 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 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 ────────────────────────────────────────
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);
$now = gmdate('Y-m-d H:i:s');
$db->prepare('
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([
't' => $clientTenant['id'],
'u' => $uid,
'ti' => $title,
'b' => $body,
'p' => $pos,
'c' => $clientTs,
's' => $now,
]);
jsonResponse(['ok' => true, 'stored' => true, 'updated_at_server' => $now]);
}
// ── 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);
$lists = shoppingLoadAll($db, (int)$tenant['id']);
jsonResponse(['ok' => true, 'lists' => $lists]);
}
// ── POST /api/shopping/check ─────────────────────────────────
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 ($uid === '' || $line === '') jsonResponse(['error' => 'card_uid and line required'], 400);
$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;
foreach ($lines as $i => $l) {
if ($l !== $line) continue;
$toggled = $checked
? preg_replace('/^(\s*-\s*)\[\s\]/', '$1[x]', $l, 1)
: preg_replace('/^(\s*-\s*)\[[xX]\]/', '$1[ ]', $l, 1);
if ($toggled === $l) continue;
$lines[$i] = $toggled;
$hit = true;
break;
}
if (!$hit) jsonResponse(['error' => 'line not found (list may have been edited — reload)'], 409);
$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 AND card_uid = :u')
->execute(['b' => $newBody, 's' => $now, 't' => $sessionTenant['id'], 'u' => $uid]);
jsonResponse(['ok' => true, 'updated_at_server' => $now]);
}