From 42517606948555c0acf58381df21442628b32c2c Mon Sep 17 00:00:00 2001 From: raj-khan Date: Sun, 24 May 2026 20:24:53 +0800 Subject: [PATCH 1/2] Add v1 polish: LessonDetail, light theme, renderItem, OG images, prerequisite graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LessonDetail: new React component renders lesson body + exercises + rubric light theme: 4th theme variant for LearningPath and ProgressTracker (indigo/pink/emerald) renderItem: render-prop on LearningPath and ProgressTracker for headless usage Lesson prerequisite graph: sequential prerequisiteIds[] on every Lesson; ProgressTracker locks lessons whose prerequisites are not yet effectively complete opengraph-image: dynamic ImageResponse for /roles/[slug] (warm) and /tools/[slug] (dark) LessonPreview in demo now renders real lesson content from generateLessonContent() DemoFlow updated with new roles (Sales, Customer Success, Finance) and tools (Windsurf, etc.) 33 core tests, 39 react tests — all passing --- ROADMAP.md | 12 +- .../src/app/roles/[slug]/opengraph-image.tsx | 121 +++++++++ .../src/app/tools/[slug]/opengraph-image.tsx | 144 ++++++++++ apps/web/src/components/demo/DemoFlow.tsx | 10 +- .../web/src/components/demo/LessonPreview.tsx | 197 ++++++-------- packages/core/src/__tests__/generate.test.ts | 25 ++ packages/core/src/generate.ts | 16 +- packages/react/src/LearningPath.tsx | 50 ++-- packages/react/src/LessonDetail.tsx | 251 ++++++++++++++++++ packages/react/src/ProgressTracker.tsx | 55 ++-- .../react/src/__tests__/LearningPath.test.tsx | 19 +- .../react/src/__tests__/LessonCard.test.tsx | 1 + .../react/src/__tests__/LessonDetail.test.tsx | 70 +++++ .../src/__tests__/ProgressTracker.test.tsx | 34 ++- packages/react/src/index.ts | 1 + packages/schemas/src/index.ts | 1 + 16 files changed, 850 insertions(+), 157 deletions(-) create mode 100644 apps/web/src/app/roles/[slug]/opengraph-image.tsx create mode 100644 apps/web/src/app/tools/[slug]/opengraph-image.tsx create mode 100644 packages/react/src/LessonDetail.tsx create mode 100644 packages/react/src/__tests__/LessonDetail.test.tsx diff --git a/ROADMAP.md b/ROADMAP.md index ecdc440..e19255c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -43,20 +43,20 @@ Core engine, React component, and public website shipped. - [x] More supported tools: Windsurf, Replit, Linear, Figma AI, v0 (13 total) - [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 -- [ ] Lesson prerequisite graph — reorder-aware sequencing ### React package -- [x] `` component — persists lesson completion state to localStorage -- [ ] `` — renders full lesson body returned by `generateLessonContent()` -- [ ] `light` theme variant (in addition to `warm`, `midnight`, `technical`) -- [ ] Headless mode: all components accept `renderItem` render-prop overrides +- [x] `` component — persists lesson completion state to localStorage; prerequisite-aware lesson locking +- [x] `` — renders full lesson body, exercises, and rubric from `generateLessonContent()` +- [x] `light` theme variant (in addition to `warm`, `midnight`, `technical`) +- [x] Headless mode: `renderItem` render-prop on `LearningPath` and `ProgressTracker` ### Web - [x] `/changelog` page — versioned release notes - [x] `/compare/[slug]` pages — Claude vs ChatGPT, Cursor vs Copilot, Windsurf vs Cursor, and more - [x] `/guides/[slug]` — Next.js integration, theming, CLI, generateLessonContent guides -- [ ] Per-role `opengraph-image` for `/roles/[slug]` pages +- [x] `opengraph-image` for `/roles/[slug]` and `/tools/[slug]` pages ### Developer experience - [x] `@learnkit-ai/cli` — `npx @learnkit-ai/cli generate` outputs a JSON learning path diff --git a/apps/web/src/app/roles/[slug]/opengraph-image.tsx b/apps/web/src/app/roles/[slug]/opengraph-image.tsx new file mode 100644 index 0000000..e40e1cc --- /dev/null +++ b/apps/web/src/app/roles/[slug]/opengraph-image.tsx @@ -0,0 +1,121 @@ +import { ImageResponse } from 'next/og'; +import { ROLES } from '@/lib/seo-data'; + +export const runtime = 'edge'; +export const size = { width: 1200, height: 630 }; +export const contentType = 'image/png'; + +export default async function Image({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const role = ROLES.find((r) => r.slug === slug); + if (!role) return new Response('Not found', { status: 404 }); + + return new ImageResponse( + ( +
+ {/* Top: brand */} +
+ LearnKit AI +
+ + {/* Middle: role name + tagline */} +
+
+ 30-day AI learning path +
+
+ AI training for +
+ {role.name}s. +
+
+ {role.blurb} +
+
+ + {/* Bottom: stats + tools */} +
+
+ 4 weeks + 12 lessons + Apache-2.0 +
+
+ learnkit-ai.com +
+
+
+ ), + { ...size }, + ); +} diff --git a/apps/web/src/app/tools/[slug]/opengraph-image.tsx b/apps/web/src/app/tools/[slug]/opengraph-image.tsx new file mode 100644 index 0000000..4536f3e --- /dev/null +++ b/apps/web/src/app/tools/[slug]/opengraph-image.tsx @@ -0,0 +1,144 @@ +import { ImageResponse } from 'next/og'; +import { TOOLS } from '@/lib/seo-data'; + +export const runtime = 'edge'; +export const size = { width: 1200, height: 630 }; +export const contentType = 'image/png'; + +export default async function Image({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + const tool = TOOLS.find((t) => t.slug === slug); + if (!tool) return new Response('Not found', { status: 404 }); + + return new ImageResponse( + ( +
+ {/* Top: brand */} +
+ LearnKit AI +
+ + {/* Middle: tool name + tagline */} +
+
+
+ {tool.name[0]} +
+
+ {tool.vendor} +
+
+
+ {tool.tagline}. +
+
+ {tool.blurb} +
+
+ + {/* Bottom: stats */} +
+
+ {tool.modules} modules + ~{tool.hours}h + Apache-2.0 +
+
+ learnkit-ai.com +
+
+
+ ), + { ...size }, + ); +} diff --git a/apps/web/src/components/demo/DemoFlow.tsx b/apps/web/src/components/demo/DemoFlow.tsx index eb3ec52..9a8c96a 100644 --- a/apps/web/src/components/demo/DemoFlow.tsx +++ b/apps/web/src/components/demo/DemoFlow.tsx @@ -19,6 +19,9 @@ const ROLES = [ 'Founder', 'Operations', 'Researcher', + 'Sales', + 'Customer Success', + 'Finance', ]; const ALL_TOOLS = [ @@ -30,6 +33,11 @@ const ALL_TOOLS = [ 'Notion AI', 'Perplexity', 'Gemini', + 'Windsurf', + 'Replit', + 'Linear', + 'Figma AI', + 'v0', ]; const GOAL_SAMPLES = [ @@ -558,7 +566,7 @@ export function DemoFlow() { } > - setStep(4)} /> + setStep(4)} /> )} diff --git a/apps/web/src/components/demo/LessonPreview.tsx b/apps/web/src/components/demo/LessonPreview.tsx index 2643e7b..201488e 100644 --- a/apps/web/src/components/demo/LessonPreview.tsx +++ b/apps/web/src/components/demo/LessonPreview.tsx @@ -1,37 +1,34 @@ 'use client'; -import { useState } from 'react'; +import { generateLearningPath, generateLessonContent } from '@learnkit-ai/core'; import { Button, ArrowR } from '@/components/ui/Button'; import { Ole } from '@/components/ui/primitives'; import { FlowFooter } from './StepShell'; -const OUTLINE = [ - 'What a system prompt does', - 'Anatomy of a great prompt', - 'Try one: rewriting your standup', - 'Common mistakes', - 'Practice & ship', -]; - export function LessonPreview({ role, tools, + goal, + level, onBack, }: { role: string; tools: string[]; + goal?: string; + level?: 'beginner' | 'intermediate' | 'advanced'; onBack: () => void; }) { - const [section, setSection] = useState(1); - const primaryTool = tools[0] ?? 'Claude'; - const roleShort = (role || 'Product Manager').split(' ').slice(-1)[0].toLowerCase(); + const path = generateLearningPath({ + role: role || 'Product Manager', + tools: tools.length > 0 ? tools : ['Claude'], + goal: goal || 'Ship an AI feature this sprint', + level: level ?? 'beginner', + }); - const exampleForTool = () => { - if (primaryTool === 'Cursor') return '"You are a senior engineer. Refactor this file."'; - if (primaryTool === 'Claude') return `"You are a senior ${roleShort}. Review this spec."`; - if (primaryTool === 'ChatGPT') return '"You are a research assistant. Summarize this report."'; - return `"You are a ${roleShort}'s assistant. Help with this task."`; - }; + const firstLesson = path.weeks[0]!.lessons[0]!; + const content = generateLessonContent(firstLesson); + const [firstPara, secondPara] = content.body.split('\n\n'); + const firstExercise = content.exercises[0]!; return (
- {/* Outline */} + {/* Outline: week 1 lessons */}
- Outline + Week 1 — {path.weeks[0]!.title}
- {OUTLINE.map((t, i) => ( + {path.weeks[0]!.lessons.map((l, i) => (
setSection(i)} + key={l.id} style={{ display: 'flex', gap: 10, padding: '8px 8px', borderRadius: 6, marginBottom: 2, - background: section === i ? 'var(--paper-3)' : 'transparent', + background: i === 0 ? 'var(--paper-3)' : 'transparent', cursor: 'pointer', fontSize: 12.5, - fontWeight: section === i ? 500 : 400, + fontWeight: i === 0 ? 500 : 400, color: 'var(--ink)', }} > @@ -96,16 +92,17 @@ export function LessonPreview({ fontFamily: 'var(--mono)', fontSize: 11, width: 16, + flexShrink: 0, }} > {i + 1} - {t} + {l.title}
))}
- {/* Content */} + {/* Content: real lesson body */}
- Section 02 · Anatomy + Day {firstLesson.day} · {firstLesson.kind} · {firstLesson.minutes}m

- A great system prompt has{' '} - three things. + {firstLesson.title}

- Most people open ChatGPT and start asking. The pros write a{' '} - persona, define a{' '} - process, and set{' '} - boundaries. That's it. Three - knobs. + {firstPara}

+ {secondPara && ( +

+ {secondPara} +

+ )}
-
# persona
-
- You are a skeptical senior PM +
+ Exercise 1
-
# process
-
For each user input, you:
-
1. Ask one clarifying question
-
2. List 3 alternatives
-
3. Pick one with rationale
-
# boundaries
-
Never invent metrics. If unsure, say so.
+

+ {firstExercise.prompt} +

-

- Try it now in the workbench on the right → -

- {/* AI Guide chat */} + {/* AI Guide */}
- I noticed you picked{' '} - {primaryTool} as your main tool — - I swapped the example to fit. Spot the missing knob: + This is your first lesson as a{' '} + {role}. Your primary tool is{' '} + {firstLesson.tool}. The exercise + below is a real task — not a tutorial. Do it before reading ahead.
- {exampleForTool()} -
-
- {['Persona', 'Process', 'Boundaries'].map((o, i) => ( - - ))} + Reviewer note:{' '} + {firstExercise.rubricHint}
diff --git a/packages/core/src/__tests__/generate.test.ts b/packages/core/src/__tests__/generate.test.ts index c9aef2f..549b15e 100644 --- a/packages/core/src/__tests__/generate.test.ts +++ b/packages/core/src/__tests__/generate.test.ts @@ -71,6 +71,31 @@ describe('generateLearningPath', () => { } }); + it('first lesson has no prerequisites', () => { + const path = generateLearningPath(SAMPLE); + expect(path.weeks[0]!.lessons[0]!.prerequisiteIds).toHaveLength(0); + }); + + it('subsequent lessons have exactly one prerequisite', () => { + const path = generateLearningPath(SAMPLE); + const allLessons = path.weeks.flatMap((w) => w.lessons); + for (let i = 1; i < allLessons.length; i++) { + expect(allLessons[i]!.prerequisiteIds).toHaveLength(1); + } + }); + + it('prerequisite ids reference real lesson ids', () => { + const path = generateLearningPath(SAMPLE); + const allIds = new Set(path.weeks.flatMap((w) => w.lessons.map((l) => l.id))); + for (const w of path.weeks) { + for (const l of w.lessons) { + for (const pid of l.prerequisiteIds) { + expect(allIds.has(pid)).toBe(true); + } + } + } + }); + it.each([ 'Marketer', 'Founder', diff --git a/packages/core/src/generate.ts b/packages/core/src/generate.ts index 140df77..9211489 100644 --- a/packages/core/src/generate.ts +++ b/packages/core/src/generate.ts @@ -1073,12 +1073,25 @@ export function generateLearningPath(rawInput: LearningPathInput): LearningPath const parsedCtx = input.companyContext ? parseCompanyContext(input.companyContext) : null; const ctxSuffix = parsedCtx ? buildContextSuffix(parsedCtx) : ''; - const weeks = (ROLE_WEEKS[role] ?? GENERIC_WEEKS).map((w, wi) => { + const weekDefs = ROLE_WEEKS[role] ?? GENERIC_WEEKS; + + const weeks = weekDefs.map((w, wi) => { const tool = wi % 2 === 0 ? primaryTool : secondaryTool; const lessons: Lesson[] = w.templates.map((template, li) => { const dayBase = wi * 7 + li * 2 + 1; const minutesAdjusted = Math.max(8, template.minutes + LEVEL_BIAS[input.level]); const baseSummary = template.summary({ tool, role, goal: input.goal }); + + let prerequisiteIds: string[] = []; + if (wi === 0 && li === 0) { + prerequisiteIds = []; + } else if (li === 0) { + const prevTemplates = weekDefs[wi - 1]!.templates; + prerequisiteIds = [hashId('l', stableSeed, wi - 1, prevTemplates.length - 1)]; + } else { + prerequisiteIds = [hashId('l', stableSeed, wi, li - 1)]; + } + return { id: hashId('l', stableSeed, wi, li), day: dayBase, @@ -1087,6 +1100,7 @@ export function generateLearningPath(rawInput: LearningPathInput): LearningPath tool, minutes: minutesAdjusted, kind: template.kind, + prerequisiteIds, }; }); return { index: (wi + 1) as 1 | 2 | 3 | 4, title: w.title, lessons } satisfies Week; diff --git a/packages/react/src/LearningPath.tsx b/packages/react/src/LearningPath.tsx index a051bdb..050bf2a 100644 --- a/packages/react/src/LearningPath.tsx +++ b/packages/react/src/LearningPath.tsx @@ -1,15 +1,17 @@ 'use client'; -import type { CSSProperties } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; import type { LearningPathInput, Lesson } from '@learnkit-ai/schemas'; import { LessonCard } from './LessonCard'; +import type { LessonStatus } from './LessonCard'; import { useLearnKit } from './useLearnKit'; -export type LearnKitTheme = 'warm' | 'midnight' | 'technical'; +export type LearnKitTheme = 'warm' | 'midnight' | 'technical' | 'light'; export interface LearningPathProps { input: LearningPathInput; onLessonClick?: (lesson: Lesson) => void; + renderItem?: (lesson: Lesson, status: LessonStatus) => ReactNode; theme?: LearnKitTheme; className?: string; style?: CSSProperties; @@ -49,11 +51,23 @@ export const THEMES: Record = { ['--lk-rule' as string]: 'rgba(230, 230, 230, 0.08)', ['--lk-rule-strong' as string]: 'rgba(230, 230, 230, 0.16)', }, + light: { + ['--lk-surface' as string]: '#FFFFFF', + ['--lk-ink' as string]: '#111827', + ['--lk-ink-soft' as string]: '#374151', + ['--lk-muted' as string]: '#9CA3AF', + ['--lk-accent' as string]: '#6366F1', + ['--lk-accent-2' as string]: '#EC4899', + ['--lk-accent-3' as string]: '#10B981', + ['--lk-rule' as string]: 'rgba(17, 24, 39, 0.08)', + ['--lk-rule-strong' as string]: 'rgba(17, 24, 39, 0.16)', + }, }; export function LearningPath({ input, onLessonClick, + renderItem, theme = 'warm', className, style, @@ -143,20 +157,24 @@ export function LearningPath({ {week.title}
- {week.lessons.map((lesson, li) => ( - - ))} + {week.lessons.map((lesson, li) => { + const status: LessonStatus = + week.index === 1 && li === 0 + ? 'in-progress' + : week.index === 4 + ? 'locked' + : 'available'; + return renderItem ? ( +
{renderItem(lesson, status)}
+ ) : ( + + ); + })}
))} diff --git a/packages/react/src/LessonDetail.tsx b/packages/react/src/LessonDetail.tsx new file mode 100644 index 0000000..eacd14b --- /dev/null +++ b/packages/react/src/LessonDetail.tsx @@ -0,0 +1,251 @@ +'use client'; + +import type { CSSProperties } from 'react'; +import { generateLessonContent } from '@learnkit-ai/core'; +import type { Lesson } from '@learnkit-ai/schemas'; +import type { LearnKitTheme } from './LearningPath'; +import { THEMES } from './LearningPath'; + +export interface LessonDetailProps { + lesson: Lesson; + theme?: LearnKitTheme; + className?: string; + style?: CSSProperties; +} + +const KIND_COLORS: Record = { + lesson: 'var(--lk-accent-2, #E8B547)', + project: 'var(--lk-accent, #C8472A)', + practicum: 'var(--lk-accent-3, #6B8F6E)', +}; + +export function LessonDetail({ lesson, theme = 'warm', className, style }: LessonDetailProps) { + const content = generateLessonContent(lesson); + const kindColor = KIND_COLORS[lesson.kind] ?? 'var(--lk-muted)'; + + return ( +
+ {/* Meta row */} +
+ + {lesson.kind} + + {lesson.tool} + · + Day {lesson.day} + · + {lesson.minutes}m +
+ + {/* Title */} +

+ {lesson.title} +

+ + {/* Body */} +
+ {content.body.split('\n\n').map((para, i) => ( +

+ {para} +

+ ))} +
+ + {/* Exercises */} +
+

+ Exercises +

+
+ {content.exercises.map((ex, i) => ( +
+
+ Exercise {i + 1} +
+

+ {ex.prompt} +

+
+

+ Expected output: + {ex.expectedOutput} +

+

+ Reviewer note: + {ex.rubricHint} +

+
+
+ ))} +
+
+ + {/* Rubric */} +
+

+ Rubric +

+
+ {content.rubric.map((item, i) => ( +
+
+ {item.criterion} +
+ {( + [ + { label: 'Excellent', text: item.excellent, color: 'var(--lk-accent-3, #6B8F6E)' }, + { label: 'Acceptable', text: item.acceptable, color: 'var(--lk-accent-2, #E8B547)' }, + { label: 'Needs work', text: item.needsWork, color: 'var(--lk-accent, #C8472A)' }, + ] as const + ).map(({ label, text, color }) => ( +
+ + {label} + + + {text} + +
+ ))} +
+ ))} +
+
+
+ ); +} diff --git a/packages/react/src/ProgressTracker.tsx b/packages/react/src/ProgressTracker.tsx index fbcb891..2f54e30 100644 --- a/packages/react/src/ProgressTracker.tsx +++ b/packages/react/src/ProgressTracker.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useCallback } from 'react'; -import type { CSSProperties } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; import { generateLearningPath } from '@learnkit-ai/core'; import type { LearningPathInput, Lesson } from '@learnkit-ai/schemas'; import { LessonCard } from './LessonCard'; @@ -13,6 +13,7 @@ export interface ProgressTrackerProps { input: LearningPathInput; theme?: LearnKitTheme; onLessonClick?: (lesson: Lesson) => void; + renderItem?: (lesson: Lesson, status: LessonStatus) => ReactNode; className?: string; style?: CSSProperties; } @@ -36,16 +37,32 @@ function writeStorage(key: string, ids: Set): void { } } -function deriveStatus(lessonId: string, allIds: string[], completedIds: Set): LessonStatus { - if (completedIds.has(lessonId)) return 'completed'; - const firstIncomplete = allIds.find((id) => !completedIds.has(id)); - return lessonId === firstIncomplete ? 'in-progress' : 'available'; +function isEffectiveDone(lessonId: string, allLessons: Lesson[], completedIds: Set): boolean { + if (!completedIds.has(lessonId)) return false; + const lesson = allLessons.find((l) => l.id === lessonId); + if (!lesson) return false; + return lesson.prerequisiteIds.every((pid) => isEffectiveDone(pid, allLessons, completedIds)); +} + +function deriveStatus(lesson: Lesson, allLessons: Lesson[], completedIds: Set): LessonStatus { + if (isEffectiveDone(lesson.id, allLessons, completedIds)) return 'completed'; + const prereqsMet = lesson.prerequisiteIds.every((pid) => + isEffectiveDone(pid, allLessons, completedIds), + ); + if (!prereqsMet) return 'locked'; + const firstAvailable = allLessons.find( + (l) => + !isEffectiveDone(l.id, allLessons, completedIds) && + l.prerequisiteIds.every((pid) => isEffectiveDone(pid, allLessons, completedIds)), + ); + return lesson.id === firstAvailable?.id ? 'in-progress' : 'available'; } export function ProgressTracker({ input, theme = 'warm', onLessonClick, + renderItem, className, style, }: ProgressTrackerProps) { @@ -101,8 +118,11 @@ export function ProgressTracker({ ); } - const allIds = path.weeks.flatMap((w) => w.lessons.map((l) => l.id)); - const completedCount = allIds.filter((id) => completedIds.has(id)).length; + const allLessons = path.weeks.flatMap((w) => w.lessons); + const allIds = allLessons.map((l) => l.id); + const completedCount = allIds.filter((id) => + isEffectiveDone(id, allLessons, completedIds), + ).length; return (
- {week.lessons.map((lesson) => ( - - ))} + {week.lessons.map((lesson) => { + const status = deriveStatus(lesson, allLessons, completedIds); + return renderItem ? ( +
{renderItem(lesson, status)}
+ ) : ( + + ); + })}
))} diff --git a/packages/react/src/__tests__/LearningPath.test.tsx b/packages/react/src/__tests__/LearningPath.test.tsx index 6eaca1b..4f1c8dd 100644 --- a/packages/react/src/__tests__/LearningPath.test.tsx +++ b/packages/react/src/__tests__/LearningPath.test.tsx @@ -30,14 +30,29 @@ describe('LearningPath', () => { expect(screen.getByText(/Invalid LearningPathInput/)).toBeInTheDocument(); }); - it('renders without throwing for all three themes', () => { - const themes = ['warm', 'midnight', 'technical'] as const; + it('renders without throwing for all four themes', () => { + const themes = ['warm', 'midnight', 'technical', 'light'] as const; for (const theme of themes) { const { unmount } = render(); unmount(); } }); + it('calls renderItem instead of the default LessonCard when provided', () => { + const rendered: string[] = []; + render( + { + rendered.push(lesson.id); + return
{lesson.title}
; + }} + />, + ); + expect(rendered.length).toBe(12); + expect(screen.queryAllByRole('button')).toHaveLength(0); + }); + it('shows lesson count summary line', () => { render(); expect(screen.getByText(/4 weeks/)).toBeInTheDocument(); diff --git a/packages/react/src/__tests__/LessonCard.test.tsx b/packages/react/src/__tests__/LessonCard.test.tsx index a415b35..1fe60dd 100644 --- a/packages/react/src/__tests__/LessonCard.test.tsx +++ b/packages/react/src/__tests__/LessonCard.test.tsx @@ -11,6 +11,7 @@ const LESSON: Lesson = { tool: 'Claude', minutes: 12, kind: 'lesson', + prerequisiteIds: [], }; describe('LessonCard', () => { diff --git a/packages/react/src/__tests__/LessonDetail.test.tsx b/packages/react/src/__tests__/LessonDetail.test.tsx new file mode 100644 index 0000000..ee67d4d --- /dev/null +++ b/packages/react/src/__tests__/LessonDetail.test.tsx @@ -0,0 +1,70 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { generateLearningPath } from '@learnkit-ai/core'; +import type { LearningPathInput } from '@learnkit-ai/schemas'; +import { LessonDetail } from '../LessonDetail'; + +const INPUT: LearningPathInput = { + role: 'Software Engineer', + tools: ['Claude'], + goal: 'Build an AI code review tool', + level: 'intermediate', +}; + +function getFirstLesson() { + return generateLearningPath(INPUT).weeks[0]!.lessons[0]!; +} + +describe('LessonDetail', () => { + it('renders the lesson title', () => { + const lesson = getFirstLesson(); + render(); + expect(screen.getByText(lesson.title)).toBeInTheDocument(); + }); + + it('renders the lesson kind badge', () => { + const lesson = getFirstLesson(); + render(); + expect(screen.getByText(lesson.kind)).toBeInTheDocument(); + }); + + it('renders exercises section', () => { + const lesson = getFirstLesson(); + render(); + expect(screen.getByText(/Exercises/i)).toBeInTheDocument(); + expect(screen.getByText(/Exercise 1/i)).toBeInTheDocument(); + }); + + it('renders rubric section', () => { + const lesson = getFirstLesson(); + render(); + expect(screen.getByText(/^Rubric$/i)).toBeInTheDocument(); + expect(screen.getAllByText(/^Excellent$/i).length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText(/^Needs work$/i).length).toBeGreaterThanOrEqual(1); + }); + + it('renders without throwing for a project lesson', () => { + const path = generateLearningPath(INPUT); + const project = path.weeks.flatMap((w) => w.lessons).find((l) => l.kind === 'project'); + expect(project).toBeDefined(); + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('renders without throwing for a practicum lesson', () => { + const path = generateLearningPath(INPUT); + const practicum = path.weeks.flatMap((w) => w.lessons).find((l) => l.kind === 'practicum'); + expect(practicum).toBeDefined(); + const { container } = render(); + expect(container).toBeTruthy(); + }); + + it('renders without throwing for all four themes', () => { + const lesson = getFirstLesson(); + const themes = ['warm', 'midnight', 'technical', 'light'] as const; + for (const theme of themes) { + const { unmount } = render(); + unmount(); + } + }); +}); diff --git a/packages/react/src/__tests__/ProgressTracker.test.tsx b/packages/react/src/__tests__/ProgressTracker.test.tsx index 47b9e06..4c19b3f 100644 --- a/packages/react/src/__tests__/ProgressTracker.test.tsx +++ b/packages/react/src/__tests__/ProgressTracker.test.tsx @@ -57,11 +57,41 @@ describe('ProgressTracker', () => { expect(screen.getByText(/role is required|Invalid|Unable/i)).toBeInTheDocument(); }); - it('renders without throwing for all three themes', () => { - const themes = ['warm', 'midnight', 'technical'] as const; + it('renders without throwing for all four themes', () => { + const themes = ['warm', 'midnight', 'technical', 'light'] as const; for (const theme of themes) { const { unmount } = render(); unmount(); } }); + + it('second lesson is locked until the first is complete', () => { + render(); + const buttons = screen.getAllByRole('button'); + // Button at index 1 should be disabled (locked — prerequisites not met) + expect(buttons[1]).toBeDisabled(); + }); + + it('second lesson unlocks after first is completed', () => { + render(); + const buttons = screen.getAllByRole('button'); + fireEvent.click(buttons[0]!); + // Now buttons[1] should be enabled (in-progress) + expect(screen.getAllByRole('button')[1]).not.toBeDisabled(); + }); + + it('calls renderItem instead of default LessonCard when provided', () => { + const rendered: string[] = []; + render( + { + rendered.push(lesson.id); + return
{lesson.title}
; + }} + />, + ); + expect(rendered.length).toBe(12); + expect(screen.queryAllByRole('button')).toHaveLength(0); + }); }); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 44e9997..498b10d 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -10,3 +10,4 @@ export { LessonCard, type LessonCardProps, type LessonStatus } from './LessonCar export { AIGuide, type AIGuideProps } from './AIGuide'; export { useLearnKit, type UseLearnKitResult } from './useLearnKit'; export { ProgressTracker, type ProgressTrackerProps } from './ProgressTracker'; +export { LessonDetail, type LessonDetailProps } from './LessonDetail'; diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index 42d416c..f9eeeec 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -28,6 +28,7 @@ export const LessonSchema = z.object({ tool: z.string(), minutes: z.number().int().min(1), kind: LessonKindSchema, + prerequisiteIds: z.array(z.string()), }); export type Lesson = z.infer; From c25b4ab6d87dd20266f8b9ffdcc4ec69236a9508 Mon Sep 17 00:00:00 2001 From: raj-khan Date: Sun, 24 May 2026 20:32:37 +0800 Subject: [PATCH 2/2] Fix schemas test fixtures: add prerequisiteIds to lesson objects --- packages/schemas/src/__tests__/index.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/schemas/src/__tests__/index.test.ts b/packages/schemas/src/__tests__/index.test.ts index c57c9c7..bd34dc9 100644 --- a/packages/schemas/src/__tests__/index.test.ts +++ b/packages/schemas/src/__tests__/index.test.ts @@ -59,6 +59,7 @@ describe('LessonSchema', () => { tool: 'Claude', minutes: 12, kind: 'lesson' as const, + prerequisiteIds: [], }; expect(LessonSchema.safeParse({ ...base, day: 0 }).success).toBe(false); expect(LessonSchema.safeParse({ ...base, day: 31 }).success).toBe(false); @@ -94,6 +95,7 @@ describe('LearningPathSchema', () => { tool: 'Claude', minutes: 10, kind: 'lesson' as const, + prerequisiteIds: [], }, ], });