习惯打卡与连续天数
每日打卡、连续坚持天数统计与日历热力图,localStorage 本地存储。
在线演示
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;
}操作步骤
- 在"在线演示"区选好日期,点击"打卡 / 撤销该日"写入当天记录,再次点击同一日期即可撤销误操作。
- 顶部四张卡片实时刷新:当前连续天数(今天未打卡不清零,从昨天起算)、历史最长连续、累计打卡天数、近 28 周完成率。
- 热力图每列一周(上为周一),右侧为本周;深色格为已打卡,带描边的为今天。可先点"载入 90 天示例数据"快速看效果。
- 数据全部存于 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 调用即可,其余算法无需改动。
运行标注
- 可直接运行:3 个文件放同一目录后双击
index.html,即可完成「打卡 → 看当前连续 / 历史最长 → 看热力图」的完整流程,零依赖、零构建、零配置。 - 可直接运行(命令行最小用例,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 导出,浏览器端不受影响。 - 需先改的配置项:
无。想改热力图周数改app.js中heatmap(log, weeks = 28)的默认值;换数据键名改顶部KEY;改配色改index.html里的 CSS 变量。 - 数据只存在本机浏览器
localStorage(键名nocau-habit-log),不联网、不上传。
来源参考
实现所依据的官方文档(均可直接访问):