現在的資料都在,但只以最原始的形式呈現:一條頂欄百分比、幾個進度環。我看不到:
而且有個更根本的問題:我的學習紀錄鎖在瀏覽器裡,換裝置就沒了。 今天先解決「使用者資料主權」(匯出/匯入),再把資料畫成圖。
順帶說明:全部圖表手寫 SVG,不裝 Chart.js(180 KB)也不裝 D3(Day 1 的無依賴原則)。三張圖加起來約 150 行。
這件事比圖表重要,而且應該在做雲端同步(Day 27)之前就有——使用者必須隨時能把自己的資料帶走。
/* js/models/backup.js */
const Backup = {
SCHEMA: 1,
export() {
return {
schema: this.SCHEMA,
exportedAt: new Date().toISOString(),
progress: Progress.load(),
gamify: Gamify.load(),
exams: Exam.loadAll(),
theme: Theme.read(),
};
},
download() {
const data = JSON.stringify(this.export(), null, 2);
const blob = new Blob([data], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `learnpath-backup-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
},
匯入是危險操作,所以要合併而不是覆蓋:
/* mode: "merge"(預設,安全)| "replace"(明確要求才做) */
import(raw, mode = "merge") {
let d;
try { d = JSON.parse(raw); } catch { return { ok: false, err: "不是有效的 JSON" }; }
if (!d || typeof d !== "object") return { ok: false, err: "格式不符" };
if (d.schema !== this.SCHEMA) return { ok: false, err: `版本不符(檔案 v${d.schema},本站 v${this.SCHEMA})` };
const isFlagMap = o => o && typeof o === "object" && !Array.isArray(o)
&& Object.values(o).every(v => v === true);
if (!isFlagMap(d.progress)) return { ok: false, err: "progress 欄位格式不符" };
/* 只採用課綱裡真實存在的 key——過濾掉舊版或偽造的資料 */
const valid = {};
let dropped = 0;
for (const k of Object.keys(d.progress)) {
if (window.LESSONS && window.LESSONS[k]) valid[k] = true; else dropped++;
}
const before = Progress.doneCount();
const merged = mode === "replace" ? valid : { ...Progress.load(), ...valid };
Progress.save(merged);
/* 遊戲化:XP 取較大值,streak 取較大值,徽章與已答題目取聯集 */
if (d.gamify && typeof d.gamify === "object") {
const cur = Gamify.load();
Gamify.save({
xp: Math.max(cur.xp, Number(d.gamify.xp) || 0),
streakDays: Math.max(cur.streakDays, Number(d.gamify.streakDays) || 0),
lastDay: cur.lastDay || d.gamify.lastDay || null,
quizzes: { ...cur.quizzes, ...(d.gamify.quizzes || {}) },
lessons: { ...cur.lessons, ...(d.gamify.lessons || {}) },
badges: { ...cur.badges, ...(d.gamify.badges || {}) },
});
}
return { ok: true, added: Progress.doneCount() - before, dropped, mode };
},
};
四個設計決定:
1. schema 版本號。 跟 Day 5 的 localStorage key 版本號同一個道理。沒有版本號的資料格式是無法演進的。
2. 預設 merge 不 replace。 匯入的最壞情況是「使用者不小心用舊備份蓋掉新進度」。merge 讓最壞情況只是「多了幾課」,而不是「掉了三個月的紀錄」。
3. 過濾不存在的 key。 備份檔可能來自舊版(課程改名過)或被手動編輯。只採用課綱裡真的有的課,並回報丟棄了幾筆——匯入不是信任,是驗證後採用。這也是 Day 9 那條原則的延伸。
4. 遊戲化用「取較大值 + 聯集」而不是覆蓋。 XP 取 max、徽章取聯集。這個合併策略在 Day 27 做雲端同步時會直接複用——先在本地把合併邏輯想清楚,上雲時就不用重新設計。
匯入 UI 用 <input type="file"> 加上明確的二次確認:
input.addEventListener("change", async () => {
const f = input.files[0]; if (!f) return;
const text = await f.text();
const preview = Backup.import(text, "merge"); // 先跑一次驗證(不寫入的 dry-run 版本更好)
if (!preview.ok) return alert(`匯入失敗:${preview.err}`);
alert(`匯入完成:新增 ${preview.added} 課${preview.dropped ? `,忽略 ${preview.dropped} 筆無效紀錄` : ""}`);
location.reload();
});
GitHub 那種一年 52 週 × 7 天的方格圖。需要「每天完成幾課」——但 Day 5 的 Progress 只存 { "ch01-1": true },沒有時間戳。
這是設計失誤的帳單。補救有兩條路:
| 做法 | 說明 |
|---|---|
| 改格式存時間 | 要遷移,而且過去的資料補不回來 |
| 加一個獨立的每日計數 | 從今天開始有資料,不動既有格式 |
選第二個,因為既有資料無論如何都補不回時間:
/* js/models/activity.js — 每日活動計數 */
const Activity = {
KEY: "learnpath-activity-v1",
load() {
try { const v = JSON.parse(localStorage.getItem(this.KEY));
return (v && typeof v === "object" && !Array.isArray(v)) ? v : {}; }
catch { return {}; }
},
/* { "2026-08-03": { lessons: 3, quizzes: 5, minutes: 42 } } */
bump(field, amount = 1) {
const d = new Date().toISOString().slice(0, 10);
const all = this.load();
const day = all[d] || { lessons: 0, quizzes: 0, minutes: 0 };
day[field] = (Number(day[field]) || 0) + amount;
all[d] = day;
try { localStorage.setItem(this.KEY, JSON.stringify(all)); } catch {}
},
};
掛在既有的事件點上:
/* chapter.js 的 onComplete */
Progress.setDone(chId, lessonId, true);
Activity.bump("lessons");
Activity.bump("minutes", lesson.min || 10); // 用課綱的預估分鐘數累積投入時間
if (window.Gamify) Gamify.onLessonComplete(lessonKey(chId, lessonId));
lesson.min 終於有第二個用途了——Day 10 費心算出來的分鐘數,今天變成「累積投入時間」的資料來源。(如果當初留著假的「一律 10 分」,這張圖就會是假的。)
熱力圖 SVG:
heatmapSVG(activity, weeks = 26) {
const CELL = 12, GAP = 3, ROW = 7;
const today = new Date();
/* 對齊到本週日,讓每一欄都是完整的一週 */
const end = new Date(today); end.setDate(end.getDate() + (6 - end.getDay()));
const days = weeks * 7;
const start = new Date(end); start.setDate(start.getDate() - days + 1);
const counts = Object.fromEntries(
Object.entries(activity).map(([d, v]) => [d, Number(v.lessons) || 0]));
const max = Math.max(1, ...Object.values(counts));
/* 五級色階:0 用軌道色,1–4 用強調色的不同不透明度 */
const level = n => n === 0 ? 0 : Math.min(4, Math.ceil(n / max * 4));
const FILL = ["var(--track)", "var(--accent)", "var(--accent)", "var(--accent)", "var(--accent)"];
const OP = [1, 0.3, 0.5, 0.75, 1];
let cells = "", monthLabels = "", lastMonth = -1;
for (let i = 0; i < days; i++) {
const d = new Date(start); d.setDate(d.getDate() + i);
const iso = d.toISOString().slice(0, 10);
const col = Math.floor(i / 7), row = d.getDay();
const n = counts[iso] || 0, lv = level(n);
const x = col * (CELL + GAP), y = row * (CELL + GAP);
cells += `<rect x="${x}" y="${y}" width="${CELL}" height="${CELL}" rx="2"
fill="${FILL[lv]}" opacity="${OP[lv]}"
role="img" aria-label="${iso}:${n} 課"><title>${iso}:${n} 課</title></rect>`;
if (row === 0 && d.getMonth() !== lastMonth) {
lastMonth = d.getMonth();
monthLabels += `<text class="hm-month" x="${x}" y="-6">${d.getMonth() + 1}月</text>`;
}
}
const W = Math.ceil(days / 7) * (CELL + GAP), H = ROW * (CELL + GAP);
return `<svg class="hm" viewBox="-4 -18 ${W + 8} ${H + 24}" role="img"
aria-label="近 ${weeks} 週學習活動熱力圖">
${monthLabels}${cells}</svg>`;
}
兩個細節:
<title> 給原生 tooltip:SVG 的 <title> 子元素會被瀏覽器當成 tooltip,零 JS 就有懸浮提示。viewBox 的負值起點(-4 -18):留出上方空間給月份標籤,不需要額外的 group transform。色階用同一個色相的不同不透明度,而不是五個不同顏色。這樣它自動跟著 Day 11 的深色模式與強調色走,不用維護第二套色票。
六個類別 → 六邊形雷達圖。純三角函數:
radarSVG(stats) { // stats: [{ label, pct, color }, …]
const N = stats.length, R = 90, CX = 120, CY = 110;
/* 從 12 點鐘方向開始,順時針 */
const pt = (i, r) => {
const a = (i / N) * Math.PI * 2 - Math.PI / 2;
return [CX + Math.cos(a) * r, CY + Math.sin(a) * r];
};
const poly = r => Array.from({ length: N }, (_, i) => pt(i, r).map(v => v.toFixed(1)).join(",")).join(" ");
/* 背景網格:四圈 */
const grid = [0.25, 0.5, 0.75, 1].map(k =>
`<polygon class="rd-grid" points="${poly(R * k)}"/>`).join("");
/* 軸線 */
const axes = stats.map((_, i) => {
const [x, y] = pt(i, R);
return `<line class="rd-axis" x1="${CX}" y1="${CY}" x2="${x.toFixed(1)}" y2="${y.toFixed(1)}"/>`;
}).join("");
/* 資料多邊形 */
const dataPts = stats.map((s, i) =>
pt(i, R * Math.max(0.02, s.pct / 100)).map(v => v.toFixed(1)).join(",")).join(" ");
/* 標籤:往外推 18px,依角度決定對齊方式 */
const labels = stats.map((s, i) => {
const [x, y] = pt(i, R + 18);
const anchor = Math.abs(x - CX) < 6 ? "middle" : (x > CX ? "start" : "end");
return `<text class="rd-label" x="${x.toFixed(1)}" y="${y.toFixed(1)}"
text-anchor="${anchor}" dominant-baseline="middle"
fill="${s.color}">${s.label} ${s.pct}%</text>`;
}).join("");
return `<svg class="rd" viewBox="0 0 240 220" role="img"
aria-label="各類別完成率:${stats.map(s => `${s.label} ${s.pct}%`).join("、")}">
${grid}${axes}
<polygon class="rd-data" points="${dataPts}"/>
${stats.map((s, i) => { const [x, y] = pt(i, R * Math.max(0.02, s.pct / 100));
return `<circle class="rd-dot" cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" fill="${s.color}"/>`;
}).join("")}
${labels}</svg>`;
}
Math.max(0.02, pct/100) 是防退化:完成率 0% 時所有點都在中心,多邊形變成一個點、看不出圖形。給 2% 的最小半徑讓形狀維持可辨。
aria-label 把整張圖的數據講一遍——SVG 圖表對螢幕閱讀器來說是黑盒子,唯一的無障礙手段就是完整的文字描述。(Day 19 會再談。)
weeklyBarsSVG(activity, weeks = 12) {
const buckets = new Array(weeks).fill(0);
const now = new Date();
for (const [iso, v] of Object.entries(activity)) {
const diffDays = Math.floor((now - new Date(iso + "T00:00:00")) / 864e5);
const w = Math.floor(diffDays / 7);
if (w >= 0 && w < weeks) buckets[weeks - 1 - w] += Number(v.minutes) || 0;
}
const max = Math.max(30, ...buckets);
const BW = 22, GAP = 8, H = 100;
const bars = buckets.map((m, i) => {
const h = (m / max) * H;
return `<g><rect class="wb-bar" x="${i * (BW + GAP)}" y="${(H - h).toFixed(1)}"
width="${BW}" height="${h.toFixed(1)}" rx="3"><title>${m} 分鐘</title></rect>
${m ? `<text class="wb-val" x="${i * (BW + GAP) + BW / 2}" y="${(H - h - 4).toFixed(1)}"
text-anchor="middle">${m}</text>` : ""}</g>`;
}).join("");
return `<svg class="wb" viewBox="-4 -14 ${weeks * (BW + GAP) + 8} ${H + 24}" role="img"
aria-label="近 ${weeks} 週每週投入分鐘數">${bars}
<line class="wb-base" x1="0" y1="${H}" x2="${weeks * (BW + GAP) - GAP}" y2="${H}"/></svg>`;
}
Math.max(30, ...) 給 y 軸一個最小上限——否則只讀了 5 分鐘的那週會變成滿高的長條,視覺上騙人。圖表的縮放預設要誠實。
const DashController = {
init() {
const act = Activity.load();
const g = Gamify.load();
const total = totalLessonCount(), done = Progress.doneCount();
const minutes = Object.values(act).reduce((s, v) => s + (Number(v.minutes) || 0), 0);
const stats = Object.entries(CAT_META).map(([cat, meta]) => {
const cs = COURSES.filter(c => c.cat === cat);
const t = cs.reduce((s, c) => s + courseLessonCount(c), 0);
const d = cs.reduce((s, c) => s + Progress.courseDone(c), 0);
return { label: meta.label, pct: t ? Math.round(d / t * 100) : 0, color: catColor(cat) };
});
document.getElementById("dash-root").innerHTML = `
<div class="dash-tiles">
${tile("已完成", `${done}`, `/${total} 課`)}
${tile("累積投入", `${Math.floor(minutes / 60)}`, `小時 ${minutes % 60} 分`)}
${tile("等級", `Lv.${Gamify.levelOf(g.xp)}`, `${g.xp} XP`)}
${tile("連續學習", `${g.streakDays}`, "天")}
</div>
<section class="dash-card"><h3>學習活動</h3>${DashView.heatmapSVG(act)}</section>
<section class="dash-2col">
<div class="dash-card"><h3>類別完成率</h3>${DashView.radarSVG(stats)}</div>
<div class="dash-card"><h3>每週投入</h3>${DashView.weeklyBarsSVG(act)}</div>
</section>
<section class="dash-card"><h3>徽章</h3>${Gamify.badgeShelfHTML()}</section>
<section class="dash-card"><h3>資料</h3>
<button class="btn" id="dl-backup">匯出備份(JSON)</button>
<label class="btn" for="up-backup">匯入備份</label>
<input id="up-backup" type="file" accept="application/json" hidden>
<p class="dash-note">匯入預設為<b>合併</b>:只會增加完成紀錄,不會刪除既有進度。</p>
</section>`;
/* 綁事件… */
},
};
Gamify.badgeShelfHTML() 是 Day 9 就寫好但一直沒地方放的函式,今天終於有家了。
時區。 new Date(iso) 對 "2026-08-03" 會解析成 UTC 午夜,在台灣是早上 8 點。做週分桶時這會讓跨週邊界的日期算錯一格。加上 T00:00:00(不帶 Z)就會用本地時間解析:
new Date("2026-08-03") // UTC 午夜 → 台灣 08:00
new Date("2026-08-03T00:00:00") // 本地午夜 ✅
這也提醒我 Day 9 的 streak 是用 toISOString() 算 UTC 日期。日期邊界的一致性是個真實問題:Activity 用本地日期、Gamify 用 UTC 日期,兩者在台灣早上 8 點前會不一致。我把 Gamify 也改成本地日期:
today() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
}
這是格式變更,需要遷移嗎? 不需要——最壞情況是某個使用者的 streak 多算或少算一天。但如果這是計費系統,就必須寫遷移。判斷「要不要遷移」的標準是錯誤的後果,不是格式是否改變。
SVG 的文字不會自動換行。 <text> 沒有 wrap。雷達圖標籤如果太長就會超出 viewBox 被裁掉。解法:縮短標籤(用 CAT_META.label 的短名)、或用 <foreignObject> 塞 HTML(但那在某些環境支援不完整)。我選縮短標籤。
URL.createObjectURL 要記得 revoke。 不 revoke 會洩漏記憶體(blob 一直被持有)。setTimeout(..., 1000) 而不是立刻 revoke,因為 a.click() 的下載是非同步開始的。
node scripts/verify.js
python3 -m http.server 8901
# http://localhost:8901/dashboard.html
先塞測試資料:
/* console:造 60 天的假活動 */
const act = {};
for (let i = 0; i < 60; i++) {
const d = new Date(Date.now() - i * 864e5);
const iso = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
if (Math.random() > 0.4) act[iso] = { lessons: 1 + (i % 4), quizzes: 2, minutes: 15 + (i % 30) };
}
localStorage.setItem("learnpath-activity-v1", JSON.stringify(act));
location.reload();
檢查清單:
"nonexistent-9")的檔案 → 該筆被忽略並回報 dropped: 1。{"schema": 99} → 明確拒絕並說明版本不符。--accent)。今天的重點:
也記一筆設計失誤的帳單:Progress 當初沒存時間戳,導致熱力圖只能從今天開始有資料。如果你的資料可能會想做時間軸分析,第一天就存時間。
明天做教學動畫:canvas 上跑真的計算。核心觀點是「動畫必須跑真模型,不能是假動畫」——我會離線訓練一個小模型,輸出成純資料檔,前端只做推論。