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}