今天要解的問題
coffee-review 繼續進行修改,原本只有『在家杯測一支豆子』與『在咖啡廳喝一杯』兩種情境,我把前面那個叫「杯測」的類型,嚴格說不是杯測——它一筆記錄配一支豆,反而是沖煮計畫隱藏在裡面,不是真正的杯測,所以這次要真的做一個出來。
真正的杯測:桌上同時擺好幾支豆,每一杯用編號或 A、B、C 標起來,一輪一輪比過去,最後排名次。這件事現有的表單做不到——你只能開四筆獨立記錄,然後自己在腦袋裡把它們排在一起。
所以要加入第三種記錄類型,而且會把「杯測」這個名字回到真正的功能,原本的名字則改掉。

想法與取捨
舊名字要讓出來嗎
規劃時的預設方向是新類型另外取名,「杯測會」、「多杯杯測」都在選項裡,舊的那個維持叫杯測。但看到選項我反而徹底我的想法:舊名字本來就用錯了,舊的名稱非常需要換掉。
舊類型改叫「沖煮」,它的表單本來就有沖煮參數,名字反而更準。之後我應該還會針對沖煮參數 UI 重新調整,現在填寫起來有點複雜。
代價是資料庫裡的 cupping_records 現在裝的是「沖煮」,程式裡的 key 也還是 cupping。名不符實的確有點刺眼,但改 key 要連表名、網址、既有的草稿 key 一起搬,等於為了命名整齊做一次資料遷移。只改顯示名稱、不動 key 與表名,舊資料一筆都不用動,這個交換划算。CLAUDE.md 裡補一行對照表讓下一個人(或下一次的我)不會被騙到就好。
編號預設手動
編號方式 AI 本來只列了 1、2、3 和 A、B、C 兩種自動產生。但我額外加上新的編號方式,手動輸入且為預設值。
預設手動的理由是:杯測時桌上的標籤是實體的,會是三碼亂數的樣品編號,像是 #317、#824 。設定為預設值是因為自己比較常用這種方式。
這個調整,新增了兩個行為:
- 切換編號方式只補空白的編號,不覆蓋已經填過的。
- 刪掉中間一杯不重編後面的編號。桌上的 C 不會因為你在 app 裡刪掉 B 就變成 B。
盲測揭曉流程砍掉
真正的杯測通常是盲的:先評分,最後才揭曉每個編號是哪支豆。規劃時有一版準備了「揭曉」步驟,讓豆子資訊評完才填。但後來我想要隨時可以填,因為這樣比較方便。
不需要 revealed 欄位,不需要「揭曉前隱藏豆名」的渲染分支,也不需要決定揭曉後能不能改回去。豆子資訊全部選填、隨時可以填。
未評分要是一級狀態
現有的 CoE 分數選擇器是兩段式的:先點獎牌等級,再點該區間的分數,預設值是 82 分(銅牌)。一支豆一筆記錄時這個預設沒什麼問題。
但一場八杯的時候,預設 82 代表還沒評的杯子會混進排名,而且看起來跟真的給了 82 分一模一樣。所以這裡會是沒點過分數就算未評分。
「未評分」會視作真的狀態:coe_total 存 null,分頁和排名顯示「—」,不列入名次。後來再補一顆「清除分數」,手滑點錯可以改回未評分。

資料放兩張表,不是一個 jsonb 陣列
最省事的做法是一張 cupping_sessions,杯子塞進 cups jsonb[]:存檔一次寫入、沒有孤兒列、跟現有的 evaluations、observation 一樣都是 jsonb。
卡在一個既有規則上:每一杯可以連到「豆源店家」,而這個專案裡店家與記錄之間的外鍵一律是 ON DELETE RESTRICT,理由寫在 CLAUDE.md 裡——店家是共享的 registry,刪店家不得改動或摧毀任何人的記錄。jsonb 裡放 uuid 沒有外鍵,店家被刪掉後那些 id 就變成孤兒,前端只會顯示「(已刪除店家)」,資料庫層完全擋不住。
為了保住這道防線,杯子獨立成一張 cupping_session_cups。
一組評分元件輪流裝 N 杯
前端的限制比資料庫更硬。這個 app 的評分區塊——獎牌列、分數列、八項感官 accordion、兩個風味輪——全部是單例:DOM id 是固定的 medalRow、scoreChipRow、flavor_score,狀態放在模組層的 coeState,而 initEvaluationAccordion() 每呼叫一次就往同一個容器再掛一組 listener。
要在一頁上放 N 杯的評分元件,等於把這些 id 全部加前綴、把 coeState 改成每杯一份,再處理風味輪存下來的 id 本身就帶容器前綴的問題。粗估要動二十幾個函式,而且畫面會變成一頁上千顆按鈕。
改成同一時間 DOM 裡只有一杯:上面一排分頁切換,切走的那杯序列化進記憶體,切回來再寫回 DOM。評分元件一個字都不用改。

