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>
21 KiB
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
0bb871cfeat(web): api client and display formatters70bc6c3feat(web): theme tokens, self-hosted Inter, HTML entry439d138feat(web): app shell, library, entry detail
What was built
index.htmlreplaces the Task 16 placeholder: a Bun HTML import withsrc/theme.cssandsrc/main.tsx. The server's import path (../../web/index.htmlinapps/server/src/main.ts) is unchanged, and the server was not touched.src/api.ts:request()andApiError(status, code, message). Cookie auth usescredentials: "same-origin". Every non-GET request sendsX-Requested-With: sttracker, includingauth/loginandauth/register(per the coordinator's heads-up). Non-multipart non-GET requests always sendcontent-type: application/json, even with no body. Multipart requests send only the CSRF header. Non-JSON errors becomehttp_<status>; fetch failures becomenetwork(status 0). Wire types are mirrored locally because apps/web has no@sttracker/coredependency, andpackage.jsonmust 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 throughDate),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 sameLibrarycomponent, so the list keeps its state), and aNotYetplaceholder for/log,/lists,/owned,/changes,/sources,/settingsand/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 infieldSources. - 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"witharia-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 to5half. The API has no preferences endpoint, so the value is stored in localStorage for now. Task 18's Settings should callsetRatingScale.
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/webfailed withCannot find module '../src/api.ts'(and the same forformat.ts): 0 pass, 2 fail. - GREEN:
bun test apps/webgave 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 anApiErrorcarryingcode. 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 reportedrating: 90with 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
#f97316is 2.80:1 and fails AA. - Sidebar group labels use
--text-faintinstead 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 usearia-current; the stars are a keyboard slider; the drawer manages focus; errors userole="alert". - Contrast was checked numerically: muted and faint text on all surfaces is ≥4.55:1, except
--text-fainton--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
TODOcomments in source.
Concerns
- No first-run endpoint. The API cannot tell the client whether any user exists.
/registerassumes first run and switches to "Create your account" with an invite field when the server answersinvite_required(or the link has?invite=)./→/loginlinks "First time here? Create the admin account" either way. AGET /api/v1/setup(or a flag on/about) would let the client route straight to/registeron first run. - 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. AGET /attachments?entity=entry&entityId=route is needed. - No preferences endpoint.
/medoes not returnusers.preferences, so the rating scale lives in localStorage (default 5 stars with halves) until an endpoint exists. - 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).
- 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
--warnfor 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).
- DECISIONS: localStorage rating scale; vendored Inter; dark text on
- Linked sources are inferred from
fieldSourcesactors, so a source that matched an entry but never wrote a field will not appear. A per-entry source-links route would fix this. - In production the bundled HTML references
/../../chunk-*.css|js, an artefact of Bun bundling../../web/index.htmlfromapps/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
d547355Merge branch 'feat/phase1' into task/17 (clean, no conflicts; tests green after it)d204775feat(server): auth status, attachments list, user preferences5fa5931fix(web): review round 1: optimistic edits, friendly errors, preferences, Gelasio
Item 0: three API routes
GET auth/status(auth none) →{ hasUsers }, via newhasUsers(db)incore/auth.ts.GET attachments?entity=&entityId=(any:read) →requireLinkTarget(404 when foreign or unknown), thenlistAttachments, each with itslinks. A badentitygives 400.GET/PATCH me/preferences(any:read / any:write) →{ ratingScale, defaultVisibility }in the newcore/preferences.ts. The keys match whatdefaultVisibility()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:
/loginredirects to/registeron a fresh instance./registershows "Create the admin account" or invite mode fromauth/status(theinvite_requiredfallback is kept).- Attachments come from the API.
- The rating scale comes from
me/preferences; the localStorage version is removed.
Items 1–3
useEntryPatch:- The cached
["entry", id]updates optimistically inonMutateand rolls back inonError(then refetches). - One entry's PATCHes run in order through a TanStack mutation
scope; I checked in query-core 5.105 thatonMutateruns immediately even while the mutation is queued. edit(cur => patch)builds from the latest cache. Tags and custom fields use it, through theaddTag,removeTagandsetCustomValuebuilders in the newsrc/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".
- The cached
- The review textarea is controlled, with a
syncedref. Incoming server changes (Restore) replace the text unless the user has typed since, so a later blur cannot undo a restore. friendlyError(err)informat.tsmapsvalidationfield/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.messageleft). It acceptsunknown, which also coversApiError. 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) andGelasio-Italic[wght].ttf, plusOFL.txt. They are vendored unmodified asapps/web/src/fonts/gelasio-wght.ttf(sha256 4daecea4…) andgelasio-italic-wght.ttf(sha256 52559e84…), withGelasio-OFL.txt. Two variable@font-facerules 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, thencreatedAt; 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-busyand refocuses after success. mediaTypeOverride ?? work.mediaTypeis used for row covers, History status labels and child rows.MONTHS,shortDateand the past-tense verb are de-duplicated intoformat.ts.- The caret uses
--text. - History shows
token:<name>actors as App token “name”.
Docs
- DESIGN.md: ratified dark text on
--warn,--text-faintgroup 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. WithTEST_POSTGRES_URL=…55432…: 549 pass / 0 fail.- CDP re-check (headless Chrome, fresh instance, production mode):
- A fresh
/loginredirects 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,
/loginstays on/login, and/registershows "Create your account" with the invite field. - The browser console was empty.
- A fresh
- 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.
- Failed review save keeps the text.
- Callback order is not reliable. The hook-level
onErrorrollback, React's effect flush and the per-callonErrorcan interleave, so I did not rely on "restoresyncedbefore 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,
syncedgoes back towas, 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
onErrorinvalidates the entry, the refetch fails while the server is down, andEntryDetailthen showed "Couldn't load this entry". That unmounted the review box and lost the text. EntryDetailnow shows the error screen only when there is no cached data; TanStack keepsdataafter a failed refetch.
- The failed save's
- Callback order is not reliable. The hook-level
wasis captured before any state update and used in the failure path.- History restore uses
scope: { id: "entry-patch:<id>" }, the same scope asuseEntryPatch, so it queues behind a pending blur-save. updatePreferencesdoes its read-modify-write in one transaction, withforUpdate()on Postgres. This followsconnections.ts; SQLite runs on a single connection.registerinvalidates["auth-status"]after success.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). WithTEST_POSTGRES_URL: 436 pass / 0 fail.- CDP check (headless Chrome; the driver spawned and killed its own server, restarted on the same DATA_DIR):
- "Saved first." reached the server.
- 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.
- After the restart the text is still there, and blurring again saves it (the server has the new text).
- 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).ReviewBoxpushes{ 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 callsdraftSettled(draft, save, ok).- The draft helpers in
format.tschanged:Draftis now{ text, synced, pending, incoming, echoes }, withnewDraft,draftIncoming,draftStartSaveanddraftSettled.- 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
storedor rollbackwas).
- 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,
pendingreturns to 0 and the echoes clear; - fail/success: ends synced;
- success/fail: the text stays unsaved, with
syncedat the first save.
- success/success: both settle,
- An incoming value that arrives while saving is applied once saving ends.
- An outside change replaces untouched text but not edited text.
- RED:
draftSettledwas not exported. GREEN:bun test apps/webgives 28 pass / 0 fail.
Verification:
bun run typecheck: clean.- Full
bun test: 352 pass / 0 fail (SQLite); withTEST_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.