feat(program): approve a proposed program in the app - #95
Merged
Conversation
mohammadp1001
force-pushed
the
feat/80-save-program-draft
branch
from
August 23, 2026 06:17
cd037de to
8df9ee5
Compare
mohammadp1001
force-pushed
the
feat/81-approve-program
branch
from
August 23, 2026 06:17
0b75a9f to
c0e664e
Compare
mohammadp1001
force-pushed
the
feat/80-save-program-draft
branch
from
August 23, 2026 06:22
8df9ee5 to
c344526
Compare
mohammadp1001
force-pushed
the
feat/81-approve-program
branch
from
August 23, 2026 06:22
c0e664e to
160a74f
Compare
A draft the user never sees is a feature that does nothing. This is the screen where the proposal from #80 becomes a decision. It shows what would change, not the YAML. The user is being asked to replace what they are following, and the only question they actually have is which exercises are new, which are gone, and which moved. A raw file dump does not answer that. `lib/program-diff.ts` holds the comparison, pure and unit-tested, because the rules are the fiddly part. Days pair by name before position, so resequencing a split does not read as removing every day and adding it back, and an unmatched day pairs by position so a rename reads as a rename rather than a simultaneous add and remove. Exercises compare by `exerciseId`, not by display name - the lift is the durable thing, not the label it happens to carry. Dropped exercises are listed after a day's contents, because "what am I losing?" only makes sense once you can see what you are getting. With no active program every exercise is reported as new, which is the honest reading for a user who has none. The rationale is shown above the diff. A list of changes with no reason behind it is not something anyone can judge. The banner sits above the program itself and is rendered on the server, so it is there on first paint rather than popping in - it is the one thing on that screen that needs an answer. Both actions take two taps: neither replacing a training block nor throwing away a proposal should happen on a stray thumb. Approving and discarding both re-read the pending draft rather than trusting the id from the screen, because the coach may have replaced it or the user may have answered it in another tab since it loaded. A stale id gets a 409 saying so. `lib/db/program-proposal.ts` assembles the proposal and its diff once, shared by the page and the API route - two implementations of a comparison this subtle would drift. Verified against the running app: a proposal renders with its counts in both Persian and English with RTL intact and no nulls in the markup, approving it through the real route makes it active and retires the previous program, and replaying the same approval returns 409.
mohammadp1001
force-pushed
the
feat/81-approve-program
branch
from
August 23, 2026 06:30
160a74f to
1554507
Compare
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.
Closes #81 — the last of the 13.
Why
A draft the user never sees is a feature that does nothing. This is where the
proposal from #80 becomes a decision.
It shows what would change, not the YAML
The user is being asked to replace what they are following. The only question
they actually have is which exercises are new, which are gone, and which
moved. A raw file dump does not answer that.
The diff rules (the actual design problem)
lib/program-diff.ts— pure and unit-tested, because this is the fiddly part:removing every day and adding it back.
than a simultaneous add and remove.
exerciseId, never by display name. The lift is thedurable thing, not the label it happens to carry — renaming an exercise must
not look like swapping it out.
makes sense once you can see what you are getting.
who has none.
The rationale comes before the diff
A list of changes with no reason behind it is not something anyone can judge.
Interaction
there on first paint rather than popping in. It is the one thing on that
screen that needs an answer.
throwing away a proposal should happen on a stray thumb.
from the screen — the coach may have replaced it, or the user may have
answered it in another tab. A stale id gets a
409saying exactly that.lib/db/program-proposal.tsassembles the proposal and its diff once, shared bythe page and the API route. Two implementations of a comparison this subtle
would drift.
Verified against the running app
1 new · 2 dropped · 1 changed) inboth Persian and English,
dircorrect, noundefined/nullin markup.isDraft, andretires the previous program rather than deleting it.
409.Tests
lib/program-diff.test.ts(11): identical programs, added/removed/changedexercises, comparison by id rather than name, reordered days not reported as
churn, a rename read as a rename, whole days added and dropped, a renamed
program, and the no-previous-program case.
app/api/programs/draft/route.test.ts(7): null when nothing waits, theproposal returned with reasoning and diff, names resolved rather than null,
approve activating and clearing, discard leaving the active program alone, a
stale id refused with 409, and a non-draft program refused.
Full suite: 443 passed, 0 failed.
tsc --noEmitandeslintclean.