iT邦幫忙

2025 iThome 鐵人賽

DAY 29
0
自我挑戰組

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

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

  • 分享至 

  • xImage
  •  

剩下最後兩天,打算來優化剩下的小地方:

  1. 表單體驗
    • record.html 自動帶入今天日期、限制輸入範圍、invalid 時禁止送出
    • 密碼欄位加「👁 顯示/隱藏」切換
    • 所有即時訊息(如 resetMessage)加 aria-live,螢幕閱讀器可讀
  2. 提醒 UX
    • 站內橫幅加「稍後提醒 10 分鐘
    • 「今日吃藥清單」區塊在首頁也顯示(你已加在 reminders.html,首頁再放一次即可)
  3. 報表空狀態
    • report.html 如果沒有資料,顯示「尚無資料,請先到填寫頁新增」的友善提示
    • 圖表加一條目標範圍陰影(例如 70–180 mg/dL)

表單體驗優化

record.html

  • nav 加上 id="navbar"(讓 script.js 能在登入後插入「提醒」「頭像」)
  • 表單語義化:fieldset/legendaria-live 的訊息區
  • 數值限制與提示:體重 20–400kg、血糖 50–600 mg/dL、步進值設定
  • 藥物欄位:仍採用既有的 idhidden/disabled 控制,完全相容現在的 script.js
  • novalidate 交給的 script.js 做統一驗錯
<!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>
  <!-- 加 id 讓 script.js 能在登入後自動插入「提醒」「頭像」等 -->
  <nav id="navbar">
    <a href="index.html">首頁</a>
    <a href="report.html">紀錄</a>
    <a href="record.html">填寫</a>
    <a href="signup.html">註冊</a>
    <a href="login.html">登入</a>
  </nav>

  <h1>填寫追蹤狀況</h1>

  <!-- 表單狀態訊息(供螢幕閱讀器讀出) -->
  <p id="formMessage" class="muted" aria-live="polite"></p>

  <form id="recordForm" novalidate>
    <!-- 日期 -->
    <label for="date">日期:</label>
    <input
      type="date"
      id="date"
      name="date"
      required
      autocomplete="off"
    />

    <!-- 體重 -->
    <label for="weight">體重(Kg):</label>
    <input
      type="number"
      id="weight"
      name="weight"
      inputmode="decimal"
      step="0.1"
      min="20"
      max="400"
      placeholder="例如:65.5"
      required
    />

    <!-- 運動 -->
    <fieldset class="form-group" style="border:0;padding:0;margin-top:14px;">
      <legend style="font-weight:bold;">運動狀況:</legend>
      <input type="radio" id="exercise_yes" name="exercise" value="yes" />
      <label for="exercise_yes">有</label>
      <input type="radio" id="exercise_no" name="exercise" value="no" checked />
      <label for="exercise_no">沒有</label>
    </fieldset>
    <input
      type="text"
      id="exercise_detail"
      name="exercise_detail"
      placeholder="請填寫運動內容(例如:快走 30 分鐘)"
      class="hidden"
      disabled
    />

    <!-- 藥物 -->
    <fieldset class="form-group" style="border:0;padding:0;margin-top:14px;">
      <legend style="font-weight:bold;">藥物使用:</legend>
      <input type="radio" id="med_yes" name="medication" value="yes" />
      <label for="med_yes">有</label>
      <input type="radio" id="med_no" name="medication" value="no" checked />
      <label for="med_no">沒有</label>
    </fieldset>

    <!-- 用藥時間必填(有用藥時),藥名選填 -->
    <label for="medication_time" class="hidden">用藥時間:</label>
    <input
      type="time"
      id="medication_time"
      name="medication_time"
      class="hidden"
      disabled
      required
    />

    <label for="medication_name" class="hidden">藥物名稱(選填):</label>
    <input
      type="text"
      id="medication_name"
      name="medication_name"
      placeholder="藥物名稱(選填)"
      class="hidden"
      disabled
    />

    <!-- 血糖 -->
    <label for="bf_glucose">餐前血糖(mg/dL):</label>
    <input
      type="number"
      id="bf_glucose"
      name="bf_glucose"
      inputmode="numeric"
      min="50"
      max="600"
      step="1"
      required
    />

    <label for="af_glucose">餐後血糖(mg/dL):</label>
    <input
      type="number"
      id="af_glucose"
      name="af_glucose"
      inputmode="numeric"
      min="50"
      max="600"
      step="1"
      required
    />

    <!-- 備註 -->
    <label for="remark">備註:</label>
    <textarea id="remark" name="remark" rows="3" placeholder="可填入飲食、心情、身體不適等"></textarea>

    <!-- 動作按鈕 -->
    <button type="submit">送出</button>
    <button type="button" id="clearBtn" class="btn secondary" style="margin-left:8px;">
      清除填寫資訊
    </button>
  </form>
</body>
</html>

密碼欄位顯示

<button type="button" class="btn secondary" id="btnTogglePwd">顯示</button>

JS 邏輯

