rename sheet template, remove completed plans and stale reports

This commit is contained in:
2026-04-16 17:06:04 +07:00
parent f98ee22f84
commit e95ec82d2f
14 changed files with 0 additions and 1840 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 433 KiB

@@ -1,121 +0,0 @@
# Phase 1: Config Changes
## Priority: High | Status: Pending
## Overview
Update/remove all TypeScript-related configuration files. This phase must complete before file conversions begin.
## Key Insights
- `tsconfig.json` has `@/*` path alias that must be preserved in a new `jsconfig.json`
- `next.config.ts` uses TS syntax (`import type`, type annotation) — convert to `next.config.mjs`
- `eslint.config.mjs` extends `next/typescript` — must be removed
- `next-env.d.ts` is auto-generated by Next.js for TS — delete it
## Related Code Files
**Delete:**
- `tsconfig.json`
- `next-env.d.ts` (if exists — auto-generated, may not be tracked)
**Modify:**
- `package.json` — remove TS devDependencies
- `eslint.config.mjs` — remove `next/typescript` from extends
- `next.config.ts` — rename to `next.config.mjs`, strip TS syntax
**Create:**
- `jsconfig.json` — preserve `@/*` path alias
## Implementation Steps
### 1. Delete `tsconfig.json`
```bash
git rm tsconfig.json
```
### 2. Create `jsconfig.json`
```json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
```
### 3. Convert `next.config.ts` to `next.config.mjs`
Before:
```ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
export default nextConfig;
```
After (`next.config.mjs`):
```js
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;
```
```bash
git rm next.config.ts
# create next.config.mjs with content above
```
### 4. Update `package.json`
Remove from `devDependencies`:
- `@types/node`
- `@types/react`
- `@types/react-dom`
- `typescript`
### 5. Update `eslint.config.mjs`
Change:
```js
...compat.extends("next/core-web-vitals", "next/typescript"),
```
To:
```js
...compat.extends("next/core-web-vitals"),
```
### 6. Delete `next-env.d.ts` if it exists
```bash
rm -f next-env.d.ts
```
### 7. Run `npm install` to update lockfile
## Todo List
- [ ] Delete `tsconfig.json`
- [ ] Create `jsconfig.json` with path alias
- [ ] Convert `next.config.ts` to `next.config.mjs`
- [ ] Remove TS deps from `package.json`
- [ ] Remove `next/typescript` from eslint config
- [ ] Delete `next-env.d.ts`
- [ ] Run `npm install`
## Success Criteria
- `jsconfig.json` exists with `@/*` alias
- No `tsconfig.json` in root
- No TS-related packages in `package.json` devDependencies
- `next.config.mjs` is valid JS
## Risk Assessment
| Risk | Mitigation |
|------|------------|
| `@/*` alias breaks without jsconfig | jsconfig.json created with same paths config |
| eslint fails without typescript extend | `next/core-web-vitals` works standalone |
@@ -1,114 +0,0 @@
# Phase 2: Convert Library Files (.ts to .js)
## Priority: High | Status: Pending
## Overview
Convert all 6 files in `src/lib/` from TypeScript to JavaScript. These are pure logic files (no JSX).
## Key Insights
- All files import types from `@/types` and `@/types/opencv` — these `import type` lines must be **removed entirely** (not converted)
- Type annotations on function params, return types, variable declarations must be stripped
- No TS enums or complex generics found — straightforward removal
- Some files use `as` type assertions — remove them
## Related Code Files
**Convert (rename .ts to .js, strip types):**
- `src/lib/image-preprocessing.ts``src/lib/image-preprocessing.js`
- `src/lib/marker-detection.ts``src/lib/marker-detection.js`
- `src/lib/bubble-grid-generator.ts``src/lib/bubble-grid-generator.js`
- `src/lib/answer-detection.ts``src/lib/answer-detection.js`
- `src/lib/debug-visualization.ts``src/lib/debug-visualization.js`
- `src/lib/scoring.ts``src/lib/scoring.js`
## Implementation Steps
For each file, apply these transformations:
### 1. Remove all `import type` statements
```ts
// DELETE these lines entirely:
import type { OpenCVMat } from '@/types/opencv';
import type { Bubble, TrueFalseAnswer } from '@/types';
```
### 2. Remove type annotations from function parameters
```ts
// Before:
function processImage(src: OpenCVMat, threshold: number): OpenCVMat {
// After:
function processImage(src, threshold) {
```
### 3. Remove return type annotations
```ts
// Before:
export function detectMarkers(gray: OpenCVMat): MarkerDetectionResult {
// After:
export function detectMarkers(gray) {
```
### 4. Remove variable type annotations
```ts
// Before:
const result: MarkerDetectionResult = { ... };
// After:
const result = { ... };
```
### 5. Remove type assertions (`as Type`)
```ts
// Before:
const cv = window.cv as OpenCV;
// After:
const cv = window.cv;
```
### 6. Remove generic type parameters
```ts
// Before:
const answers: Array<TrueFalseAnswer> = [];
// After:
const answers = [];
```
### 7. Rename files
```bash
git mv src/lib/image-preprocessing.ts src/lib/image-preprocessing.js
git mv src/lib/marker-detection.ts src/lib/marker-detection.js
git mv src/lib/bubble-grid-generator.ts src/lib/bubble-grid-generator.js
git mv src/lib/answer-detection.ts src/lib/answer-detection.js
git mv src/lib/debug-visualization.ts src/lib/debug-visualization.js
git mv src/lib/scoring.ts src/lib/scoring.js
```
## Todo List
- [ ] Convert `image-preprocessing.ts`
- [ ] Convert `marker-detection.ts`
- [ ] Convert `bubble-grid-generator.ts`
- [ ] Convert `answer-detection.ts`
- [ ] Convert `debug-visualization.ts`
- [ ] Convert `scoring.ts`
## Success Criteria
- No `.ts` files remain in `src/lib/`
- No TypeScript syntax in any `.js` file
- All exports preserved (same function names, same default values)
## Risk Assessment
| Risk | Mitigation |
|------|------------|
| Accidental logic removal when stripping types | Only remove type syntax, preserve all runtime code |
| Missed type annotation causes syntax error | `npm run build` in Phase 5 catches this |
@@ -1,100 +0,0 @@
# Phase 3: Convert Components (.tsx to .jsx)
## Priority: High | Status: Pending
## Overview
Convert all 5 React component files from TypeScript JSX to plain JSX. These files contain JSX + type annotations.
## Key Insights
- Components use `import type` from `@/types` — remove entirely
- Some components define inline prop types or use TS interfaces — remove
- React event handler types (e.g., `React.ChangeEvent<HTMLInputElement>`) must be stripped
- State hooks with generic types like `useState<TestConfig>()` — remove the generic
## Related Code Files
**Convert (rename .tsx to .jsx, strip types):**
- `src/components/ConfigurationPage.tsx``src/components/ConfigurationPage.jsx`
- `src/components/ImageProcessor.tsx``src/components/ImageProcessor.jsx`
- `src/components/Navigation.tsx``src/components/Navigation.jsx`
- `src/components/ResultsPage.tsx``src/components/ResultsPage.jsx`
- `src/components/UploadPage.tsx``src/components/UploadPage.jsx`
## Implementation Steps
For each component file, apply these transformations:
### 1. Remove `import type` statements
```tsx
// DELETE:
import type { TestConfig } from '@/types';
```
### 2. Remove prop type definitions
```tsx
// Before:
interface ConfigurationPageProps {
config: TestConfig;
onSave: (config: TestConfig) => void;
}
export default function ConfigurationPage({ config, onSave }: ConfigurationPageProps) {
// After:
export default function ConfigurationPage({ config, onSave }) {
```
### 3. Remove useState generics
```tsx
// Before:
const [config, setConfig] = useState<TestConfig>(initialConfig);
// After:
const [config, setConfig] = useState(initialConfig);
```
### 4. Remove event handler type annotations
```tsx
// Before:
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// After:
const handleChange = (e) => {
```
### 5. Remove type assertions and other TS syntax
Same rules as Phase 2 — strip `as Type`, `: Type`, generic params.
### 6. Rename files
```bash
git mv src/components/ConfigurationPage.tsx src/components/ConfigurationPage.jsx
git mv src/components/ImageProcessor.tsx src/components/ImageProcessor.jsx
git mv src/components/Navigation.tsx src/components/Navigation.jsx
git mv src/components/ResultsPage.tsx src/components/ResultsPage.jsx
git mv src/components/UploadPage.tsx src/components/UploadPage.jsx
```
## Todo List
- [ ] Convert `ConfigurationPage.tsx`
- [ ] Convert `ImageProcessor.tsx`
- [ ] Convert `Navigation.tsx`
- [ ] Convert `ResultsPage.tsx`
- [ ] Convert `UploadPage.tsx`
## Success Criteria
- No `.tsx` files remain in `src/components/`
- All components render without errors
- No TypeScript syntax in any `.jsx` file
## Risk Assessment
| Risk | Mitigation |
|------|------------|
| Inline interface removal breaks destructuring | Only remove type annotations, keep destructuring patterns |
| Missed generic on useState causes runtime issue | Generics are compile-time only; removal is safe |
@@ -1,59 +0,0 @@
# Phase 4: Convert App Files (.tsx to .jsx)
## Priority: High | Status: Pending
## Overview
Convert the 2 Next.js app directory files from TypeScript to JavaScript.
## Related Code Files
**Convert:**
- `src/app/layout.tsx``src/app/layout.jsx`
- `src/app/page.tsx``src/app/page.jsx`
## Implementation Steps
### 1. Convert `layout.tsx`
Likely contains:
- `import type { Metadata } from 'next'` — remove
- Type annotation on props — remove
- `metadata` export with `Metadata` type — keep export, remove type
```tsx
// Before:
import type { Metadata } from "next";
export const metadata: Metadata = { ... };
export default function RootLayout({ children }: { children: React.ReactNode }) {
// After:
export const metadata = { ... };
export default function RootLayout({ children }) {
```
### 2. Convert `page.tsx`
Strip any type annotations, `import type` lines. Keep all JSX and logic intact.
### 3. Rename files
```bash
git mv src/app/layout.tsx src/app/layout.jsx
git mv src/app/page.tsx src/app/page.jsx
```
## Todo List
- [ ] Convert `layout.tsx`
- [ ] Convert `page.tsx`
## Success Criteria
- No `.tsx` files remain in `src/app/`
- Next.js recognizes `.jsx` layout and page files
## Risk Assessment
| Risk | Mitigation |
|------|------------|
| Next.js not picking up `.jsx` in app dir | Next.js supports .jsx natively in app router |
@@ -1,89 +0,0 @@
# Phase 5: Cleanup and Verification
## Priority: High | Status: Pending
## Depends On: Phases 1, 2, 3, 4
## Overview
Delete leftover TS artifacts, update docs, verify the app builds and runs.
## Implementation Steps
### 1. Delete `src/types/` directory
```bash
git rm -r src/types/
```
Files deleted:
- `src/types/index.ts` (shared interfaces)
- `src/types/opencv.ts` (OpenCV type declarations)
### 2. Verify no .ts/.tsx files remain
```bash
find src/ -name "*.ts" -o -name "*.tsx" | head -20
# Should return nothing
```
### 3. Verify no `import type` or TS syntax remains
```bash
grep -r "import type" src/ || echo "Clean"
grep -rn ": [A-Z][a-zA-Z]*[^=]" src/ --include="*.js" --include="*.jsx" | head -20
# Manual review of any hits — some may be legitimate JS (object properties)
```
### 4. Run build
```bash
npm run build
```
Fix any errors. Common issues:
- Missed type annotation → syntax error pointing to exact line
- Broken import path → module not found error
### 5. Run dev server smoke test
```bash
npm run dev
# Open http://localhost:3000 and verify page loads
```
### 6. Run lint
```bash
npm run lint
```
### 7. Update README.md
- Change "Next.js 15 with TypeScript" to "Next.js 15"
- Remove TypeScript interface examples from Data Structure section, or convert to JSDoc comments
- Update Project Structure to show `.jsx`/`.js` extensions
## Todo List
- [ ] Delete `src/types/` directory
- [ ] Verify no TS files remain
- [ ] Verify no TS syntax remains in JS files
- [ ] `npm run build` passes
- [ ] `npm run lint` passes
- [ ] `npm run dev` starts and page loads
- [ ] Update README.md
## Success Criteria
- Zero `.ts`/`.tsx` files in project (except node_modules)
- `npm run build` exits 0
- `npm run dev` serves the app
- `npm run lint` passes
- README reflects JS stack
## Risk Assessment
| Risk | Mitigation |
|------|------------|
| Build fails from missed conversion | Error messages point to exact file:line, fix iteratively |
| Lint rules incompatible | Already removed `next/typescript` in Phase 1 |
@@ -1,71 +0,0 @@
---
title: "Convert TypeScript to JavaScript"
description: "Remove TypeScript from Next.js project, convert all .ts/.tsx files to .js/.jsx"
status: pending
priority: P2
effort: 2h
branch: main
tags: [migration, typescript, javascript]
created: 2026-04-12
---
# Convert TypeScript to JavaScript
## Summary
Mechanical conversion: strip all TS annotations, rename files, update configs. No logic changes.
## Data Flow
```
.ts/.tsx files --> remove type annotations --> rename to .js/.jsx
tsconfig.json --> DELETE
next.config.ts --> convert to next.config.mjs (plain JS)
package.json --> remove @types/* and typescript deps
eslint.config.mjs --> remove "next/typescript" extend
src/types/ --> DELETE entirely
```
## Phases
| # | Phase | Files Touched | Status |
|---|-------|--------------|--------|
| 1 | [Config changes](phase-01-config-changes.md) | package.json, tsconfig.json, next.config.ts, eslint.config.mjs, next-env.d.ts | Pending |
| 2 | [Convert lib files](phase-02-convert-lib-files.md) | src/lib/*.ts (6 files) | Pending |
| 3 | [Convert components](phase-03-convert-components.md) | src/components/*.tsx (5 files) | Pending |
| 4 | [Convert app files](phase-04-convert-app-files.md) | src/app/layout.tsx, src/app/page.tsx | Pending |
| 5 | [Cleanup and verify](phase-05-cleanup-and-verify.md) | src/types/ (delete), jsconfig.json (create), README.md | Pending |
## Dependency Graph
Phase 1 (config) has no blockers.
Phases 2, 3, 4 can run in parallel after Phase 1 (no shared files).
Phase 5 depends on all of 1-4 completing.
```
Phase 1 --> Phase 2 --|
\-> Phase 3 --|--> Phase 5
\-> Phase 4 --|
```
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Broken imports after rename | Low | High | Phase 5 runs `npm run build` to catch |
| `next.config.ts` rename breaks Next.js | Low | High | Next.js supports `.mjs` natively |
| Missing type-as-value usage (enums) | Very Low | Medium | No TS enums in codebase (verified) |
| `opencv-ts` package requires TS | Low | Low | It's a runtime lib, works without TS |
## Rollback Plan
Git commit before starting. Single `git checkout .` reverts everything.
## Success Criteria
- [x] `npm run build` passes with zero errors
- [x] `npm run dev` starts successfully
- [x] No `.ts` or `.tsx` files remain in `src/`
- [x] No `typescript` or `@types/*` in package.json
- [x] No `tsconfig.json` in project root
- [x] `jsconfig.json` preserves `@/*` path alias
@@ -1,190 +0,0 @@
# Phase 1: Critical Bug Fixes (P0)
## Context
- [Review report](../reports/ui-ux-review-260413-0856-answer-sheet-analysis.md)
- [Plan overview](./plan.md)
## Overview
- **Priority**: P0
- **Status**: Pending
- **Effort**: 2h
- **Blocks**: Phase 3 (state lifting replaces localStorage pattern established here)
## Key Issues
1. **localStorage race condition** - `setTimeout(100)` in UploadPage.jsx:99-104 to save results. State may not have settled; results silently lost.
2. **`useState` misused as `useEffect`** - ImageThumbnail (UploadPage.jsx:271-275) uses `useState(() => {...})` to create object URLs. Never updates on prop change, never revokes old URLs = memory leak.
3. **localStorage overflow** - Debug image data URLs (~1-2MB each) stored in `studentResults`. 50+ students = blown 5MB localStorage limit.
## Architecture
### Data Flow (After Fix)
```
processImages loop
-> handleProcessingComplete collects result in resultsRef (not state)
-> loop ends
-> finally block: write resultsRef.current to localStorage (scores only)
-> finally block: write debug images to IndexedDB via indexed-db-store.js
-> update React state from ref
ImageThumbnail
-> useEffect(file): create objectURL, return cleanup that revokes it
-> on file prop change: old URL revoked, new URL created
```
## Related Code Files
### Files to Modify
| File | Changes |
|------|---------|
| `src/components/UploadPage.jsx` | Fix race condition with ref-based result collection; fix ImageThumbnail useEffect |
| `src/components/ImageProcessor.jsx` | Separate debugImageUrl from result object before returning |
### Files to Create
| File | Purpose |
|------|---------|
| `src/lib/indexed-db-store.js` | IndexedDB wrapper for debug images (put/get/delete/clear) |
## Implementation Steps
### 1. Create `src/lib/indexed-db-store.js` (~60 lines)
Simple IndexedDB wrapper. Single object store `debugImages` keyed by student result ID.
```js
// API:
// openDB() -> Promise<IDBDatabase>
// saveDebugImage(id: string, dataUrl: string) -> Promise<void>
// getDebugImage(id: string) -> Promise<string|null>
// deleteDebugImage(id: string) -> Promise<void>
// clearDebugImages() -> Promise<void>
```
- DB name: `chambai`
- Store name: `debugImages`
- Key path: `id`
- Version: 1
### 2. Fix localStorage race condition in UploadPage.jsx
**Current code (lines 86-114):**
```js
// Uses setTimeout(100) + setState callback to read final results
setTimeout(() => {
setProcessedResults((current) => {
localStorage.setItem('studentResults', JSON.stringify([...finalResults, ...current]));
return current;
});
}, 100);
```
**Fix:** Use a ref to accumulate results during processing loop.
Changes to `UploadPage.jsx`:
- Add `const resultsRef = useRef([])` at component top
- In `handleProcessingComplete`: push to `resultsRef.current` (in addition to `setProcessedResults`)
- In `processImages`, after the for-loop completes:
- Strip `debugImageUrl` from each result before localStorage save
- Save debug images to IndexedDB via `saveDebugImage(result.id, result.debugImageUrl)`
- Merge with existing results and write to localStorage synchronously (no setTimeout)
- Reset `resultsRef.current = []`
```js
// In finally block or after loop:
const existing = JSON.parse(localStorage.getItem('studentResults') || '[]');
const toSave = resultsRef.current.map(r => {
const { debugImageUrl, ...rest } = r;
return rest;
});
localStorage.setItem('studentResults', JSON.stringify([...existing, ...toSave]));
// Save debug images to IndexedDB
for (const r of resultsRef.current) {
if (r.debugImageUrl) {
await saveDebugImage(r.id, r.debugImageUrl);
}
}
resultsRef.current = [];
```
### 3. Fix ImageThumbnail memory leak (UploadPage.jsx:267-294)
**Current (broken):**
```js
function ImageThumbnail({ file, index, onRemove }) {
const [src, setSrc] = useState('');
useState(() => { // <-- misused! This is NOT useEffect
const url = URL.createObjectURL(file);
setSrc(url);
return () => URL.revokeObjectURL(url); // return value ignored
});
```
**Fix:**
```js
function ImageThumbnail({ file, index, onRemove }) {
const [src, setSrc] = useState('');
useEffect(() => {
const url = URL.createObjectURL(file);
setSrc(url);
return () => URL.revokeObjectURL(url);
}, [file]);
```
Add `useEffect` to the import list at top of file.
### 4. Update ResultsPage.jsx to load debug images from IndexedDB
When opening StudentDetailModal, load debug image from IndexedDB:
```js
// In StudentDetailModal, add:
const [debugImageUrl, setDebugImageUrl] = useState(null);
useEffect(() => {
getDebugImage(student.id).then(url => setDebugImageUrl(url));
}, [student.id]);
```
### 5. Update ImageProcessor.jsx - no debugImageUrl in result
In `runDetectionPipeline`, return debugImageUrl separately (already done), but ensure the `onProcessingComplete` callback receives it as a separate field that UploadPage can handle independently.
No change needed here - current code already passes `debugImageUrl` in the result object. The separation happens in UploadPage step 2.
## Todo List
- [ ] Create `src/lib/indexed-db-store.js` with put/get/delete/clear API
- [ ] Fix UploadPage.jsx: add resultsRef, remove setTimeout hack, save to IndexedDB
- [ ] Fix UploadPage.jsx: change ImageThumbnail `useState` -> `useEffect` with cleanup
- [ ] Add `useEffect` to UploadPage import statement
- [ ] Update ResultsPage.jsx StudentDetailModal to load debug images from IndexedDB
- [ ] Update ResultsPage clearResults to also call `clearDebugImages()`
- [ ] Test: process 5+ images, verify all results saved, verify debug images load in detail modal
- [ ] Test: verify no object URL memory leaks (check browser devtools)
## Success Criteria
1. Process 50+ images without localStorage overflow error
2. All results persisted after processing completes (no silent data loss)
3. Debug images viewable in StudentDetailModal (loaded from IndexedDB)
4. No object URL memory leaks in ImageThumbnail on re-render
5. `localStorage.getItem('studentResults')` contains no data URL strings
## Risk Assessment
| Risk | Mitigation |
|------|------------|
| IndexedDB not available in old browsers | Feature-detect; fallback to not saving debug images (scores still saved to localStorage) |
| Existing localStorage data has embedded debug URLs | Migration: on first load, if results contain `debugImageUrl`, extract to IndexedDB and re-save without them |
## Security Considerations
- No auth concerns (client-only app)
- IndexedDB is same-origin scoped, no cross-site risk
- Debug images are transient; no PII beyond student ID numbers visible on sheet
@@ -1,290 +0,0 @@
# Phase 2: Detection Pipeline Fixes (P1)
## Context
- [Review report](../reports/ui-ux-review-260413-0856-answer-sheet-analysis.md)
- [Plan overview](./plan.md)
- Reference: CV1239/BGDDT 2025 Vietnamese THPT answer sheet format
## Overview
- **Priority**: P1
- **Status**: Pending
- **Effort**: 4h
- **Dependencies**: None (independent of Phase 1)
- **Blocks**: Nothing directly
## Key Issues
1. **SHEET_LAYOUT coordinates are wrong** - Student ID, Exam Code, and all three Phan sections have incorrect x/y values
2. **No perspective correction** - Corner markers are detected but only used for bounding box, not deskewing
3. **Phan III model is wrong** - Treats each answer as single digit; actual sheet supports multi-character (sign + digits + decimal comma)
## Architecture
### Detection Pipeline (After Fix)
```
Image -> Preprocess (grayscale + blur + threshold + morph)
-> Detect 4 corner markers
-> IF 4 markers found AND skew > 2deg:
Apply getPerspectiveTransform + warpPerspective -> corrected image
ELSE:
Use bounding box as-is (current behavior)
-> Generate bubble grid with CORRECTED coordinates
-> Phan III: multi-column per question (sign + digits + comma)
-> Measure fill + detect answers
-> Return results
```
### Phan III Data Model Change
**Before:** Each question = 1 digit column (0-9)
**After:** Each question = N character columns, where each column has rows for: `-`, `,`, `0-9`
```
Question layout (per question):
Col 0: sign column -> rows: ["-", "blank"] (or just "-" bubble + empty)
Col 1: digit column 1 -> rows: [0,1,2,3,4,5,6,7,8,9]
Col 2: digit column 2 -> rows: [0,1,2,3,4,5,6,7,8,9]
Col 3: comma column -> rows: [",", "blank"]
Col 4: digit column 3 -> rows: [0,1,2,3,4,5,6,7,8,9]
```
Actual CV1239 sheet has approximately 5 character positions per question. Each character position has 12 rows: `-`, `,`, `0-9`. The detection reads filled bubbles left-to-right to form the answer string.
## Related Code Files
### Files to Modify
| File | Changes |
|------|---------|
| `src/lib/bubble-grid-generator.js` | Fix SHEET_LAYOUT coordinates; rewrite `generatePhanIIIBubbles` for multi-char model |
| `src/lib/marker-detection.js` | Add `applyPerspectiveCorrection()` export |
| `src/lib/answer-detection.js` | Rewrite `detectPhanIIIAnswers` for multi-char reading |
| `src/lib/types.js` | Add `charPosition` and `charValue` fields to Bubble typedef |
| `src/components/ImageProcessor.jsx` | Insert perspective correction step in pipeline |
### Files NOT Modified
| File | Reason |
|------|--------|
| `src/lib/image-preprocessing.js` | No changes needed |
| `src/lib/scoring.js` | Already compares strings; multi-char answers work as-is |
| `src/lib/debug-visualization.js` | Phan III debug drawing needs update but deferred to Phase 4 (Vietnamese legend) |
## Implementation Steps
### 1. Fix SHEET_LAYOUT in `bubble-grid-generator.js`
**Current (wrong) values:**
```js
studentId: { x: 0.65, y: 0.02, w: 0.25, h: 0.18, cols: 8, rows: 10 },
examCode: { x: 0.45, y: 0.02, w: 0.15, h: 0.18, cols: 4, rows: 10 },
phanI: { x: 0.03, y: 0.25, w: 0.94, h: 0.30, ... },
phanII: { x: 0.03, y: 0.58, w: 0.94, h: 0.15, ... },
phanIII: { x: 0.03, y: 0.76, w: 0.94, h: 0.22, ... },
```
**Corrected values (from PDF analysis):**
```js
studentId: { x: 0.58, y: 0.05, w: 0.24, h: 0.20, cols: 8, rows: 10 },
examCode: { x: 0.82, y: 0.05, w: 0.13, h: 0.20, cols: 4, rows: 10 },
phanI: { x: 0.03, y: 0.28, w: 0.94, h: 0.27, ... },
phanII: { x: 0.03, y: 0.55, w: 0.94, h: 0.17, ... },
phanIII: { x: 0.03, y: 0.72, w: 0.94, h: 0.26, ... },
```
Key fix: Exam Code moves from x:0.45 (left-center, WRONG) to x:0.82 (right of SBD, CORRECT).
### 2. Rewrite Phan III bubble generation
Replace the current single-digit model in `generatePhanIIIBubbles`:
```js
// New Phan III layout config
phanIII: {
x: 0.03, y: 0.72, w: 0.94, h: 0.26,
questions: 6,
charsPerQuestion: 5, // max character positions per answer
// Row labels per character column: ['-', ',', '0', '1', ..., '9']
charRows: 12,
}
```
Each question occupies `w / 6` width. Within each question column:
- 5 sub-columns for character positions
- 12 rows: index 0 = `-` (sign), index 1 = `,` (decimal), indices 2-11 = digits 0-9
New bubble fields:
```js
{
section: 'section3',
question: 1, // 1-6
charPosition: 0, // 0-4 (which character slot)
charValue: '-', // '-', ',', '0'-'9'
// row kept for backward compat with debug visualization
}
```
### 3. Rewrite `detectPhanIIIAnswers` in `answer-detection.js`
```js
export function detectPhanIIIAnswers(bubbles, gray, questionCount = 6) {
const sectionBubbles = bubbles.filter(b => b.section === 'section3');
const answers = [];
for (let q = 1; q <= questionCount; q++) {
const qBubbles = sectionBubbles.filter(b => b.question === q);
// Group by charPosition, find best-filled in each position
const charPositions = groupByField(qBubbles, 'charPosition');
let answer = '';
for (let pos = 0; pos < 5; pos++) {
const posBubbles = charPositions[pos] || [];
const best = findBestFilled(posBubbles, gray);
if (best?.charValue) {
answer += best.charValue;
}
}
// Trim trailing empty positions, normalize
answers.push(answer.replace(/\s+$/, ''));
}
return answers;
}
```
### 4. Add perspective correction to `marker-detection.js`
Add new export function after existing `detectCornerMarkers`:
```js
/**
* Apply perspective correction using 4 detected corner markers.
* Returns corrected image and updated bounding box, or original if skew is minimal.
*/
export function applyPerspectiveCorrection(src, markers, imageWidth, imageHeight) {
const cv = window.cv;
// Only correct if we have exactly 4 corners
if (!markers.corners || markers.corners.length !== 4) {
return { corrected: src, markers, applied: false };
}
const [tl, tr, br, bl] = markers.corners;
// Calculate skew angle; skip if < 2 degrees
const topAngle = Math.atan2(tr.y - tl.y, tr.x - tl.x) * 180 / Math.PI;
if (Math.abs(topAngle) < 2) {
return { corrected: src, markers, applied: false };
}
// Destination rectangle (axis-aligned)
const dstWidth = Math.max(
Math.hypot(tr.x - tl.x, tr.y - tl.y),
Math.hypot(br.x - bl.x, br.y - bl.y)
);
const dstHeight = Math.max(
Math.hypot(bl.x - tl.x, bl.y - tl.y),
Math.hypot(br.x - tr.x, br.y - tr.y)
);
const srcPts = cv.matFromArray(4, 1, cv.CV_32FC2, [
tl.x, tl.y, tr.x, tr.y, br.x, br.y, bl.x, bl.y
]);
const dstPts = cv.matFromArray(4, 1, cv.CV_32FC2, [
0, 0, dstWidth, 0, dstWidth, dstHeight, 0, dstHeight
]);
const M = cv.getPerspectiveTransform(srcPts, dstPts);
const corrected = new cv.Mat();
cv.warpPerspective(src, corrected, M, new cv.Size(dstWidth, dstHeight));
srcPts.delete();
dstPts.delete();
M.delete();
// New bounding box covers entire corrected image
const newMarkers = {
corners: markers.corners,
edges: markers.edges,
boundingBox: {
left: 0, top: 0,
right: dstWidth, bottom: dstHeight,
width: dstWidth, height: dstHeight,
},
};
return { corrected, markers: newMarkers, applied: true };
}
```
### 5. Integrate perspective correction in `ImageProcessor.jsx`
In `runDetectionPipeline`, after marker detection, before bubble grid generation:
```js
const markers = detectCornerMarkers(thresh, imageData.width, imageData.height);
// NEW: apply perspective correction
const { corrected, markers: correctedMarkers, applied } =
applyPerspectiveCorrection(gray, markers, imageData.width, imageData.height);
const activeGray = applied ? corrected : gray;
const activeMarkers = correctedMarkers;
const activeWidth = applied ? corrected.cols : imageData.width;
const activeHeight = applied ? corrected.rows : imageData.height;
const bubbles = generateBubbleGrid(activeMarkers, activeWidth, activeHeight);
const studentId = detectStudentId(bubbles, activeGray);
// ... rest uses activeGray instead of gray
// Clean up
if (applied) corrected.delete();
```
### 6. Update `types.js` Bubble typedef
Add optional fields:
```js
* @property {number} [charPosition] - Character position index (for section3 multi-char)
* @property {string} [charValue] - Character value: '-', ',', '0'-'9' (for section3)
```
## Todo List
- [ ] Fix SHEET_LAYOUT coordinates (studentId, examCode, phanI, phanII, phanIII)
- [ ] Update phanIII layout config for multi-character model (charsPerQuestion, charRows)
- [ ] Rewrite `generatePhanIIIBubbles` for multi-character columns
- [ ] Update Bubble typedef in types.js with charPosition/charValue fields
- [ ] Rewrite `detectPhanIIIAnswers` to read multi-char answers
- [ ] Add `applyPerspectiveCorrection` to marker-detection.js
- [ ] Integrate perspective correction in ImageProcessor.jsx pipeline
- [ ] Add helper `groupByField` to answer-detection.js (or reuse groupByColumn pattern)
- [ ] Test with straight scan: verify no perspective correction applied (angle < 2deg)
- [ ] Test with skewed photo: verify perspective correction improves detection
- [ ] Test Phan III: verify "-1,5" type answers detected correctly
## Success Criteria
1. Student ID detected at correct position (x:0.58, not x:0.65)
2. Exam Code detected at RIGHT side of sheet (x:0.82, not x:0.45)
3. Phan III returns multi-character strings (e.g., "-1,5") not single digits
4. Perspective correction activates only on skewed images (>2 degrees)
5. Straight scans produce same or better results as before
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Corrected coordinates still off | Medium | High | Coordinates are in one const; easy to fine-tune with debug visualization overlay |
| Phan III char positions vary between print batches | Low | Medium | Keep charsPerQuestion configurable in SHEET_LAYOUT |
| Perspective correction introduces artifacts | Low | Medium | Only apply when skew > 2deg; use INTER_LINEAR interpolation |
| OpenCV.js getPerspectiveTransform not available | Very Low | High | Feature-detect; skip correction if function missing |
## Security Considerations
- No new attack surface; all processing is client-side
- No external network calls added
@@ -1,241 +0,0 @@
# Phase 3: UX Improvements (P1)
## Context
- [Review report](../reports/ui-ux-review-260413-0856-answer-sheet-analysis.md)
- [Plan overview](./plan.md)
## Overview
- **Priority**: P1
- **Status**: Pending
- **Effort**: 3h
- **Dependencies**: Phase 1 (IndexedDB store established; localStorage pattern understood)
- **Blocks**: Phase 4 (workflow features use lifted state)
## Key Issues
1. **No shared state** - Each page reads/writes localStorage independently, causing sync bugs
2. **No keyboard navigation** for Phan I - 40 answers require 40+ mouse clicks
3. **Upload tab accessible without config** - Teachers can process images with no answer key
4. **No step-completion indicators** - Teachers don't know which steps are done
5. **Save/Reset buttons at top** of ConfigurationPage - Must scroll up after filling answers
6. **Status messages auto-dismiss in 3s** - Too fast for target users
## Architecture
### State Lifting Design
Currently each page reads from localStorage on mount. After lifting:
```
page.jsx (Home)
state: { config, results, configSaved }
|
├── Navigation (receives: configSaved, hasResults)
├── ConfigurationPage (receives: config, onConfigChange, onSave, onReset)
├── UploadPage (receives: config, onResultsAdd)
└── ResultsPage (receives: results, config, onResultsUpdate, onResultsClear)
```
**Data flow:**
- `config` flows down from Home -> all pages
- `onConfigChange` bubbles up from ConfigPage -> Home (updates state + localStorage)
- `onResultsAdd` bubbles up from UploadPage -> Home (merges results + saves)
- `onResultsUpdate` bubbles up from ResultsPage -> Home (for manual corrections in Phase 4)
- `configSaved` boolean: true when config has been saved at least once
**Migration:** On mount, Home reads both `testConfig` and `studentResults` from localStorage. Pages no longer read localStorage directly.
## Related Code Files
### Files to Modify
| File | Changes |
|------|---------|
| `src/app/page.jsx` | Lift config + results state; pass as props; load from localStorage on mount |
| `src/components/Navigation.jsx` | Accept `configSaved` + `hasResults` props; show completion indicators; disable Upload when no config |
| `src/components/ConfigurationPage.jsx` | Accept config/callbacks as props; move Save/Reset to sticky bottom; add keyboard nav for Phan I; increase status message timeout |
| `src/components/UploadPage.jsx` | Accept config prop instead of reading localStorage; accept `onResultsAdd` callback |
| `src/components/ResultsPage.jsx` | Accept results + config as props instead of reading localStorage |
## Implementation Steps
### 1. Lift state to `page.jsx`
```jsx
export default function Home() {
const [currentPage, setCurrentPage] = useState('config');
const [config, setConfig] = useState(null);
const [results, setResults] = useState([]);
const [configSaved, setConfigSaved] = useState(false);
// Load from localStorage on mount
useEffect(() => {
const savedConfig = localStorage.getItem('testConfig');
const savedResults = localStorage.getItem('studentResults');
if (savedConfig) {
setConfig(JSON.parse(savedConfig));
setConfigSaved(true);
}
if (savedResults) {
setResults(JSON.parse(savedResults));
}
}, []);
const handleConfigSave = (newConfig) => {
setConfig(newConfig);
setConfigSaved(true);
localStorage.setItem('testConfig', JSON.stringify(newConfig));
};
const handleResultsAdd = (newResults) => {
setResults(prev => {
const merged = [...prev, ...newResults];
localStorage.setItem('studentResults', JSON.stringify(merged));
return merged;
});
};
const handleResultsClear = () => {
setResults([]);
localStorage.removeItem('studentResults');
};
const handleResetAll = () => {
setConfig(null);
setResults([]);
setConfigSaved(false);
localStorage.clear();
};
// ... pass these to child components
}
```
### 2. Update Navigation.jsx - completion indicators + guard
```jsx
export default function Navigation({ currentPage, onPageChange, configSaved, hasResults }) {
const buttons = [
{ key: 'config', label: '1. Cau hinh de thi', done: configSaved },
{ key: 'upload', label: '2. Tai va xu ly anh', done: hasResults, disabled: !configSaved },
{ key: 'results', label: '3. Ket qua', done: false, disabled: !hasResults },
];
```
- Show green checkmark icon when `done` is true
- When `disabled`, use `cursor-not-allowed` + `opacity-50` + show tooltip: "Vui long luu cau hinh truoc"
- Prevent `onPageChange` call when disabled
### 3. Update ConfigurationPage.jsx
**a) Accept props instead of localStorage:**
```jsx
export default function ConfigurationPage({ config: initialConfig, onSave, onReset }) {
const [config, setConfig] = useState(initialConfig || DEFAULT_CONFIG);
// Remove useEffect that reads localStorage
// saveConfig calls onSave(config) instead of localStorage.setItem
}
```
**b) Move Save/Reset to sticky bottom bar:**
```jsx
// Remove buttons from top of page
// Add at bottom:
<div className="sticky bottom-0 bg-white border-t border-gray-200 p-4 -mx-6 -mb-6 flex gap-3">
<button onClick={() => onSave(config)} ...>Luu cau hinh</button>
<button onClick={onReset} ...>Xoa tat ca du lieu</button>
</div>
```
**c) Add keyboard navigation for Phan I:**
Wrap Phan I grid items in a component that:
- On key press `A/B/C/D` (case insensitive): set answer for current question, auto-advance focus to next question
- On `Backspace`: clear current answer, move focus to previous question
- On `ArrowDown/ArrowRight`: advance to next question
- On `ArrowUp/ArrowLeft`: go to previous question
- Each question row gets `tabIndex={0}` and a ref for focus management
```jsx
const questionRefs = useRef([]);
const handlePhanIKeyDown = (index, e) => {
const key = e.key.toUpperCase();
if (['A', 'B', 'C', 'D'].includes(key)) {
updatePhanIAnswer(index, key);
// Auto-advance
if (index < config.phanI.questionCount - 1) {
questionRefs.current[index + 1]?.focus();
}
} else if (e.key === 'Backspace') {
updatePhanIAnswer(index, '');
if (index > 0) questionRefs.current[index - 1]?.focus();
}
// Arrow keys for navigation
};
```
**d) Increase status message timeout from 3s to 5s.**
### 4. Update UploadPage.jsx
- Accept `config` prop (used to check config exists before processing)
- Accept `onResultsAdd` callback
- Remove `localStorage.getItem('testConfig')` call
- In `processImages`: call `onResultsAdd(resultsRef.current)` instead of writing localStorage directly
- The parent (Home) handles localStorage persistence
### 5. Update ResultsPage.jsx
- Accept `results` and `config` props
- Remove `useEffect` that reads localStorage
- Accept `onResultsClear` callback for the clear button
- `clearResults` calls `onResultsClear()` instead of `localStorage.removeItem`
## Todo List
- [ ] Lift config + results state to page.jsx
- [ ] Pass config/results/callbacks as props to all child pages
- [ ] Remove all direct localStorage reads from ConfigurationPage, UploadPage, ResultsPage
- [ ] Add completion indicators (checkmarks) to Navigation buttons
- [ ] Disable Upload tab when config not saved (with tooltip)
- [ ] Move Save/Reset buttons to sticky bottom bar in ConfigurationPage
- [ ] Add keyboard navigation for Phan I (A/B/C/D keys + arrow keys + backspace)
- [ ] Increase status message timeout from 3s to 5s
- [ ] Verify: changing config in ConfigPage immediately reflects in UploadPage processing
- [ ] Verify: results added in UploadPage immediately visible in ResultsPage tab
## Success Criteria
1. No direct localStorage reads in child components (all state flows from parent)
2. Type A/B/C/D on keyboard to set Phan I answer and auto-advance to next question
3. Upload tab visually disabled (grayed out + tooltip) when config not saved
4. Green checkmark appears on Config tab after saving, on Upload tab after processing
5. Save/Reset buttons visible at bottom of ConfigurationPage without scrolling
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| State lifting breaks page isolation | Medium | Medium | Props match current data shapes; pages still manage local UI state |
| Keyboard nav conflicts with browser shortcuts | Low | Low | Only capture A/B/C/D/arrows when Phan I grid is focused |
| ConfigurationPage exceeds 200 line limit after changes | Medium | Low | Extract PhanI keyboard grid into `src/components/phan-i-answer-grid.jsx` |
## File Size Management
Current line counts:
- `page.jsx`: 48 lines -> ~80 after lifting (OK)
- `Navigation.jsx`: 32 lines -> ~60 after indicators (OK)
- `ConfigurationPage.jsx`: 279 lines -> will EXCEED 200. Extract:
- `src/components/phan-i-answer-grid.jsx` (~80 lines) - keyboard-navigable Phan I grid
- `src/components/scoring-config-panel.jsx` (~40 lines) - scoring inputs
- `UploadPage.jsx`: 294 lines -> already over 200. Extract:
- `src/components/image-thumbnail.jsx` (~30 lines) - thumbnail component (also fixes the useState bug from Phase 1)
- `ResultsPage.jsx`: 345 lines -> extract:
- `src/components/student-detail-modal.jsx` (~100 lines) - modal component
## Security Considerations
- No new attack surface
- Config validation: ensure questionCount is within sane bounds before saving (already present with min/max on inputs)
@@ -1,217 +0,0 @@
# Phase 4: Workflow Features (P2)
## Context
- [Review report](../reports/ui-ux-review-260413-0856-answer-sheet-analysis.md)
- [Plan overview](./plan.md)
## Overview
- **Priority**: P2
- **Status**: Pending
- **Effort**: 3h
- **Dependencies**: Phase 3 (lifted state, extracted components)
## Key Features
1. **Manual answer correction** in StudentDetailModal - teachers fix OCR errors
2. **Paste-from-spreadsheet** for Phan I answers - accept tab/comma-separated string
3. **Class summary statistics** - mean, median, score distribution
4. **Vietnamese debug legend** - translate English labels to Vietnamese
## Related Code Files
### Files to Modify
| File | Changes |
|------|---------|
| `src/components/student-detail-modal.jsx` | Add edit mode for manual answer correction |
| `src/components/ConfigurationPage.jsx` | Add paste handler for Phan I bulk input |
| `src/components/ResultsPage.jsx` | Add statistics summary panel |
| `src/lib/debug-visualization.js` | Translate legend labels to Vietnamese |
### Files to Create
| File | Purpose |
|------|---------|
| `src/lib/statistics.js` | Calculate class statistics (mean, median, distribution buckets) |
## Implementation Steps
### 1. Manual answer correction in StudentDetailModal
Add an "edit mode" toggle to the extracted `student-detail-modal.jsx`:
```jsx
const [editing, setEditing] = useState(false);
const [editedResult, setEditedResult] = useState(null);
const startEdit = () => {
setEditing(true);
setEditedResult({ ...student });
};
const saveEdit = () => {
onResultUpdate(editedResult); // callback from parent via ResultsPage
setEditing(false);
};
```
In edit mode:
- **Phan I**: Each answer cell becomes clickable A/B/C/D buttons (reuse phan-i-answer-grid pattern)
- **Phan II**: Dung/Sai buttons become toggleable
- **Phan III**: Text input fields for each answer
- Show "Sua" (Edit) button in modal header; when editing, show "Luu" (Save) and "Huy" (Cancel)
**Data flow:**
```
StudentDetailModal -> onResultUpdate(editedStudent)
-> ResultsPage -> onResultsUpdate(updatedResults)
-> Home -> setResults + localStorage.setItem
```
### 2. Paste-from-spreadsheet for Phan I
Add a paste zone above the Phan I answer grid in ConfigurationPage:
```jsx
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Dan dap an tu Excel (cach boi dau phay hoac tab):
</label>
<input
type="text"
placeholder="VD: A,B,C,D,A,B,C,D,..."
onPaste={handlePasteAnswers}
onChange={handlePasteInput}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
/>
</div>
```
Parse logic:
```js
const handlePasteAnswers = (e) => {
const text = (e.clipboardData?.getData('text') || '').trim();
// Split by comma, tab, newline, or space
const answers = text.split(/[,\t\n\s]+/)
.map(s => s.trim().toUpperCase())
.filter(s => ['A', 'B', 'C', 'D'].includes(s));
if (answers.length > 0) {
const newConfig = { ...config };
newConfig.phanI.answers = answers.slice(0, config.phanI.questionCount);
setConfig(newConfig);
showStatus('success', `Da dan ${answers.length} dap an Phan I`);
e.preventDefault();
}
};
```
### 3. Class summary statistics
Create `src/lib/statistics.js` (~50 lines):
```js
/**
* Calculate class summary statistics from scored results.
* @param {Array<{score: {total: number, percentage: number}}>} results
* @returns {{ mean: number, median: number, min: number, max: number, distribution: Record<string, number> }}
*/
export function calculateClassStatistics(results) {
if (results.length === 0) return null;
const scores = results.map(r => r.score?.total ?? 0).sort((a, b) => a - b);
const percentages = results.map(r => r.score?.percentage ?? 0);
const sum = scores.reduce((a, b) => a + b, 0);
const mean = sum / scores.length;
const median = scores.length % 2 === 0
? (scores[scores.length / 2 - 1] + scores[scores.length / 2]) / 2
: scores[Math.floor(scores.length / 2)];
// Distribution buckets by percentage: 0-20, 20-40, 40-60, 60-80, 80-100
const distribution = { '0-20': 0, '20-40': 0, '40-60': 0, '60-80': 0, '80-100': 0 };
for (const pct of percentages) {
if (pct < 20) distribution['0-20']++;
else if (pct < 40) distribution['20-40']++;
else if (pct < 60) distribution['40-60']++;
else if (pct < 80) distribution['60-80']++;
else distribution['80-100']++;
}
return {
mean: Math.round(mean * 100) / 100,
median: Math.round(median * 100) / 100,
min: scores[0],
max: scores[scores.length - 1],
count: scores.length,
distribution,
};
}
```
Add statistics panel to ResultsPage above the table:
```jsx
{results.length > 0 && (
<StatisticsSummary results={sortedResults} />
)}
```
Display as a grid of cards:
- Si so: N
- Diem TB: mean
- Trung vi: median
- Cao nhat / Thap nhat
- Simple bar chart of distribution (CSS-only, no chart library)
### 4. Vietnamese debug legend
Update `debug-visualization.js` `drawLegend` function:
```js
const entries = [
{ color: COLORS.allPositions, label: 'Tat ca vi tri' },
{ color: COLORS.studentId, label: 'So bao danh' },
{ color: COLORS.examCode, label: 'Ma de thi' },
{ color: COLORS.correct, label: 'Dung' },
{ color: COLORS.wrong, label: 'Sai' },
];
```
Also update the legend title from 'Debug Legend' to 'Chu thich'.
## Todo List
- [ ] Add edit mode to student-detail-modal.jsx (toggle editing, save/cancel)
- [ ] Wire onResultUpdate callback through ResultsPage -> Home
- [ ] Add paste input zone in ConfigurationPage for Phan I bulk answers
- [ ] Implement paste parsing (comma/tab/newline/space delimited, validate A/B/C/D)
- [ ] Create `src/lib/statistics.js` with calculateClassStatistics
- [ ] Add StatisticsSummary component to ResultsPage (inline, not separate file unless >50 lines)
- [ ] Translate debug-visualization.js legend to Vietnamese
- [ ] Test: paste "A,B,C,D,A,B,C,D" fills first 8 answers correctly
- [ ] Test: edit Phan I answer in modal, verify score recalculates
- [ ] Test: statistics show correct mean/median for 5+ results
## Success Criteria
1. Teachers can click "Sua" in detail modal, change any answer, save, see updated score
2. Paste "A,B,C,D..." from Excel into Phan I input fills all answer buttons
3. Statistics panel shows mean, median, min/max, distribution for class
4. Debug legend shows Vietnamese labels ("So bao danh", "Ma de thi", "Dung", "Sai")
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Paste format varies across Excel versions / locales | Medium | Low | Support multiple delimiters (comma, tab, newline, space) |
| Manual correction without audit trail | Low | Medium | Future: add edit history. For now, teachers accept this trade-off |
| Statistics calculation wrong for edge cases | Low | Low | Handle empty results, single result, all-same-score |
## Security Considerations
- Paste input sanitized: only accept characters matching A/B/C/D
- Manual edits validated against allowed answer formats before saving
- No external data transmitted
@@ -1,59 +0,0 @@
---
title: "UI/UX & Detection Pipeline Fixes"
description: "Fix critical bugs, correct sheet layout coordinates, add perspective correction, and improve UX for Vietnamese THPT answer sheet scoring app"
status: pending
priority: P1
effort: 12h
branch: main
tags: [bugfix, detection, ux, opencv]
created: 2026-04-13
---
# UI/UX & Detection Pipeline Fixes
## Context
Review report: `plans/reports/ui-ux-review-260413-0856-answer-sheet-analysis.md`
The chambai app scores Vietnamese THPT answer sheets using OpenCV.js. Three categories of issues were found: critical bugs (data loss), wrong detection coordinates, and UX friction for teachers.
## Phase Overview
| Phase | Focus | Effort | Files Modified | Status |
|-------|-------|--------|----------------|--------|
| [Phase 1](./phase-01-critical-bug-fixes.md) | P0 critical bugs (data loss, memory leak, storage overflow) | 2h | UploadPage.jsx, ImageProcessor.jsx, new: lib/indexed-db-store.js | Pending |
| [Phase 2](./phase-02-detection-pipeline-fixes.md) | Sheet layout coordinates + perspective correction + Phan III model | 4h | bubble-grid-generator.js, marker-detection.js, answer-detection.js, types.js | Pending |
| [Phase 3](./phase-03-ux-improvements.md) | State lifting, keyboard nav, navigation guards, sticky buttons | 3h | page.jsx, Navigation.jsx, ConfigurationPage.jsx, UploadPage.jsx, ResultsPage.jsx | Pending |
| [Phase 4](./phase-04-workflow-features.md) | Manual correction, paste-import, class statistics, Vietnamese debug legend | 3h | ResultsPage.jsx, ConfigurationPage.jsx, debug-visualization.js, new: lib/statistics.js | Pending |
## Dependency Graph
```
Phase 1 (bugs) ──> Phase 3 (UX - depends on state lifting which replaces localStorage pattern)
Phase 2 (detection) ──> independent, can parallel with Phase 1
Phase 3 (UX) ──> Phase 4 (workflow features use lifted state)
```
## Risk Matrix
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Sheet layout coordinates still wrong after fix | Medium | High | Must test with real CV1239 scan images; keep coordinates in a single constant for easy tuning |
| IndexedDB migration breaks existing localStorage data | Low | High | Phase 1 includes migration function; read from both stores, write to IndexedDB |
| Perspective correction degrades already-straight images | Low | Medium | Only apply when 4 markers detected AND skew angle > 2 degrees |
| State lifting breaks existing page isolation | Medium | Medium | Phase 3 lifts incrementally; each page still receives props matching its current interface |
## Rollback Strategy
- Each phase is a separate commit (or PR)
- Phase 1: revert to setTimeout-based save (lossy but functional)
- Phase 2: old SHEET_LAYOUT values are documented in phase file for revert
- Phase 3: revert state to localStorage-per-page pattern
- Phase 4: purely additive features, safe to revert
## Success Criteria
1. **Phase 1**: No data loss after processing 50+ images; no object URL memory leaks; IndexedDB stores debug images
2. **Phase 2**: Student ID and Exam Code detected correctly on straight CV1239 scans; Phan III returns multi-character answers
3. **Phase 3**: Keyboard-driven Phan I input; Upload tab blocked without config; step indicators visible
4. **Phase 4**: Teachers can correct answers in results modal; paste 40 answers from spreadsheet
@@ -1,113 +0,0 @@
# Vietnamese High School Answer Sheet (Phiếu Trả Lời Trắc Nghiệm) - Technical Specifications
## Sheet Structure & Sections
**3 Main Parts:**
1. **Phần I** - Multiple choice questions (40-50 questions per sheet typical)
2. **Phần II** - True/False section (Đúng/Sai)
3. **Phần III** - Short answer section (Tự luận số)
**2025 Format Updates** (per Công văn 1239/BGDĐT):
- Exam code: 4 digits (changed from 3 digits)
- Student ID (Số báo danh): 8 digits (changed from 6 digits)
- Layout markers: Black squares (▪) instead of black horizontal lines
## Physical Specifications
**Paper Size:** A4 standard (also available in A5/A6 reduced formats)
**Printing Requirements:**
- Offset printing (laser/inkjet acceptable; NOT photocopy/color printing)
- Red color specification: 100% TRAM density for lines/circles/text, 10% TRAM for background
- Positioning tolerance: Minimum 4.5mm margin from paper edge
## Alignment & Positioning Markers
**Corner Markers:** Black squares at all 4 corners (critical for OMR scanning)
**Registration Points:**
- Test code (Mã đề) positioning points
- Student ID (Số báo danh) positioning points
- Vertical alignment guides
Standard OMR sheets have index points at 4 corners—most important parameter for scanner alignment.
## Bubble Specifications
**Bubble Size:** 10-14 points optimal (per international OMR standards)
- Smaller bubbles increase recognition error
- Larger bubbles may touch adjacent bubbles
**Spacing Requirements:**
- Minimum 3/8 inch clearance around bubbles for scanning
- No visible lines within bubble zones
- Consistent horizontal/vertical spacing between answer options (A, B, C, D typically)
**Marking Standards:**
- Students use black pencil only
- Single bubble per question
- Bubbles must be fully darkened for recognition
## Student ID Layout
**Section:** Top of sheet (standardized position)
- 8 individual digit fields arranged horizontally or in 2 rows of 4
- Each digit has: blank space for writing + bubble array below for marking (0-9 bubbles)
- Students write digit in blank, then fill corresponding bubble
## Exam Code Layout
**Section:** Top of sheet (separate from Student ID)
- 4 digit fields with same write + bubble structure
- Positioned adjacent to Student ID section
## Key Technical Insights for Bubble Detection
**Alignment:** Corner squares enable perspective correction before bubble analysis
**Detection Approach:** Contour-based detection more reliable than circle detection (HoughCircles unreliable for scanned sheets)
**Workflow:**
1. Perspective transform using corner markers
2. Identify question regions by alignment guides
3. Extract contours, filter by aspect ratio/size
4. Group into rows (one row = one question)
5. Mark darkest bubble in each row as selected answer
**Data Extraction:** After bubble detection, cross-reference marked bubbles against printed answer key
## Spacing Standards
Per international OMR:
- Horizontal spacing between answer columns: consistent (typically 8-10mm)
- Vertical spacing between questions: consistent (typically 10-15mm)
- Left/right margins: 1cm minimum
- Top/bottom margins: 1-1.5cm
## Critical Notes for Implementation
**Must preserve corner markers** - removing them breaks OMR scanning
**Red printed areas** - low contrast makes optical detection easier (not black)
**8 digits for ID + 4 digits for exam code** - account for all 12 input fields
**Bubble size consistency** - varies by manufacturer; measure from actual template
**Lighting tolerance** - white/light gray background acceptable, but not colored paper
---
## Sources
- [Official 2025 Answer Sheet Usage Guide](https://xaydungchinhsach.chinhphu.vn/huong-dan-su-dung-phieu-tra-loi-trac-nghiem-thi-tot-nghiep-thpt-nam-2025-119250324144831872.htm)
- [Official Dispatch 1239/BGDĐT Technical Specifications](https://thuvienphapluat.vn/chinh-sach-phap-luat-moi/vn/ho-tro-phap-luat/chinh-sach-moi/81377/cong-van-1239-huong-dan-su-dung-phieu-tra-loi-trac-nghiem-thi-tot-nghiep-thpt-nam-2025)
- [TNMaker Answer Sheet Templates](https://tnmaker.net/phieu-trac-nghiem/)
- [PyImageSearch: Bubble Sheet Scanner with OpenCV](https://pyimagesearch.com/2016/10/03/bubble-sheet-multiple-choice-scanner-and-test-grader-using-omr-python-and-opencv/)
- [OMR Bubble Size Standards](https://www.addmengroup.com/omr-design/omr-bubble-size.htm)
- [General OMR Answer Sheet Specifications](https://www.yoctel.com/blogs/what-is-the-omr-answer-sheet)
---
## Unresolved Questions
1. **Exact bubble diameter in mm** - Standards specify 10-14 points but actual Vietnamese templates may vary; need to measure from official PDF template
2. **Precise spacing in mm between columns/rows** - Guidelines exist but exact measurements depend on question count per sheet
3. **Background color tolerance** - Can background be light gray or must it be pure white?
4. **Handwriting area dimensions** - For student ID digit writing field width/height specifications
@@ -1,176 +0,0 @@
# UI/UX Review & Answer Sheet Analysis Report
**Date:** 2026-04-13 | **Branch:** main | **Status:** Complete
---
## Part 1: Answer Sheet Layout Analysis (CV1239/BGDDT 2025)
### Sheet Structure (Page 0 - Answer Sheet)
The official Vietnamese THPT answer sheet has the following layout from top to bottom:
#### Header Section (top ~15% of sheet)
- Title: **"PHIEU TRA LOI TRAC NGHIEM"** (centered, bold)
- Fields: Ky thi, Mon thi, Ngay thi
- **Item 7: "So bao danh"** (Student ID) - top-right area, 8-digit grid
- **Item 8: "Ma de thi"** (Exam Code) - far top-right, 4-digit grid
- Left side: Examiner signature boxes (Giam thi 1, Giam thi 2)
- Items 1-6: Exam metadata (Hoi dong thi, Diem thi, Phong so, Ho va ten thi sinh, Ngay sinh, Chu ky thi sinh)
- Note at bottom of header: *"Chu y: Thi sinh can doc ky huong dan o mat sau Phieu nay."*
#### PHAN I Section (~30% of sheet, middle area)
- Label: **"PHAN I"** (bold, left-aligned)
- Layout: **4 columns of 10 questions each = 40 questions total**
- Column 1: Questions 1-10
- Column 2: Questions 11-20
- Column 3: Questions 21-30
- Column 4: Questions 31-40
- Each question has **4 bubbles: A, B, C, D** (circles, inline)
#### PHAN II Section (~15% of sheet)
- Label: **"PHAN II"** (bold, left-aligned)
- Layout: **8 questions (Cau 1 to Cau 8) arranged in a single row**
- Each question has: header "Cau N / Dung Sai"
- Sub-options: **a), b), c), d)** each with **2 bubbles: Dung (True) / Sai (False)**
- Arranged as 4 questions per visual group, spanning full width
#### PHAN III Section (~25% of sheet, bottom)
- Label: **"PHAN III"** (bold, left-aligned)
- Layout: **6 questions (Cau 1 to Cau 6) in 6 columns**
- Each question has a column for writing a numerical answer
- Below each writing area: **10 rows of bubbles (digits 0-9)**
- There's also a **"-" (negative sign) bubble** at the top
- And a **"," (decimal comma) bubble**
#### Corner/Alignment Markers
- **4 corner markers**: solid black squares at corners of the answer area
- Located at: top-left, top-right, bottom-left, bottom-right of the bubble grid area
- NOT at the paper corners - they frame the scoring area specifically
### Page 1 - Instructions
- Shows how to fill bubbles with 4 example figures (Hinh 1-4)
- Hinh 1: Phan I example (fill one circle per question)
- Hinh 2: Phan II example (fill Dung/Sai per sub-option)
- Hinh 3: Phan III negative number example ("-1.5")
- Hinh 4: Phan III positive number example ("1.5")
### Key Layout Proportions (for OpenCV calibration)
Based on visual analysis of the 1638x2339 pixel rendering:
| Section | Approx Y Start (%) | Approx Y End (%) | Height (%) |
|---------|-------------------|------------------|------------|
| Header + SBD/Ma de | 0% | 28% | 28% |
| PHAN I | 28% | 55% | 27% |
| PHAN II | 55% | 72% | 17% |
| PHAN III | 72% | 98% | 26% |
| Section | Approx X Start (%) | Approx X End (%) | Width (%) |
|---------|-------------------|------------------|-----------|
| SBD (8 digits) | 58% | 82% | 24% |
| Ma de thi (4 digits) | 82% | 95% | 13% |
| PHAN I (all 4 cols) | 3% | 97% | 94% |
| PHAN II (all 8 qs) | 3% | 97% | 94% |
| PHAN III (all 6 qs) | 3% | 97% | 94% |
### Critical Observations vs Current Code
**Current `bubble-grid-generator.js` SHEET_LAYOUT discrepancies:**
1. **Student ID position is WRONG**: Code has `{ x: 0.65, y: 0.02, w: 0.25, h: 0.18 }` but actual sheet puts SBD at ~`{ x: 0.58, y: 0.05, w: 0.24, h: 0.20 }` (within the header area, not at the very top)
2. **Exam Code position is WRONG**: Code has `{ x: 0.45, y: 0.02 }` but actual sheet puts Ma de thi to the RIGHT of SBD at ~`{ x: 0.82, y: 0.05 }`
3. **PHAN I Y position needs adjustment**: Code has `y: 0.25` but actual is closer to `y: 0.28`
4. **PHAN II Y position needs adjustment**: Code has `y: 0.58` but actual is closer to `y: 0.55`
5. **PHAN III**: Code has `y: 0.76` but actual starts at ~`y: 0.72`
6. **PHAN III missing features**: The actual sheet has **negative sign "-"** and **decimal comma ","** bubbles that the code doesn't handle
7. **PHAN III is multi-digit per question**: Each question can have multiple digits (e.g., "-1.5" = 4 characters), not just a single digit. Current code treats each question as a single digit selection.
---
## Part 2: UI/UX Review
### Critical Bugs (P0)
| # | Issue | Location | Impact |
|---|-------|----------|--------|
| 1 | **localStorage race condition** | `UploadPage.jsx:99-104` | `setTimeout(100)` hack for saving results - results can be silently lost |
| 2 | **`useState` misused as `useEffect`** | `UploadPage.jsx:271-275` | `ImageThumbnail` never updates URL on prop change, never revokes old URLs (memory leak) |
| 3 | **localStorage overflow risk** | `UploadPage.jsx:101-103` | Debug image data URLs (~1-2MB each) stored per result. 50+ students = blown 5MB limit |
### UX Issues (P1)
| # | Issue | Impact |
|---|-------|--------|
| 4 | Save/Reset buttons at TOP of ConfigurationPage | Teachers must scroll up after filling 40+ answers to save |
| 5 | No validation before Upload tab | Teachers can try processing without saving config |
| 6 | No step-completion indicators on Navigation | Teachers don't know which steps are done |
| 7 | No keyboard navigation for Phan I answer input | Inputting 40 answers requires 40 mouse clicks minimum |
| 8 | Debug legend in English, rest in Vietnamese | Inconsistent language |
| 9 | Status messages auto-dismiss in 3 seconds | Too fast for non-tech-savvy teachers |
| 10 | No manual answer correction in results | Teachers can't fix OCR errors |
### Workflow Improvements (P2)
| # | Suggestion | Benefit |
|---|-----------|---------|
| 11 | Lift state to parent component or use React Context | Eliminates localStorage sync bugs between tabs |
| 12 | Auto-advance prompts after key actions | Guides teachers through the 3-step flow |
| 13 | Paste-from-spreadsheet for Phan I answers | Teachers often have answer keys in Excel |
| 14 | Class summary statistics (mean, median, distribution) | Essential for teacher reporting |
| 15 | Phan II: distinguish "unanswered" vs "answered Sai" visually | Currently ambiguous |
### Visual Polish (P3)
| # | Suggestion |
|---|-----------|
| 16 | Extract shared StatusMessage component (duplicated in Config + Upload) |
| 17 | Increase touch targets (w-8 h-8 = 32px, should be 44px for mobile/tablet) |
| 18 | Add collapsible help text on each page |
| 19 | Replace native `confirm()` with inline confirmation + undo |
| 20 | Dark mode deprioritized - low value for target users (teachers in school settings) |
---
## Part 3: Core Logic Analysis - OpenCV Detection Pipeline
### Current Pipeline
```
Image -> Grayscale + GaussianBlur + AdaptiveThreshold + MorphClose
-> FindContours -> Filter square-ish contours -> Pick 4 corners
-> Generate bubble grid from proportional layout
-> MeasureBubbleFill per bubble (mean intensity in ROI)
-> Pick best-filled bubble per question group
```
### Strengths
- Solid preprocessing chain (adaptive threshold handles variable lighting)
- Morphological closing fills bubble gaps
- Fill measurement approach (mean intensity) is proven for bubble sheets
- Fallback to image bounds when markers aren't detected
### Weaknesses & Improvement Areas
1. **No perspective correction/deskewing**: Markers are detected but only used for bounding box. Should apply `cv.getPerspectiveTransform` + `cv.warpPerspective` to correct camera angle/rotation.
2. **Bubble size is hardcoded ratio (0.012)**: Should be derived from marker size or from the distance between detected marker corners.
3. **No image quality validation**: No check for blur, resolution, exposure. Should warn if image is too small or too blurry.
4. **FILL_THRESHOLD = 0.35 is static**: Should be adaptive - calculate the mean of "definitely empty" bubbles and "definitely filled" bubbles per sheet, then set threshold between them (Otsu-like approach per sheet).
5. **PHAN III model is wrong**: Current code assumes 1 digit per question. Actual sheet supports multi-character answers (e.g., "-1.5" needs 4 character positions including sign and decimal).
6. **No multi-answer detection for PHAN I**: If a student fills 2 bubbles for one question, current code just picks the darkest. Should flag as "multiple answers" (invalid).
7. **No confidence reporting per answer**: The detection returns answers but no per-answer confidence. Teachers need to see which answers are low-confidence for manual review.
---
## Unresolved Questions
1. How large are debug visualization data URLs in practice? If >1MB each, IndexedDB migration is P0.
2. Is there a standard Vietnamese THPT answer key format (Excel template) for paste-import?
3. Do teachers need to support multiple exam codes (answer keys) simultaneously?
4. PHAN III: what is the maximum number of characters per answer? The sheet shows sign + digits + decimal.
5. Should the app support camera capture directly (mobile use case) vs only file upload?