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 activeheader menu → Shopping list → radio select → Save
phone URLhttps://<relay>/shopping — session-auth, mobile layout
pick activeheader Shopping button → radio select → Save
phone accessrelay UI → shopping tab (same login)
sync cadencepiggybacks 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.
@@ -81,11 +112,12 @@ function openDialog() { panel.querySelector('.shopping-picker-close').addEventListener('click', close); panel.querySelector('.shopping-picker-cancel').addEventListener('click', close); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); - document.addEventListener('keydown', function esc(e) { - if (e.key === 'Escape') { close(); document.removeEventListener('keydown', esc); } + document.addEventListener('keydown', function escClose(e) { + if (e.key === 'Escape') { close(); document.removeEventListener('keydown', escClose); } }); - const candidates = state.cards.filter(hasEinkaufTag); + const cards = window.state?.cards || []; + const candidates = cards.filter(hasEinkaufTag); const currentActive = getActiveId(); let selectedId = currentActive; @@ -109,7 +141,6 @@ function openDialog() {
`; }).join(''); - // "No active" option — explicit way to stop syncing. list.insertAdjacentHTML('beforeend', `