// ---- 密碼顯示/隱藏通用
(function(){
  const input = document.getElementById("password") || document.getElementById("newPassword");
  const btn = document.getElementById("btnTogglePwd");
  if (input && btn) {
    btn.addEventListener("click", ()=>{
      const on = input.type === "password";
      input.type = on ? "text" : "password";
      btn.textContent = on ? "隱藏" : "顯示";
    });
  }
})();

即時訊息

<p id="resetMessage" aria-live="polite"></p>

站內橫幅

  • 多了「稍後 10 分」(Snooze)
  • 按「已服用」會紀錄今天已服用並寫一筆到 records(可在 report 看到)
  • 避免 localStorage 壞資料卡死
/* ========== 站內橫幅提醒(頁面開著就有用) ========== */
(function inAppBanner(){
  // 建橫幅
  const bar = document.createElement('div');
  bar.className = 'inapp-alert';
  bar.innerHTML = `
    <span id="iaText">到時間囉!</span>
    <button id="iaSnooze" class="btn secondary">稍後 10 分</button>
    <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 btnSnooze = bar.querySelector('#iaSnooze');

  const LS_REMINDERS = "reminders";            // 提醒清單
  const LS_TAKEN     = "reminders_taken";      // { 'YYYY-MM-DD': { id: true } }
  const LS_SNOOZE    = "reminder_snoozed";     // { 'YYYY-MM-DD HH:MM': 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 nowKeyMin= () => new Date().toISOString().slice(0,16); // YYYY-MM-DD HH:MM

  const loadJSON = (k, def) => { try { return JSON.parse(localStorage.getItem(k) || JSON.stringify(def)); } catch { return def; } };
  const saveJSON = (k, v) => localStorage.setItem(k, JSON.stringify(v));

  const loadReminders = () => loadJSON(LS_REMINDERS, []);
  const loadTaken     = () => loadJSON(LS_TAKEN, {});
  const saveTaken     = (o) => saveJSON(LS_TAKEN, o);
  const loadSnooze    = () => loadJSON(LS_SNOOZE, {});
  const saveSnooze    = (o) => saveJSON(LS_SNOOZE, o);

  let currentR = null;

  function tick(){
    const arr = loadReminders();
    if (!arr.length) return;

    // 打盹:這分鐘被延後就不彈
    const snoozed = loadSnooze();
    if (snoozed[nowKeyMin()]) 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; };

      // 稍後 10 分鐘
      btnSnooze.onclick = () => {
        const d = new Date();
        d.setMinutes(d.getMinutes() + 10);
        const key = d.toISOString().slice(0,16); // 到分鐘
        const all = loadSnooze();
        all[key] = true;
        saveSnooze(all);
        bar.classList.remove('show'); clearInterval(t); document.title = orig;
      };

      // 已服用:標記今天 + 寫一筆到 records
      btnDone.onclick = () => {
        // 1) 標記今天已服用
        const all = loadTaken();
        all[iso] = all[iso] || {};
        all[iso][currentR.id] = true;
        saveTaken(all);

        // 2) 寫入一筆用藥紀錄(讓 report 看得到)
        try {
          const now = new Date();
          const rec = {
            date: iso,
            weight: null,
            exercise: "no",
            exercise_detail: "",
            medication: "yes",
            medication_time: now.toTimeString().slice(0,5),  // HH:MM
            medication_name: currentR ? currentR.title : "未指定",
            bf_glucose: null,
            af_glucose: null,
            remark: "提醒勾選(站內)"
          };
          const records = loadJSON("records", []);
          records.unshift(rec);
          saveJSON("records", records);
        } catch(e) { console.warn("寫入用藥紀錄失敗", e); }

        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();
})();

records 匯出 CSV 按鈕

report.html

<button id="btnExportCSV" class="btn secondary">匯出 CSV</button>

JS 邏輯

// ---- 匯出 records 為 CSV
(function(){
  const btn = document.getElementById("btnExportCSV");
  if (!btn) return;
  btn.addEventListener("click", ()=>{
    const rows = JSON.parse(localStorage.getItem("records") || "[]");
    if (!rows.length) { alert("目前沒有紀錄可匯出。"); return; }
    const header = ["日期","體重","運動","運動說明","用藥","用藥時間","藥名","餐前血糖","餐後血糖","備註"];
    const csv = [header]
      .concat(rows.map(r=>[
        r.date, r.weight ?? "", r.exercise ?? "", r.exercise_detail ?? "",
        r.medication ?? "", r.medication_time ?? "", r.medication_name ?? "",
        r.bf_glucose ?? "", r.af_glucose ?? "", (r.remark ?? "").replace(/\n/g," ")
      ]))
      .map(row => row.map(x => `"${String(x).replace(/"/g,'""')}"`).join(","))
      .join("\r\n");
    const blob = new Blob([csv], {type:"text/csv;charset=utf-8"});
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url; a.download = "records.csv"; a.click();
    URL.revokeObjectURL(url);
  });
})();


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

尚未有邦友留言

立即登入留言