From d052fb5f9d554dba3e81af0bda56545d6dd81071 Mon Sep 17 00:00:00 2001
From: th
Date: Thu, 16 Apr 2026 01:24:58 +0200
Subject: [PATCH] shopping: ship as plugin, integrate phone view into relay UI,
fix sync
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
config.php | 2 +-
electron/package.json | 2 +-
public/index.php | 50 +----
public/js/app.js | 2 -
public/plugins/shopping/backend.php | 67 +++++++
public/plugins/shopping/plugin.json | 10 +
public/plugins/shopping/shopping.css | 6 +
.../modules => plugins/shopping}/shopping.js | 178 ++++++++++--------
public/plugins/tests/shopping.test.js | 60 +++---
relay/inc/routes/shopping.php | 174 ++---------------
relay/index.php | 4 -
relay/ui/app.js | 134 ++++++++++++-
relay/ui/layout.php | 16 ++
relay/ui/style.css | 37 ++++
14 files changed, 417 insertions(+), 325 deletions(-)
create mode 100644 public/plugins/shopping/backend.php
create mode 100644 public/plugins/shopping/plugin.json
create mode 100644 public/plugins/shopping/shopping.css
rename public/{js/modules => plugins/shopping}/shopping.js (56%)
diff --git a/config.php b/config.php
index 29909fb..8b10e1b 100644
--- a/config.php
+++ b/config.php
@@ -1,7 +1,7 @@
query("SELECT value FROM settings WHERE key = 'relay_url'")->fetchColumn();
- $relayKey = $db->query("SELECT value FROM settings WHERE key = 'relay_key'")->fetchColumn();
- $relayUser = $db->query("SELECT value FROM settings WHERE key = 'relay_username'")->fetchColumn();
- if (!$relayUrl || !$relayKey || !$relayUser) {
- echo json_encode(['ok' => false, 'error' => 'relay not configured']); exit;
- }
- $ch = curl_init(rtrim($relayUrl, '/') . $route);
- $opts = [
- CURLOPT_RETURNTRANSFER => true,
- CURLOPT_HTTPHEADER => [
- 'Content-Type: application/json',
- 'X-Relay-User: ' . $relayUser,
- 'X-Relay-Key: ' . $relayKey,
- ],
- 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);
- echo is_string($response) ? $response : json_encode(['ok' => false, 'error' => 'relay unreachable']);
- exit;
- }
-
http_response_code(404); echo json_encode(['error' => 'not found']); exit;
}
@@ -1357,14 +1323,6 @@ if (str_starts_with($route, '/api/')) {
-
-
-
-
-
-
@@ -1693,14 +1651,14 @@ if (str_starts_with($route, '/api/')) {
shopping list (phone sync)
-
Tag a card with #einkauf or #shopping to mark it as a shopping-list candidate. Open the header's menu → Shopping list to pick which one is the currently active list — only one at a time is mirrored to the phone.
-
The active card's body is pushed to the relay on every sync tick. On your phone, open <your-relay>/shopping and log in with your relay credentials. The page shows only the unchecked- [ ] items as tappable rows. Tapping an item flips it to - [x] on the relay in-place; the next client tick pulls that back and updates the local card.
+
Tag a card with #einkauf or #shopping to mark it as a shopping-list candidate. Click the header's Shopping button to pick which one is the currently active list — only one at a time is mirrored to the phone.
+
The active card's body is pushed to the relay on every sync tick. On your phone, log into the relay UI (same credentials as everywhere else) and open shopping from the menu. You see only the unchecked- [ ] items as tappable rows. Tapping an item flips it to - [x] on the relay in-place; the next client tick pulls that back and updates the local card.
Editing stays in hyprcrd — the phone can only check/uncheck, never change text. That keeps conflict handling trivial (last-writer wins by timestamp).
trigger tags
#einkauf or #shopping — either counts
-
pick active
header menu → Shopping list → radio select → Save
-
phone URL
https://<relay>/shopping — session-auth, mobile layout
+
pick active
header Shopping button → radio select → Save
+
phone access
relay UI → shopping tab (same login)
sync cadence
piggybacks on the shared sync tick (see Settings → Relay)
diff --git a/public/js/app.js b/public/js/app.js
index 15ed114..094620b 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -21,7 +21,6 @@ import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar
import { loadSettings as loadCaldavSettings } from './modules/caldav.js';
import { startBackgroundSync, triggerSync, loadRelaySettings } from './modules/sync.js';
import { openPluginSettings } from './modules/plugin-settings.js';
-import { initShopping } from './modules/shopping.js';
// ── Global API (for inline onclick handlers and cross-module calls) ──
@@ -218,7 +217,6 @@ async function checkAuth() {
loadBuckets();
loadCards();
startBackgroundSync();
- initShopping();
}
// ── Keyboard shortcuts (central registry) ──
diff --git a/public/plugins/shopping/backend.php b/public/plugins/shopping/backend.php
new file mode 100644
index 0000000..825d054
--- /dev/null
+++ b/public/plugins/shopping/backend.php
@@ -0,0 +1,67 @@
+ $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;
+});
diff --git a/public/plugins/shopping/plugin.json b/public/plugins/shopping/plugin.json
new file mode 100644
index 0000000..e02f4da
--- /dev/null
+++ b/public/plugins/shopping/plugin.json
@@ -0,0 +1,10 @@
+{
+ "name": "shopping",
+ "version": "1.0.0",
+ "description": "Ship one shopping card at a time to the relay so it's checkable from your phone. hyprcrd stays the edit surface; phone only toggles.",
+ "frontend": {
+ "js": ["shopping.js"],
+ "css": ["shopping.css"]
+ },
+ "backend": "backend.php"
+}
diff --git a/public/plugins/shopping/shopping.css b/public/plugins/shopping/shopping.css
new file mode 100644
index 0000000..4c3613c
--- /dev/null
+++ b/public/plugins/shopping/shopping.css
@@ -0,0 +1,6 @@
+/* Shopping plugin styles. Most of the picker dialog leans on
+ existing Tailwind tokens (text-t-accent, border-t-border, etc.)
+ so there's little needed here — only things that can't be
+ expressed inline on dynamically-created nodes. */
+
+.shopping-picker-overlay { font-family: var(--t-font-mono, inherit); }
diff --git a/public/js/modules/shopping.js b/public/plugins/shopping/shopping.js
similarity index 56%
rename from public/js/modules/shopping.js
rename to public/plugins/shopping/shopping.js
index 57c81ce..301e10f 100644
--- a/public/js/modules/shopping.js
+++ b/public/plugins/shopping/shopping.js
@@ -1,22 +1,27 @@
-// ── Shopping list (phone-synced card) ──
+// ── Shopping-list plugin (hyprcrd side) ──
//
-// One card flagged as #einkauf can be "active" at a time. Its body
-// is pushed to the relay on every sync.tick; the relay serves a
-// phone-friendly checklist at /shopping. Phone taps toggle items in
-// place on the relay; hyprcrd pulls those toggles back on the next
-// tick and patches the local card.
+// Tag a card with #einkauf or #shopping to make it eligible. Pick
+// one as "active" via the Shopping header button; the active card's
+// body is pushed to the relay on every sync.tick and on every
+// cards.reloaded so edits land on the phone within seconds. Pulls
+// phone-side toggles back and PATCHes the local card.
//
-// Phone never changes item text — only check state. Text edits stay
-// in hyprcrd, which keeps conflict resolution trivial (last-writer
-// wins by timestamp; near-simultaneous edits are extremely unlikely
-// for a shopping list).
+// No timestamp cursors — we diff the (uid, title, body) triple we
+// last saw in sync with the relay, which is timezone-agnostic.
-import { $, api, esc, getCsrf } from './util.js';
-import { state } from './state.js';
+const hyprcrd = window.hyprcrd;
+if (!hyprcrd) throw new Error('[shopping plugin] window.hyprcrd missing');
+
+const $ = (id) => document.getElementById(id);
const ACTIVE_KEY = 'hyprcrd-shopping-active-id';
-const LAST_PUSHED_KEY = 'hyprcrd-shopping-last-pushed-ts';
-const LAST_SERVER_KEY = 'hyprcrd-shopping-last-server-ts';
+const SYNCED_KEY = 'hyprcrd-shopping-synced';
+
+function esc(s) {
+ return String(s ?? '')
+ .replace(/&/g, '&').replace(//g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+}
function hasEinkaufTag(card) {
const tags = (card.tags || '').split(',').map(s => s.trim());
@@ -27,22 +32,48 @@ function getActiveId() {
const v = localStorage.getItem(ACTIVE_KEY);
return v ? String(v) : null;
}
-
function setActiveId(id) {
if (id === null || id === '') localStorage.removeItem(ACTIVE_KEY);
else localStorage.setItem(ACTIVE_KEY, String(id));
- // Reset timestamp cursors so the switch pushes fresh on next tick.
- localStorage.removeItem(LAST_PUSHED_KEY);
- localStorage.removeItem(LAST_SERVER_KEY);
+ localStorage.removeItem(SYNCED_KEY);
}
function getActiveCard() {
const id = getActiveId();
if (!id) return null;
- return state.cards.find(c => String(c.id) === id) || null;
+ const cards = window.state?.cards || [];
+ return cards.find(c => String(c.id) === id) || null;
}
-// ── Dialog ────────────────────────────────────────────────────────
+function getSynced() {
+ try { return JSON.parse(localStorage.getItem(SYNCED_KEY) || 'null'); } catch { return null; }
+}
+function setSynced(uid, title, body) {
+ localStorage.setItem(SYNCED_KEY, JSON.stringify({ uid: String(uid), title: title || '', body: body || '' }));
+}
+
+// ── Header button injection ──────────────────────────────────
+
+function injectHeaderButton() {
+ if ($('shopping-btn')) return;
+ // Put it next to Calendar. The header's right-hand button row
+ // lives in `header > div:last-child > .flex.items-center.gap-3`.
+ const header = document.querySelector('header#main-header');
+ if (!header) return;
+ const rightRow = header.querySelectorAll('.flex.items-center.gap-3');
+ const group = rightRow[rightRow.length - 1];
+ if (!group) return;
+ const btn = document.createElement('button');
+ btn.id = 'shopping-btn';
+ btn.className = 'flex items-center gap-2 px-2.5 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors';
+ btn.innerHTML = 'Shopping';
+ btn.addEventListener('click', openDialog);
+ // Insert as the first item of the right-hand group so the order
+ // reads Shopping, Calendar, Assets, Tasks, New.
+ group.insertBefore(btn, group.firstChild);
+}
+
+// ── Picker dialog ────────────────────────────────────────────
function openDialog() {
document.querySelector('.shopping-picker-overlay')?.remove();
@@ -63,7 +94,7 @@ function openDialog() {
Cards tagged with #einkauf or
#shopping can be set as the active list for the
phone. Only one is active at a time; its body is pushed to the relay on every sync
- and shown at /shopping.
+ and appears under the shopping tab in the relay UI.