今天要解的問題
Day21 處理了手機上固定列太多的問題,也在地點總表的「吃」底下加了子分類。這次一打開手機,我就發現一件更根本的事:子分類按鈕做在「行程紀錄」的地點總表裡,但我平常看的是「行前指南」。
我對 Claude 說:「我沒有看到 sub 的選項。」它去查了之後回我:店家資料其實有兩份——外層頁面的地點總表,和內嵌行前指南自己的地區清單。指南是 Day19 原樣嵌進來的,之後新加的店都寫進了總表,指南裡一家都沒有。
這讓我重新想了一次這個頁面要幫我做什麼。旅途中我需要的其實是這四件事:
- 少跳轉:店家、每日建議、注意事項都在同一頁,點了就到,不用在分頁和網頁之間切來切去
- 每天打開就有當天的建議行程
- 旅途中同步已完成的行程:去了哪家店就記回指南,之後問「附近吃什麼」時先列還沒去過的;回來後的整理也直接在同一份資料上做
- 頁首講清楚還剩多少時間:出發前還有幾天、旅途中是第幾天
所以主線是把行前指南升成整頁的主角,導覽、資料、同步流程、頁首都跟著改。支線有兩條:每家店都畫進示意圖(寫了一支依地址補座標的腳本),以及備註頁的目錄與排序。

