iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
AI Engineering

30天用 Claude Code + LangGraph 實作個人化 AI 學習教練系列 第 25 篇

Day 25:核心指標儀表板 - 學習進度可視化

  • 分享至 

  • xImage
  •  

前 24 天已完成排計畫、記錄進度、測驗與對話功能,但資料分散在不同頁面與 API。今天會建立儀表板,將重要數字與圖表集中在同一頁。

後端新增一支 GET /dashboard,把資料庫裡的任務、進度記錄和測驗成績整理成幾個指標。前端新增 /dashboard 頁面,用 Recharts 畫圖,並且每 30 秒自動更新。

概念:儀表板就是汽車的儀表板

儀表板會把資料庫的原始記錄整理成容易閱讀的指標與圖表。

任務、進度記錄、測驗成績(資料庫)
        ↓ 後端整理
   GET /dashboard(五個指標)
        ↓ 前端每 30 秒抓一次
   指標卡 + 進度條 + 兩張圖

今天只做五個指標,每一個都先想清楚「怎麼算」:

指標 怎麼算 畫成什麼
任務完成率 已到期的任務中,完成了幾成 指標卡
本週學習時數 最近 7 天進度記錄的實際時數加總 指標卡 + 長條圖
連續學習天數 從今天往回數,連續有進度記錄的天數 指標卡
能力評分 每個主題最近一次測驗的分數(Day 21 的 /skills) 橫向長條圖
朝向目標的進度 整份計畫完成的任務數 / 全部任務數 進度條

「完成率」和「進度條」衡量的範圍不同。完成率只計算已到期任務,反映該做的任務是否完成;進度條計算整份計畫,反映距離目標的進度。第一天完成當日任務時,完成率可能為 100%,進度條仍可能只有 4%。

連續天數有一個貼心的規則:今天還沒做不算中斷,會先從昨天開始數。要昨天也沒做,連續天數才會歸零。

圖表只用一種顏色。這兩張圖都只有一組資料,不需要靠顏色區分,單色更乾淨。每張圖底下也附了「查看表格」,看不清楚顏色的人,或是想看精確數字的人,都能直接讀表。


實作步驟

步驟1:定義回傳格式

檔案位置: backend/schemas.py
狀態: 修改檔案(接在檔案最後面,繼續往下加)
用途: 定義 /dashboard 回傳的所有指標
依賴: pydantic

class DailyHours(BaseModel):
    """單一天的學習時數"""
    date: str
    hours: float


class DashboardResponse(BaseModel):
    """GET /dashboard 回傳的儀表板指標"""
    completion_rate: float = Field(description="到期任務中已完成的百分比")
    goal_progress: float = Field(description="整份計畫已完成的百分比")
    completed_tasks: int
    total_tasks: int
    weekly_hours: float = Field(description="最近7天累計學習時數")
    daily_hours: list[DailyHours]
    streak_days: int = Field(description="連續有進度記錄的天數")
    skills: list[SkillStatus]

SkillStatus 是 Day 21 就定義好的,skills 這一欄直接沿用,不用重寫。

步驟2:寫 GET /dashboard

檔案位置: backend/main.py
狀態: 修改檔案(加在 Day 21 的 get_skills 後面)
用途: 從資料庫算出五個指標,一次回傳給前端
依賴: schemas, models

先在檔案最上面的 import 區,加上這一行:

from schemas import DailyHours, DashboardResponse

再把下面這段貼在 get_skills 函式的後面:

def _percent(part: int, whole: int) -> float:
    """算百分比,分母是0時回傳0,避免除以零"""
    return round(part / whole * 100, 1) if whole else 0.0


def _count_streak(active_days: set, today) -> int:
    """從今天往回數連續有進度記錄的天數,今天還沒做不算中斷,改從昨天開始數"""
    cursor = today if today in active_days else today - timedelta(days=1)
    streak = 0
    while cursor in active_days:
        streak += 1
        cursor -= timedelta(days=1)
    return streak


