feat(journal): write how a session felt, from the summary or mid-run - #140
Merged
Merged
Conversation
`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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
journal_entryhas carried free text, three 1-5 scales and a weigh-in since the schema waswritten, 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 fromthe token, so replay-safety and authorisation are one mechanism rather than two. An
unowned
logged_session_idgets the same 404 as one that does not exist. No free text isechoed back, so nothing in the response needs escaping downstream, and the
not_emptyCHECKis enforced at the edge as a 422 rather than reaching Postgres as a 500.
No migration — DB stays at
0008, andGENERATOR_VERSIONis untouched (verified againstthe 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
RunRecordand is written on every keystroke, so a failedwrite cannot lose what was typed — the failure mode this feature must not have. It is not in
the sets outbox:
pending/quarantinedare typed forLoggedSetInputand keyed onset_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 neithershowModalnorclose, which wouldhave left every focus and trap claim unprovable in the gate.
absoluteinside the playerrather than
fixed, becausefixedand viewport units resolve against kilianmc.com's viewportin the federated mount.
portfolio-shell's overlay hook closes on Escape unconditionally. Accepted deliberately ratherthan 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, sonothing 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_idwhen 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.notesis now permanently empty, making it a real drop candidate for thecolumn sweep rather than a theoretical one. Dropping it is a migration plus a prod dispatch.
a run live?" before minting a uuid, or writing from the diary mid-session would produce a
second row for one session.
region, and one created in the same tick as its text announces unreliably, so a fake
affordance was rejected over a real one.
RoutePendinghas the same gap.🤖 Generated with Claude Code