iT邦幫忙

2025 iThome 鐵人賽

DAY 28
0
自我挑戰組

打造一個糖尿病自我監測小工具:從0開始學前端系列 第 28

Day28打造一個糖尿病自我監測小工具:從0開始學前端

  • 分享至 

  • xImage
  •  

今天想到一個挺重要的東西還沒有,那就是「吃藥提醒」的功能。

Step 1

創建一個 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>

Step 2

加入此頁面的 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; }

JS 邏輯

吃藥提醒的部分

/*************************
 * 吃藥提醒(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();
})();

成果展示

  • 提醒設定頁面
    在主頁點擊右側提醒按鈕可進入此頁面,最上面先選擇允許接受通知
    https://ithelp.ithome.com.tw/upload/images/20250928/20169698xZGCkWLviK.png
  • 新增提醒部分
    https://ithelp.ithome.com.tw/upload/images/20250928/20169698nNtliDQWTr.png
  • 可以看到:新增的提醒、匯出到行事曆、今日吃藥清單
    https://ithelp.ithome.com.tw/upload/images/20250928/20169698pNk7vjQ1pK.png

上一篇
Day27打造一個糖尿病自我監測小工具:從0開始學前端
下一篇
Day29打造一個糖尿病自我監測小工具:從0開始學前端
系列文
打造一個糖尿病自我監測小工具:從0開始學前端30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言