169 Commits
Author SHA1 Message Date
th 2a216906da apple theme 2026-04-27 09:33:24 +02:00
tu 130a1c660b Improve Cluster Dashboard Timeline (#1)
Reviewed-on: tanztee/hyprcrd#1
2026-04-19 13:08:57 +00:00
thandClaude Opus 4.7 4baeee159e v2.0.14 — ghost + spinner on card move
Moving a card to another bucket took a visible round-trip (PATCH +
reloadAll) during which the card sat unchanged in the source bucket,
leaving the user wondering if the drop registered. Now every
bucket-move path marks the affected cards `.card-moving` as soon as
the action fires: 50% opacity, dark scrim and a centred accent-ring
spinner. The next renderBoard() rebuilds the DOM so the class comes
off automatically — no explicit cleanup needed.

Call sites:
- drag-drop onto a bucket tile (single + multi-select)
- drag onto an existing bucket to nest it (auto-super-bucket create)
- context-menu → Bucket → <target>

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:44:03 +02:00
thandClaude Opus 4.7 8cbec8d601 v2.0.13 — edge-to-edge fullscreen overlays, rounded-cards badge variant
- Overlays: drop the 24/40/54px inset frame on tasks, calendar,
  gallery, search, detail, edit modal, bookmarks, email, gitea and
  oumta-dashboard so they fill the viewport like the support
  overlay does. Removed the shared border-radius + overflow:hidden
  rule on their inner containers (only settings + dialog still use
  it) so the card's top colour bar isn't clipped.
- Done badge: the corner-triangle variant gets chopped by rounded
  card borders. applyDesign now toggles body.rounded-cards whenever
  --ui-radius-card > 0, and the CSS swaps the triangle for a 18px
  circle pill with the check centred. Triangle stays for square
  cards.
- Support plugin: "Close ticket" button is a regular labelled
  button now (was a 30×30 icon ghost that clipped the text). Empty
  hero state ("select a ticket") matches the board's empty style —
  centred in the column with a pixel-font blink-cursor label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:34:50 +02:00
thandClaude Opus 4.7 7bce6a3a36 v2.0.12 — support channel: relay + hyprcrd plugin
Central support desk for the Oumta project. Tickets arrive through a
self-hostable support-relay (public form, SMTP out), hyprcrd pulls
them in as cards, replies go back out through the relay, and KB
fragments are plain hyprcrd cards so the board + search + tags
machinery handles them for free.

support-relay/ (standalone PHP/SQLite service)
- Public form + submit endpoint with IP rate-limit, honeypot, and an
  email-matched reply path so users can follow up by typing their
  OUMTA-n issue id — mismatched email falls back to a fresh ticket
  so a hostile submitter can't hijack someone else's thread.
- One-shot setup wizard configures branding, issue-id prefix, public
  URL, mail footer template, SMTP creds (stored AES-256-GCM at rest),
  and generates the X-Support-Key API token shown exactly once.
- Hyprcrd-facing API: GET /api/hyprcrd/tickets?since=<iso>, POST
  …/reply (sends via SMTP, flips status to answered), PATCH
  …/status, DELETE, GET /ping, plus GET/POST /api/hyprcrd/settings
  exposing brand/public_url/mail_footer for post-setup edits.
- Minimal dependency-free SMTP client covering STARTTLS/TLS/none,
  AUTH LOGIN, RFC 2047 subject encoding, dot-stuffing. Extended to
  emit multipart/alternative (plain+HTML) + multipart/related so
  outbound mails carry the branded logo via Content-ID embedding;
  plain-text fallback stays intact for older clients.
- Mail footer template supports {brand}, {issue_id}, {public_url}
  placeholders. Lines with an empty placeholder are dropped, so the
  "Reply via …" line vanishes cleanly when no public URL is set.
- Favicon on all public pages. Logo (SVG by default, PNG preferred
  when both exist) picked up from logo2026col.* at send time.
- Docker: Dockerfile + docker-compose.yml for a stand-alone test
  container, plus a support-relay service in the main compose
  (profile "support") so hyprcrd can reach it at http://support-relay
  without host-networking shenanigans.

public/plugins/support (hyprcrd client)
- Full-screen Support overlay: ticket list (status-filterable),
  active ticket thread, reply composer with KB-fragment search +
  click-to-insert, "Save as fragment" button, "Close ticket" shortcut.
- KB fragments are regular cards in a configurable bucket. The
  composer just adds a fast search-and-insert path on top.
- Sync-tick hook auto-polls the relay alongside the existing CalDAV
  + relay-mail cycle.
- Settings tab in the main overlay: relay URL, API key, inbox / KB
  bucket pickers, connection test, and a "Relay config" subsection
  that round-trips to /api/hyprcrd/settings so the mail footer and
  branding can be edited without touching the relay's DB.
- Local cache tables support_tickets + support_replies mirror the
  relay's authoritative data keyed by relay_id.

Tooling
- dev.sh now opts into both relay + support profiles.
- dev-down.sh wipes support-relay volumes and bind-mount data for a
  clean re-setup. Hyprcrd + relay data is preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:41:19 +02:00
thandClaude Opus 4.7 2d58cd993e v2.0.10 — snappier boot + non-blocking relay push on save
DB
- Six indices on the cards table (position+created_at, bucket_id,
  parent_id, updated_at, caldav_uid, relay_uid). The /api/cards
  ORDER BY position was a full-table scan + sort on every board
  reload; now it's an index walk.
- PRAGMA synchronous=NORMAL (safe under WAL) + temp_store=MEMORY.

Save path
- pushCardToRelayRead and deleteCardFromRelayRead now run AFTER the
  response is flushed via fastcgi_finish_request (falls back to
  ignore_user_abort + flush on non-FPM setups). The user's save no
  longer blocks on the relay round-trip.
- cURL timeouts dropped from 10s/30s to 5s/10s, plus a 2s
  connect-timeout. Worst-case on a dead relay goes from ~160s down
  to ~15s — and now that worst case is in the background anyway.

Client
- loadCards phase-2 batches bumped from 25 to 500 cards per fetch.
  For 300 cards that's 1 HTTP trip instead of 12 — spinner wall time
  drops from ~3s to ~0.25s.
- renderBoard() is now requestAnimationFrame-coalesced. The boot
  sequence calls renderBoard from cache paint, meta paint, body
  paint, and the cards.reloaded plugin hook; they used to each
  trigger an innerHTML swap + layout pass. Now they collapse into
  one actual render per frame. Callers that really need a sync
  render (rare) can use the new renderBoardSync().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 20:41:48 +02:00
thandClaude Opus 4.7 87ebcd156b v2.0.9 — sync toast split, relay group chat UI, find/replace flaky fix
- sync: the post-sync toast lumped relay pulls into the CalDAV stats
  and always labelled the banner "CalDAV". Split into two separate
  toasts (CalDAV cloud-arrow + Relay broadcast icon), each only shown
  when its numbers are non-zero. Banners now stack vertically so both
  can appear together on a sync that pulled on both sides.
- relay UI: the session chat view was 1:1-only. Group rooms (which
  the client API already supports) now surface as first-class threads
  in the sidebar — sorted chronologically with 1:1 peers, tagged
  "ROOM", with a users-three icon. Sending posts to `room_uid`
  instead of `recipient`; history shows the sender name above each
  incoming message so it's clear who's talking in the room.
  Relay backend: `/api/chat/threads` returns rooms alongside 1:1
  threads; `/api/chat/history` accepts `?room_uid=`; `/api/chat/send`
  accepts `room_uid` in the body.
- tests: bump "replace all updates card bodies" poll budget from 4s
  to 10s and poll both updated cards. Flaky on cold runs because a
  fresh PHP worker + SQLite WAL warm-up plus two sequential PATCH
  calls can just barely exceed 4 seconds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:28:00 +02:00
thandClaude Opus 4.7 4aca21812e v2.0.8 — oumta timeline: continuous fading line, multi-select filter
- Connector: moved from per-entry pseudo-elements to a single
  linear-gradient line on the body. Fades in/out 28px at each end
  and no longer has gaps at entry boundaries. Dots and the date
  rect's solid background sit naturally on top; everything else
  stays transparent so the line threads through cleanly.
- Layout: entry is now a 2-row grid. The optional date rect lives
  in row 1 (justify-self:start, left-aligned), while the dot-row
  and the events column both pin to row 2 so the time label always
  lines up with the first event of the cluster — even when a date
  rect sits above. Time+dot `align-self: start` keeps them at the
  top of the event section instead of vertically centred through
  the whole cluster.
- Filters: state.timelineFilter is a Set now, so clicking multiple
  workload pills (or the overflow entries) stacks them as an
  any-of filter. "all" clears the set. The overflow dropdown stays
  open on pick so several can be toggled without reopening it each
  time. Scope label shows "filtered · N" with the active count.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:21:18 +02:00
thandClaude Opus 4.7 9f90ea516a v2.0.7 — oumta-dashboard: timeline overhaul
Two-column timestrahl layout + workload quick-filter.

- Left column (64px): date badge (big day / small month e.g. 18 APR)
  shown only on the first cluster of each day, coloured dot, and a
  compact HH:MM time (or "just now" when <60s old).
- Dot colour = dominant severity of the cluster
  (error > warning > recovered > image > info).
- Events within a 60s tolerance window collapse into one entry so
  simultaneous rollouts of multiple deployments appear as a single
  dot with each sub-event listed in the right column.
- Workload quick-pick pills above the timeline. "all" + up to six
  inline pills (workloads sorted by recency); the rest collapses into
  a "+N more" dropdown. Clicking a workload name inline inside an
  event also applies the filter. Filter hides itself when the view is
  scoped to a single workload (opened via the overview attention list).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:25:40 +02:00
thandClaude Opus 4.7 0553960a57 v2.0.6 — oumta-dashboard: hardwire config, drop settings tab
This plugin only targets one specific deployment, so the settings
panel was noise that tempted accidental misconfiguration. baseUrl,
authToken and pollIntervalSec now live in a top-of-file CONFIG
constant; the hyprcrd-settings tab, the localStorage persistence,
and the panel CSS are gone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:20:09 +02:00
thandClaude Opus 4.7 1e51bdec7c v2.0.5 — Read feature (mobile zen reader on the relay)
Per-card opt-in push to the relay for distraction-free mobile reading.
Not tag-based: a real cards.read_push column so the reading state
stays independent of whatever tags the user organises by.

hyprcrd
- cards.read_push column (0/1, default 0) via Database migration.
- POST /api/cards + PATCH /api/cards/{id} accept read_push; save hook
  posts the card (title+body, encrypted) and referenced images
  to the relay's /api/read endpoint. First inline image is flagged
  as the hero. Toggle-off / DELETE wipes the remote copy.
- Detail-bar toggle button ("Read" → "On relay") with filled book
  icon + inverted accent background when active; outline book icon
  in the bottom meta row of the board card when read_push=1.

relay
- read_items + read_attachments tables, encrypted under the
  inter-tenant key like messages (dual-format envelope).
- Client API: POST /api/read, DELETE /api/read/{uid}, POST
  /api/read-attachments. Session API: GET /api/read (list),
  GET /api/read/{uid} (single decrypted), GET /api/read-attachments/
  {uid}/{filename} (serves decrypted image bytes).
- Payload size caps raised to 15MB (attachments) / 2MB (items).
- New nav tab + view: list with thumbnails, click opens a zen reader
  overlay. Reader caps article width at 680px, bumps type to 17px/1.7,
  renders a minimal markdown subset (headings, lists, code fences,
  blockquotes, links, inline images), rewrites uploads/ references
  to the session-authed image endpoint. Escape closes the reader.
- Mobile-first: wider touch targets, tighter padding at ≤640px.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:10:04 +02:00
thandClaude Opus 4.7 f986a41ccc v2.0.4 — re-auth on 401, robust bucket-switch test
- auth: when a background poll or save returns 401 (session expired
  while the UI is still open), the api() helpers now prompt for the
  password instead of letting the save fail silently. Concurrent 401s
  collapse onto a single dialog via a shared reauthPromise, and each
  caller retries its original request once after login. /api/auth/* is
  excluded from the retry path. Extracts promptLogin() in app.js and
  exposes it as window._reauth so both the core util.js api() and the
  plugin-SDK hyprcrd.js api() can share the flow.
- tests: the "open-switches-bucket" detail test assumed buckets[0] was
  a plain top-level bucket, but the bar renders super-bucket parents
  with data-bucket-super and hides child buckets entirely, so the
  .bucket-tab.active[data-bucket] selector found nothing on setups
  with nested layouts. Test now picks two plain top-level buckets or
  skips.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 12:50:44 +02:00
thandClaude Opus 4.7 48b5907f4c v2.0.2 — English tag aliases, English-only website, undo-body flaky fix
- tags: accept #birthday and #appointment as English synonyms for
  #geburtstag and #termin. Normalised to the German canonical form at
  extractTags() write-time so every downstream check (parseTags /
  calendar logic / board filters) keeps working without per-call-site
  aliasing. Editor body-regex extended so the "new appointment"
  heuristic and auto-sync trigger on either variant.
- website: replace #termin/#geburtstag with #appointment/#birthday in
  tag chips, calendar feature list, and calendar legend.
- tests: fix flaky "Undo: Editor Save → Ctrl+Z reverts body change"
  that raced doSave(). The test polled the server for the patched body
  and fired Ctrl+Z as soon as it landed, but the undo shortcut is
  guarded by !guards.isOpen('modal'), and closeModal() only runs after
  the post-patch _reloadAll — on fast servers the key was swallowed.
  Poll now also waits for the modal `.open` class to drop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 10:04:08 +02:00
tu 89d84d3538 tweak status change representation 2026-04-17 21:08:38 +02:00
thandClaude Opus 4.7 29e818ac64 website: carry the new slogan into the <title> tag
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 19:00:30 +02:00
thandClaude Opus 4.7 53ce58a5aa website: new CTA slogan — "stop renting your memory"
Replaces the generic "your brain, self-hosted" cliché with a line that
contrasts self-hosting against cloud-SaaS note tools.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 19:00:00 +02:00
thandClaude Opus 4.7 72a7c7c7cc v2.0.1 — collab-attachments size cap + oumta overview tiles
- relay: raise /api/collab-attachments + /api/attachments/upload POST
  size limit from 256KB to 15MB, matching /api/attachments. Previous
  limit silently dropped any collab image >~100KB as 413 (envelope
  overhead pushes even modest JPEGs past the cap), leaving puller
  clients with broken <img src="uploads/..."> references.
- oumta-dashboard: centre overview tiles, bump value font to 56px,
  introduce .oumta-fail alarm state that paints the full tile red with
  white text. Toggled from JS for cluster state=down, workloads down
  >0, and open incidents >0. Fail colour uses --t-danger so it stays
  in sync with the "unavailable" workload labels.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 18:38:17 +02:00
thandClaude Opus 4.7 1dfffa5d6d website: new Calendar/Bookmarks/Shopping sections, relay rewrite, v2.0.0
Content catch-up from v1.3.3 → v2.0.0: three new feature sections,
relay section expanded to cover group chat / persistent history /
shopping / inbox view, footer version bumped, TODO trimmed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 17:05:56 +02:00
thandClaude Opus 4.7 d83de30d6d security: v2.0 — AES-GCM crypto, SSRF guards, rate limits, hardening
Breaking wire-format change: dual-read (V1+V2) / single-write (V2) so
existing user secrets, stored messages, and inter-tenant key keep
working. Old clients and relays lose cross-version chat until both
sides are upgraded.

Relay
- Switch clientEncrypt/masterEncrypt to AES-256-GCM with HKDF-derived
  per-ciphertext salts; keep CBC-V1 fallback on decrypt only.
- On-bootstrap migration re-envelopes tenant secret_encrypted and the
  inter-tenant key to GCM-V2 (idempotent).
- CORS: stop reflecting arbitrary Origin; allow own origin + optional
  RELAY_CORS_ORIGINS env allowlist; drop Access-Control-Allow-Credentials.
- Generic error messages replace "no such user: <name>" enumeration.
- Exception messages no longer echoed back; logged server-side.
- Collab-lock max hold capped at 10min so a member can't grief-refresh
  forever.
- Attachment filenames validated against hex.<ext> pattern.
- Passwords min length 10; /api/account/password rate-limited.

App / Electron client
- Shared hyprcrdRelayEncrypt/Decrypt helpers (GCM-V2 write, V1 read).
- Chat + collab-buckets plugins delegate to the shared helpers.
- Poll + push + attachments migrated to the dual-format path.
- /api/relay/settings GET returns has_key boolean; secret never echoed.
- Setup endpoint now CSRF-checked.
- SSRF: CURLOPT_PROTOCOLS restricted to HTTP(S) everywhere; bookmarks
  favicon fetch also blocks private/loopback/link-local.
- Login / password-change rate limiter moved to DB-backed IP keying.
- Passwords min length 10 throughout.
- Zip import enforces per-file (25MB) and total (500MB) caps, extension
  allowlist, no-overwrite.
- Verbose relay-push debug gated behind APP_DEBUG env.

Version bumped to 2.0.0 (protocol-breaking).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 17:02:04 +02:00
thandClaude Opus 4.6 d50f9fe789 relay UI overhaul, favicon, settings, admin last-seen, fixes
Relay:
  - Inbox/shared/sent split into own view-inbox (nav button + poll).
    Compose is now single-column, centered.
  - Header logo links to compose. Inbox badge stays fresh on all
    views via a quiet loadInbox on every view switch.
  - Mobile menu: full-height sheet below header, larger buttons
    (15px font, 14px padding), full width.
  - Login form wrapped in <form> with name attributes + for/id on
    labels so password managers can autofill.
  - Admin user list shows "last seen <relative time>" per user.
    last_seen_at column on tenants, updated on every authenticated
    request (session + client API).
  - Cache-busting on style.css + app.js via filemtime.
  - View persistence in localStorage (survives reload).
  - Shared-bucket cards show title only (preview line removed).

Favicon:
  - favicon.svg added to project root + copied to public/, relay/,
    website/. Linked via <link rel="icon" type="image/svg+xml"> in
    all three <head> sections.

Settings:
  - New toggle: "Show shopping list items in task collector"
    (default off). collectTasks skips #einkauf/#shopping cards when
    disabled.

Fixes:
  - card-spinner gets display:inline-block so it rotates outside
    flex containers (shopping save spinner).
  - Tim tab test opens the plugin before checking active state.
  - Compose view centered with margin:auto on desktop.

Bumps to 1.9.29.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 13:16:23 +02:00
thandClaude Opus 4.6 8e738ed9d4 fix(bookmarks): sync local state on save so columns persist
save() PATCHed the card on the server but left state.cards stale.
The next openOverlay read the old body from the local cache and
the new column was gone. Now save() writes the updated body back
into the matching state.cards entry so close + reopen sees the
latest data without a full _reloadAll round-trip.

Test: "new columns survive close + reopen" — adds a column via
API + local state update, closes, reopens, asserts both column
names are present in the DOM.

Bumps to 1.9.28.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 23:56:42 +02:00
thandClaude Opus 4.6 c934673c4a detail: optional bucket-switch on open from search / link
New toggle under Settings → Behavior → Interactions: "Switch to
card's bucket on open (search / link)". When enabled, openDetail
calls activateBucket(card.bucket_id) so the board and bucket bar
update to show the card's context. Skipped when already on 'all'
or the correct bucket. Default off.

Uses activateBucket() (from buckets.js) instead of raw state
assignment so the bucket-bar highlight tracks the switch.

Tests: two cases — setting on switches activeBucket + verifies the
bucket-tab.active in the bar matches; setting off leaves the
bucket unchanged. Both create/delete probe cards in try/finally
and restore the setting + activeBucket in afterEach.

Bumps to 1.9.27.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 23:39:36 +02:00
thandClaude Opus 4.6 6fa42eb267 fix(shopping): stale-row cleanup, cache-busting, view persistence
Root cause of the doubled lists: the relay DB had stale rows from
previous activations that were never deleted. The composite PK
prevented same-uid duplicates but not leftover rows from cards that
were deactivated without a successful DELETE.

Fixes:
  - Client tick sends DELETE { keep: [active uids] } after every
    push cycle. The relay drops all rows for this tenant that aren't
    in the keep list — cleans up migration leftovers, failed deletes,
    old single-list sentinels, everything in one shot.
  - Relay DELETE endpoint accepts { keep: [...] } for bulk cleanup
    alongside the existing { card_uid } single-delete.
  - Relay layout.php adds cache-busting query strings on style.css
    and app.js via filemtime() so phone browsers pick up new code
    on deploy instead of serving stale cached versions.
  - showView persists the active view name to localStorage; after
    login the relay restores it instead of always landing on compose.
  - Shopping-list-box border/background removed — items have their
    own chrome, the box is just a flex grouper now.
  - View title is always "Shopping"; each list box keeps its own
    header with the card title. No more doubled name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:20:46 +02:00
thandClaude Opus 4.6 5c9e9254d1 fix(shopping): remove old flat-list li styles causing visual doubling
.shopping-items li was the old single-list item rule. After the
multi-list refactor, the direct children of .shopping-items are
<li class="shopping-list-box"> wrappers, not individual items.
The old rule gave the box the same background + border + padding +
tap-target as the .shopping-item divs inside it — making each list
look like it appeared twice.

Drop the generic li rule entirely. Items are styled via
.shopping-item, boxes via .shopping-list-box, empty state via
.shopping-items .empty. Also dedup by card_uid in loadShopping JS
as a safety net.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 17:35:27 +02:00
thandClaude Opus 4.6 59be08a8f8 fix(shopping): filter out __inactive__ sentinel rows on relay
The old single-list clearRemote wrote a dummy row with card_uid
'__inactive__' instead of deleting. After the multi-list migration
that row still exists alongside real lists, causing every list to
appear twice on the phone — once as "all done" (the empty sentinel)
and once with actual items. Filter it out in shoppingLoadAll and
delete it on bootstrap so it doesn't linger.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 17:31:32 +02:00
thandClaude Opus 4.6 be7eb1523e 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>
2026-04-16 17:26:50 +02:00
thandClaude Opus 4.6 4c3954d989 fix: compact todo-item spacing, fix checkbox alignment in detail
Todo items in preview were ~60px tall because marked.js wraps
checkbox-list content in <p> tags, and .prose p adds 0.75em
vertical margin inside the flex item. Three changes:

  - .todo-item p { margin:0; min-height:0 } kills the paragraph
    bloat inside todo rows.
  - Padding 5px→2px, min-height 28→24px, gap 10→8px for tighter
    stacking.
  - align-items switches from center to flex-start with margin-top
    on the checkbox so it sits at the first text line regardless of
    whether the <li> contains a bare text node or a <p>. Fixes the
    upward shift that appeared in the detail preview but not in the
    side panel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:46:01 +02:00
thandClaude Opus 4.6 82b2ebc15f bookmarks: column-based bookmark manager plugin
New plugin at public/plugins/bookmarks/ — bookmark lists stored as
cards tagged #bookmarks with a JSON body. Board cards render as
coloured tiles (70% card colour + surface) with a centred link icon,
title, and date/bucket metadata; no body preview.

Clicking opens a full-screen overlay with:
  - Named columns (click header to rename, right-click for
    rename + delete), + button to add columns
  - Bookmark items: favicon (stored as data: URI via server-side
    fetch-meta) + title with ellipsis. Click opens URL in new tab.
  - Right-click item → rename or delete
  - Positional drag-and-drop between and within columns — accent
    drop-indicator line shows the insertion point
  - Full-width slider sets relative column width, persisted per card
  - Horizontal wheel-to-scroll on the column container

Adding a bookmark reads the clipboard; if it contains a URL, the
backend fetches <title> + favicon (as base64 data: URI, ≤32 KB) so
the card is fully self-contained. Non-URL clipboard → manual entry
dialog. Also accepts card:123 links — rendered with a card icon in
the linked card's colour; click opens the linked card's detail view
or, if it's another bookmark list, switches the overlay to it.

Multiple bookmark lists supported: header button always shows a
picker (even with one list) with a "new list" option. New lists
inherit the active bucket. Overlay title is click-to-rename.

Tests: plugin registration, header button injection, board card
decoration, overlay open/close + column + item rendering, fetch-meta
API shape, card-link icon rendering, column-width persistence.

Also fixes the flaky undo due_date test — replaced a fixed sleep
with waitForStateCard polling so the undo PATCH has time to settle.

Bumps to 1.9.23.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:33:18 +02:00
thandClaude Opus 4.6 883640e20c search: saved searches via bookmark button
Small bookmark icon in the search header bar opens a popup listing
localStorage-persisted search strings. Clicking an entry fills the
input and runs the search; the x button deletes it; "save current
search" at the bottom saves the active input (no dupes). List
persists across sessions, no server round-trip.

Tests: 6 cases covering button/popup toggle, save + recall, delete,
save-current-button, and duplicate rejection. All snapshot/restore
localStorage in try/finally so they don't leak into user data or
cross-test state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 09:09:18 +02:00
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
thandClaude Opus 4.6 c2485b1307 fix(editor): appointment bucket-skip detects {id:null} shells too
The calendar day-popup invokes openModal with `{id: null, title, body}`
rather than a bare `null`, so `!card` was false and the #termin-body
exemption never fired — new appointments still inherited the active
bucket. Key the "is new" check on `card?.id` instead so both entry
shapes are treated the same.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:10:17 +02:00
thandClaude Opus 4.6 b08bdbaeb2 tests: assert new appointments don't inherit the active bucket
Opens the editor twice against a real bucket as activeBucket: once
with a #termin-seeded body (should stay empty in inp-bucket) and
once with a plain body (should pick up the active bucket id).
Skipped on fresh DBs that have no buckets to inherit from.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:06:10 +02:00
thandClaude Opus 4.6 0e3088db9a editor: new appointments never inherit the active bucket
The modal's default-bucket logic dropped new cards into the current
real bucket when opened from one. Calendar-created appointments
(body pre-seeded with #termin / #geburtstag) would then land in e.g.
"work" instead of living bucket-less as calendar entries should.
Detect the pre-filled tag on open and force inpBucket empty for
those; regular new cards still inherit the active bucket.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:04:39 +02:00
thandClaude Opus 4.6 29f92dbf9e search: right-click a hit to move it to a bucket
Drag was the only way to move a search hit into a bucket, which meant
opening the main board to find a drop target first. Right-click on a
search-hit now opens a small bucket picker anchored at the cursor:
clicking a bucket PATCHes the card's bucket_id and re-runs the
current search so the row updates in place. Includes an "(unsorted)"
entry that clears the bucket.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:02:51 +02:00
thandClaude Opus 4.6 d5ebbab6a5 tests: assert journal entries don't get the local pulse
Covers the board.js journal exemption on the 1× card-pulse-local
flash. Creates two probe cards via the raw API, flags both as
"just created locally" in state.recentlyCreatedIds, runs a render,
then asserts that the journal-format one (title 9999-12-31 — a
year we can't collide with real entries) skipped the pulse class
while the __prefixed__ control card still got it.

Both probes are deleted in a finally block so a failed assertion
doesn't leave them behind for later suites.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:53:49 +02:00
thandClaude Opus 4.6 c9749352ec board: no new-card pulse for fresh journal entries
Closing a new journal entry briefly flashed the card with the
1× local-created pulse. Journal entries are daily-log noise, not
"look at this new thing" moments, so the flash reads as clutter.
Detect journals at pulse time (title starts YYYY-MM-DD, no
termin/geburtstag, no caldav_uid, no " — <summary>" suffix —
mirrors calendar.js isJournal) and drop them from the pulse set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:51:23 +02:00
thandClaude Opus 4.6 412dc527a3 help: let the dialog scroll
The shared modal-container rule slapped `overflow: hidden` on
`#help > div` to clip border-radius, which won over the Tailwind
`overflow-y-auto` on the same element and froze the scroll. Move
help to its own rule that keeps the rounded corners + shadow and
leaves overflow alone so the inline overflow-y-auto takes effect.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:46:06 +02:00
thandClaude Opus 4.6 27cc3f98ac tooltip: kill stuck tips with watchdog + pointermove sanity
Tooltips could strand on screen when pointerleave didn't fire — fast
flicks off the target, re-renders that yanked the element out mid-
hover, or the tab losing focus. Three guards:

  - 5s max-life watchdog on every show, so the worst case is a late
    fade rather than a permanent overlay
  - pointermove check: if the pointer is no longer over the tip's
    owning target (or the target got detached from the DOM), hide
  - window blur + visibilitychange also hide immediately

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:43:12 +02:00
thandClaude Opus 4.6 64be9257f2 fix(tests): chat thread-settings test is idempotent
The row the test writes survives the backup/restore cycle (plugin
tables aren't in the export), so on the second run the GET returned
1 and the "starts at 0" assertion blew up. Explicitly reset the
flag at the start, then flip, then reset again — each run lands
the DB in the same state it started.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:37:35 +02:00
thandClaude Opus 4.6 0022f64c50 chat: merge caret + title into one back trigger
Caret and "chat" label were two hit targets with two hover states and
tooltips. Collapse them into a single <button id="chat-header-nav">
with one tooltip ("back to conversations") — the caret becomes a
visibility-only hint inside the button, shown when there's somewhere
to go back to, hidden on the list itself. The .navigable class
controls whether the button reads as interactive, so clicking the
label while already on the list is a cursor-default no-op.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:33:22 +02:00
thandClaude Opus 4.6 d2f7b98e6c chat: clicking the title goes back to the conversation list
Same path as the back-caret — a bigger, easier target than the
icon. No-op when already on the list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:30:14 +02:00
thandClaude Opus 4.6 8020ddc787 tests: purge editor drafts after a run
Tests that opened the card modal left hyprcrd-editor-draft:* entries
in localStorage. The pre-run purge cleared them before the baseline
backup, but anything the tests wrote during the run stayed behind —
the user's next card open then fired "unsaved draft from 0 min ago,
restore?" for a non-edit they didn't make. Repeat the purge after
restore so the runner closes with a clean slate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:28:29 +02:00
thandClaude Opus 4.6 87ce038b19 new-bucket dialog: Enter in name field submits
Typing a name and hitting Enter did nothing — users had to move to
the Create button. Wire Enter on the name input to click the submit
path so the common flow is keyboard-only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:23:57 +02:00
thandClaude Opus 4.6 ccba2932c2 calendar: include Bis: in the appointment-create template
CalDavSync already reads **Bis:** out of the body on push (and writes
it on pull), but the in-app template used when the user clicks a
calendar day to create an appointment didn't offer the field —
forcing the user to type the marker from scratch for multi-day or
ranged events. Drop it in next to **Zeit:** so the round-trip is
symmetric.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:23:13 +02:00