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

+37 -23
View File
@@ -1,10 +1,18 @@
import { describe, it, expect, $, click, sleep, csrf } from './runner.js?v=4';
// Shopping-list tests. The proxy route is always present; without a
// configured relay it just returns {ok:false,error:"relay not
// configured"} rather than 404, so we can still assert shape. The UI
// tests create cards with either trigger tag and verify the picker
// finds them + skips non-tagged cards.
// Shopping-plugin tests. Everything ships through the shopping plugin
// (backend.php registers /api/shopping*, shopping.js injects the
// header button and the picker dialog on load). Without a configured
// relay the backend still returns {ok:false,error:"relay not
// configured"} so we can assert shape.
describe('Shopping: plugin registration', () => {
it('registers itself in window.hyprcrd.plugins', () => {
const entries = window.hyprcrd?.plugins || [];
const entry = entries.find(p => p?.name === 'shopping');
expect(entry).toExist();
});
});
describe('Shopping: API proxy', () => {
it('GET /api/shopping returns an ok field', async () => {
@@ -27,22 +35,25 @@ describe('Shopping: API proxy', () => {
});
});
describe('Shopping: header menu', () => {
it('hamburger button + menu exist; clicking toggles visibility', async () => {
const btn = $('#header-menu-btn');
const menu = $('#header-menu');
describe('Shopping: header button', () => {
it('Shopping button is injected into the header', () => {
const btn = $('#shopping-btn');
expect(btn).toExist();
expect(menu).toExist();
// Make sure we start closed.
menu.classList.add('hidden');
click(btn);
// Lives inside the main header, not bolted onto the body.
expect(btn.closest('#main-header')).toExist();
});
it('clicking opens the picker dialog with save/close controls', async () => {
document.querySelector('.shopping-picker-overlay')?.remove();
click('#shopping-btn');
await sleep(60);
const overlay = document.querySelector('.shopping-picker-overlay');
expect(overlay).toExist();
expect(overlay.querySelector('.shopping-picker-save')).toExist();
expect(overlay.querySelector('.shopping-picker-cancel')).toExist();
overlay.querySelector('.shopping-picker-cancel').click();
await sleep(40);
expect(menu.classList.contains('hidden')).toBe(false);
expect($('#menu-shopping')).toExist();
// Outside-click closer — dispatch a click elsewhere.
document.body.click();
await sleep(40);
expect(menu.classList.contains('hidden')).toBe(true);
expect(document.querySelector('.shopping-picker-overlay')).toBe(null);
});
});
@@ -63,10 +74,7 @@ describe('Shopping: picker tag matching', () => {
// Close any leftover overlay, then open the picker fresh.
document.querySelector('.shopping-picker-overlay')?.remove();
$('#header-menu')?.classList.add('hidden');
click('#header-menu-btn');
await sleep(40);
click('#menu-shopping');
click('#shopping-btn');
await sleep(80);
const overlay = document.querySelector('.shopping-picker-overlay');
@@ -78,6 +86,12 @@ describe('Shopping: picker tag matching', () => {
const controlShown = listText.includes('__test_shop_control__');
expect(controlShown).toBe(false);
// Picker always offers a "no active list" option so the
// user can stop syncing without leaving the dialog.
const radios = overlay.querySelectorAll('input[name="shopping-active"]');
const hasEmpty = [...radios].some(r => r.value === '');
expect(hasEmpty).toBe(true);
overlay.querySelector('.shopping-picker-cancel').click();
await sleep(40);
} finally {