頁面負責「看」,Claude Code 的指令負責「改」。這張圖裡的每一格,都是這次改過的。
這一趟這次照實寫城市和店名(釜山),但不寫出發日期與航班。截圖裡的日期一律換成範例日期,其他日期與航班做了模糊處理。
想法與取捨
誰是主角:紀錄,還是指南
Day19 的設計是「一頁走完一趟旅行」,以回來後的紀錄為主:每日實際行程、待填空格、學到的事、下次清單、地點總表,行前指南當成其中一個分頁原樣保留。
Claude 第一版的計畫還是照這個方向:把指南的分頁改成頁首的一個下拉選單,子分類補進指南。我直接否決了:
錯誤,行前指南才是我主要的項目,請把指南升級成真正的行程助手,行程紀錄優先度請往後退,並將其中的資訊都塞入到指南,行程紀錄只保留實際行程,連下面的學到的事、填表都移除掉。
模板是照「理論上該有什麼」設計的,我實際用的只有指南。 旅途中打開頁面,我要的是今天去哪、附近吃什麼、注意什麼,這些都在指南裡;待填空格和學到的事,是回來後才會碰的東西,而且一直沒填過。
所以這次拿掉的東西:大標題、數字儀表板、待填空格、學到的事、下次清單、地點總表。紀錄頁只剩每日行程,以及一行訂位資訊的小字。
這有代價:待填空格是存在手機瀏覽器裡的,如果之前填過東西,新版就看不到了。Claude 在動手前把這點列出來,提醒先在舊頁面按「複製全部」備份。
指南的分項:下拉選單,還是攤平成頁首導覽
原本的結構是兩層:外層頁面有「行程紀錄|行前指南」,指南裡面又有一列「地區|行程推薦|備註」。Day21 已經處理過兩層疊在一起很卡的問題,但那是用「往下滑就收起」繞過去,層級本身還在。
Claude 的計畫是在外層的「行前指南」按鈕上加一個 ▾,點開是下拉選單列出三個分項。我回的是:
將指南內的選項們都往上拆一層,地區/行程推薦/備註,將行程紀錄改成紀錄,與這些合成一塊,上面有四項,手機要變成漢堡選單,避免點不到。
下拉選單還是多一層:要先點「行前指南」,再點分項。攤平之後,頁首就是 地區|建議行程|備註|紀錄 四項(「行程推薦」後來也被我改名成「建議行程」)。
手機上四項排不下,也不想讓它變成左右滑動的一列,所以改成漢堡選單:右上角一顆按鈕寫著目前在哪一項,點開是直向的大字清單,每列 54px 高。
難的地方是:前三項其實不在外層頁面裡。 指南是放在沙箱 iframe 裡的另一份文件,它的分頁按鈕、切換邏輯都在 iframe 裡面,外層碰不到。Claude 的做法是兩邊各做一半,見「實作」。
資料放哪:外層傳給指南,還是直接寫進指南
把地點總表「塞進指南」有兩種做法,Claude 列出來問我:
- 直接寫進指南的地區清單:指南成為唯一的店家資料來源;代價是
/nearby、/trip-log這些指令以後要先把指南從頁面取出、改完再放回去 - 資料留在外層,指南載入時由外層傳進去:指令改動少;代價是單獨打開指南時看不到這些店
Claude 推薦第一個,我也選了第一個。
併進去時有一個限制:指南的 ★ 標記是用「第幾區的第幾家」當 id 存的。新的店只能加在每一區清單的最後面,插在中間,之後所有標記都會對錯店。所以總表裡 20 家指南沒有的店,都加在各區的尾端;原本就在指南裡的 25 家,只補上子分類、標籤和心得。
這條規則也寫進了給 Claude 讀的規則檔,以後新增店家都照這樣做。
旅途中記下的東西:寫進紀錄就好,還是也寫回指南
旅途中我不改頁面,是在 Claude Code 裡講一句「剛去了 X」,由 /trip-log 這個指令去改。改版前,這個指令的規則檔最後一行寫的是:
行前指南分頁保持原樣,不因實際行程而修改
指南是出發前的計畫,紀錄是實際發生的事,兩邊分開。可是這樣一來,指南永遠不知道我去過哪裡:旅途中第三天打開「地區」,看到的還是出發前的那份清單。
這次把那一行拿掉,/trip-log 改成同時寫兩個地方:
- 紀錄:當天的預定項目改成「去了」,店名與心得照實寫
- 指南:那家店標上
v:true,卡片上出現「去過」,心得連同日期寫進note
連帶改了另外兩個指令:
/nearby(「附近吃什麼」)原本是從地點總表找沒去過的店,現在改成從指南的地區清單裡找v不是true的,先列還沒去過的/trip-wrap(回來後收尾)原本要整理待填空格、學到的事、下次清單;這些都拿掉了,只剩把還是「預定」「待填」的項目一次問完,再把指南裡的店標好去過/沒去
所以指南不再是出發前的快照,而是會跟著旅程更新的那一份。
頁首的日期:獨立一層,還是併進原本那一列
第四件事是頁首要講清楚還剩多少時間。我想在頁首放這趟旅行的日期,要求是:「中間放目前的日期,若是第一天則左側時間特別,最後一天則是右側時間特別。」
第一版 Claude 在導覽列上方多加了一列日期,旅途中還會再多一列「今天的提醒」。我看了之後說:
我要的不是多一個 header,而是整合進去目前的 header,不需要再多一個 header layer。
Day21 才花了力氣把三層固定列壓下來,這裡又疊了兩層回去。改成日期放在導覽同一列的右邊;今天的提醒那一列拿掉,當天的注意事項本來就寫在建議行程與紀錄的當天卡片上。
接著我又補了兩個規則:
如果是時間還沒到的現在,要在左邊以外。已經結束的,則不需要再追蹤,直接保留起訖日期就好了。
| 階段 | 頁首顯示 |
|---|---|
| 出發前 | 今天|出發|回程(今天在出發日左邊) |
| 旅途中 | 出發|今天|回程 |
| 結束後 | 出發|回程 |
「今天」不再固定在中間,而是放在時間軸上它真正的位置:還沒到出發日,就在出發日的左邊。
日期的樣式:框框之外,讓我自己選
第一版的「今天」是一個圓角框,出發或回程當天,那一端會加上底色。我說「起訖時間不要用框框,請給我選項來決定」。
Claude 給了四個選項,每個附一段文字示意:
- 箭頭串起來的純文字
- 時間軸細線:日期是線上的小圓點,今天是實心綠點
- 純文字,強調的那個加底線
- 只用字色與粗細區分
我選了時間軸。之後又補了兩個細節:日期要帶星期;出發前,今天那個點到出發日之間用虛線(還沒走到那條線上)。

