前 24 天已完成排計畫、記錄進度、測驗與對話功能,但資料分散在不同頁面與 API。今天會建立儀表板,將重要數字與圖表集中在同一頁。
後端新增一支 GET /dashboard,把資料庫裡的任務、進度記錄和測驗成績整理成幾個指標。前端新增 /dashboard 頁面,用 Recharts 畫圖,並且每 30 秒自動更新。
儀表板會把資料庫的原始記錄整理成容易閱讀的指標與圖表。
任務、進度記錄、測驗成績(資料庫)
↓ 後端整理
GET /dashboard(五個指標)
↓ 前端每 30 秒抓一次
指標卡 + 進度條 + 兩張圖
今天只做五個指標,每一個都先想清楚「怎麼算」:
| 指標 | 怎麼算 | 畫成什麼 |
|---|---|---|
| 任務完成率 | 已到期的任務中,完成了幾成 | 指標卡 |
| 本週學習時數 | 最近 7 天進度記錄的實際時數加總 | 指標卡 + 長條圖 |
| 連續學習天數 | 從今天往回數,連續有進度記錄的天數 | 指標卡 |
| 能力評分 | 每個主題最近一次測驗的分數(Day 21 的 /skills) |
橫向長條圖 |
| 朝向目標的進度 | 整份計畫完成的任務數 / 全部任務數 | 進度條 |
「完成率」和「進度條」衡量的範圍不同。完成率只計算已到期任務,反映該做的任務是否完成;進度條計算整份計畫,反映距離目標的進度。第一天完成當日任務時,完成率可能為 100%,進度條仍可能只有 4%。
連續天數有一個貼心的規則:今天還沒做不算中斷,會先從昨天開始數。要昨天也沒做,連續天數才會歸零。
圖表只用一種顏色。這兩張圖都只有一組資料,不需要靠顏色區分,單色更乾淨。每張圖底下也附了「查看表格」,看不清楚顏色的人,或是想看精確數字的人,都能直接讀表。
檔案位置: 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 這一欄直接沿用,不用重寫。
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,
)
這段有幾個重點:
/coach/today 一樣,只看「已核准」的計畫,沒有的話回 404。get_skills(user_id, db).skills 直接呼叫 Day 21 寫好的函式,能力評分的算法只有一份,不用複製。_percent 和 _count_streak 刻意寫成不碰資料庫的小函式,輸入什麼就回傳什麼,以後寫測試(Day 28)會很輕鬆。剛做完的計畫還沒有任何進度記錄,儀表板會是一片空白。這支小腳本會替最早的 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 的測驗。
啟動後端:
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 和 這個使用者還沒有已核准的計畫。
先裝圖表套件:
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]);
檔案位置: 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 自己設了底色,所以不管電腦是淺色還是深色模式,標題都看得到。
檔案位置: 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>
);
}
檔案位置: 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、格線只留橫的淡灰線,讓資料本身比裝飾更醒目。滑鼠移上去會顯示那天的日期和時數。
檔案位置: 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 和表格都用文字寫出來。
檔案位置: 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 這個旗標則是擋掉「離開之後才回來」的慢回應,避免更新到已經不存在的畫面。
如果自動更新時剛好連不上後端,畫面會保留上一次的資料,不會突然變成錯誤頁,因為只有第一次載入就失敗才會顯示錯誤。
檔案位置: 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>
);
}
確認後端正在跑,然後:
cd frontend
npm run dev
進入 http://localhost:3000,登入後點導覽列的「儀表板」。你應該會看到:
接著試兩件事:
POST /progress/log 替另一個任務補一筆進度記錄,最多等 30 秒,儀表板的數字就會自己更新。如果你的電腦設定成深色模式,整個儀表板也會換成深色配色,圖表、卡片、進度條都有對應的深色版本。
/dashboard 跟 /coach/today 一樣,只看「已核准」的計畫。回到 Day 16,把計畫核准後再試。
計畫還沒有任何進度記錄。執行步驟 3 的 python seed_dashboard_demo.py 1 塞一些示範資料,或是到 Swagger 用 POST /progress/log 自己記幾筆。用完想清掉,加上 --clear 即可。
連續天數算的是「有進度記錄的日期」。POST /progress/log 的完成時間是伺服器收到請求的那一刻,所以今天記的進度只會算在今天,沒辦法用 API 補記到昨天。想看到比較長的連續天數,可以用步驟 3 的示範資料。另外,今天還沒記不算中斷,會從昨天開始往回數;昨天也沒記,才會歸零。
Recharts 的 ResponsiveContainer 要放在一個有明確高度的盒子裡。檢查外層的 .chartBox 有沒有 height: 240px,如果你改過樣式把高度拿掉,圖表就會縮成 0。
步驟 5 的 ready 沒有加完整。確認 AuthContext.tsx 有回傳 ready,而且儀表板、今日任務、對話三個頁面的判斷都改成「ready 為 true 之後才看 userId」。
儀表板會集中呈現前 24 天累積的學習資料。明天會使用 APScheduler 建立通知與提醒:每天早上生成今日任務、任務到期前一天提醒、每週日建立學習回顧,並將通知存於應用內供使用者查詢。