摘要
Day 12 我們完成了「進場畫面」,讓使用者先感受到共鳴,再進入表單。
經過 Day 4-12 ,你已經完整實現 Day 4 提到的最小可行產品(MVP)!以下會放加了 Day 13 功能的 MVP 完整程式碼。
今天要再進一步:
- 表單送出後才出現「精神引導選擇」,使用者能得到不同的情感回饋,銜接隔天的精神狀態小測驗。
- 新增「歷史回顧」按鈕,點擊後才切換到歷史區塊,避免初次進場畫面過於複雜。
精神引導
歷史回顧獨立切換
為何要用 preventDefault()?
<form>
submit 會整頁重新整理(或導向 action),導致 UI 狀態與 JS 記憶(如暫存、焦點)被清空。hidden vs display:none 差異:
今日的程式碼為前幾日與今日的完整功能版,讓你檢查前幾日的程式碼是否有誤,因此篇幅較長,有標註今日的新增與調整部分。
HTML:
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Procrastinator MVP</title>
</head>
<body>
<!-- Day 12:進場畫面(維持) -->
<section id="resonanceScreen" aria-labelledby="res-title" role="dialog" aria-modal="true">
<div>
<h1 id="res-title">你是不是也常常…</h1>
<ul>
<li>看了一堆自我提升影片,結果還是沒開始?</li>
<li>打開 IG/小紅書 想找靈感,結果一個多小時過去?</li>
<li>明明有想做的事,卻又莫名其妙滑到半夜?</li>
</ul>
<p><strong>想想看,假日想做但沒做的事通常是什麼?</strong></p>
<div><button id="btnStart">一起看看今天怎麼了 →</button></div>
</div>
</section>
<!-- 表單頁(主工作區) -->
<section id="recordFormSection" hidden>
<h1>I want to...</h1>
<div>
<input type="text" id="taskInput" placeholder="Enter your task..." aria-label="任務名稱"/>
<select id="reasonSelect" aria-label="拖延原因">
<option value="tired">太累 (Tired)</option>
<option value="mood">沒心情 (Not in the mood)</option>
<option value="distractions">太多干擾 (Too many distractions)</option>
<option value="difficult">太難 (Too difficult)</option>
<option value="dontKnow">不知道怎麼開始 (Don't know how to start)</option>
<option value="other">其他 (Other)</option>
</select>
<!-- [Day13-CHANGED] 明確指定 type="button",避免成為預設 submit 造成整頁刷新 -->
<button id="submitBtn" type="button">Submit</button>
</div>
<p id="quote" aria-live="polite"></p>
<!-- [Day13-NEW] Submit 後才出現的精神引導選擇 -->
<div id="motivationSection" hidden>
<p>要不要幫自己留一點能量,在最清醒的時候做想做的事?</p>
<div>
<button id="btnYes">當然!</button>
<button id="btnLater">下次吧…</button>
</div>
<p id="feedbackMsg" aria-live="polite"></p>
</div>
<!-- [Day13-NEW] 以按鈕觸發 DOM 切換到歷史回顧區塊 -->
<div style="margin-top:1rem;">
<button id="btnHistory" aria-controls="historySection">歷史回顧 →</button>
</div>
</section>
<!-- [Day13-NEW] 歷史回顧頁(獨立區塊,按下按鈕才顯示) -->
<section id="historySection" hidden aria-labelledby="history-title">
<h1 id="history-title" tabindex="-1">歷史回顧</h1>
<!-- 排序與篩選(搬入歷史頁,避免表單頁太雜) -->
<div>
<label for="sortSelect">排序:</label>
<select id="sortSelect">
<option value="time_desc">依建立時間(新→舊)</option>
<option value="time_asc">依建立時間(舊→新)</option>
<option value="reason">依原因</option>
<option value="task">依任務字母/注音順序</option>
</select>
<label for="filterSelect">篩選:</label>
<select id="filterSelect">
<option value="all">全部</option>
<option value="tired">只看「太累」</option>
<option value="mood">只看「沒心情」</option>
<option value="distractions">只看「太多干擾」</option>
<option value="difficult">只看「太難」</option>
<option value="dontKnow">只看「不知道怎麼開始」</option>
<option value="other">只看「其他」</option>
</select>
</div>
<ul id="historyList" aria-live="polite"></ul>
<div style="margin-top:0.5rem;">
<button id="undoBtn" disabled>回復上一筆</button>
<button id="clearBtn">清空紀錄</button>
</div>
<div style="margin-top:1rem;">
<button id="btnBack" aria-controls="recordFormSection">← 回到表單</button>
</div>
</section>
<script src="script.js" defer></script>
</body>
</html>
JavaScript:
// ===== 常數與偏好 =====
const STORAGE_KEY = 'procrastinator_records';
const LEGACY_KEY = 'procrastinationData';
const SORT_PREF_KEY = 'procrastinator_sort_pref';
const FILTER_PREF_KEY = 'procrastinator_filter_pref';
let currentSort = localStorage.getItem(SORT_PREF_KEY) || 'time_desc';
let currentFilter = localStorage.getItem(FILTER_PREF_KEY) || 'all';
// ===== 進場畫面(Day 12:維持原邏輯) =====
const introEl = document.getElementById('resonanceScreen');
const startBtn = document.getElementById('btnStart');
const formSection = document.getElementById('recordFormSection');
const historySection = document.getElementById('historySection');
function focusFirstField() {
document.querySelector('#taskInput')?.focus?.();
}
function showIntro() {
introEl.hidden = false;
formSection.hidden = true;
historySection.hidden = true;
}
function hideIntroShowForm() {
introEl.hidden = true;
formSection.hidden = false;
historySection.hidden = true;
focusFirstField();
}
startBtn?.addEventListener('click', hideIntroShowForm);
// ===== 資料存取(維持) =====
function safeParseArray(raw) {
try {
if (!raw) return [];
const parsed = JSON.parse(raw);
const arr = Array.isArray(parsed) ? parsed : parsed?.records;
return Array.isArray(arr) ? arr : [];
} catch {
localStorage.removeItem(STORAGE_KEY);
return [];
}
}
function readRecords() { return safeParseArray(localStorage.getItem(STORAGE_KEY)); }
function writeRecords(list) { localStorage.setItem(STORAGE_KEY, JSON.stringify(list)); }
function addRecord(record) { const list = readRecords(); list.push(record); writeRecords(list); }
// Day4 → 陣列遷移(一次性)
(function migrateLegacyKey(){
const raw = localStorage.getItem(LEGACY_KEY);
if (!raw) return;
try {
const obj = JSON.parse(raw);
if (obj && typeof obj === 'object') {
const list = readRecords();
list.push({ id:String(Date.now()), task:obj.task??'', reason:obj.reason??'', reasonCode:'legacy', quote:obj.quote??'', createdAt:Date.now() });
writeRecords(list);
}
} catch {}
localStorage.removeItem(LEGACY_KEY);
})();
// ===== 語錄 & 表單(維持) =====
function getQuoteByReason(code){
const quotes = {
tired:['先補點能量也算前進。','睡一覺,回來再戰。'],
mood:['先做 3 分鐘,心情會跟上。','心情像雲,會散開的。'],
distractions:['關掉一個通知,就是一步。','把手機放遠一點點就好。'],
difficult:['把任務切成最小一口。','先完成最簡單的 1%。'],
dontKnow:['寫下第一步就夠了。','先鋪一張桌子,事就開始。'],
other:['你願意回顧,已在改變。','行動勝於完美。']
};
const list = quotes[code] || quotes.other;
return list[Math.floor(Math.random()*list.length)];
}
function getFormValues(){
const task = document.getElementById('taskInput').value.trim();
const reasonSelect = document.getElementById('reasonSelect');
const reasonCode = reasonSelect.value;
const reasonText = reasonSelect.options[reasonSelect.selectedIndex].text;
if (!task) throw new Error('EMPTY_TASK');
return { task, reasonCode, reasonText };
}
function showReasonQuote(reasonText, quote){
document.getElementById('quote').textContent = `原來你因為「${reasonText}」。👉 ${quote}`;
}
// ===== [Day13-NEW] Submit 後才顯示精神引導區 =====
const submitBtn = document.getElementById('submitBtn'); // type="button":避免預設 submit
const motivationEl = document.getElementById('motivationSection');
const feedback = document.getElementById('feedbackMsg');
const btnYes = document.getElementById('btnYes');
const btnLater = document.getElementById('btnLater');
function onSubmit(e){
// [Day13-NEW] 若之後改成 <form> 監聽 submit,這行可避免整頁刷新:
// e?.preventDefault?.();
try {
const { task, reasonCode, reasonText } = getFormValues();
const quote = getQuoteByReason(reasonCode);
addRecord({ id:String(Date.now()), task, reason:reasonText, reasonCode, quote, createdAt:Date.now() });
showReasonQuote(reasonText, quote);
motivationEl.hidden = false; // [Day13-NEW] 顯示精神引導選擇
btnYes?.focus?.(); // a11y:引導焦點到第一個互動元件
} catch (err) {
if (err.message === 'EMPTY_TASK') {
alert('先寫下你想做的事喔!');
} else {
alert('發生未預期的錯誤,請稍後再試。');
console.error(err);
}
}
}
submitBtn?.addEventListener('click', onSubmit);
// [Day13-NEW] 精神引導分支
btnYes?.addEventListener('click', ()=>{ feedback.innerText='太好了!你今天踏出了不一樣的一步 💪'; /* 可進階:localStorage.setItem('motivationChoice','yes') */ });
btnLater?.addEventListener('click', ()=>{ feedback.innerText='沒關係,你隨時都能回來開始。我會一直在這等你 😊'; /* 可進階:localStorage.setItem('motivationChoice','later') */ });
// ===== [Day13-NEW] 歷史回顧(按鈕才切換 DOM 顯示) =====
const btnHistory = document.getElementById('btnHistory');
const btnBack = document.getElementById('btnBack');
const sortSelect = document.getElementById('sortSelect');
const filterSelect = document.getElementById('filterSelect');
const historyList = document.getElementById('historyList');
const undoBtn = document.getElementById('undoBtn');
const clearBtn = document.getElementById('clearBtn');
function showPage(page){
if (page === 'history') {
formSection.hidden = true; // [Day13-NEW]
historySection.hidden = false; // [Day13-NEW]
renderHistory(); // 只在需要時渲染(效能 & 清晰)
document.getElementById('history-title')?.focus?.(); // a11y:告知區塊已切換
} else {
historySection.hidden = true;
formSection.hidden = false;
focusFirstField();
}
}
btnHistory?.addEventListener('click', ()=>showPage('history')); // [Day13-NEW]
btnBack?.addEventListener('click', ()=>showPage('home')); // [Day13-NEW]
// 排序/篩選 + 渲染(維持)
function applySortFilter(list){
let arr = Array.isArray(list) ? list.slice() : [];
const f = (filterSelect?.value ?? currentFilter);
if (f && f !== 'all') arr = arr.filter(r => r.reasonCode === f);
const s = (sortSelect?.value ?? currentSort);
switch (s) {
case 'time_asc': arr.sort((a,b)=>a.createdAt-b.createdAt); break;
case 'reason': arr.sort((a,b)=>(a.reason||'').localeCompare(b.reason||'')); break;
case 'task': arr.sort((a,b)=>(a.task||'').localeCompare(b.task||'')); break;
case 'time_desc':
default: arr.sort((a,b)=>b.createdAt-a.createdAt); break;
}
return arr;
}
function renderHistory(){
const records = applySortFilter(readRecords());
historyList.innerHTML = records.length
? records.map(r=>`
<li>
[${new Date(r.createdAt).toLocaleString()}] ${r.task} — ${r.reason} |「${r.quote}」
<button class="btn-delete" data-id="${r.id}">刪除</button>
</li>
`).join('')
: '<li class="muted">尚無紀錄</li>';
undoBtn.disabled = deletedStack.length === 0;
}
// 刪除 / Undo(維持)
let deletedStack = [];
historyList.addEventListener('click',(e)=>{
const btn = e.target.closest('button.btn-delete');
if (!btn) return;
const id = btn.dataset.id;
const list = readRecords();
const idx = list.findIndex(r=>r.id===id);
if (idx === -1) { alert('找不到這筆紀錄,可能已在其他分頁修改。'); return renderHistory(); }
deletedStack.push(list[idx]);
writeRecords(list.filter(r=>r.id!==id));
renderHistory();
});
undoBtn.addEventListener('click',()=>{
if (!deletedStack.length) return;
const peek = deletedStack[deletedStack.length-1];
if (!window.confirm(`要回復上一筆刪除嗎?(${peek.task} — ${peek.reason})`)) return;
const rec = deletedStack.pop();
const list = readRecords(); list.push(rec); writeRecords(list);
renderHistory();
});
// 清空(維持)
clearBtn.addEventListener('click',()=>{
if (!window.confirm('確定要清空所有紀錄嗎?清空後無法回復')) return;
localStorage.removeItem(STORAGE_KEY);
renderHistory();
});
// 偏好同步(維持)
sortSelect?.addEventListener('change',e=>{
currentSort = e.target.value || 'time_desc';
localStorage.setItem(SORT_PREF_KEY, currentSort);
renderHistory();
});
filterSelect?.addEventListener('change',e=>{
currentFilter = e.target.value || 'all';
localStorage.setItem(FILTER_PREF_KEY, currentFilter);
renderHistory();
});
window.addEventListener('storage',e=>{
if (e.key === STORAGE_KEY) renderHistory();
if (e.key === SORT_PREF_KEY) {
currentSort = localStorage.getItem(SORT_PREF_KEY) || 'time_desc';
if (sortSelect) sortSelect.value = currentSort;
renderHistory();
}
if (e.key === FILTER_PREF_KEY) {
currentFilter = localStorage.getItem(FILTER_PREF_KEY) || 'all';
if (filterSelect) filterSelect.value = currentFilter;
renderHistory();
}
});
// ===== 初次載入(維持) =====
(function init(){
showIntro();
if (sortSelect) sortSelect.value = currentSort;
if (filterSelect) filterSelect.value = currentFilter;
})();
Submit 後引導:輸入任務+原因 → 按 Submit → 顯示精神引導(兩顆按鈕)並把焦點移到「當然!」。
選擇分支:
歷史切換:點「歷史回顧 →」→ 表單隱藏、歷史頁顯示並渲染清單;點「← 回到表單」返回。
排序/篩選:歷史頁變更下拉 → 立刻重渲染,偏好寫入 localStorage。
Undo/刪除/清空:與 Day 7–11 相容,動作後清單會即時刷新。
<form>
監聽)。