@app.get("/dashboard", response_model=DashboardResponse)
def get_dashboard(user_id: int, db: Session = Depends(get_db)) -> DashboardResponse:
    """整理儀表板要顯示的核心指標"""
    approved_plans = (
        db.query(Plan).filter(Plan.user_id == user_id, Plan.status == "已核准").all()
    )
    if not approved_plans:
        raise HTTPException(status_code=404, detail="這個使用者還沒有已核准的計畫")

    today = datetime.now().date()
    tasks = (
        db.query(Task).filter(Task.plan_id.in_([plan.id for plan in approved_plans])).all()
    )

    # 完成率只看已經到期的任務,進度條看整份計畫
    due_tasks = [t for t in tasks if t.deadline is not None and t.deadline.date() <= today]
    due_completed = [t for t in due_tasks if t.status == "完成"]
    completed = [t for t in tasks if t.status == "完成"]

    logs = (
        db.query(ProgressLog)
        .join(Task, ProgressLog.task_id == Task.id)
        .join(Plan, Task.plan_id == Plan.id)
        .filter(Plan.user_id == user_id, ProgressLog.completed_at.isnot(None))
        .all()
    )

    # 每天累計的實際學習時數,連續天數也從這份資料算
    hours_by_day: dict = {}
    for log in logs:
        day = log.completed_at.date()
        hours_by_day[day] = hours_by_day.get(day, 0.0) + (log.actual_hours or 0.0)

    last_7_days = [today - timedelta(days=offset) for offset in range(6, -1, -1)]
    daily_hours = [
        DailyHours(date=day.isoformat(), hours=round(hours_by_day.get(day, 0.0), 1))
        for day in last_7_days
    ]

    return DashboardResponse(
        completion_rate=_percent(len(due_completed), len(due_tasks)),
        goal_progress=_percent(len(completed), len(tasks)),
        completed_tasks=len(completed),
        total_tasks=len(tasks),
        weekly_hours=round(sum(item.hours for item in daily_hours), 1),
        daily_hours=daily_hours,
        streak_days=_count_streak(set(hours_by_day), today),
        skills=get_skills(user_id, db).skills,
    )

這段有幾個重點:

  • 跟 Day 17 的 /coach/today 一樣,只看「已核准」的計畫,沒有的話回 404。
  • 最後一行 get_skills(user_id, db).skills 直接呼叫 Day 21 寫好的函式,能力評分的算法只有一份,不用複製。
  • _percent 和 _count_streak 刻意寫成不碰資料庫的小函式,輸入什麼就回傳什麼,以後寫測試(Day 28)會很輕鬆。

步驟3:塞一些示範資料

剛做完的計畫還沒有任何進度記錄,儀表板會是一片空白。這支小腳本會替最早的 5 個待做任務補上連續 5 天的進度記錄,讓你有圖可看。

檔案位置: backend/seed_dashboard_demo.py
狀態: 新增檔案
用途: 塞入示範進度記錄,也能一行指令清掉
依賴: sqlalchemy (已安裝)

"""塞幾筆示範的進度記錄,讓儀表板有圖可看。

塞資料:python seed_dashboard_demo.py 1
清除資料:python seed_dashboard_demo.py 1 --clear
"""
import sys
from datetime import datetime, timedelta

from database import SessionLocal
from models import Plan, ProgressLog, Task

DEMO_NOTE = "dashboard示範資料"
DEMO_HOURS = [1.5, 2.0, 1.0, 2.5, 1.5]  # 由最早一天排到今天


def seed(user_id: int) -> None:
    """替最早的幾個待做任務補上進度記錄,日期排成連續的最近幾天"""
    db = SessionLocal()
    try:
        tasks = (
            db.query(Task)
            .join(Plan, Task.plan_id == Plan.id)
            .filter(Plan.user_id == user_id, Plan.status == "已核准", Task.status == "待做")
            .order_by(Task.day)
            .limit(len(DEMO_HOURS))
            .all()
        )
        if len(tasks) < len(DEMO_HOURS):
            print(f"待做任務不足{len(DEMO_HOURS)}個,沒有塞資料")
            return

        now = datetime.now()
        for index, (task, hours) in enumerate(zip(tasks, DEMO_HOURS)):
            days_ago = len(DEMO_HOURS) - 1 - index
            completed_at = (now - timedelta(days=days_ago)).replace(
                hour=20, minute=0, second=0, microsecond=0
            )
            db.add(
                ProgressLog(
                    task_id=task.id,
                    actual_hours=hours,
                    difficulty_rating=3,
                    notes=DEMO_NOTE,
                    completed_at=completed_at,
                )
            )
            task.status = "完成"
        db.commit()
        print(f"已塞入{len(DEMO_HOURS)}筆示範記錄")
    finally:
        db.close()