實作
杯只能掛在自己的場次下
create table coffee.cupping_session_cups (
id uuid primary key default gen_random_uuid(),
session_id uuid not null,
-- not null:composite FK 是 MATCH SIMPLE,任一欄 null 就不檢查
user_id uuid not null references auth.users(id),
position int not null,
code text not null check (btrim(code) <> ''),
-- restrict:店家與記錄是兩張獨立的表,刪店家不得改動或摧毀任何人的記錄
shop_id uuid references coffee.shops(id) on delete restrict,
-- ...
foreign key (session_id, user_id)
references coffee.cupping_sessions(id, user_id) on delete cascade,
unique (session_id, code) deferrable initially immediate
);
兩個地方值得說明。
第一,外鍵是 (session_id, user_id) 複合鍵,指向場次的 unique (id, user_id)。如果只 FK session_id,外鍵檢查不受 RLS 約束:知道別人場次的 uuid 就能把自己的杯掛上去,policy 的 with check (user_id = auth.uid()) 還是會過,因為那一列的 owner 的確是你。更糟的是對方刪場次時,cascade 會連你的列一起刪。複合鍵逼著杯的 owner 等於場次的 owner,兩件事都不可能。
第二,unique (session_id, code) 是 deferrable。非 deferrable 的 unique 在 Postgres 是逐列檢查,所以同一個 upsert 裡把 A 和 B 互換會在寫到第一列時就撞上;deferrable initially immediate 改成 statement 結束才檢查,互換和「把剛釋出的編號給新杯」都能在一次寫入裡完成。
每一杯的 key 集合必須一模一樣
分頁切換的核心只有這幾行:
function syncActiveCup() {
const f = state.currentForm;
const cup = f.cups[f.activeIndex];
if (!cup) return; // 表單還在載入,杯還沒放進來
f.cups[f.activeIndex] = { id: cup.id, ...readCupFromForm() };
}
readCupFromForm() 把整張表單讀成一個物件,加上原本的 id 蓋回陣列。切走、存檔、寫草稿、重畫排名之前都先呼叫它,記憶體裡就一定是最新的。
寫回去的那一半(writeCupToForm)反而是比較容易寫錯的:它必須重設每一個元件,而不是只填有值的欄位。少重設一項,上一杯的風味輪選取、chip、滑桿就會留在下一杯身上,而且使用者不會發現——直到存檔。
{ id, ...readCupFromForm() } 這個寫法還有一個目的:讓每一杯的 key 集合完全一致。存檔時所有杯是一次 bulk upsert,supabase-js 的 defaultToNull 預設會把某些列缺的欄位補成 null;key 不一致就會莫名其妙清掉資料。載入既有場次時我也是逐杯 write → sync 一遍,順便把 jsonb 的 key 順序正規化,不然光是切個分頁都會被草稿判定成「有修改」。
存檔的每一步都可以重送
async saveSession(id, session, cups, { isNew = false } = {}) {
// ...
const s = await sb.from(SUPABASE_CONFIG.sessionsTable)
.upsert(stampUserId({ ...session, id }), { onConflict: 'id' });
if (s.error) throw s.error;
// 先刪「表單裡已經沒有」的杯(以伺服器現況比對,不靠載入時的清單)
const d = await sb.from(SUPABASE_CONFIG.sessionCupsTable)
.delete().eq('session_id', id).not('id', 'in', `(${cups.map(c => c.id).join(',')})`);
if (d.error) throw d.error;
const rows = cups.map((c, i) => stampUserId({ ...c, session_id: id, position: i }));
const c = await sb.from(SUPABASE_CONFIG.sessionCupsTable).upsert(rows, { onConflict: 'id' });
if (c.error) {
if (isNew) await sb.from(SUPABASE_CONFIG.sessionsTable).delete().eq('id', id);
throw c.error;
}
}
沒有交易可用(PostgREST 一個請求一個交易,跨三個請求就沒得包),所以改成讓每一步都能重送:場次和杯的 id 都由前端 crypto.randomUUID() 產生,三步全是 upsert / delete。存到一半斷線,按下去再存一次就會收斂到畫面上的狀態。
刪除那一步一開始是拿「載入時的杯 id 清單」減掉「現在的杯」,看起來很自然。改成現在這樣是因為一個情境:上次存檔其實寫進去了、只是回應掉了,你以為沒存,回頭把那杯刪掉再存一次——用載入時的清單比對,那杯永遠不在清單裡,就會一直留在資料庫。改成「刪掉這個場次裡 id 不在表單清單內的杯」之後,存檔的語意變成「讓伺服器等於畫面」,重試幾次都一樣。
讓單例的分數元件接受 null
「未評分」要能存進去,coeState.coeTotal 就得能是 null,而這是沖煮 / 品鑑表單從來不會出現的值。三個守門加在共用的元件上:
function selectScore(score) {
// 未評分的杯直接點預設(銅)列的分數:徽章跟著分數走,coe_tier_id 才不會存成 null。
if (coeState.selectedTierId == null) {
coeState.selectedTierId = tierFromScore(score).id;
renderTierMedals();
}
coeState.coeTotal = score;
renderScoreChips(tierById(coeState.selectedTierId));
refreshTotalDisplay();
}
另外兩個是:點獎牌時如果目前是 null,只切換分數列、不直接給分(原本的行為是跳到該區間的最低分);refreshTotalDisplay 遇到 null 顯示「—」和「未評分」,否則 null.toFixed(1) 會直接炸。三個守門在舊表單裡都不會被觸發,因為那裡的分數永遠是數字。
踩到的坑
這次真正花時間的不是寫,是審:計畫寫完先讓一個 agent 專門去挑漏洞,實作完再用四個角度(表單狀態機、舊流程回歸、資料與 SQL、畫面與路由)各看一遍 diff,每個發現丟給另一個 agent 反向驗證,只留驗證過會重現的。
一、差一點踩到:表單裡沒寫 type="button" 的按鈕會送出整張表單。
這個是計畫階段就被攔下來的——當時的計畫只寫了分頁按鈕要 type="button",其他新按鈕沒提。HTML 的 <button> 預設是 type="submit",而「+ 新增一杯」、總覽的每一列、編號方式的 chip、「移除這杯」全都在 <form> 裡面,少寫一個屬性,點下去就會觸發 submit——也就是直接存檔並跳頁。舊的 #tpl-form 每顆按鈕都明確寫了 type,照抄結構時很容易只抄到版面。
所以程式碼裡沒真的出現這個 bug,但測試留著比較安心:
it('樣板裡除了 #f-save 以外的 button 都是 type="button"', () => {
const nonButton = $$('.session-form button')
.filter(b => b.id !== 'f-save' && b.getAttribute('type') !== 'button')
.map(b => b.outerHTML.slice(0, 80));
expect(nonButton).toEqual([]);
});
二、真的踩到:還在讀取的時候按儲存,會用空白蓋掉整場。
這個是實作完重讀一次 diff 才發現的。原本的順序跟舊表單一樣:先掛表單、再去抓資料、回來才填值。問題是掛上去的那一刻表單就能按了,而這時 cups 是空陣列、欄位全空。編號驗證看到空陣列判定沒問題,於是一路存下去,把伺服器上的標題、日期、筆記全部覆蓋成 null,杯子一杯不剩。驗證的 agent 用 jsdom 重現了一次,我才相信這條路真的走得到。
改法是把順序倒過來:先讀完資料,再掛表單。讀取中畫面只有「讀取中…」,沒有可以按的東西,整類問題就消失了。順帶把另一個發現(確認「移除這杯」時如果杯序剛好變了會刪錯杯)也一起收掉,改成用 id 找回要刪的那一杯,而不是重讀當時的索引。
另外兩個發現是草稿還原會把上次已經存進去的杯搬到新場次(改成還原時換新 id),以及上面講的刪除比對方式。收尾前再整份掃一次,又撈到未評分的杯在詳細頁還是會顯示「預估總分 76.0 [ 瑕疵 ]」——八項沒動過時預設都是 5,加起來剛好 76,徽章跟標頭的「未評分」互相打架。那個改成不掛徽章。
小結
這次改動落在 16 個檔案、+2374 / −145,測試從 227 條長到 308 條,lint 與測試都綠。Supabase 的 schema 也套用完成:先查權限確認 anon 對新表沒有任何 privilege,再在一個最後會 rollback 的交易裡試了兩件事——同一個 upsert 互換編號會成功、別人的 user_id 掛不進這個場次(23503)。
這次檢查後留著沒修的還有六項,都不影響正確性:載入場次時逐杯寫進 DOM 再讀回來(10 杯就是 10 次全元件重寫)、每一次輸入都重畫分頁列與排名、三份重複的錯誤卡片 markup 等等。
順手開了三個獨立任務處理既有問題:按草稿「還原」之後沒再編輯就離開會把草稿清掉、店家頁的「常見風味」永遠是空的(列表查詢根本沒撈 evaluations)、README 全新安裝的 SQL 少了 defects_tags 欄位。這三個都已經在各自的 session 跑,明天應該就從它們的結果接下去。
本文同步發表於 kiwi-walk.com:https://kiwi-walk.com/blogs/engineer/ironman-2026-day06-real-cupping-session/
