所有入門單元免費開放 · 課程內容持續更新中

和 Claude 做出「小習慣」打卡 App

帶著 spec.md 打開 Claude Code,一次只做一小步:先規劃、再做畫面、接著是新增、打卡、連續天數與一週小格子。每一步都有可以直接用的提示詞、檢查點和 Git commit,最後附上完整程式碼讓你對照。

第 9.3 堂約 20 分鐘

前兩堂課我們都在紙上作業:寫規格、選技術。現在終於要動手了。

這堂課是整個單元最長的一堂,但別擔心,你不需要自己寫程式碼。你的角色是產品負責人:告訴 Claude 要做什麼、檢查它做得對不對、決定什麼時候存檔。Claude 是你的工程師。

我們會把 App 拆成五個小步驟。每一步都有三樣東西:給 Claude 的提示詞、你要自己檢查的事、一個 Git commit。

五個步驟的時間軸:規劃、靜態畫面、新增與儲存、打卡與連續天數、一週小格子與刪除,每一步結束都有一個 commit 存檔點

每完成一步就 commit 一次,像遊戲的存檔點。下一步做壞了,隨時可以退回來。

準備:專案資料夾和 Git

還記得在「架設你的 Claude 開發環境」學過的 Git 安全網嗎?這裡會大量用到。

打開終端機,進入第一堂課建立的 tiny-habits 資料夾,裡面應該已經有 spec.md 和 wireframe.jpg。初始化 Git,然後啟動 Claude Code:

cd tiny-habits
git init
git add .
git commit -m "加入規格與草圖"
claude

接著,建立一個 CLAUDE.md。這個檔案是 Claude Code 每次啟動都會讀的「專案守則」,把你希望它一直遵守的事寫在這裡,就不用每次重複交代:

# 小習慣專案守則

- 規格在 spec.md,做任何功能前先對照它
- 只用純 HTML、CSS、JavaScript,不要加框架、不要安裝套件
- 介面文字一律用繁體中文(台灣用語)
- 程式碼要加簡短的中文註解,我還在學習
- 每完成一個步驟,告訴我要怎麼在瀏覽器裡測試
- 不要自己執行 git commit,我會自己確認後再存檔

最後一條很重要:commit 是你的存檔點,由你檢查過才按下去。

步驟 1:先規劃,不寫程式

第一句話不是「幫我做 App」,而是請 Claude 讀規格、提出計畫。Claude Code 有規劃模式(plan mode),在這個模式下它只會讀檔案、提出計畫,不會修改任何東西,很適合這一步。用一般模式也可以,只要在提示詞裡講清楚「先不要寫程式」。

步驟 1:請 Claude 讀規格、提出計畫

請讀 spec.md、CLAUDE.md 和 wireframe.jpg。先不要寫任何程式。

請告訴我:

  1. 你打算建立哪些檔案,每個檔案負責什麼
  2. 資料要用什麼結構存在 localStorage(請給我一個 JSON 範例)
  3. 你建議的實作順序,拆成 4 到 5 個小步驟,每一步做完我都能在瀏覽器看到變化

Claude 大概會建議三個檔案:index.html(畫面結構)、style.css(外觀)、app.js(互動和資料)。資料結構可能長這樣:

{
  "version": 1,
  "habits": [
    {
      "id": "m3k9x2ab",
      "name": "喝水",
      "goal": "2000cc",
      "done": ["2026-11-05", "2026-11-06", "2026-11-07"]
    }
  ]
}

每個習慣用一個陣列 done 記下「打過卡的日期」。這個設計很簡單:要知道今天有沒有打卡,就看陣列裡有沒有今天的日期;要算連續天數,就從今天往回一天一天找。version 欄位是留給未來的,如果哪天資料格式要改,程式可以靠它認出舊資料。

你要檢查的事: 計畫有沒有超出 spec.md?例如它提議加登入、加通知,就請它拿掉。看不懂的地方直接問:「為什麼要用陣列存日期?」

計畫沒問題後,回覆「計畫可以,請做步驟 2」。

步驟 2:先做出看得到的畫面

先做「假的」畫面:版面排好,用寫死的假資料顯示三個習慣,按鈕還不會動。這樣你能先確認版面對不對,再處理邏輯。

步驟 2:靜態畫面

請做步驟 2:照著 wireframe.jpg 建立 index.html 和 style.css。先用寫死的假資料顯示三個習慣:喝水 2000cc、走路 6000 步、讀書 20 分鐘。

要求:手機直式優先(寬度 375px 看起來要舒服)、打卡按鈕要大、按鈕還不需要有功能。做好後告訴我怎麼在瀏覽器打開。

要在瀏覽器看,最好用本機伺服器打開,而不是直接雙擊 HTML 檔。第五堂課的 service worker 只能在伺服器上運作,現在就養成習慣。可以請 Claude 幫你啟動,常見的兩種方式:

# 有安裝 Node.js 的話
npx serve .

# 有安裝 Python 的話
python -m http.server 8000

終端機會顯示一個網址,例如 http://localhost:3000 或 http://localhost:8000,用瀏覽器打開它。用 VS Code 的話,安裝 Live Server 擴充功能也可以。

你要檢查的事:

  • 在電腦瀏覽器按 F12 打開開發者工具,切換到手機尺寸預覽(裝置模擬模式),看看手機上的樣子。
  • 按鈕夠不夠大?文字會不會擠在一起?不滿意就直接說:「卡片之間太擠,打卡按鈕再大一點」。

滿意了就存檔:

git add .
git commit -m "靜態畫面:主畫面版面與假資料"

步驟 3:新增習慣,並且記住它

現在讓「新增」按鈕真的能用,而且重新整理頁面後資料還在。這就要用到 localStorage:瀏覽器內建的小儲物櫃,每個網站有自己的一格,可以存文字。下一堂課會詳細介紹它。

步驟 3:新增與儲存

請做步驟 3:拿掉假資料,改成真的資料。

  1. 建立 app.js,用一個 state 物件存所有習慣,結構照步驟 1 的 JSON
  2. 表單送出時新增一個習慣,id 用時間加亂數產生就好
  3. 每次資料改變都存進 localStorage,鍵名用 tiny-habits-v1
  4. 頁面打開時從 localStorage 讀回來;讀不到或格式壞掉就用空資料
  5. 沒有任何習慣時,顯示一句鼓勵的話

請用一個 render() 函式負責「依照 state 重畫整個清單」。

你要檢查的事:

  1. 新增「喝水 2000cc」,重新整理頁面,它還在嗎?
  2. 什麼都不輸入就按新增,會發生什麼?應該什麼都不做或提示你。
  3. 新增一個名稱是 <b>測試</b> 的習慣。畫面上應該原封不動顯示這串文字,而不是變成粗體。如果變粗體了,請 Claude 把顯示習慣名稱的地方改用 textContent。這不只是美觀問題:用 innerHTML 顯示使用者輸入的文字,是網頁常見的安全漏洞來源。

還想看看資料到底存成什麼樣子?在開發者工具切到「Application」(應用程式)分頁,左邊找到 Local Storage,點進去就能看到 tiny-habits-v1 和裡面的 JSON。

小習慣的核心循環:使用者點按鈕,改變 state,save 存進 localStorage,render 重畫畫面;打開 App 時 load 從 localStorage 讀回 state

整個 App 就是這個循環。之後加任何功能,都是「改 state、存檔、重畫」這三步。

git add .
git commit -m "新增習慣並存進 localStorage"

步驟 4:打卡和連續天數

這是 App 的心臟。

步驟 4:打卡與連續天數

請做步驟 4:

  1. 點「打卡」把今天的日期加進這個習慣的 done 陣列,再點一次就移除(取消打卡)
  2. 已完成的卡片換成綠色,按鈕顯示「✓ 已完成」
  3. 顯示連續天數:從今天往回數連續有打卡的天數。如果今天還沒打卡,從昨天開始算,不要讓它在早上就歸零
  4. 日期字串請用本地時間產生,格式 YYYY-MM-DD,不要用 toISOString

做完請用兩三句話解釋連續天數是怎麼算的。

第 4 點是一個很容易踩到的坑,值得多說一句。JavaScript 的 toISOString() 會用世界標準時間(UTC)產生日期,而台灣比它快 8 小時。如果用它,你早上 7 點打卡,會被記成「昨天」。這種 bug 在開發時很難發現,因為你可能都在下午測試。提前在提示詞裡講清楚,就省下以後的除錯時間。

你要檢查的事:

  1. 打卡、取消、再打卡,顏色和文字有沒有正確切換?
  2. 重新整理後,打卡狀態還在嗎?
  3. 連續天數怎麼測?你不可能等三天。可以請 Claude 幫忙:「請告訴我怎麼在開發者工具的 Console 裡,幫『喝水』加上昨天和前天的打卡紀錄,讓我測試連續天數」。測完記得刪掉這些假紀錄,或清掉 localStorage 重來。
git add .
git commit -m "打卡、取消打卡與連續天數"

步驟 5:一週小格子和刪除

步驟 5:一週小格子與刪除

請做步驟 5:

  1. 每張習慣卡片下方顯示最近 7 天(含今天)的小圓點,上面寫星期幾(日、一、二…),有打卡的圓點填滿綠色
  2. 每張卡片加一個不顯眼的「刪除」按鈕,按下去要先跳出確認,寫出習慣名稱

