Skip to content

feat(journal): write how a session felt, from the summary or mid-run - #140

Merged
kilianmc merged 1 commit into
devfrom
feat/journal-entries
Sep 8, 2026
Merged

kilianmc merged 1 commit into
devfrom
feat/journal-entries

Conversation

@kilianmc

@kilianmc kilianmc commented Sep 8, 2026

Copy link
Copy Markdown
Owner

journal_entry has carried free text, three 1-5 scales and a weigh-in since the schema was
written, with nothing able to fill it — no endpoint, no UI, no rows. This adds both ends.

The endpoint

PUT /api/journal/{client_uuid} upserts on (user_id, client_uuid) with the user taken from
the token, so replay-safety and authorisation are one mechanism rather than two. An
unowned logged_session_id gets the same 404 as one that does not exist. No free text is
echoed back, so nothing in the response needs escaping downstream, and the not_empty CHECK
is enforced at the edge as a 422 rather than reaching Postgres as a 500.

No migration — DB stays at 0008, and GENERATOR_VERSION is untouched (verified against
the diff, not asserted).

Two placements, one draft, one row

The write form renders inline on the session summary and in an overlay over the running
player, because a note is often worth taking mid-session rather than only after Finish. Both
reuse the run's uuid, so a second submit edits the row instead of adding one.

The draft lives on the persisted RunRecord and is written on every keystroke, so a failed
write cannot lose what was typed
— the failure mode this feature must not have. It is not in
the sets outbox: pending/quarantined are typed for LoggedSetInput and keyed on
set_index, which an entry has none of.

The save control states what the press would do: absent when there is nothing to send, Save
when nothing has landed (including after a 4xx, where "update" would be a lie), Update only
when the server holds a row.

The overlay

Opening it stops the countdown through the player's existing pause, and gives back only the
pause it took
— a run the climber had already paused deliberately stays paused on close. A
guard caught the opposite behaviour during development: the first version resumed a deliberate
pause.

This is the app's first overlay, so it owns the machinery every later one copies: focus in
and back to its opener, a Tab trap, Escape, and a visible Close as the primary way out. Native
<dialog> was rejected because jsdom implements neither showModal nor close, which would
have left every focus and trap claim unprovable in the gate. absolute inside the player
rather than fixed, because fixed and viewport units resolve against kilianmc.com's viewport
in the federated mount.

⚠️ In the federated mount, Escape closes the host's view rather than this overlay, because
portfolio-shell's overlay hook closes on Escape unconditionally. Accepted deliberately rather
than worked around; the visible Close control is why it is survivable.

Weight

The weigh-in input is gated on show_body_metrics, and an unread profile reads as off, so
nothing prompts for a weight it was told not to ask for. No copy anywhere frames weight as
something to reduce. The per-set body-weight snapshot stays null until #135, its only consumer,
so the offline player write path is untouched.

Guards

Every guard added was shown failing before it was trusted — 22 sabotages across the four
rounds, each red on the arm that owns its claim, then restored. Including the reviewer's own
reported bug (a saved, unchanged entry still offering a control) and an IDOR arm that returned
a stranger's logged_session_id when the ownership scope was removed.

Gate: web 1364, server 1377, both green. The server half ran against a scratch database, so
a clicked-through local account did not have to be destroyed to satisfy the pollution guard.

Follow-ups this creates, deliberately not included

  • logged_session.notes is now permanently empty, making it a real drop candidate for the
    column sweep rather than a theoretical one. Dropping it is a migration plus a prod dispatch.
  • Reading entries back — the trend chart and entry list — is the next PR, and it has to ask "is
    a run live?" before minting a uuid, or writing from the diary mid-session would produce a
    second row for one session.
  • The confirmation is not announced to a screen reader. The app has no permanently mounted live
    region, and one created in the same tick as its text announces unreliably, so a fake
    affordance was rejected over a real one. RoutePending has the same gap.

🤖 Generated with Claude Code

`journal_entry` has carried free text, three 1-5 scales and a weigh-in since
the schema was written, with nothing able to fill it: no endpoint, no UI, no
rows. This adds both ends.

`PUT /api/journal/{client_uuid}` upserts on `(user_id, client_uuid)` with the
user taken from the token, so replay-safety and authorisation are one
mechanism rather than two. An unowned `logged_session_id` gets the same 404 as
one that does not exist. No free text is echoed back, so nothing in the
response needs escaping downstream. The `not_empty` CHECK is enforced at the
edge as a 422 rather than reaching Postgres as a 500.

The write form renders in two places against one draft: inline on the session
summary, and in an overlay over the running player, because a note is often
worth taking mid-session rather than only after Finish. Both reuse the run's
uuid, so a second submit edits the row instead of adding one. The draft lives
on the persisted `RunRecord` and is written on every keystroke, so a failed
write cannot lose what was typed. The save control states what the press would
do — absent when there is nothing to send, Save when nothing has landed
(including after a 4xx), Update only when the server holds a row.

Opening the overlay stops the countdown through the player's existing pause,
and gives back only the pause it took: a run the climber had already paused
deliberately stays paused on close.

This is the app's first overlay, so it owns the machinery every later one will
copy — focus in and back to its opener, a Tab trap, Escape, and a visible
Close as the primary way out. Native `<dialog>` was rejected because jsdom
implements neither `showModal` nor `close`, which would have left every focus
and trap claim unprovable in the gate.

The weigh-in input is gated on `show_body_metrics`, and an unread profile
reads as off, so nothing prompts for a weight it was told not to ask for. No
copy anywhere frames weight as something to reduce.

No migration: DB stays at `0008` and `GENERATOR_VERSION` is untouched. The
per-set body-weight snapshot stays null until #135, its only consumer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 8, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
climb-trainer Ready Ready Preview Sep 8, 2026 8:09am UTC

@kilianmc
kilianmc merged commit 1c70678 into dev Sep 8, 2026
5 checks passed
@kilianmc
kilianmc deleted the feat/journal-entries branch September 8, 2026 08:11
kilianmc added a commit that referenced this pull request Sep 9, 2026
…eet prose (#141)

Completes #16b. The write half shipped in #140; this is the reading half.

The diary is now one section per plan, each the same shape: the plan's name
(renameable), then a card holding its chart, those readings as a table, and its
own entries. feel/sleep/skin are RAW readings over one span told apart by stroke
style, never colour alone. Entries open in a sheet and edit in place, reusing
each entry's own client_uuid so an edit can never mint a second row for one
session — which is why this no longer waits on #80.

The smoothed weight trend was cut rather than built (Kilian), so the Body weight
section is absent and the weigh-in is a column of the readings table.
JournalResponse.trends is left on the wire with no consumer, recorded in the
findings inbox rather than removed here.

Server: GET /api/journal in two flavours, scoped to a plan and unscoped, each its
own cache entry so opening the whole history and going back costs no read. The
read fetches cap + 1 to tell a real truncation from a count that merely lands on
the cap. PUT /api/plans/{id}/name renames a plan, refused for a demo principal.

Fixes found while building:
- the scope switch changed the query key, so the gate met the empty key with its
  loading page, the document collapsed and the browser clamped the reader to the
  top. keepPreviousData holds the previous scope until the new read lands, with a
  test that holds the read open to observe the in-flight moment.
- .ct-app__chartbox rendered 300x123.75 inside a 1312px card at every screen
  size. Cross-axis auto margins suppress the flex stretch, so the wrapper
  shrink-wrapped to the svg's 300x150 default. It now carries inline-size: 100%,
  and its size steps 320x132 / 606x250 / 904x373 at the named screen sizes.

Stylesheets now carry NO comments at all (Kilian): 1,530 comment lines removed
from the 15 already-committed stylesheets, with _diary.scss arriving new and
already free of them — 1,659 lines over 16 files as the sweep actually ran. The compiled CSS is byte-identical — both asset hashes match the
pre-sweep build — so this removed prose and nothing else. .scss is brought under
tests/test_comment_budget.py at a cap of zero, with a third detector for a
trailing comment after code and no allowlist exemption, since a length argument
collapses at zero. The rules that prose carried and nothing else stated are now
tripwire lines in CLAUDE.md; the reasons are gone deliberately.

CLAUDE.md's landing-page tripwire was stale and is corrected: text over a
photograph is legal behind the --ct-scrim overlay, which the code has shipped
since it was restored, and the lightest stop under copy is the contrast floor.

Co-authored-by: Kilian Mateo <13885240+kilianmc@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — 7c263672 Deployed Sep 8, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant