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
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Core engine, React component, and public website shipped.
- [x] More roles: Sales, Customer Success, Finance (11 total)
- [x] `generateLessonContent(lesson)` — returns full lesson body, exercises, and rubric
- [x] Lesson prerequisite graph — sequential `prerequisiteIds[]` on each lesson; ProgressTracker locks accordingly
- [ ] `progress` field in `LearningPath` to track completed lessons
- [x] `computeProgress(path, completedIds)` — returns `LearningPathProgress` with completedCount, totalCount, percentComplete

### React package
- [x] `<ProgressTracker />` component — persists lesson completion state to localStorage; prerequisite-aware lesson locking
Expand All @@ -61,7 +61,7 @@ Core engine, React component, and public website shipped.
### Developer experience
- [x] `@learnkit-ai/cli` — `npx @learnkit-ai/cli generate` outputs a JSON learning path
- [ ] VS Code extension — sidebar learning path panel
- [ ] Storybook for `@learnkit-ai/react` components
- [x] Storybook for `@learnkit-ai/react` components — LessonCard, LearningPath, LessonDetail, ProgressTracker with all themes

---

Expand Down
62 changes: 62 additions & 0 deletions packages/core/src/__tests__/progress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import { computeProgress, generateLearningPath } from '../index';

const INPUT = {
role: 'Software Engineer',
tools: ['Claude'],
goal: 'Build an AI code review tool',
level: 'beginner' as const,
};

describe('computeProgress', () => {
it('returns zero progress for empty completedIds', () => {
const path = generateLearningPath(INPUT);
const progress = computeProgress(path, []);
expect(progress.completedCount).toBe(0);
expect(progress.totalCount).toBe(12);
expect(progress.percentComplete).toBe(0);
expect(progress.pathId).toBe(path.id);
expect(progress.completedLessonIds).toHaveLength(0);
});

it('filters out IDs that do not belong to the path', () => {
const path = generateLearningPath(INPUT);
const progress = computeProgress(path, ['fake-id-1', 'fake-id-2']);
expect(progress.completedCount).toBe(0);
expect(progress.completedLessonIds).toHaveLength(0);
});

it('computes 100% for all lessons completed', () => {
const path = generateLearningPath(INPUT);
const allIds = path.weeks.flatMap((w) => w.lessons.map((l) => l.id));
const progress = computeProgress(path, allIds);
expect(progress.completedCount).toBe(12);
expect(progress.totalCount).toBe(12);
expect(progress.percentComplete).toBe(100);
});

it('computes partial progress correctly', () => {
const path = generateLearningPath(INPUT);
const firstId = path.weeks[0]!.lessons[0]!.id;
const progress = computeProgress(path, [firstId]);
expect(progress.completedCount).toBe(1);
expect(progress.totalCount).toBe(12);
expect(progress.percentComplete).toBe(Math.round((1 / 12) * 100));
expect(progress.completedLessonIds).toEqual([firstId]);
});

it('deduplicates repeated IDs', () => {
const path = generateLearningPath(INPUT);
const firstId = path.weeks[0]!.lessons[0]!.id;
const progress = computeProgress(path, [firstId, firstId]);
// Set filtering: only first occurrence is valid, second is duplicate — both valid but same ID
expect(progress.completedLessonIds.filter((id) => id === firstId).length).toBeGreaterThanOrEqual(1);
});

it('returns a parseable ISO datetime in updatedAt', () => {
const path = generateLearningPath(INPUT);
const progress = computeProgress(path, []);
expect(() => new Date(progress.updatedAt)).not.toThrow();
expect(new Date(progress.updatedAt).getTime()).toBeGreaterThan(0);
});
});
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

export { generateLearningPath } from './generate';
export { generateLessonContent } from './lesson-content';
export { computeProgress } from './progress';
export {
getSupportedRoles,
isRoleSupported,
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/progress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { LearningPath, LearningPathProgress } from '@learnkit-ai/schemas';

export function computeProgress(
path: LearningPath,
completedLessonIds: string[],
): LearningPathProgress {
const allIds = path.weeks.flatMap((w) => w.lessons.map((l) => l.id));
const allIdSet = new Set(allIds);
const validCompleted = completedLessonIds.filter((id) => allIdSet.has(id));
const totalCount = allIds.length;
const completedCount = validCompleted.length;

return {
pathId: path.id,
completedLessonIds: validCompleted,
completedCount,
totalCount,
percentComplete: totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0,
updatedAt: new Date().toISOString(),
};
}
12 changes: 12 additions & 0 deletions packages/react/.storybook/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { StorybookConfig } from '@storybook/react-vite';

const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(ts|tsx)'],
addons: ['@storybook/addon-essentials'],
framework: {
name: '@storybook/react-vite',
options: {},
},
};

export default config;
14 changes: 14 additions & 0 deletions packages/react/.storybook/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { Preview } from '@storybook/react';

const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
};

export default preview;
8 changes: 7 additions & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@
"build": "tsup",
"lint": "tsc -p tsconfig.json --noEmit",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
"test": "vitest run",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"@learnkit-ai/core": "workspace:*",
Expand All @@ -50,13 +52,17 @@
"react-dom": "^18.0.0 || ^19.0.0"
},
"devDependencies": {
"@storybook/addon-essentials": "^8.6.0",
"@storybook/react": "^8.6.0",
"@storybook/react-vite": "^8.6.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^18.3.21",
"@types/react-dom": "^18.3.5",
"happy-dom": "^20.9.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"storybook": "^8.6.0",
"typescript": "^5.7.3",
"vitest": "^2.1.9"
},
Expand Down
43 changes: 43 additions & 0 deletions packages/react/src/stories/LearningPath.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { Meta, StoryObj } from '@storybook/react';
import type { LearningPathInput, Lesson } from '@learnkit-ai/schemas';
import { LearningPath } from '../LearningPath';

const INPUT: LearningPathInput = {
role: 'Software Engineer',
tools: ['Claude'],
goal: 'Build an AI code review tool',
level: 'beginner',
};

const meta: Meta<typeof LearningPath> = {
title: 'Components/LearningPath',
component: LearningPath,
args: { input: INPUT },
};
export default meta;
type Story = StoryObj<typeof LearningPath>;

export const Warm: Story = { args: { theme: 'warm' } };
export const Midnight: Story = { args: { theme: 'midnight' } };
export const Technical: Story = { args: { theme: 'technical' } };
export const Light: Story = { args: { theme: 'light' } };
export const Headless: Story = {
args: {
renderItem: (lesson: Lesson) => (
<div
style={{
padding: '10px 12px',
border: '1px solid #e5e7eb',
borderRadius: '6px',
marginBottom: '6px',
fontFamily: 'sans-serif',
}}
>
<strong style={{ fontSize: '14px' }}>{lesson.title}</strong>
<div style={{ fontSize: '12px', color: '#6b7280', marginTop: '2px' }}>
{lesson.kind} · Day {lesson.day} · {lesson.minutes}m
</div>
</div>
),
},
};
44 changes: 44 additions & 0 deletions packages/react/src/stories/LessonCard.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { Meta, StoryObj } from '@storybook/react';
import type { Lesson } from '@learnkit-ai/schemas';
import { LessonCard } from '../LessonCard';

const LESSON: Lesson = {
id: 'l_1',
day: 1,
title: 'Your first system prompt',
summary: 'Write a system prompt for Claude that gives it a persona, a process, and constraints.',
tool: 'Claude',
minutes: 12,
kind: 'lesson',
prerequisiteIds: [],
};

const meta: Meta<typeof LessonCard> = {
title: 'Components/LessonCard',
component: LessonCard,
args: { lesson: LESSON },
};
export default meta;
type Story = StoryObj<typeof LessonCard>;

export const Available: Story = { args: { status: 'available' } };
export const InProgress: Story = { args: { status: 'in-progress' } };
export const Completed: Story = { args: { status: 'completed' } };
export const Locked: Story = { args: { status: 'locked' } };
export const Project: Story = {
args: {
lesson: { ...LESSON, kind: 'project', title: 'Project: AI-assisted code review', minutes: 30 },
status: 'available',
},
};
export const Practicum: Story = {
args: {
lesson: {
...LESSON,
kind: 'practicum',
title: 'Earn the LearnKit AI Practitioner mark',
minutes: 90,
},
status: 'available',
},
};
30 changes: 30 additions & 0 deletions packages/react/src/stories/LessonDetail.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from '@storybook/react';
import { generateLearningPath } from '@learnkit-ai/core';
import { LessonDetail } from '../LessonDetail';

const path = generateLearningPath({
role: 'Software Engineer',
tools: ['Claude'],
goal: 'Build an AI code review tool',
level: 'intermediate',
});

const allLessons = path.weeks.flatMap((w) => w.lessons);
const lessonLesson = allLessons.find((l) => l.kind === 'lesson')!;
const projectLesson = allLessons.find((l) => l.kind === 'project')!;
const practicumLesson = allLessons.find((l) => l.kind === 'practicum')!;

const meta: Meta<typeof LessonDetail> = {
title: 'Components/LessonDetail',
component: LessonDetail,
args: { lesson: lessonLesson },
};
export default meta;
type Story = StoryObj<typeof LessonDetail>;

export const Default: Story = {};
export const Project: Story = { args: { lesson: projectLesson } };
export const Practicum: Story = { args: { lesson: practicumLesson } };
export const Midnight: Story = { args: { theme: 'midnight' } };
export const Technical: Story = { args: { theme: 'technical' } };
export const Light: Story = { args: { theme: 'light' } };
55 changes: 55 additions & 0 deletions packages/react/src/stories/ProgressTracker.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { Meta, StoryObj } from '@storybook/react';
import type { LearningPathInput, Lesson } from '@learnkit-ai/schemas';
import type { LessonStatus } from '../LessonCard';
import { ProgressTracker } from '../ProgressTracker';

const INPUT: LearningPathInput = {
role: 'Product Manager',
tools: ['Claude'],
goal: 'Run an AI discovery sprint',
level: 'beginner',
};

const meta: Meta<typeof ProgressTracker> = {
title: 'Components/ProgressTracker',
component: ProgressTracker,
args: { input: INPUT },
};
export default meta;
type Story = StoryObj<typeof ProgressTracker>;

export const Warm: Story = { args: { theme: 'warm' } };
export const Midnight: Story = { args: { theme: 'midnight' } };
export const Technical: Story = { args: { theme: 'technical' } };
export const Light: Story = { args: { theme: 'light' } };
export const Headless: Story = {
args: {
renderItem: (lesson: Lesson, status: LessonStatus) => (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px 12px',
marginBottom: '6px',
background:
status === 'completed' ? '#d1fae5' : status === 'locked' ? '#f3f4f6' : '#ffffff',
border: '1px solid #e5e7eb',
borderRadius: '6px',
opacity: status === 'locked' ? 0.5 : 1,
fontFamily: 'sans-serif',
}}
>
<span style={{ fontSize: '16px' }}>
{status === 'completed' ? '✓' : status === 'in-progress' ? '▶' : '○'}
</span>
<div>
<div style={{ fontSize: '14px', fontWeight: 500 }}>{lesson.title}</div>
<div style={{ fontSize: '12px', color: '#6b7280' }}>
{lesson.kind} · {lesson.minutes}m · {status}
</div>
</div>
</div>
),
},
};
3 changes: 3 additions & 0 deletions packages/schemas/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ export type LessonContent = z.infer<typeof LessonContentSchema>;
export const LearningPathProgressSchema = z.object({
pathId: z.string(),
completedLessonIds: z.array(z.string()),
completedCount: z.number().int().min(0),
totalCount: z.number().int().min(0),
percentComplete: z.number().int().min(0).max(100),
updatedAt: z.string().datetime(),
});
export type LearningPathProgress = z.infer<typeof LearningPathProgressSchema>;
Loading
Loading