今天要解的問題

前兩天都在寫 llm-wiki,今天開一個全新的專案。

我出國前習慣跟 Claude 一起查景點、排行程,每趟旅行做成一個自含式的 HTML:出發前是互動式的行前指南,回來之後再整理成一份全紀錄。這些檔案散在各處,我想替它們開一個新的 repo,變成一個網站:放過去的紀錄,也放還沒出發和正在進行的行程。

我給 Claude 的要求一開始只有幾句:每趟旅行是一個單一的 HTML、可能有個總覽,然後不要透露太多我的個人資訊。規劃途中又補了兩句:旅途中我也會透過這個 repo 即時更新狀況、找附近的景點和食物,流程要順;還有,需要離線快取。

所以這次要設計的,是一頁怎麼陪著一趟旅行走完:

  1. 旅行頁的模板:出發前、旅途中、回來後都改同一頁(主線)
  2. 總覽頁:還沒結束的旅行只顯示出發日期
  3. 旅途中的更新流程與離線快取

網站本身放在數字密碼後面,整個 repo 也是 private。鎖怎麼做不是這篇的重點,下面只在需要時帶到。

東京旅行頁在手機上的每日實際行程:9/7 因大雨改走雨天線,頂端一條黃色提示寫著熱海取消;去了的景點是綠點,沒去的劃線並標「沒去」,看完展順道去的瞭望台標「計畫外」

這份計畫跟 Claude 來回了六輪才定案,下面的取捨大多是在這六輪裡一條一條長出來的。

想法與取捨

模板照行前指南,還是照回來後的全紀錄

我手邊有兩種既有的頁面:

  • 行前指南:出發前做的,互動式,按地區列景點、每天的建議路線
  • 全紀錄:回來之後整理的,有每日實際行程、待填的空格、學到的事、下次清單、地點總表,行前指南則整份內嵌成另一個分頁

Claude 的模板換了三版。第一版是它自己設計的版面。第二版改成以行前指南為基底,它的理由是那一頁最新、結構最乾淨。第三版是我指定的:照全紀錄那一頁的格式。

照全紀錄的格式,一趟旅行從出發前就是一份紀錄:一開始行程都是預定、學到的事和下次清單都是空的,旅途中一項一項改成去了或沒去,回來之後再補齊。行前指南則收在旁邊的一個分頁。

行程項目用狀態走完一趟旅行

既然同一頁要從計畫走到實際,每日行程的每個項目就帶一個狀態:

狀態意思
fixed交通、固定的安排
plan預定要去
done去了
skip沒去
todo去了,但細節還沒填

另外可以加一個標籤,例如「臨時加」「計畫外」「改動」。旅途中在對話裡說「中午吃了某某湯飯,排了 20 分鐘」,原本的 plan 就改成 done、換上實際的店名和心得;計畫外的就新增一筆並標上「臨時加」;說沒去的改成 skip,原項目保留。回來之後還是看得出原本打算去哪、實際去了哪。

旅途中的那一天,在頁面上會被標出來。

行前指南原樣內嵌,還是拆進新格式

全紀錄原本是把整份行前指南壓成一行 17 萬字元的跳脫字串塞在程式裡。Claude 的說法是旅途中幾乎不可能安全地修改這種東西。

它沒有把行前指南拆開、改寫成新格式的資料,而是整份原樣保留:內嵌文件放進一個 <script type="text/html"> 區塊,維持多行的原始 HTML,另外寫一支小工具負責放進去和取出來。規則檔裡也寫明:行前指南保持原樣,不因實際行程而修改;實際走了哪裡,記在紀錄那一邊。

做到一半它又調了一次位置:內嵌的行前指南裡有自己的 const DAYS,跟外層頁面的資料同名。它把內嵌文件移到檔案最後,旅途中要改的資料區留在前面,免得改錯地方。

另外有一件小事。有一趟旅行我另外做了一份展覽的觀看指南,我跟 Claude 說了兩次:展品資訊要嵌入到那趟旅行的頁面裡。它沒有直接動手,先拿獨立的那份檔案跟旅行頁裡的內容比對:47 件展品完全一致,而且旅行頁裡的版本比較新,多了一章。然後它問我要哪一種呈現方式,列了四個選項。

我回:「如果已經有,那就不用了。」

如果它照字面去做,會是一次不必要的改寫,而且動的是計畫裡寫明要原樣保留的頁面。

總覽頁:還沒結束的旅行只顯示出發日期

這條是我加的要求:還沒結束的旅行,在總覽頁只顯示出發日期,要等回來三天後才顯示標題。

Claude 把它拆成三個決定:

遮蔽在伺服器端做。 旅行清單是一份 JSON,回傳之前就把還沒解禁的那幾筆的標題、地區、標籤、摘要拿掉,瀏覽器收不到。

依日期自動解禁。 每次請求當下用台北時間算,不用重新部署,也不用記得去改狀態。

網址用出發日期,不帶地名。 這點是 Claude 提的。卡片本身是一個連結,連結會出現在總覽頁上;如果網址是「年月加地名」,標題遮了也沒用。所以有日期的旅行一律用出發日期當資料夾名稱,同一天有第二趟就加 -2。

代價是網址看不出是哪一趟。另外多了一條規矩:還沒解禁的旅行,目的地不寫進 commit 訊息、分支名稱和 PR 標題,一律用那個日期指稱,例如 trip <出發日期>: 更新行程紀錄。

遮蔽期間卡片還是可以點進去,進去之後內容完整顯示,旅途中我自己要用。

旅途中:先在對話回選項,挑了才寫進頁面

「旅途中要即時更新、找附近的東西」這句話,Claude 定成這樣的流程:我在對話裡說人在哪、想找什麼,它先在對話中回 3 到 5 個選項,每個一行:店名(附當地語言的店名,地圖搜尋用)、步行時間、營業時間與公休、一句推薦理由。我挑了,或說「都記下來」,它才寫進頁面的地點總表。

它在指令裡把「先回覆」標成最重要的一步:收訊不好的時候我可能等不了,先回已知的選項,查證結果再補。查不到營業時間的要明講「未確認」,不要編。

寫進頁面的只有地點本身。「我現在在哪」不寫進頁面,這條也寫進了規則檔。

配套有三個:

  • 推上去就部署,不需要任何一台電腦開著
  • 旅行內容直接進主分支;動到程式、設定或模板的要開 PR 給我看
  • 推送前跑一支檢查腳本,清單格式、日期、每一頁的程式語法有問題就擋下來,避免旅途中一次改壞讓頁面打不開

接上自動部署之後實測了一次:推送後大約 25 秒上線。

離線快取:之後再說,還是現在就做

Claude 原本把離線快取放在計畫最後的「之後可加(本次不做)」。我直接講:需要離線快取。

做法是 Service Worker:有網路時一律先抓最新版,抓不到或太慢才用存下來的版本;打開總覽時,會把還沒結束的旅行整頁先下載好,所以出發前在家開一次總覽就夠了。建立新旅行的指令最後也會提醒這一步。

它講清楚了代價:離線快取代表內容會留在打開過的裝置上,一台始終沒再連線的裝置清不掉,只能靠快取上附帶的到期時間。

另外有一點沒做到計畫寫的程度:字型沒有預先下載。沒開過的頁面離線時會退回系統字型,內容不受影響。

實作

資料區:旅途中只改這一段

模板裡所有內容都在主 <script> 開頭的資料區,下面是渲染程式。檔案裡的註解直接寫明「旅途中只改這一段」:

/* ==========================================================================
   資料區 —— 旅途中只改這一段。一筆一行,改完跑 `node scripts/check.mjs`。
   ========================================================================== */

const TRIP = {
  key: 'trip-0000-00-00',                 // localStorage 前綴,用出發日期,建立後不要改
  brand: '地點 2026/1',                    // 左上角
  eyebrow: 'Trip record · 2026.01.01 – 01.05',
  title: ['第一行標題', '第二行標題'],
  lead: '這趟旅行的一句話說明。',
  // ...
  start: '2026-01-01',
  end: '2026-01-05',
  updated: '2026-01-01 00:00',             // 每次更新內容時一起改(台北時間)
  // ...
};

// s: done 去了|fixed 交通・固定|plan 預定|skip 沒去|todo 細節待填
// tag: 額外標籤,例如 '臨時加'、'計畫外'、'改動';q: Google Maps 查詢字串
const DAYS = [
  // {d:'1/1', w:'週四', theme:'抵達', alert:'', ev:[
  //  {t:'10:50', s:'fixed', b:'航班 起飛', sub:'補充說明。'},
  //  {t:'晚上', s:'done', tag:'臨時加', b:'店名', q:'店名 地區', sub:'心得。'},
  // ]},
];

// [key, 標籤, 提示文字]
const FILL = [];

// [標題, 內容]
const LESSONS = [];

// c: 'go' 下次要去|'warn' 已結束或去不成
const NEXT = [];

// kind: museum|cafe|food|shop|sight|night|other
// visited: true 去過|false 確定沒去|null 還沒去
// info: 營業時間・公休・步行距離等;tag: 例如 '臨時加'
const PLACES = [
  // {name:'店名', area_name:'地區', kind:'food', maps_query:'店名 地區', visited:null, info:'11:00–22:00・週一休', my_note:'', tag:''},
];

/* ==========================================================================
   以下是渲染程式,旅途中不需要動
   ========================================================================== */