def clear(user_id: int) -> None:
    """刪除示範記錄,並把對應任務改回待做"""
    db = SessionLocal()
    try:
        logs = (
            db.query(ProgressLog)
            .join(Task, ProgressLog.task_id == Task.id)
            .join(Plan, Task.plan_id == Plan.id)
            .filter(Plan.user_id == user_id, ProgressLog.notes == DEMO_NOTE)
            .all()
        )
        for log in logs:
            log.task.status = "待做"
            db.delete(log)
        db.commit()
        print(f"已清除{len(logs)}筆示範記錄")
    finally:
        db.close()


if __name__ == "__main__":
    uid = int(sys.argv[1])
    if "--clear" in sys.argv:
        clear(uid)
    else:
        seed(uid)

在 backend/ 資料夾裡執行(把 1 換成你的 user_id):

python seed_dashboard_demo.py 1

用完想還原就加上 --clear。腳本只會動標了 dashboard示範資料 的記錄,不會碰你自己真正記的進度。

如果你的 /skills 還沒有測驗成績,能力評分那張圖會是空的,這是正常的。想看到圖,可以回頭到 Swagger 做一次 Day 20 的測驗。

步驟4:測試後端

啟動後端:

uvicorn main:app --reload

進入 http://127.0.0.1:8000/docs,找到 GET /dashboard,輸入 user_id 為 1,按執行。你應該會看到類似這樣的回應:

{
  "completion_rate": 100.0,
  "goal_progress": 20.8,
  "completed_tasks": 5,
  "total_tasks": 24,
  "weekly_hours": 8.5,
  "daily_hours": [
    {"date": "2026-09-26", "hours": 0.0},
    {"date": "2026-09-27", "hours": 0.0},
    {"date": "2026-09-28", "hours": 1.5},
    {"date": "2026-09-29", "hours": 2.0},
    {"date": "2026-09-30", "hours": 1.0},
    {"date": "2026-10-01", "hours": 2.5},
    {"date": "2026-10-02", "hours": 1.5}
  ],
  "streak_days": 5,
  "skills": []
}

你的數字會因為任務總數、日期而不同,但有幾件事可以對照:塞完示範資料後,weekly_hours 是 1.5 + 2.0 + 1.0 + 2.5 + 1.5 = 8.5,streak_days 是 5,daily_hours 一定剛好 7 筆。換一個沒有已核准計畫的 user_id,應該會看到 404 和 這個使用者還沒有已核准的計畫。

步驟5:安裝 Recharts,並讓頁面知道「登入狀態讀完了沒」

先裝圖表套件:

cd frontend
npm install recharts

這次裝的是 Recharts 3.x,跟 Next.js 16 與 React 19 相容。

接著要順手修一個 Day 23 留下的小問題。AuthContext 在第一次渲染時,還沒從 localStorage 讀到使用者編號,所以 userId 一定是 null。頁面看到 null 就以為沒登入,把你送回 /login。從導覽列點進來沒事(那時 userId 早就讀好了),但直接輸入網址或按重新整理,就會被踢回登入頁。

解法是讓 AuthContext 多提供一個 ready:讀完 localStorage 才是 true,頁面等到 ready 再判斷要不要導向登入頁。

檔案位置: frontend/src/context/AuthContext.tsx
狀態: 修改檔案(整個覆蓋)
用途: 多提供 ready,讓頁面知道登入狀態讀完了沒
依賴: react

"use client";

import { createContext, useContext, useSyncExternalStore, ReactNode } from "react";

