今天要解的問題
前三天都在 Art-Tracking,今天切回 Coffee Review。Day1 盤點時提過,這個 app 把「在家杯測一支豆子」和「在咖啡廳喝一杯」收進同一套表單。今天動的是品鑑表單最上面跟「店家」有關的兩塊,兩個問題都出在同一個情境:人坐在店裡,手上開著品鑑表單。
店家筆記沒人填
同一家店的氛圍、服務等印象另外記在「我的店家筆記」——那些欄位存在 coffee.shop_notes,每人每店一筆,編輯器只掛在店家頁。要記店家體驗得先把記錄存掉、再跳去店家頁,實際結果就是沒填過的店永遠不會被填。要做的是把這張筆記搬進品鑑表單:沒填過的店自動展開讓人當場補,填過的收合起來,只提示以前記了什麼。

選店家要先猜「建過了沒」
店家現在一律以 Google Places 為身分來源,google_place_id 在 DB 是 unique。但 UI 還分成兩套:「選擇店家」只搜本地快取,「新增店家」只搜 Google。使用者得自己先判斷「這家店我建過了嗎」,猜錯就撞牆——重複偵測完全靠那個 unique constraint,createShop 拋 23505 時只丟一句「這家店已經在清單裡了」,沒有任何路徑通往那家已經存在的店。
而本地搜尋只做名稱/地址的 substring 比對:打「Do did done」找不到清單裡的「DoDidDone Coffee Research」,於是走新增、撞 unique、死路。要做的是把兩套合併成一個輸入框。
想法與取捨
店家筆記
筆記要不要有自己的儲存鍵? 店家頁那張卡是獨立的編輯/儲存流程,照搬最省事。但表單底下已經有一顆「儲存」,再多一顆的結果是使用者填完筆記按了下面那顆,以為都存了。決定跟著記錄一起送,代價是兩張表兩次寫入,得處理「記錄成功、筆記失敗」。
dirty 要跟誰比? 直覺是跟 DB 讀回來的筆記比。但 initTagSections 對新筆記會預設勾「義式咖啡」,跟 DB 的空值比永遠是 dirty——使用者只是看了一眼沒動,存記錄時就會寫出一筆只有預設值的筆記,下次進來被判成「已填寫」而收合,正好把這個需求打掉。改成跟「掛載完成當下」的編輯器狀態比:沒互動就是沒改。
「強制展開」要不要做成必填? 沒做。擋存檔會卡住只想快速記一杯的情境,編輯舊記錄時也會被逼著填。目前只是自動展開。
店家搜尋
什麼時候去問 Google? 三個做法:每次打字都同時查本地和 Places、本地 0 筆才自動查、本地 0 筆時顯示一顆按鈕手動觸發。選了中間那個。第一個最省事,但每個按鍵都是一次計費的 Places 請求;第三個最省錢,可是這個 app 的情境是人坐在店裡邊喝邊記,多一次點擊就多一次分心。折衷是 0 筆時自動查、debounce 400ms,而且同一個關鍵字不重問。
要不要加一個「用 place_id 查單筆店家」的 api 方法? 撞到 23505 之後得知道那家既有店家的 id 才能連過去,直覺是加 api.getShopByPlaceId。沒加。refreshShopsCache() 在這條路徑上本來就一定要跑(選完之後表單的標籤要重繪),而 shops 是所有登入者都讀得到的共享 registry,衝突的那列必然在重抓的結果裡。多一個方法只是重新拿一次已經拿到的資料。
實作
筆記掛進表單

function mountFormShopNote(note) {
const host = document.getElementById('form-shop-note-sections');
if (!host) return;
const freshHost = host.cloneNode(false);
host.replaceWith(freshHost); // 理由見「踩到的坑」
initTagSections(freshHost);
applyShopNoteToEditor(note);
formShopNote.baseline = JSON.stringify(buildShopNotePayload());
setFormShopNoteExpanded(!note);
}
note 是 null 就代表這家店沒填過,直接展開。baseline 拍在「既有值套用完」之後,這就是上面那個 dirty 取捨的落點。
function formShopNoteIsDirty() {
if (formShopNote.baseline === null) return false;
return JSON.stringify(buildShopNotePayload()) !== formShopNote.baseline;
}
收合本身沒寫 JS:初始狀態直接給 .collapse.show 這個 class,之後的點擊交還給 Bootstrap。
兩層查找的店家選取器

