From afed87374bb77f65f1fc00998f9ff6c83e63b789 Mon Sep 17 00:00:00 2001 From: Peter Permenter <41281403+TusanHomichi@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:21:22 +0200 Subject: [PATCH] web(drafts): keep the losing writer's text after a stale save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refused save reloaded the winning copy and took the writer's own text with it, turning an honest concurrency refusal into lost work (#34). The draft working copy, its autosave chain, and the recovery buffers now have one owner: `web/src/lib/drafts/`, which the route page reads instead of holding a second copy of every editable value (#59, partial). The refusal keeps every save the contract refused, newest first, with anything typed while the request was in flight, shown read-only, copyable, and field-labelled. Nothing is merged, resubmitted, persisted, or sent anywhere, and the buffers die with the draft identity. Recovery state and its guard share the controller's lifetime, which is what the review's four lifecycle findings needed: - a queued ordinary edit is saved when the route goes away instead of being cancelled with its debounce timer; refused recovery text is not resubmitted, and a refusal the page has not answered yet is skipped; - the workflow-act guard is per refusal, and a save the writer makes after seeing a refusal resolves exactly the buffers that save carries — copying the text back and saving unlocks submission and finalization without an extra discard, while another refusal's uncopied text keeps holding the act; - the guard lives with the buffers in the controller, so a draft identity change takes it away instead of leaving a page blocked with nothing to dismiss; - the recovery continuation captures the controller and the route it started on, so a refusal whose reload lands after the page moved on touches no buffer, no error, and no guard of the draft now on screen. Browser proof in `web/e2e/draft-recovery.spec.ts` now runs eleven scenarios over two real writer contexts, synchronized on held requests: the loser copies their sentence; text typed during a pending request and text typed while the winner was reloading both stay recoverable and never reach the server; a failed reload keeps the buffer; a second refusal adds to the first; a workflow act makes no request until the writer acknowledges, and then does; copy-back and save lifts the guard without a discard; an edit made inside the debounce window survives navigation; a refusal dies with its draft while another draft's acts work; and a refusal that lands after the page moved on changes nothing. Each fix was falsified against the behaviour it replaces before it was kept. --- docs/development.md | 4 +- web/e2e/draft-recovery.spec.ts | 1353 ++++++++++++++++++++ web/src/lib/drafts/RefusedTextPanel.svelte | 177 +++ web/src/lib/drafts/editor.svelte.ts | 839 ++++++++++++ web/src/routes/drafts/[id]/+page.svelte | 424 +++--- 5 files changed, 2586 insertions(+), 211 deletions(-) create mode 100644 web/e2e/draft-recovery.spec.ts create mode 100644 web/src/lib/drafts/RefusedTextPanel.svelte create mode 100644 web/src/lib/drafts/editor.svelte.ts diff --git a/docs/development.md b/docs/development.md index c3b6b0a..659e56f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -83,6 +83,8 @@ authentication; `+layout.svelte` owns navigation and shared styling. `web/src/lib/retention/` owns policy editing, hold editing, and authority controls. `web/src/lib/api/signoffs.ts` owns the task-signoff read contract (#49) and `web/src/lib/signoffs/` its read-only presentation. +`web/src/lib/drafts/` owns the draft working copy, its autosave chain, and the +refused-save recovery buffer (#34; ownership boundary of #59). `web/src/lib/editor/` contains program-authoring components. `web/e2e/fixtures.ts` supplies each scenario's server, base URL, and setup code. @@ -96,7 +98,7 @@ and assertions in their own specs. | `/` | Capability-sensitive status, notices, administration, session/review queues, installation exports | | `/programs/**` | Program authoring, comparison, publishing, enrollment | | `/enrollments/[id]` | Lifecycle, assignments, sessions, summaries, signoffs, exports | -| `/drafts/[id]` | Authoring, review, finalized presentation, acknowledgment, amendments | +| `/drafts/[id]` | Authoring, review, finalized presentation, acknowledgment, amendments; the draft editing controller, autosave chain, and refused-save recovery buffer live in `web/src/lib/drafts/` (#34) | | `/records` | Trainee's own timeline, packet downloads, and their own complete task-signoff history | | `/retention` | Explicit authority, versioned policies, and attributed holds; no disposition execution | diff --git a/web/e2e/draft-recovery.spec.ts b/web/e2e/draft-recovery.spec.ts new file mode 100644 index 0000000..739223e --- /dev/null +++ b/web/e2e/draft-recovery.spec.ts @@ -0,0 +1,1353 @@ +// Browser proof for #34: the losing writer of a stale save can still read +// and copy the refused text, the winning content stays authoritative, and +// the buffer never reaches the server or survives navigation. +// +// The two writers are real browser contexts against one compiled server and +// one invented draft. Every race is synchronized on a held HTTP request +// rather than a sleep, so the sequence is deterministic. + +import { expect, test } from './fixtures'; +import type { Browser, BrowserContext, Page } from '@playwright/test'; + +const PASSWORD = 'invented-passphrase-1'; +const JORDAN_PASSWORD = 'trainer-passphrase-3'; +const JORDAN = 'jordan.trainer'; +const CASEY = 'casey.coord'; +const CASEY_PASSWORD = 'coordinator-passphrase-4'; +const MOST = 'Most acceptable performance.'; +const LEAST = 'Least acceptable performance.'; + +const content = { + name: 'Example County CTO Program', + label: '2026 rev A', + description: 'Invented program for draft-recovery e2e.', + phases: [{ name: 'Phase One', description: 'Observation.', presentation_number: 1 }], + phase_transitions: [], + competencies: [ + { + category: 'Call processing', + name: 'Emergency Call Interrogation', + description: 'Obtains and verifies location, callback, and nature.', + tasks: [{ prompt: 'Processes an invented structure-fire call.', citations: [] }], + citations: [] + } + ], + rating_scales: [ + { + name: 'Standard 1-7', + kind: 'anchored_numeric', + min_value: 1, + max_value: 7, + anchors: [ + { value: 1, label: 'Unacceptable', definition: 'Contrary to training.' }, + { value: 4, label: 'Meets standards', definition: 'To the invented standard.' } + ] + } + ], + rating_modifiers: [], + evaluation_forms: [ + { + record_type: 'daily_report', + name: 'Daily Observation Report', + instructions: 'Rate observed performance.', + competencies: [ + { competency: 'Emergency Call Interrogation', rating_scale: 'Standard 1-7' } + ], + narratives: [ + { prompt: MOST, required: false }, + { prompt: LEAST, required: false } + ] + } + ], + citations: [], + finalization_policy: { + review_approved: false, + required_narratives: false, + ratings_complete: false + } +}; + +interface Seeded { + draftUrl: string; + draftId: number; + versionId: number; + jordanUserId: number; + /** The one-time code the created trainer signs in with. */ + jordanResetCode: string; + /** The one-time code the created coordinator signs in with. */ + caseyResetCode: string; +} + +/** Initialize the installation as the administrator and seed one draft. */ +async function seed( + page: Page, + browser: Browser, + setupCode: string +): Promise { + await page.goto(`/`); + await expect(page).toHaveURL(/\/setup$/); + await page.getByLabel('Setup code').fill(setupCode); + await page.getByLabel('Agency name').fill('Example County Communications'); + await page.getByLabel('Administrator username').fill('avery.admin'); + await page.getByLabel('Administrator display name').fill('Avery Admin'); + await page.getByLabel('Administrator password').fill(PASSWORD); + await page.getByRole('button', { name: 'Initialize installation' }).click(); + await expect(page).toHaveURL(/\/login$/); + await page.getByLabel('Username').fill('avery.admin'); + await page.getByLabel('Password').fill(PASSWORD); + await page.getByRole('button', { name: 'Sign in' }).click(); + await expect(page.getByRole('heading', { name: 'Installation status' })).toBeVisible(); + + const program = await ( + await page.request.post(`/api/programs`, { data: { name: content.name } }) + ).json(); + const version = await ( + await page.request.post(`/api/programs/${program.id}/versions`, { data: content }) + ).json(); + await page.request.post(`/api/program-versions/${version.id}/publish`, { data: {} }); + const trainee = await ( + await page.request.post(`/api/users`, { + data: { username: 'taylor.trainee', display_name: 'Taylor Trainee' } + }) + ).json(); + const jordan = await ( + await page.request.post(`/api/users`, { + data: { username: JORDAN, display_name: 'Jordan Trainer', role: 'trainer' } + }) + ).json(); + // A coordinator can edit a draft it does not own and holds the review + // authority that sealing a record takes: the workflow-act recovery test + // needs a page that can actually attempt the act. + const casey = await ( + await page.request.post(`/api/users`, { + data: { username: CASEY, display_name: 'Casey Coordinator', role: 'coordinator' } + }) + ).json(); + const enrollment = await ( + await page.request.post(`/api/program-versions/${version.id}/enrollments`, { + data: { user_id: trainee.id } + }) + ).json(); + await page.request.post(`/api/enrollments/${enrollment.id}/assignments`, { + data: { trainer_user_id: jordan.id } + }); + const created = await page.request.post(`/api/enrollments/${enrollment.id}/sessions`, { + data: { + business_date: '2026-06-02', + timezone: 'America/Chicago', + local_start: '2026-06-02T07:00', + trainer_user_ids: [jordan.id] + } + }); + if (!created.ok()) { + throw new Error(`the seeded session failed: ${created.status()} ${await created.text()}`); + } + const session = await created.json(); + // The administrator starts the draft. Jordan is a coordinator, so the + // losing writer can edit and take a workflow act on a draft it does + // not own, which is what the recovery scenarios need. + const draft = await ( + await page.request.post(`/api/sessions/${session.id}/draft`, { data: {} }) + ).json(); + return { + draftUrl: `/drafts/${draft.id}`, + draftId: draft.id, + versionId: version.id, + jordanUserId: jordan.id, + jordanResetCode: jordan.reset_code, + caseyResetCode: casey.reset_code + }; +} + +/** The second program's form: different prompts, so field identities differ. */ +function differentFormContent() { + return { + ...content, + name: 'Second Example County Program', + label: '2026 rev A', + description: 'Invented second program for the draft-recovery e2e.', + competencies: [ + { + category: 'Radio discipline', + name: 'Emergency Call Prioritization', + description: 'Orders invented calls by severity.', + tasks: [{ prompt: 'Prioritizes an invented multi-call incident.', citations: [] }], + citations: [] + } + ], + evaluation_forms: [ + { + record_type: 'daily_report', + name: 'Daily Observation Report', + instructions: 'Rate observed performance.', + competencies: [ + { competency: 'Emergency Call Prioritization', rating_scale: 'Standard 1-7' } + ], + narratives: [{ prompt: LEAST, required: false }] + } + ] + }; +} + +/** A second session's draft, which the test starts from the home page. */ +interface SecondSession { + businessDate: string; + traineeName: string; +} + +/** + * Creates a second trainee's session from a *different* program version, so + * the draft started there is a different form with different field + * identities. The draft itself is started in the test, by its owner. + */ +async function secondSession(page: Page, trainerUserId: number): Promise { + const program = await ( + await page.request.post(`/api/programs`, { data: { name: 'Second Example County Program' } }) + ).json(); + const version = await ( + await page.request.post(`/api/programs/${program.id}/versions`, { + data: differentFormContent() + }) + ).json(); + await page.request.post(`/api/program-versions/${version.id}/publish`, { data: {} }); + const other = await ( + await page.request.post(`/api/users`, { + data: { username: 'riley.trainee', display_name: 'Riley Trainee' } + }) + ).json(); + const enrollment = await ( + await page.request.post(`/api/program-versions/${version.id}/enrollments`, { + data: { user_id: other.id } + }) + ).json(); + const created = await page.request.post(`/api/enrollments/${enrollment.id}/sessions`, { + data: { + business_date: '2026-06-03', + timezone: 'America/Chicago', + local_start: '2026-06-03T07:00', + trainer_user_ids: [trainerUserId] + } + }); + if (!created.ok()) { + throw new Error(`the second session failed: ${created.status()} ${await created.text()}`); + } + return { businessDate: '2026-06-03', traineeName: 'Riley Trainee' }; +} + +/** Hands a draft to another author, so the page holds that draft's acts. */ +async function transferDraft(page: Page, draftId: number, toUserId: number): Promise { + const response = await page.request.post(`/api/drafts/${draftId}/transfer`, { + data: { to_user_id: toUserId } + }); + if (!response.ok()) { + throw new Error(`the transfer failed: ${response.status()} ${await response.text()}`); + } +} + +/** + * Marks the document, so every later hop can prove it was client-side + * navigation: the lifecycle findings are about component state, which a + * document reload would discard for reasons of its own. + */ +async function markSpa(page: Page): Promise { + await page.evaluate(() => { + (window as unknown as { __spa?: number }).__spa = 1; + }); +} + +/** Asserts the document has not been reloaded since {@link markSpa}. */ +async function expectSpaAlive(page: Page): Promise { + expect( + await page.evaluate(() => (window as unknown as { __spa?: number }).__spa), + 'the document reloaded: this was not client-side navigation' + ).toBe(1); +} + +/** + * Goes straight from one draft to another inside the app. No shipped link + * offers this transition — every route into a draft passes through another + * page — so the tests below use the real one; this helper documents the + * component-reusing transition a guard outside the controller would + * survive, and is kept for the day such a link exists. + */ +async function spaToDraft(page: Page, draftId: number, traineeName: string): Promise { + const hop = `spa-hop-${draftId}`; + await page.evaluate( + ([target, id]) => { + const link = document.createElement('a'); + link.id = id; + link.href = target; + link.textContent = 'invented test navigation'; + link.style.position = 'fixed'; + link.style.left = '0'; + link.style.top = '0'; + link.style.zIndex = '9999'; + document.body.appendChild(link); + }, + [`/drafts/${draftId}`, hop] as const + ); + // A trusted click, so the app's router treats it like any other link. + await page.locator(`#${hop}`).click(); + await expect(page).toHaveURL(new RegExp(`/drafts/${draftId}$`)); + // The destination's own content is what proves the new draft loaded. + await expect(page.getByText(traineeName)).toBeVisible(); + await expectSpaAlive(page); +} + +/** Goes to the home page's session list, inside the app. */ +async function homeSessions(page: Page): Promise { + await page.getByRole('link', { name: 'Home' }).click(); + await expect(page.getByRole('heading', { name: 'My sessions' })).toBeVisible(); + await expectSpaAlive(page); +} + +/** Starts a session's draft from the home page, as the author who owns it. */ +async function startDraftFromHome(page: Page, businessDate: string): Promise { + const row = page.locator('tr', { hasText: businessDate }); + await row.getByRole('button', { name: 'Start draft' }).click(); + await expect(page).toHaveURL(/\/drafts\/\d+$/); + await expect(page.getByRole('heading', { name: 'Daily Observation Report' })).toBeVisible(); + const id = /\/drafts\/(\d+)$/.exec(page.url())?.[1]; + if (id === undefined) { + throw new Error(`no draft id in ${page.url()}`); + } + return Number(id); +} + +/** Opens a draft from the home page's session list, inside the app. */ +async function openDraftFromHome( + page: Page, + businessDate: string, + draftId: number +): Promise { + const row = page.locator('tr', { hasText: businessDate }); + await row.getByRole('link', { name: 'Open draft' }).click(); + await expect(page).toHaveURL(new RegExp(`/drafts/${draftId}$`)); + await expect(page.getByRole('heading', { name: 'Daily Observation Report' })).toBeVisible(); +} +/** Sign in a newly created trainer through the one-time reset code. */ +async function signIn( + context: BrowserContext, + username: string, + resetCode: string, + password: string +): Promise { + const page = await context.newPage(); + await page.goto(`/reset`); + await page.getByLabel('Username').fill(username); + await page.getByLabel('Reset code').fill(resetCode); + await page.getByLabel('New password').fill(password); + await page.getByRole('button', { name: 'Set new password' }).click(); + await expect(page).toHaveURL(/\/login$/); + await page.getByLabel('Username').fill(username); + await page.getByLabel('Password').fill(password); + await page.getByRole('button', { name: 'Sign in' }).click(); + await expect(page.getByRole('heading', { name: 'Installation status' })).toBeVisible(); + return page; +} + +/** + * A held request: the handler calls `arrive` when it lands and waits on + * `released`, and the test waits on `arrived` and calls `release`. + */ +function gate(): { + arrived: Promise; + released: Promise; + arrive: () => void; + release: () => void; +} { + let arrive: () => void = () => {}; + let release: () => void = () => {}; + const arrived = new Promise((resolve) => { + arrive = resolve; + }); + const released = new Promise((resolve) => { + release = resolve; + }); + return { arrived, released, arrive, release }; +} + +/** Waits until the draft is saved and the page reports it. */ +async function expectSaved(page: Page): Promise { + await expect(page.locator('.savestate')).toHaveText('Saved'); +} + +/** + * The refused text the panel currently shows for one narrative prompt. The + * label may carry a note that the text arrived after the refused save was + * sent, so the match is on the prompt alone. + */ +function refusedText(page: Page, prompt: string) { + return page + .locator('details.refused .narrative') + .filter({ hasText: prompt }) + .locator('pre.refused-text'); +} + +test('the losing writer recovers their sentence after a stale-save reload', async ({ + page, + setupCode, + browser +}) => { + const seeded = await seed(page, browser, setupCode); + const loserContext = await browser.newContext(); + const loser = await signIn(loserContext, JORDAN, seeded.jordanResetCode, JORDAN_PASSWORD); + try { + // The loser opens the draft and writes their sentence. + await loser.goto(seeded.draftUrl); + await expect(loser.getByRole('heading', { name: 'Daily Observation Report' })).toBeVisible(); + + // Hold the losing writer's save so the other writer can land first. + let heldSaves = 0; + let releaseLoser: () => void = () => {}; + const loserReleased = new Promise((resolve) => { + releaseLoser = resolve; + }); + let losingSaveArrived: () => void = () => {}; + const losingSaveHeld = new Promise((resolve) => { + losingSaveArrived = resolve; + }); + await loser.route(`**/api/drafts/${seeded.draftId}/content`, async (route) => { + if (route.request().method() !== 'PUT') { + await route.continue(); + return; + } + heldSaves += 1; + if (heldSaves === 1) { + losingSaveArrived(); + await loserReleased; + } + await route.continue(); + }); + + const losingSentence = 'The invented radio check was lost twice at the north desk.'; + await loser.getByLabel(MOST).fill(losingSentence); + await losingSaveHeld; + + // The other writer saves first: their copy is the winner. + await page.goto(seeded.draftUrl); + const winningSentence = 'Callback 555-0100 (invented) confirmed before dispatch.'; + await page.getByLabel(MOST).fill(winningSentence); + await expectSaved(page); + + // The held save now reaches the server against a stale revision. + releaseLoser(); + await expect(loser.getByRole('alert')).toContainText( + 'Another contributor saved first' + ); + await expect(loser.getByLabel(MOST)).toHaveValue(winningSentence); + + // The refused text is readable and copyable, and it is visibly + // separated from the winning working copy. + const panel = loser.locator('details.refused'); + await expect(panel).toBeVisible(); + await expect(panel).toContainText('Your unsaved text from before the reload'); + await expect(refusedText(loser, MOST)).toHaveText(losingSentence); + // Exactly one PUT was attempted: the refused buffer is never + // resubmitted on the writer's behalf. + expect(heldSaves).toBe(1); + + // Nothing about the refusal became record content: the server still + // holds only the winning sentence. + const persisted = await ( + await page.request.get(`/api/drafts/${seeded.draftId}`) + ).json(); + const most = persisted.content.narratives.find( + (entry: { text: string }) => entry.text === losingSentence + ); + expect(most).toBeUndefined(); + expect( + persisted.content.narratives.some( + (entry: { text: string }) => entry.text === winningSentence + ) + ).toBe(true); + + // The writer merges by hand: copy the refused text back in and save + // through the existing revision contract. + await loser.getByLabel(MOST).fill(losingSentence); + await expectSaved(loser); + const merged = await ( + await page.request.get(`/api/drafts/${seeded.draftId}`) + ).json(); + expect( + merged.content.narratives.some( + (entry: { text: string }) => entry.text === losingSentence + ) + ).toBe(true); + // Their own save is accepted, so the recovery buffer is spent. + await loser.getByRole('button', { name: 'Discard this text' }).click(); + await expect(panel).toHaveCount(0); + } finally { + await loserContext.close(); + } +}); + +test('text typed while a refused save was pending stays recoverable', async ({ + page, + setupCode, + browser +}) => { + const seeded = await seed(page, browser, setupCode); + const loserContext = await browser.newContext(); + const loser = await signIn(loserContext, JORDAN, seeded.jordanResetCode, JORDAN_PASSWORD); + try { + await loser.goto(seeded.draftUrl); + await expect(loser.getByRole('heading', { name: 'Daily Observation Report' })).toBeVisible(); + + let heldSaves = 0; + let releaseLoser: () => void = () => {}; + const loserReleased = new Promise((resolve) => { + releaseLoser = resolve; + }); + let losingSaveArrived: () => void = () => {}; + const losingSaveHeld = new Promise((resolve) => { + losingSaveArrived = resolve; + }); + await loser.route(`**/api/drafts/${seeded.draftId}/content`, async (route) => { + if (route.request().method() !== 'PUT') { + await route.continue(); + return; + } + heldSaves += 1; + if (heldSaves === 1) { + losingSaveArrived(); + await loserReleased; + } + await route.continue(); + }); + + // The refusal will carry this sentence. + const refused = 'First draft of the invented handover note.'; + await loser.getByLabel(MOST).fill(refused); + await losingSaveHeld; + + // While that request is pending, the writer keeps typing: this text + // was never submitted at all. + const typedLater = 'Second thought: the invented handover note names the north desk.'; + await loser.getByLabel(LEAST).fill(typedLater); + + // The other writer wins first. + await page.goto(seeded.draftUrl); + const winningSentence = 'Callback 555-0100 (invented) confirmed before dispatch.'; + await page.getByLabel(MOST).fill(winningSentence); + await expectSaved(page); + + const refusedResponse = loser.waitForResponse( + (response) => + response.url().includes(`/api/drafts/${seeded.draftId}/content`) && + response.status() === 409 + ); + releaseLoser(); + await refusedResponse; + await expect(loser.getByRole('alert')).toContainText( + 'Another contributor saved first' + ); + + // The refused sentence is shown under its own prompt... + await expect(refusedText(loser, MOST)).toHaveText(refused); + // ...exactly once: a second report of the same refusal would have + // replaced it with the post-reload state. + await expect(loser.locator('details.refused')).toHaveCount(1); + // ...and so is the sentence typed while the request was pending, + // labelled as never submitted rather than silently dropped. + await expect(refusedText(loser, LEAST)).toHaveText(typedLater); + await expect(loser.locator('details.refused .pending-note')).toContainText( + 'never submitted' + ); + // The winning copy still stands where it was written. + await expect(loser.getByLabel(MOST)).toHaveValue(winningSentence); + // One report per refused revision: the retry that carried the + // never-submitted sentence was refused too, and it did not replace + // the buffer with the post-reload state. The report count is what + // proves that, because the buffer itself still holds both pieces + // of divergent text. + await expect(loser.locator('details.refused')).toHaveCount(1); + // Exactly one request reached the server: the refused buffer is + // never resubmitted on the writer's behalf, and the retry the + // controller would have made is suppressed while the page has not + // yet reloaded the winner. + expect(heldSaves).toBe(1); + // The retry left the server on the winner's revision, so the + // refused revision is fully replaced rather than half-applied. + const after = await ( + await page.request.get(`/api/drafts/${seeded.draftId}`) + ).json(); + expect( + after.content.narratives.some( + (entry: { text: string }) => entry.text === typedLater + ) + ).toBe(false); + } finally { + await loserContext.close(); + } +}); + +test('text typed while the winner is being reloaded stays recoverable', async ({ + page, + setupCode, + browser +}) => { + const seeded = await seed(page, browser, setupCode); + const loserContext = await browser.newContext(); + const loser = await signIn(loserContext, JORDAN, seeded.jordanResetCode, JORDAN_PASSWORD); + try { + await loser.goto(seeded.draftUrl); + await expect(loser.getByRole('heading', { name: 'Daily Observation Report' })).toBeVisible(); + + let heldSaves = 0; + let releaseLoser: () => void = () => {}; + const loserReleased = new Promise((resolve) => { + releaseLoser = resolve; + }); + let losingSaveArrived: () => void = () => {}; + const losingSaveHeld = new Promise((resolve) => { + losingSaveArrived = resolve; + }); + await loser.route(`**/api/drafts/${seeded.draftId}/content`, async (route) => { + if (route.request().method() !== 'PUT') { + await route.continue(); + return; + } + heldSaves += 1; + if (heldSaves === 1) { + losingSaveArrived(); + await loserReleased; + } + await route.continue(); + }); + + const refused = 'First draft of the invented handover note.'; + await loser.getByLabel(MOST).fill(refused); + await losingSaveHeld; + + // The other writer wins first. + await page.goto(seeded.draftUrl); + const winningSentence = 'Callback 555-0100 (invented) confirmed before dispatch.'; + await page.getByLabel(MOST).fill(winningSentence); + await expectSaved(page); + + // The reload the refusal triggers is held open, which is the window + // a writer keeps typing in: the working copy is still the old one, + // so the field is live. + let heldReloads = 0; + let releaseReload: () => void = () => {}; + const reloadReleased = new Promise((resolve) => { + releaseReload = resolve; + }); + let reloadArrived: () => void = () => {}; + const reloadHeld = new Promise((resolve) => { + reloadArrived = resolve; + }); + await loser.route(`**/api/drafts/${seeded.draftId}`, async (route) => { + const path = new URL(route.request().url()).pathname; + if (route.request().method() !== 'GET' || path !== `/api/drafts/${seeded.draftId}`) { + await route.continue(); + return; + } + heldReloads += 1; + if (heldReloads === 1) { + reloadArrived(); + await reloadReleased; + } + await route.continue(); + }); + + const refusedResponse = loser.waitForResponse( + (response) => + response.url().includes(`/api/drafts/${seeded.draftId}/content`) && + response.status() === 409 + ); + releaseLoser(); + await refusedResponse; + await reloadHeld; + + // Typed while the winner's copy is on its way: this text was in + // neither the refused request nor the reloaded copy. + const typedDuringReload = 'Second thought: the invented note names the north desk.'; + await loser.getByLabel(MOST).fill(typedDuringReload); + releaseReload(); + + // The reload replaces the working copy with the winner's... + await expect(loser.getByLabel(MOST)).toHaveValue(winningSentence); + // ...and the text typed while it was in flight is still the + // writer's, labelled as never submitted rather than overwritten + // unseen. + await expect(refusedText(loser, MOST)).toHaveText(typedDuringReload); + await expect(loser.locator('details.refused .pending-note')).toContainText( + 'never submitted' + ); + // It never reached the server, and the winner's copy is what the + // draft holds. + const after = await ( + await page.request.get(`/api/drafts/${seeded.draftId}`) + ).json(); + expect( + after.content.narratives.some( + (entry: { text: string }) => entry.text === typedDuringReload + ) + ).toBe(false); + } finally { + await loserContext.close(); + } +}); + +test('a failed reload keeps the refused text and is not reported as a refresh', async ({ + page, + setupCode, + browser +}) => { + const seeded = await seed(page, browser, setupCode); + const loserContext = await browser.newContext(); + const loser = await signIn(loserContext, JORDAN, seeded.jordanResetCode, JORDAN_PASSWORD); + try { + await loser.goto(seeded.draftUrl); + await expect(loser.getByRole('heading', { name: 'Daily Observation Report' })).toBeVisible(); + + let heldSaves = 0; + let releaseLoser: () => void = () => {}; + const loserReleased = new Promise((resolve) => { + releaseLoser = resolve; + }); + let losingSaveArrived: () => void = () => {}; + const losingSaveHeld = new Promise((resolve) => { + losingSaveArrived = resolve; + }); + // The reload that follows a refusal fails: the refusal text must + // survive it, and the page must say the reload failed. + let refuseReload = false; + await loser.route(`**/api/drafts/${seeded.draftId}`, async (route) => { + if (refuseReload && route.request().method() === 'GET') { + await route.abort('failed'); + return; + } + await route.continue(); + }); + await loser.route(`**/api/drafts/${seeded.draftId}/content`, async (route) => { + if (route.request().method() !== 'PUT') { + await route.continue(); + return; + } + heldSaves += 1; + if (heldSaves === 1) { + losingSaveArrived(); + await loserReleased; + } + await route.continue(); + }); + + const losingSentence = 'The invented console log was misfiled.'; + await loser.getByLabel(MOST).fill(losingSentence); + await losingSaveHeld; + + await page.goto(seeded.draftUrl); + const winningSentence = 'Callback 555-0100 (invented) confirmed before dispatch.'; + await page.getByLabel(MOST).fill(winningSentence); + await expectSaved(page); + + refuseReload = true; + const refusedResponse = loser.waitForResponse( + (response) => + response.url().includes(`/api/drafts/${seeded.draftId}/content`) && + response.status() === 409 + ); + let reloadFailed: () => void = () => {}; + const reloadFailedPromise = new Promise((resolve) => { + reloadFailed = resolve; + }); + loser.on('requestfailed', (request) => { + if (request.url().includes(`/api/drafts/${seeded.draftId}`)) { + reloadFailed(); + } + }); + releaseLoser(); + await refusedResponse; + await reloadFailedPromise; + await expect(loser.getByRole('alert')).toContainText('reloaded unsuccessfully'); + await expect(loser.locator('details.refused')).toBeVisible(); + await expect(refusedText(loser, MOST)).toHaveText(losingSentence); + } finally { + await loserContext.close(); + } +}); + +test('a second refusal adds to the refused text instead of replacing it', async ({ + page, + setupCode, + browser +}) => { + const seeded = await seed(page, browser, setupCode); + const loserContext = await browser.newContext(); + const loser = await signIn(loserContext, JORDAN, seeded.jordanResetCode, JORDAN_PASSWORD); + try { + await loser.goto(seeded.draftUrl); + await expect(loser.getByRole('heading', { name: 'Daily Observation Report' })).toBeVisible(); + + // Two saves are held in turn, so each can be refused by a save from + // the other writer that lands first. + const first = gate(); + const second = gate(); + let savesArrived = 0; + await loser.route(`**/api/drafts/${seeded.draftId}/content`, async (route) => { + if (route.request().method() !== 'PUT') { + await route.continue(); + return; + } + savesArrived += 1; + if (savesArrived === 1) { + first.arrive(); + await first.released; + } + if (savesArrived === 2) { + second.arrive(); + await second.released; + } + await route.continue(); + }); + + const firstText = 'First refused sentence of the invented handover.'; + await loser.getByLabel(MOST).fill(firstText); + await first.arrived; + + // The owner wins the first race. + await page.goto(seeded.draftUrl); + const winningOne = 'Callback 555-0100 (invented) confirmed before dispatch.'; + await page.getByLabel(MOST).fill(winningOne); + await expectSaved(page); + const firstRefusal = loser.waitForResponse( + (response) => + response.url().includes(`/api/drafts/${seeded.draftId}/content`) && + response.status() === 409 + ); + first.release(); + await firstRefusal; + await expect(refusedText(loser, MOST)).toHaveText(firstText); + + // The writer types again on the reloaded copy, and the owner wins + // the second race too. + const secondText = 'Second refused sentence of the invented handover.'; + await loser.getByLabel(LEAST).fill(secondText); + await second.arrived; + await page.getByLabel(LEAST).fill('The invented north desk took the callback.'); + await expectSaved(page); + const secondRefusal = loser.waitForResponse( + (response) => + response.url().includes(`/api/drafts/${seeded.draftId}/content`) && + response.status() === 409 + ); + second.release(); + await secondRefusal; + + // Both refusals are kept: the newest first, and the earlier one + // still there to copy. A later refusal must never bury text the + // writer has not acknowledged. + await expect(loser.locator('details.refused')).toHaveCount(2); + await expect(refusedText(loser, LEAST)).toHaveText(secondText); + await expect(refusedText(loser, MOST)).toHaveText(firstText); + await expect(loser.locator('details.refused summary').first()).toContainText( + 'Your unsaved text from before the reload' + ); + await expect(loser.locator('details.refused summary').nth(1)).toContainText( + 'Text from an earlier refused save' + ); + + // Discarding one leaves the other. + await loser + .locator('details.refused') + .first() + .getByRole('button', { name: 'Discard this text' }) + .click(); + await expect(loser.locator('details.refused')).toHaveCount(1); + await expect(refusedText(loser, MOST)).toHaveText(firstText); + } finally { + await loserContext.close(); + } +}); + +test('a workflow act over a refused save is not taken sight unseen', async ({ + page, + setupCode, + browser +}) => { + const seeded = await seed(page, browser, setupCode); + + const loserContext = await browser.newContext(); + const loser = await signIn(loserContext, CASEY, seeded.caseyResetCode, CASEY_PASSWORD); + try { + // Casey coordinates this enrollment, so sealing the record is the + // authority this page really holds; the administrator, who owns the + // draft, provides the winning save. + await loser.goto(seeded.draftUrl); + await expect(loser.getByRole('heading', { name: 'Daily Observation Report' })).toBeVisible(); + await expect(loser.getByRole('button', { name: 'Finalize record' })).toBeVisible(); + + let heldSaves = 0; + let releaseLoser: () => void = () => {}; + const loserReleased = new Promise((resolve) => { + releaseLoser = resolve; + }); + let losingSaveArrived: () => void = () => {}; + const losingSaveHeld = new Promise((resolve) => { + losingSaveArrived = resolve; + }); + let submits = 0; + let finalizations = 0; + await loser.route(`**/api/drafts/${seeded.draftId}/submit`, async (route) => { + submits += 1; + await route.continue(); + }); + await loser.route(`**/api/drafts/${seeded.draftId}/finalize`, async (route) => { + finalizations += 1; + await route.continue(); + }); + await loser.route(`**/api/drafts/${seeded.draftId}/content`, async (route) => { + if (route.request().method() !== 'PUT') { + await route.continue(); + return; + } + heldSaves += 1; + if (heldSaves === 1) { + losingSaveArrived(); + await loserReleased; + } + await route.continue(); + }); + + // The coordinator's edit is refused because the owner saves first. + const losingSentence = 'The invented tone-out was read back twice.'; + await loser.getByLabel(MOST).fill(losingSentence); + await losingSaveHeld; + + await page.goto(seeded.draftUrl); + const winningSentence = 'Callback 555-0100 (invented) confirmed before dispatch.'; + await page.getByLabel(MOST).fill(winningSentence); + await expectSaved(page); + + // The refusal settles first, and the page says so. + const refused = loser.waitForResponse( + (response) => + response.url().includes(`/api/drafts/${seeded.draftId}/content`) && + response.status() === 409 + ); + releaseLoser(); + await refused; + await expect(loser.getByRole('alert')).toContainText('Another contributor saved first'); + await expect(loser.locator('details.refused')).toBeVisible(); + + // The coordinator now asks for both workflow acts over content this + // page has not seen. Each handler disables its button while it runs, + // so waiting for the button to come back is what makes "no request + // was made" a settled fact rather than a race with the click. The + // refusal stays unresolved across both attempts: one act must not + // clear the guard for the next. + const submitButton = loser.getByRole('button', { name: 'Submit for review' }); + await submitButton.click(); + await expect(submitButton).toBeEnabled(); + expect(submits, 'the draft was submitted sight unseen').toBe(0); + await expect(loser.getByRole('alert')).toContainText('Your refused text is still here'); + const finalizeButton = loser.getByRole('button', { name: 'Finalize record' }); + await finalizeButton.click(); + await expect(finalizeButton).toBeEnabled(); + expect(finalizations, 'the record was sealed sight unseen').toBe(0); + // It stays editable rather than frozen, and the refused text is + // still there to copy. + await expect(loser.getByLabel(MOST)).toHaveValue(winningSentence); + await expect(loser.locator('details.refused')).toBeVisible(); + await expect(refusedText(loser, MOST)).toHaveText(losingSentence); + + // The writer's acknowledgment is what lifts the guard: discarding + // the refused text lets the act through. + await loser.getByRole('button', { name: 'Discard this text' }).click(); + await expect(loser.locator('details.refused')).toHaveCount(0); + const submitted = loser.waitForResponse((response) => + response.url().includes(`/api/drafts/${seeded.draftId}/submit`) + ); + await submitButton.click(); + const outcome = await submitted; + expect(outcome.ok(), `submit answered ${outcome.status()}`).toBe(true); + expect(submits, 'the acknowledged draft was not submitted').toBe(1); + } finally { + await loserContext.close(); + } +}); + +test('a refusal dies with its draft and never blocks another one', async ({ + page, + setupCode, + browser +}) => { + const seeded = await seed(page, browser, setupCode); + const second = await secondSession(page, seeded.jordanUserId); + // Jordan owns the first draft, so both drafts in this test have a page + // whose acts the refusal guard would hold. + await transferDraft(page, seeded.draftId, seeded.jordanUserId); + const loserContext = await browser.newContext(); + const loser = await signIn(loserContext, JORDAN, seeded.jordanResetCode, JORDAN_PASSWORD); + try { + await loser.goto(seeded.draftUrl); + // Every hop from here is client-side navigation, and the marker is + // what proves it. + await markSpa(loser); + // The second draft is started first, from its owner's session list, + // so the refusal below can be followed by a direct draft-to-draft + // navigation — the one that reuses this route's component. + await homeSessions(loser); + const otherId = await startDraftFromHome(loser, second.businessDate); + await homeSessions(loser); + await openDraftFromHome(loser, '2026-06-02', seeded.draftId); + await expect(loser.getByRole('button', { name: 'Submit for review' })).toBeVisible(); + + const losingSave = gate(); + await loser.route(`**/api/drafts/${seeded.draftId}/content`, async (route) => { + if (route.request().method() !== 'PUT') { + await route.continue(); + return; + } + losingSave.arrive(); + await losingSave.released; + await route.continue(); + }); + let submits = 0; + await loser.route('**/api/drafts/*/submit', async (route) => { + submits += 1; + await route.continue(); + }); + + const losingSentence = 'The invented paging test failed on the first attempt.'; + await loser.getByLabel(MOST).fill(losingSentence); + await losingSave.arrived; + + await page.goto(seeded.draftUrl); + await page.getByLabel(MOST).fill('Callback 555-0100 (invented) confirmed.'); + await expectSaved(page); + + const refused = loser.waitForResponse( + (response) => + response.url().includes(`/api/drafts/${seeded.draftId}/content`) && + response.status() === 409 + ); + losingSave.release(); + await refused; + await expect(loser.locator('details.refused')).toBeVisible(); + + // Another draft, reached the way the app offers: from the home + // page's session list. It is a different form with its own + // controller, and it inherits nothing. + await homeSessions(loser); + await openDraftFromHome(loser, second.businessDate, otherId); + await expectSpaAlive(loser); + await expect(loser.getByLabel(MOST)).toHaveCount(0); + await expect(loser.locator('details.refused')).toHaveCount(0); + await expect(loser.getByText(losingSentence)).toHaveCount(0); + // Not blocked: the act goes through on the new draft. + const otherSubmitted = loser.waitForResponse((response) => + response.url().includes(`/api/drafts/${otherId}/submit`) + ); + await loser.getByRole('button', { name: 'Submit for review' }).click(); + const otherOutcome = await otherSubmitted; + expect(otherOutcome.ok(), `submit answered ${otherOutcome.status()}`).toBe(true); + expect(submits, 'the new draft was not the one submitted').toBe(1); + + // Back to the first draft, whose identity the route matches again: + // nothing of the refusal comes back with it. + await homeSessions(loser); + await openDraftFromHome(loser, '2026-06-02', seeded.draftId); + await expectSpaAlive(loser); + await expect(loser.locator('details.refused')).toHaveCount(0); + await expect(loser.getByText(losingSentence)).toHaveCount(0); + const ownSubmitted = loser.waitForResponse((response) => + response.url().includes(`/api/drafts/${seeded.draftId}/submit`) + ); + await loser.getByRole('button', { name: 'Submit for review' }).click(); + const ownOutcome = await ownSubmitted; + expect(ownOutcome.ok(), `submit answered ${ownOutcome.status()}`).toBe(true); + expect(submits, 'the returned-to draft was not the one submitted').toBe(2); + } finally { + await loserContext.close(); + } +}); + +test('an ordinary edit made just before navigating away is saved', async ({ + page, + setupCode, + browser +}) => { + const seeded = await seed(page, browser, setupCode); + const loserContext = await browser.newContext(); + const loser = await signIn(loserContext, JORDAN, seeded.jordanResetCode, JORDAN_PASSWORD); + try { + await loser.goto(seeded.draftUrl); + await expect(loser.getByRole('heading', { name: 'Daily Observation Report' })).toBeVisible(); + + let saves = 0; + loser.on('request', (request) => { + if (request.method() === 'PUT' && request.url().includes('/content')) { + saves += 1; + } + }); + + // No refusal here: this is an ordinary debounced autosave. + const sentence = 'The invented handover note was dictated before the shift change.'; + await loser.getByLabel(MOST).fill(sentence); + expect(saves, 'the debounce had already fired').toBe(0); + + // Away inside the debounce window: the timer never fired. + await loser.getByRole('link', { name: 'Home' }).click(); + await expect(loser.getByRole('heading', { name: 'Installation status' })).toBeVisible(); + + // The queued edit reaches the server anyway, once. + await expect + .poll( + async () => { + const view = await ( + await page.request.get(`/api/drafts/${seeded.draftId}`) + ).json(); + return view.content.narratives.some( + (entry: { text: string }) => entry.text === sentence + ); + }, + { message: 'the queued edit never reached the server' } + ) + .toBe(true); + expect(saves, 'the edit was saved more than once').toBe(1); + } finally { + await loserContext.close(); + } +}); + +test('copying the refused text back and saving lifts the guard without a discard', async ({ + page, + setupCode, + browser +}) => { + const seeded = await seed(page, browser, setupCode); + const loserContext = await browser.newContext(); + const loser = await signIn(loserContext, CASEY, seeded.caseyResetCode, CASEY_PASSWORD); + try { + await loser.goto(seeded.draftUrl); + await expect(loser.getByRole('heading', { name: 'Daily Observation Report' })).toBeVisible(); + + const losingSave = gate(); + await loser.route(`**/api/drafts/${seeded.draftId}/content`, async (route) => { + if (route.request().method() !== 'PUT') { + await route.continue(); + return; + } + losingSave.arrive(); + await losingSave.released; + await route.continue(); + }); + + const losingSentence = 'The invented tone-out was read back twice.'; + await loser.getByLabel(MOST).fill(losingSentence); + await losingSave.arrived; + await page.goto(seeded.draftUrl); + await page.getByLabel(MOST).fill('Callback 555-0100 (invented) confirmed before dispatch.'); + await expectSaved(page); + const refused = loser.waitForResponse( + (response) => + response.url().includes(`/api/drafts/${seeded.draftId}/content`) && + response.status() === 409 + ); + losingSave.release(); + await refused; + await expect(refusedText(loser, MOST)).toHaveText(losingSentence); + + // The recovery instructions, followed exactly: copy the text back + // into the reloaded field and let the autosave carry it. + await loser.getByLabel(MOST).fill(losingSentence); + await expectSaved(loser); + + // The act goes through without the writer also discarding the panel, + // and the panel is still there for them. + await expect(loser.locator('details.refused')).toBeVisible(); + const finalized = loser.waitForResponse((response) => + response.url().includes(`/api/drafts/${seeded.draftId}/finalize`) + ); + await loser.getByRole('button', { name: 'Finalize record' }).click(); + const outcome = await finalized; + expect(outcome.ok(), `finalize answered ${outcome.status()}`).toBe(true); + } finally { + await loserContext.close(); + } +}); + +test('saving one refusal back does not resolve another refusal\'s text', async ({ + page, + setupCode, + browser +}) => { + const seeded = await seed(page, browser, setupCode); + const loserContext = await browser.newContext(); + const loser = await signIn(loserContext, CASEY, seeded.caseyResetCode, CASEY_PASSWORD); + try { + await loser.goto(seeded.draftUrl); + await expect(loser.getByRole('heading', { name: 'Daily Observation Report' })).toBeVisible(); + + // Two refused saves, each beaten by a save from the owner. + const first = gate(); + const second = gate(); + let savesArrived = 0; + await loser.route(`**/api/drafts/${seeded.draftId}/content`, async (route) => { + if (route.request().method() !== 'PUT') { + await route.continue(); + return; + } + savesArrived += 1; + if (savesArrived === 1) { + first.arrive(); + await first.released; + } + if (savesArrived === 2) { + second.arrive(); + await second.released; + } + await route.continue(); + }); + let finalizations = 0; + await loser.route(`**/api/drafts/${seeded.draftId}/finalize`, async (route) => { + finalizations += 1; + await route.continue(); + }); + + const firstText = 'The invented radio check was missed at shift change.'; + await loser.getByLabel(MOST).fill(firstText); + await first.arrived; + await page.goto(seeded.draftUrl); + await page.getByLabel(MOST).fill('Callback 555-0100 (invented) confirmed before dispatch.'); + await expectSaved(page); + const firstRefusal = loser.waitForResponse( + (response) => + response.url().includes(`/api/drafts/${seeded.draftId}/content`) && + response.status() === 409 + ); + first.release(); + await firstRefusal; + await expect(refusedText(loser, MOST)).toHaveText(firstText); + + const secondText = 'The invented log entry named the wrong north desk.'; + await loser.getByLabel(LEAST).fill(secondText); + await second.arrived; + await page.getByLabel(LEAST).fill('The invented north desk took the callback.'); + await expectSaved(page); + const secondRefusal = loser.waitForResponse( + (response) => + response.url().includes(`/api/drafts/${seeded.draftId}/content`) && + response.status() === 409 + ); + second.release(); + await secondRefusal; + await expect(loser.locator('details.refused')).toHaveCount(2); + + // Putting back the newest refusal's text is a real save, and it + // resolves that refusal only: the earlier text is still not in the + // draft, so the act still waits. + await loser.getByLabel(LEAST).fill(secondText); + await expectSaved(loser); + const finalizeButton = loser.getByRole('button', { name: 'Finalize record' }); + await finalizeButton.click(); + await expect(finalizeButton).toBeEnabled(); + expect(finalizations, 'an act dropped the earlier refused text').toBe(0); + await expect(loser.getByRole('alert')).toContainText('Your refused text is still here'); + + // Putting the earlier text back too resolves it, and the act goes + // through without any discard. + await loser.getByLabel(MOST).fill(firstText); + await expectSaved(loser); + const finalized = loser.waitForResponse((response) => + response.url().includes(`/api/drafts/${seeded.draftId}/finalize`) + ); + await finalizeButton.click(); + const outcome = await finalized; + expect(outcome.ok(), `finalize answered ${outcome.status()}`).toBe(true); + expect(finalizations).toBe(1); + } finally { + await loserContext.close(); + } +}); + +test('a refusal whose reload lands after the page moved on changes nothing', async ({ + page, + setupCode, + browser +}) => { + const seeded = await seed(page, browser, setupCode); + const second = await secondSession(page, seeded.jordanUserId); + await transferDraft(page, seeded.draftId, seeded.jordanUserId); + const loserContext = await browser.newContext(); + const loser = await signIn(loserContext, JORDAN, seeded.jordanResetCode, JORDAN_PASSWORD); + try { + await loser.goto(seeded.draftUrl); + await markSpa(loser); + await homeSessions(loser); + const otherId = await startDraftFromHome(loser, second.businessDate); + await homeSessions(loser); + await openDraftFromHome(loser, '2026-06-02', seeded.draftId); + + const losingSave = gate(); + await loser.route(`**/api/drafts/${seeded.draftId}/content`, async (route) => { + if (route.request().method() !== 'PUT') { + await route.continue(); + return; + } + losingSave.arrive(); + await losingSave.released; + await route.continue(); + }); + // The reload a refusal triggers is held open, so the page can leave + // before its answer arrives. + const reload = gate(); + await loser.route(`**/api/drafts/${seeded.draftId}`, async (route) => { + const path = new URL(route.request().url()).pathname; + if (route.request().method() !== 'GET' || path !== `/api/drafts/${seeded.draftId}`) { + await route.continue(); + return; + } + reload.arrive(); + await reload.released; + await route.continue(); + }); + + const losingSentence = 'The invented handover note was never signed.'; + await loser.getByLabel(MOST).fill(losingSentence); + await losingSave.arrived; + await page.goto(seeded.draftUrl); + await page.getByLabel(MOST).fill('Callback 555-0100 (invented) confirmed.'); + await expectSaved(page); + const refused = loser.waitForResponse( + (response) => + response.url().includes(`/api/drafts/${seeded.draftId}/content`) && + response.status() === 409 + ); + losingSave.release(); + await refused; + await reload.arrived; + + // Leave for the other draft, inside the app, and then let the + // obsolete answer land. + await homeSessions(loser); + await openDraftFromHome(loser, second.businessDate, otherId); + reload.release(); + + // The destination is untouched: no buffer, no refused text, no error + // painted onto it, and its act is not held. + await expect(loser.locator('details.refused')).toHaveCount(0); + await expect(loser.getByText(losingSentence)).toHaveCount(0); + await expect(loser.getByRole('alert')).toHaveCount(0); + const otherSubmitted = loser.waitForResponse((response) => + response.url().includes(`/api/drafts/${otherId}/submit`) + ); + await loser.getByRole('button', { name: 'Submit for review' }).click(); + const otherOutcome = await otherSubmitted; + expect(otherOutcome.ok(), `submit answered ${otherOutcome.status()}`).toBe(true); + + // And back on the first draft, nothing stale appears either. + await homeSessions(loser); + await openDraftFromHome(loser, '2026-06-02', seeded.draftId); + await expectSpaAlive(loser); + await expect(loser.locator('details.refused')).toHaveCount(0); + await expect(loser.getByText(losingSentence)).toHaveCount(0); + await expect(loser.getByRole('alert')).toHaveCount(0); + } finally { + await loserContext.close(); + } +}); diff --git a/web/src/lib/drafts/RefusedTextPanel.svelte b/web/src/lib/drafts/RefusedTextPanel.svelte new file mode 100644 index 0000000..59b98d6 --- /dev/null +++ b/web/src/lib/drafts/RefusedTextPanel.svelte @@ -0,0 +1,177 @@ + + + +{#each buffers as refused, index (index)} + {@const narrativeCount = refused.narratives.length} + {@const ratingCount = refused.ratings.length} +
0}> + + {index === 0 + ? 'Your unsaved text from before the reload' + : 'Text from an earlier refused save'} + {#if narrativeCount > 0} + + ({narrativeCount} + {narrativeCount === 1 ? 'narrative' : 'narratives'}) + + {/if} + +

+ Another contributor saved first, so their copy is what this page now + edits. Your save was refused and never applied. The text below is + yours and was not saved anywhere — copy anything you still want into + the reloaded fields, then save again through the normal revision + check. Nothing here is merged or resubmitted for you. +

+ {#if narrativeCount > 0} +

Narrative text that differed

+ {#each refused.narratives as narrative (narrative.form_narrative_id)} +
+

+ {narrative.label} + {#if narrative.typed_while_pending} + + — typed after the refused save was sent, so it was + never submitted + + {/if} +

+ +
{narrative.text}
+
+ {/each} + {/if} + {#if ratingCount > 0} +

Ratings that differed

+ + + + + + + + + + {#each refused.ratings as rating (rating.form_competency_id)} + + + + + + {/each} + +
CompetencyYour valueModifiers
{rating.name} + {#if rating.not_observed} + Not observed + {:else if rating.value !== null} + {rating.value} + {:else} + + {/if} + + {rating.modifier_codes.join(' ')} + {#if rating.typed_while_pending} + + changed after the refused save was sent + + {/if} +
+ {/if} +
+ +
+
+{/each} + + diff --git a/web/src/lib/drafts/editor.svelte.ts b/web/src/lib/drafts/editor.svelte.ts new file mode 100644 index 0000000..62d644a --- /dev/null +++ b/web/src/lib/drafts/editor.svelte.ts @@ -0,0 +1,839 @@ +// One owner for the draft working copy an author edits: the editable +// values, the revision every save carries, the debounced autosave chain, +// the metadata refresh, and the recovery buffer that keeps a refused save's +// text (#34; ADR 0008; ownership boundary of #59). +// +// The route page keeps loading, review, finalization, acknowledgment, and +// amendment actions and reads this controller's content; it never holds a +// second copy of an editable value. The controller takes the draft's +// server view as a getter, so nothing is mirrored across two owners. +// +// Two lifetimes live here, and they are deliberately different: +// +// - component/controller-lifetimed state: `values`, `notObserved`, +// `modifiers`, `narratives`, `revision`, and the save chain. Discarded +// when the controller is destroyed; +// - `refused`: every refused save's divergent text that the author has not +// discarded yet, newest first, and only until they discard it, the +// controller is destroyed (navigation or logout), or a new draft +// identity gets a new controller. It is never persisted anywhere — no +// localStorage, sessionStorage, IndexedDB, URL state, log, or server +// call — because the refusal is a concurrency answer, not record content +// (issue #34). +import { getDraft, saveDraftContent, type DraftContent, type DraftView } from '$lib/api'; +import { ApiError } from '$lib/api/transport'; + +/** The debounced autosave's delay once an edit lands. */ +const AUTOSAVE_DELAY_MS = 600; + +/** Whether a save is pending, running, or settled. */ +export type SaveState = 'idle' | 'pending' | 'saving' | 'saved' | 'failed'; + +/** One narrative as it stood when a refused save was attempted. */ +export interface RefusedNarrative { + form_narrative_id: number; + label: string; + text: string; + /** + * The text changed after the refused request was already in flight, so + * it was never even submitted. It is kept for the same reason as the + * refused text and shown distinctly, never merged. + */ + typed_while_pending: boolean; +} + +/** One rating as it stood when a refused save was attempted. */ +export interface RefusedRating { + form_competency_id: number; + name: string; + not_observed: boolean; + value: number | null; + modifier_codes: string[]; + /** The same modifiers as ids, for comparing against the working copy. */ + modifier_ids: number[]; + /** See [`RefusedNarrative::typed_while_pending`]. */ + typed_while_pending: boolean; +} + +/** + * A save the revision contract refused (`stale_save`) and the text it + * carried, still readable after the workspace reloaded the winner. + * + * `narratives` and `ratings` hold only what actually differed from the + * reloaded content, so the page shows the author's divergent text and + * never invents a merge (issue #34; ADR 0008: one working copy). + */ +export interface RefusedBuffer { + /** Ascending `form_narrative_id`: a stable order for the buffer. */ + narratives: RefusedNarrative[]; + ratings: RefusedRating[]; + /** + * Whether a later save by the writer carried this buffer's text into + * the draft. An unresolved buffer holds a workflow act, so the writer + * either puts the text back or discards it; the text itself stays + * readable either way. + */ + resolved: boolean; +} + +/** The durable form labels a refused buffer needs to name its fields. */ +interface FormShape { + narrative_labels: Map; + competency_names: Map; + modifier_codes: Map; +} + +/** The editable state at one instant, kept durable across a reload. */ +export interface EditorSnapshot { + revision: number; + values: Map; + notObserved: Map; + modifiers: Map>; + narratives: Map; + shape: FormShape; +} + +/** What one `saveNow` attempt settled as. */ +export type SaveResult = + | { status: 'saved' } + /** The typed refusal or transport answer the attempt failed with. */ + | { status: 'failed'; message: string } + | { status: 'ignored' } + | { status: 'stale'; refused: EditorSnapshot }; + +interface SaveRun { + draft_id: number; + refused: EditorSnapshot | null; +} + +/** + * The mutable draft editor for one draft identity. Construct one per draft + * identity and call [`destroy`] when the route unmounts: a new draft gets + * a new controller, so an old draft's in-flight save can never attach its + * result or its refused text to another draft. + */ +export class DraftEditorController { + /** The revision every save and workflow act carries. */ + revision = $state(0); + /** Rating value keyed by `form_competency_id`. */ + values: Record = $state({}); + /** "No opportunity to observe" keyed by `form_competency_id`. */ + notObserved: Record = $state({}); + /** Picked modifiers keyed by `form_competency_id`, then modifier id. */ + modifiers: Record> = $state({}); + /** Narrative text keyed by `form_narrative_id`. */ + narratives: Record = $state({}); + saveState: SaveState = $state('idle'); + /** + * The text each refused save carried, plus anything typed while its + * request was still in flight, for the writer to read and copy. Empty + * whenever there is nothing to recover, and it only ever holds text + * from saves the contract actually refused or from edits that were + * never submitted because of a refusal. A later refusal is added, never + * substituted: an earlier buffer the author has not copied yet is still + * their text. + */ + refused: RefusedBuffer[] = $state([]); + /** + * A refusal whose reload did not land: the page's working copy is not + * the winner's, so nothing may be acted on until a reload or a save + * succeeds. Kept apart from the buffers because it can be true with no + * buffer to show. + */ + #reloadFailed = $state(false); + /** + * How many refusals this controller has recorded. A save that started + * before a refusal cannot resolve it: only a save the writer made after + * seeing the refusal carries the text they copied back. + */ + #refusals = 0; + + /** + * Whether a workflow act must wait: a refusal's text is not in the + * draft yet, or the copy the page holds is not the winner's. True only + * while this draft's own refusals are unresolved, so the guard is taken + * away by a draft identity change along with the buffers, and a save + * the writer makes after seeing a refusal resolves exactly the buffers + * that save carries — never another refusal's text. + */ + get unresolved(): boolean { + return this.#reloadFailed || this.refused.some((buffer) => !buffer.resolved); + } + + /** + * Every settled save reports here, whichever path started it: the + * debounce, an explicit save, or a flush before a workflow act. The + * page is the one that reloads the winner, because only it owns the + * loaded view. + */ + onSettled: (result: SaveResult) => void = () => {}; + + readonly #view: () => DraftView | null; + readonly #mayEdit: (view: DraftView) => boolean; + #timer: ReturnType | null = null; + #inFlight: Promise | null = null; + #release: (() => void) | null = null; + #dirty = false; + #destroyed = false; + /** + * A refusal the page has not answered with a reload yet. A later save + * that the same stale revision also refuses reports nothing new: it + * carries no text the first refusal did not already hand over, and + * reporting again would overwrite the buffer with post-reload state. + */ + #reported_stale = false; + + /** + * `view` returns the draft the page loaded (or `null` while loading); + * `mayEdit` is the page's session-derived editing rule, because the + * controller never guesses authority from a role label (ADR 0010). + */ + constructor( + view: () => DraftView | null, + mayEdit: (view: DraftView) => boolean + ) { + this.#view = view; + this.#mayEdit = mayEdit; + } + + /** Whether the author may edit the loaded draft right now. */ + get editable(): boolean { + const view = this.#view(); + return view !== null && openForEditing(view) && this.#mayEdit(view); + } + + // ------------------------------------------------------------ editing + + /** Debounced autosave once an edit lands. */ + scheduleSave(): void { + if (this.#destroyed || !this.editable) { + return; + } + this.saveState = 'pending'; + if (this.#timer !== null) { + clearTimeout(this.#timer); + } + this.#timer = setTimeout(() => { + this.#timer = null; + void this.saveNow(); + }, AUTOSAVE_DELAY_MS); + } + + /** + * Saves the current content, or joins the save already running. A + * `stale_save` is a typed answer, not a failure: it is reported with + * the text the refused save carried, so the page can reload the winner + * while that text stays readable. A real transport or server failure + * is reported as `failed`. Never rejects. + */ + saveNow(): Promise { + if (this.#destroyed) { + return Promise.resolve(); + } + // The armed timer is this attempt, or — when a run is already in + // flight — an edit the running chain will re-send. Either way it is + // consumed here: a timer left armed would fire after a workflow act + // completed and save over it. + if (this.#timer !== null) { + clearTimeout(this.#timer); + this.#timer = null; + } + if (this.#inFlight !== null) { + // A newer edit arrived while a request is in flight; the + // running chain re-sends the latest state once it settles. + this.#dirty = true; + return this.#inFlight; + } + const draft_id = this.#view()?.id ?? null; + if (draft_id === null) { + return Promise.resolve(); + } + if (this.#reported_stale) { + // The page has not yet reloaded the winner for the refusal it + // was already told about; a further attempt would only be + // refused by the same stale revision — and it is not a save + // that is still coming, so the indicator must not keep saying + // one is. + this.saveState = 'idle'; + return Promise.resolve(); + } + const run: SaveRun = { draft_id, refused: null }; + this.#settled = { status: 'saved' }; + const reported = new Promise((resolve) => { + this.#release = resolve; + }); + const work = this.#saveChain(run); + this.#inFlight = work; + void work.finally(() => { + if (this.#inFlight === work) { + this.#inFlight = null; + } + const release = this.#release; + this.#release = null; + release?.(); + if (!this.#destroyed) { + this.onSettled(this.#settled); + } + }); + return reported; + } + + /** + * Nothing workflow-shaped runs over unsaved edits: a pending or + * in-flight save lands first and reports through `onSettled`, whatever + * it settled as. + */ + async flush(): Promise { + if (this.#timer !== null) { + await this.saveNow(); + return; + } + if (this.#inFlight !== null) { + await this.#inFlight; + } + } + + /** + * Adopts the loaded working copy, replacing every editable value, and + * returns the state it replaced. The page keeps that instead of a + * snapshot taken before its reload: text typed while the reload was in + * flight is only in the state being replaced (issue #34). + */ + adopt(view: DraftView): EditorSnapshot { + const replaced = this.snapshot(); + const values: Record = {}; + const notObserved: Record = {}; + const modifiers: Record> = {}; + for (const competency of view.form.competencies) { + values[competency.form_competency_id] = null; + notObserved[competency.form_competency_id] = false; + modifiers[competency.form_competency_id] = {}; + } + for (const rating of view.content.ratings) { + values[rating.form_competency_id] = rating.value; + notObserved[rating.form_competency_id] = rating.not_observed; + const picked: Record = {}; + for (const id of rating.modifier_ids) { + picked[id] = true; + } + modifiers[rating.form_competency_id] = picked; + } + const narratives: Record = {}; + for (const narrative of view.form.narratives) { + narratives[narrative.form_narrative_id] = ''; + } + for (const entry of view.content.narratives) { + narratives[entry.form_narrative_id] = entry.text; + } + this.values = values; + this.notObserved = notObserved; + this.modifiers = modifiers; + this.narratives = narratives; + this.revision = view.revision; + // The page has reloaded the winning copy, so a refusal of the + // revision left behind is answered and may be reported afresh. + this.#reported_stale = false; + return replaced; + } + + /** + * Keeps a refused buffer for the author to read and copy. An empty + * buffer is nothing to show and is not kept. + */ + keepRefused(buffer: RefusedBuffer): void { + if (buffer.narratives.length === 0 && buffer.ratings.length === 0) { + return; + } + this.refused = [buffer, ...this.refused]; + } + + /** + * Drops one refused buffer the author has acknowledged, the only way it + * leaves the page besides navigation, logout, or a draft identity + * change. It deliberately leaves `saveState` alone: a failed save is a + * fact about the working copy, and discarding refused text must not + * disarm the guard that keeps a workflow act from submitting it — that + * guard is lifted only when the last buffer goes, and by the writer's + * own successful save. + */ + discardRefused(index: number): void { + this.refused = this.refused.filter((_, at) => at !== index); + this.#reported_stale = false; + } + + /** + * The refusal's reload landed: the page's working copy is the winner's, + * so whatever the buffers still hold is the writer's to copy or + * discard. + */ + markReloaded(): void { + this.#reloadFailed = false; + } + + /** + * The refusal's reload did not land: the page cannot act on a copy it + * has not seen, whatever the buffers hold. + */ + markReloadFailed(): void { + this.#reloadFailed = true; + } + + /** + * Whether the working copy now carries everything one refused buffer + * held, field for field. A save that carries a buffer's text is the + * writer putting it back; a save that does not leaves that buffer + * unresolved. + */ + #carries(buffer: RefusedBuffer): boolean { + for (const narrative of buffer.narratives) { + if ((this.narratives[narrative.form_narrative_id] ?? '') !== narrative.text) { + return false; + } + } + for (const rating of buffer.ratings) { + const id = rating.form_competency_id; + const marked = this.notObserved[id] ?? false; + const value = marked ? null : (this.values[id] ?? null); + const picked = Object.entries(this.modifiers[id] ?? {}) + .filter(([, on]) => on) + .map(([key]) => Number(key)) + .sort((left, right) => left - right); + const expected = [...rating.modifier_ids].sort((left, right) => left - right); + if (marked !== rating.not_observed || value !== rating.value) { + return false; + } + if (!sameIds(picked, expected)) { + return false; + } + } + return true; + } + + /** + * The writer's own save landed. Buffers whose text that save carried + * are resolved — the text is in the draft now — while a buffer whose + * text it did not carry stays unresolved, so an act still waits for it. + * The buffers themselves stay readable until the writer discards them. + */ + #resolveCarried(): void { + this.#reloadFailed = false; + this.refused = this.refused.map((buffer) => + !buffer.resolved && this.#carries(buffer) ? { ...buffer, resolved: true } : buffer + ); + } + + /** + * Ends this controller's work: the refused text is dropped, a late + * response from this draft is ignored, and an ordinary edit still + * waiting on the debounce is sent rather than discarded. Called on + * navigation (including client-side navigation that reuses the route) + * and on logout, because the route component is destroyed either way. + * + * The two are deliberately different. Refused recovery text is a + * concurrency answer and lives in this controller, so navigation drops + * it; an unsaved edit is the writer's work and navigation must not + * silently lose it. Nothing here resubmits text the contract refused: + * a refusal the page has not answered yet is skipped, and the buffer is + * never part of what a save carries. + */ + destroy(): void { + const draft_id = this.#view()?.id ?? null; + const pending = (this.#timer !== null || this.#dirty) && !this.#reported_stale; + this.#destroyed = true; + if (this.#timer !== null) { + clearTimeout(this.#timer); + this.#timer = null; + } + this.#dirty = false; + this.#reported_stale = false; + this.refused = []; + if (pending && draft_id !== null) { + void this.#saveOnTeardown(draft_id); + } + } + + /** + * One last save for an edit that was still queued when the route went + * away. It waits for any in-flight save first, so the revision it sends + * is the one the server actually reached, and it reports nothing: the + * page that would have shown the outcome is gone. + */ + async #saveOnTeardown(draft_id: number): Promise { + const inflight = this.#inFlight; + if (inflight !== null) { + await inflight.catch(() => {}); + } + if (this.#view()?.id !== draft_id) { + return; + } + try { + await saveDraftContent(draft_id, this.revision, this.#buildContent()); + } catch { + // The page is gone; the next visit surfaces the state. + } + } + + // --------------------------------------------------------- the content + + /** The content a save would send right now. */ + #buildContent(): DraftContent { + const view = this.#view(); + if (view === null) { + return { ratings: [], narratives: [] }; + } + const ratings: DraftContent['ratings'] = []; + for (const competency of view.form.competencies) { + const id = competency.form_competency_id; + const marked = this.notObserved[id] ?? false; + const value = marked ? null : (this.values[id] ?? null); + const picked = Object.entries(this.modifiers[id] ?? {}) + .filter(([, on]) => on) + .map(([modifierId]) => Number(modifierId)); + if (value !== null || marked || picked.length > 0) { + ratings.push({ + form_competency_id: id, + value, + not_observed: marked, + modifier_ids: picked + }); + } + } + const texts: DraftContent['narratives'] = []; + for (const narrative of view.form.narratives) { + const id = narrative.form_narrative_id; + const text = this.narratives[id] ?? ''; + if (text !== '') { + texts.push({ form_narrative_id: id, text }); + } + } + return { ratings, narratives: texts }; + } + + // ----------------------------------------------------------- internals + + /** The chain itself. Never reports twice; `#report` is the one exit. */ + async #saveChain(run: SaveRun): Promise { + // A save that lands resolves the recovery guard only when no refusal + // arrived while it was in flight: its content is what the writer + // meant to save, and the server now holds it. + const refusalsAtStart = this.#refusals; + try { + // One attempt per content state: an edit made while a request + // is pending marks the chain dirty and re-runs the save, so + // nothing typed during an await is dropped, and an attempt the + // server refused is never repeated against the same revision. + for (;;) { + this.#dirty = false; + if (this.#stale(run)) { + return; + } + this.saveState = 'saving'; + // The snapshot is taken at request time: it is what this + // save carries, and the loop below picks up any edit typed + // while the request was pending. + run.refused = this.snapshot(); + let saved; + try { + saved = await saveDraftContent( + run.draft_id, + run.refused.revision, + this.#buildContent() + ); + } catch (err) { + if (this.#stale(run)) { + return; + } + if (err instanceof ApiError && err.code === 'stale_save') { + if (run.refused.revision !== this.revision) { + // The page's reload landed while this attempt + // was in flight; the refusal belongs to a + // revision already left behind, and a retry + // now carries the writer's newest text against + // the winner's revision. + this.saveState = 'idle'; + continue; + } + // Another contributor saved first: their copy wins + // and is what the page will present. Report the + // text this save carried so the page can reload the + // winner and then keep the divergent parts of that + // text readable (#34). One report per refusal: any + // later attempt is refused by the same stale + // revision and carries nothing new. + this.saveState = 'idle'; + if (!this.#reported_stale) { + this.#reported_stale = true; + // Recorded before the page is told, so a + // workflow act that starts while the refusal's + // reload is in flight already waits. + this.#refusals += 1; + this.#settled = { + status: 'stale', + refused: run.refused + }; + } + return; + } + this.saveState = 'failed'; + this.#settled = { + status: 'failed', + message: + err instanceof ApiError ? err.message : 'the server could not be reached' + }; + return; + } + if (this.#stale(run)) { + return; + } + this.revision = saved.revision; + this.saveState = 'saved'; + if (this.#refusals === refusalsAtStart) { + this.#resolveCarried(); + } + await this.refreshMeta(run.draft_id); + if (!this.#dirty || this.#destroyed || this.#stale(run)) { + break; + } + } + } catch { + // Only the chain's own bookkeeping can fail here: the server + // call's errors are handled at the attempt. + if (!this.#stale(run)) { + this.saveState = 'failed'; + this.#settled = { status: 'failed', message: 'the save could not be completed' }; + } + } + } + + /** The result this chain settled as, reported once when it releases. */ + #settled: SaveResult = { status: 'saved' }; + + /** Whether this run's result still belongs to the draft on screen. */ + #stale(run: SaveRun): boolean { + if (this.#destroyed) { + return true; + } + const view = this.#view(); + return view === null || view.id !== run.draft_id; + } + + /** + * The editable state right now, durable against a reload. The page + * needs this at catch time as well as inside the chain: text typed + * while a refused request was in flight exists only here. + */ + snapshot(): EditorSnapshot { + const view = this.#view(); + const values = new Map(); + const notObserved = new Map(); + const modifiers = new Map>(); + const narratives = new Map(); + for (const [key, value] of Object.entries(this.values)) { + values.set(Number(key), value); + } + for (const [key, value] of Object.entries(this.notObserved)) { + notObserved.set(Number(key), value); + } + for (const [key, picked] of Object.entries(this.modifiers)) { + modifiers.set( + Number(key), + new Set( + Object.entries(picked ?? {}) + .filter(([, on]) => on) + .map(([id]) => Number(id)) + ) + ); + } + for (const [key, text] of Object.entries(this.narratives)) { + narratives.set(Number(key), text); + } + const shape: FormShape = { + narrative_labels: new Map(), + competency_names: new Map(), + modifier_codes: new Map() + }; + if (view !== null) { + for (const narrative of view.form.narratives) { + shape.narrative_labels.set(narrative.form_narrative_id, narrative.prompt); + } + for (const competency of view.form.competencies) { + shape.competency_names.set(competency.form_competency_id, competency.name); + } + for (const modifier of view.form.modifiers) { + shape.modifier_codes.set(modifier.rating_modifier_id, modifier.code); + } + } + return { revision: this.revision, values, notObserved, modifiers, narratives, shape }; + } + + /** Attribution and workflow state, refreshed without clobbering edits. */ + async refreshMeta(draft_id: number): Promise { + // The view this refresh is about: a reload that lands while the + // request is in flight replaces the object, and its newer status + // must not be overwritten by this older answer. + const view = this.#view(); + if (view === null) { + return; + } + try { + const fetched = await getDraft(draft_id); + if (this.#stale({ draft_id, refused: null }) || this.#view() !== view) { + return; + } + view.status = fetched.status; + view.owner_user_id = fetched.owner_user_id; + view.owner_display_name = fetched.owner_display_name; + view.events = fetched.events; + view.snapshots = fetched.snapshots; + view.eligible_recipients = fetched.eligible_recipients; + } catch { + // The next save or reload surfaces the problem. + } + } +} + +/** Draft, changes-requested, and returned states edit and resubmit. */ +export function openForEditing(view: DraftView): boolean { + return ( + view.status === 'draft' || + view.status === 'changes_requested' || + view.status === 'returned' + ); +} + +/** + * The parts of a refused save that differ from the reloaded winning copy. + * Text the winner already carries is not shown: the exact divergent text + * is the point (#34), and nothing here merges the two. + */ +export function divergentBuffer( + refused: EditorSnapshot, + latest: EditorSnapshot, + winner: DraftView | null +): RefusedBuffer { + // With no reloaded copy to compare against (a reload that failed), the + // whole buffer is recoverable text rather than divergent text: the + // refusal kept it, and showing nothing would discard it silently. + const winning_narratives = new Map(); + if (winner !== null) { + for (const narrative of winner.form.narratives) { + winning_narratives.set(narrative.form_narrative_id, ''); + } + for (const entry of winner.content.narratives) { + winning_narratives.set(entry.form_narrative_id, entry.text); + } + } + const narratives: RefusedNarrative[] = []; + const ids = new Set([...refused.narratives.keys(), ...latest.narratives.keys()]); + for (const id of ids) { + const sent = refused.narratives.get(id) ?? ''; + const current = latest.narratives.get(id) ?? sent; + // The newest text the writer had, and whether it ever reached the + // server: a save refused at request time never carried an edit made + // after it was sent, so that text is newer than the refusal. + const text = current; + if (text === '' || text === (winning_narratives.get(id) ?? '')) { + continue; + } + narratives.push({ + form_narrative_id: id, + label: + latest.shape.narrative_labels.get(id) ?? + refused.shape.narrative_labels.get(id) ?? + `Narrative ${id}`, + text, + typed_while_pending: current !== sent + }); + } + narratives.sort((left, right) => left.form_narrative_id - right.form_narrative_id); + + const winner_ratings = new Map< + number, + { value: number | null; not_observed: boolean; modifier_ids: number[] } + >(); + if (winner !== null) { + for (const rating of winner.content.ratings) { + winner_ratings.set(rating.form_competency_id, { + value: rating.value, + not_observed: rating.not_observed, + modifier_ids: rating.modifier_ids + }); + } + } + // The competencies to consider: the winner's form, and — when there is + // no winner to compare against, because the reload failed — the ones + // the refused save itself named. A rating the writer never touched is + // not a difference, and a competency the winner has no stored row for + // reads as the form's default state, so an untouched competency is + // never shown as divergent (issue #34). + const competency_ids = new Set(); + for (const competency of winner?.form.competencies ?? []) { + competency_ids.add(competency.form_competency_id); + } + for (const id of refused.shape.competency_names.keys()) { + competency_ids.add(id); + } + for (const id of latest.shape.competency_names.keys()) { + competency_ids.add(id); + } + const ratings: RefusedRating[] = []; + for (const id of [...competency_ids].sort((left, right) => left - right)) { + const state = (snapshot: EditorSnapshot) => { + const not_observed = snapshot.notObserved.get(id) ?? false; + return { + not_observed, + value: not_observed ? null : (snapshot.values.get(id) ?? null), + picked: [...(snapshot.modifiers.get(id) ?? new Set())].sort( + (left, right) => left - right + ) + }; + }; + const sent = state(refused); + const current = state(latest); + // No stored row is the form's default, not a divergence. + const winning = winner_ratings.get(id) ?? { + value: null, + not_observed: false, + modifier_ids: [] + }; + const same = + winning.not_observed === current.not_observed && + winning.value === current.value && + sameIds(winning.modifier_ids, current.picked); + if (same) { + continue; + } + ratings.push({ + form_competency_id: id, + name: + winner?.form.competencies.find( + (candidate) => candidate.form_competency_id === id + )?.name ?? + latest.shape.competency_names.get(id) ?? + refused.shape.competency_names.get(id) ?? + `Competency ${id}`, + not_observed: current.not_observed, + value: current.value, + modifier_codes: current.picked.map( + (candidate) => + latest.shape.modifier_codes.get(candidate) ?? + refused.shape.modifier_codes.get(candidate) ?? + String(candidate) + ), + modifier_ids: current.picked, + typed_while_pending: + sent.not_observed !== current.not_observed || + sent.value !== current.value || + !sameIds(sent.picked, current.picked) + }); + } + return { narratives, ratings, resolved: false }; +} + +function sameIds(left: number[], right: number[]): boolean { + if (left.length !== right.length) { + return false; + } + const sorted = [...left].sort((first, second) => first - second); + return sorted.every((candidate, index) => candidate === right[index]); +} diff --git a/web/src/routes/drafts/[id]/+page.svelte b/web/src/routes/drafts/[id]/+page.svelte index 3f96228..a3f8a85 100644 --- a/web/src/routes/drafts/[id]/+page.svelte +++ b/web/src/routes/drafts/[id]/+page.svelte @@ -36,6 +36,14 @@ type Verification, type VersionHistoryRow } from '$lib/api'; + import { + DraftEditorController, + divergentBuffer, + openForEditing, + type EditorSnapshot, + type SaveResult + } from '$lib/drafts/editor.svelte'; + import RefusedTextPanel from '$lib/drafts/RefusedTextPanel.svelte'; import { instant } from '$lib/format'; import type { ShellData } from '../../+layout'; @@ -65,14 +73,27 @@ let error = $state(''); let busy = $state(false); - // The working copy under edit, keyed by the pinned vocabulary ids, - // and the revision it was based on — every save carries it, so a - // concurrent contributor's work is never silently overwritten. - let revision = $state(0); - let values: Record = $state({}); - let notObserved: Record = $state({}); - let modifiers: Record> = $state({}); - let narratives: Record = $state({}); + // One owner of the editable working copy, the autosave chain, and the + // refused-save recovery buffer (#34; #59 ownership boundary). The page + // supplies the session-derived editing rule and otherwise reads the + // controller; it never mirrors an editable value beside it. + // + // The instance is deliberately not derived from the view: a reload + // replaces the working copy many times over one draft's life, and a + // fresh controller per reload would drop an in-flight save's identity + // and any refused text. One instance per draft identity, replaced only + // when the route addresses a different draft. + // Created once, then replaced only when the route addresses a different + // draft. It is never undefined: the first render reads it before any + // effect has run, and an effect-assigned binding would leave that + // render without one. The class carries its own fine-grained rune + // state, so replacing the instance is visible where it matters. + let editor: DraftEditorController = $state( + new DraftEditorController( + () => view, + () => canAssign || canAuthor + ) + ); // The sealed record, fetched when the draft is finalized: the page // then presents from the stored envelope, never from live rows @@ -110,71 +131,87 @@ let linkable: SummaryLink[] = $state([]); let linkChoice: number | '' = $state(''); - async function load() { + /** + * Loads the draft the route addresses and adopts it as the working + * copy. Returns the working copy that adopt replaced — text typed while + * this reload was in flight is only there — or `null` when the load + * failed or the route moved to another draft or version while it ran. + */ + async function load(): Promise { + const wanted = requestedVersion; + const wantedDraft = draftId; try { - const wanted = requestedVersion; - const fetched = await getDraft(draftId); + const fetched = await getDraft(wantedDraft); + let nextSealed: FinalizedView | null = null; + let nextAck: Acknowledgment | null = null; + let nextVersions: VersionHistoryRow[] = []; + // A draft that is no longer finalized has nothing to verify. + let clearVerification = false; if (fetched.status === 'finalized') { // The envelope is the only permitted presentation of a // finalized record (ADR 0011): if it cannot load, the page // fails closed and presents nothing from live joins. - sealed = + nextSealed = wanted === null - ? await finalizedVersion(draftId) - : await finalizedVersionAt(draftId, wanted); - ack = (await getAcknowledgment(draftId)).acknowledgment; - versions = (await versionHistory(draftId)).versions; - } else { - sealed = null; - verification = null; - ack = null; - versions = []; - } - if ( - fetched.record_type === 'weekly_summary' && - fetched.status !== 'finalized' - ) { - linkable = (await linkableDailies(draftId)).dailies; + ? await finalizedVersion(wantedDraft) + : await finalizedVersionAt(wantedDraft, wanted); + nextAck = (await getAcknowledgment(wantedDraft)).acknowledgment; + nextVersions = (await versionHistory(wantedDraft)).versions; } else { - linkable = []; + clearVerification = true; } - view = fetched; - const nextValues: Record = {}; - const nextObserved: Record = {}; - const nextModifiers: Record> = {}; - for (const competency of fetched.form.competencies) { - nextValues[competency.form_competency_id] = null; - nextObserved[competency.form_competency_id] = false; - nextModifiers[competency.form_competency_id] = {}; - } - for (const rating of fetched.content.ratings) { - nextValues[rating.form_competency_id] = rating.value; - nextObserved[rating.form_competency_id] = rating.not_observed; - const picked: Record = {}; - for (const id of rating.modifier_ids) { - picked[id] = true; - } - nextModifiers[rating.form_competency_id] = picked; + let nextLinkable: SummaryLink[] = []; + if (fetched.record_type === 'weekly_summary' && fetched.status !== 'finalized') { + nextLinkable = (await linkableDailies(wantedDraft)).dailies; } - const nextNarratives: Record = {}; - for (const narrative of fetched.form.narratives) { - nextNarratives[narrative.form_narrative_id] = ''; + if (draftId !== wantedDraft || requestedVersion !== wanted) { + // The route moved on while this draft was loading: its + // answer belongs to a page that is gone, and publishing it + // would put one draft's content on another's route. + return null; } - for (const entry of fetched.content.narratives) { - nextNarratives[entry.form_narrative_id] = entry.text; + view = fetched; + sealed = nextSealed; + ack = nextAck; + versions = nextVersions; + linkable = nextLinkable; + if (clearVerification) { + verification = null; } - values = nextValues; - notObserved = nextObserved; - modifiers = nextModifiers; - narratives = nextNarratives; - revision = fetched.revision; } catch (err) { + if (draftId !== wantedDraft || requestedVersion !== wanted) { + return null; + } view = null; sealed = null; error = err instanceof ApiError ? err.message : 'the server could not be reached'; + return null; } + return editor.adopt(view); } + // The refused buffer belongs to one draft identity: a new draft gets a + // new controller (and the old one clears its text), so a client-side + // navigation that reuses this route never carries text across drafts. + $effect(() => { + void draftId; + // Every settled save reports here, whichever path started it. + editor.onSettled = received; + // The route component is destroyed on navigation and on logout + // (client-side navigation to another draft included), which is + // where the refused text stops existing. + return () => { + editor.destroy(); + // A different draft must not inherit this one's working copy or + // its refused text. + editor = new DraftEditorController( + () => view, + () => canAssign || canAuthor + ); + editor.onSettled = received; + }; + }); + $effect(() => { void [draftId, requestedVersion]; void load(); @@ -186,144 +223,100 @@ return status === 'draft' || status === 'changes_requested' || status === 'returned'; } - let editable = $derived.by(() => { - const current = view; - return current !== null && openStatus(current.status) && (canAssign || canAuthor); - }); + let editable = $derived( + view !== null && openForEditing(view) && (canAssign || canAuthor) + ); let mayRoute = $derived.by(() => { const current = view; return current !== null && (canAssign || myUserId === current.owner_user_id); }); - function buildContent(): DraftContent { - const current = view; - if (current === null) { - return { ratings: [], narratives: [] }; - } - const ratings = []; - for (const competency of current.form.competencies) { - const id = competency.form_competency_id; - const marked = notObserved[id] ?? false; - const value = marked ? null : (values[id] ?? null); - const picked = Object.entries(modifiers[id] ?? {}) - .filter(([, on]) => on) - .map(([modifierId]) => Number(modifierId)); - if (value !== null || marked || picked.length > 0) { - ratings.push({ - form_competency_id: id, - value, - not_observed: marked, - modifier_ids: picked - }); - } + /** + * Whether a workflow act must wait, and why. The refusal state lives in + * the controller — set the moment a refusal is recorded, before the + * reload starts — so a draft identity change takes it away with the + * buffers instead of leaving this page blocked with nothing to dismiss. + * It is never cleared by the act it refuses: only the writer's own save + * carrying the text, or their discard, lifts it. + */ + function heldByRefusal(act: string): boolean { + if (editor.saveState === 'failed') { + error = `The draft did not save; ${act} waits until it does.`; + return true; } - const texts = []; - for (const narrative of current.form.narratives) { - const id = narrative.form_narrative_id; - const text = narratives[id] ?? ''; - if (text !== '') { - texts.push({ form_narrative_id: id, text }); - } + if (editor.unresolved) { + error = `Your refused text is still here and is not in the draft; copy anything you need into the reloaded fields and save, or discard it, before ${act}.`; + return true; } - return { ratings, narratives: texts }; + return false; } - // Autosave: debounced, with the save state visible so collaboration - // never depends on a submit button. - let saveState: 'idle' | 'pending' | 'saving' | 'saved' | 'failed' = $state('idle'); - let saveTimer: ReturnType | null = null; - let inFlight: Promise | null = null; - let staleReloaded = false; + // An edit: the controller debounces and saves it, and the page renders + // the settled save state. A refused save reloads the winner and then + // keeps the refused text readable (#34). + function editNow(): void { + editor.scheduleSave(); + } - function scheduleSave() { - if (!editable) { + // Everything the controller settled, including a real failure. + function received(result: SaveResult): void { + if (result.status === 'stale') { + void recoverFromRefusal(result); return; } - saveState = 'pending'; - if (saveTimer !== null) { - clearTimeout(saveTimer); - } - saveTimer = setTimeout(() => void saveNow(), 600); - } - - // Saves are serialized into one chain: a new edit while a request is - // in flight marks the chain dirty, and the chain re-sends the latest - // state with the revision the previous save returned — overlapping - // requests never race each other into a false conflict. - let dirtyAgain = false; - - function saveNow(): Promise { - saveTimer = null; - if (inFlight !== null) { - dirtyAgain = true; - return inFlight; + if (result.status === 'failed') { + error = `The draft did not save: ${result.message}`; } - saveState = 'saving'; - const run = (async () => { - try { - // The metadata refresh stays inside the loop: an edit made - // while it is awaited marks the chain dirty and re-runs the - // save, so nothing typed during any await is dropped. - do { - dirtyAgain = false; - saveState = 'saving'; - const saved = await saveDraftContent(draftId, revision, buildContent()); - revision = saved.revision; - saveState = 'saved'; - await refreshMeta(); - } while (dirtyAgain); - } catch (err) { - if (err instanceof ApiError && err.code === 'stale_save') { - // Another contributor saved first: their copy wins and - // the page says so, rather than overwriting it. - staleReloaded = true; - await load(); - saveState = 'idle'; - error = - 'Another contributor saved first; the draft reloaded with their latest content.'; - return; - } - saveState = 'failed'; - error = err instanceof ApiError ? err.message : 'the server could not be reached'; - } finally { - inFlight = null; - } - })(); - inFlight = run; - return run; } - // Nothing workflow-shaped runs over unsaved edits: a pending or - // in-flight save lands first. - async function flushSaves() { - if (saveTimer !== null) { - clearTimeout(saveTimer); - await saveNow(); + /** + * Another contributor saved first. Their copy wins and the page + * reloads it, so rather than overwriting it. The text this save + * carried is computed against the reloaded winner and kept in the + * controller as the read-only recovery buffer (#34). + */ + async function recoverFromRefusal(result: SaveResult): Promise { + // The controller this refusal belongs to, captured before the reload + // awaits: a route change replaces the controller, and this + // continuation must then touch nothing at all — not its buffers, not + // this page's error, not the guard of the draft now on screen. + const origin = editor; + const originDraft = draftId; + const originVersion = requestedVersion; + const refused = result.status === 'stale' ? result.refused : null; + // The reload hands back the working copy it replaced, so an edit + // made while it was in flight is still in the comparison instead of + // being overwritten unseen. + const replaced = await load(); + if (editor !== origin || draftId !== originDraft || requestedVersion !== originVersion) { + // The page moved to another draft while this reload was in + // flight: an obsolete answer, not a failed reload. return; } - if (inFlight !== null) { - await inFlight; + const reloaded = replaced !== null; + if (refused !== null) { + // With no reloaded copy to compare against, everything the + // refusal carried stays recoverable: a failed reload must never + // be the reason text disappears. + origin.keepRefused( + divergentBuffer(refused, replaced ?? origin.snapshot(), reloaded ? view : null) + ); } - } - - // Refresh attribution and workflow state without clobbering what the - // contributor is typing. - async function refreshMeta() { - const current = view; - if (current === null) { - return; + if (reloaded) { + origin.markReloaded(); + } else { + origin.markReloadFailed(); } - try { - const fetched = await getDraft(draftId); - current.status = fetched.status; - current.owner_user_id = fetched.owner_user_id; - current.owner_display_name = fetched.owner_display_name; - current.events = fetched.events; - current.snapshots = fetched.snapshots; - current.eligible_recipients = fetched.eligible_recipients; - } catch { - // The next save or reload surfaces the problem. + if (!reloaded) { + // A failed reload is not a successful refresh: the refused + // text stays available above, and the load failure is what the + // page reports. + error = + 'The draft reloaded unsuccessfully after another contributor saved first; your unsaved text is kept below.'; + return; } + error = + 'Another contributor saved first; the draft reloaded with their latest content.'; } let transferTo: number | '' = $state(''); @@ -336,7 +329,7 @@ try { await transferDraft(draftId, transferTo); transferTo = ''; - await refreshMeta(); + await editor.refreshMeta(draftId); } catch (err) { error = err instanceof ApiError ? err.message : 'the server could not be reached'; } finally { @@ -348,14 +341,14 @@ busy = true; error = ''; try { - await flushSaves(); - if (saveState === 'failed' || staleReloaded) { - // A failed save or a reload from another contributor's copy - // is not something to submit sight unseen. - staleReloaded = false; + await editor.flush(); + if (heldByRefusal('submitting')) { + // A failed save, or text of the writer's own that the + // reloaded copy does not carry, is not something to submit + // sight unseen. return; } - await submitDraft(draftId, revision); + await submitDraft(draftId, editor.revision); await load(); } catch (err) { if (err instanceof ApiError && err.code === 'stale_save') { @@ -415,12 +408,11 @@ busy = true; error = ''; try { - await flushSaves(); - if (saveState === 'failed' || staleReloaded) { - staleReloaded = false; + await editor.flush(); + if (heldByRefusal('finalizing')) { return; } - await finalizeDraft(draftId, revision); + await finalizeDraft(draftId, editor.revision); await load(); } catch (err) { if (err instanceof ApiError && err.code === 'stale_save') { @@ -533,9 +525,9 @@ busy = true; error = ''; try { - await flushSaves(); - const saved = await addSummaryLink(draftId, Number(linkChoice), revision); - revision = saved.revision; + await editor.flush(); + const saved = await addSummaryLink(draftId, Number(linkChoice), editor.revision); + editor.revision = saved.revision; const picked = linkable.find((row) => row.daily_version_id === linkChoice); if (picked) { current.summary_links = [...current.summary_links, picked]; @@ -562,9 +554,9 @@ busy = true; error = ''; try { - await flushSaves(); - const saved = await removeSummaryLink(draftId, link.daily_version_id, revision); - revision = saved.revision; + await editor.flush(); + const saved = await removeSummaryLink(draftId, link.daily_version_id, editor.revision); + editor.revision = saved.revision; current.summary_links = current.summary_links.filter( (row) => row.daily_version_id !== link.daily_version_id ); @@ -684,6 +676,18 @@ Daily draft — Consolebook +{#if editor.refused.length > 0} + +
+ editor.discardRefused(index)} + /> +
+{/if} + {#if view === null} {#if error} @@ -744,11 +748,11 @@ {#if openStatus(view.status)} - {#if saveState === 'pending' || saveState === 'saving'} + {#if editor.saveState === 'pending' || editor.saveState === 'saving'} Saving… - {:else if saveState === 'saved'} + {:else if editor.saveState === 'saved'} Saved - {:else if saveState === 'failed'} + {:else if editor.saveState === 'failed'} Save failed {/if} @@ -1214,9 +1218,9 @@ {#each numericValues(competency) as value (value)} @@ -1257,13 +1261,13 @@ type="checkbox" disabled={!editable} bind:checked={ - notObserved[competency.form_competency_id] + editor.notObserved[competency.form_competency_id] } onchange={() => { - if (notObserved[competency.form_competency_id]) { - values[competency.form_competency_id] = null; + if (editor.notObserved[competency.form_competency_id]) { + editor.values[competency.form_competency_id] = null; } - scheduleSave(); + editNow(); }} /> Not observed @@ -1278,11 +1282,11 @@ type="checkbox" disabled={!editable} bind:checked={ - modifiers[competency.form_competency_id][ + editor.modifiers[competency.form_competency_id][ modifier.rating_modifier_id ] } - onchange={scheduleSave} + onchange={editNow} /> {modifier.code} @@ -1313,8 +1317,8 @@ id={`narrative-${narrative.form_narrative_id}`} rows="4" disabled={!editable} - bind:value={narratives[narrative.form_narrative_id]} - oninput={scheduleSave} + bind:value={editor.narratives[narrative.form_narrative_id]} + oninput={editNow} > {/each}