今天想到一個挺重要的東西還沒有,那就是「吃藥提醒」的功能。
創建一個 reminders.html 頁面,管理提醒、測試通知、匯出 .ics。
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8" />
<title>吃藥提醒</title>
<link rel="stylesheet" href="style.css" />
<script src="script.js" defer></script>
</head>
<body>
<nav id="navbar">
<a href="index.html">首頁</a>
<a href="signup.html">註冊</a>
<a href="login.html">登入</a>
<!-- 登入後 script.js 會自動插入:填寫 / 紀錄 / 帳號 / 提醒 -->
</nav>
<h1>吃藥提醒</h1>
<section class="card max-600">
<p><b>使用說明:</b>第一次使用請允許瀏覽器的「顯示通知」權限;若被阻擋,請用下方的 .ics 匯出到手機行事曆。</p>
<div id="notifStatus" class="muted"></div>
<button id="btnAskPerm" class="btn secondary">允許通知權限</button>
<button id="btnTestNotif" class="button">立即測試通知</button>
</section>
<section class="card max-600">
<h2>新增提醒</h2>
<form id="reminderForm">
<label>標題(例如:早餐藥)</label>
<input type="text" id="r_title" required placeholder="例如:早餐後 1 顆二甲雙胍">
<label>時間</label>
<input type="time" id="r_time" required>
<label>星期(至少勾一個)</label>
<div class="week-grid">
<label><input type="checkbox" name="r_days" value="1"> 週一</label>
<label><input type="checkbox" name="r_days" value="2"> 週二</label>
<label><input type="checkbox" name="r_days" value="3"> 週三</label>
<label><input type="checkbox" name="r_days" value="4"> 週四</label>
<label><input type="checkbox" name="r_days" value="5"> 週五</label>
<label><input type="checkbox" name="r_days" value="6"> 週六</label>
<label><input type="checkbox" name="r_days" value="0"> 週日</label>
</div>
<div class="row">
<label><input type="checkbox" id="r_everyday"> 每天</label>
<span class="spacer"></span>
<button class="button" type="submit">加入提醒</button>
</div>
</form>
</section>
<section class="card max-600">
<h2>我的提醒</h2>
<div id="remindersEmpty" class="muted">目前沒有提醒。</div>
<div id="remindersList" class="list"></div>
</section>
<section class="card max-600">
<h2>匯出到行事曆(備用)</h2>
<p>若瀏覽器無法顯示通知,可匯出 <code>.ics</code> 檔加入手機行事曆。</p>
<button id="btnExportICS" class="btn secondary">下載 .ics</button>
</section>
<section class="card max-600">
<h2>今日吃藥清單</h2>
<div id="todayMeds"></div>
</section>
<footer>
<p>© 2025 糖尿病小護士</p>
</footer>
</body>
</html>
加入此頁面的 CSS
/* ---- 共用卡片小樣式(提醒頁用) ---- */
.card {
background:#fff; border:1px solid #e5e7eb; border-radius:12px;
padding:16px 16px 18px; margin:16px auto; box-shadow:0 4px 14px rgba(16,24,40,.06);
}
.max-600 { max-width: 600px; }
.muted { color:#666; font-size:.95rem; margin:6px 0 10px; }
.row { display:flex; align-items:center; gap:10px; }
.spacer { flex:1; }
.week-grid {
display:grid;
grid-template-columns: repeat(4, minmax(0,1fr));
gap:8px 12px;
margin:8px 0 6px;
}
@media (max-width: 480px){
.week-grid { grid-template-columns: repeat(3, minmax(0,1fr)); }
}
/* 列表 */
.list .item {
display:flex; align-items:center; gap:10px;
border:1px solid #eee; border-radius:10px; padding:10px 12px; margin:8px 0;
}
.list .meta { flex:1; }
.badge {
display:inline-block; padding:2px 8px; border-radius:999px; font-size:.8rem; background:#eef2ff; color:#3730a3;
margin-left:6px;
}
/* 按鈕 */
.btn { padding:8px 14px; border-radius:8px; border:none; cursor:pointer; }
.btn.secondary { background:#e5e7eb; color:#111827; }
.btn.secondary:hover { background:#d1d5db; }
.button { padding:10px 16px; background:#4CAF50; color:#fff; border:none; border-radius:8px; cursor:pointer; }
.button:hover { background:#45a049; }
.switch { transform: scale(1.1); }
/* 站內提醒橫幅(頁面開著時) */
.inapp-alert {
position: fixed; top: 0; left: 50%; transform: translateX(-50%);
background: #fff7ed; color: #92400e; border: 1px solid #fed7aa;
border-radius: 10px; box-shadow: 0 10px 20px rgba(0,0,0,.08);
padding: 10px 14px; z-index: 9999; display: none; gap: 10px; align-items: center;
}
.inapp-alert.show { display: flex; }
.inapp-alert .btn { padding: 6px 10px; border-radius: 8px; border: none; cursor: pointer; }
.inapp-alert .btn.primary { background: #4CAF50; color: #fff; }
.inapp-alert .btn.secondary { background: #e5e7eb; color: #111827; }
/*************************
* 吃藥提醒(localStorage + Notification + .ics)
*************************/
(function medsReminderModule(){
const LS_KEY = "reminders"; // [{id,title,timeHHMM,days[0-6],enabled,lastFired:'YYYY-MM-DD'}]
// 登入後自動在 navbar 加「提醒」連結
const navbar = document.getElementById("navbar") || document.querySelector("nav");
const loggedInUser = localStorage.getItem("loggedInUser");
if (navbar && loggedInUser && !navbar.querySelector('a[href="reminders.html"]')) {
const a = document.createElement("a");
a.href = "reminders.html";
a.textContent = "提醒";
navbar.appendChild(a);
}
// 這些元素只有 reminders.html 會存在
const reminderForm = document.getElementById("reminderForm");
const listEl = document.getElementById("remindersList");
const emptyEl = document.getElementById("remindersEmpty");
const btnAskPerm = document.getElementById("btnAskPerm");
const btnTestNotif = document.getElementById("btnTestNotif");
const notifStatus = document.getElementById("notifStatus");
const btnExportICS = document.getElementById("btnExportICS");
// 工具
const pad2 = (n) => (n<10? "0"+n : ""+n);
const todayISO = () => new Date().toISOString().slice(0,10);
const nowHHMM = () => { const d=new Date(); return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; };
const nowWeekday = () => new Date().getDay(); // 0(日)~6(六)
const loadReminders = () => JSON.parse(localStorage.getItem(LS_KEY) || "[]");
const saveReminders = (arr) => localStorage.setItem(LS_KEY, JSON.stringify(arr));
async function ensurePermission(){
if (!("Notification" in window)) return "unsupported";
if (Notification.permission === "granted") return "granted";
if (Notification.permission === "denied") return "denied";
try { return await Notification.requestPermission(); }
catch { return Notification.permission; }
}
function showNotification(title, body){
if (!("Notification" in window)) { alert(`${title}\n\n${body}`); return; }
if (Notification.permission === "granted") {
new Notification(title, { body });
} else {
alert(`${title}\n\n${body}`);
}
}
// 每分鐘整分檢查
function startTicker(){
checkDue();
const delay = 60000 - (Date.now() % 60000);
setTimeout(() => { checkDue(); setInterval(checkDue, 60*1000); }, delay);
}
function checkDue(){
const hhmm = nowHHMM();
const w = nowWeekday();
const iso = todayISO();
let arr = loadReminders();
let changed = false;
arr.forEach(r => {
if (!r.enabled) return;
if (!r.days || r.days.length === 0) return;
if (r.timeHHMM !== hhmm) return;
if (!r.days.includes(w)) return;
if (r.lastFired === iso) return; // 今天已提醒過
// 觸發一次
showNotification("吃藥提醒", `${r.title} - 現在 ${r.timeHHMM}`);
r.lastFired = iso;
changed = true;
});
if (changed) saveReminders(arr);
}
// ===== reminders.html 才需要的 UI 綁定 =====
if (reminderForm && listEl && emptyEl){
function renderPerm(){
if (!("Notification" in window)){
notifStatus.textContent = "瀏覽器不支援通知 API,會改用彈窗;建議使用 Chrome/Edge。";
return;
}
notifStatus.textContent = `通知權限:${Notification.permission}`;
}
renderPerm();
btnAskPerm?.addEventListener("click", async () => {
const p = await ensurePermission();
renderPerm();
if (p === "granted") showNotification("太好了!", "已開啟通知權限。");
});
btnTestNotif?.addEventListener("click", () => {
showNotification("測試通知", "這是一則測試訊息 ✅");
});
// 勾「每天」會全選星期
const chkEveryday = document.getElementById("r_everyday");
chkEveryday?.addEventListener("change", () => {
const boxes = document.querySelectorAll('input[name="r_days"]');
boxes.forEach(b => b.checked = chkEveryday.checked);
});
reminderForm.addEventListener("submit", (e) => {
e.preventDefault();
const title = document.getElementById("r_title").value.trim();
const time = document.getElementById("r_time").value;
const days = [...document.querySelectorAll('input[name="r_days"]:checked')].map(b => parseInt(b.value,10));
if (!title || !time || days.length===0){
alert("請輸入標題、選擇時間與至少一個星期。");
return;
}
const r = {
id: Date.now().toString(36),
title,
timeHHMM: time,
days,
enabled: true,
lastFired: null,
};
const arr = loadReminders();
arr.push(r);
saveReminders(arr);
reminderForm.reset();
renderList();
});
function renderList(){
const arr = loadReminders();
emptyEl.style.display = arr.length ? "none" : "block";
listEl.innerHTML = "";
const dayName = (d) => ["週日","週一","週二","週三","週四","週五","週六"][d];
arr.forEach(r => {
const item = document.createElement("div");
item.className = "item";
item.innerHTML = `
<input type="checkbox" class="switch" ${r.enabled ? "checked": ""} title="啟用/停用">
<div class="meta">
<div><b>${r.title}</b> <span class="badge">${r.timeHHMM}</span></div>
<div class="muted">${r.days.map(dayName).join("、")}</div>
</div>
<button class="btn secondary btn-del">刪除</button>
`;
// 啟用/停用
const sw = item.querySelector(".switch");
sw.addEventListener("change", () => {
const arr = loadReminders();
const idx = arr.findIndex(x => x.id === r.id);
if (idx >= 0){ arr[idx].enabled = sw.checked; saveReminders(arr); }
});
// 刪除
item.querySelector(".btn-del").addEventListener("click", () => {
if (!confirm("確定刪除這個提醒?")) return;
const arr = loadReminders().filter(x => x.id !== r.id);
saveReminders(arr);
renderList();
});
listEl.appendChild(item);
});
}
// 匯出 .ics(全部提醒)
btnExportICS?.addEventListener("click", () => {
const reminders = loadReminders();
if (!reminders.length){ alert("沒有提醒可匯出。"); return; }
const bydayMap = { 0:"SU",1:"MO",2:"TU",3:"WE",4:"TH",5:"FR",6:"SA" };
const now = new Date();
const y = now.getFullYear(), m = pad2(now.getMonth()+1), d = pad2(now.getDate());
let ics = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-/Diabetes Helper/Meds Reminder/TW"
];
reminders.forEach(r => {
const hh = r.timeHHMM.slice(0,2), mm = r.timeHHMM.slice(3,5);
const DTSTART = `${y}${m}${d}T${hh}${mm}00`;
const BYDAY = r.days.map(n => bydayMap[n]).join(",");
const uid = `${r.id}@diabetes-helper`;
ics.push(
"BEGIN:VEVENT",
`UID:${uid}`,
`DTSTART:${DTSTART}`,
`RRULE:FREQ=WEEKLY;BYDAY=${BYDAY}`,
`SUMMARY:${String(r.title + "(吃藥提醒)").replace(/([,;])/g,"\\$1")}`,
"END:VEVENT"
);
});
ics.push("END:VCALENDAR");
const blob = new Blob([ics.join("\r\n")], {type:"text/calendar;charset=utf-8"});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = "meds-reminders.ics"; a.click();
URL.revokeObjectURL(url);
});
renderList();
startTicker();
} else {
// 即使不在 reminders.html,仍啟動背景檢查(頁面開著就會提醒)
startTicker();
}
})();
/* ========== 站內橫幅提醒(頁面開著就有用) ========== */
(function inAppBanner(){
// 建橫幅
const bar = document.createElement('div');
bar.className = 'inapp-alert';
bar.innerHTML = `
<span id="iaText">到時間囉!</span>
<button id="iaDone" class="btn primary">已服用</button>
<button id="iaClose" class="btn secondary">忽略</button>
`;
document.body.appendChild(bar);
const iaText = bar.querySelector('#iaText');
const btnDone = bar.querySelector('#iaDone');
const btnClose = bar.querySelector('#iaClose');
const LS_KEY = "reminders"; // 與上面一致
const TAKEN_KEY = "reminders_taken"; // { 'YYYY-MM-DD': { id: true } }
const pad2 = n => n<10? "0"+n : ""+n;
const todayISO = () => new Date().toISOString().slice(0,10);
const nowHHMM = () => { const d=new Date(); return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; };
const loadReminders = () => JSON.parse(localStorage.getItem(LS_KEY) || "[]");
const loadTaken = () => JSON.parse(localStorage.getItem(TAKEN_KEY) || "{}");
const saveTaken = obj => localStorage.setItem(TAKEN_KEY, JSON.stringify(obj));
let currentR = null;
function tick(){
const arr = loadReminders();
if (!arr.length) return;
const hhmm = nowHHMM();
const w = new Date().getDay(); // 0-6
const iso = todayISO();
const taken = loadTaken()[iso] || {};
// 找第一個「到點、啟用、今天該吃、且今天尚未標註已服用」
const due = arr.find(r =>
r.enabled !== false &&
r.timeHHMM === hhmm &&
Array.isArray(r.days) && r.days.includes(w) &&
!taken[r.id]
);
if (due) {
currentR = due;
iaText.textContent = `吃藥提醒:${due.title}(${due.timeHHMM})`;
bar.classList.add('show');
// 可選:標題閃爍
const orig = document.title;
let count=0; const t = setInterval(()=>{
document.title = (count++%2===0) ? '⏰ 吃藥提醒!' : orig;
}, 800);
btnClose.onclick = () => { bar.classList.remove('show'); clearInterval(t); document.title = orig; };
btnDone.onclick = () => {
const all = loadTaken();
all[iso] = all[iso] || {};
all[iso][currentR.id] = true;
saveTaken(all);
bar.classList.remove('show'); clearInterval(t); document.title = orig;
};
}
}
// 每分鐘整點檢查
function start(){
tick();
const delay = 60000 - (Date.now() % 60000);
setTimeout(()=>{ tick(); setInterval(tick, 60000); }, delay);
}
start();
})();
/* ========== 站內橫幅提醒(頁面開著就有用) ========== */
(function inAppBanner(){
// 建橫幅
const bar = document.createElement('div');
bar.className = 'inapp-alert';
bar.innerHTML = `
<span id="iaText">到時間囉!</span>
<button id="iaDone" class="btn primary">已服用</button>
<button id="iaClose" class="btn secondary">忽略</button>
`;
document.body.appendChild(bar);
const iaText = bar.querySelector('#iaText');
const btnDone = bar.querySelector('#iaDone');
const btnClose = bar.querySelector('#iaClose');
const LS_KEY = "reminders"; // 與上面一致
const TAKEN_KEY = "reminders_taken"; // { 'YYYY-MM-DD': { id: true } }
const pad2 = n => n<10? "0"+n : ""+n;
const todayISO = () => new Date().toISOString().slice(0,10);
const nowHHMM = () => { const d=new Date(); return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; };
const loadReminders = () => JSON.parse(localStorage.getItem(LS_KEY) || "[]");
const loadTaken = () => JSON.parse(localStorage.getItem(TAKEN_KEY) || "{}");
const saveTaken = obj => localStorage.setItem(TAKEN_KEY, JSON.stringify(obj));
let currentR = null;
function tick(){
const arr = loadReminders();
if (!arr.length) return;
const hhmm = nowHHMM();
const w = new Date().getDay(); // 0-6
const iso = todayISO();
const taken = loadTaken()[iso] || {};
// 找第一個「到點、啟用、今天該吃、且今天尚未標註已服用」
const due = arr.find(r =>
r.enabled !== false &&
r.timeHHMM === hhmm &&
Array.isArray(r.days) && r.days.includes(w) &&
!taken[r.id]
);
if (due) {
currentR = due;
iaText.textContent = `吃藥提醒:${due.title}(${due.timeHHMM})`;
bar.classList.add('show');
// 可選:標題閃爍
const orig = document.title;
let count=0; const t = setInterval(()=>{
document.title = (count++%2===0) ? '⏰ 吃藥提醒!' : orig;
}, 800);
btnClose.onclick = () => { bar.classList.remove('show'); clearInterval(t); document.title = orig; };
btnDone.onclick = () => {
const all = loadTaken();
all[iso] = all[iso] || {};
all[iso][currentR.id] = true;
saveTaken(all);
bar.classList.remove('show'); clearInterval(t); document.title = orig;
};
}
}
// 每分鐘整點檢查
function start(){
tick();
const delay = 60000 - (Date.now() % 60000);
setTimeout(()=>{ tick(); setInterval(tick, 60000); }, delay);
}
start();
})();