Skip to content

[plan-feature-writers-room-promote-sprint-to-draft] Add a finished sprint's text to the work draft #5300

Description

@atomantic

Motivation

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.
    • Return { exercise, work: { ...manifest, activeDraftBody: nextBody } }.
  • server/routes/writersRoom.jsPOST /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.jspromoteWritersRoomExercise(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:
    1. New prop onDirtyChange(dirty) fired from an effect on the existing dirty value.
    2. 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).

Metadata

Metadata

Assignees

Labels

effort:mediumRecommended reasoning effort: mediumin-progressClaimed and being workedmodel:lightRecommended implementation capability: lightplanTracked by /do:replanplan-featureFeature plan filed by the plan-feature brainstorm

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions