diff --git a/apps/web/prompts/scout/extract.v2.md b/apps/web/prompts/scout/extract.v2.md new file mode 100644 index 0000000..57c00ec --- /dev/null +++ b/apps/web/prompts/scout/extract.v2.md @@ -0,0 +1,24 @@ +--- +version: 2 +purpose: Extract study material and a level from one free learning resource +--- + +You turn one free learning resource into study material for a developer. + +You receive a title, creator, description, and the resource's text. For videos the text is a transcript with `[mm:ss]` markers; for articles it is cleaned markdown. + +Rules: + +1. Use only the supplied text and description. Never add facts, chapters, tools or opinions that are not there. +2. `summary` is 2–4 sentences in plain prose. No marketing language, no bullet points. +3. `keyPoints` are the ideas a learner should remember. Each one is a single sentence. +4. Every key point that makes a factual or technical claim must include a `quote` copied verbatim from the supplied text (at least 12 characters) and the `atSeconds` where that quote appears for videos. If you cannot find an exact quote, omit the key point. +5. `skills` are 1–5 short labels naming the capability the resource teaches, e.g. "layout", "state management", "sql joins". Lowercase, no version numbers. +6. Only include `chapters` for videos. Use the description's chapter list when it exists. Otherwise propose 3–8 chapters, each with the second it starts and a short title. Never invent timestamps beyond the video duration supplied. +7. Ignore sponsor reads, subscribe requests, channel plugs and course advertisements. +8. If the text is too thin to support any claim, return an empty `keyPoints` array and say so in `summary`. +9. `level` is the level a learner needs to get value from this resource: + - `basics`: assumes no prior knowledge of the topic; introduces core terms and the first working example. + - `intermediate`: assumes the basics; builds real features, covers common patterns, trade-offs and debugging. + - `advanced`: assumes working experience; covers internals, performance, scaling, edge cases or architecture. + Judge from what the text teaches and assumes, not from words like "beginner" or "ultimate" in the title. If the text is too thin to judge, return null. diff --git a/apps/web/prompts/scout/plan.v2.md b/apps/web/prompts/scout/plan.v2.md new file mode 100644 index 0000000..535bd56 --- /dev/null +++ b/apps/web/prompts/scout/plan.v2.md @@ -0,0 +1,39 @@ +--- +version: 2 +purpose: Turn approved resources into a video ladder of levels with a small core set per level +--- + +You build a short, honest video ladder from resources a learner has already approved. The learner learns by watching videos, basics first, then moving up one level at a time. Follow the 80/20 rule: in each level, pick the few videos that cover most of what matters and mark everything else as optional. + +You receive the learner's goal and a list of resources. Each resource has an id, title, creator, kind (`video`, `article`, `doc`, `book`), an optional `level` hint (`basics`, `intermediate`, `advanced` or null), summary, skill tags and chapters. Videos also carry a duration and chapter start times. + +Levels: + +1. Group the material into modules, and give every module a `level`: `basics`, `intermediate` or `advanced`. A module is one capability, not one resource. +2. Use at most 3 modules per level. Skip a level when the material does not support it; never invent a level to fill the ladder. A ladder with only `basics` is fine. +3. List modules in level order (basics, then intermediate, then advanced). Inside a level, order modules so each depends only on earlier ones. +4. Judge the level from what the material teaches and assumes, not from the goal's ambition. Use a resource's `level` hint when it agrees with the content. + +Core picks (80/20): + +5. Every task has `core` (true or false). Core tasks are the small set that covers most of what matters at that level. +6. Each level has at most 5 core videos, ideally 3. Count distinct videos: several chapter tasks from the same video count as one video. +7. Prefer videos for core. An article or doc may be core only when the level has no suitable video. +8. Books are never core. +9. For every core task, `coreReason` is one line (under 20 words) saying why this pick matters most at its level, e.g. "Covers the request lifecycle every later module builds on." Leave `coreReason` empty for non-core tasks. +10. Everything that is not core is a "go deeper" item: optional, useful, skippable. + +Tasks: + +11. Every task must belong to a module and point at one real `resourceId` from the input. Resource ids must be copied exactly. Never create new ids. +12. For a video with chapters, turn chapters into watch tasks. Task titles read like "Watch (12:30–25:10)". Set `startSeconds` and `endSeconds` from the chapters. A video without chapters is one watch task. +13. For an article or doc, create one read task. Set `resourceId` and leave timestamps null. +14. Books are a separate reading track. Put every book task in one module titled "Reading track" with level `basics`; its level is ignored. Create one task per chapter title supplied. Never invent chapter numbers that are not in the input. +15. Do not create tasks that require paid accounts, sign-ups, or resources outside the input. +16. `estimatedMinutes` is realistic: video tasks use the chapter length; reading tasks assume 4 minutes per 1000 words when word count is known, otherwise 20 minutes. + +Roadmap fields: + +17. `summary` is 2–3 sentences describing what the learner will be able to do at the end. +18. `objectives` are 3–5 concrete abilities, each starting with a verb. +19. `difficulty` is `beginner`, `intermediate` or `advanced`: the level of the hardest material in the ladder. diff --git a/apps/web/src/components/scout/PickList.tsx b/apps/web/src/components/scout/PickList.tsx index da835d8..2f14635 100644 --- a/apps/web/src/components/scout/PickList.tsx +++ b/apps/web/src/components/scout/PickList.tsx @@ -1,6 +1,12 @@ import { Check, FileText, Video } from "lucide-react"; import { cn } from "@/lib/utils"; import { formatTimestamp, humanDuration } from "@/lib/scout/format"; +import { + groupPicks, + PICK_GROUP_LABEL, + PICK_ROLE_LABEL, + type PickRole, +} from "@/lib/scout/picks"; import type { ScoutResource } from "@/lib/scout/types"; interface PickListProps { @@ -15,126 +21,146 @@ export default function PickList({ onToggle, }: PickListProps) { return ( - + ) : null} + + + ); } diff --git a/apps/web/src/lib/__tests__/ladder.test.ts b/apps/web/src/lib/__tests__/ladder.test.ts new file mode 100644 index 0000000..0e16e3c --- /dev/null +++ b/apps/web/src/lib/__tests__/ladder.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from "vitest"; +import { getLadder, levelUpReady, nextCoreItem } from "@/lib/ladder"; +import type { + LearningLevel, + ModuleSection, + Roadmap, + Section, + Task, +} from "@/types"; + +function makeTask(id: string, extra: Partial = {}): Task { + return { + id, + title: `Task ${id}`, + completed: false, + notes: "", + subtasks: [], + attachments: [], + ...extra, + }; +} + +function videoTask(id: string, extra: Partial = {}): Task { + return makeTask(id, { + attachments: [ + { + id: `att_${id}`, + title: id, + url: `https://youtu.be/${id.padEnd(11, "x")}`, + type: "video", + description: "", + }, + ], + ...extra, + }); +} + +function articleTask(id: string, extra: Partial = {}): Task { + return makeTask(id, { + attachments: [ + { + id: `att_${id}`, + title: id, + url: `https://example.com/${id}`, + type: "link", + description: "", + }, + ], + ...extra, + }); +} + +function moduleSection( + id: string, + order: number, + tasks: Task[], + extra: { level?: LearningLevel; track?: "reading" } = {}, +): ModuleSection { + return { + id, + title: id, + order, + type: "module", + ...(extra.level !== undefined ? { level: extra.level } : {}), + ...(extra.track !== undefined ? { track: extra.track } : {}), + data: { + description: "", + tasks, + resources: [], + videos: [], + completed: false, + }, + }; +} + +function roadmap( + sections: Section[], + difficulty?: Roadmap["difficulty"], +): Roadmap { + return { + id: "r1", + title: "Roadmap", + mode: "general", + sections, + rawContent: "", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...(difficulty !== undefined ? { difficulty } : {}), + }; +} + +describe("getLadder", () => { + it("treats roadmaps without levels as one all-core legacy level", () => { + const map = roadmap( + [ + moduleSection("m1", 0, [makeTask("t1"), makeTask("t2")]), + { + id: "g1", + title: "Group", + order: 1, + type: "tasks", + data: [{ id: "tg", title: "Group", tasks: [makeTask("t3")] }], + }, + ], + "intermediate", + ); + const ladder = getLadder(map); + expect(ladder.legacy).toBe(true); + expect(ladder.levels).toHaveLength(1); + expect(ladder.levels[0].level).toBe("intermediate"); + expect(ladder.levels[0].items.map((item) => item.task.id)).toEqual([ + "t1", + "t2", + "t3", + ]); + expect(ladder.levels[0].coreTotal).toBe(3); + expect(ladder.reading).toEqual([]); + }); + + it("labels a legacy level from the roadmap difficulty", () => { + const beginner = getLadder( + roadmap([moduleSection("m", 0, [makeTask("t")])], "beginner"), + ); + const missing = getLadder( + roadmap([moduleSection("m", 0, [makeTask("t")])]), + ); + expect(beginner.levels[0].level).toBe("basics"); + expect(missing.levels[0].level).toBe("basics"); + }); + + it("groups leveled modules, splits core from deeper, and collects reading", () => { + const map = roadmap([ + moduleSection("m1", 0, [ + videoTask("v1", { core: true }), + articleTask("a1", { core: false }), + ], { level: "basics" }), + moduleSection("m2", 1, [videoTask("v2", { core: true })], { + level: "advanced", + }), + moduleSection("m3", 2, [makeTask("b1")], { track: "reading" }), + ]); + const ladder = getLadder(map); + expect(ladder.legacy).toBe(false); + expect(ladder.levels.map((level) => level.level)).toEqual([ + "basics", + "advanced", + ]); + expect(ladder.levels[0].core.map((item) => item.task.id)).toEqual(["v1"]); + expect(ladder.levels[0].deeper.map((item) => item.task.id)).toEqual(["a1"]); + expect(ladder.reading.map((item) => item.task.id)).toEqual(["b1"]); + expect(ladder.current).toBe("basics"); + }); + + it("lets unleveled sections inherit the closest preceding level", () => { + const map = roadmap([ + { + id: "g1", + title: "Group", + order: 0, + type: "tasks", + data: [{ id: "tg", title: "G", tasks: [makeTask("early")] }], + }, + moduleSection("m1", 1, [videoTask("v1", { core: true })], { + level: "intermediate", + }), + { + id: "g2", + title: "Group", + order: 2, + type: "tasks", + data: [{ id: "tg2", title: "G2", tasks: [makeTask("later")] }], + }, + ]); + const ladder = getLadder(map); + const intermediate = ladder.levels.find( + (level) => level.level === "intermediate", + ); + expect(ladder.levels).toHaveLength(1); + expect(intermediate?.items.map((item) => item.task.id)).toEqual([ + "early", + "v1", + "later", + ]); + }); + + it("advances current to the first incomplete level", () => { + const map = roadmap([ + moduleSection("m1", 0, [videoTask("v1", { core: true, completed: true })], { + level: "basics", + }), + moduleSection("m2", 1, [videoTask("v2", { core: true })], { + level: "advanced", + }), + ]); + const ladder = getLadder(map); + expect(ladder.current).toBe("advanced"); + expect(ladder.levels[0].complete).toBe(true); + }); +}); + +describe("nextCoreItem", () => { + it("prefers the first unfinished core video in the current level", () => { + const map = roadmap([ + moduleSection("m1", 0, [ + articleTask("a1", { core: true }), + videoTask("v1", { core: true }), + ], { level: "basics" }), + ]); + expect(nextCoreItem(map)?.task.id).toBe("v1"); + }); + + it("honours excludeTaskId and returns the next core item", () => { + const map = roadmap([ + moduleSection("m1", 0, [ + articleTask("a1", { core: true }), + videoTask("v1", { core: true }), + ], { level: "basics" }), + ]); + expect(nextCoreItem(map, { excludeTaskId: "v1" })?.task.id).toBe("a1"); + }); + + it("skips go-deeper and reading items entirely", () => { + const map = roadmap([ + moduleSection("m1", 0, [ + videoTask("v1", { core: true, done: true }), + videoTask("v2", { core: false }), + ], { level: "basics" }), + moduleSection("m3", 1, [makeTask("b1")], { track: "reading" }), + ]); + expect(nextCoreItem(map)).toBeNull(); + }); + + it("continues to the next level when the current one has nothing left", () => { + const map = roadmap([ + moduleSection("m1", 0, [videoTask("v1", { core: true })], { + level: "basics", + }), + moduleSection("m2", 1, [videoTask("v2", { core: true })], { + level: "intermediate", + }), + ]); + expect( + nextCoreItem(map, { excludeTaskId: "v1" })?.task.id, + ).toBe("v2"); + }); +}); + +describe("levelUpReady", () => { + const leveled = (intermediateDone: boolean) => + roadmap([ + moduleSection("m1", 0, [videoTask("v1", { core: true, completed: true })], { + level: "basics", + }), + moduleSection("m2", 1, [ + videoTask("v2", { core: true, completed: intermediateDone }), + ], { level: "intermediate" }), + ]); + + it("is ready right after a level finishes and not once the next has progress", () => { + expect(levelUpReady(getLadder(leveled(false)))).toEqual({ + from: "basics", + to: "intermediate", + }); + expect(levelUpReady(getLadder(leveled(true)))).toBeNull(); + }); + + it("is null while the first level is still in progress", () => { + const map = roadmap([ + moduleSection("m1", 0, [videoTask("v1", { core: true })], { + level: "basics", + }), + moduleSection("m2", 1, [videoTask("v2", { core: true })], { + level: "intermediate", + }), + ]); + expect(levelUpReady(getLadder(map))).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/__tests__/personal-learning.test.ts b/apps/web/src/lib/__tests__/personal-learning.test.ts index 9727f9e..b51e400 100644 --- a/apps/web/src/lib/__tests__/personal-learning.test.ts +++ b/apps/web/src/lib/__tests__/personal-learning.test.ts @@ -10,6 +10,7 @@ import { } from "@/lib/personal/store"; import { reviewSchema, + roadmapSchema, personalEntrySchema, type PersonalEntry, } from "@/lib/personal/schema"; @@ -74,6 +75,72 @@ describe("local progress and sync", () => { ).toBe(false); expect(localStorage.getItem(PERSONAL_STORAGE_KEY)).toBeNull(); }); + it("round-trips a laddered roadmap with level, track, core and coreReason", () => { + const roadmap = { + id: "rm", + title: "Ladder", + mode: "general", + rawContent: "", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + sections: [ + { + id: "m1", + title: "Module", + order: 0, + type: "module", + level: "intermediate", + data: { + description: "", + tasks: [ + { + id: "t1", + title: "Watch", + completed: false, + notes: "", + subtasks: [], + attachments: [], + core: true, + coreReason: "Covers the key idea.", + priority: "core", + }, + ], + resources: [], + videos: [], + completed: false, + }, + }, + { + id: "m2", + title: "Reading track", + order: 1, + type: "module", + track: "reading", + data: { + description: "", + tasks: [ + { + id: "t2", + title: "Read chapter", + completed: false, + notes: "", + subtasks: [], + attachments: [], + core: false, + }, + ], + resources: [], + videos: [], + completed: false, + }, + }, + ], + }; + const restored = roadmapSchema.parse( + JSON.parse(JSON.stringify(roadmap)), + ); + expect(restored).toEqual(roadmap); + }); }); describe("review scheduling", () => { diff --git a/apps/web/src/lib/ladder.ts b/apps/web/src/lib/ladder.ts new file mode 100644 index 0000000..e33b290 --- /dev/null +++ b/apps/web/src/lib/ladder.ts @@ -0,0 +1,174 @@ +import { + LEARNING_LEVELS, + type LearningLevel, + type ModuleSection, + type Roadmap, +} from "@/types"; +import { getLearningTasks, taskVideo, type LearningTask } from "./learning"; + +export type TaskRole = "core" | "deeper"; + +export interface LadderItem extends LearningTask { + role: TaskRole; + done: boolean; + isVideo: boolean; +} + +export interface LadderLevel { + level: LearningLevel; + items: LadderItem[]; + core: LadderItem[]; + deeper: LadderItem[]; + coreDone: number; + coreTotal: number; + complete: boolean; +} + +export interface Ladder { + levels: LadderLevel[]; + reading: LadderItem[]; + current: LearningLevel | null; + legacy: boolean; +} + +export const LEVEL_LABEL: Record = { + basics: "Basics", + intermediate: "Intermediate", + advanced: "Advanced", +}; + +function toItem(item: LearningTask, role: TaskRole): LadderItem { + return { + ...item, + role, + done: item.task.completed || !!item.task.done, + isVideo: taskVideo(item) !== undefined, + }; +} + +function makeLevel(level: LearningLevel, items: LadderItem[]): LadderLevel { + const core = items.filter((item) => item.role === "core"); + const deeper = items.filter((item) => item.role === "deeper"); + const coreDone = core.filter((item) => item.done).length; + const complete = + core.length > 0 ? coreDone === core.length : items.every((item) => item.done); + return { level, items, core, deeper, coreDone, coreTotal: core.length, complete }; +} + +function legacyLevel(difficulty: Roadmap["difficulty"]): LearningLevel { + switch (difficulty) { + case "intermediate": + return "intermediate"; + case "advanced": + return "advanced"; + default: + return "basics"; + } +} + +/** + * Reads any roadmap as a ladder. Roadmaps without leveled modules keep + * working: every task counts as core on a single level named after the + * roadmap's difficulty. + */ +export function getLadder(roadmap: Roadmap): Ladder { + const sections = [...roadmap.sections].sort((a, b) => a.order - b.order); + const bySectionId = new Map(sections.map((section) => [section.id, section])); + const items = getLearningTasks(roadmap); + + const leveled = sections.some( + (section) => section.type === "module" && section.level !== undefined, + ); + + if (!leveled) { + const level = legacyLevel(roadmap.difficulty); + const all = items.map((item) => toItem(item, "core")); + const levels = all.length > 0 ? [makeLevel(level, all)] : []; + return { + levels, + reading: [], + current: levels.find((entry) => !entry.complete)?.level ?? null, + legacy: true, + }; + } + + const firstLevel = + sections.find( + (section): section is ModuleSection => + section.type === "module" && section.level !== undefined, + )?.level ?? "basics"; + + const buckets = new Map(); + const reading: LadderItem[] = []; + let inherited = firstLevel; + + for (const item of items) { + const section = bySectionId.get(item.sectionId); + if (section?.type === "module" && section.track === "reading") { + reading.push(toItem(item, item.task.core === false ? "deeper" : "core")); + continue; + } + if (section?.type === "module" && section.level !== undefined) { + inherited = section.level; + } + const level = section?.type === "module" && section.level !== undefined ? section.level : inherited; + const bucket = buckets.get(level) ?? []; + bucket.push(toItem(item, item.task.core === false ? "deeper" : "core")); + buckets.set(level, bucket); + } + + const levels = LEARNING_LEVELS.filter((level) => buckets.has(level)).map( + (level) => makeLevel(level, buckets.get(level) ?? []), + ); + + return { + levels, + reading, + current: levels.find((entry) => !entry.complete)?.level ?? null, + legacy: false, + }; +} + +/** + * The one video to watch next: the first unfinished core item in the current + * level, preferring videos, then the next incomplete levels in order. + * Go-deeper and reading items never count. + */ +export function nextCoreItem( + roadmap: Roadmap, + options?: { excludeTaskId?: string }, +): LadderItem | null { + const ladder = getLadder(roadmap); + const exclude = options?.excludeTaskId; + const startIndex = ladder.levels.findIndex( + (entry) => entry.level === ladder.current, + ); + if (startIndex < 0) return null; + + for (const level of ladder.levels.slice(startIndex)) { + const open = level.core.filter( + (item) => !item.done && item.task.id !== exclude, + ); + const picked = open.find((item) => item.isVideo) ?? open[0]; + if (picked) return picked; + } + return null; +} + +/** + * Non-null when a level was just finished and the next level is untouched, + * which is the moment to show "level up" instead of "next video". + */ +export function levelUpReady( + ladder: Ladder, +): { from: LearningLevel; to: LearningLevel } | null { + if (ladder.current === null) return null; + const index = ladder.levels.findIndex( + (entry) => entry.level === ladder.current, + ); + if (index <= 0) return null; + const from = ladder.levels[index - 1]; + const to = ladder.levels[index]; + if (from.complete && to.coreDone === 0) return { from: from.level, to: to.level }; + return null; +} diff --git a/apps/web/src/lib/learning.ts b/apps/web/src/lib/learning.ts index 3f49f79..d7c1c3d 100644 --- a/apps/web/src/lib/learning.ts +++ b/apps/web/src/lib/learning.ts @@ -81,13 +81,11 @@ export function taskVideo(item: LearningTask) { ); } -export function setLearningTaskDone( +/** Save a copy of the roadmap with every task passed through `update`. */ +function updateLearningTasks( roadmap: Roadmap, - taskId: string, - completed: boolean, -) { - const update = (task: Task) => - task.id === taskId ? { ...task, completed, done: completed } : task; + update: (task: Task) => Task, +): Roadmap { const sections = roadmap.sections.map((section): Section => { if (section.type === "module") return { @@ -115,11 +113,68 @@ export function setLearningTaskDone( }; return section; }); - getStorage().saveRoadmap({ + const updated: Roadmap = { ...roadmap, sections, updatedAt: new Date().toISOString(), - }); + }; + getStorage().saveRoadmap(updated); + return updated; +} + +export function setLearningTaskDone( + roadmap: Roadmap, + taskId: string, + completed: boolean, +): Roadmap { + return updateLearningTasks(roadmap, (task) => + task.id === taskId ? { ...task, completed, done: completed } : task, + ); +} + +export function setLearningSubtaskDone( + roadmap: Roadmap, + taskId: string, + subtaskId: string, + completed: boolean, +): Roadmap { + return updateLearningTasks(roadmap, (task) => + task.id === taskId + ? { + ...task, + subtasks: task.subtasks.map((subtask) => + subtask.id === subtaskId + ? { ...subtask, completed } + : subtask, + ), + } + : task, + ); +} + +/** + * Resolve the real `Video` record behind a lesson resource. `taskVideo` + * returns a flattened `Resource`, which drops duration and timestamps. + */ +export function learningVideo(roadmap: Roadmap, url: string): Video | null { + if (!url) return null; + for (const section of roadmap.sections) { + if (section.type === "module") { + const found = section.data.videos.find( + (video) => video.url === url, + ); + if (found) return found; + } + if (section.type === "milestones") { + for (const milestone of section.data) { + const found = milestone.videos.find( + (video) => video.url === url, + ); + if (found) return found; + } + } + } + return null; } export function safeResourceUrl(value: string) { diff --git a/apps/web/src/lib/personal/schema.ts b/apps/web/src/lib/personal/schema.ts index beee7f6..b9f60b4 100644 --- a/apps/web/src/lib/personal/schema.ts +++ b/apps/web/src/lib/personal/schema.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { Roadmap, Section } from "@/types"; +import { LEARNING_LEVELS, type Roadmap, type Section } from "@/types"; const resource = z.object({ id: z.string(), @@ -43,6 +43,8 @@ const task = z.object({ description: z.string().optional(), estimatedTime: z.string().optional(), priority: z.string().optional(), + core: z.boolean().optional(), + coreReason: z.string().optional(), done: z.boolean().optional(), }); const baseSection = z.object({ @@ -68,6 +70,8 @@ const note = z.object({ export const sectionSchema: z.ZodType
= z.discriminatedUnion("type", [ baseSection.extend({ type: z.literal("module"), + level: z.enum(LEARNING_LEVELS).optional(), + track: z.literal("reading").optional(), data: z.object({ description: z.string(), estimatedTime: z.string().optional(), diff --git a/apps/web/src/lib/scout/__tests__/discovery-order.test.ts b/apps/web/src/lib/scout/__tests__/discovery-order.test.ts new file mode 100644 index 0000000..e10d9d8 --- /dev/null +++ b/apps/web/src/lib/scout/__tests__/discovery-order.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { pickVideoFirst } from "@/lib/scout/discovery/combine"; +import type { DiscoveredResource } from "@/lib/scout/discovery"; + +function video(id: string): DiscoveredResource { + return { + kind: "video", + source: "test", + url: `https://youtu.be/${id.padEnd(11, "x")}`, + title: `Video ${id}`, + }; +} + +function web(id: string): DiscoveredResource { + return { + kind: "article", + source: "test", + url: `https://blog.example.com/${id}`, + title: `Article ${id}`, + }; +} + +describe("pickVideoFirst", () => { + it("puts videos first and caps web picks at a quarter when videos are plentiful", () => { + const picked = pickVideoFirst({ + videos: [video("a"), video("b"), video("c"), video("d")], + web: [web("w1"), web("w2"), web("w3"), web("w4"), web("w5")], + limit: 8, + }); + expect(picked.map((item) => item.kind)).toEqual([ + "video", + "video", + "video", + "video", + "article", + "article", + ]); + }); + + it("lets web fill the remaining slots when fewer than three videos exist", () => { + const picked = pickVideoFirst({ + videos: [video("a"), video("b")], + web: [web("w1"), web("w2"), web("w3"), web("w4")], + limit: 5, + }); + expect(picked.map((item) => item.title)).toEqual([ + "Video a", + "Video b", + "Article w1", + "Article w2", + "Article w3", + ]); + }); + + it("keeps at least one web slot when videos hit the cap", () => { + const picked = pickVideoFirst({ + videos: [video("a"), video("b"), video("c")], + web: [web("w1"), web("w2")], + limit: 4, + }); + expect(picked).toHaveLength(4); + expect(picked.at(-1)?.kind).toBe("article"); + }); + + it("dedupes across youtube url shapes and cross-pool duplicates", () => { + const picked = pickVideoFirst({ + videos: [ + { ...video("dup"), url: "https://www.youtube.com/watch?v=dupxxxxxxxy&utm_source=x" }, + { ...video("dup"), url: "https://youtu.be/dupxxxxxxxy" }, + video("b"), + ], + web: [{ ...web("same"), url: "https://youtu.be/dupxxxxxxxy" }, web("w2")], + limit: 6, + }); + expect(picked).toHaveLength(3); + expect(picked.filter((item) => item.kind === "video")).toHaveLength(2); + expect(picked.at(-1)?.title).toBe("Article w2"); + }); +}); diff --git a/apps/web/src/lib/scout/__tests__/extract-level.test.ts b/apps/web/src/lib/scout/__tests__/extract-level.test.ts new file mode 100644 index 0000000..7cff3aa --- /dev/null +++ b/apps/web/src/lib/scout/__tests__/extract-level.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { mergeExtractionLevels } from "@/lib/scout/extract"; + +describe("mergeExtractionLevels", () => { + it("picks the majority level across chunks", () => { + expect( + mergeExtractionLevels([ + { level: "basics" }, + { level: "intermediate" }, + { level: "basics" }, + ]), + ).toBe("basics"); + }); + + it("breaks ties toward the higher level", () => { + expect( + mergeExtractionLevels([{ level: "basics" }, { level: "advanced" }]), + ).toBe("advanced"); + expect( + mergeExtractionLevels([ + { level: "intermediate" }, + { level: "basics" }, + ]), + ).toBe("intermediate"); + }); + + it("returns null for no outputs or all-null levels", () => { + expect(mergeExtractionLevels([])).toBeNull(); + expect( + mergeExtractionLevels([{ level: null }, {}]), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/scout/__tests__/picks.test.ts b/apps/web/src/lib/scout/__tests__/picks.test.ts new file mode 100644 index 0000000..0f59282 --- /dev/null +++ b/apps/web/src/lib/scout/__tests__/picks.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { groupPicks } from "@/lib/scout/picks"; +import type { ScoutResource } from "@/lib/scout/types"; + +function resource( + id: string, + kind: ScoutResource["kind"], + level: ScoutResource["level"] = null, +): ScoutResource { + return { + id, + kind, + source: "test", + url: `https://example.com/${id}`, + title: `Resource ${id}`, + level, + keyPoints: [], + skillTags: [], + chapters: [], + transcriptStatus: "ok", + transcriptChars: 400, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }; +} + +describe("groupPicks", () => { + it("groups by level, puts unsorted after advanced and reading last", () => { + const groups = groupPicks([ + resource("adv", "video", "advanced"), + resource("book", "book", "basics"), + resource("base", "video", "basics"), + resource("mid", "video", "intermediate"), + resource("free", "article", null), + ]); + expect(groups.map((group) => group.level)).toEqual([ + "basics", + "intermediate", + "advanced", + "unsorted", + "reading", + ]); + expect(groups.at(-1)?.items[0].role).toBe("reading"); + }); + + it("marks the first five videos core and the rest deeper", () => { + const groups = groupPicks( + ["v1", "v2", "v3", "v4", "v5", "v6"].map((id) => + resource(id, "video", "basics"), + ), + ); + const roles = groups[0].items.map((item) => item.role); + expect(roles).toEqual(["core", "core", "core", "core", "core", "deeper"]); + }); + + it("caps core at three when a level has no videos", () => { + const groups = groupPicks( + ["a1", "a2", "a3", "a4"].map((id) => resource(id, "article", "intermediate")), + ); + expect(groups[0].items.map((item) => item.role)).toEqual([ + "core", + "core", + "core", + "deeper", + ]); + }); + + it("treats unsorted picks like a level and omits empty groups", () => { + const groups = groupPicks([resource("v1", "video", null), resource("b1", "book", null)]); + expect(groups.map((group) => group.level)).toEqual(["unsorted", "reading"]); + expect(groups[0].items[0].role).toBe("core"); + }); +}); diff --git a/apps/web/src/lib/scout/__tests__/plan-levels.test.ts b/apps/web/src/lib/scout/__tests__/plan-levels.test.ts new file mode 100644 index 0000000..72d7985 --- /dev/null +++ b/apps/web/src/lib/scout/__tests__/plan-levels.test.ts @@ -0,0 +1,295 @@ +import { describe, expect, it } from "vitest"; +import { + buildRoadmap, + CORE_STARTING_REASON, + normalizeModules, + planSchema, + type ScoutPlan, +} from "@/lib/scout/plan"; +import type { ScoutResource } from "@/lib/scout/types"; + +function resource( + id: string, + kind: ScoutResource["kind"], + extra: Partial = {}, +): ScoutResource { + return { + id, + kind, + source: "test", + url: `https://example.com/${id}`, + title: `Resource ${id}`, + keyPoints: [], + skillTags: [], + chapters: [], + transcriptStatus: "ok", + transcriptChars: 400, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...extra, + }; +} + +function plan(modules: unknown[]): ScoutPlan { + return planSchema.parse({ title: "Goal", summary: "Summary", modules }); +} + +function task(resourceId: string, extra: Record = {}) { + return { title: `Task on ${resourceId}`, resourceId, ...extra }; +} + +describe("plan schema", () => { + it("applies defaults and catches an invalid level", () => { + const parsed = plan([ + { + title: "M", + level: "expert", + tasks: [task("v1")], + }, + { + title: "N", + level: "advanced", + tasks: [task("v2", { core: true, coreReason: "why" })], + }, + ]); + expect(parsed.modules[0].level).toBe("basics"); + expect(parsed.modules[0].tasks[0].core).toBe(false); + expect(parsed.modules[0].tasks[0].coreReason).toBe(""); + expect(parsed.modules[1].level).toBe("advanced"); + expect(parsed.modules[1].tasks[0].core).toBe(true); + expect(parsed.modules[1].tasks[0].coreReason).toBe("why"); + }); +}); + +describe("normalizeModules", () => { + it("sorts modules by level, basics first", () => { + const resources = [resource("v1", "video"), resource("v2", "video"), resource("v3", "video")]; + const modules = normalizeModules({ + plan: plan([ + { title: "Hard", level: "advanced", tasks: [task("v1")] }, + { title: "Easy", level: "basics", tasks: [task("v2", { core: true })] }, + { title: "Mid", level: "intermediate", tasks: [task("v3")] }, + ]), + resources, + }); + expect(modules.map((module) => module.level)).toEqual([ + "basics", + "intermediate", + "advanced", + ]); + }); + + it("moves book tasks into a last reading-track module that is never core", () => { + const resources = [ + resource("b1", "book", { + chapters: [ + { startSeconds: 0, title: "Part one", url: "" }, + { startSeconds: 1, title: "Part two", url: "" }, + ], + }), + resource("v1", "video"), + ]; + const modules = normalizeModules({ + plan: plan([ + { + title: "Mixed", + level: "basics", + tasks: [ + task("b1", { title: "Read Part one", core: true, coreReason: "book" }), + task("b1", { title: "Read Part two" }), + task("v1", { core: true }), + ], + }, + ]), + resources, + }); + expect(modules).toHaveLength(2); + const reading = modules[1]; + expect(reading.title).toBe("Reading track"); + expect(reading.track).toBe("reading"); + expect(reading.tasks).toHaveLength(2); + expect(reading.tasks.every((entry) => !entry.core && entry.coreReason === "")).toBe(true); + expect(modules[0].tasks).toHaveLength(1); + }); + + it("clears article core flags when the level has a video, and keeps them when it does not", () => { + const withVideo = normalizeModules({ + plan: plan([ + { + title: "M", + level: "basics", + tasks: [ + task("v1", { core: true }), + task("a1", { core: true, coreReason: "read me" }), + ], + }, + ]), + resources: [resource("v1", "video"), resource("a1", "article")], + }); + expect(withVideo[0].tasks[0].core).toBe(true); + expect(withVideo[0].tasks[1].core).toBe(false); + expect(withVideo[0].tasks[1].coreReason).toBe(""); + + const withoutVideo = normalizeModules({ + plan: plan([ + { + title: "M", + level: "basics", + tasks: [task("a1", { core: true, coreReason: "read me" }), task("a2")], + }, + ]), + resources: [resource("a1", "article"), resource("a2", "doc")], + }); + expect(withoutVideo[0].tasks[0].core).toBe(true); + expect(withoutVideo[0].tasks[0].coreReason).toBe("read me"); + }); + + it("caps core at five distinct videos while chapter tasks count once", () => { + const chapters = [ + { startSeconds: 0, title: "Intro", url: "" }, + { startSeconds: 100, title: "Body", url: "" }, + ]; + const resources = [ + resource("v1", "video", { chapters }), + resource("v2", "video"), + resource("v3", "video"), + resource("v4", "video"), + resource("v5", "video"), + resource("v6", "video"), + ]; + const modules = normalizeModules({ + plan: plan([ + { + title: "M", + level: "basics", + tasks: [ + task("v1", { startSeconds: 0, core: true }), + task("v1", { startSeconds: 100, core: true }), + task("v2", { core: true }), + task("v3", { core: true }), + task("v4", { core: true }), + task("v5", { core: true }), + task("v6", { core: true }), + ], + }, + ]), + resources, + }); + const coreIds = new Set( + modules[0].tasks.filter((entry) => entry.core).map((entry) => entry.resourceId), + ); + expect(coreIds).toEqual(new Set(["v1", "v2", "v3", "v4", "v5"])); + expect(modules[0].tasks.filter((entry) => entry.core && entry.resourceId === "v1")).toHaveLength(2); + }); + + it("promotes up to three resources when a level has no core", () => { + const modules = normalizeModules({ + plan: plan([ + { + title: "M", + level: "basics", + tasks: [task("v1"), task("v2"), task("v3"), task("v4")], + }, + ]), + resources: [ + resource("v1", "video"), + resource("v2", "video"), + resource("v3", "video"), + resource("v4", "video"), + ], + }); + const core = modules[0].tasks.filter((entry) => entry.core); + expect(new Set(core.map((entry) => entry.resourceId))).toEqual(new Set(["v1", "v2", "v3"])); + expect(core.every((entry) => entry.coreReason === CORE_STARTING_REASON)).toBe(true); + }); + + it("clears reasons on non-core tasks and fills empty core reasons", () => { + const modules = normalizeModules({ + plan: plan([ + { + title: "M", + level: "basics", + tasks: [ + task("v1", { core: true, coreReason: " " }), + task("v2", { core: true, coreReason: "covers the key idea" }), + ], + }, + ]), + resources: [resource("v1", "video"), resource("v2", "video")], + }); + expect(modules[0].tasks[0].coreReason).toBe(CORE_STARTING_REASON); + expect(modules[0].tasks[1].coreReason).toBe("covers the key idea"); + }); + + it("keeps at most three modules per level and merges the overflow", () => { + const resources = [ + resource("v1", "video"), + resource("v2", "video"), + resource("v3", "video"), + resource("v4", "video"), + ]; + const modules = normalizeModules({ + plan: plan([ + { title: "A", level: "basics", tasks: [task("v1", { core: true })] }, + { title: "B", level: "basics", tasks: [task("v2")] }, + { title: "C", level: "basics", tasks: [task("v3")] }, + { title: "D", level: "basics", tasks: [task("v4")] }, + ]), + resources, + }); + expect(modules).toHaveLength(3); + expect(modules[2].tasks.map((entry) => entry.resourceId)).toEqual(["v3", "v4"]); + }); + + it("lands missing resources in the last level as non-core and books in reading", () => { + const resources = [ + resource("v1", "video"), + resource("v2", "video"), + resource("b1", "book"), + ]; + const modules = normalizeModules({ + plan: plan([ + { title: "Deep", level: "advanced", tasks: [task("v1", { core: true })] }, + ]), + resources, + }); + const alsoIncluded = modules.find((module) => module.title === "Also included"); + expect(alsoIncluded?.level).toBe("advanced"); + expect(alsoIncluded?.tasks.every((entry) => !entry.core)).toBe(true); + const reading = modules.at(-1); + expect(reading?.track).toBe("reading"); + expect(reading?.tasks[0].resourceId).toBe("b1"); + }); +}); + +describe("buildRoadmap", () => { + it("writes level, track, core and priority onto sections and tasks", () => { + const resources = [resource("v1", "video"), resource("b1", "book")]; + const parsed = plan([ + { + title: "M", + level: "intermediate", + tasks: [ + task("v1", { core: true, coreReason: "key" }), + task("b1", { title: "Read b1" }), + ], + }, + ]); + const modules = normalizeModules({ plan: parsed, resources }); + const roadmap = buildRoadmap("pack1", "goal", parsed, modules, resources); + const moduleSections = roadmap.sections.filter( + (section) => section.type === "module", + ); + expect(moduleSections[0].level).toBe("intermediate"); + expect(moduleSections[0].metadata?.difficulty).toBe("intermediate"); + expect(moduleSections[0].data.tasks[0].core).toBe(true); + expect(moduleSections[0].data.tasks[0].priority).toBe("core"); + expect(moduleSections[0].data.tasks[0].coreReason).toBe("key"); + + const reading = moduleSections.at(-1); + expect(reading?.track).toBe("reading"); + expect(reading?.level).toBeUndefined(); + expect(reading?.data.tasks[0].priority).toBe("optional"); + expect(reading?.data.tasks[0].core).toBe(false); + }); +}); diff --git a/apps/web/src/lib/scout/db.ts b/apps/web/src/lib/scout/db.ts index 1badfe2..8b9b4b7 100644 --- a/apps/web/src/lib/scout/db.ts +++ b/apps/web/src/lib/scout/db.ts @@ -32,12 +32,21 @@ export function getScoutDb(): ScoutDatabase { db.pragma("journal_mode = WAL"); db.pragma("foreign_keys = ON"); db.exec(schema); + migrateResources(db); seedDefaults(db); store[globalKey] = { db, path }; return db; } +/** Existing databases gain columns the schema added since they were created. */ +function migrateResources(db: ScoutDatabase) { + const columns = db.prepare(`PRAGMA table_info(resources)`).all() as Array<{ name: string }>; + if (!columns.some((column) => column.name === "level")) { + db.exec(`ALTER TABLE resources ADD COLUMN level TEXT`); + } +} + export function closeScoutDb() { const store = globalThis as GlobalWithDb; const cached = store[globalKey]; diff --git a/apps/web/src/lib/scout/discovery/combine.ts b/apps/web/src/lib/scout/discovery/combine.ts index 129fffe..5d5f452 100644 --- a/apps/web/src/lib/scout/discovery/combine.ts +++ b/apps/web/src/lib/scout/discovery/combine.ts @@ -38,28 +38,43 @@ export async function discoverForGoal(input: { goal: string; limit?: number; llm const videoPool = clean(dedupeResources([...broadVideos, ...web.filter(item => item.kind === "video"), ...preferredVideos])); const webPool = clean(web.filter(item => item.kind !== "video")); - const maxVideos = Math.max(3, Math.ceil(limit * 0.7)); - const picked: DiscoveredResource[] = []; + return pickVideoFirst({ videos: videoPool, web: webPool, limit }); +} + +/** + * The pack is a video ladder, so videos come first. Articles and docs fill a + * small tail once the pack has enough videos; when videos are scarce they may + * take the rest of the slots. + */ +export function pickVideoFirst(input: { + videos: DiscoveredResource[]; + web: DiscoveredResource[]; + limit: number; +}): DiscoveredResource[] { + const limit = Math.max(0, Math.floor(input.limit)); const seen = new Set(); - const push = (item: DiscoveredResource) => { + const picked: DiscoveredResource[] = []; + + const push = (item: DiscoveredResource): boolean => { const key = canonicalResourceUrl(item.url); - if (seen.has(key) || picked.length >= limit) return; + if (seen.has(key) || picked.length >= limit) return false; seen.add(key); picked.push(item); + return true; }; - // Alternating keeps the pack from becoming ten videos or ten articles. - const maxLength = Math.max(videoPool.length, webPool.length); - for (let index = 0; index < maxLength; index += 1) { - if (videoPool[index] && picked.filter((item) => item.kind === "video").length < maxVideos) { - push(videoPool[index]); - } - if (webPool[index]) push(webPool[index]); + for (const video of dedupeResources(input.videos)) { if (picked.length >= limit) break; + push(video); } - for (const item of [...videoPool, ...webPool]) push(item); + const videoCount = picked.length; + const webCap = videoCount >= 3 ? Math.max(1, Math.floor(limit * 0.25)) : limit; + for (const item of dedupeResources(input.web)) { + if (picked.length >= limit || picked.length - videoCount >= webCap) break; + push(item); + } return picked; } diff --git a/apps/web/src/lib/scout/extract.ts b/apps/web/src/lib/scout/extract.ts index 4a0e28a..f569e92 100644 --- a/apps/web/src/lib/scout/extract.ts +++ b/apps/web/src/lib/scout/extract.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { LEARNING_LEVELS, type LearningLevel } from "@/types"; import { clip, deepLink, parseDescriptionChapters, quoteAppearsIn, normalizeForMatch, chunkText } from "./format"; import { scoutGenerateObject, ScoutError, type ScoutLlmConfig } from "./llm"; import { SCOUT_PROMPTS } from "./prompts"; @@ -25,6 +26,7 @@ export const extractionSchema = z.object({ }), ) .default([]), + level: z.enum(LEARNING_LEVELS).nullable().optional(), }); export type ExtractionOutput = z.infer; @@ -58,6 +60,7 @@ export interface ExtractionResult { keyPoints: KeyPoint[]; skills: string[]; chapters: Chapter[]; + level: LearningLevel | null; degraded: boolean; error?: string; } @@ -230,6 +233,32 @@ function mergeKeyPoints( return points.slice(0, 10); } +/** + * Chunks may disagree on a level; the majority wins. A tie goes to the + * higher of the tied levels so the ladder never starts too low. + */ +export function mergeExtractionLevels( + outputs: ReadonlyArray>, +): LearningLevel | null { + const counts = new Map(); + for (const output of outputs) { + if (!output.level) continue; + counts.set(output.level, (counts.get(output.level) ?? 0) + 1); + } + + let best: LearningLevel | null = null; + let bestCount = 0; + for (const level of LEARNING_LEVELS) { + const count = counts.get(level) ?? 0; + if (count === 0) continue; + if (count >= bestCount) { + best = level; + bestCount = count; + } + } + return best; +} + function mergeSummary(outputs: ExtractionOutput[], fallback: string): string { const seen = new Set(); const sentences: string[] = []; @@ -275,6 +304,7 @@ export async function extractResource(input: ExtractionInput): Promise 0 ? skills : uniqueSkills([input.resource.title]), chapters, + level: mergeExtractionLevels(outputs), degraded: false, }; } diff --git a/apps/web/src/lib/scout/pack.ts b/apps/web/src/lib/scout/pack.ts index dbe8ea1..79cc2de 100644 --- a/apps/web/src/lib/scout/pack.ts +++ b/apps/web/src/lib/scout/pack.ts @@ -1,4 +1,4 @@ -import type { Roadmap, SrsItem } from "@/types"; +import { LEARNING_LEVELS, type LearningLevel, type Roadmap, type SrsItem } from "@/types"; import { getScoutDb, newId, nowIso } from "./db"; import type { Chapter, KeyPoint, PackStatus, ResourceKind, ScoutPack, ScoutResource, TranscriptStatus } from "./types"; @@ -31,6 +31,7 @@ export interface ResourceRow { thumbnail_url: string | null; description: string | null; summary: string | null; + level: string | null; key_points: string; skill_tags: string; chapters: string; @@ -43,6 +44,10 @@ export interface ResourceRow { updated_at: string; } +function parseLevel(value: string | null): LearningLevel | null { + return LEARNING_LEVELS.find((level) => level === value) ?? null; +} + function parseJson(value: string | null | undefined, fallback: T): T { if (!value) return fallback; try { @@ -66,6 +71,7 @@ export function toScoutResource(row: ResourceRow, approved?: boolean): ScoutReso thumbnailUrl: row.thumbnail_url, description: row.description, summary: row.summary, + level: parseLevel(row.level), keyPoints: parseJson(row.key_points, []), skillTags: parseJson(row.skill_tags, []), chapters: parseJson(row.chapters, []), @@ -318,6 +324,7 @@ export function approvePackPicks(packId: string, picks: string[]): string[] { export interface ExtractionWrite { summary: string; + level?: LearningLevel | null; keyPoints: KeyPoint[]; skillTags: string[]; chapters: Chapter[]; @@ -332,7 +339,7 @@ export function saveResourceExtraction(id: string, data: ExtractionWrite) { getScoutDb() .prepare( `UPDATE resources SET - summary = ?, key_points = ?, skill_tags = ?, chapters = ?, + summary = ?, level = ?, key_points = ?, skill_tags = ?, chapters = ?, transcript = COALESCE(?, transcript), transcript_status = ?, transcript_chars = ?, duration_seconds = COALESCE(?, duration_seconds), @@ -341,6 +348,7 @@ export function saveResourceExtraction(id: string, data: ExtractionWrite) { ) .run( data.summary, + data.level ?? null, JSON.stringify(data.keyPoints), JSON.stringify(data.skillTags), JSON.stringify(data.chapters), diff --git a/apps/web/src/lib/scout/picks.ts b/apps/web/src/lib/scout/picks.ts new file mode 100644 index 0000000..1b59a2b --- /dev/null +++ b/apps/web/src/lib/scout/picks.ts @@ -0,0 +1,73 @@ +import { LEARNING_LEVELS, type LearningLevel } from "@/types"; +import type { ScoutResource } from "./types"; + +export type PickRole = "core" | "deeper" | "reading"; + +export interface PickGroup { + level: LearningLevel | "unsorted" | "reading"; + items: Array<{ resource: ScoutResource; role: PickRole }>; +} + +export const PICK_GROUP_LABEL: Record = { + basics: "Basics", + intermediate: "Intermediate", + advanced: "Advanced", + unsorted: "Level set when the plan is built", + reading: "Reading track", +}; + +export const PICK_ROLE_LABEL: Record = { + core: "Core", + deeper: "Go deeper", + reading: "Reading", +}; + +/** + * Groups approved picks the way the roadmap will treat them: levels first, + * unsorted picks next, books last as the reading track. Within a level the + * first few videos are core and the rest are optional depth. + */ +export function groupPicks(resources: ScoutResource[]): PickGroup[] { + const byLevel = new Map(); + const unsorted: ScoutResource[] = []; + const reading: ScoutResource[] = []; + + for (const resource of resources) { + if (resource.kind === "book") { + reading.push(resource); + } else if (resource.level) { + const bucket = byLevel.get(resource.level) ?? []; + bucket.push(resource); + byLevel.set(resource.level, bucket); + } else { + unsorted.push(resource); + } + } + + const groups: PickGroup[] = []; + const addLevel = (level: LearningLevel | "unsorted", items: ScoutResource[]) => { + if (items.length === 0) return; + const hasVideo = items.some((item) => item.kind === "video"); + const cap = hasVideo ? 5 : 3; + let coreCount = 0; + groups.push({ + level, + items: items.map((resource) => { + const eligible = hasVideo ? resource.kind === "video" : true; + const role: PickRole = eligible && coreCount < cap ? (coreCount += 1, "core") : "deeper"; + return { resource, role }; + }), + }); + }; + + for (const level of LEARNING_LEVELS) addLevel(level, byLevel.get(level) ?? []); + addLevel("unsorted", unsorted); + if (reading.length > 0) { + groups.push({ + level: "reading", + items: reading.map((resource) => ({ resource, role: "reading" as const })), + }); + } + + return groups; +} diff --git a/apps/web/src/lib/scout/pipeline.ts b/apps/web/src/lib/scout/pipeline.ts index 44e068b..23adf77 100644 --- a/apps/web/src/lib/scout/pipeline.ts +++ b/apps/web/src/lib/scout/pipeline.ts @@ -167,7 +167,7 @@ async function handleProcessResource(job: JobRow, payload: Record; -interface PlanModule { +export interface PlanModule { title: string; description: string; skill: string; + level: LearningLevel; + track?: "reading"; resourceIds: string[]; tasks: Array<{ title: string; @@ -75,6 +82,8 @@ interface PlanModule { startSeconds: number | null; endSeconds: number | null; estimatedMinutes: number | null; + core: boolean; + coreReason: string; }>; } @@ -208,6 +217,8 @@ function buildTask(input: { endSeconds: number | null; title?: string; estimatedMinutes?: number | null; + core: boolean; + coreReason: string; }) { const { resource, startSeconds, endSeconds } = input; const link = attachment(resource, startSeconds); @@ -221,8 +232,10 @@ function buildTask(input: { subtasks: [], attachments: link ? [link] : [], estimatedTime: `${Math.max(2, Math.min(300, Math.round(minutes)))} min`, - priority: "core", + priority: input.core ? "core" : "optional", + core: input.core, }; + if (input.coreReason) task.coreReason = input.coreReason; return task; } @@ -241,6 +254,7 @@ function fallbackPlan(goal: string, resources: ScoutResource[]): ScoutPlan { title: skill, description: `Work through ${items.length} free resource${items.length === 1 ? "" : "s"} on ${skill.toLowerCase()}.`, skill, + level: "basics", tasks: items.flatMap((resource): PlanTask[] => { if (resource.kind === "book") { const chapters = resource.chapters.length > 0 ? resource.chapters : [{ title: resource.title, startSeconds: 0, url: resource.url }]; @@ -250,6 +264,8 @@ function fallbackPlan(goal: string, resources: ScoutResource[]): ScoutPlan { startSeconds: null, endSeconds: null, estimatedMinutes: 45, + core: false, + coreReason: "", })); } if (resource.kind === "video") { @@ -260,6 +276,8 @@ function fallbackPlan(goal: string, resources: ScoutResource[]): ScoutPlan { startSeconds: chapter.startSeconds, endSeconds: chapter.endSeconds ?? null, estimatedMinutes: taskMinutes(resource, chapter.startSeconds, chapter.endSeconds ?? null), + core: false, + coreReason: "", })); } return [ @@ -269,6 +287,8 @@ function fallbackPlan(goal: string, resources: ScoutResource[]): ScoutPlan { startSeconds: null, endSeconds: null, estimatedMinutes: taskMinutes(resource, null, null), + core: false, + coreReason: "", }, ]; } @@ -279,6 +299,8 @@ function fallbackPlan(goal: string, resources: ScoutResource[]): ScoutPlan { startSeconds: null, endSeconds: null, estimatedMinutes: taskMinutes(resource, null, null), + core: false, + coreReason: "", }, ]; }), @@ -295,12 +317,72 @@ function fallbackPlan(goal: string, resources: ScoutResource[]): ScoutPlan { }; } -function normalizeModules(plan: ScoutPlan, resources: ScoutResource[]): PlanModule[] { +export const CORE_STARTING_REASON = "Chosen as a starting point for this level."; + +const MAX_CORE_RESOURCES = 5; +const PROMOTED_CORE_RESOURCES = 3; +const MAX_MODULES_PER_LEVEL = 3; + +function isCoreEligible(kind: ScoutResource["kind"], levelHasVideo: boolean) { + if (levelHasVideo) return kind === "video"; + return kind === "article" || kind === "doc"; +} + +/** Applies the level's core rules to its tasks in place, in task order. */ +function settleCore(tasks: PlanModule["tasks"], byId: Map) { + const levelHasVideo = tasks.some((task) => byId.get(task.resourceId)?.kind === "video"); + + for (const task of tasks) { + const kind = byId.get(task.resourceId)?.kind; + if (!kind || !isCoreEligible(kind, levelHasVideo)) task.core = false; + } + + const coreResources = new Set(); + for (const task of tasks) { + if (!task.core || coreResources.has(task.resourceId)) continue; + if (coreResources.size >= MAX_CORE_RESOURCES) { + task.core = false; + continue; + } + coreResources.add(task.resourceId); + } + + if (!tasks.some((task) => task.core)) { + const promoted = new Set(); + for (const task of tasks) { + const kind = byId.get(task.resourceId)?.kind; + if (!kind || !isCoreEligible(kind, levelHasVideo)) continue; + if (promoted.has(task.resourceId) || promoted.size >= PROMOTED_CORE_RESOURCES) continue; + promoted.add(task.resourceId); + task.core = true; + task.coreReason = CORE_STARTING_REASON; + } + } + + for (const task of tasks) { + if (!task.core) { + task.coreReason = ""; + continue; + } + task.coreReason = clip(task.coreReason, 140); + if (!task.coreReason) task.coreReason = CORE_STARTING_REASON; + } +} + +/** + * Whatever the model returned, the roadmap comes out of here as a ladder: + * level-ordered modules, books in their own track, and a small core set per + * level. Every rule below is deterministic so tests can pin it down. + */ +export function normalizeModules(input: { plan: ScoutPlan; resources: ScoutResource[] }): PlanModule[] { + const { plan, resources } = input; const byId = resourceMap(resources); const seen = new Set(); const modules: PlanModule[] = []; + const readingTasks: PlanModule["tasks"] = []; + const readingIds: string[] = []; - for (const module of plan.modules.slice(0, 6)) { + for (const module of plan.modules.slice(0, 12)) { const tasks: PlanModule["tasks"] = []; const resourceIds: string[] = []; @@ -316,17 +398,30 @@ function normalizeModules(plan: ScoutPlan, resources: ScoutResource[]): PlanModu const startSeconds = chapter?.startSeconds ?? null; const endSeconds = chapter?.endSeconds ?? null; - const key = `${resource.id}:${startSeconds ?? ""}`; + // Books have no timestamps, so their chapter tasks are keyed by title. + const key = resource.kind === "book" + ? `${resource.id}:read:${clip(task.title, 140).toLowerCase()}` + : `${resource.id}:${startSeconds ?? ""}`; if (seen.has(key)) continue; seen.add(key); - tasks.push({ + const normalized: PlanModule["tasks"][number] = { title: clip(task.title, 140), resourceId: resource.id, startSeconds, endSeconds, estimatedMinutes: task.estimatedMinutes ?? null, - }); + core: resource.kind === "book" ? false : task.core, + coreReason: resource.kind === "book" ? "" : task.coreReason, + }; + + if (resource.kind === "book") { + readingTasks.push(normalized); + readingIds.push(resource.id); + continue; + } + + tasks.push(normalized); resourceIds.push(resource.id); } @@ -335,32 +430,91 @@ function normalizeModules(plan: ScoutPlan, resources: ScoutResource[]): PlanModu title: clip(module.title || module.skill || "Module", 120), description: clip(module.description ?? "", 600), skill: module.skill ?? "", + level: module.level, resourceIds: unique(resourceIds), tasks, }); } + const levelRank = new Map(LEARNING_LEVELS.map((level, index) => [level, index])); + modules.sort((left, right) => (levelRank.get(left.level) ?? 0) - (levelRank.get(right.level) ?? 0)); + // Every approved resource must appear somewhere, even when the model - // forgot it. The appended module stays honest about why it exists. - const included = new Set(modules.flatMap(module => module.resourceIds)); + // forgot it. Books join the reading track; the rest get an honest module + // at the last level in the ladder. + const included = new Set([...modules.flatMap(module => module.resourceIds), ...readingIds]); const missing = resources.filter(resource => !included.has(resource.id)); - if (missing.length > 0) { + for (const resource of missing) { + if (resource.kind !== "book") continue; + const chapters = resource.chapters.length > 0 ? resource.chapters : [{ title: resource.title, startSeconds: 0, url: resource.url }]; + for (const chapter of chapters.slice(0, 10)) { + readingTasks.push({ + title: `Read ${clip(chapter.title, 120)}`, + resourceId: resource.id, + startSeconds: null, + endSeconds: null, + estimatedMinutes: taskMinutes(resource, null, null), + core: false, + coreReason: "", + }); + } + readingIds.push(resource.id); + } + const missingOther = missing.filter(resource => resource.kind !== "book"); + if (missingOther.length > 0) { modules.push({ title: "Also included", description: "Resources from your approved picks that did not fit an earlier module.", skill: "Additional practice", - resourceIds: missing.map((resource) => resource.id), - tasks: missing.map((resource) => ({ + level: modules.at(-1)?.level ?? "basics", + resourceIds: missingOther.map((resource) => resource.id), + tasks: missingOther.map((resource) => ({ title: `${resource.kind === "video" ? "Watch" : "Read"} ${resource.title}`, resourceId: resource.id, startSeconds: null, endSeconds: null, estimatedMinutes: taskMinutes(resource, null, null), + core: false, + coreReason: "", })), }); } - return modules; + // At most three modules per level; overflow modules merge into the last + // kept module of their level so no task is dropped. + const capped: PlanModule[] = []; + const keptByLevel = new Map(); + for (const module of modules) { + const kept = keptByLevel.get(module.level) ?? []; + if (kept.length < MAX_MODULES_PER_LEVEL) { + kept.push(module); + keptByLevel.set(module.level, kept); + capped.push(module); + continue; + } + const target = kept[kept.length - 1]; + target.tasks.push(...module.tasks); + target.resourceIds = unique([...target.resourceIds, ...module.resourceIds]); + } + + for (const level of LEARNING_LEVELS) { + const levelTasks = capped.filter(module => module.level === level).flatMap(module => module.tasks); + if (levelTasks.length > 0) settleCore(levelTasks, byId); + } + + if (readingTasks.length > 0) { + capped.push({ + title: "Reading track", + description: "Books stay a separate track: read them alongside the ladder, in any order.", + skill: "Reading", + level: "basics", + track: "reading", + resourceIds: unique(readingIds), + tasks: readingTasks, + }); + } + + return capped; } function estimatedDuration(tasks: Task[]) { @@ -375,7 +529,7 @@ function estimatedDuration(tasks: Task[]) { return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`; } -function buildRoadmap( +export function buildRoadmap( packId: string, goal: string, plan: ScoutPlan, @@ -404,6 +558,8 @@ function buildRoadmap( endSeconds: task.endSeconds, title: task.title, estimatedMinutes: task.estimatedMinutes, + core: task.core, + coreReason: task.coreReason, }), ); }); @@ -418,10 +574,12 @@ function buildRoadmap( title: module.title, order: moduleIndex, type: "module", + ...(module.track === "reading" ? { track: "reading" as const } : { level: module.level }), metadata: { estimatedDuration: estimatedDuration(tasks), taskCount: tasks.length, keyOutcome: module.skill || undefined, + difficulty: module.level, }, data: { description: module.description, @@ -621,6 +779,7 @@ export async function buildWorkspace(input: BuildWorkspaceInput): Promise section.type === "module"); diff --git a/apps/web/src/lib/scout/prompts.ts b/apps/web/src/lib/scout/prompts.ts index 7750ce6..55a31db 100644 --- a/apps/web/src/lib/scout/prompts.ts +++ b/apps/web/src/lib/scout/prompts.ts @@ -1,5 +1,5 @@ -import extractRaw from "../../../prompts/scout/extract.v1.md?raw"; -import planRaw from "../../../prompts/scout/plan.v1.md?raw"; +import extractRaw from "../../../prompts/scout/extract.v2.md?raw"; +import planRaw from "../../../prompts/scout/plan.v2.md?raw"; import quizRaw from "../../../prompts/scout/quiz.v1.md?raw"; import bookRaw from "../../../prompts/scout/book.v1.md?raw"; @@ -24,7 +24,7 @@ function parsePrompt(raw: string): ScoutPrompt { } /** - * Prompts live in prompts/scout/*.v1.md so a change is reviewable in a diff. + * Prompts live in prompts/scout/*.vN.md so a change is reviewable in a diff. * Bump the filename when the contract changes. */ export const SCOUT_PROMPTS = { diff --git a/apps/web/src/lib/scout/schema.sql b/apps/web/src/lib/scout/schema.sql index 994f0fc..4c19a91 100644 --- a/apps/web/src/lib/scout/schema.sql +++ b/apps/web/src/lib/scout/schema.sql @@ -46,6 +46,7 @@ CREATE TABLE IF NOT EXISTS resources ( thumbnail_url TEXT, description TEXT, summary TEXT, + level TEXT, key_points TEXT NOT NULL DEFAULT '[]', skill_tags TEXT NOT NULL DEFAULT '[]', chapters TEXT NOT NULL DEFAULT '[]', diff --git a/apps/web/src/lib/scout/types.ts b/apps/web/src/lib/scout/types.ts index 0c599cd..f771a08 100644 --- a/apps/web/src/lib/scout/types.ts +++ b/apps/web/src/lib/scout/types.ts @@ -6,6 +6,8 @@ * transcript field. */ +import type { LearningLevel } from "@/types"; + export type ScoutSourceKind = "youtube_channel" | "site" | "creator" | "book"; export interface ScoutSource { @@ -50,6 +52,7 @@ export interface ScoutResource { thumbnailUrl?: string | null; description?: string | null; summary?: string | null; + level?: LearningLevel | null; keyPoints: KeyPoint[]; skillTags: string[]; chapters: Chapter[]; diff --git a/apps/web/src/styles/learning.css b/apps/web/src/styles/learning.css index ef645ee..b360d2d 100644 --- a/apps/web/src/styles/learning.css +++ b/apps/web/src/styles/learning.css @@ -1543,3 +1543,1180 @@ transform: none; } } + +/* ============================================================ + Learn · Desk + A paper workbench for one lesson. Scoped to .sy-desk so the + rest of the app keeps the dark shell. + ============================================================ */ +.sy-main:has(> .sy-desk) { + padding: 0; +} +.sy-desk { + --color-page: #f7f6f2; + --color-surface: #ffffff; + --color-surface-subtle: #f1efe8; + --color-raised: #eae7dd; + --color-border: #e0ded5; + --color-border-strong: #c9c6ba; + --color-text: #1e2025; + --color-text-muted: #5d5f57; + --color-text-soft: #7a7c73; + --color-accent: #3f4cc4; + --color-accent-strong: #2f3aa8; + --color-accent-soft: #eef0fd; + position: relative; + min-width: 0; + min-height: 100dvh; + background: #f7f6f2; + color: var(--color-text); + color-scheme: light; +} +.sy-desk button, +.sy-desk summary { + cursor: pointer; +} +.sy-desk button:focus-visible, +.sy-desk a:focus-visible, +.sy-desk input:focus-visible, +.sy-desk textarea:focus-visible, +.sy-desk summary:focus-visible { + outline: 2px solid #3f4cc4; + outline-offset: 2px; +} +.sy-desk-mono { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} + +/* Top bar */ +.sy-desk-top { + position: sticky; + top: 0; + z-index: 30; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + height: 54px; + padding: 0 24px; + background: rgba(247, 246, 242, 0.92); + border-bottom: 1px solid #e0ded5; + backdrop-filter: blur(10px); +} +.sy-desk-crumb { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + font-size: 12px; + color: #7a7c73; +} +.sy-desk-crumb a { + color: inherit; + flex: none; +} +.sy-desk-crumb a:hover { + color: #1e2025; +} +.sy-desk-crumb-sep { + color: #b5b1a5; + flex: none; +} +.sy-desk-crumb-topic { + flex: 0 1 auto; + min-width: 0; + max-width: 22ch; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.sy-desk-crumb b { + flex: 0 1 auto; + min-width: 0; + color: #2a2c32; + font-weight: 550; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.sy-desk-top-right { + display: flex; + align-items: center; + gap: 9px; + flex: none; +} +.sy-desk-count { + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: 0.08em; + color: #8a8c84; + white-space: nowrap; +} +.sy-desk-top .sy-button { + min-height: 32px; + font-size: 12px; + gap: 6px; + white-space: nowrap; +} +.sy-desk-top .sy-button svg { + width: 13px; + height: 13px; +} +.sy-desk-alert { + padding: 16px 32px 0; +} + +/* Two-column workbench */ +.sy-desk-grid { + display: grid; + grid-template-columns: 340px minmax(0, 1fr); + align-items: start; + gap: 30px; + width: 100%; + max-width: 1440px; + margin: 0 auto; + padding: 26px 32px 90px; +} +.sy-desk-work { + display: grid; + gap: 10px; + min-width: 0; + position: sticky; + top: 76px; + max-height: calc(100dvh - 94px); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: #d5d2c7 transparent; + padding-bottom: 2px; +} +.sy-desk-work > * { + min-width: 0; +} +.sy-desk-card { + background: #fff; + border: 1px solid #e0ded5; + border-radius: 8px; + overflow: hidden; +} +.sy-desk-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 14px; + border-bottom: 1px solid #eae7dd; + font-size: 10.5px; + line-height: 1.4; + letter-spacing: 0.07em; + text-transform: uppercase; + color: #7a7c73; +} +.sy-desk-card-head b { + font-weight: 600; + color: #5d5f57; +} + +/* Video */ +.sy-desk-live { + display: inline-flex; + align-items: center; + gap: 7px; + min-width: 0; +} +.sy-desk-live i { + width: 6px; + height: 6px; + border-radius: 50%; + background: #3f4cc4; + flex: none; +} +.sy-desk-video-body { + background: #0d0d0f; +} +.sy-desk-video-body .sy-embed { + border-radius: 0; +} +.sy-desk-video-body .sy-error { + margin: 10px; +} +.sy-desk-video-empty { + padding: 26px 18px; + background: #fbfaf7; + border-bottom: 1px solid #eae7dd; +} +.sy-desk-video-empty p { + font-size: 12.5px; + line-height: 1.65; + color: #7a7c73; +} +.sy-desk-video-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; +} +.sy-desk-video-foot > div { + min-width: 0; +} +.sy-desk-video-foot b { + display: block; + font-size: 12.5px; + font-weight: 550; + line-height: 1.45; + overflow-wrap: anywhere; +} +.sy-desk-video-foot span { + display: block; + margin-top: 2px; + font-size: 11px; + color: #8a8c84; + font-variant-numeric: tabular-nums; +} +.sy-desk-external { + display: inline-flex; + align-items: center; + gap: 5px; + flex: none; + font-size: 11px; + color: #7a7c73; +} +.sy-desk-external:hover { + color: #1e2025; +} +.sy-desk-manual { + border-top: 1px solid #eae7dd; + padding: 0 14px 12px; +} +.sy-desk-manual summary { + padding: 9px 0; + font-size: 11px; + line-height: 1.5; + color: #8a8c84; +} +.sy-desk-manual summary:hover { + color: #1e2025; +} +.sy-desk-manual-row { + display: flex; + align-items: flex-end; + gap: 8px; +} +.sy-desk-manual-row label { + display: grid; + gap: 5px; + font-size: 10.5px; + color: #8a8c84; +} +.sy-desk-manual-row .sy-input { + width: 88px; + min-height: 34px; + padding: 4px 8px; + font-size: 12px; + background: #fbfaf7; +} +.sy-desk-manual-row .sy-button { + min-height: 34px; + font-size: 12px; +} + +/* Chapters */ +.sy-desk-chapters ol { + list-style: none; + margin: 0; + padding: 4px 0; +} +.sy-desk-chapters li + li { + border-top: 1px solid #f4f2ec; +} +.sy-desk-chapters button { + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + gap: 9px; + width: 100%; + padding: 8px 14px; + text-align: left; + font-size: 12.5px; + line-height: 1.5; + color: #4c4e47; +} +.sy-desk-chapters button:hover { + background: #fbfaf7; + color: #1e2025; +} +.sy-desk-chapters button[aria-current="true"] { + background: #f6f5ef; + color: #1e2025; + font-weight: 550; + box-shadow: inset 2px 0 0 #3f4cc4; +} +.sy-desk-chapters button .sy-desk-mono { + font-size: 10.5px; + color: #8a8c84; + padding-top: 2px; +} + +/* Notes */ +.sy-desk-note-form { + display: flex; + align-items: flex-end; + gap: 7px; + padding: 12px 14px; + border-bottom: 1px solid #f4f2ec; +} +.sy-desk-note-form textarea { + flex: 1; + min-width: 0; + min-height: 36px; + max-height: 140px; + padding: 8px 10px; + resize: vertical; + background: #fbfaf7; + color: #1e2025; + border: 1px solid #dcd9cf; + border-radius: 6px; + font: 12.5px/1.5 var(--font-sans); + field-sizing: content; +} +.sy-desk-note-form textarea::placeholder { + color: #a3a59b; +} +.sy-desk-note-form textarea:focus-visible { + outline-offset: 0; + border-color: #3f4cc4; +} +.sy-desk-note-form button { + display: grid; + place-items: center; + width: 36px; + height: 36px; + flex: none; + border-radius: 6px; + background: #1e2025; + color: #fff; +} +.sy-desk-note-form button:disabled { + opacity: 0.3; + cursor: default; +} +.sy-desk-notes-empty { + padding: 13px 14px; + font-size: 11.5px; + line-height: 1.6; + color: #8a8c84; +} +.sy-desk-note-list { + list-style: none; + margin: 0; + padding: 0; +} +.sy-desk-note-list li { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: start; + gap: 9px; + padding: 10px 14px; + border-bottom: 1px solid #f4f2ec; +} +.sy-desk-note-list li:last-child { + border-bottom: 0; +} +.sy-desk-note-time { + padding-top: 2px; + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; + font-size: 10.5px; + color: #3f4cc4; + white-space: nowrap; +} +.sy-desk-note-time:hover { + text-decoration: underline; +} +.sy-desk-note-list p { + font-size: 12.5px; + line-height: 1.6; + color: #3a3c35; + overflow-wrap: anywhere; + white-space: pre-wrap; +} +.sy-desk-note-del { + padding: 2px; + color: #c2bfb3; +} +.sy-desk-note-del:hover { + color: #a3324a; +} + +/* Lessons */ +.sy-desk-lessons ol { + list-style: none; + margin: 0; + padding: 0; +} +.sy-desk-lessons li + li { + border-top: 1px solid #f4f2ec; +} +.sy-desk-lessons button { + display: grid; + grid-template-columns: 22px minmax(0, 1fr) 8px; + align-items: center; + gap: 9px; + width: 100%; + padding: 9px 14px; + text-align: left; + font-size: 12.5px; + line-height: 1.5; + color: #5d5f57; +} +.sy-desk-lessons button:hover { + background: #fbfaf7; + color: #1e2025; +} +.sy-desk-lessons button[aria-current="step"] { + background: #f6f5ef; + color: #1e2025; + font-weight: 550; +} +.sy-desk-lessons button .sy-desk-mono { + font-size: 10px; + color: #a8aa9f; +} +.sy-desk-pip { + justify-self: end; + width: 6px; + height: 6px; + border: 1px solid #cfccc0; + border-radius: 50%; +} +.sy-desk-pip[data-done] { + background: #3f4cc4; + border-color: #3f4cc4; +} + +/* Ladder levels inside the lessons card */ +.sy-desk-lessons .sy-desk-level-head { + display: flex; + grid-template-columns: none; + align-items: center; + justify-content: space-between; + gap: 10px; + font-weight: 550; + color: #1e2025; + border-top: 1px solid #f4f2ec; +} +.sy-desk-level:first-child .sy-desk-level-head { + border-top: 0; +} +.sy-desk-level-head .sy-desk-mono { + font-size: 10px; + color: #a8aa9f; +} +.sy-desk-level-tag { + font-family: var(--font-mono); + font-size: 9.5px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #8a8c84; +} +.sy-desk-levelup { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 14px; + font-size: 12.5px; + font-weight: 550; + color: #3f4cc4; + border-top: 1px solid #f4f2ec; +} +.sy-desk-levelup svg { + width: 14px; + height: 14px; +} +.sy-desk-levelup:hover { + background: #fbfaf7; +} +.sy-desk-deeper-head { + padding: 10px 14px 4px; + font-family: var(--font-mono); + font-size: 9.5px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #a8aa9f; +} +.sy-desk-reason { + display: block; + margin-top: 1px; + font-size: 11px; + color: #a8aa9f; +} +.sy-desk-recall { + padding: 16px; + margin-bottom: 16px; + display: flex; + flex-direction: column; + gap: 10px; +} +.sy-desk-recall-hint { + margin: 0; + font-size: 12.5px; + color: #7a7c73; +} + +/* Pomodoro, detuned to fit a card */ +.sy-desk-pomodoro-card { + padding: 14px 14px 16px; +} +.sy-desk-pomodoro-card .sy-pomodoro { + padding: 0; + margin: 0; + border: 0; +} +.sy-desk-pomodoro-card .sy-timer-clock { + font-size: 30px; + letter-spacing: -0.8px; + margin: 8px 0 12px; +} +.sy-desk-pomodoro-card .sy-timer-actions { + gap: 8px; +} +.sy-desk .sy-timer-settings input { + background: #fbfaf7; + color: #1e2025; + border-color: #dcd9cf; +} + +/* Lesson claim */ +.sy-desk-panel { + min-width: 0; +} +.sy-desk-claim { + padding: 4px 2px 0; +} +.sy-desk-claim-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} +.sy-desk-eyebrow { + margin: 0; + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #8a8c84; +} +.sy-desk-done { + display: inline-flex; + align-items: center; + gap: 7px; + flex: none; + font-size: 11.5px; + color: #7a7c73; +} +.sy-desk-done input { + width: 15px; + height: 15px; + margin: 0; + accent-color: #3f4cc4; +} +.sy-desk-done:has(input:checked) { + color: #1e2025; +} +.sy-desk-claim h1 { + margin-top: 10px; + font-size: 34px; + font-weight: 560; + letter-spacing: -1.1px; + line-height: 1.14; + text-wrap: balance; +} +.sy-desk-claim-meta { + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + margin-top: 12px; + font-size: 12px; + color: #7a7c73; +} +.sy-desk-claim-meta span + span::before { + content: "·"; + margin-right: 10px; + color: #c9c6ba; +} + +/* Tabs */ +.sy-desk-tabs { + position: sticky; + top: 54px; + z-index: 20; + display: flex; + align-items: center; + gap: 20px; + margin-top: 20px; + padding: 0 2px; + background: #f7f6f2; + border-bottom: 1px solid #e0ded5; +} +.sy-desk-tabs button { + display: inline-flex; + align-items: center; + gap: 7px; + margin-bottom: -1px; + padding: 10px 1px 11px; + border-bottom: 2px solid transparent; + background: none; + font-size: 13px; + font-weight: 500; + color: #7a7c73; + white-space: nowrap; +} +.sy-desk-tabs button:hover { + color: #1e2025; +} +.sy-desk-tabs button[aria-selected="true"] { + color: #1e2025; + border-bottom-color: #1e2025; +} +.sy-desk-tab-count { + font-family: var(--font-mono); + font-size: 10px; + line-height: 1; + color: #5d5f57; + background: #eae7dd; + border-radius: 20px; + padding: 2px 6px; +} + +/* Reading surface */ +.sy-desk-surface { + background: #fff; + border: 1px solid #e0ded5; + border-top: 0; + border-radius: 0 0 8px 8px; +} +.sy-desk-tab { + padding: 30px 38px 44px; +} +.sy-desk-tab[hidden] { + display: none; +} +.sy-desk-copy { + max-width: 700px; +} +.sy-desk-lede { + font-size: 17px; + line-height: 1.68; + color: #2f312c; + letter-spacing: -0.1px; +} +.sy-desk-block { + margin-top: 36px; +} +.sy-desk-block > h2 { + margin-bottom: 14px; + font-family: var(--font-mono); + font-size: 10.5px; + font-weight: 500; + letter-spacing: 0.11em; + text-transform: uppercase; + color: #7a7c73; +} +.sy-desk-block .sy-desk-hint { + margin-top: -4px; + margin-bottom: 14px; +} +.sy-desk-prose p { + font-size: 15px; + line-height: 1.78; + color: #33352f; +} +.sy-desk-prose p + p { + margin-top: 14px; +} +.sy-desk-prose b { + font-weight: 600; + color: #1e2025; +} +.sy-desk-objectives { + list-style: none; + margin: 18px 0 0; + padding: 0; + border-top: 1px solid #eae7dd; +} +.sy-desk-objectives li { + display: grid; + grid-template-columns: 30px minmax(0, 1fr); + gap: 10px; + padding: 11px 0; + border-bottom: 1px solid #eae7dd; + font-size: 14px; + line-height: 1.6; + color: #3a3c35; +} +.sy-desk-objectives .sy-desk-mono { + padding-top: 3px; + font-size: 10.5px; + color: #a8aa9f; +} + +/* Sources */ +.sy-desk-source-list { + display: grid; + gap: 8px; +} +.sy-desk-source { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 12px 14px; + border: 1px solid #e0ded5; + border-radius: 7px; + background: #fbfaf7; + color: inherit; + transition: + border-color 0.12s ease, + background 0.12s ease; +} +.sy-desk-source:hover { + border-color: #c9c6ba; + background: #fff; +} +.sy-desk-source-type { + font-size: 9.5px; + letter-spacing: 0.07em; + text-transform: uppercase; + color: #7a7c73; + background: #fff; + border: 1px solid #e0ded5; + border-radius: 4px; + padding: 3px 6px; +} +.sy-desk-source-body { + min-width: 0; +} +.sy-desk-source-body b { + display: block; + font-size: 13.5px; + font-weight: 550; + line-height: 1.45; + overflow-wrap: anywhere; +} +.sy-desk-source-body small { + display: block; + margin-top: 2px; + font-size: 11.5px; + line-height: 1.55; + color: #7a7c73; +} +.sy-desk-source > svg { + color: #a8aa9f; + flex: none; +} + +/* Check tab */ +.sy-desk-tab-head h2 { + font-size: 21px; + font-weight: 550; + letter-spacing: -0.4px; + line-height: 1.25; +} +.sy-desk-tab-head p { + margin-top: 8px; + font-size: 14px; + line-height: 1.7; + color: #7a7c73; +} +.sy-desk-subtasks { + list-style: none; + margin: 22px 0 0; + padding: 0; + border-top: 1px solid #eae7dd; +} +.sy-desk-subtasks li { + border-bottom: 1px solid #eae7dd; +} +.sy-desk-subtasks label { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 13px 0; + font-size: 14.5px; + line-height: 1.55; + color: #2f312c; + cursor: pointer; +} +.sy-desk-subtasks input { + flex: none; + width: 16px; + height: 16px; + margin: 3px 0 0; + accent-color: #3f4cc4; +} +.sy-desk-subtasks label:has(input:checked) span { + color: #8a8c84; + text-decoration: line-through; + text-decoration-color: #c9c6ba; +} + +/* Apply tab */ +.sy-desk-field { + display: grid; + gap: 8px; + margin-top: 22px; +} +.sy-desk-field > span { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.09em; + text-transform: uppercase; + color: #7a7c73; +} +.sy-desk .sy-textarea { + min-height: 150px; + padding: 13px 14px; + background: #fff; + color: #1e2025; + border-color: #dcd9cf; + font-size: 14.5px; + line-height: 1.7; +} +.sy-desk .sy-textarea::placeholder { + color: #a3a59b; +} +.sy-desk .sy-textarea:focus-visible { + outline-offset: 0; + border-color: #3f4cc4; +} +.sy-desk-actions { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + margin-top: 16px; +} +.sy-desk-review > .sy-button.quiet { + margin-top: 2px; + padding: 8px 14px; + background: #fff; + color: #1e2025; + border: 1px solid #c9c6ba; + border-radius: 6px; +} +.sy-desk-review > .sy-button.quiet:hover { + background: #fbfaf7; + border-color: #9a978a; +} +.sy-desk-review > .sy-form { + padding: 18px; + background: #fbfaf7; + border: 1px solid #e0ded5; + border-radius: 8px; +} +.sy-desk-saved { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: #5d5f57; +} +.sy-desk-hint { + font-size: 13px; + line-height: 1.7; + color: #7a7c73; +} +.sy-desk-next { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + margin-top: 40px; + padding-top: 22px; + border-top: 1px solid #eae7dd; +} +.sy-desk-next > div { + min-width: 0; +} +.sy-desk-next p { + margin-top: 5px; + font-size: 15px; + font-weight: 550; + line-height: 1.45; + overflow-wrap: anywhere; +} +.sy-desk-next .sy-button { + flex: none; +} + +/* Shared controls, re-tuned for paper */ +.sy-desk .sy-button.primary { + background: #1e2025; + color: #fff; +} +.sy-desk .sy-button.primary:hover:not(:disabled) { + background: #000; +} +.sy-desk .sy-button.primary:disabled { + opacity: 1; + background: #eceae3; + color: #9a9c93; +} +.sy-desk .sy-button.secondary { + background: #fff; + color: #1e2025; + border: 1px solid #c9c6ba; +} +.sy-desk .sy-button.secondary:hover:not(:disabled) { + border-color: #9a978a; + background: #fbfaf7; +} +.sy-desk .sy-button.quiet { + color: #5d5f57; +} +.sy-desk .sy-button.quiet:hover { + color: #1e2025; + background: #eae7dd; +} +.sy-desk .sy-button.icon { + background: #f1efe8; + color: #1e2025; +} +.sy-desk .sy-input { + background: #fff; + color: #1e2025; + border-color: #dcd9cf; +} +.sy-desk .sy-form label { + color: #3a3c35; +} +.sy-desk .sy-form-hint { + color: #7a7c73; +} +.sy-desk .sy-card-actions { + margin-top: 18px; +} +.sy-desk .sy-error { + color: #a3324a; + background: #fdf5f6; + border-color: #e3c4ca; +} +.sy-desk .sy-empty { + color: #7a7c73; +} +.sy-desk-empty { + padding: 12px 0; + font-size: 13.5px; + line-height: 1.7; + color: #7a7c73; +} + +/* Loading and empty states */ +.sy-desk-simple { + display: grid; + place-items: center; + padding: 80px 24px; + text-align: center; +} +.sy-desk-simple-inner { + max-width: 460px; +} +.sy-desk-simple h1 { + margin-top: 10px; + font-size: 27px; + letter-spacing: -0.7px; + line-height: 1.25; +} +.sy-desk-simple .sy-desk-lede { + margin-top: 12px; + font-size: 14.5px; + color: #5d5f57; +} +.sy-desk-simple .sy-desk-actions { + justify-content: center; + margin-top: 22px; +} + +/* ---- Responsive ---- */ +@media (max-width: 1240px) { + .sy-desk-grid { + grid-template-columns: 304px minmax(0, 1fr); + gap: 24px; + padding-inline: 24px; + } + .sy-desk-tab { + padding: 26px 30px 40px; + } +} +@media (max-width: 1100px) { + .sy-desk-grid { + grid-template-columns: 280px minmax(0, 1fr); + gap: 22px; + } + .sy-desk-claim h1 { + font-size: 30px; + } +} +@media (max-width: 1024px) { + /* The mobile nav is sticky here, so the page bar must scroll away. */ + .sy-desk-top, + .sy-desk-tabs { + position: static; + } + .sy-desk-alert { + padding: 16px 24px 0; + } + .sy-desk-claim h1 { + font-size: 32px; + } +} +@media (max-width: 980px) { + .sy-desk-grid { + grid-template-columns: minmax(0, 1fr); + gap: 16px; + padding: 18px 24px 80px; + } + .sy-desk-work { + display: contents; + } + .sy-desk-video { + order: 1; + } + .sy-desk-chapters { + order: 2; + } + .sy-desk-panel { + order: 3; + } + .sy-desk-notes { + order: 4; + } + .sy-desk-lessons { + order: 5; + } + .sy-desk-pomodoro-card { + order: 6; + } + .sy-desk-tabs { + margin-top: 18px; + } + .sy-desk-note-form textarea { + min-height: 40px; + } + .sy-desk-pip { + width: 7px; + height: 7px; + } +} +@media (max-width: 760px) { + .sy-desk-top { + height: auto; + min-height: 48px; + padding: 8px 20px; + } + .sy-desk-claim h1 { + font-size: 27px; + letter-spacing: -0.8px; + } + .sy-desk-grid { + padding: 14px 20px 72px; + } + .sy-desk-tab { + padding: 22px 22px 32px; + } + .sy-desk-lede { + font-size: 15.5px; + } + .sy-desk-prose p { + font-size: 14.5px; + } + .sy-desk-tab-head h2 { + font-size: 19px; + } + .sy-desk-claim-meta { + font-size: 11.5px; + } + .sy-desk-alert { + padding: 14px 20px 0; + } +} +@media (max-width: 600px) { + .sy-desk-top { + padding: 8px 16px; + gap: 10px; + } + .sy-desk-crumb-topic, + .sy-desk-crumb-sep, + .sy-desk-count { + display: none; + } + .sy-desk-edit-label { + display: none; + } + .sy-desk-top .sy-button { + min-height: 30px; + padding: 4px 6px; + } + .sy-desk-claim { + padding: 2px 0 0; + } + .sy-desk-claim h1 { + font-size: 24px; + letter-spacing: -0.6px; + } + .sy-desk-claim-top { + gap: 10px; + } + .sy-desk-tabs { + gap: 18px; + overflow-x: auto; + scrollbar-width: none; + } + .sy-desk-tabs::-webkit-scrollbar { + display: none; + } + .sy-desk-grid { + padding: 12px 16px 64px; + } + .sy-desk-tab { + padding: 20px 16px 28px; + } + .sy-desk-surface { + border-radius: 0 0 6px 6px; + } + .sy-desk-source { + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px 10px; + padding: 11px 12px; + } + .sy-desk-source-type { + grid-column: 1 / -1; + justify-self: start; + } + .sy-desk-objectives li { + grid-template-columns: 24px minmax(0, 1fr); + } + .sy-desk-next { + gap: 12px; + margin-top: 30px; + } + .sy-desk-next .sy-button { + width: 100%; + } + .sy-desk-actions .sy-button { + width: 100%; + } + .sy-desk-simple { + padding: 56px 20px; + } + .sy-desk-simple h1 { + font-size: 23px; + } + .sy-desk-alert { + padding: 12px 16px 0; + } +} diff --git a/apps/web/src/types/index.ts b/apps/web/src/types/index.ts index a5f786d..bc90718 100644 --- a/apps/web/src/types/index.ts +++ b/apps/web/src/types/index.ts @@ -1,5 +1,9 @@ /* ZNS RoadMap Studio type definitions */ +/* Video ladder levels, lowest to highest */ +export const LEARNING_LEVELS = ["basics", "intermediate", "advanced"] as const; +export type LearningLevel = (typeof LEARNING_LEVELS)[number]; + /* Section types */ export type SectionType = | "module" @@ -72,6 +76,10 @@ export type Section = export interface ModuleSection extends SectionBase { type: "module"; + /** Ladder level for this module; absent on reading-track modules. */ + level?: LearningLevel; + /** Marks the module as the separate reading track. */ + track?: "reading"; data: { description: string; estimatedTime?: string; @@ -164,6 +172,8 @@ export interface Task { description?: string; estimatedTime?: string; priority?: "core" | "optional" | "advanced" | string; + core?: boolean; + coreReason?: string; done?: boolean; } diff --git a/apps/web/src/views/studio/home.tsx b/apps/web/src/views/studio/home.tsx index 0f57ad5..a201d14 100644 --- a/apps/web/src/views/studio/home.tsx +++ b/apps/web/src/views/studio/home.tsx @@ -1,7 +1,8 @@ import { Link } from "@tanstack/react-router"; import { ArrowRight, BookOpen, Play, Telescope } from "lucide-react"; import { useLearningRoadmaps } from "@/hooks/useLearningRoadmaps"; -import { getLearningTasks, nextLearningTask, taskVideo } from "@/lib/learning"; +import { taskVideo } from "@/lib/learning"; +import { getLadder, levelUpReady, LEVEL_LABEL, nextCoreItem } from "@/lib/ladder"; import { getYouTubeId, getVideoStartSeconds } from "@/lib/video-utils"; import { personalEntries, usePersonalData } from "@/lib/personal/store"; import { getStorage } from "@/lib/storage"; @@ -14,30 +15,38 @@ export default function HomePage() { const positions = personalEntries(records, "study") .map((item) => item.data) .sort((a, b) => b.lastOpenedAt.localeCompare(a.lastOpenedAt)); - const recent = positions.find((position) => - roadmaps.some( - (roadmap) => - roadmap.id === position.roadmapId && nextLearningTask(roadmap), - ), - ); + const recentRoadmap = positions + .map((position) => + roadmaps.find((roadmap) => roadmap.id === position.roadmapId), + ) + .find((roadmap) => roadmap && nextCoreItem(roadmap)); const resumeRoadmap = - roadmaps.find((roadmap) => roadmap.id === recent?.roadmapId) ?? - roadmaps.find((roadmap) => nextLearningTask(roadmap)); - const recentTask = resumeRoadmap - ? getLearningTasks(resumeRoadmap).find( - (item) => - item.task.id === recent?.taskId && - !item.task.completed && - !item.task.done, - ) - : null; - const resumeTask = - recentTask ?? (resumeRoadmap ? nextLearningTask(resumeRoadmap) : null); + recentRoadmap ?? roadmaps.find((roadmap) => nextCoreItem(roadmap)); + const ladder = resumeRoadmap ? getLadder(resumeRoadmap) : null; + const resumeTask = resumeRoadmap ? nextCoreItem(resumeRoadmap) : null; + const resumeLevel = ladder?.levels.find((level) => + level.items.some((entry) => entry.task.id === resumeTask?.task.id), + ); + const levelUp = ladder ? levelUpReady(ladder) : null; + const lastRoadmap = + positions + .map((position) => + roadmaps.find((roadmap) => roadmap.id === position.roadmapId), + ) + .find((roadmap) => roadmap !== undefined) ?? + [...roadmaps].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0] ?? + null; const video = resumeTask ? taskVideo(resumeTask) : null; const youtubeId = video ? getYouTubeId(video.url) : null; + const studyEntry = + resumeRoadmap && resumeTask + ? records[`study:${resumeRoadmap.id}:${resumeTask.task.id}`] + : undefined; const seconds = - recent && resumeTask && recent.taskId === resumeTask.task.id - ? recent.seconds + video && + studyEntry?.kind === "study" && + studyEntry.data.resourceUrl === video.url + ? studyEntry.data.seconds : video ? getVideoStartSeconds(video.url) : 0; @@ -91,7 +100,7 @@ export default function HomePage() {

Continue learning

- {recent && Last studied} + {recentRoadmap && Last studied}
{resumeTask && resumeRoadmap ? ( <> @@ -120,15 +129,22 @@ export default function HomePage() { )}
-

{resumeRoadmap.title}

+

+ {levelUp + ? `Level up: ${LEVEL_LABEL[levelUp.to]} · ` + : ""} + {resumeRoadmap.title} +

{resumeTask.task.title || resumeTask.task.text}

- {resumeTask.sectionTitle} + {resumeLevel + ? `${LEVEL_LABEL[resumeLevel.level]} · core ${resumeLevel.coreDone + 1} of ${resumeLevel.coreTotal}` + : resumeTask.sectionTitle} {video && seconds > 0 - ? ` · saved at ${formatTimestamp(seconds)}` + ? ` · resume at ${formatTimestamp(seconds)}` : ""}

{roadmaps.length - ? "You’ve finished your current tasks. Choose another topic when you’re ready." + ? "You’ve watched every core video. Go deeper or pick a new goal." : "Start with something you want to learn. Scout can help find and organize the resources."}

+ {lastRoadmap && ( + + Go deeper + + + )} Choose a learning goal @@ -175,12 +201,22 @@ export default function HomePage() {
{roadmaps.length ? ( roadmaps.slice(0, 5).map((roadmap) => { - const tasks = getLearningTasks(roadmap); - const done = tasks.filter( - (item) => - item.task.completed || item.task.done, - ).length; - const next = nextLearningTask(roadmap); + const topicLadder = getLadder(roadmap); + const currentLevel = topicLadder.levels.find( + (level) => level.level === topicLadder.current, + ); + const done = currentLevel + ? currentLevel.coreDone + : topicLadder.levels.reduce( + (total, level) => total + level.coreDone, + 0, + ); + const total = currentLevel + ? currentLevel.coreTotal + : topicLadder.levels.reduce( + (total, level) => total + level.coreTotal, + 0, + ); return (

- {next?.task.title || - next?.task.text || - (tasks.length - ? "All tasks complete" - : "Ready to explore")} + {currentLevel + ? `${LEVEL_LABEL[currentLevel.level]} · ${currentLevel.coreDone}/${currentLevel.coreTotal} core` + : topicLadder.levels.length + ? "Ladder complete" + : "Ready to explore"}

- {done} / {tasks.length} + {done} / {total} (roadmap ? getLearningTasks(roadmap) : []), [roadmap], ); - const item = + const ladder = useMemo( + () => (roadmap ? getLadder(roadmap) : null), + [roadmap], + ); + const candidate = tasks.find((entry) => entry.task.id === taskId) ?? - (roadmap ? (nextLearningTask(roadmap) ?? tasks[0]) : null); - const video = item ? taskVideo(item) : null; + (roadmap && ladder + ? (nextCoreItem(roadmap) ?? + ladder.levels[0]?.items[0] ?? + ladder.reading[0] ?? + nextLearningTask(roadmap) ?? + tasks[0]) + : null); + // Keep the open lesson steady when completion updates the roadmap. + const openTaskId = useRef(null); + if (openTaskId.current === null) + openTaskId.current = candidate?.task.id ?? null; + const item = + tasks.find((entry) => entry.task.id === openTaskId.current) ?? + candidate; + const openInfo = useMemo(() => { + if (!ladder || !item) return null; + for (const level of ladder.levels) { + const found = level.items.find( + (entry) => entry.task.id === item.task.id, + ); + if (found) { + return { + item: found, + level, + coreIndex: level.core.findIndex( + (entry) => entry.task.id === found.task.id, + ), + }; + } + } + const readingItem = ladder.reading.find( + (entry) => entry.task.id === item.task.id, + ); + return readingItem ? { item: readingItem, level: null, coreIndex: -1 } : null; + }, [ladder, item]); + const videoResource = item ? taskVideo(item) : null; + const video = useMemo( + () => (roadmap && videoResource ? learningVideo(roadmap, videoResource.url) : null), + [roadmap, videoResource], + ); + const activeUrl = + videoResource?.url ?? item?.resources[0]?.url ?? ""; + const activeId = item?.task.id; + const activeRoadmap = roadmap?.id; + const practiceId = + activeRoadmap && activeId + ? `annotation:practice:${activeRoadmap}:${activeId}` + : ""; + const studyId = + activeRoadmap && activeId ? `study:${activeRoadmap}:${activeId}` : ""; + const [draft, setDraft] = useState(""); - const [tab, setTab] = useState<"notes" | "roadmap">("notes"); + const [tab, setTab] = useState("read"); const [seekTo, setSeekTo] = useState(null); - const [composerOpen, setComposerOpen] = useState(false); const [position, setPosition] = useState(0); const positionRef = useRef(0); - const startRef = useRef(0); const [manualPosition, setManualPosition] = useState(""); const [error, setError] = useState(null); - const activeId = item?.task.id; - const activeRoadmap = roadmap?.id; - const activeUrl = video?.url ?? item?.resources[0]?.url ?? ""; - const notes = personalEntries(records, "annotation") - .filter( - (entry) => - entry.data.anchor.kind === "lesson" && - entry.data.anchor.roadmapId === roadmap?.id && - entry.data.anchor.taskId === item?.task.id, - ) - .sort((a, b) => b.updatedAt - a.updatedAt); + const [practice, setPractice] = useState(""); + const [levelOverrides, setLevelOverrides] = useState>( + () => new Map(), + ); + const [readingOpen, setReadingOpen] = useState(false); + const [completion, setCompletion] = useState<{ + taskId: string; + roadmap: Roadmap; + core: boolean; + } | null>(null); + const [recallText, setRecallText] = useState(""); + const [recallResolved, setRecallResolved] = useState(false); + const tabRefs = useRef>({ + read: null, + check: null, + apply: null, + }); + const uid = useId().replace(/:/g, ""); + const tabId = (key: TabKey) => `${uid}-tab-${key}`; + const panelId = (key: TabKey) => `${uid}-panel-${key}`; + useEffect(() => { if (!activeId || !activeRoadmap) return; - const stored = - getPersonalSnapshot()[`study:${activeRoadmap}:${activeId}`]; + const stored = getPersonalSnapshot()[`study:${activeRoadmap}:${activeId}`]; const seconds = targetSeconds ?? (stored?.kind === "study" && stored.data.resourceUrl === activeUrl ? stored.data.seconds : getVideoStartSeconds(activeUrl)); - startRef.current = seconds; positionRef.current = seconds; setPosition(seconds); setSeekTo(null); setDraft(""); setError(null); - putPersonalEntry({ - kind: "study", - id: `study:${activeRoadmap}:${activeId}`, - updatedAt: Date.now(), - deleted: false, - data: { - roadmapId: activeRoadmap, - taskId: activeId, - resourceUrl: activeUrl, - seconds, - lastOpenedAt: new Date().toISOString(), - }, - }); - }, [activeId, activeRoadmap, activeUrl, targetSeconds]); + setTab("read"); + setCompletion(null); + setRecallText(""); + setRecallResolved(false); + const savedPractice = practiceId + ? getPersonalSnapshot()[practiceId] + : undefined; + setPractice( + savedPractice?.kind === "annotation" + ? savedPractice.data.text + : "", + ); + try { + putPersonalEntry({ + kind: "study", + id: `study:${activeRoadmap}:${activeId}`, + updatedAt: Date.now(), + deleted: false, + data: { + roadmapId: activeRoadmap, + taskId: activeId, + resourceUrl: activeUrl, + seconds, + lastOpenedAt: new Date().toISOString(), + }, + }); + } catch { + setError( + "Your playback position could not be saved. Free some browser storage and try again.", + ); + } + }, [activeId, activeRoadmap, activeUrl, targetSeconds, practiceId]); + function recordPosition(seconds: number) { if (!activeRoadmap || !activeId) return; positionRef.current = seconds; @@ -117,6 +223,14 @@ export default function LearnPage({ ); } } + + function seek(seconds: number) { + const safe = Math.max(0, seconds); + recordPosition(safe); + setSeekTo(null); + requestAnimationFrame(() => setSeekTo(safe)); + } + function saveNote() { if (!draft.trim() || !roadmap || !item) return; try { @@ -140,388 +254,1114 @@ export default function LearnPage({ }, }); setDraft(""); + setError(null); + } catch { + setError( + "Your note could not be saved. Free some browser storage and try again.", + ); + } + } + + function savePractice() { + if (!practice.trim() || !roadmap || !item || !practiceId) return; + try { + putPersonalEntry({ + kind: "annotation", + id: practiceId, + updatedAt: Date.now(), + deleted: false, + data: { + text: practice.trim(), + quote: "", + color: "#b7c9e8", + createdAt: new Date().toISOString(), + anchor: { + kind: "lesson", + roadmapId: roadmap.id, + taskId: item.task.id, + url: activeUrl, + seconds: 0, + }, + }, + }); + setError(null); + } catch { + setError( + "Your practice note could not be saved. Free some browser storage and try again.", + ); + } + } + + function saveRecall() { + if (!recallText.trim() || !roadmap || !item) return; + try { + putPersonalEntry({ + kind: "annotation", + id: `annotation:recall:${roadmap.id}:${item.task.id}`, + updatedAt: Date.now(), + deleted: false, + data: { + text: recallText.trim(), + quote: "", + color: "#b7c9e8", + createdAt: new Date().toISOString(), + anchor: { + kind: "lesson", + roadmapId: roadmap.id, + taskId: item.task.id, + url: activeUrl, + seconds: positionRef.current, + }, + }, + }); + setRecallResolved(true); + setError(null); } catch { setError( "Your note could not be saved. Free some browser storage and try again.", ); } } + + function onMarkDone(checked: boolean) { + if (!roadmap || !item) return; + const updated = setLearningTaskDone(roadmap, item.task.id, checked); + if (!checked) { + setCompletion(null); + return; + } + setRecallText(""); + setRecallResolved(false); + setCompletion({ + taskId: item.task.id, + roadmap: updated, + core: openInfo?.item.role === "core", + }); + } + + function onTabKey(event: KeyboardEvent, index: number) { + let next = index; + if (event.key === "ArrowRight") next = (index + 1) % TAB_ORDER.length; + else if (event.key === "ArrowLeft") + next = (index - 1 + TAB_ORDER.length) % TAB_ORDER.length; + else if (event.key === "Home") next = 0; + else if (event.key === "End") next = TAB_ORDER.length - 1; + else return; + event.preventDefault(); + const key = TAB_ORDER[next]; + setTab(key); + tabRefs.current[key]?.focus(); + } + if (!loaded) return ( -
- Loading your learning topic… +
+

Loading your learning topic…

); if (!roadmap) return ( -
-

Learn

-
-

+

+
+

Learn

+

{roadmapId - ? "This learning topic could not be found on this device." + ? "This learning topic is not on this device" + : "Start a learning topic"} +

+

+ {roadmapId + ? "It may have been deleted here, or it only exists on another device." : "Start with a goal, or organize resources you already have."}

- - Open Scout - - - - Add your own roadmap - +
+ + Open Scout + + + + Add your own roadmap + +
); if (!item) return ( -
-

{roadmap.title}

-
-

- This roadmap has no task list yet. Open its full - workspace to add tasks or explore its resources. +

+
+

{roadmap.title}

+

This roadmap has no lessons yet

+

+ Open its workspace to add tasks and resources, or + explore what it already holds.

- - Open full workspace - - +
+ + Open full workspace + + +
); - const source = safeResourceUrl(activeUrl); - const external = source ? new URL(source) : null; - if (external && video) - external.searchParams.set("t", `${Math.floor(position)}s`); - const saved = records[`study:${roadmap.id}:${item.task.id}`]; + + const index = tasks.findIndex( + (entry) => entry.task.id === item.task.id, + ); + const title = item.task.title || item.task.text || "Untitled lesson"; + const done = item.task.completed || !!item.task.done; + const next = nextCoreItem(roadmap, { excludeTaskId: item.task.id }); + const countLabel = openInfo + ? openInfo.level === null + ? "READING TRACK" + : openInfo.item.role === "core" + ? `${LEVEL_LABEL[openInfo.level.level].toUpperCase()} · CORE ${openInfo.coreIndex + 1} / ${openInfo.level.coreTotal}` + : `${LEVEL_LABEL[openInfo.level.level].toUpperCase()} · GO DEEPER` + : `LESSON ${index + 1} / ${tasks.length}`; + const metaLabel = openInfo + ? openInfo.level === null + ? "Reading track" + : openInfo.item.role === "core" + ? `${LEVEL_LABEL[openInfo.level.level]} · core ${openInfo.coreIndex + 1} of ${openInfo.level.coreTotal}` + : `${LEVEL_LABEL[openInfo.level.level]} · go deeper` + : `Lesson ${index + 1} of ${tasks.length}`; + const ladderLevelUp = ladder ? levelUpReady(ladder) : null; + const ladderLevelUpTarget = nextCoreItem(roadmap); + const openLevels = new Set(); + if (ladder?.current) openLevels.add(ladder.current); + if (openInfo?.level) openLevels.add(openInfo.level.level); + const isLevelOpen = (level: LearningLevel) => + levelOverrides.get(level) ?? openLevels.has(level); + const toggleLevel = (level: LearningLevel) => + setLevelOverrides( + (current) => new Map(current).set(level, !isLevelOpen(level)), + ); + const completionOpen = completion !== null && completion.taskId === item.task.id; + const completionLadder = + completionOpen && completion ? getLadder(completion.roadmap) : null; + const completionLevelUp = completionLadder + ? levelUpReady(completionLadder) + : null; + const completionNext = completion + ? nextCoreItem(completion.roadmap, { excludeTaskId: completion.taskId }) + : null; + const completionLevelUpTarget = completion + ? nextCoreItem(completion.roadmap) + : null; + + const lessonRow = (entry: LadderItem, position: number) => { + const current = entry.task.id === item.task.id; + return ( +
  • + +
  • + ); + }; + + const levelSection = (level: LadderLevel) => { + const open = isLevelOpen(level.level); + const panelId = `${uid}-level-${level.level}`; + return ( +
    + + {open ? ( +
      + {level.core.map((entry, position) => + lessonRow(entry, position), + )} + {level.deeper.length > 0 && ( +
    1. + Go deeper (optional) +
    2. + )} + {level.deeper.map((entry, position) => + lessonRow(entry, position), + )} +
    + ) : null} +
    + ); + }; + const practiceEntry = practiceId ? records[practiceId] : undefined; + const practiceSaved = + practiceEntry?.kind === "annotation" && + practiceEntry.data.text === practice.trim() && + practice.trim() !== ""; + const subtasks = item.task.subtasks; + const doneSubtasks = subtasks.filter((subtask) => subtask.completed).length; + const section = roadmap.sections.find( + (entry) => entry.id === item.sectionId, + ); + const moduleData = section?.type === "module" ? section.data : null; + const milestone = + section?.type === "milestones" + ? section.data.find((entry) => entry.title === item.sectionTitle) + : undefined; + const contextDescription = + moduleData?.description || milestone?.description || ""; + const objectives = moduleData?.objectives ?? []; + const concepts = moduleData?.concepts ?? ""; + const sectionNotes = moduleData?.notes ?? ""; + const brief = item.task.description || item.task.text || ""; + const savedStudy = studyId ? records[studyId] : undefined; const startSeconds = targetSeconds ?? - (saved?.kind === "study" - ? saved.data.seconds + (savedStudy?.kind === "study" && + savedStudy.data.resourceUrl === activeUrl + ? savedStudy.data.seconds : getVideoStartSeconds(activeUrl)); + const externalSource = safeResourceUrl(activeUrl); + const external = + externalSource && video ? new URL(externalSource) : null; + if (external) external.searchParams.set("t", `${Math.floor(position)}s`); + const chapters = (video?.timestamps ?? []).flatMap((stamp) => { + const seconds = parseTimestamp(stamp.time); + return seconds === null + ? [] + : [ + { + seconds, + label: stamp.label || stamp.time, + time: stamp.time, + }, + ]; + }); + const activeChapter = chapters.reduce( + (current, chapter) => + position + 0.5 >= chapter.seconds ? chapter.seconds : current, + null, + ); + const notes = personalEntries(records, "annotation") + .flatMap((entry) => { + const anchor = entry.data.anchor; + if (anchor.kind !== "lesson") return []; + if ( + anchor.roadmapId !== roadmap.id || + anchor.taskId !== item.task.id + ) + return []; + if (entry.id.startsWith("annotation:practice:")) return []; + return [ + { + id: entry.id, + text: entry.data.text, + seconds: anchor.seconds, + updatedAt: entry.updatedAt, + }, + ]; + }) + .sort((a, b) => b.updatedAt - a.updatedAt); + const sources = item.resources.filter( + (resource) => safeResourceUrl(resource.url) !== null, + ); + return ( -
    -
    - Your learning topics - / - {roadmap.title} -
    -
    -
    -

    {item.task.title || item.task.text}

    -

    {item.sectionTitle}

    +
    +
    + +
    + {countLabel} + + + Edit roadmap + + +
    - - Edit roadmap - -
    + {error && ( -

    - {error} -

    +
    +

    + {error} +

    +
    )} -
    -
    - {video ? ( - <> - -
    - - Saved at {formatTimestamp(position)} + +
    +