diff --git a/backend/src/modules/learning/controllers/learningController.test.ts b/backend/src/modules/learning/controllers/learningController.test.ts index 5f7b12b..0555b68 100644 --- a/backend/src/modules/learning/controllers/learningController.test.ts +++ b/backend/src/modules/learning/controllers/learningController.test.ts @@ -2,12 +2,14 @@ import request from 'supertest'; import express from 'express'; import learningRouter from '../routes/learningRoutes'; import { RoadmapService } from '../services/roadmapService'; +import { ProgressService } from '../services/progressService'; import { runStepValidators } from '../engine/engine'; import { ValidatorResult } from '../engine/types'; import { ProjectService } from '../../projects/services/projectService'; import { Roadmap } from '../format/roadmapTypes'; jest.mock('../services/roadmapService'); +jest.mock('../services/progressService'); jest.mock('../engine/engine'); jest.mock('../../projects/services/projectService'); @@ -180,5 +182,89 @@ describe('LearningController', () => { expect(res.status).toBe(500); expect(res.body).toEqual({ error: 'boom' }); }); + + it('records the attempt and verdict in the progress store', async () => { + const res = await request(app).post('/api/learning/validate').send(validBody); + + expect(res.status).toBe(200); + expect(ProgressService.recordValidation).toHaveBeenCalledWith( + 'project-1', + 'example-roadmap', + 'step-one', + true, + res.body.checkedAt + ); + }); + + it('still returns the verdict when progress recording fails', async () => { + (ProgressService.recordValidation as jest.Mock).mockImplementation(() => { + throw new Error('disk full'); + }); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const res = await request(app).post('/api/learning/validate').send(validBody); + + expect(res.status).toBe(200); + expect(res.body.stepPassed).toBe(true); + errorSpy.mockRestore(); + }); + }); + + describe('GET /api/learning/progress/:projectId/:roadmapId', () => { + it('returns the stored progress for the pair', async () => { + const progress = { + projectId: 'project-1', + roadmapId: 'example-roadmap', + steps: { 'step-one': { passed: true, attempts: 2, revealedHints: 1 } }, + }; + (ProgressService.getProgress as jest.Mock).mockReturnValue(progress); + + const res = await request(app).get('/api/learning/progress/project-1/example-roadmap'); + + expect(res.status).toBe(200); + expect(res.body).toEqual(progress); + expect(ProgressService.getProgress).toHaveBeenCalledWith('project-1', 'example-roadmap'); + }); + }); + + describe('PUT /api/learning/progress/:projectId/:roadmapId/hints', () => { + it('stores the absolute revealed-hints count and returns 204', async () => { + const res = await request(app) + .put('/api/learning/progress/project-1/example-roadmap/hints') + .send({ stepId: 'step-one', revealedHints: 2 }); + + expect(res.status).toBe(204); + expect(ProgressService.recordRevealedHints).toHaveBeenCalledWith( + 'project-1', + 'example-roadmap', + 'step-one', + 2 + ); + }); + + it.each([ + ['missing stepId', { revealedHints: 1 }, 'stepId'], + ['empty stepId', { stepId: '', revealedHints: 1 }, 'stepId'], + ['missing revealedHints', { stepId: 'step-one' }, 'revealedHints'], + ['negative revealedHints', { stepId: 'step-one', revealedHints: -1 }, 'revealedHints'], + ['non-integer revealedHints', { stepId: 'step-one', revealedHints: 1.5 }, 'revealedHints'], + ])('returns 400 on %s', async (_name, body, field) => { + const res = await request(app) + .put('/api/learning/progress/project-1/example-roadmap/hints') + .send(body); + + expect(res.status).toBe(400); + expect(res.body.error).toContain(`"${field}"`); + expect(ProgressService.recordRevealedHints).not.toHaveBeenCalled(); + }); + }); + + describe('DELETE /api/learning/progress/:projectId/:roadmapId', () => { + it('resets the pair and returns 204', async () => { + const res = await request(app).delete('/api/learning/progress/project-1/example-roadmap'); + + expect(res.status).toBe(204); + expect(ProgressService.resetProgress).toHaveBeenCalledWith('project-1', 'example-roadmap'); + }); }); }); diff --git a/backend/src/modules/learning/controllers/learningController.ts b/backend/src/modules/learning/controllers/learningController.ts index 3efe90f..f10eec1 100644 --- a/backend/src/modules/learning/controllers/learningController.ts +++ b/backend/src/modules/learning/controllers/learningController.ts @@ -1,5 +1,6 @@ import { Request, Response } from 'express'; import { RoadmapService } from '../services/roadmapService'; +import { ProgressService } from '../services/progressService'; import { runStepValidators } from '../engine/engine'; import { ValidatorResult } from '../engine/types'; import { ProjectService } from '../../projects/services/projectService'; @@ -96,9 +97,68 @@ export class LearningController { results, checkedAt: new Date().toISOString(), }; + try { + ProgressService.recordValidation( + projectId as string, + roadmap.id, + step.id, + response.stepPassed, + response.checkedAt + ); + } catch (recordErr: unknown) { + // Recording is bookkeeping — the learner's verdict must still go out. + console.error('[learning] Failed to record validation progress:', recordErr); + } res.json(response); } catch (err: unknown) { res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); } } + + public static async getProgress(req: Request, res: Response): Promise { + try { + res.json( + ProgressService.getProgress(req.params.projectId as string, req.params.roadmapId as string) + ); + } catch (err: unknown) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + } + } + + public static async recordRevealedHints(req: Request, res: Response): Promise { + try { + const { stepId, revealedHints } = (req.body ?? {}) as Record; + if (typeof stepId !== 'string' || stepId.length === 0) { + res.status(400).json({ error: '"stepId" is required and must be a string' }); + return; + } + if (typeof revealedHints !== 'number' || !Number.isInteger(revealedHints) || revealedHints < 0) { + res.status(400).json({ error: '"revealedHints" is required and must be a non-negative integer' }); + return; + } + // No existence check against the roadmap: progress is local, non-sensitive + // data, and hint reveals must stay cheap fire-and-forget writes. + ProgressService.recordRevealedHints( + req.params.projectId as string, + req.params.roadmapId as string, + stepId, + revealedHints + ); + res.status(204).end(); + } catch (err: unknown) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + } + } + + public static async resetProgress(req: Request, res: Response): Promise { + try { + ProgressService.resetProgress( + req.params.projectId as string, + req.params.roadmapId as string + ); + res.status(204).end(); + } catch (err: unknown) { + res.status(500).json({ error: err instanceof Error ? err.message : String(err) }); + } + } } diff --git a/backend/src/modules/learning/routes/learningRoutes.ts b/backend/src/modules/learning/routes/learningRoutes.ts index f555e03..beca60f 100644 --- a/backend/src/modules/learning/routes/learningRoutes.ts +++ b/backend/src/modules/learning/routes/learningRoutes.ts @@ -6,5 +6,8 @@ const router = Router(); router.get('/roadmaps', LearningController.listRoadmaps); router.get('/roadmaps/:id', LearningController.getRoadmap); router.post('/validate', LearningController.validate); +router.get('/progress/:projectId/:roadmapId', LearningController.getProgress); +router.put('/progress/:projectId/:roadmapId/hints', LearningController.recordRevealedHints); +router.delete('/progress/:projectId/:roadmapId', LearningController.resetProgress); export default router; diff --git a/backend/src/modules/learning/services/progressService.test.ts b/backend/src/modules/learning/services/progressService.test.ts new file mode 100644 index 0000000..ac255e7 --- /dev/null +++ b/backend/src/modules/learning/services/progressService.test.ts @@ -0,0 +1,143 @@ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { ProgressService } from './progressService'; + +describe('ProgressService', () => { + let dir: string; + let file: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'torollo-progress-')); + file = path.join(dir, 'progress.json'); + (ProgressService as unknown as { storeRecovered: boolean }).storeRecovered = false; + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('returns empty steps when nothing was ever recorded', () => { + const progress = ProgressService.getProgress('project-1', 'roadmap-1', file); + + expect(progress).toEqual({ projectId: 'project-1', roadmapId: 'roadmap-1', steps: {} }); + expect(fs.existsSync(file)).toBe(false); + }); + + it('counts attempts and keeps the latest verdict across validations', () => { + ProgressService.recordValidation('project-1', 'roadmap-1', 'step-a', true, '2026-07-16T10:00:00.000Z', file); + ProgressService.recordValidation('project-1', 'roadmap-1', 'step-a', false, '2026-07-16T10:05:00.000Z', file); + + const progress = ProgressService.getProgress('project-1', 'roadmap-1', file); + + expect(progress.steps['step-a']).toEqual({ + passed: false, + attempts: 2, + revealedHints: 0, + lastCheckedAt: '2026-07-16T10:05:00.000Z', + }); + }); + + it('persists across service calls via the file (survives a fresh read)', () => { + ProgressService.recordValidation('project-1', 'roadmap-1', 'step-a', true, '2026-07-16T10:00:00.000Z', file); + ProgressService.recordRevealedHints('project-1', 'roadmap-1', 'step-b', 2, file); + + const stored = JSON.parse(fs.readFileSync(file, 'utf-8')); + + expect(stored.version).toBe(1); + expect(stored.entries).toHaveLength(1); + expect(stored.entries[0].projectId).toBe('project-1'); + expect(stored.entries[0].roadmapId).toBe('roadmap-1'); + expect(stored.entries[0].steps['step-a'].passed).toBe(true); + expect(stored.entries[0].steps['step-b'].revealedHints).toBe(2); + expect(fs.existsSync(`${file}.tmp`)).toBe(false); + }); + + it('stores revealed hints as an absolute count', () => { + ProgressService.recordRevealedHints('project-1', 'roadmap-1', 'step-a', 1, file); + ProgressService.recordRevealedHints('project-1', 'roadmap-1', 'step-a', 3, file); + + const progress = ProgressService.getProgress('project-1', 'roadmap-1', file); + + expect(progress.steps['step-a']).toEqual({ passed: false, attempts: 0, revealedHints: 3 }); + }); + + it('keys steps by stable id, independent of any ordering', () => { + ProgressService.recordValidation('project-1', 'roadmap-1', 'step-c', true, '2026-07-16T10:00:00.000Z', file); + ProgressService.recordValidation('project-1', 'roadmap-1', 'step-a', true, '2026-07-16T10:01:00.000Z', file); + + const progress = ProgressService.getProgress('project-1', 'roadmap-1', file); + + expect(progress.steps['step-a'].passed).toBe(true); + expect(progress.steps['step-c'].passed).toBe(true); + }); + + it('resetProgress removes only the targeted (project, roadmap) entry', () => { + ProgressService.recordValidation('project-1', 'roadmap-1', 'step-a', true, '2026-07-16T10:00:00.000Z', file); + ProgressService.recordValidation('project-1', 'roadmap-2', 'step-a', true, '2026-07-16T10:00:00.000Z', file); + ProgressService.recordValidation('project-2', 'roadmap-1', 'step-a', true, '2026-07-16T10:00:00.000Z', file); + + ProgressService.resetProgress('project-1', 'roadmap-1', file); + + expect(ProgressService.getProgress('project-1', 'roadmap-1', file).steps).toEqual({}); + expect(ProgressService.getProgress('project-1', 'roadmap-2', file).steps).not.toEqual({}); + expect(ProgressService.getProgress('project-2', 'roadmap-1', file).steps).not.toEqual({}); + }); + + it('deleteProjectProgress removes every roadmap entry of the project, nothing else', () => { + ProgressService.recordValidation('project-1', 'roadmap-1', 'step-a', true, '2026-07-16T10:00:00.000Z', file); + ProgressService.recordValidation('project-1', 'roadmap-2', 'step-a', true, '2026-07-16T10:00:00.000Z', file); + ProgressService.recordValidation('project-2', 'roadmap-1', 'step-a', true, '2026-07-16T10:00:00.000Z', file); + + ProgressService.deleteProjectProgress('project-1', file); + + expect(ProgressService.getProgress('project-1', 'roadmap-1', file).steps).toEqual({}); + expect(ProgressService.getProgress('project-1', 'roadmap-2', file).steps).toEqual({}); + expect(ProgressService.getProgress('project-2', 'roadmap-1', file).steps).not.toEqual({}); + }); + + describe('unreadable store recovery', () => { + let errorSpy: jest.SpyInstance; + + beforeEach(() => { + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + errorSpy.mockRestore(); + }); + + it('moves a corrupt file aside, starts fresh and reports the recovery once', () => { + fs.writeFileSync(file, 'not json at all {'); + + const first = ProgressService.getProgress('project-1', 'roadmap-1', file); + expect(first.steps).toEqual({}); + expect(first.storeRecovered).toBe(true); + expect(fs.readFileSync(`${file}.corrupt`, 'utf-8')).toBe('not json at all {'); + + const second = ProgressService.getProgress('project-1', 'roadmap-1', file); + expect(second.storeRecovered).toBeUndefined(); + }); + + it('treats an unknown store version like corruption — never guesses', () => { + fs.writeFileSync(file, JSON.stringify({ version: 99, entries: [] })); + + const progress = ProgressService.getProgress('project-1', 'roadmap-1', file); + + expect(progress.steps).toEqual({}); + expect(progress.storeRecovered).toBe(true); + expect(fs.existsSync(`${file}.corrupt`)).toBe(true); + }); + + it('keeps working after recovery: new writes land in a fresh store', () => { + fs.writeFileSync(file, '{broken'); + + ProgressService.recordValidation('project-1', 'roadmap-1', 'step-a', true, '2026-07-16T10:00:00.000Z', file); + + const progress = ProgressService.getProgress('project-1', 'roadmap-1', file); + expect(progress.steps['step-a'].passed).toBe(true); + // The recovery happened on the write path — still reported on the next read. + expect(progress.storeRecovered).toBe(true); + }); + }); +}); diff --git a/backend/src/modules/learning/services/progressService.ts b/backend/src/modules/learning/services/progressService.ts new file mode 100644 index 0000000..19ba761 --- /dev/null +++ b/backend/src/modules/learning/services/progressService.ts @@ -0,0 +1,190 @@ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +/** Persisted progress of one roadmap step, keyed by the step's stable id. */ +export interface StepProgress { + /** Verdict of the latest validation — same latest-wins semantics as the player. */ + passed: boolean; + /** Number of validation runs that reached evaluation (rejected requests don't count). */ + attempts: number; + /** Revealed rungs on the step's hint ladder [...hints, solution?] — an absolute count. */ + revealedHints: number; + /** ISO timestamp of the latest validation, absent until the first one. */ + lastCheckedAt?: string; +} + +/** One (project, roadmap) play-through inside the store. */ +export interface ProgressEntry { + projectId: string; + roadmapId: string; + updatedAt: string; + steps: Record; +} + +/** + * On-disk shape of ~/.torollo/progress.json. `version` is the migration + * contract: a reader that finds another version must treat the file as + * unknown (see readStore), never guess. + */ +interface ProgressStore { + version: typeof STORE_VERSION; + entries: ProgressEntry[]; +} + +/** Contract of GET /api/learning/progress/:projectId/:roadmapId — documented in docs/learning-api.md. */ +export interface RoadmapProgressResponse { + projectId: string; + roadmapId: string; + steps: Record; + /** Present (true) once after an unreadable store was moved aside — the UI should tell the user. */ + storeRecovered?: boolean; +} + +const STORE_VERSION = 1; +const PROGRESS_PATH = path.join(os.homedir(), '.torollo', 'progress.json'); + +function isProgressStore(data: unknown): data is ProgressStore { + return ( + typeof data === 'object' && + data !== null && + (data as ProgressStore).version === STORE_VERSION && + Array.isArray((data as ProgressStore).entries) + ); +} + +/** + * Local, account-less persistence of roadmap progression. One JSON file next + * to projects.json; entries are keyed by (projectId, roadmapId) and step data + * by the roadmap's stable step ids — never positions, so re-editing a roadmap + * file cannot corrupt existing progress. Translations share ids, so progress + * is language-neutral. + * + * The validation engine stays stateless: recording happens at the API layer. + * The optional `filePath` parameter exists for tests only. + */ +export class ProgressService { + // Set when an unreadable store was moved aside; reported to the frontend by + // the next getProgress (once), so the user learns their progress was lost. + private static storeRecovered = false; + + public static getProgress( + projectId: string, + roadmapId: string, + filePath: string = PROGRESS_PATH + ): RoadmapProgressResponse { + const store = this.readStore(filePath); + const entry = store.entries.find(e => e.projectId === projectId && e.roadmapId === roadmapId); + const response: RoadmapProgressResponse = { projectId, roadmapId, steps: entry?.steps ?? {} }; + if (this.storeRecovered) { + response.storeRecovered = true; + this.storeRecovered = false; + } + return response; + } + + public static recordValidation( + projectId: string, + roadmapId: string, + stepId: string, + stepPassed: boolean, + checkedAt: string, + filePath: string = PROGRESS_PATH + ): void { + const store = this.readStore(filePath); + const step = this.upsertStep(store, projectId, roadmapId, stepId); + step.attempts += 1; + step.passed = stepPassed; + step.lastCheckedAt = checkedAt; + this.writeStore(store, filePath); + } + + public static recordRevealedHints( + projectId: string, + roadmapId: string, + stepId: string, + revealedHints: number, + filePath: string = PROGRESS_PATH + ): void { + const store = this.readStore(filePath); + const step = this.upsertStep(store, projectId, roadmapId, stepId); + // Absolute count, not an increment: a lost update self-heals on the next reveal. + step.revealedHints = revealedHints; + this.writeStore(store, filePath); + } + + /** Forgets one (project, roadmap) play-through — the "restart roadmap" action. */ + public static resetProgress( + projectId: string, + roadmapId: string, + filePath: string = PROGRESS_PATH + ): void { + const store = this.readStore(filePath); + store.entries = store.entries.filter( + e => !(e.projectId === projectId && e.roadmapId === roadmapId) + ); + this.writeStore(store, filePath); + } + + /** Called from project deletion: progress is meaningless without its project's containers. */ + public static deleteProjectProgress(projectId: string, filePath: string = PROGRESS_PATH): void { + const store = this.readStore(filePath); + store.entries = store.entries.filter(e => e.projectId !== projectId); + this.writeStore(store, filePath); + } + + private static upsertStep( + store: ProgressStore, + projectId: string, + roadmapId: string, + stepId: string + ): StepProgress { + let entry = store.entries.find(e => e.projectId === projectId && e.roadmapId === roadmapId); + if (!entry) { + entry = { projectId, roadmapId, updatedAt: '', steps: {} }; + store.entries.push(entry); + } + entry.updatedAt = new Date().toISOString(); + let step = entry.steps[stepId]; + if (!step) { + step = { passed: false, attempts: 0, revealedHints: 0 }; + entry.steps[stepId] = step; + } + return step; + } + + private static readStore(filePath: string): ProgressStore { + if (!fs.existsSync(filePath)) { + return { version: STORE_VERSION, entries: [] }; + } + try { + const data: unknown = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + if (isProgressStore(data)) { + return data; + } + } catch { + // Unparseable — recovered below, same as an unknown shape/version. + } + // Never crash and never guess: move the unreadable file aside so nothing + // is silently destroyed, start fresh, and flag it for the next getProgress. + try { + fs.renameSync(filePath, `${filePath}.corrupt`); + } catch (err: unknown) { + console.error(`[learning] Failed to move unreadable progress store aside:`, err); + } + console.error( + `[learning] Progress store ${filePath} was unreadable or of an unknown version; ` + + `starting fresh (previous file kept as progress.json.corrupt).` + ); + this.storeRecovered = true; + return { version: STORE_VERSION, entries: [] }; + } + + private static writeStore(store: ProgressStore, filePath: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + // Write-then-rename: a crash mid-write can never leave a truncated store. + const tmpPath = `${filePath}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(store, null, 2)); + fs.renameSync(tmpPath, filePath); + } +} diff --git a/backend/src/modules/projects/services/projectService.ts b/backend/src/modules/projects/services/projectService.ts index c9034a7..64dfd91 100644 --- a/backend/src/modules/projects/services/projectService.ts +++ b/backend/src/modules/projects/services/projectService.ts @@ -3,6 +3,7 @@ import path from 'path'; import os from 'os'; import { containerProvider } from '../../../infrastructure/docker/providers/dockerContainerProvider'; import { NetworkService } from '../../network/services/networkService'; +import { ProgressService } from '../../learning/services/progressService'; export interface Project { id: string; @@ -64,6 +65,14 @@ export class ProjectService { const filtered = db.filter(p => p.id !== id); this.writeDB(filtered); + // Learning progress is validated against this project's containers — + // without them it is meaningless, so it goes with the project. + try { + ProgressService.deleteProjectProgress(id); + } catch (err) { + console.error(`Failed to delete learning progress during project cleanup:`, err); + } + // Stop and delete all containers belonging to this project const containers = await containerProvider.listContainersByProject(id); for (const c of containers) { diff --git a/docs/learning-api.md b/docs/learning-api.md index 38bb409..85bdb8a 100644 --- a/docs/learning-api.md +++ b/docs/learning-api.md @@ -39,7 +39,7 @@ Because translations share an `id`, the real key is `(id, language)`: ### `POST /api/learning/validate` -Runs every validator of one step against the real state of one project. The engine is **stateless**: it evaluates, it never records progression. +Runs every validator of one step against the real state of one project. The engine itself is **stateless** — it evaluates, it never records — but the API layer records the attempt: each call that reaches evaluation increments the step's `attempts` and stores the verdict in the [local progress store](#local-progression-no-account) (a storage failure is logged and never blocks the verdict). Request body: @@ -86,6 +86,45 @@ Success response (`200`): - `stepPassed` is `true` iff **every** result has `status: "pass"`. An `error` result never validates a step (⚠ is not ✓). - `expected` / `observed` are short human-readable snapshots, present when the check can express them. +## Local progression (no account) + +Roadmap progression is persisted locally in `~/.torollo/progress.json`, next to `projects.json` — no account, no auth, nothing leaves the machine. One entry per `(projectId, roadmapId)` pair (a step's ✓ describes the containers of the project it was validated in), holding per step — keyed by the step's **stable id**, so re-editing or reordering a roadmap file never corrupts progress, and translations (which share ids) share progress: + +```json +{ + "version": 1, + "entries": [ + { + "projectId": "project-1751883322290", + "roadmapId": "example-first-architecture", + "updatedAt": "2026-07-16T20:11:00.000Z", + "steps": { + "create-web-server": { + "passed": true, + "attempts": 3, + "revealedHints": 1, + "lastCheckedAt": "2026-07-16T20:10:58.000Z" + } + } + } + ] +} +``` + +`passed` is the verdict of the **latest** validation (same semantics as the player's in-session display); `attempts` counts the validation runs that reached evaluation; `revealedHints` is the absolute number of revealed rungs on the step's hint ladder `[...hints, solution?]`. Validator results are deliberately **not** persisted — they describe a past container state; only the verdict survives. The top-level `version` is the migration contract: a reader that finds an unknown version (or an unparseable file) must not guess — the server moves the file aside as `progress.json.corrupt`, starts fresh, and reports it once via `storeRecovered` on the next progress read so the UI can tell the user. Writes are write-then-rename, so a crash mid-write cannot truncate the store. Deleting a project deletes its progress entries. + +### `GET /api/learning/progress/:projectId/:roadmapId` + +Returns `{ projectId, roadmapId, steps }` — `steps` is the per-step record above, `{}` when nothing was ever recorded. `storeRecovered: true` is present once after a corrupt/unknown-version store was discarded. The player calls this when opening a roadmap and resumes on the first step whose `passed` is not true. + +### `PUT /api/learning/progress/:projectId/:roadmapId/hints` + +Body `{ "stepId": "create-web-server", "revealedHints": 2 }` → `204`. Stores the absolute revealed count (idempotent — a lost write self-heals on the next reveal). `400` when `stepId` is not a non-empty string or `revealedHints` is not a non-negative integer. No existence check against the roadmap: progress is local, non-sensitive data, and hint reveals are cheap fire-and-forget writes. + +### `DELETE /api/learning/progress/:projectId/:roadmapId` + +Forgets that pair's progress (the player's "Restart roadmap" action) → `204`. Other roadmaps and projects are untouched. + ## Result semantics: `pass` / `fail` / `error` | `status` | Meaning | Suggested UI | diff --git a/frontend/src/features/learning/components/LearningPanel.test.tsx b/frontend/src/features/learning/components/LearningPanel.test.tsx index 21ca46b..89b350d 100644 --- a/frontend/src/features/learning/components/LearningPanel.test.tsx +++ b/frontend/src/features/learning/components/LearningPanel.test.tsx @@ -4,6 +4,7 @@ import { render, screen, fireEvent } from '@testing-library/react'; import LearningPanel from './LearningPanel'; import type { Roadmap, + RoadmapProgressResponse, RoadmapSummary, StepValidationResponse, } from '../../../shared/types/roadmap'; @@ -74,20 +75,38 @@ const passResponse: StepValidationResponse = { checkedAt: '2026-07-15T10:01:00.000Z', }; +const emptyProgress: RoadmapProgressResponse = { + projectId: 'p1', + roadmapId: roadmap.id, + steps: {}, +}; + function jsonResponse(ok: boolean, body: unknown): Response { return { ok, json: () => Promise.resolve(body) } as Response; } -/** Routes fetch calls by URL so the catalogue, roadmap and validate endpoints can be scripted independently. */ +/** Routes fetch calls by URL so the catalogue, roadmap, validate and progress endpoints can be scripted independently. */ function buildFetchMock(handlers: { roadmaps?: () => Response; roadmap?: () => Response; validate?: () => Response; + progress?: () => Response; + hints?: () => Response; + reset?: () => Response; }) { - return vi.fn((url: string) => { + return vi.fn((url: string, options?: RequestInit) => { if (url.includes('/api/learning/validate')) { return Promise.resolve(handlers.validate?.() ?? jsonResponse(true, failResponse)); } + if (url.includes('/api/learning/progress/') && url.endsWith('/hints')) { + return Promise.resolve(handlers.hints?.() ?? jsonResponse(true, {})); + } + if (url.includes('/api/learning/progress/')) { + if (options?.method === 'DELETE') { + return Promise.resolve(handlers.reset?.() ?? jsonResponse(true, {})); + } + return Promise.resolve(handlers.progress?.() ?? jsonResponse(true, emptyProgress)); + } if (url.includes('/api/learning/roadmaps/')) { return Promise.resolve(handlers.roadmap?.() ?? jsonResponse(true, roadmap)); } @@ -246,6 +265,72 @@ describe('LearningPanel', () => { expect(await screen.findByText('Not yet — see the results below')).toBeInTheDocument(); }); + it('restores persisted progress: reopens on the first incomplete step with ✓ markers', async () => { + vi.stubGlobal( + 'fetch', + buildFetchMock({ + progress: () => + jsonResponse(true, { + ...emptyProgress, + steps: { 'create-web-server': { passed: true, attempts: 2, revealedHints: 0 } }, + }), + }) + ); + render( {}} />); + + fireEvent.click(await screen.findByText('Your first architecture')); + + expect(await screen.findByText('Step 2 of 2')).toBeInTheDocument(); + expect(screen.getByTitle('Passed')).toBeInTheDocument(); + // Only the verdict is restored — no stale validator results are replayed. + expect(screen.queryByText('Step passed')).not.toBeInTheDocument(); + }); + + it('restarts the roadmap behind a two-click confirmation', async () => { + vi.stubGlobal( + 'fetch', + buildFetchMock({ + progress: () => + jsonResponse(true, { + ...emptyProgress, + steps: { 'create-web-server': { passed: true, attempts: 1, revealedHints: 0 } }, + }), + }) + ); + render( {}} />); + fireEvent.click(await screen.findByText('Your first architecture')); + expect(await screen.findByText('Step 2 of 2')).toBeInTheDocument(); + + // First click only arms the confirmation — nothing is deleted yet. + fireEvent.click(screen.getByRole('button', { name: 'Restart roadmap' })); + expect(screen.getByText('Step 2 of 2')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Sure? Click again to restart' })); + + expect(await screen.findByText('Step 1 of 2')).toBeInTheDocument(); + expect(screen.queryByTitle('Passed')).not.toBeInTheDocument(); + }); + + it('tells the user when an unreadable progress store was reset, dismissibly', async () => { + vi.stubGlobal( + 'fetch', + buildFetchMock({ + progress: () => jsonResponse(true, { ...emptyProgress, storeRecovered: true }), + }) + ); + render( {}} />); + await openRoadmapFromCatalog(); + + expect( + screen.getByText(/Your saved progress could not be read and had to be reset/) + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); + expect( + screen.queryByText(/Your saved progress could not be read and had to be reset/) + ).not.toBeInTheDocument(); + }); + it('calls onClose from the header button', async () => { vi.stubGlobal('fetch', buildFetchMock({})); const onClose = vi.fn(); diff --git a/frontend/src/features/learning/components/RoadmapPlayer.tsx b/frontend/src/features/learning/components/RoadmapPlayer.tsx index c0ef780..6e16c45 100644 --- a/frontend/src/features/learning/components/RoadmapPlayer.tsx +++ b/frontend/src/features/learning/components/RoadmapPlayer.tsx @@ -1,9 +1,10 @@ +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ArrowLeft, ChevronLeft, ChevronRight, Globe } from 'lucide-react'; +import { AlertTriangle, ArrowLeft, ChevronLeft, ChevronRight, Globe, RotateCcw, X } from 'lucide-react'; import StepValidationResults from './StepValidationResults'; import StepHints from './StepHints'; import { renderInstruction } from './InstructionMarkdown'; -import { outcomePreset } from '../validationStatus'; +import { outcomePreset, STATUS_PRESETS } from '../validationStatus'; import type { StepValidationResponse } from '../../../shared/types/roadmap'; import type { useLearningPlayer } from '../hooks/useLearningPlayer'; import type { ContainerData } from '../../../shared/types'; @@ -25,6 +26,21 @@ function StepMarker({ response }: { response: StepValidationResponse }) { ); } +/** + * ✓ for a step whose recorded validation passed in a previous session. Only + * the verdict is persisted — validator results describe a past container + * state — so there is no response object and no results card behind it. + */ +function RestoredStepMarker() { + const { t } = useTranslation(); + const { icon: Icon, color, labelKey } = STATUS_PRESETS.pass; + return ( + + + + ); +} + export default function RoadmapPlayer({ player, containers = [], @@ -48,8 +64,27 @@ export default function RoadmapPlayer({ revealedHintsByStepId, revealNextHint, closeRoadmap, + completedStepIds, + progressNotice, + dismissProgressNotice, + resetProgress, + resetting, + resetError, } = player; + // Restarting the roadmap forgets persisted progress, so it sits behind the + // same light two-click brake as the solution reveal: first click arms a + // confirmation label, second executes; leaving the button disarms. + const [resetArmed, setResetArmed] = useState(false); + const handleReset = () => { + if (!resetArmed) { + setResetArmed(true); + return; + } + setResetArmed(false); + resetProgress(); + }; + if (!roadmap || !currentStep) return null; const atFirstStep = currentStepIndex === 0; @@ -57,12 +92,47 @@ export default function RoadmapPlayer({ return (
- +
+ + +
{roadmap.title} + {resetError !== null && ( +
+ {resetError || t('learning.player.resetProgressError')} +
+ )} + + {progressNotice && ( +
+ + {t('learning.player.progressRecovered')} + +
+ )} +
{roadmap.steps.map((step, index) => { const result = resultsByStepId[step.id]; @@ -78,7 +148,11 @@ export default function RoadmapPlayer({ > {index + 1}. {step.title} - {result && } + {result ? ( + + ) : ( + completedStepIds[step.id] && + )} ); })} @@ -200,6 +274,12 @@ const styles: Record = { flexDirection: 'column', gap: '14px', }, + headerRow: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: '8px', + }, backLink: { display: 'flex', alignItems: 'center', @@ -213,6 +293,46 @@ const styles: Record = { cursor: 'pointer', fontFamily: 'var(--font-sans)', }, + resetBtn: { + display: 'flex', + alignItems: 'center', + padding: '3px 8px', + border: '1px dashed var(--border-color)', + borderRadius: '6px', + background: 'none', + color: 'var(--color-text-muted)', + fontSize: '10px', + fontWeight: 600, + cursor: 'pointer', + fontFamily: 'var(--font-sans)', + }, + resetBtnArmed: { + border: '1px dashed var(--color-danger)', + color: 'var(--color-danger)', + }, + noticeBox: { + display: 'flex', + alignItems: 'flex-start', + gap: '6px', + padding: '8px 10px', + border: '1px solid var(--color-warning)', + borderRadius: '6px', + backgroundColor: 'var(--color-warning-glow)', + }, + noticeText: { + flex: 1, + fontSize: '11px', + color: 'var(--color-warning-strong)', + lineHeight: 1.5, + }, + noticeDismiss: { + display: 'flex', + padding: 0, + border: 'none', + background: 'none', + color: 'var(--color-warning-strong)', + cursor: 'pointer', + }, roadmapTitle: { fontSize: '14px', fontWeight: 700, diff --git a/frontend/src/features/learning/hooks/useLearningPlayer.test.ts b/frontend/src/features/learning/hooks/useLearningPlayer.test.ts index 644bd74..9c1b046 100644 --- a/frontend/src/features/learning/hooks/useLearningPlayer.test.ts +++ b/frontend/src/features/learning/hooks/useLearningPlayer.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useLearningPlayer } from './useLearningPlayer'; -import type { Roadmap, StepValidationResponse } from '../../../shared/types/roadmap'; +import type { + Roadmap, + RoadmapProgressResponse, + StepValidationResponse, +} from '../../../shared/types/roadmap'; function jsonResponse(ok: boolean, body: unknown): Response { return { ok, json: () => Promise.resolve(body) } as Response; @@ -39,11 +43,22 @@ const passResponse: StepValidationResponse = { checkedAt: '2026-07-15T10:00:00.000Z', }; +const emptyProgress: RoadmapProgressResponse = { + projectId: 'p1', + roadmapId: roadmap.id, + steps: {}, +}; + async function openExampleRoadmap( result: { current: ReturnType }, - fetchMock: ReturnType + fetchMock: ReturnType, + progress: RoadmapProgressResponse = emptyProgress ) { fetchMock.mockResolvedValueOnce(jsonResponse(true, roadmap)); + fetchMock.mockResolvedValueOnce(jsonResponse(true, progress)); + // Default for later untargeted calls (the fire-and-forget hints PUT); + // per-test mockResolvedValueOnce/mockRejectedValueOnce take precedence. + fetchMock.mockResolvedValue(jsonResponse(true, {})); await act(async () => { await result.current.openRoadmap({ id: roadmap.id, language: 'en' }); }); @@ -261,14 +276,162 @@ describe('useLearningPlayer', () => { expect(result.current.revealedHintsByStepId['create-web-server']).toBe(1); }); - it('resets revealed hints when a roadmap is reopened', async () => { + it('rehydrates revealed hints from the store on reopen — an empty store means none', async () => { + const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' })); + await openExampleRoadmap(result, fetchMock); + + act(() => result.current.revealNextHint()); + await openExampleRoadmap(result, fetchMock); + + expect(result.current.revealedHintsByStepId).toEqual({}); + }); + + it('pushes the absolute revealed count to the progress endpoint', async () => { const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' })); await openExampleRoadmap(result, fetchMock); act(() => result.current.revealNextHint()); + act(() => result.current.revealNextHint()); + + expect(fetchMock).toHaveBeenLastCalledWith( + expect.stringContaining(`/api/learning/progress/p1/${roadmap.id}/hints`), + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ stepId: 'create-web-server', revealedHints: 2 }), + }) + ); + }); + + it('never blocks a reveal on a failed push — the local count still advances', async () => { + const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' })); await openExampleRoadmap(result, fetchMock); + fetchMock.mockRejectedValueOnce(new Error('network down')); + await act(async () => { + result.current.revealNextHint(); + }); + + expect(result.current.revealedHintsByStepId['create-web-server']).toBe(1); + }); + }); + + describe('progress hydration', () => { + it('restores completed steps and revealed hints, and opens on the first incomplete step', async () => { + const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' })); + await openExampleRoadmap(result, fetchMock, { + ...emptyProgress, + steps: { + 'create-web-server': { passed: true, attempts: 2, revealedHints: 1 }, + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining(`/api/learning/progress/p1/${roadmap.id}`) + ); + expect(result.current.completedStepIds).toEqual({ 'create-web-server': true }); + expect(result.current.revealedHintsByStepId).toEqual({ 'create-web-server': 1 }); + expect(result.current.currentStepIndex).toBe(1); + expect(result.current.resultsByStepId).toEqual({}); + }); + + it('opens on the last step when every step is already completed', async () => { + const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' })); + await openExampleRoadmap(result, fetchMock, { + ...emptyProgress, + steps: { + 'create-web-server': { passed: true, attempts: 1, revealedHints: 0 }, + 'add-database': { passed: true, attempts: 1, revealedHints: 0 }, + }, + }); + + expect(result.current.currentStepIndex).toBe(1); + expect(result.current.completedStepIds).toEqual({ + 'create-web-server': true, + 'add-database': true, + }); + }); + + it('ignores unknown step ids and clamps revealed hints to the ladder length', async () => { + const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' })); + await openExampleRoadmap(result, fetchMock, { + ...emptyProgress, + steps: { + 'create-web-server': { passed: false, attempts: 1, revealedHints: 99 }, + 'no-such-step': { passed: true, attempts: 1, revealedHints: 2 }, + }, + }); + + // 2 hints + 1 solution = 3 rungs. + expect(result.current.revealedHintsByStepId).toEqual({ 'create-web-server': 3 }); + expect(result.current.completedStepIds).toEqual({}); + expect(result.current.currentStepIndex).toBe(0); + }); + + it('opens fresh on step 1 when the progress endpoint is unreachable', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(true, roadmap)); + fetchMock.mockRejectedValueOnce(new Error('network down')); + + const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' })); + await act(async () => { + await result.current.openRoadmap({ id: roadmap.id, language: 'en' }); + }); + + expect(result.current.roadmap).toEqual(roadmap); + expect(result.current.currentStepIndex).toBe(0); + expect(result.current.completedStepIds).toEqual({}); + }); + + it('raises the recovery notice when the store had to be discarded, dismissible', async () => { + const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' })); + await openExampleRoadmap(result, fetchMock, { ...emptyProgress, storeRecovered: true }); + + expect(result.current.progressNotice).toBe(true); + + act(() => result.current.dismissProgressNotice()); + expect(result.current.progressNotice).toBe(false); + }); + }); + + describe('resetProgress', () => { + it('deletes the stored progress and restarts the roadmap from step 1', async () => { + const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' })); + await openExampleRoadmap(result, fetchMock, { + ...emptyProgress, + steps: { 'create-web-server': { passed: true, attempts: 1, revealedHints: 2 } }, + }); + expect(result.current.currentStepIndex).toBe(1); + + fetchMock.mockResolvedValueOnce(jsonResponse(true, {})); + await act(async () => { + await result.current.resetProgress(); + }); + + expect(fetchMock).toHaveBeenLastCalledWith( + expect.stringContaining(`/api/learning/progress/p1/${roadmap.id}`), + expect.objectContaining({ method: 'DELETE' }) + ); + expect(result.current.currentStepIndex).toBe(0); + expect(result.current.completedStepIds).toEqual({}); expect(result.current.revealedHintsByStepId).toEqual({}); + expect(result.current.resultsByStepId).toEqual({}); + expect(result.current.resetError).toBeNull(); + }); + + it('keeps the state and surfaces an error when the reset fails', async () => { + const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' })); + await openExampleRoadmap(result, fetchMock, { + ...emptyProgress, + steps: { 'create-web-server': { passed: true, attempts: 1, revealedHints: 0 } }, + }); + + fetchMock.mockRejectedValueOnce(new Error('network down')); + await act(async () => { + await result.current.resetProgress(); + }); + + expect(result.current.resetError).toBe(''); + expect(result.current.completedStepIds).toEqual({ 'create-web-server': true }); + expect(result.current.resetting).toBe(false); }); }); @@ -288,6 +451,8 @@ describe('useLearningPlayer', () => { expect(result.current.roadmap).toBeNull(); expect(result.current.resultsByStepId).toEqual({}); expect(result.current.revealedHintsByStepId).toEqual({}); + expect(result.current.completedStepIds).toEqual({}); + expect(result.current.progressNotice).toBe(false); expect(result.current.currentStepIndex).toBe(0); }); }); diff --git a/frontend/src/features/learning/hooks/useLearningPlayer.ts b/frontend/src/features/learning/hooks/useLearningPlayer.ts index 68785da..137d078 100644 --- a/frontend/src/features/learning/hooks/useLearningPlayer.ts +++ b/frontend/src/features/learning/hooks/useLearningPlayer.ts @@ -3,8 +3,10 @@ import { API_BASE } from '../../../shared/types'; import { readErrorMessage } from '../../../shared/utils/readErrorMessage'; import type { Roadmap, + RoadmapProgressResponse, RoadmapStep, RoadmapSummary, + StepProgress, StepValidationResponse, } from '../../../shared/types/roadmap'; @@ -12,10 +14,21 @@ interface UseLearningPlayerOptions { projectId: string; } +/** Revealed rungs on a step's hint ladder [...hints, solution?]. */ +function ladderLength(step: RoadmapStep): number { + return (step.hints?.length ?? 0) + (step.solution ? 1 : 0); +} + /** * State of one roadmap play-through: the opened roadmap, the current step, - * and the validation results per step. Everything lives in memory — losing - * it on reload is the accepted P-1 scope; persistence is P-4. + * and the validation results per step. + * + * Progression (completed steps, revealed hints, attempts) is persisted by the + * backend per (project, roadmap) and survives restarts: validation attempts + * are recorded server-side by POST /validate itself, hint reveals are pushed + * here fire-and-forget, and openRoadmap hydrates everything back. Validator + * results are the exception — they describe a past container state, so they + * are session-only and a restored completed step just shows its ✓ marker. * * Error fields hold the server-provided message when there is one, or '' * for a generic failure (network down) — the component falls back to an @@ -29,11 +42,15 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) { const [validating, setValidating] = useState(false); const [validationError, setValidationError] = useState(null); const [resultsByStepId, setResultsByStepId] = useState>({}); - // Count of revealed rungs on the step's hint ladder [...hints, solution?]. - // Kept across step navigation (a revealed hint stays revealed for the whole - // session), reset when a roadmap is opened or closed — same lifecycle as - // resultsByStepId, and the natural seam for P-4 to persist later. const [revealedHintsByStepId, setRevealedHintsByStepId] = useState>({}); + // Steps whose latest recorded validation passed — hydrated from persisted + // progress on open. Session results take display precedence over it. + const [completedStepIds, setCompletedStepIds] = useState>({}); + // True when the backend had to discard an unreadable progress store: the + // learner must be told their saved progression was lost, not left guessing. + const [progressNotice, setProgressNotice] = useState(false); + const [resetting, setResetting] = useState(false); + const [resetError, setResetError] = useState(null); // React state updates are async, so `validating` alone cannot stop two // synchronous clicks — the ref is the actual double-submit guard. const validatingRef = useRef(false); @@ -43,6 +60,12 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) { const currentStep: RoadmapStep | null = roadmap?.steps[currentStepIndex] ?? null; + const progressUrl = useCallback( + (roadmapId: string) => + `${API_BASE}/api/learning/progress/${encodeURIComponent(projectId)}/${encodeURIComponent(roadmapId)}`, + [projectId] + ); + const openRoadmap = useCallback( async ({ id, language }: Pick) => { const seq = ++openSeqRef.current; @@ -63,11 +86,49 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) { setRoadmapError(''); return; } + + // Hydrate persisted progress before committing anything, so the + // player opens directly on the restored step — no blank-state flash. + // Progress is a convenience: if it can't be read, open fresh anyway. + let progressSteps: Record = {}; + let recovered = false; + try { + const progressRes = await fetch(progressUrl(data.id)); + if (progressRes.ok) { + const progress: RoadmapProgressResponse = await progressRes.json(); + progressSteps = progress.steps ?? {}; + recovered = progress.storeRecovered === true; + } else { + console.error('Failed to load roadmap progress: HTTP', progressRes.status); + } + } catch (err) { + console.error('Failed to load roadmap progress:', err); + } + if (seq !== openSeqRef.current) return; + + const steps = data.steps as RoadmapStep[]; + const completed: Record = {}; + const revealed: Record = {}; + // Walking the roadmap's steps (not the stored keys) drops progress of + // step ids that no longer exist in the file. + for (const step of steps) { + const stepProgress = progressSteps[step.id]; + if (!stepProgress) continue; + if (stepProgress.passed) completed[step.id] = true; + if (stepProgress.revealedHints > 0) { + revealed[step.id] = Math.min(stepProgress.revealedHints, ladderLength(step)); + } + } + const firstIncomplete = steps.findIndex(step => !completed[step.id]); + setRoadmap(data); - setCurrentStepIndex(0); + setCurrentStepIndex(firstIncomplete === -1 ? steps.length - 1 : firstIncomplete); setResultsByStepId({}); - setRevealedHintsByStepId({}); + setRevealedHintsByStepId(revealed); + setCompletedStepIds(completed); + setProgressNotice(recovered); setValidationError(null); + setResetError(null); } catch (err) { console.error('Failed to load roadmap:', err); if (seq === openSeqRef.current) setRoadmapError(''); @@ -75,7 +136,7 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) { if (seq === openSeqRef.current) setRoadmapLoading(false); } }, - [] + [progressUrl] ); const closeRoadmap = useCallback(() => { @@ -84,7 +145,10 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) { setCurrentStepIndex(0); setResultsByStepId({}); setRevealedHintsByStepId({}); + setCompletedStepIds({}); + setProgressNotice(false); setValidationError(null); + setResetError(null); }, []); const goToStep = useCallback( @@ -93,20 +157,25 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) { setCurrentStepIndex(Math.max(0, Math.min(index, roadmap.steps.length - 1))); // A transport error is about the attempt, not the step; results stay. setValidationError(null); + setResetError(null); }, [roadmap] ); const revealNextHint = useCallback(() => { - if (!currentStep) return; + if (!roadmap || !currentStep) return; // Sequential reveal only: one more rung per call, clamped to the ladder // length so a stray extra click can never jump past the solution. - const totalRungs = (currentStep.hints?.length ?? 0) + (currentStep.solution ? 1 : 0); - setRevealedHintsByStepId(prev => ({ - ...prev, - [currentStep.id]: Math.min((prev[currentStep.id] ?? 0) + 1, totalRungs), - })); - }, [currentStep]); + const next = Math.min((revealedHintsByStepId[currentStep.id] ?? 0) + 1, ladderLength(currentStep)); + setRevealedHintsByStepId(prev => ({ ...prev, [currentStep.id]: next })); + // Fire-and-forget, absolute count: a reveal must never block on the + // network, and a lost write self-heals on the next one. + fetch(`${progressUrl(roadmap.id)}/hints`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ stepId: currentStep.id, revealedHints: next }), + }).catch(err => console.error('Failed to save revealed hints:', err)); + }, [roadmap, currentStep, revealedHintsByStepId, progressUrl]); const validateCurrentStep = useCallback(async () => { if (validatingRef.current || !roadmap || !currentStep) return; @@ -136,6 +205,33 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) { } }, [projectId, roadmap, currentStep]); + /** Forgets this roadmap's persisted progress and restarts it from step 1. */ + const resetProgress = useCallback(async () => { + if (resetting || !roadmap) return; + try { + setResetting(true); + setResetError(null); + const res = await fetch(progressUrl(roadmap.id), { method: 'DELETE' }); + if (!res.ok) { + setResetError(await readErrorMessage(res, '')); + return; + } + setCurrentStepIndex(0); + setResultsByStepId({}); + setRevealedHintsByStepId({}); + setCompletedStepIds({}); + setProgressNotice(false); + setValidationError(null); + } catch (err) { + console.error('Failed to reset roadmap progress:', err); + setResetError(''); + } finally { + setResetting(false); + } + }, [resetting, roadmap, progressUrl]); + + const dismissProgressNotice = useCallback(() => setProgressNotice(false), []); + return { roadmap, roadmapLoading, @@ -151,5 +247,11 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) { validateCurrentStep, revealedHintsByStepId, revealNextHint, + completedStepIds, + progressNotice, + dismissProgressNotice, + resetProgress, + resetting, + resetError, }; } diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index c89756b..fc00470 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -51,7 +51,12 @@ "showHint": "Show hint ({{n}}/{{total}})", "solutionLabel": "Solution", "showSolution": "Reveal the solution", - "confirmSolution": "Sure? Click again to reveal" + "confirmSolution": "Sure? Click again to reveal", + "resetProgress": "Restart roadmap", + "resetProgressConfirm": "Sure? Click again to restart", + "resetProgressError": "Could not reset the progress. Nothing was changed — try again.", + "progressRecovered": "Your saved progress could not be read and had to be reset. Sorry — this roadmap starts fresh.", + "dismissNotice": "Dismiss" } }, "projects": { diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 0ae27fb..15fd948 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -51,7 +51,12 @@ "showHint": "Voir un indice ({{n}}/{{total}})", "solutionLabel": "Solution", "showSolution": "Révéler la solution", - "confirmSolution": "Sûr ? Cliquez à nouveau pour révéler" + "confirmSolution": "Sûr ? Cliquez à nouveau pour révéler", + "resetProgress": "Recommencer la roadmap", + "resetProgressConfirm": "Sûr ? Cliquez à nouveau pour recommencer", + "resetProgressError": "Impossible de réinitialiser la progression. Rien n'a été modifié — réessayez.", + "progressRecovered": "Votre progression sauvegardée était illisible et a dû être réinitialisée. Désolé — cette roadmap repart de zéro.", + "dismissNotice": "Fermer" } }, "projects": { diff --git a/frontend/src/shared/types/roadmap.ts b/frontend/src/shared/types/roadmap.ts index bcfa108..9b326fc 100644 --- a/frontend/src/shared/types/roadmap.ts +++ b/frontend/src/shared/types/roadmap.ts @@ -5,7 +5,8 @@ * format types: backend/src/modules/learning/format/roadmapTypes.ts * API types: backend/src/modules/learning/engine/types.ts (ValidatorResult), * controllers/learningController.ts (StepValidationResponse), - * services/roadmapService.ts (RoadmapSummary). + * services/roadmapService.ts (RoadmapSummary), + * services/progressService.ts (StepProgress, RoadmapProgressResponse). * The duplication is deliberate: backend and frontend are separate npm * packages (same policy as the Project type in shared/types/index.ts). * Source of truth for the format is the JSON Schema: @@ -96,3 +97,24 @@ export interface StepValidationResponse { /** ISO timestamp of the evaluation. */ checkedAt: string; } + +/** Persisted progress of one roadmap step, keyed by the step's stable id. */ +export interface StepProgress { + /** Verdict of the latest validation — same latest-wins semantics as the player. */ + passed: boolean; + /** Number of validation runs that reached evaluation. */ + attempts: number; + /** Revealed rungs on the step's hint ladder [...hints, solution?] — an absolute count. */ + revealedHints: number; + /** ISO timestamp of the latest validation, absent until the first one. */ + lastCheckedAt?: string; +} + +/** Response of GET /api/learning/progress/:projectId/:roadmapId. */ +export interface RoadmapProgressResponse { + projectId: string; + roadmapId: string; + steps: Record; + /** Present (true) once after an unreadable store was moved aside — tell the user. */ + storeRecovered?: boolean; +}