interface AuthContextValue {
  userId: number | null;
  ready: boolean;
  setUserId: (id: number) => void;
  logout: () => void;
}

const AuthContext = createContext<AuthContextValue | undefined>(undefined);

const STORAGE_KEY = "userId";
const listeners = new Set<() => void>();

// 讓React在localStorage改變時重新讀取(同一個分頁用listeners,其他分頁用storage事件)
function subscribe(callback: () => void) {
  listeners.add(callback);
  window.addEventListener("storage", callback);
  return () => {
    listeners.delete(callback);
    window.removeEventListener("storage", callback);
  };
}

function getSnapshot() {
  return localStorage.getItem(STORAGE_KEY);
}

// 伺服器端沒有localStorage,第一次渲染一律當作還沒登入
function getServerSnapshot() {
  return null;
}

// ready在伺服器端是false,到瀏覽器後變成true,代表localStorage已經讀過了
const subscribeNothing = () => () => {};

function notify() {
  listeners.forEach((listener) => listener());
}

export function AuthProvider({ children }: { children: ReactNode }) {
  const stored = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
  const userId = stored ? Number(stored) : null;
  const ready = useSyncExternalStore(subscribeNothing, () => true, () => false);

  const setUserId = (id: number) => {
    localStorage.setItem(STORAGE_KEY, String(id));
    notify();
  };

  const logout = () => {
    localStorage.removeItem(STORAGE_KEY);
    notify();
  };

  return (
    <AuthContext.Provider value={{ userId, ready, setUserId, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth(): AuthContextValue {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error("useAuth必須在AuthProvider底下使用");
  }
  return context;
}

Day 23 的今日任務頁和 Day 24 的對話頁也有同樣的導向判斷,各改兩個地方。

frontend/src/app/today/page.tsx:

// 1. 把這一行
const { userId } = useAuth();
// 改成
const { userId, ready } = useAuth();

// 2. 把檢查登入的 useEffect 開頭、最後的依賴陣列改成
useEffect(() => {
  if (!ready) {
    return;
  }
  if (userId === null) {
    router.push("/login");
    return;
  }

  // ...下面原本的程式碼不動
}, [ready, userId, router]);

frontend/src/app/chat/page.tsx:

// 1. 同樣改成
const { userId, ready } = useAuth();

// 2. 把導向登入頁的 useEffect 整個換成
useEffect(() => {
  if (ready && userId === null) {
    router.push("/login");
  }
}, [ready, userId, router]);

步驟6:儀表板的樣式

檔案位置: frontend/src/components/dashboard/dashboard.module.css
狀態: 新增檔案
用途: 儀表板所有卡片、圖表的樣式,淺色與深色模式各有一組配色
依賴: 無

.page {
  --page: #f9f9f7;
  --surface: #fcfcfb;
  --text-primary: #0b0b0b;
  --text-secondary: #52514e;
  --text-muted: #898781;
  --grid: #e1e0d9;
  --axis: #c3c2b7;
  --border: rgba(11, 11, 11, 0.1);
  --series: #2a78d6;
  --track: #cde2fb;

  min-height: 100vh;
  padding: 16px;
  background: var(--page);
  color: var(--text-primary);
}

.content {
  max-width: 880px;
  margin: 0 auto;
}

@media (prefers-color-scheme: dark) {
  .page {
    --page: #0d0d0d;
    --surface: #1a1a19;
    --text-primary: #ffffff;
    --text-secondary: #c3c2b7;
    --grid: #2c2c2a;
    --axis: #383835;
    --border: rgba(255, 255, 255, 0.1);
    --series: #3987e5;
    --track: #0d366b;
  }
}

.hint {
  margin: 4px 0 16px;
  color: var(--text-secondary);
  font-size: 14px;
}

.statRow {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 12px;
  margin-bottom: 12px;
}

.card {
  padding: 16px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 12px;
}

.cardTitle {
  margin: 0 0 4px;
  font-size: 15px;
  font-weight: 600;
}

.cardSub {
  margin: 0 0 12px;
  color: var(--text-secondary);
  font-size: 13px;
}

.statLabel {
  color: var(--text-secondary);
  font-size: 14px;
}

.statValue {
  margin-top: 4px;
  font-size: 32px;
  font-weight: 600;
}

.statNote {
  margin-top: 2px;
  color: var(--text-muted);
  font-size: 13px;
}

.track {
  height: 10px;
  overflow: hidden;
  background: var(--track);
  border-radius: 5px;
}

.fill {
  height: 100%;
  background: var(--series);
  border-radius: 5px;
}

.meterText {
  margin-top: 8px;
  color: var(--text-secondary);
  font-size: 14px;
}

.chartGrid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
  gap: 12px;
  margin-top: 12px;
}

.chartBox {
  width: 100%;
  height: 240px;
}

.tooltip {
  padding: 8px 12px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  color: var(--text-primary);
  font-size: 13px;
}

.details {
  margin-top: 12px;
  color: var(--text-secondary);
  font-size: 13px;
}

.table {
  width: 100%;
  margin-top: 8px;
  border-collapse: collapse;
}

.table th,
.table td {
  padding: 4px 8px;
  border-bottom: 1px solid var(--grid);
  text-align: left;
}

.empty {
  padding: 24px 0;
  color: var(--text-muted);
  text-align: center;
}

顏色都先寫成 --series、--surface 這種有名字的變數,深色模式只要在 @media (prefers-color-scheme: dark) 裡換一組值,元件本身一行都不用改。.page 自己設了底色,所以不管電腦是淺色還是深色模式,標題都看得到。

步驟7:指標卡元件

檔案位置: frontend/src/components/dashboard/StatCard.tsx
狀態: 新增檔案
用途: 單一數字的指標卡,顯示名稱、大數字、補充說明
依賴: dashboard.module.css

import styles from "./dashboard.module.css";

interface StatCardProps {
  label: string;
  value: string;
  note?: string;
}

// 單一數字的指標卡:上面是名稱,中間是大數字,下面是補充說明
export function StatCard({ label, value, note }: StatCardProps) {
  return (
    <div className={styles.card}>
      <div className={styles.statLabel}>{label}</div>
      <div className={styles.statValue}>{value}</div>
      {note && <div className={styles.statNote}>{note}</div>}
    </div>
  );
}

步驟8:每日學習時數長條圖

檔案位置: frontend/src/components/dashboard/WeeklyHoursChart.tsx
狀態: 新增檔案
用途: 畫最近 7 天每天的學習時數,附表格檢視
依賴: recharts, dashboard.module.css

"use client";

import {
  Bar,
  BarChart,
  CartesianGrid,
  ResponsiveContainer,
  Tooltip,
  XAxis,
  YAxis,
} from "recharts";
import styles from "./dashboard.module.css";

export interface DailyHours {
  date: string;
  hours: number;
}

const WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"];

// "2026-10-02" 轉成 "週五"
function weekdayLabel(isoDate: string): string {
  return `週${WEEKDAYS[new Date(`${isoDate}T00:00:00`).getDay()]}`;
}

export function WeeklyHoursChart({ data }: { data: DailyHours[] }) {
  const chartData = data.map((item) => ({
    ...item,
    label: weekdayLabel(item.date),
  }));

  return (
    <div className={styles.card}>
      <h2 className={styles.cardTitle}>最近 7 天學習時數</h2>
      <p className={styles.cardSub}>每天實際花在任務上的小時數</p>
      <div className={styles.chartBox}>
        <ResponsiveContainer width="100%" height="100%">
          <BarChart data={chartData} margin={{ top: 8, right: 8, bottom: 0, left: -16 }}>
            <CartesianGrid vertical={false} stroke="var(--grid)" />
            <XAxis
              dataKey="label"
              tickLine={false}
              axisLine={{ stroke: "var(--axis)" }}
              tick={{ fill: "var(--text-muted)", fontSize: 12 }}
            />
            <YAxis
              allowDecimals={false}
              tickLine={false}
              axisLine={false}
              tick={{ fill: "var(--text-muted)", fontSize: 12 }}
            />
            <Tooltip
              cursor={false}
              content={({ active, payload }) => {
                if (!active || !payload?.length) {
                  return null;
                }
                const item = payload[0].payload as DailyHours & { label: string };
                return (
                  <div className={styles.tooltip}>
                    {item.date}({item.label}):{item.hours} 小時
                  </div>
                );
              }}
            />
            <Bar
              dataKey="hours"
              fill="var(--series)"
              maxBarSize={24}
              radius={[4, 4, 0, 0]}
            />
          </BarChart>
        </ResponsiveContainer>
      </div>
      <details className={styles.details}>
        <summary>查看表格</summary>
        <table className={styles.table}>
          <thead>
            <tr>
              <th>日期</th>
              <th>學習時數</th>
            </tr>
          </thead>
          <tbody>
            {chartData.map((item) => (
              <tr key={item.date}>
                <td>{item.date}</td>
                <td>{item.hours} 小時</td>
              </tr>
            ))}
          </tbody>
        </table>
      </details>
    </div>
  );
}

Recharts 的圖表要「畫在一個有高度的盒子裡」。ResponsiveContainer 會跟著外層盒子的大小縮放,所以外層 .chartBox 一定要有明確的高度(樣式裡設了 240px),否則圖表會縮成 0。

長條最粗 24px、頂端圓角 4px、格線只留橫的淡灰線,讓資料本身比裝飾更醒目。滑鼠移上去會顯示那天的日期和時數。

步驟9:能力評分橫向長條圖

檔案位置: frontend/src/components/dashboard/SkillsChart.tsx
狀態: 新增檔案
用途: 畫各主題最近一次測驗分數,沒有成績時顯示提示
依賴: recharts, dashboard.module.css

"use client";

import {
  Bar,
  BarChart,
  CartesianGrid,
  LabelList,
  ResponsiveContainer,
  Tooltip,
  XAxis,
  YAxis,
} from "recharts";
import styles from "./dashboard.module.css";

export interface Skill {
  topic: string;
  latest_score: number;
  status: string;
}

export function SkillsChart({ skills }: { skills: Skill[] }) {
  return (
    <div className={styles.card}>
      <h2 className={styles.cardTitle}>各主題掌握度</h2>
      <p className={styles.cardSub}>最近一次測驗分數,60 分以下需複習,80 分以上算已掌握</p>
      {skills.length === 0 ? (
        <p className={styles.empty}>還沒有測驗成績,完成一次測驗後會出現在這裡</p>
      ) : (
        <div className={styles.chartBox}>
          <ResponsiveContainer width="100%" height="100%">
            <BarChart
              data={skills}
              layout="vertical"
              margin={{ top: 4, right: 32, bottom: 0, left: 8 }}
            >
              <CartesianGrid horizontal={false} stroke="var(--grid)" />
              <XAxis
                type="number"
                domain={[0, 100]}
                tickLine={false}
                axisLine={false}
                tick={{ fill: "var(--text-muted)", fontSize: 12 }}
              />
              <YAxis
                type="category"
                dataKey="topic"
                width={110}
                tickLine={false}
                axisLine={{ stroke: "var(--axis)" }}
                tick={{ fill: "var(--text-secondary)", fontSize: 12 }}
              />
              <Tooltip
                cursor={false}
                content={({ active, payload }) => {
                  if (!active || !payload?.length) {
                    return null;
                  }
                  const item = payload[0].payload as Skill;
                  return (
                    <div className={styles.tooltip}>
                      {item.topic}:{item.latest_score} 分({item.status})
                    </div>
                  );
                }}
              />
              <Bar
                dataKey="latest_score"
                fill="var(--series)"
                maxBarSize={20}
                radius={[0, 4, 4, 0]}
              >
                <LabelList
                  dataKey="latest_score"
                  position="right"
                  fill="var(--text-secondary)"
                  fontSize={12}
                />
              </Bar>
            </BarChart>
          </ResponsiveContainer>
        </div>
      )}
      {skills.length > 0 && (
        <details className={styles.details}>
          <summary>查看表格</summary>
          <table className={styles.table}>
            <thead>
              <tr>
                <th>主題</th>
                <th>分數</th>
                <th>狀態</th>
              </tr>
            </thead>
            <tbody>
              {skills.map((skill) => (
                <tr key={skill.topic}>
                  <td>{skill.topic}</td>
                  <td>{skill.latest_score}</td>
                  <td>{skill.status}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </details>
      )}
    </div>
  );
}

這張圖把分數直接標在長條的尾端,一眼就能讀。「需複習」「待加強」「已掌握」這些狀態不靠顏色表達,tooltip 和表格都用文字寫出來。

步驟10:儀表板頁面

檔案位置: frontend/src/app/dashboard/page.tsx
狀態: 新增檔案
用途: 呼叫 /dashboard,組合指標卡、進度條、兩張圖,並每 30 秒自動更新
依賴: context, lib/api, components/dashboard

"use client";

import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import { apiFetch } from "@/lib/api";
import { StatCard } from "@/components/dashboard/StatCard";
import { SkillsChart, Skill } from "@/components/dashboard/SkillsChart";
import { WeeklyHoursChart, DailyHours } from "@/components/dashboard/WeeklyHoursChart";
import styles from "@/components/dashboard/dashboard.module.css";

interface DashboardResponse {
  completion_rate: number;
  goal_progress: number;
  completed_tasks: number;
  total_tasks: number;
  weekly_hours: number;
  daily_hours: DailyHours[];
  streak_days: number;
  skills: Skill[];
}

const REFRESH_INTERVAL_MS = 30_000;

export default function DashboardPage() {
  const { userId, ready } = useAuth();
  const router = useRouter();
  const [data, setData] = useState<DashboardResponse | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    // 還沒讀完localStorage之前,userId一定是null,不能拿來判斷有沒有登入
    if (!ready) {
      return;
    }
    if (userId === null) {
      router.push("/login");
      return;
    }

    // 離開頁面或換使用者後,晚到的回應不要再更新畫面
    let cancelled = false;

    const load = () => {
      apiFetch<DashboardResponse>(`/dashboard?user_id=${userId}`)
        .then((result) => {
          if (!cancelled) {
            setData(result);
            setError(null);
          }
        })
        .catch((err: Error) => {
          if (!cancelled) {
            setError(err.message);
          }
        });
    };

    load();
    const timer = setInterval(load, REFRESH_INTERVAL_MS);

    return () => {
      cancelled = true;
      clearInterval(timer);
    };
  }, [ready, userId, router]);

  if (error && !data) {
    return (
      <main className={styles.page}>
        <div className={styles.content}>
          <p>發生錯誤:{error}</p>
        </div>
      </main>
    );
  }

  if (!data) {
    return (
      <main className={styles.page}>
        <div className={styles.content}>
          <p>載入中...</p>
        </div>
      </main>
    );
  }

  return (
    <main className={styles.page}>
      <div className={styles.content}>
        <h1>學習儀表板</h1>
        <p className={styles.hint}>每 30 秒自動更新一次</p>

        <div className={styles.statRow}>
          <StatCard
            label="任務完成率"
            value={`${data.completion_rate}%`}
            note="已到期的任務中,完成了多少"
          />
          <StatCard
            label="本週學習時數"
            value={`${data.weekly_hours} 小時`}
            note="最近 7 天累計"
          />
          <StatCard
            label="連續學習"
            value={`${data.streak_days} 天`}
            note="連續有進度記錄的天數"
          />
        </div>

        <div className={styles.card}>
          <h2 className={styles.cardTitle}>朝向目標的進度</h2>
          <p className={styles.cardSub}>整份計畫已完成的比例</p>
          <div
            className={styles.track}
            role="progressbar"
            aria-valuemin={0}
            aria-valuemax={100}
            aria-valuenow={data.goal_progress}
          >
            <div className={styles.fill} style={{ width: `${data.goal_progress}%` }} />
          </div>
          <p className={styles.meterText}>
            {data.goal_progress}%(完成 {data.completed_tasks} / {data.total_tasks} 個任務)
          </p>
        </div>

        <div className={styles.chartGrid}>
          <WeeklyHoursChart data={data.daily_hours} />
          <SkillsChart skills={data.skills} />
        </div>
      </div>
    </main>
  );
}

「自動更新」只靠 setInterval:進入頁面先抓一次,之後每 30 秒再抓一次。離開頁面時 clearInterval 把計時器停掉,不然人都走了還在背景一直打 API。cancelled 這個旗標則是擋掉「離開之後才回來」的慢回應,避免更新到已經不存在的畫面。

如果自動更新時剛好連不上後端,畫面會保留上一次的資料,不會突然變成錯誤頁,因為只有第一次載入就失敗才會顯示錯誤。

步驟11:導覽列加上儀表板連結

檔案位置: frontend/src/components/NavBar.tsx
狀態: 修改檔案(加一行 Link)
用途: 讓使用者能從導覽列進入儀表板
依賴: context

"use client";

import Link from "next/link";
import { useAuth } from "@/context/AuthContext";

export function NavBar() {
  const { userId, logout } = useAuth();

  if (userId === null) {
    return null;
  }

  return (
    <nav>
      <span>使用者 {userId}</span>
      <Link href="/today">今日任務</Link>
      <Link href="/chat">對話</Link>
      <Link href="/dashboard">儀表板</Link>
      <button onClick={logout}>登出</button>
    </nav>
  );
}

步驟12:測試

確認後端正在跑,然後:

cd frontend
npm run dev

進入 http://localhost:3000,登入後點導覽列的「儀表板」。你應該會看到:

  1. 三張指標卡:任務完成率、本週學習時數、連續學習天數
  2. 一條藍色進度條,底下寫著「完成 X / Y 個任務」
  3. 左邊是最近 7 天的長條圖,右邊是各主題掌握度的橫條圖
  4. 滑鼠移到長條上,會跳出日期(或主題)和數字
  5. 點「查看表格」,會展開同一份資料的表格

接著試兩件事:

  • 重新整理這一頁:應該還停在儀表板,不會被送回登入頁。
  • 自動更新:保持頁面開著,到 Swagger 用 POST /progress/log 替另一個任務補一筆進度記錄,最多等 30 秒,儀表板的數字就會自己更新。

如果你的電腦設定成深色模式,整個儀表板也會換成深色配色,圖表、卡片、進度條都有對應的深色版本。


常見問題

儀表板顯示「發生錯誤:這個使用者還沒有已核准的計畫」

/dashboard 跟 /coach/today 一樣,只看「已核准」的計畫。回到 Day 16,把計畫核准後再試。

數字都是 0,長條圖也是空的

計畫還沒有任何進度記錄。執行步驟 3 的 python seed_dashboard_demo.py 1 塞一些示範資料,或是到 Swagger 用 POST /progress/log 自己記幾筆。用完想清掉,加上 --clear 即可。

連續學習天數是 0 或 1,跟我想的不一樣

連續天數算的是「有進度記錄的日期」。POST /progress/log 的完成時間是伺服器收到請求的那一刻,所以今天記的進度只會算在今天,沒辦法用 API 補記到昨天。想看到比較長的連續天數,可以用步驟 3 的示範資料。另外,今天還沒記不算中斷,會從昨天開始往回數;昨天也沒記,才會歸零。

圖表區塊一片空白,沒有報錯

Recharts 的 ResponsiveContainer 要放在一個有明確高度的盒子裡。檢查外層的 .chartBox 有沒有 height: 240px,如果你改過樣式把高度拿掉,圖表就會縮成 0。

直接輸入網址或重新整理,被送回登入頁

步驟 5 的 ready 沒有加完整。確認 AuthContext.tsx 有回傳 ready,而且儀表板、今日任務、對話三個頁面的判斷都改成「ready 為 true 之後才看 userId」。


儀表板會集中呈現前 24 天累積的學習資料。明天會使用 APScheduler 建立通知與提醒:每天早上生成今日任務、任務到期前一天提醒、每週日建立學習回顧,並將通知存於應用內供使用者查詢。


上一篇
Day 24:簡化的聊天功能 - 串流回應
系列文
30天用 Claude Code + LangGraph 實作個人化 AI 學習教練 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言