diff --git a/docs/task-breakdown.md b/docs/task-breakdown.md index d510144..77f1c84 100644 --- a/docs/task-breakdown.md +++ b/docs/task-breakdown.md @@ -1,6 +1,6 @@ # Software Engineering Workshop:Milestone/Task Breakdown -> 狀態:Active backlog +> 狀態:Complete > 日期:2026-08-23 > 依據:[project-plan.md](./project-plan.md)、[project-sa.md](./project-sa.md)、[project-sd.md](./project-sd.md) @@ -33,7 +33,7 @@ Task 不等於檔案。若一個 task 同時需要修改多個檔案,但輸出 | ID | Milestone | 結果 | 依賴 | 狀態 | | --- | --- | --- | --- | --- | | M0 | Project Contract | Project Plan、SA、SD、task breakdown 可供開發對齊 | 無 | done | -| M1 | Module Foundation | 新 topic 可依共同契約接入 route、progress、Lab、tests | M0 | done(P2 架構 follow-up) | +| M1 | Module Foundation | 新 topic 可依共同契約接入 route、progress、Lab、tests | M0 | done | | M2 | Foundations/Web | 開發基本功與 Web 主題逐站開放 | M1 | done | | M3 | Data | SQL、schema、index/transaction、PostgreSQL 開放 | M1 | done | | M4 | Quality | unit、integration、logs 開放 | M1 | done | @@ -67,9 +67,9 @@ M0 完成條件:專案層級目標、需求、技術邊界、Milestone、task | CORE-005 | 建立共用 Lesson/Lab shell | topic header、status feedback、reset、completion treatment | CORE-001 | done | | CORE-006 | 建立 simulator test harness | 共用測試 fixture、reset、completion assertion pattern | CORE-001 | done | | CORE-007 | 建立共用可及性檢查清單 | keyboard、live region、mobile、reduced motion 驗收點 | M0 | done(2026-08-23;M6 release audit completed) | -| CORE-008 | App registry-driven dispatcher | 移除 App 中 topic-specific render 分支,讓 route registry/module contract 成為唯一 dispatch 入口 | CORE-001、CORE-003、CORE-005 | backlog | +| CORE-008 | App registry-driven dispatcher | 移除 App 中 topic-specific render 分支,讓 route registry/module contract 成為唯一 dispatch 入口 | CORE-001、CORE-003、CORE-005 | done(2026-08-23) | -M1 的 foundation contract、progress、route resolver、test harness 與 checklist 已完成。原始的「App 不新增 topic-specific 分支」目標仍是 P2 架構 follow-up,追蹤於 `CORE-008`;目前不阻塞既有 ready topics 的 pageflow。 +M1 的 foundation contract、progress、route resolver、test harness、checklist 與 registry-driven dispatcher 已完成。App 的 topic lesson、Lab 與 Extension 導航現在由 route/module registry 與 curriculum contract 產生;新增 ready topic 不需再增加 App 的 topic-specific JSX 分支。 M1 的可平行工作線: @@ -248,4 +248,4 @@ M6 的 task 依賴分成兩層:`RELEASE-001`、`RELEASE-002`、`RELEASE-003` ## 15. 目前下一個可開工 task -目前 19 / 19 Core topic 已 ready,M2、M3、M4、M5、M6 已完成;release PR #90 與 final documentation closeout PR #92 已合併至 `main`,GitHub Pages publish workflow #16/#17 與 live site smoke check 均通過。`CORE-008` 仍是非阻塞 architecture follow-up,沒有阻擋本 release 的未完成 Core task。 +目前 19 / 19 Core topic 已 ready,M1、M2、M3、M4、M5、M6 與 `CORE-008` 已完成;release PR #90 與 final documentation closeout PR #92 已合併至 `main`,GitHub Pages publish workflow #16/#17 與 live site smoke check 均通過。本專案目前沒有 canonical backlog task。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3e3163c..ad9a967 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,9 +1,9 @@ -import { useEffect, useMemo, useState } from "react"; +import { Fragment, useEffect, useMemo, useState } from "react"; import { curriculum } from "./curriculum"; import { aggregateProgress, completedReadyTopicIds } from "./progress/aggregation"; import { createLocalStorageProgressRepository } from "./progress/repository"; import { parseRoute, resolveRoute, topicPath, trackPath, type RouteDefinition } from "./routes/registry"; -import { TOPIC_MODULE_IDS } from "./topics/registry"; +import { getTopicNavigationEntries, TOPIC_MODULE_IDS } from "./topics/registry"; import { CurriculumMap } from "./components/CurriculumMap"; import { TrackPage } from "./components/TrackPage"; import { TopicRouteView } from "./components/TopicRouteView"; @@ -15,6 +15,10 @@ function routeLabel(route: RouteDefinition): string { return route.kind === "lab" ? `${topic} LAB` : topic; } +const topicNavigationEntries = getTopicNavigationEntries(curriculum); +const corePracticeTopics = topicNavigationEntries.filter((topic) => topic.trackKind === "core"); +const extensionTopics = topicNavigationEntries.filter((topic) => topic.trackKind === "extension"); + export default function App() { const [route, setRoute] = useState(() => resolveRoute(window.location.hash, curriculum, TOPIC_MODULE_IDS)); const [menuOpen, setMenuOpen] = useState(false); @@ -83,30 +87,24 @@ export default function App() {
實作 / PRACTICE
EXTENSION / AI
總進度{progress.coreProgress.completed} / {progress.coreProgress.total}
diff --git a/frontend/src/topics/registry.test.ts b/frontend/src/topics/registry.test.ts index d845738..afa3f4c 100644 --- a/frontend/src/topics/registry.test.ts +++ b/frontend/src/topics/registry.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { curriculum } from "../curriculum"; -import { getTopicViewModule, TOPIC_MODULE_IDS } from "./registry"; +import { getTopicNavigationEntries, getTopicViewModule, TOPIC_MODULE_IDS } from "./registry"; describe("topic view registry", () => { it("registers exactly every ready curriculum topic", () => { @@ -32,4 +32,40 @@ describe("topic view registry", () => { }); }); }); + + it("builds practice navigation from ready topics and their track kind", () => { + const readyTopicIds = curriculum.tracks.flatMap((track) => + track.topics.filter((topic) => topic.status === "ready").map((topic) => topic.id), + ); + const navigationEntries = getTopicNavigationEntries(curriculum); + + expect(navigationEntries.map((entry) => entry.topicId)).toEqual(readyTopicIds); + expect(navigationEntries.filter((entry) => entry.trackKind === "core")).toHaveLength(19); + expect(navigationEntries.filter((entry) => entry.trackKind === "extension").map((entry) => entry.topicId)).toEqual([ + "guardrail", + "problem-solving", + ]); + expect(navigationEntries.find((entry) => entry.topicId === "rest")?.navigationLabel).toBe("FastAPI"); + expect(navigationEntries.find((entry) => entry.topicId === "guardrail")?.labNavigationLabel).toBe("Guardrail"); + }); + + it("omits planned or unregistered topics from navigation", () => { + const navigationEntries = getTopicNavigationEntries({ + version: 1, + tracks: [ + { + id: "future", + title: "Future", + description: "Future topics", + kind: "core", + topics: [ + { id: "planned-topic", title: "Planned", summary: "Not ready", status: "planned" }, + { id: "unregistered-topic", title: "Unregistered", summary: "No module", status: "ready" }, + ], + }, + ], + }); + + expect(navigationEntries).toEqual([]); + }); }); diff --git a/frontend/src/topics/registry.tsx b/frontend/src/topics/registry.tsx index 399a23c..3507126 100644 --- a/frontend/src/topics/registry.tsx +++ b/frontend/src/topics/registry.tsx @@ -1,4 +1,5 @@ import type { ComponentType } from "react"; +import type { Curriculum } from "../types"; import { AuthLab } from "../components/AuthLab"; import { AuthLesson } from "../components/AuthLesson"; import { GitLab } from "../components/GitLab"; @@ -76,33 +77,42 @@ export interface TopicLabViewProps { export interface TopicViewModule { id: string; + navigationLabel: string; + labNavigationLabel?: string; orientation: LessonOrientation; lesson: ComponentType; lab: ComponentType; } +export interface TopicNavigationEntry { + topicId: string; + trackKind: "core" | "extension"; + navigationLabel: string; + labNavigationLabel: string; +} + export const TOPIC_MODULE_REGISTRY: Readonly> = { - git: { id: "git", orientation: gitOrientation, lesson: GitLesson, lab: GitLab }, - auth: { id: "auth", orientation: authOrientation, lesson: AuthLesson, lab: AuthLab }, - remote: { id: "remote", orientation: remoteLesson.orientation, lesson: RemoteLesson, lab: RemoteLab }, - cli: { id: "cli", orientation: cliLesson.orientation, lesson: CliLesson, lab: CliLab }, - ide: { id: "ide", orientation: ideLesson.orientation, lesson: IdeLesson, lab: IdeLab }, - package: { id: "package", orientation: packageLesson.orientation, lesson: PackageLesson, lab: PackageLab }, - guardrail: { id: "guardrail", orientation: guardrailLesson.orientation, lesson: GuardrailLesson, lab: GuardrailLab }, - rest: { id: "rest", orientation: restLesson.orientation, lesson: RestLesson, lab: RestLab }, - env: { id: "env", orientation: envLesson.orientation, lesson: EnvLesson, lab: EnvLab }, - build: { id: "build", orientation: buildLesson.orientation, lesson: BuildLesson, lab: BuildLab }, - docker: { id: "docker", orientation: dockerLesson.orientation, lesson: DockerLesson, lab: DockerLab }, - cicd: { id: "cicd", orientation: cicdLesson.orientation, lesson: CicdLesson, lab: CicdLab }, - deploy: { id: "deploy", orientation: deployLesson.orientation, lesson: DeployLesson, lab: DeployLab }, - sql: { id: "sql", orientation: sqlLesson.orientation, lesson: SqlLesson, lab: SqlLab }, - schema: { id: "schema", orientation: schemaLesson.orientation, lesson: SchemaLesson, lab: SchemaLab }, - index: { id: "index", orientation: indexLesson.orientation, lesson: IndexLesson, lab: IndexLab }, - postgresql: { id: "postgresql", orientation: postgresqlLesson.orientation, lesson: PostgreSqlLesson, lab: PostgreSqlLab }, - "problem-solving": { id: "problem-solving", orientation: problemSolvingLesson.orientation, lesson: ProblemSolvingLesson, lab: ProblemSolvingLab }, - unit: { id: "unit", orientation: unitLesson.orientation, lesson: UnitLesson, lab: UnitLab }, - integration: { id: "integration", orientation: integrationLesson.orientation, lesson: IntegrationLesson, lab: IntegrationLab }, - logs: { id: "logs", orientation: logsLesson.orientation, lesson: LogsLesson, lab: LogsLab }, + git: { id: "git", navigationLabel: "Git", orientation: gitOrientation, lesson: GitLesson, lab: GitLab }, + auth: { id: "auth", navigationLabel: "Auth", orientation: authOrientation, lesson: AuthLesson, lab: AuthLab }, + remote: { id: "remote", navigationLabel: "Remote", orientation: remoteLesson.orientation, lesson: RemoteLesson, lab: RemoteLab }, + cli: { id: "cli", navigationLabel: "CLI", orientation: cliLesson.orientation, lesson: CliLesson, lab: CliLab }, + ide: { id: "ide", navigationLabel: "IDE", orientation: ideLesson.orientation, lesson: IdeLesson, lab: IdeLab }, + package: { id: "package", navigationLabel: "Package", orientation: packageLesson.orientation, lesson: PackageLesson, lab: PackageLab }, + guardrail: { id: "guardrail", navigationLabel: "Guardrails", labNavigationLabel: "Guardrail", orientation: guardrailLesson.orientation, lesson: GuardrailLesson, lab: GuardrailLab }, + rest: { id: "rest", navigationLabel: "FastAPI", orientation: restLesson.orientation, lesson: RestLesson, lab: RestLab }, + env: { id: "env", navigationLabel: "ENV", orientation: envLesson.orientation, lesson: EnvLesson, lab: EnvLab }, + build: { id: "build", navigationLabel: "BUILD", orientation: buildLesson.orientation, lesson: BuildLesson, lab: BuildLab }, + docker: { id: "docker", navigationLabel: "DOCKER", orientation: dockerLesson.orientation, lesson: DockerLesson, lab: DockerLab }, + cicd: { id: "cicd", navigationLabel: "CI/CD", orientation: cicdLesson.orientation, lesson: CicdLesson, lab: CicdLab }, + deploy: { id: "deploy", navigationLabel: "DEPLOY", orientation: deployLesson.orientation, lesson: DeployLesson, lab: DeployLab }, + sql: { id: "sql", navigationLabel: "SQL", orientation: sqlLesson.orientation, lesson: SqlLesson, lab: SqlLab }, + schema: { id: "schema", navigationLabel: "Schema", orientation: schemaLesson.orientation, lesson: SchemaLesson, lab: SchemaLab }, + index: { id: "index", navigationLabel: "Index", orientation: indexLesson.orientation, lesson: IndexLesson, lab: IndexLab }, + postgresql: { id: "postgresql", navigationLabel: "PostgreSQL", orientation: postgresqlLesson.orientation, lesson: PostgreSqlLesson, lab: PostgreSqlLab }, + "problem-solving": { id: "problem-solving", navigationLabel: "Problem-solving", orientation: problemSolvingLesson.orientation, lesson: ProblemSolvingLesson, lab: ProblemSolvingLab }, + unit: { id: "unit", navigationLabel: "Unit Testing", orientation: unitLesson.orientation, lesson: UnitLesson, lab: UnitLab }, + integration: { id: "integration", navigationLabel: "Integration", orientation: integrationLesson.orientation, lesson: IntegrationLesson, lab: IntegrationLab }, + logs: { id: "logs", navigationLabel: "Logs", orientation: logsLesson.orientation, lesson: LogsLesson, lab: LogsLab }, }; export const TOPIC_MODULE_IDS: ReadonlySet = new Set(Object.keys(TOPIC_MODULE_REGISTRY)); @@ -110,3 +120,22 @@ export const TOPIC_MODULE_IDS: ReadonlySet = new Set(Object.keys(TOPIC_M export function getTopicViewModule(topicId: string | undefined): TopicViewModule | undefined { return topicId ? TOPIC_MODULE_REGISTRY[topicId] : undefined; } + +export function getTopicNavigationEntries(curriculum: Curriculum): readonly TopicNavigationEntry[] { + return curriculum.tracks.flatMap((track) => + track.topics + .filter((topic) => topic.status === "ready") + .map((topic) => { + const topicModule = getTopicViewModule(topic.id); + return topicModule + ? { + topicId: topic.id, + trackKind: track.kind, + navigationLabel: topicModule.navigationLabel, + labNavigationLabel: topicModule.labNavigationLabel ?? topicModule.navigationLabel, + } + : undefined; + }) + .filter((entry): entry is TopicNavigationEntry => entry !== undefined), + ); +}