You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Writers Room already logs timed sprints ("Write for 10") and persists the prose typed during a finished sprint as appendedText on the exercise record — but nothing consumes it. The text is stranded in Recent sessions history; the writer has to copy/paste it into the draft by hand (or lose it). docs/features/writers-room.md names this as deliberately deferred: "a 'promote to draft' action that merges appendedText into the work — the session record carries everything needed to add it later." This closes that gap for Goal 5 (Creative Production: "make it easier to create than to consume") and FR-22 (Writers Room drafting with versioned drafts).
Approach (decided)
One-click "Add to draft" on a finished sprint row, implemented as a server-side append so the promotion is durable, idempotent, and federates with the exercise record.
Server
server/services/writersRoom/local.js — add promoteExercise(id) next to finishExercise:
Load the exercise; 404 if missing.
400 unless status === 'finished', workId is set, appendedText is a non-empty string after trim(), and promotedAt is not already set (second click → 400 'Exercise already promoted').
Read the work's active draft via getWorkWithBody(workId); build the new body as body.trimEnd() === '' ? text : ${body.trimEnd()}\n\n${text}\n`` where text = appendedText.trim(); persist through the existing `saveDraftBody(workId, nextBody)` (omit `referencedIngredientIds` so the prior snapshot is preserved — same absent-vs-empty rule as the PUT route). No new draft version is created (see Non-goals).
Stamp promotedAt (ISO) and promotedDraftVersionId (the manifest's activeDraftVersionId) on the exercise, bump updatedAt, store().writeExercise(...), then emitRecordUpdated(WRITERS_ROOM_EXERCISE_KIND, id) exactly as finishExercise does so subscribed peers converge.
Add promotedAt and promotedDraftVersionId to EXERCISE_RESTORABLE so conflict-restore keeps them.
server/routes/writersRoom.js — POST /exercises/:id/promote (no body; mirrors the /finish and /discard routes). Regenerate the route catalog with npm run generate:api-docs and commit server/lib/apiRouteCatalog.generated.json.
Federation: the two new fields ride inside the exercise's data JSONB and pass through sanitizeExerciseForSync's ...raw spread unchanged. This is additive — an older peer ignores the fields — so no SCHEMA_VERSIONS.writersRoomExercises bump and no migration (same posture as other additive record fields). writers_room_exercises DDL is untouched.
Client
client/src/services/apiWritersRoom.js — promoteWritersRoomExercise(id, options) → POST /writers-room/exercises/:id/promote.
client/src/components/writers-room/ExercisePanel.jsx — in the Recent sessions list, render an "Add to draft" button (lucide FilePlus, 44px tap target, aria-label="Add sprint text to draft") on rows where ex.status === 'finished' && ex.appendedText && ex.workId === activeWork?.id && !ex.promotedAt. Already-promoted rows show a static ✓ in draft marker instead. New props: editorDirty (button disabled with title="Save first" while true — same convention as the WorkEditor overflow-menu actions) and onWorkChange. On success: toast.success('Added N words to draft'), onWorkChange(result.work), then refresh() so the row flips to the promoted marker.
client/src/components/writers-room/WorkEditor.jsx — two small changes so a sibling panel can safely push a server-side body change into the editor:
New prop onDirtyChange(dirty) fired from an effect on the existing dirty value.
Widen the key-effect guard at the top of the body-sync useEffect (the prevKey check): also re-sync body/savedBody when work.activeDraftBody !== savedBody && !dirty. A normal save passes activeDraftBody: body (equal to savedBody) so existing behavior is unchanged; only a genuinely new server body on a clean editor is applied.
client/src/pages/WritersRoom.jsx — hold editorDirty state; pass onDirtyChange={setEditorDirty} to WorkEditor and editorDirty={editorDirty} onWorkChange={handleWorkChange} to ExercisePanel.
Docs
docs/features/writers-room.md — replace the "Not built" sentence so it lists only pause/resume as deferred, and document the promote action + the two new exercise fields under Writing Exercise.
Scope: small
Acceptance criteria
POST /api/writers-room/exercises/:id/promote on a finished, work-linked exercise with appendedText appends the trimmed text to the work's active draft separated by a blank line, bumps the draft's wordCount, and returns { exercise, work } where work.activeDraftBody is the new body.
The promoted exercise carries promotedAt and promotedDraftVersionId; both survive restoreExercise (listed in EXERCISE_RESTORABLE).
A second promote of the same exercise returns 400; promoting a running/discarded exercise, one with workId: null, or one with empty/whitespace appendedText returns 400; unknown id returns 404.
Promoting does not create a new draft version and does not change the stored referencedIngredientIds of the active draft.
server/services/writersRoom/local.test.js covers the success path (body + wordCount + stamps) and the four 400 cases above; server/routes/writersRoom.test.js covers the route delegating to the service.
In the UI, a finished sprint tied to the open work shows Add to draft; clicking it updates the editor buffer with the appended text without a page reload, and the row then shows the promoted marker. Rows for standalone sprints (workId: null) and already-promoted sprints show no button.
The button is disabled with a "Save first" hint while the editor has unsaved changes.
WorkEditor.test.jsx asserts a clean editor adopts a new activeDraftBody passed via props, and a dirty editor does not; a new ExercisePanel.test.jsx covers button visibility rules, the dirty-disabled state, and the success callback.
server/lib/apiRouteCatalog.generated.json includes the new route (regenerated, not hand-edited); docs/features/writers-room.md updated.
cd server && npm test and cd client && npm test pass.
Non-goals
No pause/resume mid-sprint (the other deferred item in the doc).
No "pick a target work" flow for standalone sprints (workId: null) — they stay history-only.
No automatic snapshot/new draft version on promote; the writer uses the existing versions UI if they want a checkpoint first.
No insert-at-cursor placement — the sprint text is appended at the end of the draft.
No un-promote / undo; the draft edit is a normal saved edit the writer can revise.
No federation schema-version bump or DB migration (fields are additive and ride the existing JSONB).
Motivation
Writers Room already logs timed sprints ("Write for 10") and persists the prose typed during a finished sprint as
appendedTexton the exercise record — but nothing consumes it. The text is stranded inRecent sessionshistory; the writer has to copy/paste it into the draft by hand (or lose it).docs/features/writers-room.mdnames this as deliberately deferred: "a 'promote to draft' action that mergesappendedTextinto the work — the session record carries everything needed to add it later." This closes that gap for Goal 5 (Creative Production: "make it easier to create than to consume") and FR-22 (Writers Room drafting with versioned drafts).Approach (decided)
One-click "Add to draft" on a finished sprint row, implemented as a server-side append so the promotion is durable, idempotent, and federates with the exercise record.
Server
server/services/writersRoom/local.js— addpromoteExercise(id)next tofinishExercise:404if missing.400unlessstatus === 'finished',workIdis set,appendedTextis a non-empty string aftertrim(), andpromotedAtis not already set (second click →400 'Exercise already promoted').getWorkWithBody(workId); build the new body asbody.trimEnd() === '' ? text :${body.trimEnd()}\n\n${text}\n`` wheretext = appendedText.trim(); persist through the existing `saveDraftBody(workId, nextBody)` (omit `referencedIngredientIds` so the prior snapshot is preserved — same absent-vs-empty rule as the PUT route). No new draft version is created (see Non-goals).promotedAt(ISO) andpromotedDraftVersionId(the manifest'sactiveDraftVersionId) on the exercise, bumpupdatedAt,store().writeExercise(...), thenemitRecordUpdated(WRITERS_ROOM_EXERCISE_KIND, id)exactly asfinishExercisedoes so subscribed peers converge.promotedAtandpromotedDraftVersionIdtoEXERCISE_RESTORABLEso conflict-restore keeps them.{ exercise, work: { ...manifest, activeDraftBody: nextBody } }.server/routes/writersRoom.js—POST /exercises/:id/promote(no body; mirrors the/finishand/discardroutes). Regenerate the route catalog withnpm run generate:api-docsand commitserver/lib/apiRouteCatalog.generated.json.dataJSONB and pass throughsanitizeExerciseForSync's...rawspread unchanged. This is additive — an older peer ignores the fields — so noSCHEMA_VERSIONS.writersRoomExercisesbump and no migration (same posture as other additive record fields).writers_room_exercisesDDL is untouched.Client
client/src/services/apiWritersRoom.js—promoteWritersRoomExercise(id, options)→POST /writers-room/exercises/:id/promote.client/src/components/writers-room/ExercisePanel.jsx— in theRecent sessionslist, render an "Add to draft" button (lucideFilePlus, 44px tap target,aria-label="Add sprint text to draft") on rows whereex.status === 'finished' && ex.appendedText && ex.workId === activeWork?.id && !ex.promotedAt. Already-promoted rows show a static✓ in draftmarker instead. New props:editorDirty(button disabled withtitle="Save first"while true — same convention as the WorkEditor overflow-menu actions) andonWorkChange. On success:toast.success('Added N words to draft'),onWorkChange(result.work), thenrefresh()so the row flips to the promoted marker.client/src/components/writers-room/WorkEditor.jsx— two small changes so a sibling panel can safely push a server-side body change into the editor:onDirtyChange(dirty)fired from an effect on the existingdirtyvalue.useEffect(theprevKeycheck): also re-syncbody/savedBodywhenwork.activeDraftBody !== savedBody && !dirty. A normal save passesactiveDraftBody: body(equal tosavedBody) so existing behavior is unchanged; only a genuinely new server body on a clean editor is applied.client/src/pages/WritersRoom.jsx— holdeditorDirtystate; passonDirtyChange={setEditorDirty}toWorkEditorandeditorDirty={editorDirty} onWorkChange={handleWorkChange}toExercisePanel.Docs
docs/features/writers-room.md— replace the "Not built" sentence so it lists only pause/resume as deferred, and document the promote action + the two new exercise fields under Writing Exercise.Scope: small
Acceptance criteria
POST /api/writers-room/exercises/:id/promoteon a finished, work-linked exercise withappendedTextappends the trimmed text to the work's active draft separated by a blank line, bumps the draft'swordCount, and returns{ exercise, work }wherework.activeDraftBodyis the new body.promotedAtandpromotedDraftVersionId; both surviverestoreExercise(listed inEXERCISE_RESTORABLE).400; promoting arunning/discardedexercise, one withworkId: null, or one with empty/whitespaceappendedTextreturns400; unknown id returns404.referencedIngredientIdsof the active draft.server/services/writersRoom/local.test.jscovers the success path (body + wordCount + stamps) and the four400cases above;server/routes/writersRoom.test.jscovers the route delegating to the service.workId: null) and already-promoted sprints show no button.WorkEditor.test.jsxasserts a clean editor adopts a newactiveDraftBodypassed via props, and a dirty editor does not; a newExercisePanel.test.jsxcovers button visibility rules, the dirty-disabled state, and the success callback.server/lib/apiRouteCatalog.generated.jsonincludes the new route (regenerated, not hand-edited);docs/features/writers-room.mdupdated.cd server && npm testandcd client && npm testpass.Non-goals
workId: null) — they stay history-only.