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:
1 parent
b9d610b612
commit
d052fb5f9d
14 files changed
+417
-325
No files matched your search
+4
-46
@@ -1148,40 +1148,6 @@ if (str_starts_with($route, '/api/')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// ── Shopping list (proxy to relay) ───────────────────────────
|
||||
// Thin forwarder. The client (shopping.js) talks to us, we relay
|
||||
// to /api/shopping with the configured X-Relay-User/-Key headers.
|
||||
// Phone UI talks to the relay directly via session auth.
|
||||
if (($route === '/api/shopping' || $route === '/api/shopping/check') && in_array($method, ['GET', 'POST'])) {
|
||||
$relayUrl = $db->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/')) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative" id="header-menu-wrap">
|
||||
<button id="header-menu-btn" class="flex items-center gap-1 px-2 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors" data-tip="menu">
|
||||
<i class="ph ph-list-dashes text-sm"></i>
|
||||
</button>
|
||||
<div id="header-menu" class="hidden absolute right-0 mt-1 min-w-[180px] bg-t-surface border border-t-border shadow-2xl z-50">
|
||||
<button id="menu-shopping" class="w-full text-left px-3 py-2 text-xs text-t-muted hover:bg-t-raised hover:text-t-accent transition-colors flex items-center gap-2"><i class="ph ph-shopping-cart text-sm"></i><span class="font-pixel text-[10px]">Shopping list</span></button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="calendar-btn" class="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">
|
||||
<i class="ph ph-calendar-dots text-sm"></i><span class="font-pixel text-[10px]">Calendar</span>
|
||||
</button>
|
||||
@@ -1693,14 +1651,14 @@ if (str_starts_with($route, '/api/')) {
|
||||
<section>
|
||||
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-shopping-cart text-sm"></i>shopping list (phone sync)</h3>
|
||||
<div class="text-xs leading-relaxed space-y-3 text-t-muted mb-3">
|
||||
<p>Tag a card with <span class="text-t-accent">#einkauf</span> or <span class="text-t-accent">#shopping</span> to mark it as a shopping-list candidate. Open the header's <i class="ph ph-list-dashes text-xs"></i> menu → <span class="text-t-bright font-pixel">Shopping list</span> to pick which one is the currently <span class="text-t-bright">active</span> list — only one at a time is mirrored to the phone.</p>
|
||||
<p>The active card's body is pushed to the relay on every sync tick. On your phone, open <span class="text-t-accent"><your-relay>/shopping</span> and log in with your relay credentials. The page shows only the <span class="text-t-bright">unchecked</span> <code class="text-t-accent">- [ ]</code> items as tappable rows. Tapping an item flips it to <code class="text-t-accent">- [x]</code> on the relay in-place; the next client tick pulls that back and updates the local card.</p>
|
||||
<p>Tag a card with <span class="text-t-accent">#einkauf</span> or <span class="text-t-accent">#shopping</span> to mark it as a shopping-list candidate. Click the header's <span class="text-t-bright font-pixel">Shopping</span> button to pick which one is the currently <span class="text-t-bright">active</span> list — only one at a time is mirrored to the phone.</p>
|
||||
<p>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 <span class="text-t-bright font-pixel">shopping</span> from the menu. You see only the <span class="text-t-bright">unchecked</span> <code class="text-t-accent">- [ ]</code> items as tappable rows. Tapping an item flips it to <code class="text-t-accent">- [x]</code> on the relay in-place; the next client tick pulls that back and updates the local card.</p>
|
||||
<p>Editing stays in hyprcrd — the phone can only check/uncheck, never change text. That keeps conflict handling trivial (last-writer wins by timestamp).</p>
|
||||
</div>
|
||||
<table class="w-full text-xs">
|
||||
<tr><td class="py-1 text-t-dim w-40 font-pixel"><i class="ph ph-hash text-xs"></i> trigger tags</td><td><span class="text-t-accent">#einkauf</span> or <span class="text-t-accent">#shopping</span> — either counts</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-list-dashes text-xs"></i> pick active</td><td>header menu → Shopping list → radio select → Save</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-device-mobile text-xs"></i> phone URL</td><td><code>https://<relay>/shopping</code> — session-auth, mobile layout</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-shopping-cart text-xs"></i> pick active</td><td>header Shopping button → radio select → Save</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-device-mobile text-xs"></i> phone access</td><td>relay UI → shopping tab (same login)</td></tr>
|
||||
<tr><td class="py-1 text-t-dim font-pixel"><i class="ph ph-arrows-clockwise text-xs"></i> sync cadence</td><td>piggybacks on the shared sync tick (see Settings → Relay)</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
@@ -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) ──
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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, '"').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 = '<i class="ph ph-shopping-cart text-sm"></i><span class="font-pixel text-[10px]">Shopping</span>';
|
||||
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 <span class="text-t-accent">#einkauf</span> or
|
||||
<span class="text-t-accent">#shopping</span> 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 <span class="text-t-accent">/shopping</span>.
|
||||
and appears under the <span class="text-t-accent">shopping</span> tab in the relay UI.
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto px-2 py-2" id="shopping-picker-list"></div>
|
||||
<div class="flex items-center gap-2 px-5 py-3 border-t border-t-border">
|
||||
@@ -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() {
|
||||
</div>
|
||||
</label>`;
|
||||
}).join('');
|
||||
// "No active" option — explicit way to stop syncing.
|
||||
list.insertAdjacentHTML('beforeend', `<label class="flex items-center gap-3 px-3 py-2 border-t border-t-border hover:bg-t-raised cursor-pointer mt-2">
|
||||
<input type="radio" name="shopping-active" value="" ${currentActive ? '' : 'checked'} style="margin-top:0;">
|
||||
<span class="text-[11px] text-t-muted italic">(no active list)</span>
|
||||
@@ -136,93 +167,74 @@ function openDialog() {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sync ───────────────────────────────────────────────────────
|
||||
// ── Sync ─────────────────────────────────────────────────────
|
||||
|
||||
async function pushActive() {
|
||||
const card = getActiveCard();
|
||||
if (!card) return;
|
||||
const localTs = card.updated_at || new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||
const lastPushed = localStorage.getItem(LAST_PUSHED_KEY);
|
||||
if (lastPushed && lastPushed >= localTs) return; // nothing newer
|
||||
const res = await api('api/shopping', {
|
||||
const uid = String(card.id);
|
||||
const title = card.title || '';
|
||||
const body = card.body || '';
|
||||
const synced = getSynced();
|
||||
if (synced && synced.uid === uid && synced.title === title && synced.body === body) return;
|
||||
const res = await hyprcrd.api('api/shopping', {
|
||||
method: 'POST',
|
||||
body: { card_uid: String(card.id), title: card.title || '', body: card.body || '', updated_at: localTs },
|
||||
body: { card_uid: uid, title, body, updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) },
|
||||
});
|
||||
if (res?.ok) {
|
||||
localStorage.setItem(LAST_PUSHED_KEY, localTs);
|
||||
if (res.updated_at_server) localStorage.setItem(LAST_SERVER_KEY, res.updated_at_server);
|
||||
}
|
||||
if (res?.ok && res.stored !== false) setSynced(uid, title, body);
|
||||
}
|
||||
|
||||
async function pullActive() {
|
||||
const card = getActiveCard();
|
||||
if (!card) return;
|
||||
const res = await api('api/shopping', { method: 'GET' });
|
||||
const res = await hyprcrd.api('api/shopping', { method: 'GET' });
|
||||
if (!res?.ok || !res.state) return;
|
||||
const { card_uid, body, updated_at_server } = res.state;
|
||||
if (String(card_uid) !== String(card.id)) return; // relay still has a previous list
|
||||
const seen = localStorage.getItem(LAST_SERVER_KEY);
|
||||
if (seen && seen >= updated_at_server) return; // no news
|
||||
if ((body || '') === (card.body || '')) {
|
||||
localStorage.setItem(LAST_SERVER_KEY, updated_at_server);
|
||||
const { card_uid, body, title } = res.state;
|
||||
if (String(card_uid) !== String(card.id)) return;
|
||||
const synced = getSynced();
|
||||
if (synced && synced.uid === String(card.id) && synced.body === (body || '') && synced.title === (title || '')) return;
|
||||
if ((card.body || '') === (body || '') && (card.title || '') === (title || '')) {
|
||||
setSynced(card.id, card.title || '', card.body || '');
|
||||
return;
|
||||
}
|
||||
// Phone toggled something — patch the local card.
|
||||
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body } });
|
||||
localStorage.setItem(LAST_SERVER_KEY, updated_at_server);
|
||||
localStorage.setItem(LAST_PUSHED_KEY, updated_at_server); // suppress immediate re-push
|
||||
// Relay differs from both the sync marker and the local card — phone
|
||||
// toggled. Phone can only flip checkboxes (never edit text), so the
|
||||
// relay body is authoritative for this tick.
|
||||
await hyprcrd.api(`api/cards/${card.id}`, { method: 'PATCH', body: { body } });
|
||||
setSynced(card.id, card.title || '', body || '');
|
||||
await window._reloadAll?.();
|
||||
}
|
||||
|
||||
async function clearRemote() {
|
||||
// No DELETE endpoint — send an empty list that "displaces" the
|
||||
// previous active card on the relay so the phone sees nothing.
|
||||
await api('api/shopping', {
|
||||
await hyprcrd.api('api/shopping', {
|
||||
method: 'POST',
|
||||
body: { card_uid: '__inactive__', title: '', body: '', updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) },
|
||||
});
|
||||
localStorage.removeItem(LAST_PUSHED_KEY);
|
||||
localStorage.removeItem(LAST_SERVER_KEY);
|
||||
localStorage.removeItem(SYNCED_KEY);
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
if (!getActiveId()) return;
|
||||
// Pull first so phone toggles land before we overwrite with a push.
|
||||
try { await pullActive(); } catch {}
|
||||
try { await pushActive(); } catch {}
|
||||
try { await pullActive(); } catch {}
|
||||
}
|
||||
|
||||
// ── Hamburger menu wiring ─────────────────────────────────────
|
||||
// ── Init ─────────────────────────────────────────────────────
|
||||
|
||||
function initMenu() {
|
||||
const btn = $('header-menu-btn');
|
||||
const menu = $('header-menu');
|
||||
if (!btn || !menu) return;
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
menu.classList.toggle('hidden');
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!menu.contains(e.target) && e.target !== btn) menu.classList.add('hidden');
|
||||
});
|
||||
$('menu-shopping')?.addEventListener('click', () => {
|
||||
menu.classList.add('hidden');
|
||||
openDialog();
|
||||
});
|
||||
function init() {
|
||||
injectHeaderButton();
|
||||
hyprcrd.hooks?.on?.('sync.tick', () => { tick().catch(() => {}); });
|
||||
hyprcrd.hooks?.on?.('cards.reloaded', () => { tick().catch(() => {}); });
|
||||
}
|
||||
|
||||
export function initShopping() {
|
||||
initMenu();
|
||||
// Hook into the shared sync heartbeat so push/pull happens on the
|
||||
// same cadence as every other poller — one timer rules them all.
|
||||
window.hyprcrd?.hooks?.on?.('sync.tick', () => { tick().catch(() => {}); });
|
||||
// Also push right after a save of the active card so the phone
|
||||
// sees edits without waiting for the next tick.
|
||||
window.addEventListener('hyprcrd:editor-close', (e) => {
|
||||
const active = getActiveId();
|
||||
if (active && String(e?.detail?.cardId) === active) {
|
||||
// Small delay so state.cards has the post-save body.
|
||||
setTimeout(() => tick().catch(() => {}), 300);
|
||||
}
|
||||
});
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
hyprcrd.registerPlugin?.({
|
||||
name: 'shopping',
|
||||
version: '1.0.0',
|
||||
description: 'Sync one shopping card to the relay so it\'s checkable from a phone.',
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user