今天要解的問題

Day12 把 coffee-review 搬到 Cloudflare 之後,issue 清單還停在 Supabase 時代。先清一輪:多人模式、GitHub OAuth 這幾張的前提已經被 Access 取代,照片上傳原本規劃走 Supabase Storage,搬到 Cloudflare 之後也不成立了,Google Places、單元測試早就做完,一共關掉 5 張。

剩下的挑了 #21 和 #91 一起做,兩張講的是同一件事:記錄列表只有一種順序(建立時間新到舊),想看「平均分數最低的店家」、「單品和配方豆各喝了什麼」都得自己數。

想法與取捨

日期照哪個欄位排。 列表原本照 created_at,但卡片上顯示的是拜訪日(品鑑)或場次日(杯測)。選了照顯示的日期排,代價是補登的舊品鑑不再浮在最上面,而是回到它實際拜訪的位置。

杯測場次算一筆還是好幾筆。 一個場次有很多杯,店家和豆子類型記在杯上,場次本身沒有。分組時沿用店家頁的做法,把場次攤平成一杯一張卡,各杯落到自己的組;不分組時維持一場一張卡,排分數用最高分那杯,跟卡片上顯示的一致。

一場杯測,在列表上算幾筆

缺值放哪。 未評分、沒店家的記錄,不論升降冪都排最後,「未指定」組也固定在最後。組與組之間的順序跟著排序走:照分數排看組平均,照店家排看組名,照日期排看各組排第一的那筆。

另外列表 API 原本沒回傳 bean_type,Worker 的查詢補上這一欄;欄位本來就在,不用 migration。

實作

排序和分組寫進 hash query,驗證時用的是 Object.hasOwn:

sort: Object.hasOwn(SORT_OPTIONS, query.sort) ? query.sort : 'date-desc',

一開始寫成 query.sort in SORT_OPTIONS,?sort=toString 會因為原型鏈而通過,所以改掉,測試也把這個值寫進去。

踩到的坑

開 PR 之後跑了一次 code review,抓到兩個 bug。

第一個:分組時攤平場次,還沒有杯的場次攤出來是零筆,整個從列表消失;如果它是唯一一筆,畫面還會顯示「還沒有記錄」。修法是空場次保留原樣,落在「未指定」組:

function rowsForGrouping(rows) {
    return rows.flatMap(r => (r._type === 'session' && !(r.cups || []).length ? [r] : flattenSessionCups([r])));
}

第二個:改排序會重打一次 /api/records,而每次請求在發出時就把 sort / group 讀死。連續改兩次,慢的舊請求晚回來,就會用舊設定蓋掉新畫面,下拉選單和網址卻顯示新的。排序和分組只是重排,根本不需要重抓,所以改成快取最近一次的列表、直接重繪:

el.addEventListener('change', () => {
    state.listFilter[key] = el.value;
    syncFilterToHash();
    renderCards();   // 用快取的 listRows 重排,不打 API
});

renderCards 讀的是當下的 sort / group,請求途中改了選項,資料回來時也會套新的。兩個 bug 各補了一個測試。

小結

列表現在能照日期、分數、店家名稱排序,並依店家或單品 / 配方分組,組標題帶筆數和平均分。

依店家分組、照分數高到低:同一場「週末杯測」的三杯分散到兩個店家的組裡(示意資料)

#91 提到的「最多紀錄的店家」還沒有直接的排序,只能看組標題的筆數。

明天還沒想好要先動哪一塊;候選是 #97(杯測時固定樣本編號列)。


本文同步發表於 kiwi-walk.com:https://kiwi-walk.com/blogs/engineer/ironman-2026-day14-records-sort-group/