做完後,請整體檢查一次 app.js,看有沒有重複或可以簡化的地方,但不要加新功能。

你要檢查的事: 最右邊的圓點是不是今天?刪除時按「取消」,習慣有沒有好好留著?

git add .
git commit -m "一週小格子與刪除習慣"

恭喜!到這裡,「小習慣」第一版的核心功能都完成了。

卡住的時候

做到一半畫面變空白、按鈕沒反應,是一定會遇到的事,不是你做錯了什麼。處理順序:

  1. 按 F12 打開開發者工具,看 Console 分頁有沒有紅字。 紅字就是錯誤訊息。
  2. 整段複製給 Claude,加上你剛剛做了什麼:「我按打卡之後畫面變空白,Console 出現這段錯誤:(貼上)」。
  3. 如果改了幾次都越改越糟,退回上一個存檔點:git restore . 會丟掉還沒 commit 的修改,讓已經存檔過的檔案回到上一次 commit 的樣子(這之後才新建的檔案不會被刪掉,要刪的話自己刪)。然後換個方式描述需求,再試一次。

對照用:完整程式碼

下面是一份能正常運作的參考版本。你的版本不需要一模一樣,Claude 每次寫出來的都會有點不同,這很正常。拿來對照:如果你的版本有 bug,可以把這份貼給 Claude,請它比較差異。

index.html:

<!doctype html>
<html lang="zh-Hant-TW">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>小習慣</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <header>
    <h1>小習慣</h1>
    <p>每天一點點,慢慢變成你。</p>
  </header>

  <main>
    <form id="add-form">
      <input name="habit" placeholder="習慣名稱,例如:喝水" maxlength="20" required>
      <input name="goal" placeholder="目標,例如:2000cc" maxlength="20">
      <button type="submit">新增</button>
    </form>

    <section id="habit-list"></section>
  </main>

  <script src="app.js"></script>
</body>
</html>

app.js:

// 小習慣:所有資料都存在這支手機(或電腦)的瀏覽器裡
const STORAGE_KEY = 'tiny-habits-v1';

let state = load();

// ---------- 資料 ----------
function load() {
  try {
    const data = JSON.parse(localStorage.getItem(STORAGE_KEY));
    if (data && Array.isArray(data.habits)) return data;
  } catch (err) {
    console.warn('讀取資料失敗,改用空白資料', err);
  }
  return { version: 1, habits: [] };
}

function save() {
  localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}

// 用「本地時間」產生 2026-11-07 這種日期字串
// 不用 toISOString(),因為它是世界標準時間,台灣早上 8 點前會變成昨天
function dayKey(date = new Date()) {
  const y = date.getFullYear();
  const m = String(date.getMonth() + 1).padStart(2, '0');
  const d = String(date.getDate()).padStart(2, '0');
  return `${y}-${m}-${d}`;
}

function addHabit(name, goal) {
  state.habits.push({
    id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
    name,
    goal,
    done: [], // 打過卡的日期,例如 ["2026-11-06", "2026-11-07"]
  });
  save();
  render();
}

function toggleToday(id) {
  const habit = state.habits.find((h) => h.id === id);
  if (!habit) return;
  const today = dayKey();
  habit.done = habit.done.includes(today)
    ? habit.done.filter((d) => d !== today)
    : [...habit.done, today];
  save();
  render();
}

function deleteHabit(id) {
  const habit = state.habits.find((h) => h.id === id);
  if (!habit || !confirm(`確定要刪除「${habit.name}」和它的打卡紀錄嗎?`)) return;
  state.habits = state.habits.filter((h) => h.id !== id);
  save();
  render();
}

// 連續天數:今天還沒打卡不算斷,從昨天往回數
function streak(habit) {
  const done = new Set(habit.done);
  const day = new Date();
  if (!done.has(dayKey(day))) day.setDate(day.getDate() - 1);
  let count = 0;
  while (done.has(dayKey(day))) {
    count++;
    day.setDate(day.getDate() - 1);
  }
  return count;
}

function lastSevenDays() {
  const days = [];
  for (let i = 6; i >= 0; i--) {
    const d = new Date();
    d.setDate(d.getDate() - i);
    days.push(d);
  }
  return days;
}

// ---------- 畫面 ----------
const list = document.querySelector('#habit-list');
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];

