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.
)}
,
+ setEntries: (entries: Array) => void,
+) => {
+ const { addToast } = useToast();
+ return () => {
+ Effect.runFork(
+ requestAction(() => scan.applyWeek(entries)).pipe(
+ Effect.tap((result) =>
+ Effect.sync(() => {
+ if (!result) {
+ return;
+ }
+ if (result.success) {
+ setEntries([...result.entries]);
+ addToast(
+ 'Week applied. Your edits are preserved; check due dates against the paper before saving.',
+ 'info',
+ );
+ } else {
+ addToast(result.error, 'error');
+ }
+ }),
+ ),
+ Effect.catchAll((error) =>
+ Effect.sync(() => addToast(error.message, 'error')),
+ ),
+ ),
+ );
+ };
+};
diff --git a/apps/web/src/features/scanner/screens/hooks/use-scan-save.ts b/apps/web/src/features/scanner/screens/hooks/use-scan-save.ts
index 767fca8..f9924f9 100644
--- a/apps/web/src/features/scanner/screens/hooks/use-scan-save.ts
+++ b/apps/web/src/features/scanner/screens/hooks/use-scan-save.ts
@@ -16,7 +16,7 @@ export const useScanSave = ({ scan, entries, clear }: SaveOptions) => {
const [isSyncing, setIsSyncing] = useState(false);
const { addToast } = useToast();
const handleSync = () => {
- if (entries.length === 0 || !scan.weekId || isSyncing) {
+ if (entries.length === 0 || !scan.weekId || !scan.canSave || isSyncing) {
return;
}
const { weekId } = scan;
diff --git a/apps/web/src/features/scanner/screens/hooks/use-scan-screen.ts b/apps/web/src/features/scanner/screens/hooks/use-scan-screen.ts
index 6d010bb..9a5bc20 100644
--- a/apps/web/src/features/scanner/screens/hooks/use-scan-screen.ts
+++ b/apps/web/src/features/scanner/screens/hooks/use-scan-screen.ts
@@ -6,6 +6,7 @@ import type { ExtractedEntry } from '@/shared/homework/entry';
import { useSettings } from '@/shared/hooks/use-settings';
import { requestAction } from '@/shared/http/action';
import { useScan } from '../../hooks/use-scan';
+import { useApplyReviewWeek } from './use-apply-review-week';
import { useScanImagePaste } from './use-scan-image-paste';
import { useScanSave } from './use-scan-save';
@@ -17,6 +18,11 @@ export const useScanScreen = () => {
});
const [isDragging, setIsDragging] = useState(false);
const [editedEntries, setEditedEntries] = useState>([]);
+ const handleApplyWeek = useApplyReviewWeek(
+ scan,
+ editedEntries,
+ setEditedEntries,
+ );
const handleClear = () => {
setEditedEntries([]);
scan.clear();
@@ -47,7 +53,8 @@ export const useScanScreen = () => {
isLoading ||
scan.state.status === 'processing' ||
scan.state.status === 'uploading' ||
- saving.isSyncing;
+ saving.isSyncing ||
+ scan.isUpdatingWeek;
useScanImagePaste({ onFileSelect: handleFileSelect, isDisabled: isBusy });
const handleProcess = () => {
setEditedEntries([]);
@@ -95,6 +102,7 @@ export const useScanScreen = () => {
editedEntries,
panelState: scan.state.status,
handleFileSelect,
+ handleApplyWeek,
handleProcess,
handleClear,
handleScanFromDevice,
diff --git a/apps/web/src/features/scanner/screens/scan-screen.tsx b/apps/web/src/features/scanner/screens/scan-screen.tsx
index 41a8483..71de117 100644
--- a/apps/web/src/features/scanner/screens/scan-screen.tsx
+++ b/apps/web/src/features/scanner/screens/scan-screen.tsx
@@ -1,5 +1,6 @@
'use client';
+import { Button } from '@papersync/ui/button';
import { useState } from 'react';
import type { UseScanReturn } from '../hooks/use-scan-types';
import { NetworkScannersPanel } from './components/network-scanners-panel';
@@ -9,12 +10,18 @@ import { useScanScreen } from './hooks/use-scan-screen';
const getWeekHelp = (scan: UseScanReturn) => {
if (scan.state.status === 'complete' && !scan.weekId) {
- return 'The photo must show the year and week, or a full date. Older sheets omit the year; enter their week here and analyze again.';
+ return 'Enter the printed week and choose “Use this week.” Your recognized entries and edits will stay here.';
}
return 'Read from the full printed week or day dates. You can correct the week here.';
};
-const WeekEditor = ({ scan }: { readonly scan: UseScanReturn }) => {
+const WeekEditor = ({
+ scan,
+ onApplyWeek,
+}: {
+ readonly scan: UseScanReturn;
+ readonly onApplyWeek: () => void;
+}) => {
const [isOpen, setIsOpen] = useState(false);
return (
{
/>
{getWeekHelp(scan)}
+ {scan.state.status === 'complete' ? (
+ <>
+
+ Changing the week keeps your entries and due dates. Check dates
+ against the paper before saving.
+
+ {scan.canSave ? null : (
+
+ {scan.isUpdatingWeek ? 'Applying week…' : 'Use this week'}
+
+ )}
+ >
+ ) : 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,
+ }),
+ ),
+ );