习惯打卡与连续天数

每日打卡、连续坚持天数统计与日历热力图,localStorage 本地存储。

场景:效率工具 技术栈:HTML / JS 来源:GitHub 参考

在线演示

0当前连续天数
0历史最长连续
0累计打卡天数
0%近 28 周完成率

热力图:每列一周(周一到周日),共 28 周,浅色格为未打卡,右侧为本周。

等待操作:点击“打卡 / 撤销该日”写入当天记录。

项目简介

把目标拆成每天可重复的小动作,是养成习惯最朴素的方法。本页提供打卡数据模型与连续天数算法:用 localStorage 持久化日期集合,支持补卡判断、断签重置和日历热力图展示,可直接嵌入个人效率工具。

代码示例:打卡数据模型

js
const KEY = "nocau-habit-log";
const today = () => new Date().toISOString().slice(0, 10); // YYYY-MM-DD

function loadLog() {
  try { return JSON.parse(localStorage.getItem(KEY)) || []; } catch (e) { return []; }
}
function saveLog(log) { localStorage.setItem(KEY, JSON.stringify(log)); }

function toggleCheck(date = today()) {
  const log = loadLog();
  const i = log.indexOf(date);
  if (i >= 0) log.splice(i, 1); else log.push(date);
  saveLog(log);
  return i < 0;
}

代码示例:连续天数与热力图

js
const DAY = 86400000;
function parse(d) { return new Date(d + "T00:00:00Z").getTime(); }

// 从最近一天往前数连续打卡天数(昨天中断即清零)
function streakDays(log) {
  const set = new Set(log);
  let streak = 0;
  let cursor = parse(today());
  while (set.has(new Date(cursor).toISOString().slice(0, 10))) {
    streak += 1;
    cursor -= DAY;
  }
  return streak;
}

// 近 28 周热力图数据:week 数组内 7 天是否打卡
function heatmap(log, weeks = 28) {
  const set = new Set(log);
  const rows = [];
  const end = parse(today()) + DAY;
  for (let w = 0; w < weeks; w++) {
    const week = [];
    for (let d = 0; d < 7; d++) {
      const t = end - (weeks - w) * 7 * DAY + d * DAY;
      const key = new Date(t).toISOString().slice(0, 10);
      week.push({ date: key, done: set.has(key), future: t >= end });
    }
    rows.push(week);
  }
  return rows;
}

操作步骤

  1. 在"在线演示"区选好日期,点击"打卡 / 撤销该日"写入当天记录,再次点击同一日期即可撤销误操作。
  2. 顶部四张卡片实时刷新:当前连续天数(今天未打卡不清零,从昨天起算)、历史最长连续、累计打卡天数、近 28 周完成率。
  3. 热力图每列一周(上为周一),右侧为本周;深色格为已打卡,带描边的为今天。可先点"载入 90 天示例数据"快速看效果。
  4. 数据全部存于 localStorage(键名 nocau-habit-log),刷新页面不丢失,"清空本地记录"可重置。

完整可运行工程

可直接运行 零依赖 无需改配置

运行环境:任意现代浏览器(Chrome / Edge / Firefox / Safari)。下载下面 3 个文件放在同一目录,双击 index.html 就能完成"打卡 → 看连续天数 → 看热力图"的完整流程。数据只存在本机 localStorage,不联网。

habit-tracker/ ├── index.html 打开即用的单页应用(含样式与结构) ├── app.js 打卡数据模型 + 连续天数 + 热力图渲染 └── README.md 运行与二次开发说明

index.html

