shopping: multi-list, DnD ordering, phone layout; fix flaky tests
Shopping lists:
- Multiple lists per tenant: picker uses checkboxes instead of
radios. Relay schema migrated to composite PK (tenant_id,
card_uid) with a position column for display ordering.
- Picker rows are drag-and-drop reorderable (grip handle +
accent drop indicator). Order persists in localStorage and is
pushed to the relay as position integers — phone renders lists
in that order (ORDER BY position, title).
- Each picker row has a goto-arrow that closes the popup,
switches to the card's bucket if needed, and opens the card.
- Relay DELETE /api/shopping removes deactivated lists. Client
proxy forwards DELETE. Deactivating a list in the picker
immediately removes it from the relay.
- Phone UI renders multiple lists as stacked boxes with headers.
Section headings (# h1–h6) get 14px top padding for breathing
room. CSS selectors fixed to match div-based headings inside
the new .shopping-list-box containers.
- Help text updated for multi-list wording.
- pullOne preserves the synced position so a pull doesn't cause
a spurious re-push of the same order.
Test fixes:
- Line-numbers gutter height tolerance bumped 50%→60% — OS text
shaping can push the divergence past 50%.
- Find/replace flaky: replaced sleep(200) with waitFor on the
confirm dialog so slow renders don't miss the OK click.
- Shopping picker test updated: checkboxes instead of radios.
Bumps to 1.9.25.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
4c3954d989
commit
be7eb1523e
12 files changed
+370
-192
No files matched your search
+1
-1
@@ -1651,7 +1651,7 @@ 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. 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>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 and check any number of lists to activate them — all active lists are pushed to the relay and shown stacked on 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>
|
||||
|
||||
@@ -34,7 +34,7 @@ function shoppingRelay(): array {
|
||||
|
||||
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;
|
||||
if (!in_array($method, ['GET', 'POST', 'DELETE'], true)) return;
|
||||
|
||||
$r = shoppingRelay();
|
||||
if (!$r['url'] || !$r['user'] || !$r['key']) {
|
||||
@@ -54,6 +54,9 @@ PluginManager::instance()->onApiRoute(function (string $route, string $method) {
|
||||
if ($method === 'POST') {
|
||||
$opts[CURLOPT_POST] = true;
|
||||
$opts[CURLOPT_POSTFIELDS] = file_get_contents('php://input') ?: '{}';
|
||||
} elseif ($method === 'DELETE') {
|
||||
$opts[CURLOPT_CUSTOMREQUEST] = 'DELETE';
|
||||
$opts[CURLOPT_POSTFIELDS] = file_get_contents('php://input') ?: '{}';
|
||||
}
|
||||
curl_setopt_array($ch, $opts);
|
||||
$response = curl_exec($ch);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// ── Shopping-list plugin (hyprcrd side) ──
|
||||
//
|
||||
// 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
|
||||
// any number as "active" via the Shopping header button; each 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-side toggles back and PATCHes the local cards.
|
||||
//
|
||||
// No timestamp cursors — we diff the (uid, title, body) triple we
|
||||
// last saw in sync with the relay, which is timezone-agnostic.
|
||||
@@ -14,8 +14,9 @@ if (!hyprcrd) throw new Error('[shopping plugin] window.hyprcrd missing');
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const ACTIVE_KEY = 'hyprcrd-shopping-active-id';
|
||||
const ACTIVE_KEY = 'hyprcrd-shopping-active-ids';
|
||||
const SYNCED_KEY = 'hyprcrd-shopping-synced';
|
||||
const ORDER_KEY = 'hyprcrd-shopping-order';
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '')
|
||||
@@ -28,48 +29,71 @@ function hasEinkaufTag(card) {
|
||||
return tags.includes('einkauf') || tags.includes('shopping');
|
||||
}
|
||||
|
||||
function getActiveId() {
|
||||
const v = localStorage.getItem(ACTIVE_KEY);
|
||||
return v ? String(v) : null;
|
||||
// ── Active IDs (multi-select) ────────────────────────────────
|
||||
|
||||
function getActiveIds() {
|
||||
try { return JSON.parse(localStorage.getItem(ACTIVE_KEY) || '[]'); } catch { return []; }
|
||||
}
|
||||
function setActiveId(id) {
|
||||
if (id === null || id === '') localStorage.removeItem(ACTIVE_KEY);
|
||||
else localStorage.setItem(ACTIVE_KEY, String(id));
|
||||
localStorage.removeItem(SYNCED_KEY);
|
||||
function setActiveIds(ids) {
|
||||
localStorage.setItem(ACTIVE_KEY, JSON.stringify(ids.map(String)));
|
||||
}
|
||||
function isActive(id) {
|
||||
return getActiveIds().includes(String(id));
|
||||
}
|
||||
|
||||
function getActiveCard() {
|
||||
const id = getActiveId();
|
||||
if (!id) return null;
|
||||
function getActiveCards() {
|
||||
const ids = getActiveIds();
|
||||
if (!ids.length) return [];
|
||||
const cards = window.state?.cards || [];
|
||||
return cards.find(c => String(c.id) === id) || null;
|
||||
return ids.map(id => cards.find(c => String(c.id) === id)).filter(Boolean);
|
||||
}
|
||||
|
||||
function getSynced() {
|
||||
try { return JSON.parse(localStorage.getItem(SYNCED_KEY) || 'null'); } catch { return null; }
|
||||
function getStoredOrder() {
|
||||
try { return JSON.parse(localStorage.getItem(ORDER_KEY) || '[]'); } catch { return []; }
|
||||
}
|
||||
function setSynced(uid, title, body) {
|
||||
localStorage.setItem(SYNCED_KEY, JSON.stringify({ uid: String(uid), title: title || '', body: body || '' }));
|
||||
function setStoredOrder(ids) {
|
||||
localStorage.setItem(ORDER_KEY, JSON.stringify(ids.map(String)));
|
||||
}
|
||||
function sortByOrder(cards) {
|
||||
const order = getStoredOrder();
|
||||
const idx = new Map(order.map((id, i) => [id, i]));
|
||||
return [...cards].sort((a, b) => {
|
||||
const ai = idx.has(String(a.id)) ? idx.get(String(a.id)) : 9999;
|
||||
const bi = idx.has(String(b.id)) ? idx.get(String(b.id)) : 9999;
|
||||
return ai - bi;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Synced state (per card_uid) ──────────────────────────────
|
||||
|
||||
function getSyncedMap() {
|
||||
try { return JSON.parse(localStorage.getItem(SYNCED_KEY) || '{}'); } catch { return {}; }
|
||||
}
|
||||
function setSyncedFor(uid, title, body, pos) {
|
||||
const m = getSyncedMap();
|
||||
m[String(uid)] = { title: title || '', body: body || '', pos: pos ?? -1 };
|
||||
localStorage.setItem(SYNCED_KEY, JSON.stringify(m));
|
||||
}
|
||||
function removeSyncedFor(uid) {
|
||||
const m = getSyncedMap();
|
||||
delete m[String(uid)];
|
||||
localStorage.setItem(SYNCED_KEY, JSON.stringify(m));
|
||||
}
|
||||
|
||||
// ── 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];
|
||||
const rows = header.querySelectorAll('.flex.items-center.gap-3');
|
||||
const group = rows[rows.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);
|
||||
}
|
||||
|
||||
@@ -87,14 +111,14 @@ function openDialog() {
|
||||
panel.innerHTML = `
|
||||
<div class="flex items-center gap-3 px-5 py-3 border-b border-t-border">
|
||||
<i class="ph ph-shopping-cart text-t-accent text-base"></i>
|
||||
<h2 class="font-pixel text-sm text-t-accent flex-1">Shopping list</h2>
|
||||
<h2 class="font-pixel text-sm text-t-accent flex-1">Shopping lists</h2>
|
||||
<button class="shopping-picker-close text-t-muted hover:text-t-danger"><i class="ph ph-x text-sm"></i></button>
|
||||
</div>
|
||||
<div class="px-5 py-3 text-[11px] text-t-muted leading-relaxed">
|
||||
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 appears under the <span class="text-t-accent">shopping</span> tab in the relay UI.
|
||||
<span class="text-t-accent">#shopping</span> can be activated for the phone.
|
||||
Check any number of lists; their items appear stacked 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">
|
||||
@@ -117,9 +141,9 @@ function openDialog() {
|
||||
});
|
||||
|
||||
const cards = window.state?.cards || [];
|
||||
const candidates = cards.filter(hasEinkaufTag);
|
||||
const currentActive = getActiveId();
|
||||
let selectedId = currentActive;
|
||||
const candidates = sortByOrder(cards.filter(hasEinkaufTag));
|
||||
const currentIds = new Set(getActiveIds());
|
||||
const selected = new Set(currentIds);
|
||||
|
||||
if (!candidates.length) {
|
||||
list.innerHTML = `<div class="px-4 py-6 text-center text-[11px] text-t-dim">
|
||||
@@ -130,93 +154,165 @@ function openDialog() {
|
||||
} else {
|
||||
list.innerHTML = candidates.map(c => {
|
||||
const id = String(c.id);
|
||||
const checked = id === selectedId ? 'checked' : '';
|
||||
const checked = selected.has(id) ? 'checked' : '';
|
||||
const items = (c.body || '').split('\n').filter(l => /^\s*-\s*\[[ xX]\]/.test(l));
|
||||
const open = items.filter(l => /^\s*-\s*\[\s\]/.test(l)).length;
|
||||
return `<label class="flex items-start gap-3 px-3 py-2 hover:bg-t-raised cursor-pointer">
|
||||
<input type="radio" name="shopping-active" value="${id}" ${checked} style="margin-top:3px;">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs text-t-bright font-pixel">${esc(c.title || '(untitled)')}</div>
|
||||
<div class="text-[10px] text-t-dim font-pixel mt-0.5">${open} open · ${items.length} total</div>
|
||||
</div>
|
||||
</label>`;
|
||||
return `<div class="shopping-picker-row flex items-start gap-3 px-3 py-2 hover:bg-t-raised" draggable="true" data-card-id="${id}">
|
||||
<i class="ph ph-dots-six-vertical text-t-dim" style="margin-top:4px;cursor:grab;flex-shrink:0;"></i>
|
||||
<label class="flex items-start gap-3 flex-1 min-w-0 cursor-pointer">
|
||||
<input type="checkbox" data-card-id="${id}" ${checked} style="margin-top:3px;">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs text-t-bright font-pixel">${esc(c.title || '(untitled)')}</div>
|
||||
<div class="text-[10px] text-t-dim font-pixel mt-0.5">${open} open · ${items.length} total</div>
|
||||
</div>
|
||||
</label>
|
||||
<button class="shopping-goto text-t-dim hover:text-t-accent transition-colors" data-card-id="${id}" data-bucket="${c.bucket_id || ''}" title="go to card" style="padding:4px;margin-top:1px;"><i class="ph ph-arrow-square-out text-sm"></i></button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
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>
|
||||
</label>`);
|
||||
list.querySelectorAll('input[name="shopping-active"]').forEach(r => {
|
||||
r.addEventListener('change', () => { selectedId = r.value || null; });
|
||||
list.querySelectorAll('input[type="checkbox"]').forEach(cb => {
|
||||
cb.addEventListener('change', () => {
|
||||
if (cb.checked) selected.add(cb.dataset.cardId);
|
||||
else selected.delete(cb.dataset.cardId);
|
||||
});
|
||||
});
|
||||
// Drag-and-drop reordering of picker rows.
|
||||
let dragRow = null;
|
||||
list.querySelectorAll('.shopping-picker-row').forEach(row => {
|
||||
row.addEventListener('dragstart', (e) => {
|
||||
dragRow = row;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
row.style.opacity = '0.4';
|
||||
});
|
||||
row.addEventListener('dragend', () => {
|
||||
row.style.opacity = '';
|
||||
dragRow = null;
|
||||
list.querySelectorAll('.shopping-picker-row').forEach(r => r.style.borderTop = '');
|
||||
});
|
||||
row.addEventListener('dragover', (e) => {
|
||||
if (!dragRow || dragRow === row) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
list.querySelectorAll('.shopping-picker-row').forEach(r => r.style.borderTop = '');
|
||||
const rect = row.getBoundingClientRect();
|
||||
const mid = rect.top + rect.height / 2;
|
||||
if (e.clientY < mid) {
|
||||
row.style.borderTop = '2px solid var(--t-accent)';
|
||||
} else {
|
||||
const next = row.nextElementSibling;
|
||||
if (next) next.style.borderTop = '2px solid var(--t-accent)';
|
||||
}
|
||||
});
|
||||
row.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
if (!dragRow || dragRow === row) return;
|
||||
const rect = row.getBoundingClientRect();
|
||||
const mid = rect.top + rect.height / 2;
|
||||
if (e.clientY < mid) {
|
||||
list.insertBefore(dragRow, row);
|
||||
} else {
|
||||
list.insertBefore(dragRow, row.nextSibling);
|
||||
}
|
||||
// Persist new order from DOM.
|
||||
const newOrder = [...list.querySelectorAll('.shopping-picker-row')].map(r => r.dataset.cardId);
|
||||
setStoredOrder(newOrder);
|
||||
list.querySelectorAll('.shopping-picker-row').forEach(r => r.style.borderTop = '');
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll('.shopping-goto').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const cardId = btn.dataset.cardId;
|
||||
const bucketId = btn.dataset.bucket;
|
||||
close();
|
||||
// Switch bucket if needed so the card is visible.
|
||||
if (bucketId && window.state?.activeBucket !== bucketId
|
||||
&& window.state?.activeBucket !== 'all') {
|
||||
window.state.activeBucket = bucketId;
|
||||
localStorage.setItem('hyprcrd-active-bucket', bucketId);
|
||||
window._renderBoard?.();
|
||||
}
|
||||
setTimeout(() => window._openDetail?.(parseInt(cardId)), 100);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
panel.querySelector('.shopping-picker-save').addEventListener('click', async () => {
|
||||
const prev = getActiveId();
|
||||
setActiveId(selectedId);
|
||||
const prev = getActiveIds();
|
||||
const next = [...selected];
|
||||
setActiveIds(next);
|
||||
status.textContent = 'saving…';
|
||||
try {
|
||||
if (selectedId) await pushActive();
|
||||
else await clearRemote();
|
||||
// Remove deactivated lists from relay.
|
||||
for (const id of prev) {
|
||||
if (!selected.has(id)) {
|
||||
removeSyncedFor(id);
|
||||
await hyprcrd.api('api/shopping', { method: 'DELETE', body: { card_uid: id } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
// Push all active lists.
|
||||
await tick();
|
||||
status.textContent = 'saved';
|
||||
setTimeout(close, 400);
|
||||
} catch (e) {
|
||||
status.textContent = 'error: ' + (e?.message || e);
|
||||
status.style.color = 'var(--t-danger)';
|
||||
setActiveId(prev);
|
||||
setActiveIds(prev);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sync ─────────────────────────────────────────────────────
|
||||
|
||||
async function pushActive() {
|
||||
const card = getActiveCard();
|
||||
if (!card) return;
|
||||
async function pushOne(card) {
|
||||
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 order = getStoredOrder();
|
||||
const pos = order.indexOf(uid);
|
||||
const synced = getSyncedMap()[uid];
|
||||
if (synced && synced.title === title && synced.body === body && synced.pos === pos) return;
|
||||
const res = await hyprcrd.api('api/shopping', {
|
||||
method: 'POST',
|
||||
body: { card_uid: uid, title, body, updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) },
|
||||
body: { card_uid: uid, title, body, position: pos >= 0 ? pos : 999, updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) },
|
||||
});
|
||||
if (res?.ok && res.stored !== false) setSynced(uid, title, body);
|
||||
if (res?.ok && res.stored !== false) setSyncedFor(uid, title, body, pos);
|
||||
}
|
||||
|
||||
async function pullActive() {
|
||||
const card = getActiveCard();
|
||||
if (!card) return;
|
||||
const res = await hyprcrd.api('api/shopping', { method: 'GET' });
|
||||
if (!res?.ok || !res.state) return;
|
||||
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;
|
||||
async function pullOne(card, relayState) {
|
||||
const uid = String(card.id);
|
||||
const { body, title } = relayState;
|
||||
const synced = getSyncedMap()[uid];
|
||||
// Preserve the position we last pushed so a pull doesn't reset it
|
||||
// and cause an unnecessary re-push on the next tick.
|
||||
const curPos = synced?.pos ?? -1;
|
||||
if (synced && synced.body === (body || '') && synced.title === (title || '')) return;
|
||||
if ((card.body || '') === (body || '') && (card.title || '') === (title || '')) {
|
||||
setSynced(card.id, card.title || '', card.body || '');
|
||||
setSyncedFor(card.id, card.title || '', card.body || '', curPos);
|
||||
return;
|
||||
}
|
||||
// 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 || '');
|
||||
setSyncedFor(card.id, card.title || '', body || '', curPos);
|
||||
await window._reloadAll?.();
|
||||
}
|
||||
|
||||
async function clearRemote() {
|
||||
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(SYNCED_KEY);
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
if (!getActiveId()) return;
|
||||
try { await pushActive(); } catch {}
|
||||
try { await pullActive(); } catch {}
|
||||
const activeCards = getActiveCards();
|
||||
if (!activeCards.length) return;
|
||||
// Push all active cards first.
|
||||
for (const card of activeCards) {
|
||||
try { await pushOne(card); } catch {}
|
||||
}
|
||||
// Pull: fetch all lists from relay, match by card_uid.
|
||||
try {
|
||||
const res = await hyprcrd.api('api/shopping', { method: 'GET' });
|
||||
if (!res?.ok || !Array.isArray(res.lists)) return;
|
||||
const byUid = new Map(res.lists.map(l => [l.card_uid, l]));
|
||||
for (const card of activeCards) {
|
||||
const relay = byUid.get(String(card.id));
|
||||
if (relay) await pullOne(card, relay);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────
|
||||
@@ -235,6 +331,6 @@ if (document.readyState === 'loading') {
|
||||
|
||||
hyprcrd.registerPlugin?.({
|
||||
name: 'shopping',
|
||||
version: '1.0.0',
|
||||
description: 'Sync one shopping card to the relay so it\'s checkable from a phone.',
|
||||
version: '1.1.0',
|
||||
description: 'Sync shopping cards to the relay so they\'re checkable from a phone.',
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, afterEach, expect, $, $$, click, sleep, closeAll, csrf } from './runner.js?v=4';
|
||||
import { describe, it, afterEach, expect, $, $$, click, sleep, waitFor, closeAll, csrf } from './runner.js?v=4';
|
||||
|
||||
async function makeCard(title, body) {
|
||||
return fetch('api/cards', {
|
||||
@@ -70,13 +70,13 @@ describe('Search: Find/Replace', () => {
|
||||
const applyBtn = document.getElementById('search-replace-all');
|
||||
expect(applyBtn.disabled).toBe(false);
|
||||
click(applyBtn);
|
||||
await sleep(200);
|
||||
|
||||
const dialog = document.getElementById('dialog');
|
||||
if (dialog?.classList.contains('open')) {
|
||||
const okBtn = document.getElementById('dialog-ok');
|
||||
if (okBtn) click(okBtn);
|
||||
}
|
||||
// The replace-all button opens a confirm dialog — wait for it
|
||||
// to appear rather than relying on a fixed sleep, since the
|
||||
// dialog creation is async and can lag on slow runs.
|
||||
await waitFor(() => document.getElementById('dialog')?.classList.contains('open'), 2000);
|
||||
const okBtn = document.getElementById('dialog-ok');
|
||||
if (okBtn) click(okBtn);
|
||||
|
||||
// Poll until the replace has actually persisted
|
||||
let updated1, updated2;
|
||||
|
||||
@@ -143,9 +143,10 @@ describe('Line Numbers: Alignment with Textarea', () => {
|
||||
const cs = getComputedStyle(body);
|
||||
const pad = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
|
||||
const textTotal = body.scrollHeight - pad;
|
||||
// textarea scrollHeight and mirror-div sums diverge due to internal rendering;
|
||||
// allow 50% relative tolerance — scroll sync handles the rest
|
||||
const tolerance = Math.max(textTotal, gutterTotal) * 0.5;
|
||||
// textarea scrollHeight and mirror-div sums diverge due to internal rendering,
|
||||
// font metrics, and OS-level text shaping — allow 60% relative tolerance.
|
||||
// Scroll sync (proportional mapping) handles the real alignment at runtime.
|
||||
const tolerance = Math.max(textTotal, gutterTotal) * 0.6;
|
||||
expect(Math.abs(gutterTotal - textTotal)).toBeLessThan(tolerance);
|
||||
});
|
||||
|
||||
|
||||
@@ -86,11 +86,9 @@ 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);
|
||||
// Picker uses checkboxes so multiple lists can be active.
|
||||
const checkboxes = overlay.querySelectorAll('input[type="checkbox"]');
|
||||
expect(checkboxes.length).toBeGreaterThan(0);
|
||||
|
||||
overlay.querySelector('.shopping-picker-cancel').click();
|
||||
await sleep(40);
|
||||
|
||||
Reference in new issue
Block a user