shopping: ship as plugin, integrate phone view into relay UI, fix sync

Restructure the shopping-list feature end to end:

  - New public/plugins/shopping/ plugin with backend.php (proxy
    routes registered via PluginManager::onApiRoute), shopping.js
    (self-contained; injects the header Shopping button, owns the
    picker dialog + sync loop via hyprcrd.hooks), plugin.json and a
    placeholder css file. Core no longer knows about shopping —
    index.php loses the static button and the proxy block, app.js
    loses the initShopping wiring, the old js/modules/shopping.js
    is gone.
  - Phone view moves out of a standalone /shopping page and into
    the relay UI as a regular `view-shopping` behind a nav-burger
    entry (relay/ui/layout.php + app.js + style.css). Same session
    auth as compose/chat/account — no separate login. Auto-refresh
    every 4 s while the view is active; no manual refresh button.
    Headings "# h1 … ###### h6" render between items for grouping.
    Orphan section headings (no open items under them) are hidden.
  - Sync: drop timestamp cursors (broke across SQLite-UTC / PHP
    timezones and silently blocked pushes after a pull). Both sides
    diff the (uid, title, body) triple instead — timezone-agnostic
    and authoritative. Client ticks push-before-pull so fresh local
    edits land on the relay before a stale pull can overwrite them.
    Relay drops its "server newer" guard and stamps server times
    with gmdate so anything left comparing to SQLite is UTC.
  - Client also ticks on cards.reloaded, not just sync.tick, so
    any edit path (editor save, checkbox toggle, context menu,
    undo) pushes the update immediately.
  - Tests: updated for the plugin (still uses the same selectors
    because the plugin injects the same ids), plus a plugin-
    registration check and "(no active list)" option assertion.
  - Help section copy updated — references the Shopping header
    button and the shopping tab in the relay UI rather than the
    gone /shopping URL.

Both #einkauf and #shopping work as trigger tags; phone is
check-only, editing stays in hyprcrd. Version bumped to 1.9.21.

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 01:24:58 +02:00
1 parent b9d610b612
commit d052fb5f9d
14 files changed
+417 -325

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
<?php
/**
* Shopping Plugin: backend.
*
* Thin forwarder from the client to the relay's /api/shopping*
* endpoints. The phone-side view is part of the relay UI; this
* plugin just pipes hyprcrd's push/pull through the configured
* relay credentials.
*
* Routes:
* GET /api/shopping — pull active list state from relay
* POST /api/shopping — push active card {uid,title,body}
* POST /api/shopping/check — (mostly phone-only; proxied for
* completeness/tests — relay will
* 401 for client-auth callers)
*/
function shoppingJson($data, int $code = 200): void {
http_response_code($code);
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
function shoppingRelay(): array {
$db = Database::connect();
return [
'url' => $db->query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn() ?: '',
'user' => $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn() ?: '',
'key' => $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn() ?: '',
];
}
PluginManager::instance()->onApiRoute(function (string $route, string $method) {
if ($route !== '/api/shopping' && $route !== '/api/shopping/check') return;
if (!in_array($method, ['GET', 'POST'], true)) return;
$r = shoppingRelay();
if (!$r['url'] || !$r['user'] || !$r['key']) {
shoppingJson(['ok' => false, 'error' => 'relay not configured']);
}
$ch = curl_init(rtrim($r['url'], '/') . $route);
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-Relay-User: ' . $r['user'],
'X-Relay-Key: ' . $r['key'],
],
CURLOPT_TIMEOUT => 10,
];
if ($method === 'POST') {
$opts[CURLOPT_POST] = true;
$opts[CURLOPT_POSTFIELDS] = file_get_contents('php://input') ?: '{}';
}
curl_setopt_array($ch, $opts);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
http_response_code($status ?: 502);
header('Content-Type: application/json');
echo is_string($response) ? $response : json_encode(['ok' => false, 'error' => 'relay unreachable']);
exit;
});