页面结构 + 深色模式适配样式(自包含,不依赖任何 CDN)。

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>习惯打卡与连续天数</title>
  <style>
    :root { color-scheme: light dark; }
    body { margin: 0; padding: 28px 18px; font-family: system-ui, "Segoe UI", sans-serif; background: #f6f7f8; color: #1a1d21; }
    .wrap { max-width: 760px; margin: 0 auto; }
    h1 { font-size: 1.6rem; margin: 0 0 6px; }
    .sub { color: #5b636e; font-size: 0.9rem; margin: 0 0 20px; }
    .row { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
    .field { display: flex; flex-direction: column; gap: 6px; font-size: 0.82rem; color: #5b636e; }
    input { padding: 9px 12px; border: 1px solid #d6dae0; border-radius: 10px; background: #fff; font-size: 0.9rem; }
    button { padding: 9px 18px; border-radius: 10px; border: 1px solid #d6dae0; background: #fff; cursor: pointer; font-size: 0.88rem; }
    button.primary { background: #1c6659; border-color: #1c6659; color: #fff; }
    button:hover { border-color: #1c6659; }
    .kpis { display: flex; flex-wrap: wrap; gap: 12px; margin: 22px 0 8px; }
    .kpi { flex: 1 1 130px; border: 1px solid #e2e5ea; border-radius: 12px; padding: 12px; text-align: center; background: #fff; }
    .kpi b { display: block; font-size: 1.5rem; color: #1c6659; }
    .kpi span { font-size: 0.74rem; color: #5b636e; }
    .heatmap { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 14px); gap: 3px; overflow-x: auto; padding: 6px 0; }
    .cell { width: 14px; height: 14px; border-radius: 3px; background: #eceef1; border: 1px solid #dfe3e8; }
    .cell.on { background: #1c6659; border-color: #1c6659; }
    .cell.today { outline: 1px solid #0f4a40; outline-offset: 1px; }
    #status { font-size: 0.84rem; color: #5b636e; min-height: 1.3em; }
    @media (prefers-color-scheme: dark) {
      body { background: #14161a; color: #e6e9ee; }
      .sub, .field, #status { color: #9aa3b0; }
      input, button, .kpi { background: #1c1f25; color: #e6e9ee; border-color: #2a2f38; }
      .kpi b { color: #6fb3a8; }
      .cell { background: #22262d; border-color: #2f3540; }
      .cell.on { background: #6fb3a8; border-color: #6fb3a8; }
      button.primary { background: #6fb3a8; border-color: #6fb3a8; color: #10130f; }
    }
  </style>
</head>
<body>
  <div class="wrap">
    <h1>习惯打卡与连续天数</h1>
    <p class="sub">数据保存在浏览器 localStorage,不会离开你的设备。</p>
    <div class="row">
      <div class="field"><label for="name">习惯名称</label><input id="name" type="text" value="每日阅读 30 分钟" /></div>
      <div class="field"><label for="date">打卡日期</label><input id="date" type="date" /></div>
      <button class="primary" id="check">打卡 / 撤销该日</button>
    </div>
    <div class="kpis">
      <div class="kpi"><b id="streak">0</b><span>当前连续天数</span></div>
      <div class="kpi"><b id="best">0</b><span>历史最长连续</span></div>
      <div class="kpi"><b id="total">0</b><span>累计打卡天数</span></div>
      <div class="kpi"><b id="rate">0%</b><span>近 28 周完成率</span></div>
    </div>
    <div class="heatmap" id="heat"></div>
    <p id="status"></p>
  </div>
  <script src="app.js"></script>
</body>
</html>

app.js

数据模型、连续天数算法、最长连续、完成率与热力图渲染,全部为纯函数 + 一个 render 入口。

js
/* 习惯打卡:数据模型 + 连续天数算法 + 热力图渲染(无依赖,可直接运行) */
const KEY = "nocau-habit-log";
const DAY = 86400000;

const todayStr = () => new Date().toISOString().slice(0, 10);
const parse = (d) => new Date(d + "T00:00:00Z").getTime();
const fmt = (t) => new Date(t).toISOString().slice(0, 10);

function loadLog() {
  try { return JSON.parse(localStorage.getItem(KEY)) || []; } catch (e) { return []; }
}
function saveLog(log) {
  log.sort();
  localStorage.setItem(KEY, JSON.stringify(log));
}

/* 打卡 / 撤销:返回 true 表示本次为新增打卡 */
function toggleCheck(date = todayStr()) {
  const log = loadLog();
  const i = log.indexOf(date);
  if (i >= 0) log.splice(i, 1); else log.push(date);
  saveLog(log);
  return i < 0;
}

/* 当前连续:从今天往前数,允许今天未打卡时从昨天起算 */
function currentStreak(log, ref = todayStr()) {
  const set = new Set(log);
  let cursor = parse(ref);
  if (!set.has(fmt(cursor))) cursor -= DAY;      // 今天未打卡不打断连击
  let streak = 0;
  while (set.has(fmt(cursor))) { streak += 1; cursor -= DAY; }
  return streak;
}

/* 历史最长连续 */
function bestStreak(log) {
  const days = [...new Set(log)].map(parse).sort((a, b) => a - b);
  let best = 0, run = 0, prev = null;
  for (const t of days) {
    run = prev !== null && t - prev === DAY ? run + 1 : 1;
    prev = t;
    if (run > best) best = run;
  }
  return best;
}

/* 近 weeks 周热力图:列=周(周一起),行=周一..周日 */
function heatmap(log, weeks = 28) {
  const set = new Set(log);
  const today = parse(todayStr());
  const dow = (new Date(today).getUTCDay() + 6) % 7;        // 周一=0
  const end = today + (6 - dow) * DAY;                      // 本周周日
  const start = end - (weeks * 7 - 1) * DAY;
  const rows = [];
  for (let w = 0; w < weeks; w++) {
    const col = [];
    for (let d = 0; d < 7; d++) {
      const t = start + (w * 7 + d) * DAY;
      col.push({ date: fmt(t), done: set.has(fmt(t)), future: t > today, today: t === today });
    }
    rows.push(col);
  }
  return rows;
}

/* 近 28 周完成率(不含未来日期) */
function completionRate(log, weeks = 28) {
  const set = new Set(log);
  const grid = heatmap(log, weeks).flat().filter((c) => !c.future);
  if (!grid.length) return 0;
  return Math.round((grid.filter((c) => c.done).length / grid.length) * 100);
}

function render() {
  const log = loadLog();
  const name = document.getElementById("name").value.trim() || "未命名习惯";
  document.getElementById("streak").textContent = currentStreak(log);
  document.getElementById("best").textContent = bestStreak(log);
  document.getElementById("total").textContent = log.length;
  document.getElementById("rate").textContent = completionRate(log) + "%";

  const heat = document.getElementById("heat");
  heat.innerHTML = "";
  heatmap(log).forEach((col) =>
    col.forEach((c) => {
      const cell = document.createElement("div");
      cell.className = "cell" + (c.done ? " on" : "") + (c.today ? " today" : "");
      cell.title = c.date + (c.future ? "(未来)" : c.done ? " 已打卡" : " 未打卡");
      heat.appendChild(cell);
    })
  );
  document.getElementById("status").textContent =
    `「${name}」累计 ${log.length} 天,当前连续 ${currentStreak(log)} 天。`;
}

if (typeof document !== "undefined") {
  document.addEventListener("DOMContentLoaded", () => {
  const dateInput = document.getElementById("date");
  dateInput.value = todayStr();
  document.getElementById("check").addEventListener("click", () => {
    const on = toggleCheck(dateInput.value || todayStr());
    document.getElementById("status").textContent = on ? "已记录打卡。" : "已撤销该日打卡。";
    render();
  });
  document.getElementById("name").addEventListener("input", render);
  render();
});
}


/* Node 端自测导出:浏览器中 module 未定义,此段不会执行 */
if (typeof module !== "undefined" && module.exports) {
  module.exports = { KEY, todayStr, toggleCheck, loadLog, saveLog,
                     currentStreak, bestStreak, heatmap, completionRate };
}

README.md

运行方式与扩展点说明。

md
# 习惯打卡与连续天数(habit-tracker)

零依赖的浏览器端习惯追踪小工具:localStorage 持久化、连续天数统计、GitHub 风格热力图。

## 运行

1. 直接双击 index.html(或拖入浏览器)即可使用,无需构建、无需依赖。
2. 数据保存在浏览器 localStorage 的 `nocau-habit-log` 键,清空浏览器数据会一并清除。

## 自测(零依赖,Node 14+)

```bash
# 连续天数 / 历史最长连续 两个纯算法的最小用例
node -e "const m=require('./app.js');console.log(m.currentStreak(['2026-09-18','2026-09-19'],'2026-09-19'),m.bestStreak(['2026-09-01','2026-09-02','2026-09-03']))"
# 期望输出:2 3
```

浏览器端无需任何命令:双击 `index.html` 即是完整功能。

## 二次开发

- 多习惯:把 KEY 改为 `nocau-habit-log:<习惯名>`,或存对象 `{ habit: [dates] }`。
- 导出数据:`JSON.stringify(loadLog())` 可一键导出;恢复时 `saveLog(arr)` 写回。
- 对接后端:把 loadLog / saveLog 换成 fetch 调用即可,其余算法无需改动。

运行标注

  1. 可直接运行:3 个文件放同一目录后双击 index.html,即可完成「打卡 → 看当前连续 / 历史最长 → 看热力图」的完整流程,零依赖、零构建、零配置。
  2. 可直接运行(命令行最小用例,Node 14+,零依赖):node -e "const m=require('./app.js');console.log(m.currentStreak(['2026-09-18','2026-09-19'],'2026-09-19'),m.bestStreak(['2026-09-01','2026-09-02']))",期望输出 2 2;app.js 末尾已加 UMD 导出,浏览器端不受影响。
  3. 需先改的配置项:无。想改热力图周数改 app.js 中 heatmap(log, weeks = 28) 的默认值;换数据键名改顶部 KEY;改配色改 index.html 里的 CSS 变量。
  4. 数据只存在本机浏览器 localStorage(键名 nocau-habit-log),不联网、不上传。
复制结果为 # ===== 文件名 ===== 分隔的多文件文本,按文件名分别保存即可。

来源参考

实现所依据的官方文档(均可直接访问):