function render() {
  list.innerHTML = '';
  if (state.habits.length === 0) {
    list.innerHTML = '<p class="empty">還沒有習慣,先從一個小小的開始吧!</p>';
    return;
  }
  const today = dayKey();
  for (const habit of state.habits) {
    const doneToday = habit.done.includes(today);
    const card = document.createElement('article');
    card.className = 'habit' + (doneToday ? ' is-done' : '');

    const info = document.createElement('div');
    const title = document.createElement('h2');
    title.textContent = habit.name; // 用 textContent,使用者輸入的字不會被當成 HTML
    const meta = document.createElement('p');
    meta.className = 'meta';
    meta.textContent = `${habit.goal}・連續 ${streak(habit)} 天`;
    info.append(title, meta);

    const week = document.createElement('ol');
    week.className = 'week';
    for (const d of lastSevenDays()) {
      const li = document.createElement('li');
      li.textContent = WEEKDAYS[d.getDay()];
      if (habit.done.includes(dayKey(d))) li.classList.add('on');
      week.append(li);
    }

    const check = document.createElement('button');
    check.className = 'check';
    check.textContent = doneToday ? '✓ 已完成' : '打卡';
    check.addEventListener('click', () => toggleToday(habit.id));

    const del = document.createElement('button');
    del.className = 'delete';
    del.textContent = '刪除';
    del.addEventListener('click', () => deleteHabit(habit.id));

    card.append(info, check, week, del);
    list.append(card);
  }
}

// ---------- 新增表單 ----------
document.querySelector('#add-form').addEventListener('submit', (event) => {
  event.preventDefault();
  const form = event.target;
  const name = form.elements.habit.value.trim();
  if (!name) return;
  addHabit(name, form.elements.goal.value.trim() || '每天一次');
  form.reset();
});

render();

style.css:

* { box-sizing: border-box; }
body {
  margin: 0;
  font-family: system-ui, "Noto Sans TC", "PingFang TC", sans-serif;
  background: #f6f6fb;
  color: #1b1d2e;
}
header, main { max-width: 480px; margin: 0 auto; padding: 16px; }
header h1 { margin: 8px 0 0; color: #5b4ef0; }
header p { margin: 4px 0 0; color: #6b7088; }

#add-form { display: flex; gap: 8px; flex-wrap: wrap; }
#add-form input { flex: 1 1 140px; padding: 12px; font-size: 16px; border: 1px solid #d8dbe8; border-radius: 10px; }
button { font: inherit; border: 0; border-radius: 10px; padding: 12px 16px; cursor: pointer; }
#add-form button { background: #5b4ef0; color: #fff; }

.habit {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 8px 12px;
  align-items: center;
  margin-top: 12px;
  padding: 16px;
  background: #fff;
  border-radius: 16px;
}
.habit h2 { margin: 0; font-size: 18px; }
.meta { margin: 4px 0 0; color: #6b7088; font-size: 14px; }
.check { background: #eeecff; color: #5b4ef0; font-weight: 700; min-width: 96px; }
.is-done .check { background: #0ea5a4; color: #fff; }

.week { display: flex; gap: 6px; list-style: none; margin: 0; padding: 0; }
.week li {
  width: 28px; height: 28px; border-radius: 50%;
  display: grid; place-items: center;
  font-size: 13px; background: #f0f1f6; color: #6b7088;
}
.week li.on { background: #0ea5a4; color: #fff; }
.delete { background: none; color: #6b7088; font-size: 14px; padding: 4px 8px; justify-self: end; }
.empty { color: #6b7088; text-align: center; margin-top: 32px; }

幾個值得注意的小地方:

  • 輸入框的字級設成 16px。iPhone 的 Safari 在輸入框字級小於 16px 時,點下去會自動放大畫面,很惱人。
  • 表單欄位取名 habit 而不是 name,並用 form.elements.habit 讀取。因為表單本身就有一個叫 name 的屬性,用 form.name 會拿錯東西。這種 bug 很難找,Claude 有時也會踩到。
  • render() 每次都清空、整個重畫。對幾十個習慣來說完全夠快,而且邏輯最好懂。
行動練習
  1. 照著五個步驟,和 Claude 做出你的「小習慣」。每一步都先自己檢查,再 commit。
  2. 用 git log --oneline 看看你的存檔點,應該至少有五個。
  3. 故意弄壞一次:請 Claude 做一個你不想要的修改(例如「把所有按鈕改成紅色」),看完之後用 git restore . 退回去。體會一下有存檔點的安心感。
  4. 新增你自己真正想養成的 2 到 3 個習慣,從今天開始用。下一堂課前,你已經有幾天真實的打卡紀錄了。

讀完了嗎?

標記完成後,單元與專案的進度會自動更新(只存在這台裝置的瀏覽器)。

卡住了?讓 AI 當你的家教複製這段提問範本,貼到 Claude 或其他 AI 工具,再補上你卡住的地方。

留言與討論

卡關了、有心得,或發現內容需要更新?都歡迎留言。留言使用 GitHub 帳號登入。

留言區即將開放。現在可以先到 GitHub 討論區 發問,或看看 交流頁 的其他管道。