diff --git a/apps/web/a11y/scan.a11y.ts b/apps/web/a11y/scan.a11y.ts index ee24120..ecf58f5 100644 --- a/apps/web/a11y/scan.a11y.ts +++ b/apps/web/a11y/scan.a11y.ts @@ -18,7 +18,9 @@ let modelResponse = JSON.stringify({ confidence: 1, }); let endpoint = ''; +let modelRequests = 0; const server = createServer((_request, response) => { + modelRequests += 1; response.setHeader('content-type', 'application/json'); response.end(JSON.stringify({ response: modelResponse })); }); @@ -144,6 +146,11 @@ test('a sheet uses OCR for its week and only asks for unreadable weeks', async ( }); await process.click(); await expect(approve).toBeDisabled(); + const requestsBeforeCorrection = modelRequests; + const homework = page.getByLabel('Homework or note'); + await homework.fill('Exercises 1–4, corrected'); + await completed.check(); + await page.getByLabel('Due date').fill('2026-01-06'); const weekInput = page.getByLabel('Week printed on the sheet'); await expect(weekInput).toBeVisible(); await weekInput.focus(); @@ -152,6 +159,8 @@ test('a sheet uses OCR for its week and only asks for unreadable weeks', async ( await page.keyboard.press('ArrowRight'); await page.keyboard.press('2'); await expect(weekInput).toHaveValue('0002-W01'); + await expect(homework).toHaveValue('Exercises 1–4, corrected'); + await expect(approve).toBeDisabled(); await expect(page.getByText('Sheet week: 0002-W01')).toBeVisible(); await expect( page.locator('details').filter({ has: weekInput }), @@ -160,21 +169,33 @@ test('a sheet uses OCR for its week and only asks for unreadable weeks', async ( await page.keyboard.type('026'); await expect(weekInput).toHaveValue('2026-W01'); await expect(weekInput).toBeFocused(); - modelResponse = JSON.stringify({ - weekId: '2026-W01', - entries: [entry], - confidence: 1, - }); - await process.click(); + await expect(approve).toBeDisabled(); + await page.route('**/scan', (route) => route.abort(), { times: 1 }); + await page + .getByRole('button', { name: 'Use this week', exact: true }) + .click(); + await expect( + page.getByRole('alert').filter({ hasText: 'PaperSync could not complete' }), + ).toContainText('PaperSync could not complete the request'); + await page + .getByRole('alert') + .filter({ hasText: 'PaperSync could not complete' }) + .getByRole('button', { name: 'Dismiss', exact: true }) + .click(); + await expect(homework).toHaveValue('Exercises 1–4, corrected'); + await expect(approve).toBeDisabled(); + await page + .getByRole('button', { name: 'Use this week', exact: true }) + .click(); await expect(approve).toBeEnabled(); + await expect(homework).toHaveValue('Exercises 1–4, corrected'); + await expect(completed).toBeChecked(); + await expect(page.getByLabel('Due date')).toHaveValue('2026-01-06'); + expect(modelRequests).toBe(requestsBeforeCorrection); expect(await scanWcag22AaViolations(page)).toEqual([]); await expect( page.getByRole('button', { name: 'Dismiss', exact: true }), ).toHaveCount(0); - await page.screenshot({ - path: test.info().outputPath('review.png'), - fullPage: true, - }); }); test('rescans collapse saved homework and allow deliberate corrections without losing typing focus', async ({ diff --git a/apps/web/integration/homework.ts b/apps/web/integration/homework.ts index 3fe3005..447c1a2 100644 --- a/apps/web/integration/homework.ts +++ b/apps/web/integration/homework.ts @@ -13,6 +13,7 @@ import { getPendingHomework, } from '../src/shared/homework/queue'; import { reconcileHomework } from '../src/shared/homework/reconcile'; +import { reconcileReviewWeek } from '../src/shared/homework/review-week'; import { OCRResponse, type TaskAction } from '../src/shared/types/schemas'; const runIsolated = (program: Effect.Effect) => @@ -165,3 +166,46 @@ it('rescans separate saved homework from new entries and changed paper details', expect(result.unknownWeek).toEqual({ ...response, weekId: null }); expect(result.remaining).toEqual([]); }); + +it('changing a reviewed week rechecks duplicates and preserves edits without queue writes', async () => { + const result = await runIsolated( + Effect.gen(function* () { + yield* enqueueHomework([entry], options); + const edited = { + ...entry, + id: 'stable-review-id', + content: ` ${entry.content} `, + }; + const sameWeek = yield* reconcileReviewWeek([edited], options.weekId); + const otherWeek = yield* reconcileReviewWeek(sameWeek, '2026-W38'); + const changed = yield* reconcileReviewWeek( + [{ ...edited, isCompleted: true, dueDate: '2026-09-12' }], + options.weekId, + ); + const invalid = yield* reconcileReviewWeek([edited], 'invalid').pipe( + Effect.either, + ); + return { + edited, + sameWeek, + otherWeek, + changed, + invalid, + pending: yield* getPendingHomework, + }; + }), + ); + expect(result.sameWeek).toEqual([{ ...result.edited, action: 'skip' }]); + expect(result.otherWeek).toEqual([{ ...result.edited, action: 'add' }]); + expect(result.changed).toEqual([ + { + ...result.edited, + action: 'modify', + isCompleted: true, + dueDate: '2026-09-12', + }, + ]); + expect(result.invalid._tag).toBe('Left'); + expect(result.pending).toHaveLength(1); + expect(result.pending[0].payload.isCompleted).toBe(false); +}); diff --git a/apps/web/src/features/scanner/hooks/use-review-week.ts b/apps/web/src/features/scanner/hooks/use-review-week.ts new file mode 100644 index 0000000..8a5d4e1 --- /dev/null +++ b/apps/web/src/features/scanner/hooks/use-review-week.ts @@ -0,0 +1,65 @@ +'use client'; + +import { Effect } from 'effect'; +import { + type Dispatch, + type RefObject, + type SetStateAction, + useState, +} from 'react'; +import { applyReviewWeek } from '@/shared/homework/actions'; +import type { ExtractedEntry } from '@/shared/homework/entry'; +import type { ReviewWeekResult } from '@/shared/homework/review-week'; +import { requestAction } from '@/shared/http/action'; +import type { WeekId } from '@/shared/types/schemas'; +import type { ScanState } from './use-scan-types'; + +type ReviewWeekOptions = { + readonly state: ScanState; + readonly setState: Dispatch>; + readonly weekId: WeekId | null; + readonly revisionRef: RefObject; +}; + +export const useReviewWeek = ({ + state, + setState, + weekId, + revisionRef, +}: ReviewWeekOptions) => { + const [isUpdatingWeek, setIsUpdatingWeek] = useState(false); + const applyWeek = ( + entries: ReadonlyArray, + ): Promise => { + if (state.status !== 'complete' || !weekId || isUpdatingWeek) { + return Promise.resolve(null); + } + revisionRef.current += 1; + const currentRevision = revisionRef.current; + setIsUpdatingWeek(true); + return Effect.runPromise( + requestAction(() => applyReviewWeek(entries, weekId)).pipe( + Effect.catchAll((error) => + Effect.succeed({ success: false as const, error: error.message }), + ), + Effect.map((result) => { + if (currentRevision !== revisionRef.current) { + return null; + } + if (result.success) { + setState({ ...state, weekId, entries: result.entries }); + } + return result; + }), + Effect.ensuring( + Effect.sync(() => { + if (currentRevision === revisionRef.current) { + setIsUpdatingWeek(false); + } + }), + ), + ), + ); + }; + return { isUpdatingWeek, setIsUpdatingWeek, applyWeek }; +}; diff --git a/apps/web/src/features/scanner/hooks/use-scan-types.ts b/apps/web/src/features/scanner/hooks/use-scan-types.ts index 77fa2dc..93d4370 100644 --- a/apps/web/src/features/scanner/hooks/use-scan-types.ts +++ b/apps/web/src/features/scanner/hooks/use-scan-types.ts @@ -1,4 +1,5 @@ import type { ExtractedEntry } from '@/shared/homework/entry'; +import type { ReviewWeekResult } from '@/shared/homework/review-week'; import type { WeekId } from '@/shared/types/schemas'; export type ScanState = | { readonly status: 'idle' } @@ -28,6 +29,11 @@ export type UseScanReturn = { readonly state: ScanState; readonly weekId: WeekId | null; readonly setWeekId: (value: string) => void; + readonly isUpdatingWeek: boolean; + readonly canSave: boolean; + readonly applyWeek: ( + entries: ReadonlyArray, + ) => Promise; readonly imagePreview: string | null; readonly upload: (file: File) => Promise; readonly process: () => Promise; diff --git a/apps/web/src/features/scanner/hooks/use-scan.ts b/apps/web/src/features/scanner/hooks/use-scan.ts index 8153819..8b27e00 100644 --- a/apps/web/src/features/scanner/hooks/use-scan.ts +++ b/apps/web/src/features/scanner/hooks/use-scan.ts @@ -3,6 +3,7 @@ import { Effect, Schema } from 'effect'; import { useRef, useState } from 'react'; import { WeekId } from '@/shared/types/schemas'; +import { useReviewWeek } from './use-review-week'; import { processExtractionEffect, readFileAsDataUrl } from './use-scan-effects'; import type { ScanState, @@ -14,10 +15,17 @@ export const useScan = (options: UseScanOptions): UseScanReturn => { const [imagePreview, setImagePreview] = useState(null); const [weekId, setWeek] = useState(null); const revisionRef = useRef(0); + const { isUpdatingWeek, setIsUpdatingWeek, applyWeek } = useReviewWeek({ + state, + setState, + weekId, + revisionRef, + }); const clear = () => { revisionRef.current += 1; setState({ status: 'idle' }); + setIsUpdatingWeek(false); setImagePreview(null); setWeek(null); }; @@ -83,13 +91,22 @@ export const useScan = (options: UseScanOptions): UseScanReturn => { const setWeekId = (value: string) => { revisionRef.current += 1; setWeek(Schema.is(WeekId)(value) ? value : null); - setState({ status: 'idle' }); + setState((current) => + current.status === 'complete' ? current : { status: 'idle' }, + ); }; return { state, weekId, setWeekId, + isUpdatingWeek, + canSave: + state.status === 'complete' && + weekId !== null && + state.weekId === weekId && + !isUpdatingWeek, + applyWeek, imagePreview, upload, process, diff --git a/apps/web/src/features/scanner/screens/components/results-panel-complete.tsx b/apps/web/src/features/scanner/screens/components/results-panel-complete.tsx index 484a5fc..51039c6 100644 --- a/apps/web/src/features/scanner/screens/components/results-panel-complete.tsx +++ b/apps/web/src/features/scanner/screens/components/results-panel-complete.tsx @@ -112,7 +112,8 @@ export const ResultsPanelComplete = ({

{canSave ? null : (

- Enter the printed week and analyze again before saving. + Enter the printed week and choose “Use this week” before saving. + Your entries will stay here.

)} + )} + + ) : null} ); }; @@ -61,7 +85,12 @@ export const ScanScreen = (): React.ReactElement => {

1. Scan the sheet

- {scan.imagePreview ? : null} + {scan.imagePreview ? ( + + ) : null} { onDeleteEntry={controller.handleDeleteEntry} onSync={controller.handleSync} isSyncing={controller.isSyncing} - canSave={scan.weekId !== null} + canSave={scan.canSave} />
diff --git a/apps/web/src/shared/homework/actions.ts b/apps/web/src/shared/homework/actions.ts index 8cff322..80e1bab 100644 --- a/apps/web/src/shared/homework/actions.ts +++ b/apps/web/src/shared/homework/actions.ts @@ -10,6 +10,7 @@ import { } from './connection'; import type { ExtractedEntry } from './entry'; import { enqueueHomework, getPendingHomework } from './queue'; +import { type ReviewWeekResult, reconcileReviewWeek } from './review-week'; export const getConnectionStatus = async () => { await requireSession(); return databaseRuntime.runPromise( @@ -43,3 +44,21 @@ export const saveHomework = async ( ), ); }; + +export const applyReviewWeek = async ( + entries: ReadonlyArray, + weekId: string, +): Promise => { + await requireSession(); + return databaseRuntime.runPromise( + reconcileReviewWeek(entries, weekId).pipe( + Effect.map((reviewEntries) => ({ + success: true as const, + entries: reviewEntries, + })), + Effect.catchAll((error) => + Effect.succeed({ success: false as const, error: error.message }), + ), + ), + ); +}; diff --git a/apps/web/src/shared/homework/review-week.ts b/apps/web/src/shared/homework/review-week.ts new file mode 100644 index 0000000..394c183 --- /dev/null +++ b/apps/web/src/shared/homework/review-week.ts @@ -0,0 +1,48 @@ +import { Effect, Schema } from 'effect'; +import { TaskEntry, WeekId } from '@/shared/types/schemas'; +import type { ExtractedEntry } from './entry'; +import { HomeworkError } from './error'; +import { reconcileHomework } from './reconcile'; + +export type ReviewWeekResult = + | { readonly success: true; readonly entries: ReadonlyArray } + | { readonly success: false; readonly error: string }; + +const ReviewWeek = Schema.Struct({ + weekId: WeekId, + entries: Schema.Array( + TaskEntry.pipe(Schema.extend(Schema.Struct({ id: Schema.String }))), + ), +}); + +export const reconcileReviewWeek = ( + entries: ReadonlyArray, + weekId: string, +) => + Effect.gen(function* () { + const input = yield* Schema.decodeUnknown(ReviewWeek)({ entries, weekId }); + const result = yield* reconcileHomework({ + weekId: input.weekId, + confidence: 1, + entries: input.entries.map((entry) => ({ + ...entry, + action: 'add' as const, + })), + }); + // Recheck duplicate status while preserving review IDs, wording, and dates. + return input.entries.map( + (entry, index): ExtractedEntry => ({ + ...entry, + action: result.entries[index].action, + }), + ); + }).pipe( + Effect.mapError( + (cause) => + new HomeworkError({ + message: + 'Could not apply the week. Check the week and entry dates, then try again. Your review is still here.', + cause, + }), + ), + );