function scheduleGoogle(query, localCount) {
clearTimeout(googleTimer);
if (!query || localCount > 0) { hideGoogle(); return; }
if (query === lastGoogleQuery) return; // 結果還在畫面上,別重問
googleTimer = setTimeout(() => {
if (!document.body.contains(backdrop)) return;
runGoogleSearch(query);
}, 400);
}
本地過濾完才呼叫它,localCount 就是本地命中數——兩層查找的閘門就是 localCount > 0 那一行。裡面再檢查一次 document.body.contains(backdrop),是因為 modal 可能在這 400ms 內被關掉。
const known = state.shops.find(s => s.google_place_id === place.id);
if (known) { pick(known.id); return; }
try {
const saved = await api.createShop({ /* ...name / location / google_place_id / lat / lng... */ });
await refreshShopsCache();
pick(saved.id, saved);
} catch (err) {
if (err.code === '23505') {
await refreshShopsCache();
const found = state.shops.find(s => s.google_place_id === place.id);
if (found) { pick(found.id); return; }
}
showErrorToast('新增店家失敗:' + (err.message || err));
}
點下 Google 結果時先比對 google_place_id,命中就直接連結既有店家。真的還是撞了 23505(快取太舊、或別人剛建),重抓快取再連過去,只有連重抓都找不到才報錯。
踩到的坑
三個都不是 crash,沒有錯誤訊息,都是 code review 指出後才回頭重現的。
listener 疊兩層,chip 點了等於沒點
initTagSections 每次呼叫都會對容器 addEventListener('click', ...)。店家頁每次進編輯都重建整個容器,所以不會累積;但表單的容器是樣板裡的固定節點,換店家就會重掛。第二次起,一次點擊被兩個 listener 處理,chip 的 setSelected(chip, !selected) 跑兩次互相抵銷——點了等於沒點,而且永遠不會 dirty,整張筆記等於存不進去。單元測試沒抓到,是因為它只 mount 一次、只點一次。在 jsdom 裡對同一個節點連呼叫兩次 initTagSections 就重現了,修法就是上面的 cloneNode(false) + replaceWith,再補一個「重掛之後 chip 仍然點得動」的測試。
失敗的 toast 被成功訊息蓋掉
toast 只有一個 #toastMsg 節點。筆記寫失敗跳的錯誤訊息,馬上被 submitForm 的「✓ 已儲存」蓋掉,新增流程還會立刻換頁——失敗看起來像成功。改成只回傳成敗,由 submitForm 決定最後顯示哪一句。
為了修一段到不了的 fallback,把能用的路徑弄壞
搬選取器的程式碼時看到原本的座標取值:
lat: place.location?.lat?.() ?? place.location?.lat ?? null,
判定它有 bug——?.() 只在 null/undefined 時短路,lat 要是數字就會直接丟 TypeError,後面那段 fallback 永遠到不了。於是「修」成一個 helper,coord(place.location?.lat),裡面判斷 typeof v === 'function' ? v() : v。
這一改把真正能用的路徑弄壞了。Places 回的 location 是 LatLng,lat() 讀 this;把方法從物件上拆下來單獨呼叫,this 是 undefined,strict mode 直接炸。而它落在 createShop 的引數裡、包在 try 之內,畫面上只冒出一句「新增店家失敗」——等於從 Google 新增店家這條主線完全不能用。
自己補的測試沒抓到,因為 stub 寫的是 { lat: () => 25.03 },arrow function 不看 this,剛好把 bug 蓋掉。修法是連著物件一起呼叫:
const coord = (loc, key) => (typeof loc?.[key] === 'function' ? loc[key]() : loc?.[key] ?? null);
測試也改用 class,讓 lat() 真的去讀 this._lat,才測得出來。
小結
品鑑表單現在會就地載入該店的筆記,沒填過展開、填過收合;店家選取是一個輸入框,本地找不到自動接 Google,已經在清單裡的直接連結過去。lint 與測試都綠,兩個 PR 都合併進 main 了。
留著的缺口:店家筆記不在表單的草稿 autosave 裡,未存檔就離開會遺失;「強制展開」要不要升級成必填也還沒決定;Google 只在本地 0 筆時才查,所以一個部分命中但不對的本地結果(搜「興波」命中無關的「興波咖啡分店」)會整個蓋掉 Google 區塊。明天先看要不要補一個「還是找不到?改查 Google 地圖」的明確入口。
本文同步發表於 kiwi-walk.com:https://kiwi-walk.com/blogs/engineer/ironman-2026-day05-shop-in-tasting-form/
