和 Claude 做出「小習慣」打卡 App
帶著 spec.md 打開 Claude Code,一次只做一小步:先規劃、再做畫面、接著是新增、打卡、連續天數與一週小格子。每一步都有可以直接用的提示詞、檢查點和 Git commit,最後附上完整程式碼讓你對照。
前兩堂課我們都在紙上作業:寫規格、選技術。現在終於要動手了。
這堂課是整個單元最長的一堂,但別擔心,你不需要自己寫程式碼。你的角色是產品負責人:告訴 Claude 要做什麼、檢查它做得對不對、決定什麼時候存檔。Claude 是你的工程師。
我們會把 App 拆成五個小步驟。每一步都有三樣東西:給 Claude 的提示詞、你要自己檢查的事、一個 Git 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),在這個模式下它只會讀檔案、提出計畫,不會修改任何東西,很適合這一步。用一般模式也可以,只要在提示詞裡講清楚「先不要寫程式」。
請讀 spec.md、CLAUDE.md 和 wireframe.jpg。先不要寫任何程式。
請告訴我:
- 你打算建立哪些檔案,每個檔案負責什麼
- 資料要用什麼結構存在 localStorage(請給我一個 JSON 範例)
- 你建議的實作順序,拆成 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:照著 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:拿掉假資料,改成真的資料。
- 建立 app.js,用一個 state 物件存所有習慣,結構照步驟 1 的 JSON
- 表單送出時新增一個習慣,id 用時間加亂數產生就好
- 每次資料改變都存進 localStorage,鍵名用 tiny-habits-v1
- 頁面打開時從 localStorage 讀回來;讀不到或格式壞掉就用空資料
- 沒有任何習慣時,顯示一句鼓勵的話
請用一個 render() 函式負責「依照 state 重畫整個清單」。
你要檢查的事:
- 新增「喝水 2000cc」,重新整理頁面,它還在嗎?
- 什麼都不輸入就按新增,會發生什麼?應該什麼都不做或提示你。
- 新增一個名稱是
<b>測試</b>的習慣。畫面上應該原封不動顯示這串文字,而不是變成粗體。如果變粗體了,請 Claude 把顯示習慣名稱的地方改用textContent。這不只是美觀問題:用innerHTML顯示使用者輸入的文字,是網頁常見的安全漏洞來源。
還想看看資料到底存成什麼樣子?在開發者工具切到「Application」(應用程式)分頁,左邊找到 Local Storage,點進去就能看到 tiny-habits-v1 和裡面的 JSON。
整個 App 就是這個循環。之後加任何功能,都是「改 state、存檔、重畫」這三步。
git add .
git commit -m "新增習慣並存進 localStorage"
步驟 4:打卡和連續天數
這是 App 的心臟。
請做步驟 4:
- 點「打卡」把今天的日期加進這個習慣的 done 陣列,再點一次就移除(取消打卡)
- 已完成的卡片換成綠色,按鈕顯示「✓ 已完成」
- 顯示連續天數:從今天往回數連續有打卡的天數。如果今天還沒打卡,從昨天開始算,不要讓它在早上就歸零
- 日期字串請用本地時間產生,格式 YYYY-MM-DD,不要用 toISOString
做完請用兩三句話解釋連續天數是怎麼算的。
第 4 點是一個很容易踩到的坑,值得多說一句。JavaScript 的 toISOString() 會用世界標準時間(UTC)產生日期,而台灣比它快 8 小時。如果用它,你早上 7 點打卡,會被記成「昨天」。這種 bug 在開發時很難發現,因為你可能都在下午測試。提前在提示詞裡講清楚,就省下以後的除錯時間。
你要檢查的事:
- 打卡、取消、再打卡,顏色和文字有沒有正確切換?
- 重新整理後,打卡狀態還在嗎?
- 連續天數怎麼測?你不可能等三天。可以請 Claude 幫忙:「請告訴我怎麼在開發者工具的 Console 裡,幫『喝水』加上昨天和前天的打卡紀錄,讓我測試連續天數」。測完記得刪掉這些假紀錄,或清掉 localStorage 重來。
git add .
git commit -m "打卡、取消打卡與連續天數"
步驟 5:一週小格子和刪除
請做步驟 5:
- 每張習慣卡片下方顯示最近 7 天(含今天)的小圓點,上面寫星期幾(日、一、二…),有打卡的圓點填滿綠色
- 每張卡片加一個不顯眼的「刪除」按鈕,按下去要先跳出確認,寫出習慣名稱
做完後,請整體檢查一次 app.js,看有沒有重複或可以簡化的地方,但不要加新功能。
你要檢查的事: 最右邊的圓點是不是今天?刪除時按「取消」,習慣有沒有好好留著?
git add .
git commit -m "一週小格子與刪除習慣"
恭喜!到這裡,「小習慣」第一版的核心功能都完成了。
卡住的時候
做到一半畫面變空白、按鈕沒反應,是一定會遇到的事,不是你做錯了什麼。處理順序:
- 按 F12 打開開發者工具,看 Console 分頁有沒有紅字。 紅字就是錯誤訊息。
- 整段複製給 Claude,加上你剛剛做了什麼:「我按打卡之後畫面變空白,Console 出現這段錯誤:(貼上)」。
- 如果改了幾次都越改越糟,退回上一個存檔點:
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()每次都清空、整個重畫。對幾十個習慣來說完全夠快,而且邏輯最好懂。
- 照著五個步驟,和 Claude 做出你的「小習慣」。每一步都先自己檢查,再 commit。
- 用
git log --oneline看看你的存檔點,應該至少有五個。 - 故意弄壞一次:請 Claude 做一個你不想要的修改(例如「把所有按鈕改成紅色」),看完之後用
git restore .退回去。體會一下有存檔點的安心感。 - 新增你自己真正想養成的 2 到 3 個習慣,從今天開始用。下一堂課前,你已經有幾天真實的打卡紀錄了。
讀完了嗎?
標記完成後,單元與專案的進度會自動更新(只存在這台裝置的瀏覽器)。
留言與討論
卡關了、有心得,或發現內容需要更新?都歡迎留言。留言使用 GitHub 帳號登入。