Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions docs/task-breakdown.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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、checklistregistry-driven dispatcher 已完成。App topic lesson、Lab 與 Extension 導航現在由 route/module registry 與 curriculum contract 產生;新增 ready topic 不需再增加 App 的 topic-specific JSX 分支

M1 的可平行工作線:

Expand Down Expand Up @@ -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。
44 changes: 21 additions & 23 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<RouteDefinition>(() => resolveRoute(window.location.hash, curriculum, TOPIC_MODULE_IDS));
const [menuOpen, setMenuOpen] = useState(false);
Expand Down Expand Up @@ -83,30 +87,24 @@ export default function App() {
</nav>
<div className="nav-label">實作 / PRACTICE</div>
<nav>
<button className={route.path === "/lab" ? "active" : ""} type="button" onClick={() => goTopic("git", "lab")}><span>↳</span>Git Lab</button>
<button className={route.path === "/auth-lab" ? "active" : ""} type="button" onClick={() => goTopic("auth", "lab")}><span>↳</span>Auth Lab</button>
<button className={route.path === "/cli-lab" ? "active" : ""} type="button" onClick={() => goTopic("cli", "lab")}><span>↳</span>CLI Lab</button>
<button className={route.path === "/ide-lab" ? "active" : ""} type="button" onClick={() => goTopic("ide", "lab")}><span>↳</span>IDE Lab</button>
<button className={route.path === "/package-lab" ? "active" : ""} type="button" onClick={() => goTopic("package", "lab")}><span>↳</span>Package Lab</button>
<button className={route.path === "/env-lab" ? "active" : ""} type="button" onClick={() => goTopic("env", "lab")}><span>↳</span>ENV Lab</button>
<button className={route.path === "/build-lab" ? "active" : ""} type="button" onClick={() => goTopic("build", "lab")}><span>↳</span>BUILD Lab</button>
<button className={route.path === "/docker-lab" ? "active" : ""} type="button" onClick={() => goTopic("docker", "lab")}><span>↳</span>DOCKER Lab</button>
<button className={route.path === "/cicd-lab" ? "active" : ""} type="button" onClick={() => goTopic("cicd", "lab")}><span>↳</span>CI/CD Lab</button>
<button className={route.path === "/deploy-lab" ? "active" : ""} type="button" onClick={() => goTopic("deploy", "lab")}><span>↳</span>DEPLOY Lab</button>
<button className={route.path === "/remote-lab" ? "active" : ""} type="button" onClick={() => goTopic("remote", "lab")}><span>↳</span>Remote Lab</button>
<button className={route.path === "/rest-lab" ? "active" : ""} type="button" onClick={() => goTopic("rest", "lab")}><span>↳</span>FastAPI Lab</button>
<button className={route.path === "/sql-lab" ? "active" : ""} type="button" onClick={() => goTopic("sql", "lab")}><span>↳</span>SQL Lab</button>
<button className={route.path === "/schema-lab" ? "active" : ""} type="button" onClick={() => goTopic("schema", "lab")}><span>↳</span>Schema Lab</button>
<button className={route.path === "/index-lab" ? "active" : ""} type="button" onClick={() => goTopic("index", "lab")}><span>↳</span>Index Lab</button>
<button className={route.path === "/postgresql-lab" ? "active" : ""} type="button" onClick={() => goTopic("postgresql", "lab")}><span>↳</span>PostgreSQL Lab</button>
<button className={route.path === "/unit-lab" ? "active" : ""} type="button" onClick={() => goTopic("unit", "lab")}><span>↳</span>Unit Testing Lab</button>
<button className={route.path === "/integration-lab" ? "active" : ""} type="button" onClick={() => goTopic("integration", "lab")}><span>↳</span>Integration Lab</button>
<button className={route.path === "/logs-lab" ? "active" : ""} type="button" onClick={() => goTopic("logs", "lab")}><span>↳</span>Logs Lab</button>
{corePracticeTopics.map(({ topicId, labNavigationLabel }) => (
<button className={route.path === topicPath(topicId, "lab") ? "active" : ""} type="button" key={topicId} onClick={() => goTopic(topicId, "lab")}>
<span>↳</span>{labNavigationLabel} Lab
</button>
))}
</nav>
<div className="nav-label">EXTENSION / AI</div>
<nav>
<button className={route.path === "/guardrail" ? "active" : ""} type="button" onClick={() => goTopic("guardrail", "lesson")}><span>EX</span>Guardrails {progressRepository.read("guardrail") ? <i>✓</i> : null}</button>
<button className={route.path === "/guardrail-lab" ? "active" : ""} type="button" onClick={() => goTopic("guardrail", "lab")}><span>↳</span>Guardrail Lab</button>
{extensionTopics.map(({ topicId, navigationLabel, labNavigationLabel }) => (
<Fragment key={topicId}>
<button className={route.path === topicPath(topicId, "lesson") ? "active" : ""} type="button" onClick={() => goTopic(topicId, "lesson")}>
<span>EX</span>{navigationLabel} {progressRepository.read(topicId) ? <i>✓</i> : null}
</button>
<button className={route.path === topicPath(topicId, "lab") ? "active" : ""} type="button" onClick={() => goTopic(topicId, "lab")}>
<span>↳</span>{labNavigationLabel} Lab
</button>
</Fragment>
))}
</nav>
<div className="sidebar-progress">
<div><span>總進度</span><b>{progress.coreProgress.completed} / {progress.coreProgress.total}</b></div>
Expand Down
38 changes: 37 additions & 1 deletion frontend/src/topics/registry.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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([]);
});
});
71 changes: 50 additions & 21 deletions frontend/src/topics/registry.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -76,37 +77,65 @@ export interface TopicLabViewProps {

export interface TopicViewModule {
id: string;
navigationLabel: string;
labNavigationLabel?: string;
orientation: LessonOrientation;
lesson: ComponentType<TopicLessonViewProps>;
lab: ComponentType<TopicLabViewProps>;
}

export interface TopicNavigationEntry {
topicId: string;
trackKind: "core" | "extension";
navigationLabel: string;
labNavigationLabel: string;
}

export const TOPIC_MODULE_REGISTRY: Readonly<Record<string, TopicViewModule>> = {
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<string> = new Set(Object.keys(TOPIC_MODULE_REGISTRY));

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),
);
}
Loading