Compare commits

...
7 Commits
Author SHA1 Message Date
thandClaude Opus 4.6 be28080439 docs: website update plan as website/TODO.md
App audit (current 1.9.21 vs. site footer 1.3.3) found the site
~14 releases behind on content and missing whole feature areas.
Jotted the gap list and a prioritised plan so we can step through
it rather than redoing the survey each time. P0 (critical
freshness + Calendar/Relay rewrite + plugin details), P1 (gallery
+ workflows + real CTA targets), P2 (demo GIF, keyboard ref,
theming preview, changelog box, comparison table). "Not doing"
list at the bottom records the deliberate exclusions so we don't
re-litigate them later.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 01:31:03 +02:00
thandClaude Opus 4.6 c07340a3ec relay: consistent 16px gap between compose-sidebar sections on mobile
#compose-sidebar only had its flex-column + gap:16px declaration
inside the desktop media query, so on mobile the inbox/shared/sent
cards stacked as plain blocks with no deliberate spacing. Move the
rule to base CSS so the rhythm between inbox → shared → sent
matches the compose → inbox gap regardless of viewport width.
Desktop re-declares it next to the grid layout for clarity; the
duplication is harmless.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 01:30:54 +02:00
thandClaude Opus 4.6 d052fb5f9d 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>
2026-04-16 01:24:58 +02:00
thandClaude Opus 4.6 b9d610b612 shopping: UI strings all English
Picker dialog, hamburger menu entry, phone page and help section
still had German labels from the original request ("Einkaufsliste",
"keine aktive Liste", etc.). The rest of the app's UI is English,
so align everything here: menu entry is "Shopping list", dialog +
phone page copy translated, help references match. Trigger tags
stay as-is — both #einkauf and #shopping keep working so users with
either convention aren't disrupted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 00:48:05 +02:00
thandClaude Opus 4.6 d925070a36 fix(tests): shopping proxy-check test tolerates session-auth 401
/api/shopping/check is session-auth on the relay (phone-only), so
hitting it via the client proxy with client-auth credentials returns
{error: "unauthorized"} instead of a response with an ok field. The
test was over-specific — relax to either `ok:boolean` (relay not
configured, proxy's own fallback) or `error:string` (relay rejected).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 00:43:14 +02:00
thandClaude Opus 4.6 e086db0c2c shopping: #shopping tag, help section, tests, client-side proxy
Three follow-ups:

  - Accept #shopping as an alias for #einkauf. Either tag now makes
    a card show up in the picker; the help blurb in the dialog says
    so explicitly.
  - Add a proper help-overlay section covering the trigger tags,
    how to pick an active list, the phone URL, sync cadence, and
    why phone is check-only.
  - Add a client-side proxy in public/index.php for /api/shopping
    and /api/shopping/check — shopping.js was calling api('api/...')
    which went to hyprcrd, but only the relay had handlers. Without
    this forwarder, push/pull just 404'd. Proxy mirrors the pattern
    used by /api/relay/push.
  - New shopping.test.js: asserts the proxy returns a JSON shape
    with `ok` (works whether or not a relay is configured), the
    hamburger menu toggles as expected, and the picker lists both
    #einkauf + #shopping cards while excluding untagged ones.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 00:35:52 +02:00
thandClaude Opus 4.6 670f125627 shopping list: phone-synced card via relay
Pick one #einkauf-tagged card as "active"; its body is pushed to the
relay on every sync.tick and rendered on a phone-friendly session-
auth'd page at /shopping. Phone shows only the open "- [ ]" items as
tappable rows — tapping flips the line to "- [x]" on the relay in
place. The next client tick pulls those toggles back and PATCHes the
local card, so hyprcrd stays the source of truth for item text.

  - Relay: shopping_state table (one row per tenant), POST/GET
    /api/shopping for content push/pull, POST /api/shopping/check
    for phone toggles, GET /shopping for the phone UI.
  - Client: hamburger menu in the header with an "Einkaufsliste"
    entry that opens a picker listing all #einkauf cards with a
    radio for the active one. Timestamp cursors in localStorage
    keep push/pull idempotent.
  - Phone can only toggle, not edit text — edit stays in hyprcrd,
    which makes the merge trivial (last-writer wins by timestamp).

Multi-card support is there (any card with #einkauf is a candidate)
but only one is active for the phone at a time, per the spec.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 00:32:42 +02:00
16 changed files with 835 additions and 4 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
<?php <?php
define('APP_NAME', 'hyprcrd'); define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '1.9.17'); define('APP_VERSION', '1.9.21');
// Database path — writable directory, outside webroot // Database path — writable directory, outside webroot
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation // DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hyprcrd", "name": "hyprcrd",
"version": "1.9.17", "version": "1.9.21",
"description": "hyprcrd — self-hosted card manager", "description": "hyprcrd — self-hosted card manager",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
+15
View File
@@ -1648,6 +1648,21 @@ if (str_starts_with($route, '/api/')) {
</table> </table>
</section> </section>
<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>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-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>
<section> <section>
<h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-download-simple text-sm"></i>import / export</h3> <h3 class="font-pixel text-xs text-t-accent mb-3 flex items-center gap-2"><i class="ph ph-download-simple text-sm"></i>import / export</h3>
<table class="w-full text-xs"> <table class="w-full text-xs">
+67
View File
@@ -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;
});
+10
View File
@@ -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"
}
+6
View File
@@ -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); }
+240
View File
@@ -0,0 +1,240 @@
// ── 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
// cards.reloaded so edits land on the phone within seconds. Pulls
// phone-side toggles back and PATCHes the local card.
//
// No timestamp cursors — we diff the (uid, title, body) triple we
// last saw in sync with the relay, which is timezone-agnostic.
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 SYNCED_KEY = 'hyprcrd-shopping-synced';
function esc(s) {
return String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function hasEinkaufTag(card) {
const tags = (card.tags || '').split(',').map(s => s.trim());
return tags.includes('einkauf') || tags.includes('shopping');
}
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));
localStorage.removeItem(SYNCED_KEY);
}
function getActiveCard() {
const id = getActiveId();
if (!id) return null;
const cards = window.state?.cards || [];
return cards.find(c => String(c.id) === id) || null;
}
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();
const overlay = document.createElement('div');
overlay.className = 'shopping-picker-overlay';
overlay.style.cssText = 'position:fixed;inset:0;background:var(--t-overlay,rgba(0,0,0,0.6));z-index:200;display:flex;align-items:center;justify-content:center;padding:24px;';
const panel = document.createElement('div');
panel.style.cssText = 'background:var(--t-surface);border:1px solid var(--t-border);box-shadow:0 8px 32px rgba(0,0,0,0.6);width:100%;max-width:520px;max-height:80vh;display:flex;flex-direction:column;';
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>
<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.
</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">
<span id="shopping-picker-status" class="font-pixel text-[10px] text-t-dim flex-1"></span>
<button class="shopping-picker-cancel px-3 py-1.5 text-xs border border-t-border text-t-muted hover:border-t-danger hover:text-t-danger"><span class="font-pixel text-[10px]">Close</span></button>
<button class="shopping-picker-save px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent/10"><span class="font-pixel text-[10px]">Save</span></button>
</div>
`;
overlay.appendChild(panel);
document.body.appendChild(overlay);
const list = panel.querySelector('#shopping-picker-list');
const status = panel.querySelector('#shopping-picker-status');
const close = () => overlay.remove();
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 escClose(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', escClose); }
});
const cards = window.state?.cards || [];
const candidates = cards.filter(hasEinkaufTag);
const currentActive = getActiveId();
let selectedId = currentActive;
if (!candidates.length) {
list.innerHTML = `<div class="px-4 py-6 text-center text-[11px] text-t-dim">
No cards tagged <span class="text-t-accent">#einkauf</span> or
<span class="text-t-accent">#shopping</span> yet. Create a card with checkbox items
and add one of those tags — it'll show up here.
</div>`;
} else {
list.innerHTML = candidates.map(c => {
const id = String(c.id);
const checked = id === selectedId ? '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>`;
}).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; });
});
}
panel.querySelector('.shopping-picker-save').addEventListener('click', async () => {
const prev = getActiveId();
setActiveId(selectedId);
status.textContent = 'saving…';
try {
if (selectedId) await pushActive();
else await clearRemote();
status.textContent = 'saved';
setTimeout(close, 400);
} catch (e) {
status.textContent = 'error: ' + (e?.message || e);
status.style.color = 'var(--t-danger)';
setActiveId(prev);
}
});
}
// ── Sync ─────────────────────────────────────────────────────
async function pushActive() {
const card = getActiveCard();
if (!card) return;
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: uid, title, body, updated_at: new Date().toISOString().replace('T', ' ').slice(0, 19) },
});
if (res?.ok && res.stored !== false) setSynced(uid, title, body);
}
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;
if ((card.body || '') === (body || '') && (card.title || '') === (title || '')) {
setSynced(card.id, card.title || '', card.body || '');
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 || '');
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 {}
}
// ── Init ─────────────────────────────────────────────────────
function init() {
injectHeaderButton();
hyprcrd.hooks?.on?.('sync.tick', () => { tick().catch(() => {}); });
hyprcrd.hooks?.on?.('cards.reloaded', () => { tick().catch(() => {}); });
}
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.',
});
+104
View File
@@ -0,0 +1,104 @@
import { describe, it, expect, $, click, sleep, csrf } from './runner.js?v=4';
// 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 () => {
const r = await fetch('api/shopping').then(x => x.json());
expect(typeof r.ok).toBe('boolean');
});
it('POST /api/shopping/check returns a JSON object', async () => {
const r = await fetch('api/shopping/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify({ line: '- [ ] nope', checked: true }),
}).then(x => x.json());
expect(r).toExist();
// The check endpoint is session-auth on the relay (phone-only),
// so hitting it via the client proxy returns an `error` field
// from the relay's unauthorized branch. Without a configured
// relay, it's `ok:false`. Either way it's a JSON object.
expect(typeof r.ok === 'boolean' || typeof r.error === 'string').toBe(true);
});
});
describe('Shopping: header button', () => {
it('Shopping button is injected into the header', () => {
const btn = $('#shopping-btn');
expect(btn).toExist();
// 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(document.querySelector('.shopping-picker-overlay')).toBe(null);
});
});
describe('Shopping: picker tag matching', () => {
// Creates one card per trigger tag + one untagged control, then
// opens the picker and asserts the list. Both #einkauf and
// #shopping must qualify; untagged card must not.
it('lists both #einkauf and #shopping cards, excludes others', async () => {
const token = csrf();
const h = { 'Content-Type': 'application/json', 'X-CSRF-Token': token };
const hDel = { 'X-CSRF-Token': token };
let eink, shop, other;
try {
eink = await fetch('api/cards', { method: 'POST', headers: h, body: JSON.stringify({ title: '__test_shop_einkauf__', body: '- [ ] milk #einkauf' }) }).then(r => r.json());
shop = await fetch('api/cards', { method: 'POST', headers: h, body: JSON.stringify({ title: '__test_shop_shopping__', body: '- [ ] bread #shopping' }) }).then(r => r.json());
other = await fetch('api/cards', { method: 'POST', headers: h, body: JSON.stringify({ title: '__test_shop_control__', body: 'plain card' }) }).then(r => r.json());
await window._reloadAll();
// Close any leftover overlay, then open the picker fresh.
document.querySelector('.shopping-picker-overlay')?.remove();
click('#shopping-btn');
await sleep(80);
const overlay = document.querySelector('.shopping-picker-overlay');
expect(overlay).toExist();
const listText = overlay.querySelector('#shopping-picker-list').textContent;
expect(listText).toContain('__test_shop_einkauf__');
expect(listText).toContain('__test_shop_shopping__');
// Non-tagged control must be absent.
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 {
for (const c of [eink, shop, other]) {
if (c?.id) await fetch(`api/cards/${c.id}`, { method: 'DELETE', headers: hDel });
}
await window._reloadAll();
}
});
});
+1
View File
@@ -55,6 +55,7 @@ const FILES = [
'./terminal.test.js', './terminal.test.js',
'./theme.test.js', './theme.test.js',
'./chat.test.js', './chat.test.js',
'./shopping.test.js',
]; ];
// Await all imports so the runner can start in a deterministic state. // Await all imports so the runner can start in a deterministic state.
+14
View File
@@ -233,6 +233,20 @@ $db->exec('CREATE TABLE IF NOT EXISTS chat_room_members (
)'); )');
$db->exec('CREATE INDEX IF NOT EXISTS idx_chat_room_member_tenant ON chat_room_members(tenant_id, room_uid)'); $db->exec('CREATE INDEX IF NOT EXISTS idx_chat_room_member_tenant ON chat_room_members(tenant_id, room_uid)');
// Shopping list: one active card per tenant, body mirrored from the
// client. Phone toggles checkboxes in-place via the session UI — no
// multi-list fan-out, no history, no attachments. hyprcrd remains the
// only surface that edits item text.
$db->exec('CREATE TABLE IF NOT EXISTS shopping_state (
tenant_id INTEGER PRIMARY KEY,
card_uid TEXT NOT NULL,
title TEXT NOT NULL DEFAULT "",
body TEXT NOT NULL DEFAULT "",
updated_at_client TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at_server TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
)');
// Sync-event notifications: replaces the old bot-chat mechanism. When a // Sync-event notifications: replaces the old bot-chat mechanism. When a
// client syncs a card they received, they POST here; the sender polls // client syncs a card they received, they POST here; the sender polls
// GET /api/sync-events and surfaces each as a transient toast. Rows are // GET /api/sync-events and surfaces each as a transient toast. Rows are
+115
View File
@@ -0,0 +1,115 @@
<?php
// Shopping-list routes:
// POST /api/shopping (client) upsert active card {uid, title, body, updated_at}
// GET /api/shopping (client + session) read current state
// POST /api/shopping/check (session) toggle one line {line, checked}
// GET /shopping (session) phone-friendly UI
//
// Plaintext on purpose — shopping-list content is low-stakes and
// everything here is session- or client-auth'd. No per-tenant key
// dance like chat / collab-buckets. If that needs tightening later,
// it's a one-table migration.
if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
$clientTenant = $clientTenant ?? authClient($db);
$tenant = $sessionTenant ?: $clientTenant;
function shoppingLoad(PDO $db, int $tenantId): ?array {
$stmt = $db->prepare('SELECT card_uid, title, body, updated_at_client, updated_at_server FROM shopping_state WHERE tenant_id = :t');
$stmt->execute(['t' => $tenantId]);
$row = $stmt->fetch();
return $row ?: null;
}
// ── POST /api/shopping ────────────────────────────────────────
// Client pushes the currently-active card's content. `updated_at`
// is the client's timestamp — used for lose-the-race checks.
if ($route === '/api/shopping' && $method === 'POST') {
if (!$clientTenant) jsonResponse(['error' => 'unauthorized'], 401);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$uid = trim((string)($input['card_uid'] ?? ''));
$title = (string)($input['title'] ?? '');
$body = (string)($input['body'] ?? '');
$clientTs = trim((string)($input['updated_at'] ?? '')) ?: gmdate('Y-m-d H:i:s');
if ($uid === '') jsonResponse(['error' => 'card_uid required'], 400);
// No server-side staleness guard: the client decides whether to
// push by diffing its last-synced body against the current card,
// not by timestamp, so cross-timezone clock comparisons can't
// silently block edits. Last-writer per tick wins — phone-toggle
// races lose at most one tick of flips (rare by design).
$now = gmdate('Y-m-d H:i:s');
$db->prepare('
INSERT INTO shopping_state (tenant_id, card_uid, title, body, updated_at_client, updated_at_server)
VALUES (:t, :u, :ti, :b, :c, :s)
ON CONFLICT(tenant_id) DO UPDATE SET
card_uid = excluded.card_uid,
title = excluded.title,
body = excluded.body,
updated_at_client = excluded.updated_at_client,
updated_at_server = excluded.updated_at_server
')->execute([
't' => $clientTenant['id'],
'u' => $uid,
'ti' => $title,
'b' => $body,
'c' => $clientTs,
's' => $now,
]);
jsonResponse(['ok' => true, 'stored' => true, 'updated_at_server' => $now]);
}
// ── GET /api/shopping ────────────────────────────────────────
// Both the client (to pull phone-made toggles back) and the
// session-auth'd phone UI poll this.
if ($route === '/api/shopping' && $method === 'GET') {
if (!$tenant) jsonResponse(['error' => 'unauthorized'], 401);
$state = shoppingLoad($db, (int)$tenant['id']);
if (!$state) jsonResponse(['ok' => true, 'state' => null]);
jsonResponse(['ok' => true, 'state' => $state]);
}
// ── POST /api/shopping/check ─────────────────────────────────
// Phone-side toggle. Body: { line: "- [ ] milk", checked: true }.
// We match the exact line once to keep merges simple; if the line
// isn't found (hyprcrd just rewrote it), the caller reloads and
// retries. Bumps updated_at_server so the next client pull sees
// the change.
if ($route === '/api/shopping/check' && $method === 'POST') {
if (!$sessionTenant) jsonResponse(['error' => 'unauthorized'], 401);
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$line = (string)($input['line'] ?? '');
$checked = !empty($input['checked']);
if ($line === '') jsonResponse(['error' => 'line required'], 400);
$state = shoppingLoad($db, (int)$sessionTenant['id']);
if (!$state) jsonResponse(['error' => 'no active list'], 404);
$body = $state['body'];
$lines = preg_split("/\r\n|\n|\r/", $body);
$hit = false;
foreach ($lines as $i => $l) {
if ($l !== $line) continue;
$toggled = $checked
? preg_replace('/^(\s*-\s*)\[\s\]/', '$1[x]', $l, 1)
: preg_replace('/^(\s*-\s*)\[[xX]\]/', '$1[ ]', $l, 1);
if ($toggled === $l) continue; // not a checkbox line
$lines[$i] = $toggled;
$hit = true;
break;
}
if (!$hit) jsonResponse(['error' => 'line not found (list may have been edited — reload)'], 409);
$newBody = implode("\n", $lines);
$now = gmdate('Y-m-d H:i:s');
$db->prepare('UPDATE shopping_state SET body = :b, updated_at_server = :s WHERE tenant_id = :t')
->execute(['b' => $newBody, 's' => $now, 't' => $sessionTenant['id']]);
jsonResponse(['ok' => true, 'updated_at_server' => $now]);
}
// Shopping list lives inside the relay UI as a regular view (see
// relay/ui/layout.php + app.js). No separate /shopping page — login
// and navigation are the same as for every other relay feature.
+1
View File
@@ -65,6 +65,7 @@ if (str_starts_with($route, '/api/')) {
require __DIR__ . '/inc/routes/collab-buckets.php'; require __DIR__ . '/inc/routes/collab-buckets.php';
require __DIR__ . '/inc/routes/collab-cards.php'; require __DIR__ . '/inc/routes/collab-cards.php';
require __DIR__ . '/inc/routes/collab-locks.php'; require __DIR__ . '/inc/routes/collab-locks.php';
require __DIR__ . '/inc/routes/shopping.php';
jsonResponse(['error' => 'not found'], 404); jsonResponse(['error' => 'not found'], 404);
} }
+132 -2
View File
@@ -68,15 +68,16 @@ function showView(name) {
if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) { if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) {
name = 'change-password'; name = 'change-password';
} }
['setup', 'login', 'change-password', 'compose', 'account', 'admin', 'chat'].forEach(v => { ['setup', 'login', 'change-password', 'compose', 'account', 'admin', 'chat', 'shopping'].forEach(v => {
$('view-' + v)?.classList.add('hidden'); $('view-' + v)?.classList.add('hidden');
}); });
$('view-' + name)?.classList.remove('hidden'); $('view-' + name)?.classList.remove('hidden');
if (['compose', 'account', 'admin', 'chat'].includes(name)) { if (['compose', 'account', 'admin', 'chat', 'shopping'].includes(name)) {
$('nav-buttons').classList.remove('hidden'); $('nav-buttons').classList.remove('hidden');
$('nav-compose').classList.toggle('active', name === 'compose'); $('nav-compose').classList.toggle('active', name === 'compose');
$('nav-chat')?.classList.toggle('active', name === 'chat'); $('nav-chat')?.classList.toggle('active', name === 'chat');
$('nav-shopping')?.classList.toggle('active', name === 'shopping');
$('nav-account').classList.toggle('active', name === 'account'); $('nav-account').classList.toggle('active', name === 'account');
$('nav-admin').classList.toggle('active', name === 'admin'); $('nav-admin').classList.toggle('active', name === 'admin');
$('nav-burger')?.classList.remove('hidden'); $('nav-burger')?.classList.remove('hidden');
@@ -105,6 +106,12 @@ function showView(name) {
} else { } else {
stopChatPoll(); stopChatPoll();
} }
if (name === 'shopping') {
loadShopping();
startShoppingPoll();
} else {
stopShoppingPoll();
}
if (name === 'admin') { if (name === 'admin') {
loadUsers(); loadUsers();
} else if (name === 'account') { } else if (name === 'account') {
@@ -695,6 +702,129 @@ window.deleteAllMessages = deleteAllMessages;
/** Lightweight relative-time formatter for the inbox. SQLite gives us /** Lightweight relative-time formatter for the inbox. SQLite gives us
* `YYYY-MM-DD HH:MM:SS` (UTC). Returns "2 min ago", "today 14:32", * `YYYY-MM-DD HH:MM:SS` (UTC). Returns "2 min ago", "today 14:32",
* or a full date for older messages. */ * or a full date for older messages. */
// ── Shopping (phone-side of the hyprcrd → relay list) ──
//
// We don't own the content — hyprcrd pushes the active card's body
// to /api/shopping and we just render the unchecked "- [ ]" lines
// as tappable rows. A tap POSTs to /api/shopping/check which flips
// that exact line to "- [x]" on disk; hyprcrd's next pull sees it.
let shoppingPollHandle = null;
let shoppingBusy = false;
function startShoppingPoll() {
stopShoppingPoll();
// 4 s is responsive enough that edits on the desktop show up
// while you're still holding the phone, and cheap enough that
// an always-on tab costs basically nothing (single indexed
// SELECT per poll).
shoppingPollHandle = setInterval(() => {
if (!document.hidden && !shoppingBusy) loadShopping(true);
}, 4000);
}
function stopShoppingPoll() {
if (shoppingPollHandle) { clearInterval(shoppingPollHandle); shoppingPollHandle = null; }
}
function shoppingErr(msg) {
const el = $('shopping-err');
if (!el) return;
if (!msg) { el.classList.add('hidden'); el.textContent = ''; return; }
el.textContent = msg;
el.classList.remove('hidden');
}
function shoppingParseLines(body) {
// Two node kinds end up on the phone:
// heading — "# h1" … "###### h6" inside the body, used to
// section the list (Obst / Backwaren / …)
// item — unchecked "- [ ] …" lines (checked ones are hidden)
// Then drop orphan headings (no open items before the next
// heading or end of list) so empty section labels don't sit
// around after everything under them is ticked off.
const nodes = [];
for (const line of String(body || '').split(/\r\n|\n|\r/)) {
const hm = /^\s*(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
if (hm) { nodes.push({ kind: 'heading', level: hm[1].length, text: hm[2] }); continue; }
const im = /^\s*-\s*\[([ xX])\]\s*(.*)$/.exec(line);
if (!im) continue;
if (im[1].toLowerCase() === 'x') continue;
nodes.push({ kind: 'item', text: im[2], line });
}
const out = [];
for (let i = 0; i < nodes.length; i++) {
if (nodes[i].kind === 'heading') {
let hasItem = false;
for (let j = i + 1; j < nodes.length; j++) {
if (nodes[j].kind === 'heading') break;
if (nodes[j].kind === 'item') { hasItem = true; break; }
}
if (!hasItem) continue;
}
out.push(nodes[i]);
}
return out;
}
async function loadShopping(quiet = false) {
if (!quiet) shoppingErr('');
const data = await api('api/shopping');
const title = $('shopping-title');
const list = $('shopping-items');
if (!list) return;
if (!data?.ok) { if (!quiet) shoppingErr(data?.error || 'load failed'); return; }
if (!data.state) {
if (title) title.textContent = '';
list.innerHTML = '<li class="empty">no active shopping list</li>';
return;
}
const { title: t, body } = data.state;
if (title) title.textContent = t || '';
const nodes = shoppingParseLines(body);
const hasItems = nodes.some(n => n.kind === 'item');
if (!hasItems) {
list.innerHTML = '<li class="empty">\u2713 all done</li>';
return;
}
list.innerHTML = '';
for (const n of nodes) {
const li = document.createElement('li');
if (n.kind === 'heading') {
li.className = 'shopping-heading h' + n.level;
li.textContent = n.text;
} else {
li.textContent = n.text;
li.dataset.line = n.line;
li.addEventListener('click', () => shoppingCheck(li));
}
list.appendChild(li);
}
}
async function shoppingCheck(li) {
if (shoppingBusy) return;
shoppingBusy = true;
li.classList.add('checked');
try {
const res = await api('api/shopping/check', { method: 'POST', body: { line: li.dataset.line, checked: true } });
if (!res?.ok) throw new Error(res?.error || 'check failed');
setTimeout(() => { shoppingBusy = false; loadShopping(true); }, 300);
} catch (e) {
shoppingBusy = false;
li.classList.remove('checked');
shoppingErr(e.message);
}
}
// Auto-refresh when the tab comes back into focus — same pattern as
// the standalone page used to have, just without a manual button.
document.addEventListener('visibilitychange', () => {
if (document.hidden) return;
if ($('view-shopping')?.classList.contains('hidden')) return;
if (shoppingBusy) return;
loadShopping(true);
});
// ── Chat ── // ── Chat ──
let chatActivePeer = null; let chatActivePeer = null;
+16
View File
@@ -23,6 +23,7 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
<span id="nav-user" class="user"></span> <span id="nav-user" class="user"></span>
<button id="nav-compose" class="nav-btn active" onclick="showView('compose')"><i class="ph ph-pencil-line"></i> compose</button> <button id="nav-compose" class="nav-btn active" onclick="showView('compose')"><i class="ph ph-pencil-line"></i> compose</button>
<button id="nav-chat" class="nav-btn" onclick="showView('chat')"><i class="ph ph-chat-circle-dots"></i> chat</button> <button id="nav-chat" class="nav-btn" onclick="showView('chat')"><i class="ph ph-chat-circle-dots"></i> chat</button>
<button id="nav-shopping" class="nav-btn" onclick="showView('shopping')"><i class="ph ph-shopping-cart"></i> shopping</button>
<button id="nav-account" class="nav-btn" onclick="showView('account')"><i class="ph ph-user-circle"></i> account</button> <button id="nav-account" class="nav-btn" onclick="showView('account')"><i class="ph ph-user-circle"></i> account</button>
<button id="nav-admin" class="nav-btn hidden" onclick="showView('admin')"><i class="ph ph-users"></i> users</button> <button id="nav-admin" class="nav-btn hidden" onclick="showView('admin')"><i class="ph ph-users"></i> users</button>
<button class="nav-btn danger" onclick="doLogout()"><i class="ph ph-sign-out"></i> logout</button> <button class="nav-btn danger" onclick="doLogout()"><i class="ph ph-sign-out"></i> logout</button>
@@ -210,6 +211,21 @@ if (!defined('RELAY_BOOTSTRAPPED')) { http_response_code(404); exit; }
</div> </div>
</div> </div>
<!-- ── Shopping view ── -->
<!-- Phone-friendly checklist. Shows only unchecked "- [ ]" items
from the currently active shopping card. Tapping an item flips
it to "- [x]" on the relay in place; hyprcrd pulls that back on
the next sync tick. Edits to item text stay in hyprcrd. -->
<div id="view-shopping" class="hidden">
<div class="shopping-wrap">
<div class="shopping-top">
<span id="shopping-title" class="pixel" style="font-size:11px;color:var(--bright);">loading…</span>
</div>
<div id="shopping-err" class="shopping-err hidden"></div>
<ul id="shopping-items" class="shopping-items"></ul>
</div>
</div>
<!-- ── Chat view ── --> <!-- ── Chat view ── -->
<div id="view-chat" class="hidden"> <div id="view-chat" class="hidden">
<div class="chat-layout"> <div class="chat-layout">
+45
View File
@@ -232,6 +232,14 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point
#view-compose:not(.hidden) { #view-compose:not(.hidden) {
display: flex; flex-direction: column; gap: 16px; display: flex; flex-direction: column; gap: 16px;
} }
/* Same 16 px rhythm between inbox / shared / sent on mobile as
between compose and inbox — desktop re-declares this inside the
grid rule further down. */
#compose-sidebar {
display: flex;
flex-direction: column;
gap: 16px;
}
.compose-attachments { .compose-attachments {
display: flex; flex-wrap: wrap; gap: 6px; min-height: 0; display: flex; flex-wrap: wrap; gap: 6px; min-height: 0;
} }
@@ -254,6 +262,43 @@ label.checkbox input[type="checkbox"] { accent-color:var(--accent); cursor:point
display:flex; gap:8px; align-items:center; display:flex; gap:8px; align-items:center;
} }
.compose-card .actions .spacer { flex:1; } .compose-card .actions .spacer { flex:1; }
/* Shopping view — phone-friendly checklist. Large tap targets,
unchecked items only, tap to flip. */
.shopping-wrap { max-width: 520px; margin: 0 auto; display: flex; flex-direction: column; gap: 12px; }
.shopping-top { display: flex; align-items: center; gap: 10px; padding: 6px 2px; }
.shopping-items { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; }
.shopping-items li {
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg);
padding: 14px 16px; font-size: 16px; cursor: pointer; user-select: none;
-webkit-tap-highlight-color: transparent;
transition: transform 0.12s, opacity 0.3s, background 0.12s;
}
.shopping-items li:active { transform: scale(0.98); background: var(--raised, var(--surface)); }
.shopping-items li.checked { opacity: 0.35; text-decoration: line-through; }
.shopping-items .empty { text-align: center; padding: 40px 16px; color: var(--dim); background: transparent; border: none; cursor: default; }
.shopping-items .empty:active { transform: none; }
/* Section headings between items — "# Obst", "## Backwaren", etc. */
.shopping-items li.shopping-heading {
background: transparent;
border: none;
padding: 18px 4px 4px;
cursor: default;
font-weight: 600;
letter-spacing: 0.02em;
}
.shopping-items li.shopping-heading:active { transform: none; background: transparent; opacity: 1; }
.shopping-items li.shopping-heading:first-child { padding-top: 2px; }
.shopping-items li.shopping-heading.h1 { font-size: 18px; color: var(--bright); }
.shopping-items li.shopping-heading.h2 { font-size: 15px; color: var(--accent); }
.shopping-items li.shopping-heading.h3,
.shopping-items li.shopping-heading.h4,
.shopping-items li.shopping-heading.h5,
.shopping-items li.shopping-heading.h6 { font-size: 13px; color: var(--muted); }
.shopping-err { background: var(--danger-bg, #7f1d1d); color: var(--danger-fg, #fecaca); padding: 8px 12px; border-radius: 6px; font-size: 13px; }
.shopping-err.hidden { display: none; }
/* Chat view */ /* Chat view */
.chat-layout { display: flex; height: calc(100vh - 200px); min-height: 300px; border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; } .chat-layout { display: flex; height: calc(100vh - 200px); min-height: 300px; border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
.chat-sidebar { width: 200px; border-right: 1px solid var(--border); display: flex; flex-direction: column; background: var(--surface); } .chat-sidebar { width: 200px; border-right: 1px solid var(--border); display: flex; flex-direction: column; background: var(--surface); }
+67
View File
@@ -0,0 +1,67 @@
# Website TODO
Audit (app 1.9.17 vs. current site 1.3.3) found the site is ~14 releases
behind on content and missing entire feature categories. Items grouped
by priority so it's obvious what's must-have vs. polish.
## P0 — critical freshness gap
- [ ] **Bump footer version** to the actual app version (pull from
`config.php` APP_VERSION or `electron/package.json`). Today shows
`v1.3.3`, app is `1.9.17`.
- [ ] **Refresh hero screenshot** (`screenshot.png`). Probably 1+ year
stale; current board has new bucket bar with fade edges, loading
spinner, peer strip in chat, etc.
- [ ] **New section: Calendar + CalDAV.** Completely absent today.
Cover: month grid, journal entries (`YYYY-MM-DD` titled cards),
appointments via `#termin` template (Zeit/Bis/Ort/Teilnehmer),
bidirectional ownCloud/Nextcloud sync, day-popup quick-add. This
is a big differentiator vs. Obsidian/Notion and currently hidden.
- [ ] **Rewrite Relay section.** Today only mentions 1:1 chat + collab
buckets. Add: group chat (named rooms, member mgmt, persistent
history, backfill on new device), disposable vs. persistent 1:1
threads, shared-bucket inbox in relay UI, sync-event toasts.
- [ ] **Flesh out bundled plugins.** Currently lists names only. Add
a short paragraph + tiny screenshot each for: TIM (time calc),
Terminal, Slideshow, Logseq-Import, Canvas (ASCII art).
## P1 — substance
- [ ] **Screenshots gallery section.** Only one screenshot (hero) exists.
Shoot + embed: calendar month view, day-popup with templates,
chat flyout with peer strip, group chat + member list, detail
split view, superview, gallery plugin, Gitea issue browser.
Thumbnails with click-to-zoom.
- [ ] **"Daily use" section** with three concrete workflows:
1. Journaling + calendar sync (date cards → ownCloud)
2. Team collab (shared bucket + group chat + shared-bucket inbox)
3. Gitea ticket-to-card flow
- [ ] **CTA buttons point to real landing targets**, not raw
`README.md`. Minimum: a `/install.html` with Docker, Electron
and PHP-hosting tabs.
- [ ] **Mention Superview + Split View + Find-and-Replace** explicitly.
Today buried or absent. Same for Undo/Redo across all mutations
and the editor draft auto-backup.
## P2 — nice-to-have
- [ ] Animated hero demo (GIF or ≤30s mp4): create card, tag-click,
detail open, calendar jump. No voiceover, just the keystrokes.
- [ ] Keyboard shortcut reference expanded (app has ~40+; site shows
~20). Maybe collapsible per-category.
- [ ] Theming preview: 3–4 design-token presets side by side so the
"fully themed" claim has a picture behind it.
- [ ] Visible changelog / "recently added" box so the site doesn't
drift 14 releases behind again.
- [ ] Comparison table vs. Obsidian / Notion / Joplin — privacy +
self-hosted + collab + calendar all-in-one is the pitch.
- [ ] Vault-key explainer (multi-instance CalDAV sharing, separate
from login password). Niche but unique to hyprcrd.
## Not doing (deliberate)
- Pricing page — open-source/MIT, no revenue model to surface.
- User testimonials / case studies — no real users to quote.
- Community links (Discord/Matrix) — none exist yet.
- Mobile app shots — there isn't a mobile app, only mobile-friendly
relay UI.