From 5891f584d68c5abcd9edd1300f7cd1e1049b5d98 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 18:59:07 +0000 Subject: [PATCH 1/6] feat: add multi-stage module type with gated stepper progression Introduces "multistage" as a new ModuleType alongside "lessons". Multi-stage modules present the module itself as a single ordered journey: stages are gated by their tests, code carries forward between stages (with optional per-stage Starter.java for resets), and progress persists as JSON in the existing UserProgress.code column. - lib/types.ts: ModuleType, Stage, MultiStageModuleData, MultiStageProgressState - lib/lessons.ts: type-aware loader, getMultiStageModuleData, authoring validation - app/lessons/[moduleSlug]/page.tsx + MultiStageModulePage client component - app/lessons/[moduleSlug]/[lessonSlug] redirects multi-stage modules to /lessons/ - components/lesson/ModuleStageStepper - Sidebar branches on module.type, rendering stage checklist for multi-stage - /api/progress validates v2 JSON payloads, keeps legacy strings working - /api/analytics/event accepts stage_complete (new enum value + migration) - Converts 01-getting-started to multi-stage: module-level Starter/Solution, per-stage Starter.java for independent OpModes, stage.json per stage --- app/api/analytics/event/route.ts | 7 +- app/api/progress/route.ts | 27 +- .../[moduleSlug]/MultiStageModulePage.tsx | 575 ++++++++++++++++++ .../[moduleSlug]/[lessonSlug]/page.tsx | 14 +- app/lessons/[moduleSlug]/page.tsx | 48 ++ components/layout/Sidebar.tsx | 121 +++- components/lesson/ModuleStageStepper.tsx | 78 +++ .../{exercise.json => stage.json} | 0 .../{exercise.json => stage.json} | 0 .../{exercise.json => stage.json} | 0 .../{exercise.json => stage.json} | 0 .../{exercise.json => stage.json} | 0 .../lessons/01-getting-started/Solution.java | 22 + .../{01-hello-opmode => }/Starter.java | 0 .../lessons/01-getting-started/_module.json | 3 +- lib/lessons.ts | 199 +++++- lib/types.ts | 72 ++- prisma/generated/prisma/enums.ts | 3 +- .../migration.sql | 2 + prisma/schema.prisma | 1 + 20 files changed, 1151 insertions(+), 21 deletions(-) create mode 100644 app/lessons/[moduleSlug]/MultiStageModulePage.tsx create mode 100644 app/lessons/[moduleSlug]/page.tsx create mode 100644 components/lesson/ModuleStageStepper.tsx rename content/lessons/01-getting-started/01-hello-opmode/{exercise.json => stage.json} (100%) rename content/lessons/01-getting-started/02-your-first-motor/{exercise.json => stage.json} (100%) rename content/lessons/01-getting-started/03-servo-control/{exercise.json => stage.json} (100%) rename content/lessons/01-getting-started/04-telemetry-deep-dive/{exercise.json => stage.json} (100%) rename content/lessons/01-getting-started/05-common-debugging/{exercise.json => stage.json} (100%) create mode 100644 content/lessons/01-getting-started/Solution.java rename content/lessons/01-getting-started/{01-hello-opmode => }/Starter.java (100%) create mode 100644 prisma/migrations/20260419000000_add_stage_complete_event/migration.sql diff --git a/app/api/analytics/event/route.ts b/app/api/analytics/event/route.ts index a2e5858..ab2ebd0 100644 --- a/app/api/analytics/event/route.ts +++ b/app/api/analytics/event/route.ts @@ -3,7 +3,12 @@ import { auth } from "@/auth" import { recordEvent } from "@/lib/analytics" import type { EventType } from "@/prisma/generated/prisma/client" -const ALLOWED_TYPES: EventType[] = ["lesson_view", "hint_view", "solution_view"] +const ALLOWED_TYPES: EventType[] = [ + "lesson_view", + "hint_view", + "solution_view", + "stage_complete", +] export async function POST(request: Request) { try { diff --git a/app/api/progress/route.ts b/app/api/progress/route.ts index 78e06cf..be96046 100644 --- a/app/api/progress/route.ts +++ b/app/api/progress/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server" import { auth } from "@/auth" import { prisma } from "@/lib/prisma" +import { isMultiStageProgressState } from "@/lib/types" export async function GET(request: Request) { const session = await auth() @@ -28,11 +29,33 @@ export async function PUT(request: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } - const { lessonId, code } = await request.json() as { lessonId: string; code: string } - if (!lessonId || typeof code !== "string") { + const body = (await request.json()) as { lessonId?: unknown; code?: unknown } + const { lessonId, code } = body + if (typeof lessonId !== "string" || !lessonId || typeof code !== "string") { return NextResponse.json({ error: "Missing lessonId or code" }, { status: 400 }) } + // If the payload looks like JSON, validate it against the multi-stage schema. + // Reject malformed JSON payloads that claim to be v2 but aren't well-formed. + if (code.startsWith("{")) { + try { + const parsed = JSON.parse(code) as unknown + if ( + parsed && + typeof parsed === "object" && + (parsed as { __v?: unknown }).__v !== undefined && + !isMultiStageProgressState(parsed) + ) { + return NextResponse.json( + { error: "Invalid multi-stage progress payload" }, + { status: 400 } + ) + } + } catch { + // Not JSON — treat as legacy plain-string code (no-op here) + } + } + await prisma.userProgress.upsert({ where: { userId_lessonId: { userId: session.user.id, lessonId } }, create: { userId: session.user.id, lessonId, code }, diff --git a/app/lessons/[moduleSlug]/MultiStageModulePage.tsx b/app/lessons/[moduleSlug]/MultiStageModulePage.tsx new file mode 100644 index 0000000..3289fdf --- /dev/null +++ b/app/lessons/[moduleSlug]/MultiStageModulePage.tsx @@ -0,0 +1,575 @@ +"use client" + +import { useState, useCallback, useEffect, useRef, useMemo } from "react" +import Link from "next/link" +import { useRouter, useSearchParams } from "next/navigation" +import { PanelLeftOpen, PanelRightOpen, ArrowRight, CheckCircle2 } from "lucide-react" +import { Group, Panel, Separator } from "react-resizable-panels" +import { LessonLayout } from "@/components/layout/LessonLayout" +import { Sidebar } from "@/components/layout/Sidebar" +import { LessonContent } from "@/components/lesson/LessonContent" +import { ModuleStageStepper } from "@/components/lesson/ModuleStageStepper" +import { CodeEditor } from "@/components/editor/CodeEditor" +import { EditorToolbar } from "@/components/editor/EditorToolbar" +import { OutputPanel } from "@/components/editor/OutputPanel" +import { HintAccordion } from "@/components/ui/HintAccordion" +import { useCheerpJ } from "@/lib/cheerpj-context" +import { executeInBrowser } from "@/lib/cheerpj-executor" +import { + isMultiStageProgressState, + type ExecutionResult, + type MultiStageModuleData, + type MultiStageProgressState, + type SidebarModule, +} from "@/lib/types" + +interface MultiStageModulePageProps { + data: MultiStageModuleData + modules: SidebarModule[] + moduleSlug: string + userId: string | null +} + +function useDebounce(value: T, delay: number): T { + const [debounced, setDebounced] = useState(value) + useEffect(() => { + const t = setTimeout(() => setDebounced(value), delay) + return () => clearTimeout(t) + }, [value, delay]) + return debounced +} + +function storageKey(moduleSlug: string) { + return `ftc-module:${moduleSlug}` +} + +function testsStorageKey(moduleSlug: string, stageIndex: number) { + return `ftc-tests:${moduleSlug}#${stageIndex}` +} + +function clampStage(n: number, total: number) { + if (!Number.isFinite(n) || n < 0) return 0 + if (n > total - 1) return total - 1 + return Math.floor(n) +} + +export function MultiStageModulePage({ + data, + modules, + moduleSlug, + userId, +}: MultiStageModulePageProps) { + const router = useRouter() + const searchParams = useSearchParams() + const { status: cheerpjStatus } = useCheerpJ() + + const totalStages = data.stages.length + + const [hydrated, setHydrated] = useState(false) + const [currentStage, setCurrentStageState] = useState(0) + const [perStageCode, setPerStageCode] = useState>({}) + const [completedStages, setCompletedStages] = useState([]) + const [code, setCode] = useState(data.starterCode) + const [result, setResult] = useState(null) + const [isRunning, setIsRunning] = useState(false) + const [showingSolution, setShowingSolution] = useState(false) + const [sidebarCollapsed, setSidebarCollapsed] = useState(false) + const [editorCollapsed, setEditorCollapsed] = useState(false) + + const activeStage = data.stages[currentStage] ?? data.stages[0]! + const lessonId = moduleSlug // multi-stage lessonId = moduleSlug + + const dbLoadedRef = useRef(false) + const lastSavedSerializedRef = useRef(null) + const codeRef = useRef(code) + useEffect(() => { codeRef.current = code }, [code]) + + // Initialize editor buffer for the active stage. + // Priority: persisted perStageCode -> per-stage Starter.java -> carry-forward -> module starter + const initBufferForStage = useCallback( + (nextIndex: number, state: { perStageCode: Record; carryFrom?: string }) => { + const saved = state.perStageCode[String(nextIndex)] + if (saved !== undefined) return saved + const stage = data.stages[nextIndex] + if (stage?.starterCode !== undefined) return stage.starterCode + if (state.carryFrom !== undefined) return state.carryFrom + // First stage fallback + return data.starterCode + }, + [data.stages, data.starterCode] + ) + + const applyState = useCallback( + (state: MultiStageProgressState) => { + const stage = clampStage(state.currentStage, totalStages) + setPerStageCode(state.perStageCode) + setCompletedStages(state.completedStages) + setCurrentStageState(stage) + const next = initBufferForStage(stage, { + perStageCode: state.perStageCode, + carryFrom: undefined, + }) + setCode(next) + }, + [initBufferForStage, totalStages] + ) + + // Hydrate from ?stage= or localStorage on mount; then from DB if logged in + useEffect(() => { + const key = storageKey(moduleSlug) + let initialState: MultiStageProgressState = { + __v: 2, + currentStage: 0, + perStageCode: {}, + completedStages: [], + } + + try { + const rawLocal = localStorage.getItem(key) + if (rawLocal) { + const parsed = JSON.parse(rawLocal) as unknown + if (isMultiStageProgressState(parsed)) initialState = parsed + } + } catch { /* ignore */ } + + // ?stage= overrides current stage for deep-linking + const stageParam = searchParams.get("stage") + if (stageParam) { + const n = Number(stageParam) - 1 + if (Number.isInteger(n)) { + initialState = { ...initialState, currentStage: clampStage(n, totalStages) } + } + } + + applyState(initialState) + setHydrated(true) + + if (userId) { + fetch(`/api/progress?lessonId=${encodeURIComponent(lessonId)}`) + .then((r) => r.json()) + .then(({ code: dbCode }: { code: string | null }) => { + if (dbCode) { + try { + const parsed = JSON.parse(dbCode) as unknown + if (isMultiStageProgressState(parsed)) { + let dbState = parsed + if (stageParam) { + const n = Number(stageParam) - 1 + if (Number.isInteger(n)) { + dbState = { ...dbState, currentStage: clampStage(n, totalStages) } + } + } + applyState(dbState) + localStorage.setItem(key, JSON.stringify(dbState)) + lastSavedSerializedRef.current = JSON.stringify(dbState) + } + } catch { /* treat as legacy plain string — ignore */ } + } + dbLoadedRef.current = true + }) + .catch(() => { dbLoadedRef.current = true }) + } else { + dbLoadedRef.current = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Track test-pass indicator per stage (for stepper + sidebar) + const [stageTestsPassed, setStageTestsPassed] = useState>({}) + + useEffect(() => { + if (typeof window === "undefined") return + const passed: Record = {} + for (let i = 0; i < totalStages; i++) { + try { + const raw = localStorage.getItem(testsStorageKey(moduleSlug, i)) + if (raw) { + const { passed: p, total } = JSON.parse(raw) as { passed: number; total: number } + if (total > 0 && p === total) passed[i] = true + } + } catch { /* ignore */ } + } + setStageTestsPassed(passed) + }, [moduleSlug, totalStages]) + + // Keep ?stage= URL param in sync with currentStage (1-indexed for humans) + useEffect(() => { + if (!hydrated) return + const params = new URLSearchParams(searchParams.toString()) + params.set("stage", String(currentStage + 1)) + router.replace(`/lessons/${moduleSlug}?${params.toString()}`, { scroll: false }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentStage, hydrated]) + + // Serialize + persist state (localStorage always; DB debounced for logged-in) + const state: MultiStageProgressState = useMemo( + () => ({ + __v: 2 as const, + currentStage, + perStageCode: { ...perStageCode, [String(currentStage)]: code }, + completedStages, + }), + [currentStage, perStageCode, completedStages, code] + ) + + const serialized = JSON.stringify(state) + + useEffect(() => { + if (!hydrated) return + localStorage.setItem(storageKey(moduleSlug), serialized) + }, [hydrated, serialized, moduleSlug]) + + const debouncedSerialized = useDebounce(serialized, 1500) + useEffect(() => { + if (!userId || !dbLoadedRef.current || !hydrated) return + if (debouncedSerialized === lastSavedSerializedRef.current) return + lastSavedSerializedRef.current = debouncedSerialized + fetch("/api/progress", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ lessonId, code: debouncedSerialized }), + }).catch(() => {}) + }, [debouncedSerialized, lessonId, userId, hydrated]) + + // Analytics: lesson_view fires on module entry and every stage change + useEffect(() => { + if (!hydrated) return + fetch("/api/analytics/event", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "lesson_view", + lessonId: `${moduleSlug}/${activeStage.slug}`, + }), + }).catch(() => {}) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hydrated, moduleSlug, activeStage.slug]) + + const latestRunAllPassed = + result !== null && + result.testResults.length > 0 && + result.testResults.every((t) => t.passed) + + const canAdvance = latestRunAllPassed || stageTestsPassed[currentStage] === true + const isFinalStage = currentStage === totalStages - 1 + + const handleJumpStage = useCallback( + (targetIndex: number) => { + if (targetIndex === currentStage) return + const newPerStage = { ...perStageCode, [String(currentStage)]: codeRef.current } + setPerStageCode(newPerStage) + setResult(null) + setShowingSolution(false) + const nextBuffer = initBufferForStage(targetIndex, { + perStageCode: newPerStage, + carryFrom: codeRef.current, + }) + setCode(nextBuffer) + setCurrentStageState(targetIndex) + }, + [currentStage, perStageCode, initBufferForStage] + ) + + const handleAdvance = useCallback(() => { + if (!canAdvance) return + const stageSlug = activeStage.slug + const completedIdx = currentStage + + // Record completion + const nextCompleted = completedStages.includes(completedIdx) + ? completedStages + : [...completedStages, completedIdx].sort((a, b) => a - b) + setCompletedStages(nextCompleted) + + // Fire stage_complete analytics + fetch("/api/analytics/event", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "stage_complete", + lessonId: `${moduleSlug}/${stageSlug}`, + moduleSlug, + stageIndex: completedIdx, + stageSlug, + }), + }).catch(() => {}) + + if (isFinalStage) { + // Fire exercise_complete for the module as a whole + fetch("/api/analytics/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ lessonId: moduleSlug, allPassed: true }), + }).catch(() => {}) + return + } + + // Advance to next stage + handleJumpStage(completedIdx + 1) + }, [ + canAdvance, + activeStage.slug, + currentStage, + completedStages, + moduleSlug, + isFinalStage, + handleJumpStage, + ]) + + const handleRun = useCallback(async () => { + if (cheerpjStatus !== "ready") { + setResult({ + success: false, + runtimeError: cheerpjStatus === "loading" + ? "Java runtime is still loading — please wait a moment and try again" + : "Java runtime failed to load. Try refreshing the page.", + testResults: [], + }) + return + } + + setIsRunning(true) + setResult(null) + + try { + const execResult = await executeInBrowser(code, activeStage.testCode) + setResult(execResult) + + if (execResult.testResults.length > 0) { + const passed = execResult.testResults.filter((t) => t.passed).length + const total = execResult.testResults.length + localStorage.setItem( + testsStorageKey(moduleSlug, currentStage), + JSON.stringify({ passed, total }) + ) + setStageTestsPassed((prev) => ({ + ...prev, + [currentStage]: total > 0 && passed === total, + })) + window.dispatchEvent(new Event("ftc-tests-updated")) + } + + const allPassed = + execResult.testResults.length > 0 && + execResult.testResults.every((t) => t.passed) + + fetch("/api/analytics/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + lessonId: `${moduleSlug}/${activeStage.slug}`, + allPassed, + }), + }).catch(() => {}) + } catch (err) { + setResult({ + success: false, + runtimeError: + err instanceof Error ? err.message : "Execution failed unexpectedly", + testResults: [], + }) + } finally { + setIsRunning(false) + } + }, [code, activeStage.testCode, activeStage.slug, cheerpjStatus, moduleSlug, currentStage]) + + const handleReset = useCallback(() => { + const resetBuffer = activeStage.starterCode ?? data.starterCode + setCode(resetBuffer) + setResult(null) + setShowingSolution(false) + }, [activeStage.starterCode, data.starterCode]) + + const handleToggleSolution = useCallback(() => { + setShowingSolution((prev) => { + if (!prev) { + fetch("/api/analytics/event", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "solution_view", + lessonId: `${moduleSlug}/${activeStage.slug}`, + }), + }).catch(() => {}) + } + return !prev + }) + }, [moduleSlug, activeStage.slug]) + + const handleHintOpen = useCallback( + (index: number) => { + fetch("/api/analytics/event", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "hint_view", + lessonId: `${moduleSlug}/${activeStage.slug}`, + hintIndex: index, + }), + }).catch(() => {}) + }, + [moduleSlug, activeStage.slug] + ) + + const moduleCompleted = completedStages.includes(totalStages - 1) + + const solutionCode = activeStage.solutionCode ?? data.solutionCode + const introContent = currentStage === 0 && data.intro ? data.intro : "" + const leftContent = introContent + ? `${introContent}\n\n${activeStage.content}` + : activeStage.content + + const stepperNode = ( + + ) + + const hintsNode = + activeStage.hints.length > 0 ? ( +
+ +
+ ) : null + + const footerNode = ( +
+
+ Stage {currentStage + 1} of {totalStages} +
+ {isFinalStage ? ( + moduleCompleted ? ( +
+ + + Module complete + + {data.nextModule && ( + + Next module + + + )} +
+ ) : ( + + ) + ) : ( + + )} +
+ ) + + return ( +
+ {!sidebarCollapsed && ( + setSidebarCollapsed(true)} + /> + )} +
+ {sidebarCollapsed && ( + + )} + {editorCollapsed ? ( + <> +
+ {stepperNode} +
+ + {hintsNode} +
+ {footerNode} +
+ + + ) : ( + + {stepperNode} +
+ + {hintsNode} +
+ {footerNode} +
+ } + rightPanel={ + <> + setEditorCollapsed(true)} + showingSolution={showingSolution} + isRunning={isRunning} + code={code} + /> + + +
+
+ +
+
+ {}} readOnly /> +
+
+
+ {!showingSolution && ( + <> + + +
+ +
+
+ + )} +
+ + } + /> + )} +
+ + ) +} diff --git a/app/lessons/[moduleSlug]/[lessonSlug]/page.tsx b/app/lessons/[moduleSlug]/[lessonSlug]/page.tsx index c75c754..3a631d4 100644 --- a/app/lessons/[moduleSlug]/[lessonSlug]/page.tsx +++ b/app/lessons/[moduleSlug]/[lessonSlug]/page.tsx @@ -1,4 +1,4 @@ -import { notFound } from "next/navigation" +import { notFound, redirect } from "next/navigation" import { getLessonData, getModules } from "@/lib/lessons" import { auth } from "@/auth" import { LessonPage } from "./LessonPage" @@ -12,11 +12,13 @@ interface Props { export default async function LessonRoute({ params }: Props) { const { moduleSlug, lessonSlug } = await params - const [data, modules, session] = await Promise.all([ - getLessonData(moduleSlug, lessonSlug), - getModules(), - auth(), - ]) + const [modules, session] = await Promise.all([getModules(), auth()]) + const mod = modules.find((m) => m.meta.slug === moduleSlug) + if (mod?.meta.type === "multistage") { + redirect(`/lessons/${moduleSlug}`) + } + + const data = await getLessonData(moduleSlug, lessonSlug) if (!data) { notFound() diff --git a/app/lessons/[moduleSlug]/page.tsx b/app/lessons/[moduleSlug]/page.tsx new file mode 100644 index 0000000..a9f4e45 --- /dev/null +++ b/app/lessons/[moduleSlug]/page.tsx @@ -0,0 +1,48 @@ +import { notFound, redirect } from "next/navigation" +import { getMultiStageModuleData, getModules } from "@/lib/lessons" +import { auth } from "@/auth" +import { MultiStageModulePage } from "./MultiStageModulePage" + +interface Props { + params: Promise<{ moduleSlug: string }> +} + +export default async function ModuleRoute({ params }: Props) { + const { moduleSlug } = await params + const modules = await getModules() + const mod = modules.find((m) => m.meta.slug === moduleSlug) + + if (!mod) notFound() + + if (mod.meta.type === "lessons") { + const first = mod.lessons[0] + if (!first) notFound() + redirect(`/lessons/${moduleSlug}/${first.slug}`) + } + + const [data, session] = await Promise.all([ + getMultiStageModuleData(moduleSlug), + auth(), + ]) + + if (!data) notFound() + + return ( + + ) +} + +export async function generateMetadata({ params }: Props) { + const { moduleSlug } = await params + const data = await getMultiStageModuleData(moduleSlug).catch(() => null) + if (!data) return { title: "Module Not Found" } + return { + title: `${data.module.title} | Code FTC`, + description: data.module.description, + } +} diff --git a/components/layout/Sidebar.tsx b/components/layout/Sidebar.tsx index 64c184a..6068814 100644 --- a/components/layout/Sidebar.tsx +++ b/components/layout/Sidebar.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from "react" import Link from "next/link" import { usePathname } from "next/navigation" -import { ChevronRight, ChevronDown, BookOpen, Home, Menu, X, LogIn, LogOut, PanelLeftClose, BarChart2 } from "lucide-react" +import { ChevronRight, ChevronDown, BookOpen, Home, Menu, X, LogIn, LogOut, PanelLeftClose, BarChart2, Check, Layers } from "lucide-react" import { useSession, signOut } from "next-auth/react" import type { SidebarModule } from "@/lib/types" @@ -106,6 +106,7 @@ function UserFooter() { } type TestProgress = Record +type MultiStageProgress = Record }> function loadTestProgress(modules: SidebarModule[]): TestProgress { const result: TestProgress = {} @@ -121,6 +122,49 @@ function loadTestProgress(modules: SidebarModule[]): TestProgress { return result } +function loadMultiStageProgress(modules: SidebarModule[]): MultiStageProgress { + const result: MultiStageProgress = {} + for (const mod of modules) { + if (mod.meta.type !== "multistage") continue + const key = `ftc-module:${mod.meta.slug}` + let completed: number[] = [] + let currentStage = 0 + try { + const raw = localStorage.getItem(key) + if (raw) { + const parsed = JSON.parse(raw) as { + __v?: number + currentStage?: number + completedStages?: number[] + } + if (parsed.__v === 2) { + completed = parsed.completedStages ?? [] + currentStage = parsed.currentStage ?? 0 + } + } + } catch { /* ignore */ } + + const stagesPassed: Record = {} + for (let i = 0; i < mod.stages.length; i++) { + try { + const raw = localStorage.getItem(`ftc-tests:${mod.meta.slug}#${i}`) + if (raw) { + const { passed, total } = JSON.parse(raw) as { passed: number; total: number } + if (total > 0 && passed === total) stagesPassed[i] = true + } + } catch { /* ignore */ } + } + + result[mod.meta.slug] = { + completed, + total: mod.stages.length, + currentStage, + stagesPassed, + } + } + return result +} + export function Sidebar({ modules, moduleSlug, lessonSlug, onCollapse }: SidebarProps) { const { data: session } = useSession() const pathname = usePathname() @@ -131,9 +175,13 @@ export function Sidebar({ modules, moduleSlug, lessonSlug, onCollapse }: Sidebar return initial }) const [testProgress, setTestProgress] = useState({}) + const [multiStageProgress, setMultiStageProgress] = useState({}) useEffect(() => { - const handler = () => setTestProgress(loadTestProgress(modules)) + const handler = () => { + setTestProgress(loadTestProgress(modules)) + setMultiStageProgress(loadMultiStageProgress(modules)) + } handler() window.addEventListener("ftc-tests-updated", handler) return () => window.removeEventListener("ftc-tests-updated", handler) @@ -203,6 +251,75 @@ export function Sidebar({ modules, moduleSlug, lessonSlug, onCollapse }: Sidebar {modules.map((mod) => { const isExpanded = expandedModules.has(mod.meta.slug) + + if (mod.meta.type === "multistage") { + const progress = multiStageProgress[mod.meta.slug] + const isFullyComplete = + progress !== undefined && + progress.total > 0 && + progress.completed.length >= progress.total + return ( +
+ + + {isExpanded && ( +
    + {mod.stages.map((stage, idx) => { + const completedSet = new Set(progress?.completed ?? []) + const isCompleted = completedSet.has(idx) + const isCurrent = + mod.meta.slug === moduleSlug && + (progress?.currentStage ?? 0) === idx + const isUnlocked = isCompleted || isCurrent + return ( +
  • + { + if (!isUnlocked) e.preventDefault() + else setMobileOpen(false) + }} + aria-disabled={!isUnlocked} + className={`flex items-center gap-2 py-1.5 pl-10 pr-3 text-sm transition-colors ${ + isCurrent + ? "border-r-2 border-[var(--color-accent)] bg-[var(--color-surface-hover)] font-medium text-[var(--color-accent)]" + : isUnlocked + ? "text-[var(--color-text-muted)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text)]" + : "cursor-not-allowed text-[var(--color-text-muted)]/50" + }`} + > + {stage.title} + {session && isCompleted && ( + + )} + +
  • + ) + })} +
+ )} +
+ ) + } + return (
+ {idx < stages.length - 1 && ( + + )} + + ) + })} + + ) +} diff --git a/content/lessons/01-getting-started/01-hello-opmode/exercise.json b/content/lessons/01-getting-started/01-hello-opmode/stage.json similarity index 100% rename from content/lessons/01-getting-started/01-hello-opmode/exercise.json rename to content/lessons/01-getting-started/01-hello-opmode/stage.json diff --git a/content/lessons/01-getting-started/02-your-first-motor/exercise.json b/content/lessons/01-getting-started/02-your-first-motor/stage.json similarity index 100% rename from content/lessons/01-getting-started/02-your-first-motor/exercise.json rename to content/lessons/01-getting-started/02-your-first-motor/stage.json diff --git a/content/lessons/01-getting-started/03-servo-control/exercise.json b/content/lessons/01-getting-started/03-servo-control/stage.json similarity index 100% rename from content/lessons/01-getting-started/03-servo-control/exercise.json rename to content/lessons/01-getting-started/03-servo-control/stage.json diff --git a/content/lessons/01-getting-started/04-telemetry-deep-dive/exercise.json b/content/lessons/01-getting-started/04-telemetry-deep-dive/stage.json similarity index 100% rename from content/lessons/01-getting-started/04-telemetry-deep-dive/exercise.json rename to content/lessons/01-getting-started/04-telemetry-deep-dive/stage.json diff --git a/content/lessons/01-getting-started/05-common-debugging/exercise.json b/content/lessons/01-getting-started/05-common-debugging/stage.json similarity index 100% rename from content/lessons/01-getting-started/05-common-debugging/exercise.json rename to content/lessons/01-getting-started/05-common-debugging/stage.json diff --git a/content/lessons/01-getting-started/Solution.java b/content/lessons/01-getting-started/Solution.java new file mode 100644 index 0000000..c97b0b2 --- /dev/null +++ b/content/lessons/01-getting-started/Solution.java @@ -0,0 +1,22 @@ +import com.qualcomm.robotcore.eventloop.opmode.LinearOpMode; +import com.qualcomm.robotcore.eventloop.opmode.TeleOp; +import com.qualcomm.robotcore.hardware.DcMotor; + +@TeleOp(name = "Debug Exercise") +public class StudentCode extends LinearOpMode { + @Override + public void runOpMode() { + DcMotor driveMotor = hardwareMap.get(DcMotor.class, "driveMotor"); + + telemetry.addData("Status", "Initialized"); + telemetry.update(); + + waitForStart(); + + driveMotor.setPower(1.0); + + telemetry.addData("Status", "Running"); + telemetry.addData("Motor Power", driveMotor.getPower()); + telemetry.update(); + } +} \ No newline at end of file diff --git a/content/lessons/01-getting-started/01-hello-opmode/Starter.java b/content/lessons/01-getting-started/Starter.java similarity index 100% rename from content/lessons/01-getting-started/01-hello-opmode/Starter.java rename to content/lessons/01-getting-started/Starter.java diff --git a/content/lessons/01-getting-started/_module.json b/content/lessons/01-getting-started/_module.json index aae489b..c8c4270 100644 --- a/content/lessons/01-getting-started/_module.json +++ b/content/lessons/01-getting-started/_module.json @@ -1,5 +1,6 @@ { "title": "Getting Started", "order": 1, - "description": "Learn the fundamentals of FTC programming with OpModes and hardware." + "description": "Learn the fundamentals of FTC programming with OpModes and hardware.", + "type": "multistage" } diff --git a/lib/lessons.ts b/lib/lessons.ts index 1b212bf..888b5d6 100644 --- a/lib/lessons.ts +++ b/lib/lessons.ts @@ -1,14 +1,19 @@ -import { readdir, readFile } from "node:fs/promises" +import { readdir, readFile, stat } from "node:fs/promises" import { join } from "node:path" import { unstable_cache } from "next/cache" import matter from "gray-matter" import type { ModuleMeta, + ModuleType, LessonMeta, + StageMeta, Exercise, ExerciseFile, + Hint, LessonData, + MultiStageModuleData, SidebarModule, + Stage, } from "./types" const CONTENT_DIR = join(process.cwd(), "content/lessons") @@ -25,8 +30,19 @@ export const getModules = unstable_cache( for (const dir of moduleDirs) { const modulePath = join(CONTENT_DIR, dir.name) const meta = await loadModuleMeta(modulePath, dir.name) - const lessons = await loadLessonMetas(modulePath, dir.name) - modules.push({ meta, lessons }) + if (meta.type === "multistage") { + const stages = await loadStageMetas(modulePath, dir.name) + if (stages.length === 0) { + throw new Error( + `Module "${dir.name}" has type "multistage" but contains no stage folders.` + ) + } + modules.push({ meta, lessons: [], stages }) + } else { + const lessons = await loadLessonMetas(modulePath, dir.name) + await validateNoStageFolders(modulePath, dir.name) + modules.push({ meta, lessons, stages: [] }) + } } return modules @@ -41,12 +57,19 @@ async function loadModuleMeta( ): Promise { try { const raw = await readFile(join(modulePath, "_module.json"), "utf-8") - const data = JSON.parse(raw) as { title: string; order: number; description?: string } + const data = JSON.parse(raw) as { + title: string + order: number + description?: string + type?: ModuleType + } + const type: ModuleType = data.type === "multistage" ? "multistage" : "lessons" return { title: data.title, slug: dirName, order: data.order, description: data.description, + type, } } catch { // Fallback: derive from directory name @@ -55,7 +78,7 @@ async function loadModuleMeta( .replace(/^\d+-/, "") .replace(/-/g, " ") .replace(/\b\w/g, (c) => c.toUpperCase()) - return { title, slug: dirName, order } + return { title, slug: dirName, order, type: "lessons" } } } @@ -97,6 +120,55 @@ async function loadLessonMetas( return lessons } +async function loadStageMetas( + modulePath: string, + moduleSlug: string +): Promise { + const entries = await readdir(modulePath, { withFileTypes: true }) + const stageDirs = entries + .filter((e) => e.isDirectory()) + .sort((a, b) => a.name.localeCompare(b.name)) + + const stages: StageMeta[] = [] + + for (const dir of stageDirs) { + const stagePath = join(modulePath, dir.name) + let stageMeta: { title?: string; testCount?: number; description?: string } = {} + try { + const raw = await readFile(join(stagePath, "stage.json"), "utf-8") + stageMeta = JSON.parse(raw) as typeof stageMeta + } catch { + // No stage.json — skip directory (not a stage) + continue + } + const order = parseInt(dir.name.split("-")[0] ?? "0", 10) + stages.push({ + slug: dir.name, + moduleSlug, + order, + title: + stageMeta.title ?? + dir.name.replace(/^\d+-/, "").replace(/-/g, " "), + testCount: stageMeta.testCount ?? 0, + description: stageMeta.description, + }) + } + + return stages +} + +async function validateNoStageFolders(modulePath: string, moduleSlug: string) { + const entries = await readdir(modulePath, { withFileTypes: true }) + for (const e of entries) { + if (!e.isDirectory()) continue + if (await fileExists(join(modulePath, e.name, "stage.json"))) { + throw new Error( + `Module "${moduleSlug}" has type "lessons" but contains stage-shaped folder "${e.name}" (stage.json present). Set "type": "multistage" in _module.json.` + ) + } + } +} + export async function getLessonData( moduleSlug: string, lessonSlug: string @@ -106,6 +178,7 @@ export async function getLessonData( // Find the current module and lesson const currentModule = modules.find((m) => m.meta.slug === moduleSlug) if (!currentModule) return null + if (currentModule.meta.type !== "lessons") return null const currentLesson = currentModule.lessons.find( (l) => l.slug === lessonSlug @@ -127,9 +200,10 @@ export async function getLessonData( ]) const exercise: Exercise = { ...exerciseFile, starterCode, solutionCode } - // Build flat list of all lessons for prev/next + // Build flat list of all lessons for prev/next (only across lesson-type modules) const allLessons: { moduleSlug: string; lessonSlug: string }[] = [] for (const mod of modules) { + if (mod.meta.type !== "lessons") continue for (const lesson of mod.lessons) { allLessons.push({ moduleSlug: mod.meta.slug, lessonSlug: lesson.slug }) } @@ -152,3 +226,116 @@ export async function getLessonData( : null, } } + +async function fileExists(path: string): Promise { + try { + await stat(path) + return true + } catch { + return false + } +} + +async function readIfExists(path: string): Promise { + try { + return await readFile(path, "utf-8") + } catch { + return undefined + } +} + +export async function getMultiStageModuleData( + moduleSlug: string +): Promise { + const modules = await getModules() + const currentModule = modules.find((m) => m.meta.slug === moduleSlug) + if (!currentModule) return null + if (currentModule.meta.type !== "multistage") return null + + const modulePath = join(CONTENT_DIR, moduleSlug) + + const [starterCode, solutionCode] = await Promise.all([ + readFile(join(modulePath, "Starter.java"), "utf-8"), + readFile(join(modulePath, "Solution.java"), "utf-8"), + ]) + + const introRaw = await readIfExists(join(modulePath, "intro.mdx")) + const intro = introRaw ? matter(introRaw).content : undefined + + const stageEntries = await readdir(modulePath, { withFileTypes: true }) + const stageDirs = stageEntries + .filter((e) => e.isDirectory()) + .sort((a, b) => a.name.localeCompare(b.name)) + + const stages: Stage[] = [] + for (const dir of stageDirs) { + const stagePath = join(modulePath, dir.name) + if (!(await fileExists(join(stagePath, "stage.json")))) continue + + const [stageMetaRaw, mdxRaw, testCode] = await Promise.all([ + readFile(join(stagePath, "stage.json"), "utf-8"), + readFile(join(stagePath, "content.mdx"), "utf-8").catch(() => { + throw new Error( + `Stage "${dir.name}" in module "${moduleSlug}" is missing content.mdx.` + ) + }), + readFile(join(stagePath, "Test.java"), "utf-8").catch(() => { + throw new Error( + `Stage "${dir.name}" in module "${moduleSlug}" is missing Test.java.` + ) + }), + ]) + + const stageMeta = JSON.parse(stageMetaRaw) as { + title?: string + testCount?: number + description?: string + hints?: Hint[] + } + const { content } = matter(mdxRaw) + + const [perStageSolution, perStageStarter] = await Promise.all([ + readIfExists(join(stagePath, "Solution.java")), + readIfExists(join(stagePath, "Starter.java")), + ]) + + stages.push({ + slug: dir.name, + title: + stageMeta.title ?? + dir.name.replace(/^\d+-/, "").replace(/-/g, " "), + description: stageMeta.description, + content, + testCode, + testCount: stageMeta.testCount ?? 0, + hints: stageMeta.hints ?? [], + solutionCode: perStageSolution, + starterCode: perStageStarter, + }) + } + + if (stages.length === 0) { + throw new Error( + `Multi-stage module "${moduleSlug}" has no stages.` + ) + } + + // Resolve prev/next module by order + const ordered = [...modules].sort((a, b) => a.meta.order - b.meta.order) + const idx = ordered.findIndex((m) => m.meta.slug === moduleSlug) + const prevModule = idx > 0 ? { slug: ordered[idx - 1]!.meta.slug } : null + const nextModule = + idx >= 0 && idx < ordered.length - 1 + ? { slug: ordered[idx + 1]!.meta.slug } + : null + + return { + module: currentModule.meta, + intro, + starterCode, + solutionCode, + stages, + prevModule, + nextModule, + } +} diff --git a/lib/types.ts b/lib/types.ts index 2b46952..e3ba28e 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,8 +1,11 @@ +export type ModuleType = "lessons" | "multistage" + export interface ModuleMeta { title: string slug: string order: number description?: string + type: ModuleType } export interface LessonMeta { @@ -14,18 +17,44 @@ export interface LessonMeta { description?: string } +export interface StageMeta { + slug: string + moduleSlug: string + title: string + order: number + testCount: number + description?: string +} + +export interface Hint { + title: string + content: string +} + export interface Exercise { title: string testCount: number starterCode: string solutionCode: string - hints: { title: string; content: string }[] + hints: Hint[] } export interface ExerciseFile { title: string testCount: number - hints: { title: string; content: string }[] + hints: Hint[] +} + +export interface Stage { + slug: string + title: string + description?: string + content: string + testCode: string + testCount: number + hints: Hint[] + solutionCode?: string + starterCode?: string } export interface TestResult { @@ -53,7 +82,46 @@ export interface LessonData { next: { moduleSlug: string; lessonSlug: string } | null } +export interface MultiStageModuleData { + module: ModuleMeta + intro?: string + starterCode: string + solutionCode: string + stages: Stage[] + prevModule: { slug: string } | null + nextModule: { slug: string } | null +} + export interface SidebarModule { meta: ModuleMeta lessons: LessonMeta[] + stages: StageMeta[] +} + +export interface MultiStageProgressState { + __v: 2 + currentStage: number + perStageCode: Record + completedStages: number[] +} + +export function isMultiStageProgressState( + value: unknown +): value is MultiStageProgressState { + if (!value || typeof value !== "object") return false + const v = value as Record + if (v.__v !== 2) return false + if (typeof v.currentStage !== "number" || !Number.isInteger(v.currentStage) || v.currentStage < 0) { + return false + } + if (!Array.isArray(v.completedStages)) return false + for (const s of v.completedStages) { + if (typeof s !== "number" || !Number.isInteger(s) || s < 0) return false + } + if (!v.perStageCode || typeof v.perStageCode !== "object") return false + for (const [k, val] of Object.entries(v.perStageCode as Record)) { + if (!/^\d+$/.test(k)) return false + if (typeof val !== "string") return false + } + return true } diff --git a/prisma/generated/prisma/enums.ts b/prisma/generated/prisma/enums.ts index f21e7fb..4ac071c 100644 --- a/prisma/generated/prisma/enums.ts +++ b/prisma/generated/prisma/enums.ts @@ -22,7 +22,8 @@ export const EventType = { code_run: 'code_run', exercise_complete: 'exercise_complete', hint_view: 'hint_view', - solution_view: 'solution_view' + solution_view: 'solution_view', + stage_complete: 'stage_complete' } as const export type EventType = (typeof EventType)[keyof typeof EventType] diff --git a/prisma/migrations/20260419000000_add_stage_complete_event/migration.sql b/prisma/migrations/20260419000000_add_stage_complete_event/migration.sql new file mode 100644 index 0000000..72b086b --- /dev/null +++ b/prisma/migrations/20260419000000_add_stage_complete_event/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "EventType" ADD VALUE 'stage_complete'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c0879a2..a7f9a0f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -18,6 +18,7 @@ enum EventType { exercise_complete hint_view solution_view + stage_complete } model User { From 4d798c54f1915196352936f2a8ef629803184cf8 Mon Sep 17 00:00:00 2001 From: Trevor Bedson Date: Sun, 19 Apr 2026 17:25:14 -0400 Subject: [PATCH 2/6] fix(multistage): unlock first stage in sidebar and auto-scroll stepper - Sidebar: unlock any stage at or before saved currentStage so stage 0 is reachable from an unvisited multi-stage module (previously all stage links were disabled until the user was already inside). - Stepper: replace wrapping pill row with a single horizontal track that auto-centers the current stage with a slide animation on advance; no manual scroll or jump interaction. --- .../[moduleSlug]/MultiStageModulePage.tsx | 1 - components/layout/Sidebar.tsx | 5 +- components/lesson/ModuleStageStepper.tsx | 75 ++++++++++--------- 3 files changed, 44 insertions(+), 37 deletions(-) diff --git a/app/lessons/[moduleSlug]/MultiStageModulePage.tsx b/app/lessons/[moduleSlug]/MultiStageModulePage.tsx index 3289fdf..69c823d 100644 --- a/app/lessons/[moduleSlug]/MultiStageModulePage.tsx +++ b/app/lessons/[moduleSlug]/MultiStageModulePage.tsx @@ -424,7 +424,6 @@ export function MultiStageModulePage({ stages={data.stages} current={currentStage} completed={completedStages} - onJump={handleJumpStage} /> ) diff --git a/components/layout/Sidebar.tsx b/components/layout/Sidebar.tsx index 6068814..7adc026 100644 --- a/components/layout/Sidebar.tsx +++ b/components/layout/Sidebar.tsx @@ -281,10 +281,11 @@ export function Sidebar({ modules, moduleSlug, lessonSlug, onCollapse }: Sidebar {mod.stages.map((stage, idx) => { const completedSet = new Set(progress?.completed ?? []) const isCompleted = completedSet.has(idx) + const savedStage = progress?.currentStage ?? 0 const isCurrent = mod.meta.slug === moduleSlug && - (progress?.currentStage ?? 0) === idx - const isUnlocked = isCompleted || isCurrent + savedStage === idx + const isUnlocked = isCompleted || idx <= savedStage return (
  • (null) + const [offset, setOffset] = useState(0) + + useEffect(() => { + const track = trackRef.current + if (!track) return + const el = track.children[current] as HTMLElement | undefined + if (!el) return + const viewport = track.parentElement + if (!viewport) return + const viewportWidth = viewport.clientWidth + const target = el.offsetLeft + el.offsetWidth / 2 - viewportWidth / 2 + setOffset(-target) + }, [current, stages.length]) return ( -
      - {stages.map((stage, idx) => { - const isCurrent = idx === current - const isCompleted = completedSet.has(idx) - const isUnlocked = isCompleted || isCurrent - const state = isCompleted ? "completed" : isCurrent ? "current" : "locked" +
      +
      +
      +
      + {stages.map((stage, idx) => { + const isCurrent = idx === current + const isCompleted = completedSet.has(idx) + const state = isCompleted ? "completed" : isCurrent ? "current" : "locked" - return ( -
    1. - - {idx < stages.length - 1 && ( - - )} -
    2. - ) - })} -
    + {stage.title} +
  • + ) + })} + + ) } From be7f8b2ca48d3ccc4f8b9428e71cf9a723627447 Mon Sep 17 00:00:00 2001 From: Trevor Bedson Date: Sun, 19 Apr 2026 17:32:57 -0400 Subject: [PATCH 3/6] fix(multistage): stable sidebar progression and URL-driven stage view - Unlock stages based on completions (maxCompleted+1) rather than the transient currentStage, so navigating backward never re-locks a stage you already advanced past. - React to ?stage= changes after mount so sidebar links actually swap the displayed stage instead of just updating the URL. - Dispatch ftc-tests-updated when module state is persisted so the sidebar reflects advancement immediately. - Derive sidebar isCurrent from the URL on the active module to avoid a styling flicker between navigation and localStorage write. --- .../[moduleSlug]/MultiStageModulePage.tsx | 19 +++++++++++++++ components/layout/Sidebar.tsx | 23 +++++++++++++++---- test-results/.last-run.json | 4 ++++ 3 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 test-results/.last-run.json diff --git a/app/lessons/[moduleSlug]/MultiStageModulePage.tsx b/app/lessons/[moduleSlug]/MultiStageModulePage.tsx index 69c823d..d2e10a9 100644 --- a/app/lessons/[moduleSlug]/MultiStageModulePage.tsx +++ b/app/lessons/[moduleSlug]/MultiStageModulePage.tsx @@ -83,6 +83,7 @@ export function MultiStageModulePage({ const lastSavedSerializedRef = useRef(null) const codeRef = useRef(code) useEffect(() => { codeRef.current = code }, [code]) + const handleJumpStageRef = useRef<((idx: number) => void) | null>(null) // Initialize editor buffer for the active stage. // Priority: persisted perStageCode -> per-stage Starter.java -> carry-forward -> module starter @@ -201,6 +202,19 @@ export function MultiStageModulePage({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentStage, hydrated]) + // React to external ?stage= changes (e.g. sidebar link clicks) + const stageParamValue = searchParams.get("stage") + useEffect(() => { + if (!hydrated) return + if (!stageParamValue) return + const n = Number(stageParamValue) - 1 + if (!Number.isInteger(n)) return + const target = clampStage(n, totalStages) + if (target === currentStage) return + handleJumpStageRef.current?.(target) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stageParamValue, hydrated]) + // Serialize + persist state (localStorage always; DB debounced for logged-in) const state: MultiStageProgressState = useMemo( () => ({ @@ -217,6 +231,7 @@ export function MultiStageModulePage({ useEffect(() => { if (!hydrated) return localStorage.setItem(storageKey(moduleSlug), serialized) + window.dispatchEvent(new Event("ftc-tests-updated")) }, [hydrated, serialized, moduleSlug]) const debouncedSerialized = useDebounce(serialized, 1500) @@ -270,6 +285,10 @@ export function MultiStageModulePage({ [currentStage, perStageCode, initBufferForStage] ) + useEffect(() => { + handleJumpStageRef.current = handleJumpStage + }, [handleJumpStage]) + const handleAdvance = useCallback(() => { if (!canAdvance) return const stageSlug = activeStage.slug diff --git a/components/layout/Sidebar.tsx b/components/layout/Sidebar.tsx index 7adc026..02786ea 100644 --- a/components/layout/Sidebar.tsx +++ b/components/layout/Sidebar.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react" import Link from "next/link" -import { usePathname } from "next/navigation" +import { usePathname, useSearchParams } from "next/navigation" import { ChevronRight, ChevronDown, BookOpen, Home, Menu, X, LogIn, LogOut, PanelLeftClose, BarChart2, Check, Layers } from "lucide-react" import { useSession, signOut } from "next-auth/react" import type { SidebarModule } from "@/lib/types" @@ -168,6 +168,7 @@ function loadMultiStageProgress(modules: SidebarModule[]): MultiStageProgress { export function Sidebar({ modules, moduleSlug, lessonSlug, onCollapse }: SidebarProps) { const { data: session } = useSession() const pathname = usePathname() + const searchParams = useSearchParams() const [mobileOpen, setMobileOpen] = useState(false) const [expandedModules, setExpandedModules] = useState>(() => { const initial = new Set() @@ -279,13 +280,25 @@ export function Sidebar({ modules, moduleSlug, lessonSlug, onCollapse }: Sidebar {isExpanded && (
      {mod.stages.map((stage, idx) => { - const completedSet = new Set(progress?.completed ?? []) + const completedArr = progress?.completed ?? [] + const completedSet = new Set(completedArr) const isCompleted = completedSet.has(idx) + const maxCompleted = completedArr.length + ? Math.max(...completedArr) + : -1 const savedStage = progress?.currentStage ?? 0 - const isCurrent = + const urlStageRaw = searchParams.get("stage") + const urlStage = urlStageRaw ? Number(urlStageRaw) - 1 : null + const viewedStage = mod.meta.slug === moduleSlug && - savedStage === idx - const isUnlocked = isCompleted || idx <= savedStage + urlStage !== null && + Number.isInteger(urlStage) && + urlStage >= 0 + ? urlStage + : savedStage + const isCurrent = + mod.meta.slug === moduleSlug && viewedStage === idx + const isUnlocked = isCompleted || idx <= maxCompleted + 1 return (
    • Date: Sun, 19 Apr 2026 17:33:26 -0400 Subject: [PATCH 4/6] chore: gitignore playwright test-results --- .gitignore | 4 ++++ test-results/.last-run.json | 4 ---- 2 files changed, 4 insertions(+), 4 deletions(-) delete mode 100644 test-results/.last-run.json diff --git a/.gitignore b/.gitignore index 3cede48..fa8d9ac 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,10 @@ next-env.d.ts # CheerpJ build artifacts (built with: bun run build:cheerpj) /public/cheerpj/ +# Playwright +/test-results/ +/playwright-report/ + # IDE .idea .vscode diff --git a/test-results/.last-run.json b/test-results/.last-run.json deleted file mode 100644 index cbcc1fb..0000000 --- a/test-results/.last-run.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": "passed", - "failedTests": [] -} \ No newline at end of file From b015f3e3c759dd082129463905122b0ddefa2eb1 Mon Sep 17 00:00:00 2001 From: Trevor Bedson Date: Sun, 19 Apr 2026 17:41:30 -0400 Subject: [PATCH 5/6] fix(getting-started): carry-forward code and register prior-stage devices - Prioritize carry-forward over per-stage Starter.java so each stage builds on the learner's prior code by default. - Register testMotor on the servo-control stage test and testServo on the telemetry stage test so carried-forward code referencing earlier devices executes instead of throwing before assertions. --- app/lessons/[moduleSlug]/MultiStageModulePage.tsx | 6 +++--- .../lessons/01-getting-started/03-servo-control/Test.java | 2 ++ .../01-getting-started/04-telemetry-deep-dive/Test.java | 2 ++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/lessons/[moduleSlug]/MultiStageModulePage.tsx b/app/lessons/[moduleSlug]/MultiStageModulePage.tsx index d2e10a9..736244e 100644 --- a/app/lessons/[moduleSlug]/MultiStageModulePage.tsx +++ b/app/lessons/[moduleSlug]/MultiStageModulePage.tsx @@ -86,15 +86,15 @@ export function MultiStageModulePage({ const handleJumpStageRef = useRef<((idx: number) => void) | null>(null) // Initialize editor buffer for the active stage. - // Priority: persisted perStageCode -> per-stage Starter.java -> carry-forward -> module starter + // Priority: persisted perStageCode -> carry-forward from previous stage + // -> per-stage Starter.java -> module starter const initBufferForStage = useCallback( (nextIndex: number, state: { perStageCode: Record; carryFrom?: string }) => { const saved = state.perStageCode[String(nextIndex)] if (saved !== undefined) return saved + if (state.carryFrom !== undefined) return state.carryFrom const stage = data.stages[nextIndex] if (stage?.starterCode !== undefined) return stage.starterCode - if (state.carryFrom !== undefined) return state.carryFrom - // First stage fallback return data.starterCode }, [data.stages, data.starterCode] diff --git a/content/lessons/01-getting-started/03-servo-control/Test.java b/content/lessons/01-getting-started/03-servo-control/Test.java index 1c387ac..79bd4e2 100644 --- a/content/lessons/01-getting-started/03-servo-control/Test.java +++ b/content/lessons/01-getting-started/03-servo-control/Test.java @@ -6,6 +6,8 @@ public static void main(String[] args) throws Exception { HardwareMap hwMap = new HardwareMap(); ServoImpl testServo = new ServoImpl(); hwMap.registerDevice("testServo", testServo); + DcMotorImpl testMotor = new DcMotorImpl(); + hwMap.registerDevice("testMotor", testMotor); StudentCode op = new StudentCode(); op.hardwareMap = hwMap; diff --git a/content/lessons/01-getting-started/04-telemetry-deep-dive/Test.java b/content/lessons/01-getting-started/04-telemetry-deep-dive/Test.java index 90c27ba..93eca07 100644 --- a/content/lessons/01-getting-started/04-telemetry-deep-dive/Test.java +++ b/content/lessons/01-getting-started/04-telemetry-deep-dive/Test.java @@ -7,6 +7,8 @@ public static void main(String[] args) throws Exception { HardwareMap hwMap = new HardwareMap(); DcMotorImpl testMotor = new DcMotorImpl(); hwMap.registerDevice("testMotor", testMotor); + ServoImpl testServo = new ServoImpl(); + hwMap.registerDevice("testServo", testServo); TelemetryImpl telemetry = new TelemetryImpl(); From 308520283ebf24a0996204d14ae642dddc1ee6a7 Mon Sep 17 00:00:00 2001 From: Trevor Bedson Date: Sun, 19 Apr 2026 23:12:19 -0400 Subject: [PATCH 6/6] feat(sidebar): group modules into named sections - Add content/lessons/_sections.json declaring 4 sections: The Basics, Sensors & Feedback, Autonomy & Control, Advanced Systems. - getModuleSections() loader resolves slugs in declared order, with an auto 'Other' bucket for unlisted modules. - Sidebar and ProgressPage iterate sections and render a small header above each group; flatten modules internally so progress lookup behavior is unchanged. feat(state-machines): convert module to multi-stage Two stages with carry-forward priority, module-level Starter/Solution. chore: remove multithreading module and curriculum intro section Also bypass generateMetadata for multistage modules to avoid ENOENT on old lesson URLs, drop unused brace-expansion override, and clear a stale eslint-disable. --- .../[moduleSlug]/MultiStageModulePage.tsx | 9 +- .../[moduleSlug]/[lessonSlug]/LessonPage.tsx | 8 +- .../[moduleSlug]/[lessonSlug]/page.tsx | 17 +- app/lessons/[moduleSlug]/page.tsx | 7 +- app/lessons/introduction/page.tsx | 31 +--- app/progress/ProgressPage.tsx | 129 +++++++++----- app/progress/page.tsx | 8 +- bun.lock | 19 ++- components/layout/Sidebar.tsx | 21 ++- components/lesson/ModuleStageStepper.tsx | 1 - .../{exercise.json => stage.json} | 0 .../{exercise.json => stage.json} | 0 .../lessons/08-state-machines/Solution.java | 53 ++++++ .../lessons/08-state-machines/Starter.java | 39 +++++ .../lessons/08-state-machines/_module.json | 5 +- .../01-loop-time/Solution.java | 28 ---- .../01-loop-time/Starter.java | 27 --- .../15-multithreading/01-loop-time/Test.java | 33 ---- .../01-loop-time/content.mdx | 100 ----------- .../01-loop-time/exercise.json | 18 -- .../02-java-threads/Solution.java | 39 ----- .../02-java-threads/Starter.java | 30 ---- .../02-java-threads/Test.java | 28 ---- .../02-java-threads/content.mdx | 140 ---------------- .../02-java-threads/exercise.json | 18 -- .../03-thread-safety/Solution.java | 29 ---- .../03-thread-safety/Starter.java | 26 --- .../03-thread-safety/Test.java | 27 --- .../03-thread-safety/content.mdx | 127 -------------- .../03-thread-safety/exercise.json | 18 -- .../04-async-sensor-reads/Solution.java | 44 ----- .../04-async-sensor-reads/Starter.java | 34 ---- .../04-async-sensor-reads/Test.java | 34 ---- .../04-async-sensor-reads/content.mdx | 141 ---------------- .../04-async-sensor-reads/exercise.json | 18 -- .../05-kotlin-coroutines/Solution.java | 38 ----- .../05-kotlin-coroutines/Starter.java | 36 ---- .../05-kotlin-coroutines/Test.java | 34 ---- .../05-kotlin-coroutines/content.mdx | 158 ------------------ .../05-kotlin-coroutines/exercise.json | 18 -- .../lessons/15-multithreading/_module.json | 5 - content/lessons/_sections.json | 8 + lib/lessons.ts | 42 +++++ lib/types.ts | 5 + package.json | 1 - 45 files changed, 305 insertions(+), 1346 deletions(-) rename content/lessons/08-state-machines/01-intro-to-fsm/{exercise.json => stage.json} (100%) rename content/lessons/08-state-machines/02-multi-mechanism-fsm/{exercise.json => stage.json} (100%) create mode 100644 content/lessons/08-state-machines/Solution.java create mode 100644 content/lessons/08-state-machines/Starter.java delete mode 100644 content/lessons/15-multithreading/01-loop-time/Solution.java delete mode 100644 content/lessons/15-multithreading/01-loop-time/Starter.java delete mode 100644 content/lessons/15-multithreading/01-loop-time/Test.java delete mode 100644 content/lessons/15-multithreading/01-loop-time/content.mdx delete mode 100644 content/lessons/15-multithreading/01-loop-time/exercise.json delete mode 100644 content/lessons/15-multithreading/02-java-threads/Solution.java delete mode 100644 content/lessons/15-multithreading/02-java-threads/Starter.java delete mode 100644 content/lessons/15-multithreading/02-java-threads/Test.java delete mode 100644 content/lessons/15-multithreading/02-java-threads/content.mdx delete mode 100644 content/lessons/15-multithreading/02-java-threads/exercise.json delete mode 100644 content/lessons/15-multithreading/03-thread-safety/Solution.java delete mode 100644 content/lessons/15-multithreading/03-thread-safety/Starter.java delete mode 100644 content/lessons/15-multithreading/03-thread-safety/Test.java delete mode 100644 content/lessons/15-multithreading/03-thread-safety/content.mdx delete mode 100644 content/lessons/15-multithreading/03-thread-safety/exercise.json delete mode 100644 content/lessons/15-multithreading/04-async-sensor-reads/Solution.java delete mode 100644 content/lessons/15-multithreading/04-async-sensor-reads/Starter.java delete mode 100644 content/lessons/15-multithreading/04-async-sensor-reads/Test.java delete mode 100644 content/lessons/15-multithreading/04-async-sensor-reads/content.mdx delete mode 100644 content/lessons/15-multithreading/04-async-sensor-reads/exercise.json delete mode 100644 content/lessons/15-multithreading/05-kotlin-coroutines/Solution.java delete mode 100644 content/lessons/15-multithreading/05-kotlin-coroutines/Starter.java delete mode 100644 content/lessons/15-multithreading/05-kotlin-coroutines/Test.java delete mode 100644 content/lessons/15-multithreading/05-kotlin-coroutines/content.mdx delete mode 100644 content/lessons/15-multithreading/05-kotlin-coroutines/exercise.json delete mode 100644 content/lessons/15-multithreading/_module.json create mode 100644 content/lessons/_sections.json diff --git a/app/lessons/[moduleSlug]/MultiStageModulePage.tsx b/app/lessons/[moduleSlug]/MultiStageModulePage.tsx index 736244e..3f1d9aa 100644 --- a/app/lessons/[moduleSlug]/MultiStageModulePage.tsx +++ b/app/lessons/[moduleSlug]/MultiStageModulePage.tsx @@ -18,14 +18,14 @@ import { executeInBrowser } from "@/lib/cheerpj-executor" import { isMultiStageProgressState, type ExecutionResult, + type ModuleSection, type MultiStageModuleData, type MultiStageProgressState, - type SidebarModule, } from "@/lib/types" interface MultiStageModulePageProps { data: MultiStageModuleData - modules: SidebarModule[] + sections: ModuleSection[] moduleSlug: string userId: string | null } @@ -55,7 +55,7 @@ function clampStage(n: number, total: number) { export function MultiStageModulePage({ data, - modules, + sections, moduleSlug, userId, }: MultiStageModulePageProps) { @@ -257,7 +257,6 @@ export function MultiStageModulePage({ lessonId: `${moduleSlug}/${activeStage.slug}`, }), }).catch(() => {}) - // eslint-disable-next-line react-hooks/exhaustive-deps }, [hydrated, moduleSlug, activeStage.slug]) const latestRunAllPassed = @@ -504,7 +503,7 @@ export function MultiStageModulePage({
      {!sidebarCollapsed && ( setSidebarCollapsed(true)} diff --git a/app/lessons/[moduleSlug]/[lessonSlug]/LessonPage.tsx b/app/lessons/[moduleSlug]/[lessonSlug]/LessonPage.tsx index 55470e2..f6df8c9 100644 --- a/app/lessons/[moduleSlug]/[lessonSlug]/LessonPage.tsx +++ b/app/lessons/[moduleSlug]/[lessonSlug]/LessonPage.tsx @@ -13,11 +13,11 @@ import { OutputPanel } from "@/components/editor/OutputPanel" import { HintAccordion } from "@/components/ui/HintAccordion" import { useCheerpJ } from "@/lib/cheerpj-context" import { executeInBrowser } from "@/lib/cheerpj-executor" -import type { LessonData, SidebarModule, ExecutionResult } from "@/lib/types" +import type { LessonData, ModuleSection, ExecutionResult } from "@/lib/types" interface LessonPageProps { data: LessonData - modules: SidebarModule[] + sections: ModuleSection[] moduleSlug: string lessonSlug: string userId: string | null @@ -43,7 +43,7 @@ function useDebounce(value: T, delay: number): T { export function LessonPage({ data, - modules, + sections, moduleSlug, lessonSlug, userId, @@ -237,7 +237,7 @@ export function LessonPage({
      {!sidebarCollapsed && ( setSidebarCollapsed(true)} diff --git a/app/lessons/[moduleSlug]/[lessonSlug]/page.tsx b/app/lessons/[moduleSlug]/[lessonSlug]/page.tsx index 3a631d4..2227b6b 100644 --- a/app/lessons/[moduleSlug]/[lessonSlug]/page.tsx +++ b/app/lessons/[moduleSlug]/[lessonSlug]/page.tsx @@ -1,5 +1,5 @@ import { notFound, redirect } from "next/navigation" -import { getLessonData, getModules } from "@/lib/lessons" +import { getLessonData, getModules, getModuleSections } from "@/lib/lessons" import { auth } from "@/auth" import { LessonPage } from "./LessonPage" @@ -12,7 +12,11 @@ interface Props { export default async function LessonRoute({ params }: Props) { const { moduleSlug, lessonSlug } = await params - const [modules, session] = await Promise.all([getModules(), auth()]) + const [modules, sections, session] = await Promise.all([ + getModules(), + getModuleSections(), + auth(), + ]) const mod = modules.find((m) => m.meta.slug === moduleSlug) if (mod?.meta.type === "multistage") { redirect(`/lessons/${moduleSlug}`) @@ -27,7 +31,7 @@ export default async function LessonRoute({ params }: Props) { return ( m.meta.slug === moduleSlug) + if (!mod || mod.meta.type === "multistage") { + return { title: "Lesson Not Found" } + } + const data = await getLessonData(moduleSlug, lessonSlug).catch(() => null) if (!data) return { title: "Lesson Not Found" } return { title: `${data.lesson.title} | Code FTC`, diff --git a/app/lessons/[moduleSlug]/page.tsx b/app/lessons/[moduleSlug]/page.tsx index a9f4e45..36dd30c 100644 --- a/app/lessons/[moduleSlug]/page.tsx +++ b/app/lessons/[moduleSlug]/page.tsx @@ -1,5 +1,5 @@ import { notFound, redirect } from "next/navigation" -import { getMultiStageModuleData, getModules } from "@/lib/lessons" +import { getMultiStageModuleData, getModules, getModuleSections } from "@/lib/lessons" import { auth } from "@/auth" import { MultiStageModulePage } from "./MultiStageModulePage" @@ -20,9 +20,10 @@ export default async function ModuleRoute({ params }: Props) { redirect(`/lessons/${moduleSlug}/${first.slug}`) } - const [data, session] = await Promise.all([ + const [data, session, sections] = await Promise.all([ getMultiStageModuleData(moduleSlug), auth(), + getModuleSections(), ]) if (!data) notFound() @@ -30,7 +31,7 @@ export default async function ModuleRoute({ params }: Props) { return ( diff --git a/app/lessons/introduction/page.tsx b/app/lessons/introduction/page.tsx index 89963b1..6537c85 100644 --- a/app/lessons/introduction/page.tsx +++ b/app/lessons/introduction/page.tsx @@ -1,4 +1,4 @@ -import { getModules } from "@/lib/lessons" +import { getModuleSections } from "@/lib/lessons" import { Sidebar } from "@/components/layout/Sidebar" import { BookOpen, Code, Cpu, Zap } from "lucide-react" import Link from "next/link" @@ -9,11 +9,11 @@ export const metadata = { } export default async function IntroductionPage() { - const modules = await getModules() + const sections = await getModuleSections() return (
      - +
      {/* Hero section */} @@ -65,31 +65,6 @@ export default async function IntroductionPage() {

      - {/* Curriculum overview */} -

      Curriculum

      -
      - {modules.map((mod) => ( -
      -

      {mod.meta.title}

      - {mod.meta.description && ( -

      {mod.meta.description}

      - )} -
        - {mod.lessons.map((lesson) => ( -
      • - - {lesson.title} - -
      • - ))} -
      -
      - ))} -
      - {/* CTA */}
      +type AllProgress = Record function loadAllProgress(modules: SidebarModule[]): AllProgress { const result: AllProgress = {} for (const mod of modules) { + if (mod.meta.type === "multistage") { + for (let i = 0; i < mod.stages.length; i++) { + const id = `${mod.meta.slug}#${i}` + try { + const raw = localStorage.getItem(`ftc-tests:${id}`) + if (raw) result[id] = JSON.parse(raw) as ItemProgress + } catch { /* ignore */ } + } + continue + } for (const lesson of mod.lessons) { const id = `${lesson.moduleSlug}/${lesson.slug}` try { const raw = localStorage.getItem(`ftc-tests:${id}`) - if (raw) result[id] = JSON.parse(raw) as LessonProgress + if (raw) result[id] = JSON.parse(raw) as ItemProgress } catch { /* ignore */ } } } return result } +interface Row { + id: string + title: string + href: string + passed: number + total: number +} + +function buildRows(mod: SidebarModule, progress: AllProgress): Row[] { + if (mod.meta.type === "multistage") { + return mod.stages.map((stage, i) => { + const id = `${mod.meta.slug}#${i}` + const p = progress[id] + return { + id, + title: stage.title, + href: `/lessons/${mod.meta.slug}?stage=${i + 1}`, + passed: p?.passed ?? 0, + total: p?.total ?? stage.testCount, + } + }) + } + return mod.lessons.map((lesson) => { + const id = `${lesson.moduleSlug}/${lesson.slug}` + const p = progress[id] + return { + id, + title: lesson.title, + href: `/lessons/${lesson.moduleSlug}/${lesson.slug}`, + passed: p?.passed ?? 0, + total: p?.total ?? lesson.testCount, + } + }) +} + function ProgressBar({ passed, total }: { passed: number; total: number }) { const pct = total > 0 ? Math.round((passed / total) * 100) : 0 const color = @@ -57,7 +102,8 @@ function ProgressBar({ passed, total }: { passed: number; total: number }) { ) } -export function ProgressPage({ modules }: { modules: SidebarModule[] }) { +export function ProgressPage({ sections }: { sections: ModuleSection[] }) { + const modules: SidebarModule[] = sections.flatMap((s) => s.modules) const [progress, setProgress] = useState({}) useEffect(() => { @@ -65,17 +111,22 @@ export function ProgressPage({ modules }: { modules: SidebarModule[] }) { handler() window.addEventListener("ftc-tests-updated", handler) return () => window.removeEventListener("ftc-tests-updated", handler) - }, [modules]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sections]) + + const sectionData = sections.map((section) => ({ + title: section.title, + modules: section.modules.map((mod) => ({ mod, rows: buildRows(mod, progress) })), + })) - // Compute totals let totalPassed = 0 let totalTests = 0 - for (const mod of modules) { - for (const lesson of mod.lessons) { - const id = `${lesson.moduleSlug}/${lesson.slug}` - const p = progress[id] - totalPassed += p?.passed ?? 0 - totalTests += p?.total ?? lesson.testCount + for (const section of sectionData) { + for (const { rows } of section.modules) { + for (const row of rows) { + totalPassed += row.passed + totalTests += row.total + } } } const overallPct = totalTests > 0 ? Math.round((totalPassed / totalTests) * 100) : 0 @@ -109,66 +160,68 @@ export function ProgressPage({ modules }: { modules: SidebarModule[] }) {
      - {/* Per-module breakdown */} -
      - {modules.map((mod) => { - let modPassed = 0 - let modTotal = 0 - for (const lesson of mod.lessons) { - const id = `${lesson.moduleSlug}/${lesson.slug}` - const p = progress[id] - modPassed += p?.passed ?? 0 - modTotal += p?.total ?? lesson.testCount - } + {/* Section-grouped module breakdown */} +
      + {sectionData.map((section) => { + const visibleModules = section.modules.filter(({ rows }) => rows.length > 0) + if (visibleModules.length === 0) return null + return ( +
      +

      + {section.title} +

      +
      + {visibleModules.map(({ mod, rows }) => { + const modPassed = rows.reduce((s, r) => s + r.passed, 0) + const modTotal = rows.reduce((s, r) => s + r.total, 0) return (
      -

      +

      {mod.meta.title} -

      + {modPassed}/{modTotal}
      - {mod.lessons.map((lesson, i) => { - const id = `${lesson.moduleSlug}/${lesson.slug}` - const p = progress[id] - const passed = p?.passed ?? 0 - const total = p?.total ?? lesson.testCount - const isComplete = total > 0 && passed === total + {rows.map((row, i) => { + const isComplete = row.total > 0 && row.passed === row.total return ( 0 ? "border-t border-[var(--color-border)]" : "" }`} > - {/* Completion dot */} 0 + : row.passed > 0 ? "bg-[var(--color-warning)]" : "bg-red-500" }`} /> - {lesson.title} + {row.title}
      - +
      ) })}
      + ) + })} +
      +
      ) })}
      diff --git a/app/progress/page.tsx b/app/progress/page.tsx index 86ae7fd..91bfb3a 100644 --- a/app/progress/page.tsx +++ b/app/progress/page.tsx @@ -1,13 +1,13 @@ import { redirect } from "next/navigation" import { auth } from "@/auth" -import { getModules } from "@/lib/lessons" +import { getModuleSections } from "@/lib/lessons" import { Sidebar } from "@/components/layout/Sidebar" import { ProgressPage } from "./ProgressPage" export const metadata = { title: "My Progress | Code FTC" } export default async function ProgressRoute() { - const [session, modules] = await Promise.all([auth(), getModules()]) + const [session, sections] = await Promise.all([auth(), getModuleSections()]) if (!session?.user) { redirect("/auth/signin?callbackUrl=/progress") @@ -15,9 +15,9 @@ export default async function ProgressRoute() { return (
      - +
      - +
      ) diff --git a/bun.lock b/bun.lock index 67ba888..b19e9b6 100644 --- a/bun.lock +++ b/bun.lock @@ -45,7 +45,6 @@ }, "overrides": { "@hono/node-server": "^1.19.14", - "brace-expansion": "^2.0.2", "defu": "^6.1.7", "dompurify": "^3.4.0", "flatted": "^3.4.2", @@ -478,13 +477,13 @@ "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], "better-result": ["better-result@2.8.2", "", {}, "sha512-YOf0VSj5nUPI27doTtXF+BBnsiRq3qY7avHqfIWnppxTLGyvkLq1QV2RTxkwoZwJ60ywLfZ0raFF4J/G886i7A=="], - "brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + "brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -524,6 +523,8 @@ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], @@ -1495,5 +1496,17 @@ "which-builtin-type/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], "@prisma/streams-local/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "eslint-plugin-import/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + + "eslint-plugin-jsx-a11y/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + + "eslint-plugin-react/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + + "eslint-plugin-import/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "eslint-plugin-jsx-a11y/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "eslint-plugin-react/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], } } diff --git a/components/layout/Sidebar.tsx b/components/layout/Sidebar.tsx index 02786ea..e4da248 100644 --- a/components/layout/Sidebar.tsx +++ b/components/layout/Sidebar.tsx @@ -5,10 +5,10 @@ import Link from "next/link" import { usePathname, useSearchParams } from "next/navigation" import { ChevronRight, ChevronDown, BookOpen, Home, Menu, X, LogIn, LogOut, PanelLeftClose, BarChart2, Check, Layers } from "lucide-react" import { useSession, signOut } from "next-auth/react" -import type { SidebarModule } from "@/lib/types" +import type { ModuleSection, SidebarModule } from "@/lib/types" interface SidebarProps { - modules: SidebarModule[] + sections: ModuleSection[] moduleSlug: string lessonSlug: string onCollapse?: () => void @@ -165,10 +165,11 @@ function loadMultiStageProgress(modules: SidebarModule[]): MultiStageProgress { return result } -export function Sidebar({ modules, moduleSlug, lessonSlug, onCollapse }: SidebarProps) { +export function Sidebar({ sections, moduleSlug, lessonSlug, onCollapse }: SidebarProps) { const { data: session } = useSession() const pathname = usePathname() const searchParams = useSearchParams() + const modules: SidebarModule[] = sections.flatMap((s) => s.modules) const [mobileOpen, setMobileOpen] = useState(false) const [expandedModules, setExpandedModules] = useState>(() => { const initial = new Set() @@ -186,7 +187,10 @@ export function Sidebar({ modules, moduleSlug, lessonSlug, onCollapse }: Sidebar handler() window.addEventListener("ftc-tests-updated", handler) return () => window.removeEventListener("ftc-tests-updated", handler) - }, [modules]) + // modules is derived from sections; depending on sections keeps the + // reference stable across renders and avoids an infinite update loop. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sections]) function toggleModule(slug: string) { setExpandedModules((prev) => { @@ -250,7 +254,12 @@ export function Sidebar({ modules, moduleSlug, lessonSlug, onCollapse }: Sidebar )} - {modules.map((mod) => { + {sections.map((section, sectionIdx) => ( +
      0 ? "mt-3" : "mt-2"}> +

      + {section.title} +

      + {section.modules.map((mod) => { const isExpanded = expandedModules.has(mod.meta.slug) if (mod.meta.type === "multistage") { @@ -376,6 +385,8 @@ export function Sidebar({ modules, moduleSlug, lessonSlug, onCollapse }: Sidebar
      ) })} +
      + ))}
      diff --git a/components/lesson/ModuleStageStepper.tsx b/components/lesson/ModuleStageStepper.tsx index c98141a..3cae778 100644 --- a/components/lesson/ModuleStageStepper.tsx +++ b/components/lesson/ModuleStageStepper.tsx @@ -8,7 +8,6 @@ interface ModuleStageStepperProps { stages: Stage[] current: number completed: number[] - onJump: (index: number) => void } export function ModuleStageStepper({ diff --git a/content/lessons/08-state-machines/01-intro-to-fsm/exercise.json b/content/lessons/08-state-machines/01-intro-to-fsm/stage.json similarity index 100% rename from content/lessons/08-state-machines/01-intro-to-fsm/exercise.json rename to content/lessons/08-state-machines/01-intro-to-fsm/stage.json diff --git a/content/lessons/08-state-machines/02-multi-mechanism-fsm/exercise.json b/content/lessons/08-state-machines/02-multi-mechanism-fsm/stage.json similarity index 100% rename from content/lessons/08-state-machines/02-multi-mechanism-fsm/exercise.json rename to content/lessons/08-state-machines/02-multi-mechanism-fsm/stage.json diff --git a/content/lessons/08-state-machines/Solution.java b/content/lessons/08-state-machines/Solution.java new file mode 100644 index 0000000..b4d8db2 --- /dev/null +++ b/content/lessons/08-state-machines/Solution.java @@ -0,0 +1,53 @@ +import com.qualcomm.robotcore.eventloop.opmode.LinearOpMode; +import com.qualcomm.robotcore.eventloop.opmode.TeleOp; +import com.qualcomm.robotcore.hardware.DcMotor; + +@TeleOp(name = "Lift FSM") +public class StudentCode extends LinearOpMode { + + enum LiftState { + IDLE, + RAISING, + HOLDING + } + + @Override + public void runOpMode() { + DcMotor liftMotor = hardwareMap.get(DcMotor.class, "liftMotor"); + LiftState state = LiftState.IDLE; + + waitForStart(); + + // --- First FSM iteration --- + switch (state) { + case IDLE: + liftMotor.setPower(0.0); + if (gamepad1.a) { + state = LiftState.RAISING; + } + break; + case RAISING: + liftMotor.setPower(1.0); + break; + case HOLDING: + liftMotor.setPower(0.1); + break; + } + + // --- Second FSM iteration --- + switch (state) { + case IDLE: + liftMotor.setPower(0.0); + if (gamepad1.a) { + state = LiftState.RAISING; + } + break; + case RAISING: + liftMotor.setPower(1.0); + break; + case HOLDING: + liftMotor.setPower(0.1); + break; + } + } +} \ No newline at end of file diff --git a/content/lessons/08-state-machines/Starter.java b/content/lessons/08-state-machines/Starter.java new file mode 100644 index 0000000..218c13b --- /dev/null +++ b/content/lessons/08-state-machines/Starter.java @@ -0,0 +1,39 @@ +import com.qualcomm.robotcore.eventloop.opmode.LinearOpMode; +import com.qualcomm.robotcore.eventloop.opmode.TeleOp; +import com.qualcomm.robotcore.hardware.DcMotor; + +@TeleOp(name = "Lift FSM") +public class StudentCode extends LinearOpMode { + + enum LiftState { + IDLE, + RAISING, + HOLDING + } + + @Override + public void runOpMode() { + DcMotor liftMotor = hardwareMap.get(DcMotor.class, "liftMotor"); + LiftState state = LiftState.IDLE; + + waitForStart(); + + // --- First FSM iteration --- + // TODO: Use a switch statement on 'state' to handle each LiftState. + // + // In the IDLE case: + // - Set liftMotor power to 0.0 + // - If gamepad1.a is pressed, transition to RAISING + // + // In the RAISING case: + // - Set liftMotor power to 1.0 + // + // In the HOLDING case: + // - Set liftMotor power to 0.1 + + // --- Second FSM iteration --- + // TODO: Copy the same switch statement here. + // In a real program this would be inside a while(opModeIsActive()) loop. + // The second iteration picks up the new state from the first. + } +} \ No newline at end of file diff --git a/content/lessons/08-state-machines/_module.json b/content/lessons/08-state-machines/_module.json index dab2c52..7e2a244 100644 --- a/content/lessons/08-state-machines/_module.json +++ b/content/lessons/08-state-machines/_module.json @@ -1,5 +1,6 @@ { "title": "State Machines", "order": 8, - "description": "Organize complex robot behavior with finite state machines." -} \ No newline at end of file + "description": "Organize complex robot behavior with finite state machines.", + "type": "multistage" +} diff --git a/content/lessons/15-multithreading/01-loop-time/Solution.java b/content/lessons/15-multithreading/01-loop-time/Solution.java deleted file mode 100644 index c5c1234..0000000 --- a/content/lessons/15-multithreading/01-loop-time/Solution.java +++ /dev/null @@ -1,28 +0,0 @@ -import com.qualcomm.robotcore.eventloop.opmode.LinearOpMode; -import com.qualcomm.robotcore.eventloop.opmode.TeleOp; -import com.qualcomm.robotcore.hardware.DcMotor; -import com.qualcomm.robotcore.util.ElapsedTime; - -@TeleOp(name = "Loop Time Monitor") -public class StudentCode extends LinearOpMode { - - ElapsedTime loopTimer = new ElapsedTime(); - - @Override - public void runOpMode() { - DcMotor leftMotor = hardwareMap.get(DcMotor.class, "leftMotor"); - DcMotor rightMotor = hardwareMap.get(DcMotor.class, "rightMotor"); - - waitForStart(); - - while (opModeIsActive()) { - loopTimer.reset(); - - leftMotor.setPower(-gamepad1.left_stick_y); - rightMotor.setPower(-gamepad1.right_stick_y); - - telemetry.addData("Loop ms", loopTimer.milliseconds()); - telemetry.update(); - } - } -} diff --git a/content/lessons/15-multithreading/01-loop-time/Starter.java b/content/lessons/15-multithreading/01-loop-time/Starter.java deleted file mode 100644 index 6ffce26..0000000 --- a/content/lessons/15-multithreading/01-loop-time/Starter.java +++ /dev/null @@ -1,27 +0,0 @@ -import com.qualcomm.robotcore.eventloop.opmode.LinearOpMode; -import com.qualcomm.robotcore.eventloop.opmode.TeleOp; -import com.qualcomm.robotcore.hardware.DcMotor; -import com.qualcomm.robotcore.util.ElapsedTime; - -@TeleOp(name = "Loop Time Monitor") -public class StudentCode extends LinearOpMode { - - // TODO: Declare an ElapsedTime loopTimer here - - @Override - public void runOpMode() { - DcMotor leftMotor = hardwareMap.get(DcMotor.class, "leftMotor"); - DcMotor rightMotor = hardwareMap.get(DcMotor.class, "rightMotor"); - - waitForStart(); - - while (opModeIsActive()) { - // TODO: Reset loopTimer at the top of the loop - - // TODO: Set motor powers from gamepad (negate Y axis) - - // TODO: Log loopTimer.milliseconds() as "Loop ms" - // TODO: Call telemetry.update() - } - } -} diff --git a/content/lessons/15-multithreading/01-loop-time/Test.java b/content/lessons/15-multithreading/01-loop-time/Test.java deleted file mode 100644 index df58b1b..0000000 --- a/content/lessons/15-multithreading/01-loop-time/Test.java +++ /dev/null @@ -1,33 +0,0 @@ -import com.qualcomm.robotcore.hardware.*; -import org.firstinspires.ftc.robotcore.external.TelemetryImpl; - -public class Test { - public static void main(String[] args) throws Exception { - HardwareMap hwMap = new HardwareMap(); - DcMotorImpl leftMotor = new DcMotorImpl(); - DcMotorImpl rightMotor = new DcMotorImpl(); - hwMap.registerDevice("leftMotor", leftMotor); - hwMap.registerDevice("rightMotor", rightMotor); - - TelemetryImpl telemetry = new TelemetryImpl(); - - StudentCode op = new StudentCode(); - op.hardwareMap = hwMap; - op.telemetry = telemetry; - op.gamepad1 = new Gamepad(); - op.gamepad2 = new Gamepad(); - op.gamepad1.left_stick_y = -0.7f; - op.gamepad1.right_stick_y = -0.5f; - op.setStarted(true); - op.setMaxActiveLoops(1); - - try { op.runOpMode(); } catch (Exception ignored) {} - - TestBase.assertNear("leftMotor power = 0.7 (-left_stick_y)", - leftMotor.getPower(), 0.7, 0.01); - TestBase.assertNear("rightMotor power = 0.5 (-right_stick_y)", - rightMotor.getPower(), 0.5, 0.01); - - TestBase.printResults(); - } -} diff --git a/content/lessons/15-multithreading/01-loop-time/content.mdx b/content/lessons/15-multithreading/01-loop-time/content.mdx deleted file mode 100644 index dd66145..0000000 --- a/content/lessons/15-multithreading/01-loop-time/content.mdx +++ /dev/null @@ -1,100 +0,0 @@ ---- -title: "Loop Time & Blocking" -description: "Understand what loop time is, what degrades it, and how to measure it — the foundation of writing performant FTC code." ---- - -# Loop Time & Blocking - -Every TeleOp and autonomous OpMode runs as a loop. How fast that loop runs determines how responsive your robot feels and how accurately your control algorithms execute. In this lesson, you will learn what loop time is, what causes it to spike, and how to measure it. - -## What Is Loop Time? - -Loop time is how long one iteration of your `while (opModeIsActive())` loop takes to complete. A single iteration reads gamepad input, runs control logic, writes motor outputs, and updates telemetry. The faster this completes, the more frequently the robot reacts to driver input and sensor feedback. - -| Loop time | Loop frequency | Feel | -|-----------|---------------|------| -| 1 ms | ~1000 Hz | Excellent | -| 5 ms | ~200 Hz | Good | -| 20 ms | ~50 Hz | Acceptable | -| 100 ms | ~10 Hz | Sluggish, control loops may be unstable | -| 500+ ms | <2 Hz | Broken — Driver Station may warn "Not responding" | - -For PID controllers, inconsistent loop time causes the derivative term to spike and the integral to accumulate incorrectly, making tuning much harder. - -## What Kills Loop Time? - -### `sleep()` and `Thread.sleep()` - -```java -// BAD — blocks the entire loop for 500ms -sleep(500); -``` - -This is the most obvious offender. Never call `sleep()` inside a TeleOp loop. - -### Slow I2C Sensors - -I2C is a serial communication bus. Reading from a color sensor, distance sensor, or IMU over I2C can take anywhere from 5ms to 30ms per read — sometimes more. If you call `colorSensor.red()` inside your main loop, that cost is paid every single iteration. - -```java -// Each of these may take 10–20ms -int r = colorSensor.red(); -int g = colorSensor.green(); -int b = colorSensor.blue(); -``` - -A single loop with three I2C reads could push loop time above 50ms even with no other logic. - -### Nested Loops and Busy-Waiting - -```java -// BAD — burns CPU and blocks the loop -while (motor.getCurrentPosition() < 1000) { /* spin */ } -``` - -This is a busy-wait. The main thread spins at full speed doing nothing useful while waiting for a condition that only changes when sensor hardware updates. - -### Heavy Computation - -Complex math (matrix operations, trigonometric calculations done naively in a loop) can add milliseconds. Cache computed values instead of recalculating them each iteration. - -## Measuring Loop Time - -Use `ElapsedTime` to measure how long each iteration takes: - -```java -ElapsedTime loopTimer = new ElapsedTime(); - -while (opModeIsActive()) { - loopTimer.reset(); // Start timing at the top of the loop - - // ... your robot code ... - - double loopTimeMs = loopTimer.milliseconds(); - telemetry.addData("Loop time (ms)", loopTimeMs); - telemetry.update(); -} -``` - -Add this to any OpMode you are optimizing. If you see consistent spikes above 20ms, there is likely a blocking call in your loop. - -## The Fix: Measure First, Then Optimize - -Before optimizing, **measure**. Add loop time telemetry and watch it on the Driver Station during a test run. A spike that only happens when you press a specific button tells you exactly where to look. - -Common fixes: -- Move I2C reads to a background thread (covered in lesson 4 of this module) -- Replace `sleep()` with a state machine using `ElapsedTime` -- Cache results that don't change every loop (e.g., `motor.getCurrentPosition()` if only needed every 5 loops) - -## Your Exercise - -You have a two-motor drive. Your task: - -1. Get `leftMotor` and `rightMotor` from the hardware map. -2. Declare an `ElapsedTime loopTimer` instance variable. -3. After `waitForStart()`, loop with `while (opModeIsActive())`. -4. At the top of each loop: reset `loopTimer`. -5. Set motor powers from gamepad (`-gamepad1.left_stick_y` and `-gamepad1.right_stick_y`). -6. Log `loopTimer.milliseconds()` to telemetry as `"Loop ms"`. -7. Call `telemetry.update()`. diff --git a/content/lessons/15-multithreading/01-loop-time/exercise.json b/content/lessons/15-multithreading/01-loop-time/exercise.json deleted file mode 100644 index bbf9b62..0000000 --- a/content/lessons/15-multithreading/01-loop-time/exercise.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "title": "Loop Time & Blocking", - "testCount": 2, - "hints": [ - { - "title": "Declaring ElapsedTime", - "content": "Declare `ElapsedTime loopTimer = new ElapsedTime();` before the loop. Call `loopTimer.reset()` at the very top of each loop iteration to start the timer for that iteration." - }, - { - "title": "Reading elapsed time", - "content": "Use `loopTimer.milliseconds()` to get how many milliseconds have passed since the last reset. Log it with `telemetry.addData(\"Loop ms\", loopTimer.milliseconds());`" - }, - { - "title": "Negating stick values", - "content": "Remember to negate the Y axis: `leftMotor.setPower(-gamepad1.left_stick_y);`" - } - ] -} diff --git a/content/lessons/15-multithreading/02-java-threads/Solution.java b/content/lessons/15-multithreading/02-java-threads/Solution.java deleted file mode 100644 index 1d05014..0000000 --- a/content/lessons/15-multithreading/02-java-threads/Solution.java +++ /dev/null @@ -1,39 +0,0 @@ -import com.qualcomm.robotcore.eventloop.opmode.LinearOpMode; -import com.qualcomm.robotcore.eventloop.opmode.TeleOp; -import com.qualcomm.robotcore.hardware.DcMotor; -import java.util.concurrent.atomic.AtomicInteger; - -@TeleOp(name = "Java Threads") -public class StudentCode extends LinearOpMode { - - volatile boolean threadRunning = true; - AtomicInteger counter = new AtomicInteger(0); - - @Override - public void runOpMode() throws InterruptedException { - DcMotor motor = hardwareMap.get(DcMotor.class, "motor"); - - Thread backgroundThread = new Thread(() -> { - while (threadRunning) { - counter.incrementAndGet(); - try { - Thread.sleep(5); - } catch (InterruptedException e) { - break; - } - } - }); - backgroundThread.setDaemon(true); - backgroundThread.start(); - - waitForStart(); - - motor.setPower(0.5); - telemetry.addData("Count", counter.get()); - telemetry.update(); - - threadRunning = false; - backgroundThread.interrupt(); - backgroundThread.join(); - } -} diff --git a/content/lessons/15-multithreading/02-java-threads/Starter.java b/content/lessons/15-multithreading/02-java-threads/Starter.java deleted file mode 100644 index ceda52a..0000000 --- a/content/lessons/15-multithreading/02-java-threads/Starter.java +++ /dev/null @@ -1,30 +0,0 @@ -import com.qualcomm.robotcore.eventloop.opmode.LinearOpMode; -import com.qualcomm.robotcore.eventloop.opmode.TeleOp; -import com.qualcomm.robotcore.hardware.DcMotor; -import java.util.concurrent.atomic.AtomicInteger; - -@TeleOp(name = "Java Threads") -public class StudentCode extends LinearOpMode { - - // TODO: Declare a volatile boolean threadRunning = true - // TODO: Declare an AtomicInteger counter = new AtomicInteger(0) - - @Override - public void runOpMode() throws InterruptedException { - DcMotor motor = hardwareMap.get(DcMotor.class, "motor"); - - // TODO: Create a background Thread that: - // - Loops while threadRunning is true - // - Calls counter.incrementAndGet() - // - Calls Thread.sleep(5) (handle InterruptedException by breaking) - // TODO: Set the thread as a daemon and start it - - waitForStart(); - - // TODO: Set motor power to 0.5 - // TODO: Log counter.get() to telemetry as "Count" - telemetry.update(); - - // TODO: Set threadRunning = false, interrupt, and join the thread - } -} diff --git a/content/lessons/15-multithreading/02-java-threads/Test.java b/content/lessons/15-multithreading/02-java-threads/Test.java deleted file mode 100644 index c397a7a..0000000 --- a/content/lessons/15-multithreading/02-java-threads/Test.java +++ /dev/null @@ -1,28 +0,0 @@ -import com.qualcomm.robotcore.hardware.*; -import org.firstinspires.ftc.robotcore.external.TelemetryImpl; - -public class Test { - public static void main(String[] args) throws Exception { - HardwareMap hwMap = new HardwareMap(); - DcMotorImpl motor = new DcMotorImpl(); - hwMap.registerDevice("motor", motor); - - StudentCode op = new StudentCode(); - op.hardwareMap = hwMap; - op.telemetry = new TelemetryImpl(); - op.gamepad1 = new Gamepad(); - op.gamepad2 = new Gamepad(); - op.setStarted(true); - - try { op.runOpMode(); } catch (Exception ignored) {} - - TestBase.assertNear("Motor power is set to 0.5", - motor.getPower(), 0.5, 0.01); - - TestBase.assertTrue("Background thread incremented counter at least once", - op.counter.get() >= 1, - "Counter was " + op.counter.get() + ", expected >= 1"); - - TestBase.printResults(); - } -} diff --git a/content/lessons/15-multithreading/02-java-threads/content.mdx b/content/lessons/15-multithreading/02-java-threads/content.mdx deleted file mode 100644 index 47bc5df..0000000 --- a/content/lessons/15-multithreading/02-java-threads/content.mdx +++ /dev/null @@ -1,140 +0,0 @@ ---- -title: "Java Threads" -description: "Create and safely manage background threads in FTC using Thread, Runnable, and the stop-flag pattern." ---- - -# Java Threads - -The previous lesson showed what blocks your main loop. The solution is often to move work off the main thread. In Java, that means creating a **thread** — an independent unit of execution that runs concurrently with your OpMode loop. - -## What Is a Thread? - -Your OpMode runs on the **main thread**. When you create a new `Thread`, the JVM schedules it to run alongside the main thread. Both threads run concurrently, sharing access to the same object fields. This lets you do work in the background without blocking the main loop. - -## Creating a Thread - -The simplest way is to pass a `Runnable` lambda: - -```java -Thread backgroundThread = new Thread(() -> { - // This code runs on the background thread - while (true) { - // do background work - } -}); - -backgroundThread.start(); // Begin execution -``` - -Once `start()` is called, the background thread begins running immediately alongside your main loop. - -## The Stop-Flag Pattern - -Background threads need a way to know when to stop. **Never** call `thread.stop()` — it is deprecated and can corrupt state. Instead, use a `volatile boolean` flag: - -```java -volatile boolean threadRunning = true; - -Thread backgroundThread = new Thread(() -> { - while (threadRunning) { - // do background work - } -}); -``` - -The `volatile` keyword tells the JVM that this variable may be changed by another thread, so it should always be read from main memory rather than a CPU cache. Without `volatile`, the background thread might never see that `threadRunning` has been set to `false`. - -To stop the thread: - -```java -threadRunning = false; -backgroundThread.interrupt(); // Wake it if it's sleeping -backgroundThread.join(); // Wait for it to finish -``` - -Always call `join()` before your OpMode ends. If you don't, the background thread can outlive the OpMode and interfere with the next one. - -## The `isDaemon` Option - -Marking a thread as a **daemon thread** means the JVM will kill it automatically if no non-daemon threads are alive. In FTC this is useful as a safety net, but you should still stop threads explicitly: - -```java -backgroundThread.setDaemon(true); -backgroundThread.start(); -``` - -## The `interrupt()` Pattern - -If your background thread calls `Thread.sleep()`, setting `threadRunning = false` alone won't wake it immediately — it will sleep through the rest of its sleep duration. `interrupt()` breaks the sleep early by throwing `InterruptedException`: - -```java -Thread backgroundThread = new Thread(() -> { - while (threadRunning) { - doWork(); - try { - Thread.sleep(10); // Sleep 10ms between reads - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); // Restore interrupted status - break; - } - } -}); -``` - -## Full Pattern: Background Sensor Read - -```java -@TeleOp(name = "Threaded Example") -public class ThreadedExample extends LinearOpMode { - - volatile boolean threadRunning = true; - volatile double sensorValue = 0.0; - - @Override - public void runOpMode() { - DistanceSensor sensor = hardwareMap.get(DistanceSensor.class, "frontSensor"); - DcMotor motor = hardwareMap.get(DcMotor.class, "driveMotor"); - - Thread sensorThread = new Thread(() -> { - while (threadRunning) { - sensorValue = sensor.getDistance(DistanceUnit.CM); - try { Thread.sleep(20); } catch (InterruptedException e) { break; } - } - }); - - sensorThread.setDaemon(true); - sensorThread.start(); - - waitForStart(); - - while (opModeIsActive()) { - motor.setPower(-gamepad1.left_stick_y); - - // Read the cached value — no I2C delay here - telemetry.addData("Distance (cm)", sensorValue); - telemetry.update(); - } - - // Clean shutdown - threadRunning = false; - sensorThread.interrupt(); - try { sensorThread.join(500); } catch (InterruptedException ignored) {} - } -} -``` - -The main loop reads `sensorValue` instantly — the I2C cost is paid by the background thread on its own schedule. - -## Your Exercise - -You have a `DcMotor` named `"motor"` and a shared `AtomicInteger counter` (imported from `java.util.concurrent.atomic`). - -Your task: - -1. Get `motor` from the hardware map. -2. Create a background thread that increments `counter` by 1, then calls `Thread.sleep(5)` in a loop, stopping when `threadRunning` becomes `false`. -3. Mark the thread as a daemon and start it. -4. After `waitForStart()`, run one iteration of the loop: set `motor` power to `0.5` and log `counter.get()` to telemetry. -5. Set `threadRunning = false`, call `interrupt()` and `join()` on the thread. - -After your code runs, `motor` power should be `0.5` and `counter` should be at least `1`. diff --git a/content/lessons/15-multithreading/02-java-threads/exercise.json b/content/lessons/15-multithreading/02-java-threads/exercise.json deleted file mode 100644 index 2808861..0000000 --- a/content/lessons/15-multithreading/02-java-threads/exercise.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "title": "Java Threads", - "testCount": 2, - "hints": [ - { - "title": "Creating a thread with a stop flag", - "content": "Declare `volatile boolean threadRunning = true;` as an instance variable. In the thread body, loop with `while (threadRunning)`. Set `threadRunning = false` to signal the thread to stop." - }, - { - "title": "AtomicInteger usage", - "content": "Declare `AtomicInteger counter = new AtomicInteger(0);`. In the background thread, call `counter.incrementAndGet()` to safely increment it. In the main thread, read it with `counter.get()`." - }, - { - "title": "Stopping a thread cleanly", - "content": "Set `threadRunning = false`, call `thread.interrupt()` to break any sleep, then call `thread.join()` to wait for it to fully finish before proceeding." - } - ] -} diff --git a/content/lessons/15-multithreading/03-thread-safety/Solution.java b/content/lessons/15-multithreading/03-thread-safety/Solution.java deleted file mode 100644 index 5899cee..0000000 --- a/content/lessons/15-multithreading/03-thread-safety/Solution.java +++ /dev/null @@ -1,29 +0,0 @@ -import com.qualcomm.robotcore.eventloop.opmode.LinearOpMode; -import com.qualcomm.robotcore.eventloop.opmode.TeleOp; -import com.qualcomm.robotcore.hardware.DcMotor; -import java.util.concurrent.atomic.AtomicReference; - -@TeleOp(name = "Thread Safety") -public class StudentCode extends LinearOpMode { - - AtomicReference cachedPower = new AtomicReference<>(0.0); - AtomicReference cachedPosition = new AtomicReference<>(0.0); - - @Override - public void runOpMode() throws InterruptedException { - DcMotor motor = hardwareMap.get(DcMotor.class, "motor"); - - Thread writer = new Thread(() -> { - cachedPower.set(0.75); - cachedPosition.set(1234.0); - }); - writer.start(); - writer.join(); - - waitForStart(); - - motor.setPower(cachedPower.get()); - telemetry.addData("Position", cachedPosition.get()); - telemetry.update(); - } -} diff --git a/content/lessons/15-multithreading/03-thread-safety/Starter.java b/content/lessons/15-multithreading/03-thread-safety/Starter.java deleted file mode 100644 index a580094..0000000 --- a/content/lessons/15-multithreading/03-thread-safety/Starter.java +++ /dev/null @@ -1,26 +0,0 @@ -import com.qualcomm.robotcore.eventloop.opmode.LinearOpMode; -import com.qualcomm.robotcore.eventloop.opmode.TeleOp; -import com.qualcomm.robotcore.hardware.DcMotor; -import java.util.concurrent.atomic.AtomicReference; - -@TeleOp(name = "Thread Safety") -public class StudentCode extends LinearOpMode { - - // TODO: Declare AtomicReference cachedPower initialized to 0.0 - // TODO: Declare AtomicReference cachedPosition initialized to 0.0 - - @Override - public void runOpMode() throws InterruptedException { - DcMotor motor = hardwareMap.get(DcMotor.class, "motor"); - - // TODO: Create a thread that sets cachedPower to 0.75 - // and cachedPosition to 1234.0, then finishes - // TODO: Start the thread and join() it - - waitForStart(); - - // TODO: Read cachedPower.get() and set motor power to that value - // TODO: Log cachedPosition.get() to telemetry as "Position" - telemetry.update(); - } -} diff --git a/content/lessons/15-multithreading/03-thread-safety/Test.java b/content/lessons/15-multithreading/03-thread-safety/Test.java deleted file mode 100644 index ff65226..0000000 --- a/content/lessons/15-multithreading/03-thread-safety/Test.java +++ /dev/null @@ -1,27 +0,0 @@ -import com.qualcomm.robotcore.hardware.*; -import org.firstinspires.ftc.robotcore.external.TelemetryImpl; - -public class Test { - public static void main(String[] args) throws Exception { - HardwareMap hwMap = new HardwareMap(); - DcMotorImpl motor = new DcMotorImpl(); - hwMap.registerDevice("motor", motor); - - StudentCode op = new StudentCode(); - op.hardwareMap = hwMap; - op.telemetry = new TelemetryImpl(); - op.gamepad1 = new Gamepad(); - op.gamepad2 = new Gamepad(); - op.setStarted(true); - - try { op.runOpMode(); } catch (Exception ignored) {} - - TestBase.assertNear("Motor power is 0.75 (read from AtomicReference)", - motor.getPower(), 0.75, 0.01); - - TestBase.assertNear("cachedPosition is 1234.0", - op.cachedPosition.get(), 1234.0, 0.01); - - TestBase.printResults(); - } -} diff --git a/content/lessons/15-multithreading/03-thread-safety/content.mdx b/content/lessons/15-multithreading/03-thread-safety/content.mdx deleted file mode 100644 index c45894f..0000000 --- a/content/lessons/15-multithreading/03-thread-safety/content.mdx +++ /dev/null @@ -1,127 +0,0 @@ ---- -title: "Thread Safety" -description: "Avoid race conditions using volatile, synchronized blocks, and atomic types to safely share data between threads." ---- - -# Thread Safety - -When two threads access the same data, things can go wrong in subtle ways. A variable that looks correct from one thread may be stale or half-written from another. This lesson covers the tools Java gives you to share data safely between threads. - -## The Race Condition Problem - -Consider two threads both incrementing the same `int count`: - -```java -int count = 0; - -// Thread A // Thread B -count++; count++; -``` - -`count++` is not one operation — it is three: **read**, **add 1**, **write back**. If Thread A reads `count = 5` and Thread B also reads `count = 5` before either writes back, both write `6`. You lost an increment. This is a **race condition**: the result depends on which thread runs first. - -## `volatile`: Visibility Without Atomicity - -`volatile` guarantees that reads and writes to a variable are always visible across threads. Without it, the JVM may cache a variable's value in a CPU register and never re-read from memory. - -```java -volatile boolean running = true; // Safe: writes are always visible -volatile double heading = 0.0; // Safe for double (but NOT for double++ !) -``` - -`volatile` is sufficient when: -- Only **one** thread writes the variable -- Other threads only **read** it -- The value is a single field (not a compound update like `x += 1`) - -`volatile` is **not** sufficient for `count++` because that is a compound read-modify-write. - -## `synchronized`: Mutual Exclusion - -A `synchronized` block ensures only one thread executes it at a time: - -```java -private final Object lock = new Object(); -private double cachedHeading = 0.0; - -// Writer thread -synchronized (lock) { - cachedHeading = imu.getRobotYawPitchRollAngles().getYaw(AngleUnit.DEGREES); -} - -// Reader thread -double heading; -synchronized (lock) { - heading = cachedHeading; -} -``` - -Both accesses use the same lock object, so they can never run simultaneously. The drawback is that acquiring and releasing a lock takes a small amount of time — for frequently-accessed values, atomic types are faster. - -## `AtomicReference` and `AtomicDouble`: Lock-Free Thread Safety - -The `java.util.concurrent.atomic` package provides types that handle thread-safety internally without a lock: - -```java -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; - -AtomicInteger count = new AtomicInteger(0); -count.incrementAndGet(); // Thread-safe increment -int value = count.get(); // Thread-safe read - -AtomicReference heading = new AtomicReference<>(0.0); -heading.set(imu.getHeading()); // Thread-safe write -double h = heading.get(); // Thread-safe read -``` - -Atomic types use hardware-level **compare-and-swap** (CAS) instructions, which are faster than `synchronized` for simple operations. Use them when you have a single value being written by one thread and read by another. - -## Choosing the Right Tool - -| Situation | Tool | -|-----------|------| -| One writer, many readers; single value | `volatile` | -| Counter incremented by multiple threads | `AtomicInteger` | -| Object reference shared across threads | `AtomicReference` | -| Multiple fields that must update together | `synchronized` block | -| Complex data structure updates | `synchronized` block or `java.util.concurrent` collections | - -## A Practical FTC Cache Pattern - -Here is the complete pattern for a thread-safe sensor cache — the most common multithreading pattern in FTC: - -```java -private final AtomicReference cachedDistance = new AtomicReference<>(0.0); -private volatile boolean threadRunning = true; - -private void startSensorThread(DistanceSensor sensor) { - Thread t = new Thread(() -> { - while (threadRunning) { - cachedDistance.set(sensor.getDistance(DistanceUnit.CM)); - try { Thread.sleep(20); } catch (InterruptedException e) { break; } - } - }); - t.setDaemon(true); - t.start(); -} -``` - -The main loop reads `cachedDistance.get()` at any time without blocking — no locks, no race conditions. - -## Your Exercise - -You have a `DcMotor` named `"motor"` and two `AtomicReference` fields: `cachedPower` and `cachedPosition`. - -Your task: - -1. Get `motor` from the hardware map. -2. Create a background thread that: - - Sets `cachedPower` to `0.75` using `cachedPower.set(0.75)`. - - Sets `cachedPosition` to `1234.0` using `cachedPosition.set(1234.0)`. - - Then stops (runs only once — no loop needed). -3. Start the thread and call `join()` on it so the main thread waits for it to finish. -4. After `waitForStart()`, read `cachedPower.get()` and set it as the motor's power. -5. Log `cachedPosition.get()` to telemetry as `"Position"`. - -The motor should end up at power `0.75` and telemetry should show position `1234.0`. diff --git a/content/lessons/15-multithreading/03-thread-safety/exercise.json b/content/lessons/15-multithreading/03-thread-safety/exercise.json deleted file mode 100644 index 57c6f91..0000000 --- a/content/lessons/15-multithreading/03-thread-safety/exercise.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "title": "Thread Safety", - "testCount": 2, - "hints": [ - { - "title": "Using AtomicReference", - "content": "Declare `AtomicReference cachedPower = new AtomicReference<>(0.0);`. Write with `cachedPower.set(0.75)` and read with `cachedPower.get()`." - }, - { - "title": "Waiting for the thread to finish", - "content": "Call `thread.join()` after starting it to block the main thread until the background thread completes. This ensures the cached values are ready before you read them." - }, - { - "title": "Setting motor power from the cache", - "content": "After join(), read the cached value: `double power = cachedPower.get();` then `motor.setPower(power);`" - } - ] -} diff --git a/content/lessons/15-multithreading/04-async-sensor-reads/Solution.java b/content/lessons/15-multithreading/04-async-sensor-reads/Solution.java deleted file mode 100644 index 307c345..0000000 --- a/content/lessons/15-multithreading/04-async-sensor-reads/Solution.java +++ /dev/null @@ -1,44 +0,0 @@ -import com.qualcomm.robotcore.eventloop.opmode.LinearOpMode; -import com.qualcomm.robotcore.eventloop.opmode.TeleOp; -import com.qualcomm.robotcore.hardware.DcMotor; -import com.qualcomm.robotcore.hardware.DistanceSensor; -import org.firstinspires.ftc.robotcore.external.navigation.DistanceUnit; -import java.util.concurrent.atomic.AtomicReference; - -@TeleOp(name = "Async Sensor Reads") -public class StudentCode extends LinearOpMode { - - AtomicReference cachedDistance = new AtomicReference<>(0.0); - volatile boolean sensorThreadRunning = true; - - @Override - public void runOpMode() throws InterruptedException { - DistanceSensor frontSensor = hardwareMap.get(DistanceSensor.class, "frontSensor"); - DcMotor driveMotor = hardwareMap.get(DcMotor.class, "driveMotor"); - - Thread sensorThread = new Thread(() -> { - while (sensorThreadRunning) { - cachedDistance.set(frontSensor.getDistance(DistanceUnit.CM)); - try { - Thread.sleep(20); - } catch (InterruptedException e) { - break; - } - } - }); - sensorThread.setDaemon(true); - sensorThread.start(); - Thread.sleep(50); - - waitForStart(); - - double distance = cachedDistance.get(); - driveMotor.setPower(distance > 30.0 ? 0.5 : 0.0); - telemetry.addData("Distance (cm)", distance); - telemetry.update(); - - sensorThreadRunning = false; - sensorThread.interrupt(); - sensorThread.join(500); - } -} diff --git a/content/lessons/15-multithreading/04-async-sensor-reads/Starter.java b/content/lessons/15-multithreading/04-async-sensor-reads/Starter.java deleted file mode 100644 index 8844436..0000000 --- a/content/lessons/15-multithreading/04-async-sensor-reads/Starter.java +++ /dev/null @@ -1,34 +0,0 @@ -import com.qualcomm.robotcore.eventloop.opmode.LinearOpMode; -import com.qualcomm.robotcore.eventloop.opmode.TeleOp; -import com.qualcomm.robotcore.hardware.DcMotor; -import com.qualcomm.robotcore.hardware.DistanceSensor; -import org.firstinspires.ftc.robotcore.external.navigation.DistanceUnit; -import java.util.concurrent.atomic.AtomicReference; - -@TeleOp(name = "Async Sensor Reads") -public class StudentCode extends LinearOpMode { - - // TODO: Declare AtomicReference cachedDistance initialized to 0.0 - // TODO: Declare volatile boolean sensorThreadRunning = true - - @Override - public void runOpMode() throws InterruptedException { - DistanceSensor frontSensor = hardwareMap.get(DistanceSensor.class, "frontSensor"); - DcMotor driveMotor = hardwareMap.get(DcMotor.class, "driveMotor"); - - // TODO: Create a Thread that: - // - Loops while sensorThreadRunning - // - Reads frontSensor.getDistance(DistanceUnit.CM) and stores in cachedDistance - // - Sleeps 20ms (handle InterruptedException by breaking) - // TODO: Set as daemon, start, then sleep 50ms to let it read once - - waitForStart(); - - // TODO: One loop iteration: - // - Read cachedDistance.get() - // - Set driveMotor power to 0.5 if distance > 30.0, else 0.0 - // - Log distance to telemetry, call telemetry.update() - - // TODO: Shut down: set sensorThreadRunning=false, interrupt, join - } -} diff --git a/content/lessons/15-multithreading/04-async-sensor-reads/Test.java b/content/lessons/15-multithreading/04-async-sensor-reads/Test.java deleted file mode 100644 index 938c507..0000000 --- a/content/lessons/15-multithreading/04-async-sensor-reads/Test.java +++ /dev/null @@ -1,34 +0,0 @@ -import com.qualcomm.robotcore.hardware.*; -import org.firstinspires.ftc.robotcore.external.TelemetryImpl; - -public class Test { - public static void main(String[] args) throws Exception { - HardwareMap hwMap = new HardwareMap(); - - DistanceSensorImpl frontSensor = new DistanceSensorImpl(); - frontSensor.setDistanceCm(45.0); // 45cm > 30cm threshold - - DcMotorImpl driveMotor = new DcMotorImpl(); - hwMap.registerDevice("frontSensor", frontSensor); - hwMap.registerDevice("driveMotor", driveMotor); - - StudentCode op = new StudentCode(); - op.hardwareMap = hwMap; - op.telemetry = new TelemetryImpl(); - op.gamepad1 = new Gamepad(); - op.gamepad2 = new Gamepad(); - op.setStarted(true); - op.setMaxActiveLoops(1); - - try { op.runOpMode(); } catch (Exception ignored) {} - - TestBase.assertTrue("Sensor thread read frontSensor at least once", - !frontSensor.getCallLog().isEmpty(), - "frontSensor.getDistance() was never called from background thread"); - - TestBase.assertNear("driveMotor power is 0.5 (distance 45cm > 30cm threshold)", - driveMotor.getPower(), 0.5, 0.01); - - TestBase.printResults(); - } -} diff --git a/content/lessons/15-multithreading/04-async-sensor-reads/content.mdx b/content/lessons/15-multithreading/04-async-sensor-reads/content.mdx deleted file mode 100644 index b3e9e61..0000000 --- a/content/lessons/15-multithreading/04-async-sensor-reads/content.mdx +++ /dev/null @@ -1,141 +0,0 @@ ---- -title: "Async Sensor Reads" -description: "Offload slow I2C sensor reads to a background thread and give your main loop instant access to cached values." ---- - -# Async Sensor Reads - -This lesson combines everything from the previous three lessons into the most important multithreading pattern for FTC: **asynchronous sensor reads**. I2C sensors are the most common source of loop time problems in competitive FTC robots. Moving them off the main thread is often worth 10–40ms per loop iteration. - -## Why I2C Sensors Are Slow - -I2C (Inter-Integrated Circuit) is a two-wire serial bus. Every read from a color sensor, distance sensor, or IMU goes through this bus: - -1. The Control Hub sends a read request over the I2C wire. -2. The sensor processes it. -3. The sensor sends data back. -4. The SDK deserializes and returns the value. - -This round-trip takes **5–30ms** depending on the sensor. Reading three channels of a color sensor (red, green, blue) could take 60ms or more — pushing your 1kHz loop down to 16Hz. - -## The Pattern - -Keep a **cached value** that the main loop reads instantly. A dedicated **sensor thread** updates the cache in the background at whatever rate the sensor supports. - -``` -Main Thread Sensor Thread -─────────────── ───────────────────── -read cachedValue (instant) while running: -set motor power cachedValue = sensor.read() ← slow I2C -update telemetry sleep(20ms) -loop in <1ms -``` - -## Implementing the Pattern - -```java -@TeleOp(name = "Async Distance") -public class AsyncDistance extends LinearOpMode { - - // Shared cache — AtomicReference for thread-safe writes and reads - private final AtomicReference cachedDistance = - new AtomicReference<>(0.0); - - private volatile boolean sensorThreadRunning = true; - - @Override - public void runOpMode() { - DistanceSensor frontSensor = hardwareMap.get(DistanceSensor.class, "frontSensor"); - DcMotor leftMotor = hardwareMap.get(DcMotor.class, "leftMotor"); - DcMotor rightMotor = hardwareMap.get(DcMotor.class, "rightMotor"); - - // --- Start sensor thread --- - Thread sensorThread = new Thread(() -> { - while (sensorThreadRunning) { - double distance = frontSensor.getDistance(DistanceUnit.CM); - cachedDistance.set(distance); - try { - Thread.sleep(20); // Read at ~50Hz - } catch (InterruptedException e) { - break; - } - } - }); - sensorThread.setDaemon(true); - sensorThread.start(); - - waitForStart(); - - // --- Main loop: no I2C blocking --- - while (opModeIsActive()) { - double distance = cachedDistance.get(); // Instant — no I2C wait - - // Slow down if something is close - double speed = (distance < 20.0) ? 0.3 : 1.0; - - leftMotor.setPower(-gamepad1.left_stick_y * speed); - rightMotor.setPower(-gamepad1.right_stick_y * speed); - - telemetry.addData("Distance (cm)", distance); - telemetry.addData("Speed limit", speed); - telemetry.update(); - } - - // --- Clean shutdown --- - sensorThreadRunning = false; - sensorThread.interrupt(); - try { sensorThread.join(500); } catch (InterruptedException ignored) {} - } -} -``` - -## Tuning the Sensor Thread Sleep - -The `Thread.sleep(20)` controls how often the sensor is read (every 20ms = 50Hz). Balance this against how much CPU you want to give the sensor thread: - -| Sleep | Read rate | Notes | -|-------|-----------|-------| -| 5 ms | 200 Hz | More CPU, diminishing returns for slow I2C sensors | -| 20 ms | 50 Hz | Good balance for most sensors | -| 50 ms | 20 Hz | Fine for sensors where freshness is less critical | - -Don't set it to 0 — a busy-spinning sensor thread competes with the main thread for CPU. - -## Multiple Sensors, Multiple Threads - -Each slow sensor can have its own thread, or you can consolidate all slow reads into one thread: - -```java -Thread sensorThread = new Thread(() -> { - while (sensorThreadRunning) { - cachedDistance.set(frontSensor.getDistance(DistanceUnit.CM)); - cachedHeading.set(imu.getRobotYawPitchRollAngles().getYaw(AngleUnit.DEGREES)); - try { Thread.sleep(20); } catch (InterruptedException e) { break; } - } -}); -``` - -Consolidating sensors into one thread avoids thread-scheduling overhead and keeps things simpler to shut down. - -## What Not to Do From Background Threads - -- **Don't write to motors or servos** from a background thread. The FTC SDK's hardware access is not thread-safe for output devices. All motor/servo writes must happen on the main thread. -- **Don't call telemetry.update()** from a background thread. -- **Don't read gamepad values** from a background thread. - -Background threads should be **input only** — read sensors, compute values, cache results. The main thread handles all output. - -## Your Exercise - -You have a `DistanceSensor` named `"frontSensor"` and a `DcMotor` named `"driveMotor"`. - -Your task: - -1. Declare an `AtomicReference cachedDistance` initialized to `0.0` and a `volatile boolean sensorThreadRunning = true`. -2. Get both devices from the hardware map. -3. Create a sensor thread that reads `frontSensor.getDistance(DistanceUnit.CM)`, stores it in `cachedDistance`, sleeps 20ms, and stops when `sensorThreadRunning` is `false`. -4. Set the thread as a daemon, start it, and sleep 50ms to let it read at least once. -5. After `waitForStart()`, run one loop iteration: read `cachedDistance.get()` and set `driveMotor` power to `0.5` if distance > 30cm, or `0.0` if ≤ 30cm. -6. Shut down the thread cleanly. - -The simulated sensor returns **45.0 cm**, so the motor should end up at power **0.5**. diff --git a/content/lessons/15-multithreading/04-async-sensor-reads/exercise.json b/content/lessons/15-multithreading/04-async-sensor-reads/exercise.json deleted file mode 100644 index 0f11e3d..0000000 --- a/content/lessons/15-multithreading/04-async-sensor-reads/exercise.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "title": "Async Sensor Reads", - "testCount": 2, - "hints": [ - { - "title": "Starting the sensor thread before waitForStart()", - "content": "Create and start the sensor thread before calling `waitForStart()`. The thread can begin warming up while the drivers prepare to start the match, so the cache is already populated when the OpMode begins." - }, - { - "title": "Letting the thread read at least once", - "content": "After starting the thread, call `Thread.sleep(50)` to give it time to complete at least one sensor read before your main loop checks the cache." - }, - { - "title": "Motor power logic", - "content": "Read `cachedDistance.get()` once per loop iteration. If it is greater than 30.0, set power to 0.5. Otherwise set power to 0.0. The sensor is simulated at 45.0cm, so the motor should get 0.5." - } - ] -} diff --git a/content/lessons/15-multithreading/05-kotlin-coroutines/Solution.java b/content/lessons/15-multithreading/05-kotlin-coroutines/Solution.java deleted file mode 100644 index 9a71b6b..0000000 --- a/content/lessons/15-multithreading/05-kotlin-coroutines/Solution.java +++ /dev/null @@ -1,38 +0,0 @@ -import com.qualcomm.robotcore.eventloop.opmode.LinearOpMode; -import com.qualcomm.robotcore.eventloop.opmode.TeleOp; -import com.qualcomm.robotcore.hardware.DcMotor; -import com.qualcomm.robotcore.hardware.DistanceSensor; -import org.firstinspires.ftc.robotcore.external.navigation.DistanceUnit; -import java.util.concurrent.atomic.AtomicReference; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -@TeleOp(name = "Executor Pattern") -public class StudentCode extends LinearOpMode { - - AtomicReference cachedDistance = new AtomicReference<>(0.0); - - @Override - public void runOpMode() throws InterruptedException { - DistanceSensor frontSensor = hardwareMap.get(DistanceSensor.class, "frontSensor"); - DcMotor driveMotor = hardwareMap.get(DcMotor.class, "driveMotor"); - - ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); - scheduler.scheduleAtFixedRate( - () -> cachedDistance.set(frontSensor.getDistance(DistanceUnit.CM)), - 0, 20, TimeUnit.MILLISECONDS - ); - - Thread.sleep(50); - - waitForStart(); - - double distance = cachedDistance.get(); - driveMotor.setPower(distance > 30.0 ? 0.5 : 0.0); - telemetry.addData("Distance (cm)", distance); - telemetry.update(); - - scheduler.shutdownNow(); - } -} diff --git a/content/lessons/15-multithreading/05-kotlin-coroutines/Starter.java b/content/lessons/15-multithreading/05-kotlin-coroutines/Starter.java deleted file mode 100644 index 127edf2..0000000 --- a/content/lessons/15-multithreading/05-kotlin-coroutines/Starter.java +++ /dev/null @@ -1,36 +0,0 @@ -import com.qualcomm.robotcore.eventloop.opmode.LinearOpMode; -import com.qualcomm.robotcore.eventloop.opmode.TeleOp; -import com.qualcomm.robotcore.hardware.DcMotor; -import com.qualcomm.robotcore.hardware.DistanceSensor; -import org.firstinspires.ftc.robotcore.external.navigation.DistanceUnit; -import java.util.concurrent.atomic.AtomicReference; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -@TeleOp(name = "Executor Pattern") -public class StudentCode extends LinearOpMode { - - AtomicReference cachedDistance = new AtomicReference<>(0.0); - - @Override - public void runOpMode() throws InterruptedException { - DistanceSensor frontSensor = hardwareMap.get(DistanceSensor.class, "frontSensor"); - DcMotor driveMotor = hardwareMap.get(DcMotor.class, "driveMotor"); - - // TODO: Create a ScheduledExecutorService using Executors.newSingleThreadScheduledExecutor() - - // TODO: Call scheduler.scheduleAtFixedRate() to read frontSensor.getDistance(DistanceUnit.CM) - // into cachedDistance every 20ms, starting immediately (initial delay = 0) - - // TODO: Sleep 50ms so at least one read completes before waitForStart() - - waitForStart(); - - // TODO: Read cachedDistance.get() - // TODO: Set driveMotor power to 0.5 if distance > 30.0, else 0.0 - // TODO: Log distance to telemetry, call telemetry.update() - - // TODO: Call scheduler.shutdownNow() to stop the scheduler - } -} diff --git a/content/lessons/15-multithreading/05-kotlin-coroutines/Test.java b/content/lessons/15-multithreading/05-kotlin-coroutines/Test.java deleted file mode 100644 index 97427d7..0000000 --- a/content/lessons/15-multithreading/05-kotlin-coroutines/Test.java +++ /dev/null @@ -1,34 +0,0 @@ -import com.qualcomm.robotcore.hardware.*; -import org.firstinspires.ftc.robotcore.external.TelemetryImpl; - -public class Test { - public static void main(String[] args) throws Exception { - HardwareMap hwMap = new HardwareMap(); - - DistanceSensorImpl frontSensor = new DistanceSensorImpl(); - frontSensor.setDistanceCm(45.0); // 45cm > 30cm threshold - - DcMotorImpl driveMotor = new DcMotorImpl(); - hwMap.registerDevice("frontSensor", frontSensor); - hwMap.registerDevice("driveMotor", driveMotor); - - StudentCode op = new StudentCode(); - op.hardwareMap = hwMap; - op.telemetry = new TelemetryImpl(); - op.gamepad1 = new Gamepad(); - op.gamepad2 = new Gamepad(); - op.setStarted(true); - op.setMaxActiveLoops(1); - - try { op.runOpMode(); } catch (Exception ignored) {} - - TestBase.assertTrue("Scheduler read frontSensor at least once", - !frontSensor.getCallLog().isEmpty(), - "frontSensor.getDistance() was never called by the scheduled task"); - - TestBase.assertNear("driveMotor power is 0.5 (distance 45cm > 30cm threshold)", - driveMotor.getPower(), 0.5, 0.01); - - TestBase.printResults(); - } -} diff --git a/content/lessons/15-multithreading/05-kotlin-coroutines/content.mdx b/content/lessons/15-multithreading/05-kotlin-coroutines/content.mdx deleted file mode 100644 index e004eac..0000000 --- a/content/lessons/15-multithreading/05-kotlin-coroutines/content.mdx +++ /dev/null @@ -1,158 +0,0 @@ ---- -title: "Multithreading in Kotlin" -description: "Use Kotlin coroutines to write clean, easy-to-read async code — and understand how it maps to Java threads under the hood." ---- - -# Multithreading in Kotlin - -Java threads work, but the boilerplate — creating `Thread` objects, managing stop flags, calling `join()` — adds a lot of ceremony. Kotlin provides **coroutines**: a lightweight concurrency framework that makes async code look almost like normal sequential code. If your team is writing OpModes in Kotlin, coroutines are the cleanest way to handle background work. - -## What Is a Coroutine? - -A coroutine is a block of code that can be **suspended** (paused) and **resumed** without blocking a thread. Unlike threads, many coroutines can share a single thread, and the Kotlin runtime handles switching between them efficiently. - -Think of a coroutine as a very cheap thread. Creating a Java thread takes ~1ms and uses significant memory. Creating a coroutine takes microseconds and uses kilobytes. - -## Adding the Dependency - -Add the coroutines library to your `build.gradle`: - -```groovy -dependencies { - implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' -} -``` - -## `launch`: Fire and Forget - -`launch` starts a coroutine that runs concurrently and doesn't return a value: - -```kotlin -import kotlinx.coroutines.* - -val scope = CoroutineScope(Dispatchers.Default) - -val job = scope.launch { - while (isActive) { - cachedDistance = frontSensor.getDistance(DistanceUnit.CM) - delay(20) // Non-blocking — doesn't block the thread - } -} - -// Later, stop the coroutine: -job.cancel() -job.join() -``` - -`delay(20)` is the coroutine equivalent of `Thread.sleep(20)`, but it **does not block the underlying thread**. The thread is released to do other work while the delay is pending. - -## `async`/`await`: Getting a Result Back - -`async` starts a coroutine that returns a value via a `Deferred`: - -```kotlin -val result: Deferred = scope.async { - computeExpensivePath() // Runs in background -} - -// Later, get the result (suspends until ready): -val path = result.await() -``` - -## Dispatchers: Which Thread Pool? - -Coroutines run on **dispatchers** that control which thread pool is used: - -| Dispatcher | Use case | -|-----------|---------| -| `Dispatchers.Default` | CPU-heavy work (path planning, kinematics) | -| `Dispatchers.IO` | Sensor reads, file I/O | -| `Dispatchers.Main` | Android UI (rarely used in FTC) | - -For FTC sensor reads, use `Dispatchers.IO`: - -```kotlin -scope.launch(Dispatchers.IO) { - while (isActive) { - cachedDistance = frontSensor.getDistance(DistanceUnit.CM) - delay(20) - } -} -``` - -## A Full Kotlin OpMode with Coroutines - -```kotlin -@TeleOp(name = "Coroutine Drive") -class CoroutineDrive : LinearOpMode() { - - @Volatile var cachedDistance: Double = 0.0 - private val scope = CoroutineScope(Dispatchers.IO) - - override fun runOpMode() { - val frontSensor = hardwareMap.get(DistanceSensor::class.java, "frontSensor") - val leftMotor = hardwareMap.get(DcMotor::class.java, "leftMotor") - val rightMotor = hardwareMap.get(DcMotor::class.java, "rightMotor") - - // Start sensor coroutine - val sensorJob = scope.launch { - while (isActive) { - cachedDistance = frontSensor.getDistance(DistanceUnit.CM) - delay(20) - } - } - - waitForStart() - - while (opModeIsActive()) { - val speed = if (cachedDistance < 20.0) 0.3 else 1.0 - leftMotor.setPower(-gamepad1.left_stick_y * speed) - rightMotor.setPower(-gamepad1.right_stick_y * speed) - - telemetry.addData("Distance", cachedDistance) - telemetry.update() - } - - // Clean shutdown - sensorJob.cancel() - runBlocking { sensorJob.join() } - scope.cancel() - } -} -``` - -Compare this to the Java version from the previous lesson. There is no `Thread` object, no `volatile boolean threadRunning`, no `interrupt()` — `isActive` and `cancel()` handle it cleanly. - -## How Coroutines Map to Java Threads - -Under the hood, `Dispatchers.IO` maintains a thread pool (capped at 64 threads by default). `launch` submits a task to that pool — conceptually the same as `ExecutorService.submit()` in Java. The key difference is `delay()`, which is a **suspend function**: it yields the thread back to the pool while waiting, rather than blocking it. - -```java -// Java equivalent of scope.launch { delay(20); cachedDistance = ... } -ExecutorService executor = Executors.newCachedThreadPool(); -ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); - -scheduler.scheduleAtFixedRate(() -> { - cachedDistance = frontSensor.getDistance(DistanceUnit.CM); -}, 0, 20, TimeUnit.MILLISECONDS); -``` - -Kotlin coroutines are the ergonomic, safe wrapper around this kind of Java concurrency code. - -## Your Exercise - -The exercise for this lesson is in Java to keep it runnable in the sandbox. You will implement the Java `ExecutorService` equivalent of a coroutine-based sensor read. - -You have a `DistanceSensor` named `"frontSensor"` and a `DcMotor` named `"driveMotor"`. - -Your task: - -1. Declare an `AtomicReference cachedDistance` initialized to `0.0`. -2. Get both devices from the hardware map. -3. Create a `ScheduledExecutorService` using `Executors.newSingleThreadScheduledExecutor()`. -4. Call `scheduler.scheduleAtFixedRate()` to read `frontSensor.getDistance(DistanceUnit.CM)` and store it in `cachedDistance` every **20 milliseconds**, starting immediately (initial delay = 0). -5. Sleep 50ms to let the first read complete. -6. After `waitForStart()`, run one loop iteration: if `cachedDistance.get()` is greater than **30.0**, set `driveMotor` power to `0.5`; otherwise `0.0`. -7. Shut down the scheduler with `scheduler.shutdownNow()`. - -The simulated sensor returns **45.0 cm**, so the motor should end up at power **0.5**. diff --git a/content/lessons/15-multithreading/05-kotlin-coroutines/exercise.json b/content/lessons/15-multithreading/05-kotlin-coroutines/exercise.json deleted file mode 100644 index 47ec767..0000000 --- a/content/lessons/15-multithreading/05-kotlin-coroutines/exercise.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "title": "Multithreading in Kotlin", - "testCount": 2, - "hints": [ - { - "title": "Creating a ScheduledExecutorService", - "content": "Use `ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();` — this is the Java equivalent of a coroutine dispatcher with a single background thread." - }, - { - "title": "Scheduling a repeating task", - "content": "Call `scheduler.scheduleAtFixedRate(() -> { cachedDistance.set(frontSensor.getDistance(DistanceUnit.CM)); }, 0, 20, TimeUnit.MILLISECONDS);` — the 0 means start immediately, 20ms between each run." - }, - { - "title": "Letting the first read complete", - "content": "After calling scheduleAtFixedRate, sleep for 50ms with `Thread.sleep(50)` so the scheduled task has time to run at least once before your main code reads the cache." - } - ] -} diff --git a/content/lessons/15-multithreading/_module.json b/content/lessons/15-multithreading/_module.json deleted file mode 100644 index 0cc0b1b..0000000 --- a/content/lessons/15-multithreading/_module.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "title": "Multithreading", - "order": 15, - "description": "Improve robot loop times and responsiveness with multithreading: Java threads, thread safety, async sensor reads, and Kotlin coroutines." -} \ No newline at end of file diff --git a/content/lessons/_sections.json b/content/lessons/_sections.json new file mode 100644 index 0000000..2d48e82 --- /dev/null +++ b/content/lessons/_sections.json @@ -0,0 +1,8 @@ +{ + "sections": [ + { "title": "The Basics", "modules": ["01-getting-started", "02-teleop-control", "03-hardware-essentials"] }, + { "title": "Sensors & Feedback", "modules": ["04-sensors", "05-driving-with-the-imu", "06-encoders"] }, + { "title": "Autonomy & Control", "modules": ["07-autonomous", "08-state-machines", "09-control-theory"] }, + { "title": "Advanced Systems", "modules": ["10-advanced-motor-control", "11-code-architecture", "12-advanced-control", "13-camera-vision", "14-odometry-and-localization"] } + ] +} diff --git a/lib/lessons.ts b/lib/lessons.ts index 888b5d6..4589680 100644 --- a/lib/lessons.ts +++ b/lib/lessons.ts @@ -13,6 +13,7 @@ import type { LessonData, MultiStageModuleData, SidebarModule, + ModuleSection, Stage, } from "./types" @@ -51,6 +52,47 @@ export const getModules = unstable_cache( { revalidate: false } ) +export const getModuleSections = unstable_cache( + async (): Promise => { + const modules = await getModules() + const modulesBySlug = new Map(modules.map((m) => [m.meta.slug, m])) + + type SectionsConfig = { sections: { title: string; modules: string[] }[] } + let config: SectionsConfig | null = null + try { + const raw = await readFile(join(CONTENT_DIR, "_sections.json"), "utf-8") + config = JSON.parse(raw) as SectionsConfig + } catch { + config = null + } + + const claimed = new Set() + const sections: ModuleSection[] = [] + + for (const entry of config?.sections ?? []) { + const resolved: SidebarModule[] = [] + for (const slug of entry.modules) { + const mod = modulesBySlug.get(slug) + if (!mod) continue + claimed.add(slug) + resolved.push(mod) + } + if (resolved.length > 0) { + sections.push({ title: entry.title, modules: resolved }) + } + } + + const leftover = modules.filter((m) => !claimed.has(m.meta.slug)) + if (leftover.length > 0) { + sections.push({ title: "Other", modules: leftover }) + } + + return sections + }, + ["module-sections"], + { revalidate: false } +) + async function loadModuleMeta( modulePath: string, dirName: string diff --git a/lib/types.ts b/lib/types.ts index e3ba28e..e0313c3 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -98,6 +98,11 @@ export interface SidebarModule { stages: StageMeta[] } +export interface ModuleSection { + title: string + modules: SidebarModule[] +} + export interface MultiStageProgressState { __v: 2 currentStage: number diff --git a/package.json b/package.json index 1cb91fa..2d184f2 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,6 @@ "overrides": { "dompurify": "^3.4.0", "@hono/node-server": "^1.19.14", - "brace-expansion": "^2.0.2", "defu": "^6.1.7", "flatted": "^3.4.2", "picomatch": "^4.0.4"