Files
STTracker/docs/plans/001-phase1-sdd/reports/task-17-report.md
T
Lucas WintherandClaude Opus 5.5 be04fab098 docs: phase 1 handoff, execution ledger and task reports
Captures the owner's requirements and decisions from the planning
conversation, current branch/worktree state, open tasks 17-19, pending
owner decisions and the controller's rulings so a new agent can resume.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 04:53:02 +01:00

21 KiB
Raw Blame History

Task 17 report: web app shell, auth, library, quick add, entry detail

Status: DONE_WITH_CONCERNS Branch: task/17 (worktree /home/user/STTracker/.worktrees/t17, based on task/16 at 0a54020)

Commits

  • 0bb871c feat(web): api client and display formatters
  • 70bc6c3 feat(web): theme tokens, self-hosted Inter, HTML entry
  • 439d138 feat(web): app shell, library, entry detail

What was built

  • index.html replaces the Task 16 placeholder: a Bun HTML import with src/theme.css and src/main.tsx. The server's import path (../../web/index.html in apps/server/src/main.ts) is unchanged, and the server was not touched.
  • src/api.ts: request() and ApiError(status, code, message). Cookie auth uses credentials: "same-origin". Every non-GET request sends X-Requested-With: sttracker, including auth/login and auth/register (per the coordinator's heads-up). Non-multipart non-GET requests always send content-type: application/json, even with no body. Multipart requests send only the CSRF header. Non-JSON errors become http_<status>; fetch failures become network (status 0). Wire types are mirrored locally because apps/web has no @sttracker/core dependency, and package.json must not change.
  • src/format.ts: formatRating(v, "5half"|"5"|"10"|"100"), formatPartialDate, formatDateRange (adds "(no exact date)" when either end is coarser than a day), parsePartialDate (string-only, never through Date), rollupLabel ("season 3 of 5", "5 seasons", "all 200 episodes"), plus status, media-type, ordinal and time-ago helpers.
  • Routes (wouter): /login, /register ("Create the admin account"), / and /entries/:id (the same Library component, so the list keeps its state), and a NotYet placeholder for /log, /lists, /owned, /changes, /sources, /settings and /about, which Task 18 replaces.
  • Shell: sidebar with Library, Log, Lists, Owned, Changes (pending count badge in --warn), the Sources list (connections with a status dot, then "All sources" or "Connect a source"), Settings, and the user name with "Sign out". On mobile (≤860px) it becomes a top bar.
  • List pane: search (debounced), the Quick add input with the exact placeholder (Enter creates the entry, selects it and clears the box), status chips plus a "Draft" chip, media-type chips, and rows showing cover or striped draft cover, title, type · meta (child roll-up, else status, plus year), and the Draft badge, stars, or an accent dot when in progress. An orange dot marks entries with pending changes. Also: "Show more" (cursor paging), skeleton loading, empty and no-match states.
  • Detail pane:
    • Header: cover; title with Draft badge; meta line ("Book · Read twice · last finished 14 Sep 2026"); a star rating slider in the user's scale; status select; History button; tag pills (remove with ×; add with Enter or a comma).
    • The orange change alert appears when pending changes target the entry or its engagements. "See what changed" goes to /changes?entry=:id.
    • "Link to catalogue" (drafts only) shows match suggestions with "Link" / "Not this", and a catalogue search whose "Link" calls POST entries/:id/move.
    • Boxes:
      • Reads/Plays/Watches/Listens: newest first, numbered "2nd read", with partial dates; add form, and remove.
      • Your copy: list, plus an add form for bought at, format, when, price and currency.
      • Attachments: upload with CSS background-image thumbnails.
      • Linked sources: derived from plugin:<id>:<connectionId> actors in fieldSources.
      • Details: custom fields for every field type, saved on blur or change. The box is hidden when no fields are defined.
      • Children: each with its own rating, linking to the child entry.
      • Your review: Gelasio, a Markdown textarea that saves on blur or Cmd/Ctrl+Enter.
    • History drawer: a non-modal side panel that takes focus, closes on Esc and returns focus to the History button. It shows field-level before → after diffs and "Restore".
  • Rating control: a role="slider" with aria-valuetext ("4½ stars"). Arrows step by half a star, Home/End jump, and Delete/Backspace clears. Pointer clicks pick a half or full star. Keyboard steps are saved once, 500 ms after the last key, on blur, or on unmount, so stepping does not write a history row per keypress. The 10- and 100-point scales use a labelled number field.
  • Rating scale setting: src/settings.ts (useRatingScale, setRatingScale) defaults to 5half. The API has no preferences endpoint, so the value is stored in localStorage for now. Task 18's Settings should call setRatingScale.

Files

apps/web/index.html, apps/web/src/{main.tsx,api.ts,format.ts,hooks.ts,settings.ts,theme.css}, apps/web/src/routes/{login,register,library,not-yet}.tsx, apps/web/src/components/{Shell,EntryDetail,DetailBoxes,HistoryDrawer,LinkToCatalogue,StarRating,Cover,Icon}.tsx, apps/web/src/fonts/{inter-latin-wght-normal.woff2,Inter-OFL.txt}, apps/web/test/{api,format}.test.ts.

TDD evidence

  • RED: bun test apps/web failed with Cannot find module '../src/api.ts' (and the same for format.ts): 0 pass, 2 fail.
  • GREEN: bun test apps/web gave 15 pass, 0 fail, 58 expect() calls. The tests include the brief's exact values: formatRating(90,"5half")="★★★★½", null gives "", {2025-03,month} gives "Mar 2025", {2019,year} gives "2019", and a non-2xx JSON error throws an ApiError carrying code. Additional tests cover CSRF and content-type headers on login and on body-less POSTs, multipart without content-type, query building, 204, non-JSON errors and network errors.
  • No DB code was touched, so the Postgres run is not applicable.

Typecheck

bun run typecheck (tsc --noEmit -p tsconfig.json) is clean, with no output.

Manual check (headless Chrome via CDP; nothing installed)

/usr/bin/google-chrome --headless=new was driven by a throwaway Bun CDP script in /tmp/t17-cdp/ (outside the repo). It ran against a fresh DATA_DIR, first in dev mode and then with NODE_ENV=production.

  • / redirects to /login. On /register, "Create the admin account" registers and auto-signs in, then the empty library shows.
  • Quick add: typing "From the Library of Jurgen Leitner" and pressing Enter created the entry, navigated to /entries/:id, highlighted the row and opened the detail.
  • Keyboard rating: 9 × ArrowRight gave aria-valuetext "4½ stars". The API then reported rating: 90 with exactly one history row for it.
  • Engagements, tags, copy and review render. The History drawer works. Mobile at 390px stacks correctly: the list alone on /, the detail alone with "‹ Library" on /entries/:id.
  • Production mode: HTML, CSS and JS chunks return 200, Inter is inlined into the CSS, and the browser console shows no errors.
  • Screenshots: /tmp/t17-cdp/01-login.png … 08-library-mobile.png (05 is the desktop detail, 06 the history, 07 the mobile detail).

Compared with the mockup (section 1): the layout matches: 190px sidebar, 360px list and a fluid detail pane, with the same tokens, dashed amber "+ Quick add" box, 40×56 row covers, 110×160 detail cover with shadow, stars in --rating, pills, the 2-column box grid with the same headings and row layout ("2nd read · 2 Sep – 14 Sep 2026", "1st read · Mar 2025 (no exact date)", Bought / When / price / Format) and a full-width Gelasio review box. Intended differences:

  • Filter chips sit under Quick add, as the brief requires.
  • A "Link to catalogue" box appears for drafts.
  • "Search your library…" replaces "Search 1,284 entries…", because the API returns no total.
  • The Changes badge and alert button use dark text on orange, because white on #f97316 is 2.80:1 and fails AA.
  • Sidebar group labels use --text-faint instead of the mockup's #6f69a3 (3.59:1).

Fixed during the check:

  • The catalogue-search icon overlapped its placeholder.
  • A visible scrollbar strip showed in the mobile top bar.
  • Each arrow keypress on the rating saved a PATCH and a history row.
  • The tag input animated its width, which the impeccable detector flagged.

Self-review

  • Accessibility:
    • Every input is labelled (sr-only labels where the mockup shows none).
    • Focus is visible: a 2px --focus (#9d92ff) ring at 6.3:1.
    • Chips use aria-pressed; rows use aria-current; the stars are a keyboard slider; the drawer manages focus; errors use role="alert".
    • Contrast was checked numerically: muted and faint text on all surfaces is ≥4.55:1, except --text-faint on --surface-3, so placeholders there use --text-muted (5.79:1).
  • The impeccable detector also flagged "repeating stripes" and "Inter" (overused font). Both are pinned by DESIGN.md (the striped draft cover and Inter for the UI), so they were kept.
  • No TODO comments in source.

Concerns

  1. No first-run endpoint. The API cannot tell the client whether any user exists. /register assumes first run and switches to "Create your account" with an invite field when the server answers invite_required (or the link has ?invite=). / → /login links "First time here? Create the admin account" either way. A GET /api/v1/setup (or a flag on /about) would let the client route straight to /register on first run.
  2. No route to list an entry's attachments. Core has listAttachments(db, userId, entity, entityId), but no route exposes it. The Attachments box uploads correctly but shows only files uploaded in the current session; after a reload they are not shown, though they remain stored. A GET /attachments?entity=entry&entityId= route is needed.
  3. No preferences endpoint. /me does not return users.preferences, so the rating scale lives in localStorage (default 5 stars with halves) until an endpoint exists.
  4. Gelasio is not self-hosted. No local copy was available and adding assets from the network was out of scope, so review text falls back to Georgia. Inter is vendored (OFL, licence included).
  5. Docs duties not done. The dispatch limited me to apps/web/** because Task 19 is editing docs in parallel. These entries need adding by whoever merges:
    • DECISIONS: localStorage rating scale; vendored Inter; dark text on --warn for AA; the register first-run heuristic; keyboard rating saves debounced.
    • TODO: items 1–4 above.
    • ARCHITECTURE: the web app's module layout (api.ts, format.ts, hooks.ts, settings.ts, routes and components).
  6. Linked sources are inferred from fieldSources actors, so a source that matched an entry but never wrote a field will not appear. A per-entry source-links route would fix this.
  7. In production the bundled HTML references /../../chunk-*.css|js, an artefact of Bun bundling ../../web/index.html from apps/server. Browsers normalise it to /chunk-*, and those load (200), so it works. Worth knowing for the Task 19 Docker check.

Fix round 1

Status: DONE_WITH_CONCERNS (one: Gelasio ships as TTF, not woff2; see below).

Commits

  • d547355 Merge branch 'feat/phase1' into task/17 (clean, no conflicts; tests green after it)
  • d204775 feat(server): auth status, attachments list, user preferences
  • 5fa5931 fix(web): review round 1: optimistic edits, friendly errors, preferences, Gelasio

Item 0: three API routes

  • GET auth/status (auth none) → { hasUsers }, via new hasUsers(db) in core/auth.ts.
  • GET attachments?entity=&entityId= (any:read) → requireLinkTarget (404 when foreign or unknown), then listAttachments, each with its links. A bad entity gives 400.
  • GET/PATCH me/preferences (any:read / any:write) → { ratingScale, defaultVisibility } in the new core/preferences.ts. The keys match what defaultVisibility() reads; the test proves quick add then uses "friends". The zod body is .partial().strict(). Unknown stored keys are kept.
  • RED: bun test apps/server/test/api.test.ts → 3 fail (the new tests). GREEN: SQLite 32 pass / 0 fail; Postgres 59 pass / 0 fail.
  • UI:
    • /login redirects to /register on a fresh instance.
    • /register shows "Create the admin account" or invite mode from auth/status (the invite_required fallback is kept).
    • Attachments come from the API.
    • The rating scale comes from me/preferences; the localStorage version is removed.

Items 1–3

  1. useEntryPatch:
    • The cached ["entry", id] updates optimistically in onMutate and rolls back in onError (then refetches).
    • One entry's PATCHes run in order through a TanStack mutation scope; I checked in query-core 5.105 that onMutate runs immediately even while the mutation is queued.
    • edit(cur => patch) builds from the latest cache. Tags and custom fields use it, through the addTag, removeTag and setCustomValue builders in the new src/edits.ts.
    • Only the last queued edit writes the server's answer back.
    • Test: "two quick tag adds built from the latest cache keep both".
  2. The review textarea is controlled, with a synced ref. Incoming server changes (Restore) replace the text unless the user has typed since, so a later blur cannot undo a restore.
  3. friendlyError(err) in format.ts maps validation field/reason pairs (core reasons and zod messages: not_url, not_date, bad_precision, out_of_range, required/too small, too_long/too big, not_number, unknown_option, duplicate, mime, password, username, currency/cents), and 413 or "larger than 25 MB" to "That file is too large. The limit is 25 MB.". Other API errors keep the server's message. It is used at every error display site (grep shows no raw .message left). It accepts unknown, which also covers ApiError. Tests cover the mapping.

Item 4: Gelasio

  • No offline copy existed and there is no woff2 tooling (fonttools/brotli are not installed, and installing them would need approval).
  • So I downloaded the official files, as the ruling allowed, from raw.githubusercontent.com/google/fonts/main/ofl/gelasio: Gelasio[wght].ttf (regular to bold, so 400 and 600) and Gelasio-Italic[wght].ttf, plus OFL.txt. They are vendored unmodified as apps/web/src/fonts/gelasio-wght.ttf (sha256 4daecea4…) and gelasio-italic-wght.ttf (sha256 52559e84…), with Gelasio-OFL.txt. Two variable @font-face rules cover weight 400–700, normal and italic.
  • Deviation: they are TTF (~170 KB each), not latin-subset woff2. That is filed in TODO.

Minors (all done)

  • Engagement delete has onError.
  • Sign out catches errors and still clears the cache, resets the library filters and navigates.
  • Ordinals use orderEngagements (finishedAt ?? startedAt, then createdAt; undated counts as oldest), with a test.
  • The remove button is visible under @media (hover: none).
  • The upload error goes through friendlyError.
  • The Changes count uses visually hidden " waiting" text; the source status dot has role="img".
  • Quick add uses readOnly + aria-busy and refocuses after success.
  • mediaTypeOverride ?? work.mediaType is used for row covers, History status labels and child rows.
  • MONTHS, shortDate and the past-tense verb are de-duplicated into format.ts.
  • The caret uses --text.
  • History shows token:<name> actors as App token “name”.

Docs

  • DESIGN.md: ratified dark text on --warn, --text-faint group labels, placeholders, self-hosted fonts.
  • DECISIONS: one new entry at the top, newest first.
  • TODO: Gelasio woff2, linked-sources inference, attachments of reads/copies, and a Known-issue note on the /../../chunk-* production paths.
  • ARCHITECTURE: the apps/web row and core/preferences.

Verification

  • bun run typecheck: clean.
  • bun test (full): 347 pass / 0 fail. With TEST_POSTGRES_URL=…55432…: 549 pass / 0 fail.
  • CDP re-check (headless Chrome, fresh instance, production mode):
    • A fresh /login redirects to /register "Create the admin account".
    • Quick add keeps focus on #quick-add.
    • Two quick tag adds give ["cosmic horror","re-read"] in the UI and on the server.
    • Review: "First thoughts." → "Second thoughts." → Restore shows "First thoughts." in the UI, and after a blur the server still has "First thoughts.".
    • An uploaded attachment is still listed after reload.
    • After sign-out, /login stays on /login, and /register shows "Create your account" with the invite field.
    • The browser console was empty.
  • Screenshots: /tmp/t17-cdp/fix1-detail.png, /tmp/t17-cdp/fix1-register-invite.png.

Fix round 2

Status: DONE. Commit: 4608c97 fix(web): keep review text on a failed save; review round 2. Files touched: apps/web/src/{format.ts, components/DetailBoxes.tsx, components/EntryDetail.tsx, components/HistoryDrawer.tsx, routes/register.tsx}, apps/web/test/format.test.ts, packages/core/src/preferences.ts.

  1. Failed review save keeps the text.
    • Callback order is not reliable. The hook-level onError rollback, React's effect flush and the per-call onError can interleave, so I did not rely on "restore synced before the rollback lands".
    • The sync logic is now pure helpers in format.ts (Draft, draftIncoming, draftStartSave, draftSaved, draftFailedSave):
      • while a save is in flight, incoming values (optimistic, then rollback) are ignored;
      • on failure, synced goes back to was, so the local text counts as unsaved.
    • Overlapping saves are counted (inFlight).
    • Tests: "a rollback after a failed save keeps the local text", in both orders (rollback first, error callback first), plus "Restore replaces untouched text but not edited text".
    • Second bug found by the CDP check:
      • The failed save's onError invalidates the entry, the refetch fails while the server is down, and EntryDetail then showed "Couldn't load this entry". That unmounted the review box and lost the text.
      • EntryDetail now shows the error screen only when there is no cached data; TanStack keeps data after a failed refetch.
  2. was is captured before any state update and used in the failure path.
  3. History restore uses scope: { id: "entry-patch:<id>" }, the same scope as useEntryPatch, so it queues behind a pending blur-save.
  4. updatePreferences does its read-modify-write in one transaction, with forUpdate() on Postgres. This follows connections.ts; SQLite runs on a single connection.
  5. register invalidates ["auth-status"] after success.
  6. friendlyError(err, { ratingScale }): out-of-range ratings read "Pick a rating between 0 and 5 stars." (10 → "between 0 and 10", 100 → "between 0 and 100"; custom fields: "Pick a rating for Mood between …"). It is passed the user's scale at the entry and custom-field error sites. Tests cover this.

Verification

  • bun run typecheck: clean.
  • bun test apps/web apps/server packages/core: 241 pass / 0 fail (SQLite). With TEST_POSTGRES_URL: 436 pass / 0 fail.
  • CDP check (headless Chrome; the driver spawned and killed its own server, restarted on the same DATA_DIR):
    1. "Saved first." reached the server.
    2. With the server stopped, I typed "Written while the server was down." and blurred. The error shows ("Can't reach the server. Check your connection and try again.") and the text is kept.
    3. After the restart the text is still there, and blurring again saves it (the server has the new text).
    4. The browser console was empty.
  • Screenshot: /tmp/t17-cdp/fix2-failed-save.png.

Noted, not changed (outside this round's files)

While the server is down, the library list pane replaces its rows with the error message on a failed refetch. Same pattern as EntryDetail; routes/library.tsx was not in this round's file list, so it is unchanged.


Fix round 3

Status: DONE. Touched only apps/web/src/{components/DetailBoxes.tsx,hooks.ts,format.ts} and apps/web/test/format.test.ts.

Fix (saving stuck on overlapping saves):

  • Bookkeeping no longer uses per-call mutate(..., { onSuccess, onError }); those fire only for the observer's latest call.
  • useEntryPatch(id, { onSettled }) gains a hook-level callback. It is held in a ref and fires once for every mutation, with (patch, error).
  • ReviewBox pushes { was, stored } onto a FIFO ref per save. Each settle shifts the oldest; the entry-patch scope is serial, so they settle in order. It then calls draftSettled(draft, save, ok).
  • The draft helpers in format.ts changed:
    • Draft is now { text, synced, pending, incoming, echoes }, with newDraft, draftIncoming, draftStartSave and draftSettled.
    • While saves are pending, incoming values are only remembered.
    • When the last save settles, the remembered value is applied unless it is one of our own echoes (a save's optimistic stored or rollback was).
  • That echo filter fixes a subtle case the new tests caught: in fail-then-success, the last remembered value is the first save's rollback ("old"). Re-applying it would replace text the server already has. A genuine outside change (a Restore, another tab) is still applied once saving ends.

Tests (format.test.ts, "review draft sync"):

  • A rollback after a failed save keeps the local text.
  • Two overlapping saves:
    • success/success: both settle, pending returns to 0 and the echoes clear;
    • fail/success: ends synced;
    • success/fail: the text stays unsaved, with synced at the first save.
  • An incoming value that arrives while saving is applied once saving ends.
  • An outside change replaces untouched text but not edited text.
  • RED: draftSettled was not exported. GREEN: bun test apps/web gives 28 pass / 0 fail.

Verification:

  • bun run typecheck: clean.
  • Full bun test: 352 pass / 0 fail (SQLite); with TEST_POSTGRES_URL, 554 pass / 0 fail.
  • The browser failed-save check (/tmp/t17-cdp/drive3.ts) passed again: text typed during the outage is kept, survives the restart, and re-saves to the server. The console was empty.