另外有一件我自己沒想到、Claude 提出來的事:網站判斷「今天」用的是台北時間,但韓國快一小時。旅途中台北晚上 11 點到 12 點,韓國已經過了午夜,頁面卻還停在前一天。所以資料區多了一個 tz,旅途中用目的地的時區判斷今天。
備註頁:目錄怎麼放,內容怎麼排
指南的備註頁很長:簽證、Pass、交通、錢、吃飯、天氣……Claude 在最上方加了區塊目錄,桌機上是一排按鈕。手機上它原本寫的是「可以左右滑動」,我只回了一句:
手機版左右滑動??
改成跟導覽一樣的做法:手機上只留一條「目錄 目前那一節 ▾」,點開是直向清單。這條「手機上不要左右滑動的列」後來也寫進了規則檔和 Claude 的記憶裡。
排序則是我要求「從行程前的準備到行程中與後」。Claude 把每一節標上階段(出發前/旅途中/回程),目錄也照階段分組。原本「錢、免稅與退稅」混了兩個時間點的事,拆成旅途中的「錢與換錢」和回程的「到機場、退稅與免稅提領」。
這一輪我順便請它補了三樣內容:簽證與入境、可勾選的必帶物品清單、溫度對照衣服。簽證和入境的部分,它另外派了一個代理去查最新規定,查到彼此矛盾的來源(例如紙本入境卡還收不收),就照實寫「說法不一」,建議先填電子版。
示意圖:只畫有座標的店,還是每家都畫
這條是 PR 快結束時冒出來的。我在手機上篩選類別,發現:
點選類別後,下面項目的編號會不見
Claude 查到的原因是:示意圖只畫有座標的店,編號也只有畫在圖上的店才有。從地點總表併進指南的 20 家都沒有座標,所以沒有編號,篩選後看起來就像編號消失了。
最直接的做法是請 Claude 一家一家查座標寫進去,但之後旅途中每加一家店都要再來一次。所以它改成寫一支腳本 scripts/geocode.mjs:店家多記一個 addr(當地語言的路名地址),腳本去查座標寫回去,示意圖是頁面打開時依座標即時畫的,不用另外重畫。/nearby、/trip-log、/trip-new 都加上了「跑一次補座標」的步驟。
腳本有兩條規則,都是為了跑幾次結果都一樣:
- 已經有座標的店一律不動,只補缺的。改完重跑一次,檔案完全沒變
- 查到的點離該地區中心超過 6 公里就不採用,視為查錯,那家店維持沒有座標
剩下沒畫上去的店怎麼辦,Claude 第一版是編號一律顯示、不在圖上的用虛線圈。我接著要求:
不在圖上的也放在示意圖上面,但整體字用成斜線
所以現在每家店都在圖上,分三種:
- 有座標:照實際位置畫
- 太遠的(例如要轉車的景點):照原比例畫的話整張圖會縮到看不清楚,所以只依實際方向貼在圖的邊緣,虛線圈加斜體,清單寫「圖外・需轉車」
- 沒有座標的:放在地區中心附近,清單寫「大約位置」

實作
把 iframe 裡的分項,攤平到外層導覽
外層頁面不執行指南,只用 DOMParser 把它解析一次,讀出分項的名稱:
function buildDocTabs(){
document.querySelectorAll('script[type="text/html"][data-doc]').forEach(src => {
const id = src.dataset.doc.toLowerCase();
DOCS[id] = src;
const title = src.dataset.title || src.dataset.doc;
// 只解析不執行,用來讀出分項名稱
const parsed = new DOMParser().parseFromString(unescapeDoc(src.textContent), 'text/html');
const tabs = [...parsed.querySelectorAll('[role=tablist] [role=tab]')].filter(t => t.id);
if (tabs.length) {
FIRST_TAB[id] = (tabs.find(t => t.getAttribute('aria-selected') === 'true') || tabs[0]).id;
tabs.forEach(t => NAV.push({ p: id + ':' + t.id, label: t.textContent.trim(), doc: id, tab: t.id }));
} else NAV.push({ p: id, label: title, doc: id });
// ...
指南裡只要分項用的是標準的 [role=tablist] [role=tab],就會被攤平成頁首的項目,例如 guide:t-areas;沒有分項的文件維持一個按鈕。三個項目共用同一個 iframe。指南預設選中的那一項(建議行程)就是整頁的預設頁。
指南裡的「建議行程」也補上了一段:旅途中依目的地的時間找到今天那一天,加上「今天」的標記並直接選中。兩件事合起來,旅途中打開頁面,第一眼就是當天的建議行程。
iframe 裡注入一段 docTabsShim,跟 Day21 的 docScrollShim 放在一起:
function docTabsShim(id, initial){
document.documentElement.classList.add('ts-tabs');
const tabs = () => [...document.querySelectorAll('[role=tablist] [role=tab]')];
const pick = t => {
const b = tabs().find(x => x.id === t);
if (b && b.getAttribute('aria-selected') !== 'true') b.click();
return !!b;
};
addEventListener('message', e => {
const m = e.data;
if (e.source !== parent || !m || m.type !== 'ts-doc-tab' || typeof m.tab !== 'string') return;
if (pick(m.tab)) scrollTo({ top: 0, behavior: 'instant' }); // 文件若設了平滑捲動,換分項時不要慢慢捲
});
document.addEventListener('DOMContentLoaded', () => {
if (initial) pick(initial);
let now = '';
const report = () => {
const b = tabs().find(x => x.getAttribute('aria-selected') === 'true');
if (!b || b.id === now) return;
now = b.id;
parent.postMessage({ type: 'ts-doc-tab-now', doc: id, tab: b.id }, '*');
};
new MutationObserver(report).observe(document.body, { subtree: true, attributes: true, attributeFilter: ['aria-selected'] });
report();
});
}
它做三件事:
- 在指南的
<html>加上ts-tabs,指南用這個 class 把自己那列分頁藏起來。單獨打開指南時沒有這個 class,分頁照舊 - 外層點了某個分項,就送
ts-doc-tab進來,shim 替使用者去點指南裡對應的那顆按鈕。切換本身完全用指南原本的程式 - 用
MutationObserver盯著aria-selected,分項一變就回報。這是為了指南內部的跳轉:在建議行程點某家店,指南會自己切到「地區」,外層的導覽也要跟著亮到「地區」
外層收到回報時,第一次和之後的處理不同:
addEventListener('message', e => {
const m = fromDoc(e, 'ts-doc-tab-now');
if (!m || typeof m.tab !== 'string' || !NAV.some(n => n.p === m.doc + ':' + m.tab)) return;
const first = !ready[m.doc];
ready[m.doc] = true;
// 剛載入時若使用者已經點了別的分項,補送一次;之後以文件回報的為準(例如從建議行程跳到地區)
if (first && want[m.doc] && want[m.doc] !== m.tab) { sendTab(m.doc); return; }
want[m.doc] = m.tab;
if (!document.getElementById('p-' + m.doc).hidden) mark(m.doc + ':' + m.tab);
});
fromDoc 跟其他訊息共用同一套檢查:訊息必須來自那個分頁自己的 iframe。iframe 還在載入時,使用者可能已經點了別的分項,所以第一次回報如果跟想要的不一樣,就補送一次;之後一律以指南回報的為準。
頁首的時間軸
const leg = (cls, label, date, info, hot) => `<div class="pt leg ${cls}${hot ? ' hot' : ''}"><i class="dot"></i>
<b>${md(date)}<span class="w">(${wd(date)})</span></b><small>${info && info.length ? esc(info.join(' ')) : esc(label)}</small></div>`;
// 還沒出發:今天到出發日之間用虛線
const before = TODAY < TRIP.start;
const start = leg(before ? 'cld cr' : 'cr', '出發', TRIP.start, TRIP.go, TODAY === TRIP.start);
const end = leg('cl', '回程', TRIP.end, TRIP.back, TODAY === TRIP.end);
const seg = '<i class="seg"></i>';
const now = (sub, cls = '') => `<div class="pt now ${cls}"><i class="dot"></i><b>${todayMD}<span class="w">(${wd(TODAY)})</span></b><small>${sub}</small></div>`;
// 出發/回程當天,今天就是那個端點,不另外畫一個點
const mid = TODAY === TRIP.start || TODAY === TRIP.end ? '' : now(`第 ${dayDiff(TRIP.start, TODAY) + 1} 天`, 'cl cr') + seg;
document.getElementById('dates').innerHTML =
before ? now(`還有 ${dayDiff(TODAY, TRIP.start)} 天`, 'crd') + '<i class="seg dash"></i>' + start + seg + end
: inTrip ? start + seg + mid + end
: start + seg + end;
三種階段就是最後那個三元運算。cl/cr 標示這個點的左邊或右邊有線,cld/crd 是虛線版。出發或回程當天,今天就是那個端點,加上 hot 變成綠色,不另外畫一個點。
線為什麼要分成「點自己的半截」和「中間的線段」兩部分,見「踩到的坑」。
依地址補座標
geocode.mjs 先把內嵌的指南取出來,挑出要查的店:
const todo = [];
for (const area of AREAS) {
const hub = area.hub || (area.st && area.st[0] && [area.st[0].lat, area.st[0].lng]);
for (const it of area.items) {
if (it.off || (Number.isFinite(it.lat) && Number.isFinite(it.lng))) continue;
todo.push({ area, hub, it });
}
}
已經有座標的、標成太遠(off)的都跳過,所以重跑不會動到之前的結果。hub 是該地區的中心,沒有另外設就用第一個車站。
查詢用的是 OpenStreetMap 的 Nominatim,每秒最多查一次。查到之後先量距離:
const query = it.addr || it.q;
if (k) await sleep(1100);
let hit = null, why = '';
try { hit = await geocode(query); } catch (err) { why = err.message; }
if (hit && hub) {
const d = distKm(hub, [hit.lat, hit.lng]);
if (d > maxKm) { why = `查到的點離「${area.short || area.name}」${d.toFixed(1)} 公里,太遠不採用(${hit.name.slice(0, 40)})`; hit = null; }
}
有 addr 就用地址查,沒有才退回地圖搜尋用的 q。離地區中心超過 maxKm(預設 6)就當作查錯。採用的點寫回時會多一個 geo:'osm',看得出這個座標是腳本查的。
預設是預覽模式,只印出哪些查到、哪些查不到;加上 --write 才寫回頁面。
踩到的坑
時間軸的線碰不到點
第一版的時間軸,每個日期是一個欄位(點在上、日期文字在下),兩個日期之間放一條線段。在手機上看,線和點之間都有一段空隙。
原因是點置中在日期文字的上方,而日期文字比點寬得多。線段只能接到「欄位」的邊緣,接不到點。
改法是讓每個點自己畫半截線:左邊有線的點,用 ::before 從欄位左緣畫到中心;右邊有線的,用 ::after 從中心畫到右緣。中間的線段再把兩個欄位接起來,整條線就連續了:
.dates .pt.cl::before,.dates .pt.cr::after{content:"";position:absolute;top:4px;height:1px;background:var(--muted);opacity:.6;z-index:0}
.dates .pt.cl::before{left:0;right:50%}
.dates .pt.cr::after{left:50%;right:0}
.dates .dot{width:9px;height:9px;border-radius:50%;border:1.5px solid var(--muted);background:var(--paper);margin-bottom:3px;position:relative;z-index:1}
點用 z-index:1 壓在線上面,背景色跟頁面一樣,看起來就是線穿過空心圓。
手機上一列塞不下
日期併進導覽那一列之後,手機上要放:logo、三個日期、漢堡選單按鈕。第一次在 375px 寬的畫面上看,回程那一格被選單按鈕蓋住一半。
接著我又要求補上星期,日期變長,又蓋住了。最後的取捨:
- 手機上,出發與回程只顯示日期與星期,時間和機場只在當天才顯示
- 星期改用半形括號
(四),比全形(四)省兩個字寬 - 日期字級縮小一級,線段最短縮到 6px
每改一次,都在手機尺寸實際看過才算數。這一列的寬度是被內容撐出來的,加一個字就可能爆掉。
合併時一刀切掉一個函式,整頁空白
這段期間主分支合進了 Day21 的「往上滑分兩段」,跟這次的改動在同一個檔案衝突。Claude 的解法是拿主分支的 docScrollShim 換掉這邊的舊版本,用的是「從 // 手機上回報捲動方向 這行開始,一直切到 function docHtml 為止」。
問題是 docTabsShim 剛好就放在這兩者之間,一起被切掉了。檢查腳本只檢查語法,沒有報錯;打開頁面是一片空白,console 裡是:
Uncaught ReferenceError: docTabsShim is not defined
at docHtml
at show
把 docTabsShim 從合併前的 commit 補回來就好了。用「從 A 切到 B」的方式替換程式碼時,要先確認 A 和 B 之間沒有別的東西。 這次是在瀏覽器裡實際打開才發現的,光跑檢查腳本看不出來。
記住的舊分頁,讓新的預設頁失效
改完之後第一次打開,頁面停在「紀錄」,而不是預設的建議行程。
頁面會記住上次看的分頁,存的 key 是 tab。我之前在舊版看過「行程紀錄」,存的值是 recap;新版的「紀錄」也叫 recap,是合法的值,所以就被沿用了。
改成用新的 key nav 存,舊的值自然就不會被讀到。結構改了,記住狀態的 key 也要跟著換,不然舊狀態會偷偷覆蓋新的預設。
切換分項時,畫面像是沒反應
從「地區」切到「行程推薦」時,截圖裡的畫面還停在一半的位置。shim 明明有呼叫 scrollTo(0, 0)。
指南的樣式裡有一條 html{scroll-behavior:smooth},所以 scrollTo 變成慢慢捲回頂端。在手機上,切了分項卻看到內容從中間慢慢往上滑,看起來像是切換卡住了。
改成明確指定 behavior: 'instant'。平滑捲動對「跳到某一家店」是好的,對「換一整個分項」不是。
手機的目錄清單分成兩欄
備註目錄在手機上展開時,清單右邊多出了被截斷的第二欄。原因是桌機版的目錄寫了 flex-wrap:wrap,手機版把它改成直向、又限制了最大高度,項目塞不下就換到第二欄去了。手機版補上 flex-wrap:nowrap 就好了。
只靠店名,幾乎查不到座標
腳本寫好後,先用預覽模式對那 20 家沒有座標的店跑一次:
查詢 20 筆:補上座標 2 筆,查不到 18 筆
✓ 西面|釜田市場 35.1607, 129.0614 ← 부전시장
✓ 西面|烤肉的男子 35.1571, 129.0615 ← 고기굽는남자 부산
✗ 西面|Diart Coffee La Lim 查不到 ← 디아트커피 라림 전포(沒有 addr,補上當地路名地址再跑一次)
✗ 西面|83獬豸(83 Haechi) 查不到 ← 83해치 서면(沒有 addr,補上當地路名地址再跑一次)
...
這時店家都還沒有 addr,查的是地圖搜尋用的店名字串,用的已經是韓文店名,還是幾乎全部落空。Claude 接著替這 20 家查了路名地址,只留路名和門牌、拿掉樓層,再跑一次:
查詢 20 筆:補上座標 19 筆,查不到 1 筆
查不到的那一家是換錢所,三個來源給了三個不同的地址,找不到可靠的,就先放「大約位置」。查地址的過程中還順便更正了一家換錢所的門牌:原本記的號碼其實是另一家換錢所。這類免費的地址查詢,餵它路名地址才有用,店名幾乎不行。 所以規則檔和 /nearby 都寫明:新增店家時一定要記 addr。
另外有一家冷麵店,我丟了一個 Google Maps 連結問 Claude 是不是這家。它解開連結後發現那是西面分店,頁面上記的卻是要搭車過去的本店。改成西面分店之後,它從「圖外・需轉車」變成西面示意圖上的一般點。以後店家位置不對,丟連結過去就好;直接取自連結的座標,在規則檔裡註明優先於腳本查到的結果。
小結
- 少跳轉:頁首導覽是
地區|建議行程|備註|紀錄,前三項來自指南自己的分項;手機上是漢堡選單。地點總表的 20 家店併進指南各區的尾端,原本的 25 家補上子分類、標籤與心得,店家資料只剩一份 - 每天的建議行程:預設頁是建議行程,旅途中依目的地時間直接選到今天
- 旅途中同步:
/trip-log同時改紀錄與指南,/nearby先列還沒去過的店,/trip-wrap只剩確認去了沒;紀錄只留每日行程 - 頁首倒數:一列裡放 logo、導覽、時間軸日期;出發前是「還有 N 天」、旅途中是「第 N 天」、結束後只留起訖,「今天」用目的地時區判斷
- 示意圖:95 家店全部有編號、全部畫在圖上;
geocode.mjs依地址補了 19 家的座標,太遠和沒座標的用虛線圈 - 備註:依出發前/旅途中/回程排序,有目錄;新增簽證與入境、必帶物品清單、溫度對照衣服
- 模板與規則:外層模板、指南模板、檢查腳本、四個 Claude Code 指令、規則檔都改成以指南為主
本文同步發表於 kiwi-walk.com:https://kiwi-walk.com/blogs/engineer/ironman-2026-day22-guide-first/

