今天要解的問題
coffee-review 是我自己記咖啡用的 web app,沖煮、品鑑和一場多杯的杯測都記在裡面。
記錄累積了一陣子,雖然已經有各種各樣的資料與店家頁的統計,但我想要的是全盤的統計,讓我知道我已完成多少次手沖紀錄、或是我的平均打分是如何。
後來發現關於這件事的需求,我手上有三張 issue 卡著:
- 一句話的「個人內部,有數據:記錄的店家,杯測數字,品鑑數字,各種分析」。
- 很早就開的統計 Dashboard:五張卡(分數分布、時間趨勢、風味 top 10、店家排行、杯測 vs 品鑑比例),再加時間範圍篩選。
- 個人風味偏好分析:統計每個風味詞的次數,用 treemap 或 chip cloud 呈現,可以篩選,點風味還要跳到記錄列表。
三張的範圍互相重疊,第二張幾乎完全涵蓋第一張,第三張的風味又和第二張的 top 10 撞在一起。所以就打算全部一次做掉,只是因其工作量很大,有進行拆分。

想法與取捨
放在哪裡?
一開始我想放在「個人」頁,因為目前的 #/me 內容太少,只有登入登出。但放在獨立的 #/stats,感覺對未來來說比較合理。
所以 #/stats 是獨立的一頁,入口放在個人頁的帳號卡下方,底部的 tabbar 不加新的 tab。
第一刀
只要三個數字
第一刀原本選的是「分數總覽」:記錄數、平均分,加上依徽章等級的分數分布和每月平均趨勢。接著又縮了一次,變成先只做已登記的店家數量、已完成的沖煮品鑑杯測數量,還有我的平均分數。
這是範圍縮得最明顯的一步,分布、趨勢、排行、風味全部往後延。
店家數我選了「只算我記錄過的店家」,標籤也跟著改成「記錄過的店家」。店家表本身是所有使用者共用的登記表,「已登記」會把別人登記的店也算進來。版面則是三張 375px mockup 裡的清單列。
數字的單位刻意不一致:記錄數算杯測的「場次」,平均分和店家數則攤成「每一杯」來算,因為豆子的來源店家掛在杯上,分數也打在杯上。
第二刀
五張卡砍成三個分頁
Dashboard 那張原本的五張卡,這次的選擇是:
- 風味全部留給下一刀。風味 top 10 和風味分析那張本來就重疊。
- 不做時間範圍篩選。
- 店家排行可以在「平均分」和「筆數」之間切換。
- 類型比例不另外畫圖,直接沿用第一刀的沖煮、品鑑、杯測三列數字。
版面從三版 375px mockup(橫條 + 折線、直條 + 月長條、一張卡三個分頁)裡選了一張卡三個分頁。分頁和排序的狀態不寫進網址、不記住,每次進頁都從「分數分布」「平均分」開始。
第三刀
風味做成第四個分頁
風味偏好直接做成第二刀那張卡的第四個分頁,不另開卡,不做篩選,點風味跳記錄列表也不做。記錄列表目前也沒有風味篩選。
呈現方式看了三版假資料的 mockup(treemap、chip cloud、橫條 + top 10),我選了 treemap,但只需要保留大項,下面的小標籤不需要。所以最後只畫風味輪的 8 個大類,沒有圖例。太小的色塊放不下字,數字就放在 <title> 和整張 SVG 的 aria-label 裡。
圖表庫
三刀都沒有用
早期 Dashboard 那張的實作提示,同時寫了「為避免引入大型圖表庫,可用純 CSS / SVG bar chart」和「若要圖表庫,建議 Chart.js」,所以圖表庫原本是開放的。三刀最後都維持純 CSS 和手寫 SVG,計算也全在前端,沒有加 API、欄位或 migration。
實作
清單卡
#/stats 只呼叫一次既有的 api.listRecords({ type: 'all' }),新寫的只有一個純函式,其他都重用店家頁已經有的彙總:
// 個人數據頁(#/stats)的數字。輸入是 listRecords({ type: 'all' }) 的原始結果(場次未攤平):
// 記錄數算「場次」,杯數、平均分與店家則以每一杯為單位(豆源店家掛在杯上)。
function summarizePersonalStats(records) {
const sessions = records.filter(r => r._type === 'session').length;
const flat = flattenSessionCups(records);
const { counts, avgScore } = summarizeRecords(flat);
const shopCount = new Set(flat.map(r => r.shop_id).filter(Boolean)).size;
return {
shopCount,
cupping: counts.cupping,
tasting: counts.tasting,
sessions,
sessionCups: counts.sessionCup,
total: counts.cupping + counts.tasting + sessions,
avgScore,
};
}
先數場次,再用 flattenSessionCups 把場次攤成一杯一筆,交給既有的 summarizeRecords 算平均,店家用 Set 去重。
拿本機的假資料對一次:登記了 3 家店,其中一家沒有任何記錄,所以顯示 2。沖煮、品鑑加上杯測的 3 杯共 5 個評分對象,4 個有分,平均是 (86 + 84 + 88 + 83) / 4 = 85.25,顯示 85.3,沒評分的那杯不會拉低平均。
三個分頁
第二刀接在清單卡下面,加一張三個分頁的卡:
- 分數分布是 CSS 橫條,寬度 = 那一級的筆數 / 最多那一級的筆數。
- 每月平均是手寫的 inline SVG。
- 店家排行直接用店家列表卡片的
aggregateShopStats,兩邊的數字保證一樣。店家清單讀取失敗時,排行照樣出現,只是店名留空。
每月平均的折線,沒評分的月份用 M 重新起筆,線就自然斷開:
months.forEach((m, i) => {
if (m.avg === null) return;
path += `${i > 0 && months[i - 1].avg !== null ? 'L' : 'M'}${x(i)} ${y(m.avg)} `;
// ...
});

分頁的狀態只是 mountStatsCharts 裡的一個區域物件,監聽掛在容器上:
function mountStatsCharts(box, records) {
const flat = flattenSessionCups(records);
const charts = {
tab: 'dist',
mode: 'avg',
distribution: scoreDistribution(flat),
months: monthlyAverages(flat),
rankings: { avg: rankShops(flat, 'avg'), count: rankShops(flat, 'count') },
};
const draw = (focusSelector) => {
box.innerHTML = statsChartsMarkup(charts);
if (focusSelector) box.querySelector(focusSelector)?.focus();
};
// 監聽掛在容器上(事件委派),重畫內容不會疊加監聽。
box.addEventListener('click', (e) => {
const tab = e.target.closest('[data-stats-tab]');
if (tab) {
charts.tab = tab.dataset.statsTab;
draw(`[data-stats-tab="${charts.tab}"]`);
return;
}
// ...
});
// ...
}
進頁時所有分頁的資料一次算好,切分頁、切排序都只重畫這張卡,不重打 API。因為監聽在容器上,每次重畫 innerHTML 也不會疊監聽;離開再回來會重新 mount,自然回到預設。分頁也可以用左右方向鍵繞圈切換。
風味 treemap
第三刀不用圖表庫,就得自己寫 squarified treemap,大約 40 行的純函式 treemapLayout。做法是把色塊一塊一塊排成一列,直到再加一塊會讓最扁那塊的長寬比變差,就換到剩下的長方形繼續排:
// 一列沿短邊排開時,最扁那一塊的長寬比(越接近 1 越好)。
const worst = (row, side) => {
const sum = row.reduce((n, r) => n + r.area, 0);
const max = Math.max(...row.map(r => r.area));
const min = Math.min(...row.map(r => r.area));
return Math.max((side * side * max) / (sum * sum), (sum * sum) / (side * side * min));
};
while (rest.length) {
const side = Math.min(w, h);
let n = 1;
while (n < rest.length && worst(rest.slice(0, n + 1), side) <= worst(rest.slice(0, n), side)) n++;
const row = rest.slice(0, n);
rest = rest.slice(n);
// ... 把這一列排在短邊上,剩下的長方形繼續
}
計數直接重用店家頁「常見風味」的兩個函式:recordFlavorLeafIds 只留葉節點、decodeFlavorMeta 驗證 id,只多一步,從 id 裡 __l1-<slug> 那一段找出大類。所以兩邊的計數規則一定一致,測試也直接對照 summarizeRecords().topFlavors 的總數。
接上第二刀的分頁卡幾乎是填空:STATS_TABS 多一項、statsChartsMarkup 多一個分支、mountStatsCharts 進頁時多算一份資料。方向鍵繞圈本來就看 STATS_TABS.length,第四頁自動包含在內。
踩到的坑
讀取中換頁
#app 是所有頁面共用的容器。讀取還沒回來時使用者就換頁,晚回來的結果會把已經換掉的頁面蓋成統計頁。之前做存檔時就遇過「存檔中離開」這類 race,這次頁面只讀不寫,一樣會中。
照 viewSessionDetail 已有的寫法,讀取前記下網址,回來時不一樣就不畫:
async function viewStats(root) {
if (renderAccessGate(root)) return;
root.innerHTML = '<div class="empty-state"><i class="bi bi-hourglass-split"></i>讀取中…</div>';
// 讀取期間使用者可能已經換頁;#app 是共用的,晚回來的結果不能蓋掉新畫面。
const route = location.hash;
try {
const records = await api.listRecords({ type: 'all' });
if (location.hash !== route) return;
root.innerHTML = statsMarkup(summarizePersonalStats(records));
} catch (e) {
// ...
}
}
也寫了一條測試,並確認拿掉守衛時這條會失敗。
第二刀多讀一份店家清單,改成 Promise.all 之後,原本那條「讀取中換頁」測試的 stub 會被第二次呼叫蓋掉 resolve,要依路徑分開 stub。
UTC 的月份
計畫寫的是取日期字串的前 7 個字當月份。品鑑和杯測的日期是純日期沒問題,但沖煮(和沒填日期的品鑑)用的是 UTC 的 created_at,台灣時間每月 1 日早上 8 點前的記錄,會被算進上個月。所以時間戳改成裝置時區的年月:
// recordDateIso 的月份(YYYY-MM)。visit_date / session_date 是純日期字串,直接取前 7 字;
// fallback 的 created_at 是 UTC 時間戳,要換成裝置時區的月份,才和以 now 算出的「本月」一致。
function recordMonthKey(r) {
const iso = recordDateIso(r) || '';
if (iso.length <= 10) return iso.slice(0, 7);
const d = new Date(iso);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
修好之後又發現,新寫的測試在 UTC 的 CI 上測不出差別:切字串和換成本地月份剛好得到同一個答案。所以在測試裡把 TZ 固定成 Asia/Taipei,並確認還原成切字串時這條測試會失敗。
空白的風味分頁
測試全綠,正式環境卻永遠是空的
第三刀的第一版,所有測試都過、截圖也有資料,但放到正式環境,風味偏好會永遠是空的。
原因是 /api/records 為了讓列表輕量,只有帶 withEvaluations=1 才會回風味欄位(evaluations、observation),店家頁的常見風味就有帶,#/stats 沒有。而測試沒抓到,是因為:
- 單元測試的 stub 不看 query string,一律回完整的資料。
- 截圖是用靜態伺服器加攔截
/api/*回假資料,剛好也繞過了這個 bug。
回頭讀 Worker 的 SQL 才發現。修法除了帶上旗標,也把測試的 stub 改成跟 Worker 一樣,沒帶旗標就去掉風味欄位:
// 和 Worker 一樣:沒帶 withEvaluations=1 就不回 evaluations / observation(場次內嵌的杯也是)。
const strip = (r) => {
const rest = { ...r };
delete rest.evaluations;
delete rest.observation;
return rest;
};
const asWorker = (path, records) => (path.includes('withEvaluations=1') ? records
: records.map(r => (r.cups ? { ...strip(r), cups: r.cups.map(strip) } : strip(r))));
再確認拿掉旗標會讓 5 個測試失敗。有資料的截圖也改在 wrangler dev 加一個全新的本機 D1 上,用 API 灌假資料重拍。
店家頁的「常見風味」當初也是同一類問題,查詢沒有撈這兩個欄位,見 Day7。
文字分級
計畫的規則和驗收條件打架
treemap 的文字分三級:放得下兩行就寫「類別名」和「N 次 · P%」,只放得下名稱就只寫名稱,再小就沒字。計畫寫的是「寬 ≥ 56 就兩行」,但「堅果/巧克力類」7 個字 × 11px 大約 77px,寬 56 到 88 的色塊照字面做,名稱就會超出色塊,違反驗收條件「色塊內文字不超出色塊」。
所以兩行的門檻改成名稱和數字那行都放得下才算,再用 Playwright 量每段 <text> 的 bounding box 有沒有超出 <rect>,375px 和桌機都是 0 個。
截圖與假資料的小坑
- 本機第一次開
#/stats顯示「找不到頁面」:service worker 的 stale-while-revalidate 先給了舊的app.js,重新整理一次才拿到新版。 - 用 JS 觸發 click 後,焦點放回按鈕,截圖會出現 focus ring(真的滑鼠點不會),截圖前要先
blur()。 page.goto到同一個#/stats,瀏覽器只當成 hash 沒變、不會重新載入,換了假資料畫面也不變,中間要先goto('about:blank')。PUT /api/sessions/:id回 204 沒有 body,灌假資料的腳本不能一律res.json()。.empty-state .bi會把按鈕裡的圖示也放大成 2rem 的區塊,所以空狀態和錯誤狀態的按鈕不放圖示。- 第二刀的方向鍵測試寫死「分數分布按兩次左 → 店家排行」,多了第四個分頁之後,繞圈的落點變成「風味偏好」,要跟著改。
小結
#/stats 現在有一張清單卡和四個分頁的圖表卡,沒有新的相依,第三刀的 app.js 只多了約 5 KB,測試從 427 個、447 個到 460 個。
希望能感覺到不斷前進的腳步!
