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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,31 @@ describe('LearningController', () => {
});
});

describe('GET /api/learning/progress', () => {
it('returns every play-through summary wrapped in entries', async () => {
const summaries = [
{ projectId: 'project-1', roadmapId: 'roadmap-1', updatedAt: '2026-07-16T10:00:00.000Z', completedSteps: 3 },
];
(ProgressService.listProgress as jest.Mock).mockReturnValue(summaries);

const res = await request(app).get('/api/learning/progress');

expect(res.status).toBe(200);
expect(res.body).toEqual({ entries: summaries });
});

it('returns 500 when the service throws', async () => {
(ProgressService.listProgress as jest.Mock).mockImplementation(() => {
throw new Error('disk exploded');
});

const res = await request(app).get('/api/learning/progress');

expect(res.status).toBe(500);
expect(res.body.error).toBe('disk exploded');
});
});

describe('GET /api/learning/progress/:projectId/:roadmapId', () => {
it('returns the stored progress for the pair', async () => {
const progress = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ export class LearningController {
}
}

public static async listProgress(req: Request, res: Response): Promise<void> {
try {
res.json({ entries: ProgressService.listProgress() });
} catch (err: unknown) {
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
}
}

public static async getProgress(req: Request, res: Response): Promise<void> {
try {
res.json(
Expand Down
1 change: 1 addition & 0 deletions backend/src/modules/learning/routes/learningRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const router = Router();
router.get('/roadmaps', LearningController.listRoadmaps);
router.get('/roadmaps/:id', LearningController.getRoadmap);
router.post('/validate', LearningController.validate);
router.get('/progress', LearningController.listProgress);
router.get('/progress/:projectId/:roadmapId', LearningController.getProgress);
router.put('/progress/:projectId/:roadmapId/hints', LearningController.recordRevealedHints);
router.delete('/progress/:projectId/:roadmapId', LearningController.resetProgress);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"schemaVersion": 1,
"id": "zz-unlisted-roadmap",
"title": "Unlisted roadmap",
"description": "Sorts first by file name, last by id: proves the catalogue orders on ids.",
"language": "en",
"estimatedMinutes": 5,
"difficulty": "beginner",
"steps": [
{
"id": "start-web",
"title": "Start the web server",
"instruction": "Create a node named `web` and start it.",
"validators": [
{
"type": "container_running",
"params": { "node": "web" }
}
]
}
]
}
32 changes: 32 additions & 0 deletions backend/src/modules/learning/services/progressService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,38 @@ describe('ProgressService', () => {
expect(ProgressService.getProgress('project-2', 'roadmap-1', file).steps).not.toEqual({});
});

describe('listProgress', () => {
it('returns an empty list when no store exists', () => {
expect(ProgressService.listProgress(file)).toEqual([]);
expect(fs.existsSync(file)).toBe(false);
});

it('summarizes every entry with its count of passed steps', () => {
ProgressService.recordValidation('project-1', 'roadmap-1', 'step-a', true, '2026-07-16T10:00:00.000Z', file);
ProgressService.recordValidation('project-1', 'roadmap-1', 'step-b', false, '2026-07-16T10:01:00.000Z', file);
ProgressService.recordValidation('project-1', 'roadmap-1', 'step-c', true, '2026-07-16T10:02:00.000Z', file);
ProgressService.recordValidation('project-2', 'roadmap-2', 'step-a', false, '2026-07-16T10:03:00.000Z', file);

const summaries = ProgressService.listProgress(file);

expect(summaries).toHaveLength(2);
expect(summaries[0]).toMatchObject({ projectId: 'project-1', roadmapId: 'roadmap-1', completedSteps: 2 });
expect(summaries[0].updatedAt).not.toBe('');
expect(summaries[1]).toMatchObject({ projectId: 'project-2', roadmapId: 'roadmap-2', completedSteps: 0 });
});

it('does not consume the one-shot storeRecovered flag reserved for getProgress', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
fs.writeFileSync(file, 'not json at all {');

expect(ProgressService.listProgress(file)).toEqual([]);

// The recovery notice must still reach the player's next per-pair read.
expect(ProgressService.getProgress('project-1', 'roadmap-1', file).storeRecovered).toBe(true);
errorSpy.mockRestore();
});
});

describe('unreadable store recovery', () => {
let errorSpy: jest.SpyInstance;

Expand Down
24 changes: 24 additions & 0 deletions backend/src/modules/learning/services/progressService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ interface ProgressStore {
entries: ProgressEntry[];
}

/** One item of GET /api/learning/progress — documented in docs/learning-api.md. */
export interface ProgressEntrySummary {
projectId: string;
roadmapId: string;
updatedAt: string;
/** Count of steps whose latest validation passed. */
completedSteps: number;
}

/** Contract of GET /api/learning/progress/:projectId/:roadmapId — documented in docs/learning-api.md. */
export interface RoadmapProgressResponse {
projectId: string;
Expand Down Expand Up @@ -83,6 +92,21 @@ export class ProgressService {
return response;
}

/**
* Summarizes every play-through in the store, for surfaces that show
* progress across projects (e.g. the landing page's roadmap cards).
* Deliberately leaves the one-shot `storeRecovered` flag untouched — that
* notice belongs to the player's getProgress, which can act on it.
*/
public static listProgress(filePath: string = PROGRESS_PATH): ProgressEntrySummary[] {
return this.readStore(filePath).entries.map(entry => ({
projectId: entry.projectId,
roadmapId: entry.roadmapId,
updatedAt: entry.updatedAt,
completedSteps: Object.values(entry.steps).filter(step => step.passed).length,
}));
}

public static recordValidation(
projectId: string,
roadmapId: string,
Expand Down
28 changes: 27 additions & 1 deletion backend/src/modules/learning/services/roadmapService.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import path from 'path';
import { RoadmapService } from './roadmapService';
import { CURATED_ROADMAP_ORDER, RoadmapService } from './roadmapService';

const FIXTURES_DIR = path.resolve(__dirname, '__fixtures__/roadmaps');

Expand Down Expand Up @@ -34,9 +34,35 @@ describe('RoadmapService', () => {
estimatedMinutes: 10,
stepCount: 2,
},
{
id: 'zz-unlisted-roadmap',
title: 'Unlisted roadmap',
description: 'Sorts first by file name, last by id: proves the catalogue orders on ids.',
language: 'en',
difficulty: 'beginner',
estimatedMinutes: 5,
stepCount: 1,
},
]);
});

it('orders roadmaps outside the curated list by id, not by file name', () => {
const ids = RoadmapService.listRoadmaps(FIXTURES_DIR).map(summary => summary.id);

// a-unlisted-id.json holds `zz-unlisted-roadmap`: file name first, id last.
expect(ids).toEqual(['fixture-roadmap', 'fixture-roadmap', 'zz-unlisted-roadmap']);
});

it('opens the shipped catalogue on the curated order — the first entry is what a first-run user is pitched', () => {
const summaries = RoadmapService.listRoadmaps();
const curated = summaries.map(s => s.id).filter(id => CURATED_ROADMAP_ORDER.includes(id));
// Translations share an id and sit next to each other: collapse the runs.
const distinct = curated.filter((id, index) => id !== curated[index - 1]);

expect(summaries[0].id).toBe(CURATED_ROADMAP_ORDER[0]);
expect(distinct).toEqual(CURATED_ROADMAP_ORDER.filter(id => curated.includes(id)));
});

it('skips invalid roadmap files and warns with the file name', () => {
RoadmapService.listRoadmaps(FIXTURES_DIR);

Expand Down
44 changes: 35 additions & 9 deletions backend/src/modules/learning/services/roadmapService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,22 @@ export interface RoadmapSummary {
// services/ → learning → modules → src|dist → backend → repo root.
const ROADMAPS_DIR = path.resolve(__dirname, '../../../../../roadmaps');

/**
* The catalogue is a suggested path, not a directory listing: this is the order
* the shipped roadmaps are meant to be taken in, and its first entry is the one
* a first-run user is pitched on the landing page. Without it the order would
* fall back to file names — alphabetical, which pitches whichever roadmap
* happens to sort first.
*
* Roadmaps that are not listed here (community files dropped into `roadmaps/`)
* follow, ordered by id. Ids that no longer exist are simply ignored.
*/
export const CURATED_ROADMAP_ORDER = [
'resilient-three-tier',
'cache-aside-redis',
'redis-queue-workers',
];

/**
* Loads roadmap files (format v1) from the roadmaps/ directory.
*
Expand All @@ -29,15 +45,25 @@ const ROADMAPS_DIR = path.resolve(__dirname, '../../../../../roadmaps');
*/
export class RoadmapService {
public static listRoadmaps(dir: string = ROADMAPS_DIR): RoadmapSummary[] {
return this.readAll(dir).map(roadmap => ({
id: roadmap.id,
title: roadmap.title,
description: roadmap.description,
language: roadmap.language,
difficulty: roadmap.difficulty,
estimatedMinutes: roadmap.estimatedMinutes,
stepCount: roadmap.steps.length,
}));
return this.readAll(dir)
.map(roadmap => ({
id: roadmap.id,
title: roadmap.title,
description: roadmap.description,
language: roadmap.language,
difficulty: roadmap.difficulty,
estimatedMinutes: roadmap.estimatedMinutes,
stepCount: roadmap.steps.length,
}))
// Curated order first, then unlisted ids by id. Translations of one id
// compare equal and keep the file-name order readAll() guarantees, so
// the whole list stays deterministic.
.sort((a, b) => this.catalogueRank(a.id) - this.catalogueRank(b.id) || a.id.localeCompare(b.id));
}

private static catalogueRank(id: string): number {
const index = CURATED_ROADMAP_ORDER.indexOf(id);
return index === -1 ? CURATED_ROADMAP_ORDER.length : index;
}

/**
Expand Down
6 changes: 6 additions & 0 deletions docs/learning-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ This document is the contract for API consumers (the roadmap player in the front

Lists the available roadmaps as summaries — **one entry per file**. Translations of the same roadmap share an `id` and differ by `language` (see the format's language model), so they appear as separate catalogue entries; a selection UI should surface the `language` field.

The list is a **suggested path, not a directory listing**: the roadmaps shipped with Torollo come first, in the order they are meant to be taken (`CURATED_ROADMAP_ORDER` in `roadmapService.ts`), and the rest — roadmaps you dropped into `roadmaps/` yourself — follow, ordered by `id`. Clients can rely on that order: the app pitches the first entry to a first-run user.

```json
[
{
Expand Down Expand Up @@ -113,6 +115,10 @@ Roadmap progression is persisted locally in `~/.torollo/progress.json`, next to

`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`

Returns `{ "entries": [ { "projectId", "roadmapId", "updatedAt", "completedSteps" } ] }` — one summary per `(projectId, roadmapId)` play-through in the store, where `completedSteps` counts the steps whose latest validation passed. Used by surfaces that show progress across projects (e.g. the landing page's roadmap cards, which keep the most recent entry per roadmap). This endpoint never emits `storeRecovered` — that one-shot notice is reserved for the per-pair read below.

### `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.
Expand Down
18 changes: 13 additions & 5 deletions frontend/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useState } from 'react';
import CanvasPage from '../pages/CanvasPage/CanvasPage';
import TerminalModal from '../features/terminal/components/TerminalModal';
import ProjectsPage from '../pages/ProjectsPage/ProjectsPage';
import type { ProjectInfo, TerminalInfo } from '../shared/types';
import type { LearningIntent, ProjectInfo, TerminalInfo } from '../shared/types';

function App() {
const [activeProject, setActiveProject] = useState<ProjectInfo | null>(() => {
Expand All @@ -14,6 +14,9 @@ function App() {
}
});
const [activeTerminal, setActiveTerminal] = useState<TerminalInfo | null>(null);
// Session-only, deliberately not persisted: a reload must never replay
// an "open the learning panel" intent from a past navigation.
const [learningIntent, setLearningIntent] = useState<LearningIntent | null>(null);

const handleSelectProject = (project: ProjectInfo | null) => {
setActiveProject(project);
Expand All @@ -27,18 +30,23 @@ function App() {
return (
<div style={{ height: '100vh', width: '100vw', display: 'flex', flexDirection: 'column' }}>
{!activeProject ? (
<ProjectsPage
onSelectProject={(id, name) => handleSelectProject({ id, name })}
<ProjectsPage
onSelectProject={(id, name, intent) => {
setLearningIntent(intent ?? null);
handleSelectProject({ id, name });
}}
/>
) : (
<CanvasPage
<CanvasPage
projectId={activeProject.id}
projectName={activeProject.name}
initialLearning={learningIntent}
onLearningIntentConsumed={() => setLearningIntent(null)}
onBackToProjects={() => {
handleSelectProject(null);
setActiveTerminal(null);
}}
onTerminalOpen={(id, name) => setActiveTerminal({ id, name })}
onTerminalOpen={(id, name) => setActiveTerminal({ id, name })}
/>
)}

Expand Down
41 changes: 41 additions & 0 deletions frontend/src/features/learning/components/DifficultyChip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { useTranslation } from 'react-i18next';
import type { RoadmapDifficulty } from '../../../shared/types/roadmap';

interface DifficultyChipProps {
difficulty: RoadmapDifficulty;
}

const DIFFICULTY_COLORS: Record<RoadmapDifficulty, string> = {
beginner: 'var(--color-success)',
intermediate: 'var(--color-warning)',
advanced: 'var(--color-danger)',
};

/** Small colored pill for a roadmap's difficulty — green/amber/red semantics. */
export default function DifficultyChip({ difficulty }: DifficultyChipProps) {
const { t } = useTranslation();
const color = DIFFICULTY_COLORS[difficulty];
return (
<span
style={{
...styles.chip,
color,
background: `color-mix(in srgb, ${color} 12%, transparent)`,
}}
>
{t(`learning.catalog.difficulty.${difficulty}`)}
</span>
);
}

const styles: Record<string, React.CSSProperties> = {
chip: {
display: 'inline-flex',
alignItems: 'center',
padding: '2px var(--space-2)',
borderRadius: 'var(--radius-sm)',
fontSize: 'var(--text-xs)',
fontWeight: 600,
whiteSpace: 'nowrap',
},
};
Loading
Loading