prepare('SELECT card_uid, title, body, updated_at_client, updated_at_server FROM shopping_state WHERE tenant_id = :t'); $stmt->execute(['t' => $tenantId]); $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'] ?? ''); $clientTs = trim((string)($input['updated_at'] ?? '')) ?: date('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'); $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, title = excluded.title, body = excluded.body, updated_at_client = excluded.updated_at_client, updated_at_server = excluded.updated_at_server ')->execute([ 't' => $clientTenant['id'], 'u' => $uid, 'ti' => $title, 'b' => $body, '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. 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]); } // ── 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) ?: []; $line = (string)($input['line'] ?? ''); $checked = !empty($input['checked']); if ($line === '') jsonResponse(['error' => 'line required'], 400); $state = shoppingLoad($db, (int)$sessionTenant['id']); if (!$state) jsonResponse(['error' => 'no active list'], 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; // not a checkbox line $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 = date('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); ?> Shopping list

Shopping list

…