Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 31 additions & 10 deletions apps/web/a11y/scan.a11y.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
});
Expand Down Expand Up @@ -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();
Expand All @@ -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 }),
Expand All @@ -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 ({
Expand Down
44 changes: 44 additions & 0 deletions apps/web/integration/homework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <A, E>(program: Effect.Effect<A, E, PgClient.PgClient>) =>
Expand Down Expand Up @@ -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);
});
65 changes: 65 additions & 0 deletions apps/web/src/features/scanner/hooks/use-review-week.ts
Original file line number Diff line number Diff line change
@@ -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<SetStateAction<ScanState>>;
readonly weekId: WeekId | null;
readonly revisionRef: RefObject<number>;
};

export const useReviewWeek = ({
state,
setState,
weekId,
revisionRef,
}: ReviewWeekOptions) => {
const [isUpdatingWeek, setIsUpdatingWeek] = useState(false);
const applyWeek = (
entries: ReadonlyArray<ExtractedEntry>,
): Promise<ReviewWeekResult | null> => {
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 };
};
6 changes: 6 additions & 0 deletions apps/web/src/features/scanner/hooks/use-scan-types.ts
Original file line number Diff line number Diff line change
@@ -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' }
Expand Down Expand Up @@ -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<ExtractedEntry>,
) => Promise<ReviewWeekResult | null>;
readonly imagePreview: string | null;
readonly upload: (file: File) => Promise<boolean>;
readonly process: () => Promise<ScanState>;
Expand Down
19 changes: 18 additions & 1 deletion apps/web/src/features/scanner/hooks/use-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -14,10 +15,17 @@ export const useScan = (options: UseScanOptions): UseScanReturn => {
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [weekId, setWeek] = useState<WeekId | null>(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);
};
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ export const ResultsPanelComplete = ({
</p>
{canSave ? null : (
<p className="mb-3 text-sm">
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.
</p>
)}
<Button
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
'use client';

import { Effect } from 'effect';
import { useToast } from '@/shared/components/use-toast';
import type { ExtractedEntry } from '@/shared/homework/entry';
import { requestAction } from '@/shared/http/action';
import type { UseScanReturn } from '../../hooks/use-scan-types';

export const useApplyReviewWeek = (
scan: UseScanReturn,
entries: ReadonlyArray<ExtractedEntry>,
setEntries: (entries: Array<ExtractedEntry>) => 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')),
),
),
);
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/features/scanner/screens/hooks/use-scan-screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -17,6 +18,11 @@ export const useScanScreen = () => {
});
const [isDragging, setIsDragging] = useState(false);
const [editedEntries, setEditedEntries] = useState<Array<ExtractedEntry>>([]);
const handleApplyWeek = useApplyReviewWeek(
scan,
editedEntries,
setEditedEntries,
);
const handleClear = () => {
setEditedEntries([]);
scan.clear();
Expand Down Expand Up @@ -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([]);
Expand Down Expand Up @@ -95,6 +102,7 @@ export const useScanScreen = () => {
editedEntries,
panelState: scan.state.status,
handleFileSelect,
handleApplyWeek,
handleProcess,
handleClear,
handleScanFromDevice,
Expand Down
Loading