Day14 留的候選是 #97。杯測表單原本一頁通包,從上到下是:場次資訊、總覽與排名、杯號分頁與編號、這一杯的豆子資訊、CoE 總分、感官評估。
實際杯測時只做一件事:一杯一杯打分數。但每換一杯,就得往上滑回杯號分頁,中間卡著排名和豆子欄位,滑過頭再滑回來。豆子資訊在盲測時根本還不能填,排名要等全部評完才有意義。
#97 的構想是拆成三個階段,每個階段只專注一件事:
| 階段 | 要做的事 |
|---|---|
| ① 設定 | 日期、杯數、每杯的編號 |
| ② 評分 | 頂端只有杯號,點了切杯;只顯示評分卡 |
| ③ 揭曉 | 看排名、填豆子資訊,任何欄位都能改 |
方案先在 Claude 的 plan mode 裡談。它每一輪先列出假設,需要時畫示意圖,我看了不對就推回去。這次被推翻的次數比寫程式還多,下面照順序記。
「樣品的類型」是哪個欄位。 需求寫「第一階段填編號與樣品的類型」,但 schema 裡沒有這個欄位。最接近的是既有的 bean_type(單品 / 配方),決定沿用它、改成選填,不另開欄位。
階段要不要存進 DB。 三個選項:只在 UI 流程上切、存 DB 並永久鎖定、存 DB 但可以手動解鎖。選了存 DB 加手動解鎖,新增 cupping_sessions.stage。存檔時機也選了「每次換階段都存」,階段之間靠既有的 localStorage 草稿保護,中途離開可以從詳細頁的「繼續杯測」回到原階段。
那顆解鎖按鈕。 照上面的決定,第三階段會鎖分數,要改得先按「解鎖評分」。示意圖畫出來,這顆按鈕放在頂端非常突兀。回頭想,真正的需求是「單杯可以修改」,鎖定只是為了防誤觸。所以整個鎖定概念拿掉:第三階段每杯的分數預設收合,點開就能改,收合本身就擋掉誤觸。stage 欄位留著,但只用來決定打開時在哪個階段,Worker 不依它擋寫入。

同一輪也拿掉「② 回到 ①」的返回鍵。第三階段什麼都能改,包含場次和編號,沒有理由往回走,於是三個階段變成只往前。
按鈕放哪。 第一版把「下一步 / 返回」放在頁面底部。但 app 底部已經有固定的 tabbar,兩排按鈕疊在一起。改成頂端的固定列:左邊是階段名稱,右邊是前進按鈕;第二階段再多一排杯號。
第三階段長什麼樣。 第一版在頂端也放杯號分頁,下面接排名表格。被指出第三階段是「通盤檢視」,不該有單杯的頂端分頁,排名應該在最上面。改成依分數排序的杯卡:名次、編號、分數徽章、豆名,點卡片就在下方展開這杯的編輯區。又因為徽章寬度隨分數字數變動,豆名對不齊,最後把每張卡片的欄寬固定下來。
中間還畫過一版前三名頒獎台,看完就撤回,回到杯卡。
編號怎麼輸入。 杯測編號多半是三位數,原本要一杯一杯按「新增」再手打。有考慮過滾輪拉數字,但三位數要三個滾輪,六杯就是 18 次拖曳,手指粗也容易拉過頭。改成選杯數直接長出格子,數字鍵盤打滿 3 碼自動跳下一格,六杯可以一口氣打完。另外加了「隨機產生」,規則是三個數字不重複、相鄰兩位不連續(123、457、870 都不行),杯上的標籤比較不會看錯。
三位數要不要新增 code_style。 DB 的 check constraint 只允許 manual / number / letter,SQLite 要改 check 就得重建表。所以三位數存成 manual,載入時看編號是否全是三位數來還原成「三位數」。
同一天多場會不會蓋掉。 場次的主鍵是前端產生的 UUID,沒有任何東西以日期為鍵,所以不會。但沒取名的場次在清單上全叫「(未命名杯測)」,分不出來,改成用建立時間當名字,例如「杯測 18:13」。
資料庫只加一欄,舊場次都是一次填完的,default 成 reveal 就直接打開在揭曉頁:
alter table cupping_sessions
add column stage text not null default 'reveal' check (stage in ('setup', 'scoring', 'reveal'));
存檔時把 stage 往前推一格,再導回編輯頁重新載入。navigate 遇到同一個 hash 會自己呼叫 renderRoute(),所以從 ② 存到 ③ 時網址不變也會重畫:
await api.saveSession(form.id, { ...buildSessionPayload(), stage: SESSION_STAGES[stage].next }, form.cups);
// ...
navigate(stage === 'reveal' ? `/session/${form.id}` : `/session/${form.id}/edit`);
表單有個既有的限制:DOM 裡同一時間只有一杯的評分元件,因為 accordion 用全域 id,重複初始化會疊 listener。第三階段要在點開的杯卡下面放編輯區,做法是把整組元件搬過去,不複製:
function renderRevealCards() {
// ...
const editor = document.getElementById('cup-editor');
// 先把元件搬回家,重畫卡片才不會把它們一起清掉。
document.getElementById('cup-editor-home').appendChild(editor);
// ...
if (f.revealOpenId) list.querySelector('.reveal-cup.is-open .reveal-cup-slot')?.appendChild(editor);
}
appendChild 移動節點時 listener 跟著走,id 也不會重複。代價是重畫卡片前一定要先搬回家,否則 innerHTML 會連元件一起清掉。輸入時也只更新卡片標頭的文字,不整個重畫,不然正在打字的欄位會失焦。