六個常數各對應頁面上的一區:頁首、每日實際行程、待填空格、學到的事、下次清單、地點總表。規則是一筆一行。每個地點都帶一個 Google Maps 的查詢字串,頁面上點店名就直接開地圖。

「今天」是用台北時間算的,旅途中那一天的區塊會多一個 class:

const todayMD = Number(TODAY.slice(5, 7)) + '/' + Number(TODAY.slice(8, 10));
const inTrip = TODAY >= TRIP.start && TODAY <= TRIP.end;
// ...
   <article class="day${inTrip && day.d === todayMD ? ' today' : ''}">

待填空格(住宿名、花費、最好的一餐這類)的輸入只存在瀏覽器裡,旁邊有一個「複製全部」。回來之後我把整段貼給 Claude,它再寫進對應的位置。

四個指令對應一趟旅行的三個階段

階段指令做什麼
出發前/trip-new用出發日期建資料夾、複製模板、內嵌行前指南、登記到總覽
旅途中/nearby、/trip-log找附近的選項;把實際去了哪寫進當天行程
回來後/trip-wrap清掉還是 plan、todo 的項目,整理學到的事與下次清單

/trip-log 寫進去的樣子是一行:

{t:'12:30', s:'done', b:'店名', q:'當地語言店名 地區', sub:'心得。'},

去過的地點同時在地點總表設成 visited:true,心得連同日期寫進 my_note。指令裡也寫了:只記我說的,不要自行補上沒提到的評價或細節。

/trip-wrap 的第一步是把還在 plan 或 todo 的項目一次列出來,讓我逐一確認是去了還是沒去,「一次問完,不要一筆一筆來回」。學到的事要先給我看草稿才寫入。

這四個指令還沒在真正的旅途中跑過,只照步驟手動演練過一趟測試旅行。

內嵌文件怎麼放進 <script> 裡

一份完整的 HTML 要放進 <script type="text/html">,問題是文件裡自己的 </script> 會提早結束外層區塊。做法是放進去時在三種開頭前面多加一個反斜線,取出來時還原:

export const escapeDoc = (s) => s.replace(/<(\/script|script|!--)/gi, '<\\$1');
export const unescapeDoc = (s) => s.replace(/<\\(\/script|script|!--)/gi, '<$1');

放進去之後,工具會立刻取出來跟來源比對,不一致就中止。行前指南放進新頁面後,取出來的內容跟原檔逐字相同。

總覽清單在回傳前遮蔽

export function maskTrips(trips, today, unlockAfterDays) {
  return trips
    .filter((t) => t && SLUG_RE.test(t.slug || ''))
    .map((t) => {
      // 沒有日期的頁面(例如選項比較)不屬於「旅行中」,直接顯示
      if (!isDate(t.start) || !isDate(t.end)) return { ...t, locked: false };
      if (today >= addDays(t.end, unlockAfterDays)) return { ...t, locked: false };
      return { slug: t.slug, start: t.start, locked: true };
    });
}

repo 裡的清單檔是完整的。還沒解禁的那幾筆,回傳時只剩網址代號、出發日期和一個 locked 標記,總覽頁拿到什麼就畫什麼。

「網址不能帶地名」這條不靠自律,寫進了推送前的檢查:

      // 網址會出現在總覽頁,所以有日期的旅行一律用出發日期當 slug,不能帶地名
      if (validDate(t.start) && !new RegExp(`^${t.start}(-\\d+)?$`).test(t.slug)) {
        fail(where, `有日期的旅行,slug 必須是出發日期(${t.start}),目前是 ${t.slug}`);
      }

驗證

都是在本機的開發伺服器上跑的:

  • 遮蔽的邊界:解禁前一分鐘仍遮蔽,整點解禁。這是用一個只在本機生效的時間覆寫測的
  • 離線:關掉伺服器,沒開過、但已預先下載的旅行頁打得開,底部出現離線提示
  • 匯入三個既有頁面之後,檢查腳本回報「3 趟旅行、3 個頁面」通過

小結

今天完成的:

  • 旅行頁模板:以全紀錄為基底,資料區六個常數,行程項目用狀態從預定走到實際
  • 行前指南原樣內嵌,用小工具放入與取出,取出後跟原檔逐字相同
  • 總覽頁:還沒結束的旅行只顯示出發日期,回來三天後自動顯示標題;網址一律用出發日期
  • 旅途中的流程:四個指令、推送前的檢查腳本、推送後約 25 秒上線
  • 離線快取:還沒結束的旅行預先下載
  • 匯入三個既有頁面

還沒做的有兩件:登入之後的實機檢查(總覽是否只顯示日期、手機飛航模式下能不能開)要我自己用瀏覽器做;四個旅途用的指令還沒在真正的旅途中跑過。


本文同步發表於 kiwi-walk.com:https://kiwi-walk.com/blogs/engineer/ironman-2026-day19-one-page-per-trip/