mirror of
https://github.com/tiennm99/chambai.git
synced 2026-07-29 20:20:37 +00:00
rename sheet template, remove completed plans and stale reports
This commit is contained in:
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?
|
||||
Reference in New Issue
Block a user