卡片依分數排序,但展開一張來改分數時,如果跟著重排,卡片會在手指底下跳走。所以展開期間凍結順序,收合才重新排:
let order = ranked.map(r => r.cup.id);
// 有卡片展開時凍結順序:改分數時卡片不會在手指底下跳走,收合才重新排序。
if (f.revealOpenId && f.revealOrder) {
const kept = f.revealOrder.filter(id => ranks.has(id));
order = [...kept, ...order.filter(id => !kept.includes(id))];
}
f.revealOrder = order;
隨機編號的規則寫成一個純函式,測試跑 1000 次確認產出都合格:
function isAcceptableRandomCode(code) {
if (!/^\d{3}$/.test(code)) return false;
const [a, b, c] = [...code].map(Number);
if (a === b || b === c || a === c) return false;
return Math.abs(a - b) !== 1 && Math.abs(b - c) !== 1;
}
還有一個不能破的約束:存檔是整批刪掉再整批插入,每杯的 key 集合必須一致。以前編號從元件讀,現在改成由編號格子直接寫進每杯的資料;新增的杯則逐杯寫進元件再讀回來,補齊所有 key。
截圖上沒亮的選項。 本機用 wrangler dev 開手機寬度實測。第一張截圖上選了 6 杯,「6」卻沒亮。先懷疑 class 沒加上,查 className 有 selected、computed 背景色也是對的,原來是 0.12 秒的 transition 被截圖截在中間。
看不到的新 CSS。 接著改 CSS 讓六個杯號擠進一列,重新整理兩次,寬度都沒變。getComputedStyle 顯示 min-width 還是舊的 54.4px,再用 fetch('/styles.css', { cache: 'no-store' }) 確認伺服器上已經是新檔。問題在 service worker:app shell 走 stale-while-revalidate,而 VERSION 在改這段 CSS 之前就升過了,快取名稱沒變。清掉這個 origin 的 caches、unregister 之後才看到新樣式。正式環境不受影響,這個版本號還沒部署過。
替換整段時刪掉還在用的函式。 表單那一大段是整塊替換的。接著要改詳細頁排名的 renderCupRanking 時,腳本裡的 assert 找不到原本的程式碼,才發現它原本放在被換掉的那段裡,而詳細頁還在呼叫它。從 git 撈回來,順手拿掉表單專用、已經沒人用的參數。
兩次 code review 抓到的。 開 #110 前跑了一次 review:「刪除整場」只放在第三階段,評到一半想放棄的場次,得先按「完成評分」才刪得掉。改成二、三階段都顯示。
接著的 #111 把編號方式與杯數改成下拉選單,review 又抓到一個非同步的問題。減少杯數會先跳確認框,handler 等完之後重畫選單:
document.getElementById('f-cup-count').addEventListener('change', async e => {
const current = state.currentForm;
await setCupCount(Number(e.target.value));
// 減少杯數時按了取消:選單要回到實際的杯數(確認框開著時已換頁就不動)
if (state.currentForm === current) renderCupCountSelect();
});
原本的判斷只是 if (state.currentForm)。確認框開著時按上一頁換到沖煮表單,state.currentForm 仍然有值,但新頁面沒有這個選單,就丟出 TypeError: Cannot set properties of null (setting 'innerHTML')。補了一個測試重現它:先把修正 stash 掉跑一次,確認測試會失敗,再放回修正確認會過。
#110 把杯測拆成三個階段上線,#111 接著把編號方式、杯數改成下拉選單。這次的體會有四點:
真正操作過,才看得到流程哪裡不對。 一頁通包的表單,寫的時候不覺得有問題。是實際一杯一杯打分數時,才發現每換一杯都得滑回頂端,豆子欄位和排名還卡在中間。流程合不合用,要用過才知道。
每個階段、每個畫面只做一件事。 不要想把所有東西都合在同一頁,能選的東西越多,就越難操作。這次是把事情排成一棵樹:先分三個階段,評分階段再分成一杯一杯,揭曉頁每杯的細節預設收合,要改才點開。每一層只看得到當下需要的東西。
在彈性和固定之間找平衡。 我杯測幾乎都用三碼編號,所以編號方式就做成下拉選單、預設三位數,格子打滿三碼自動跳下一格。遇到不一樣的情況,再回去改選項就好,不必為了少數情況把最常走的路變複雜。
杯測結果也要簡化。 揭曉頁從排名表格改成依分數排序的杯卡,一眼就看得到名次。那顆解鎖按鈕是我一開始自己想加的,覺得鎖住分數比較保險,但畫面一出來就發現它很突兀。我真正需要的只是「單杯可以改、不要誤觸」,收合就做到了。
#97 提到的「樣本連結到店家的豆子」還欠著,列表上也還看不出哪些場次在進行中。明天還沒想好要先動哪一塊。
本文同步發表於 kiwi-walk.com:https://kiwi-walk.com/blogs/engineer/ironman-2026-day15-cupping-three-stages/