
我翻了上百个首页发现最容易让人想每天“登录”的功能居然不是复杂的数据大屏而是 GitHub 主页那一排排绿色格子。很多人嘴上说这是“程序员种菜”但手却很诚实为了不让绿点断掉硬是逼自己每天提交一次代码。这个设计真正厉害的地方不是它炫耀了提交次数而是把一年 365 天压缩进一屏用颜色深浅告诉你你哪些天在行动哪些天在偷懒。这种“可视化 连续反馈”的机制完全可以迁移到读书这件事上。这篇文章要做的就是实现一个 GitHub Heatmap for Reading把 GitHub 贡献热力图的视觉效果和交互思维搬到一个纯前端的“阅读记录热力图”页面上。你可以用它记录每天的阅读分钟数和页数生成类似 GitHub 主页的绿点矩阵并通过 GitHub Pages 免费部署再配合 GitHub Actions 自动更新数据。读完这篇文章你会得到三样东西第一理解 GitHub 热力图Contribution Heatmap背后的数据结构和渲染原理第二拿到一套可以直接复制运行的 HTML / CSS / JavaScript 代码以及配套的 JSON 数据文件第三掌握从本地预览、推送仓库到开启 Pages、配置定时任务的全流程并知道常见的坑在哪里。1. 为什么要做一张“阅读热力图”很多人试过用打卡 App 或者手账记录阅读但能坚持过三个月的并不多。原因在于列表式记录给不了反馈。你打开 App 看到的是“今天读完 1 章”“本周阅读 3 次”这些数字冷冰冰的一旦断了一两天很容易产生“干脆算了”的破罐子心理。但 GitHub 热力图不一样。每次看到那一整年的格子你会自动去寻找“连续绿了几天”的链条。这种 streak连续记录机制给大脑的暗示是你已经坚持了 12 天今天断掉太可惜。哪怕当天只提交一行文档修改也会保住链条于是行为就持续下去了。把这套逻辑搬到阅读上思路很清晰GitHub 贡献热力图阅读热力图每天提交代码次数每天阅读分钟数 / 页数提交次数决定颜色深浅阅读时长决定颜色深浅没有提交 灰色格子没有阅读 灰色格子连续提交天数streak连续阅读天数一年 365 天矩阵一年 365 天矩阵这个工具本质上不是帮你读书而是帮你看见自己的阅读轨迹。当你把一年压缩到一张图里你会清楚地看到自己哪几个月是“读书高峰”哪几个月是“彻底停摆”。有了这种反馈你才更容易做出调整。它适合谁适合想养成阅读习惯的开发者适合正在学习前端想拿真实场景练手的人也适合对 GitHub 生态感兴趣、想体验 Actions 和 Pages 的人。它不适合把“点亮格子”当成唯一目标而忽视真实阅读的人——热力图只能记录行为不能替代行为。2. GitHub Heatmap 的核心原理先明确一个概念GitHub 主页上的 Contribution Graph严格来说不是统计学意义上的“热力图heatmap”而是一张矩阵日历图calendar heatmap。它是一个 7 行、约 53 列的网格。行代表星期几。第一行是周日。列代表一年里的某一周。每个格子代表一天。颜色深浅代表当天贡献的多少。GitHub 默认将颜色分成 5 档0 提交是浅灰色1 到 3 次提交是很浅的绿色4 到 6 次是中等绿色7 到 9 次是深绿色10 次以上是最深的绿色。这个分档不是固定编码写死的而是根据当天的提交数量划分区间本质是把数值映射为颜色等级。对于阅读热力图我们做同样的映射30 分钟以上阅读最深色20 到 29 分钟较深色10 到 19 分钟中等绿色1 到 9 分钟浅绿色0 分钟灰色从前端实现的角度渲染逻辑可以拆成三步生成一年里所有的日期。找到这一年第一天所在周的周日作为矩阵的第一个格子防止第一列日期错乱。按日期顺序逐个创建格子根据当天数据判断颜色等级然后填充样式。为什么不需要后端因为所有数据都可以存到一个 JSON 文件里。前端通过 fetch 读取这个 JSON再用 DOM 操作生成格子。页面不需要数据库、不需要服务器甚至不需要 Node.js 环境一个静态服务器就够了。这也正是它能部署到 GitHub Pages 的原因。3. 环境准备与前置条件这个项目的环境要求非常低即使你之前没部署过任何网站也可以按下面的清单准备依赖说明是否必须浏览器Chrome / Edge / Firefox 均可必须GitHub 账号托管代码、开启 Pages必须Git 客户端命令行推送代码强烈建议文本编辑器VS Code、Sublime Text、Vim 均可建议Python 3可选用于启动本地 HTTP 服务或生成数据可选版本方面不用纠结。这个方案不依赖某个特定版本的框架Git 使用 2.x 主流版本即可Python 不是必须项如果本机没有可以直接用 VS Code 的 Live Server 插件替代本地服务器。有一点需要提前说明如果直接用浏览器双击打开index.html页面的基础布局能显示但 fetch 本地data.json会被浏览器的 file:// 策略拦截控制台会报Failed to fetch。所以本地预览建议统一使用本地 HTTP 服务后面第 4 章会给出启动命令。4. 核心流程拆解整个项目的流程可以拆成 8 个步骤。我把每一步的“做什么”和“为什么”都说清楚避免你跟着敲完代码却不知道自己在哪一步。4.1 第一步在 GitHub 创建远程仓库登录 GitHub 后点击右上角加号选择 New repository。仓库名可以填reading-heatmap可见性建议先选 Private私有等你确定内容没问题再改成 Public。不要勾选 “Add a README file”因为我们要从本地推送代码空仓库可以避免初始提交冲突。这个仓库的作用有两个一是保存项目的源码和阅读数据二是开启 GitHub Pages 后它会把仓库里的静态文件发布成一个可访问的网页。4.2 第二步在本地创建项目目录在本地新建一个文件夹名字建议与远程仓库保持一致例如reading-heatmap。在这个目录下我们需要准备这些文件reading-heatmap/ ├── index.html ├── style.css ├── app.js ├── data.json ├── reading.log ├── .github/ │ └── workflows/ │ └── update-heatmap.yml └── scripts/ └── generate_data.pyreading.log是人类的原始阅读记录generate_data.py会把它转换成前端读取的data.json。如果暂时不想用 Python 脚本也可以直接编辑data.json。4.3 第三步编写页面三件套index.html负责页面结构style.css负责热力图样式app.js负责读取数据和渲染格子。三者通过文件路径引用关联文件必须放在同一层级。4.4 第四步准备阅读记录数据你可以直接在data.json里写当天记录也可以在reading.log里按行写入然后由脚本自动生成 JSON。两种方式最终产出的数据结构一样。4.5 第五步本地预览在项目目录打开终端执行python -m http.server 8080然后浏览器访问http://localhost:8080。如果看到满屏灰色格子以及底部四个统计数字说明页面已经跑通了。4.6 第六步推送代码到 GitHub 并开启 Pages用 Git 把本地文件提交并推送到远程仓库。然后进入仓库的 Settings → Pages将 Source 设置为 Deploy from a branch分支选择 main路径选择 /root保存即可。4.7 第七步用 GitHub Actions 自动更新数据可选如果你希望“每天自动把 reading.log 里新增的天数同步到 data.json 并提交到仓库”可以添加一个定时 workflow。它会在每天固定时间运行一次 Python 脚本然后自动提交变化。这个步骤不是必须的但它是理解 GitHub Actions 用法的一个很好的练手项目。4.8 第八步持续记录与迭代之后你每天只需要做一件事往reading.log里追加一行比如2025-06-10 35 18代表当天阅读 35 分钟、18 页。剩下的渲染和部署都是自动的。5. 完整代码实现下面是整个项目最小的可运行实现文件都标注了路径。你可以照着创建先让它跑起来再去改颜色和数据。5.1 index.html!-- 文件路径reading-heatmap/index.html -- !DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / title阅读热力图 Reading Heatmap/title link relstylesheet hrefstyle.css / /head body main classcontainer header classpage-header h1Reading Heatmap/h1 p classsubtitle用 GitHub 风格热力图记录每天的阅读时间/p /header section classheatmap-card div classheatmap-meta span idyearLabel2025/span span classlegend span少/span span classlegend-level/span span classlegend-level/span span classlegend-level/span span classlegend-level/span span多/span /span /div div idheatmap classheatmap/div /section section classstats div classstat-item strong idtotalDays0/strong span累计阅读天数/span /div div classstat-item strong idtotalMinutes0/strong span累计阅读分钟/span /div div classstat-item strong idstreak0/strong span当前连续天数/span /div div classstat-item strong idavgMinutes0/strong span日均分钟/span /div /section /main script srcapp.js/script /body /html页面结构不复杂一个标题区、一个热力图卡片、四个统计卡片。热力图容器#heatmap是空的由 JavaScript 动态填充格子。5.2 style.css/* 文件路径reading-heatmap/style.css */ * { box-sizing: border-box; } body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, Segoe UI, PingFang SC, Hiragino Sans GB, Microsoft YaHei, sans-serif; background: #f6f8fa; color: #24292f; } .container { max-width: 920px; margin: 40px auto; padding: 0 20px; } .page-header h1 { margin: 0 0 8px; font-size: 32px; } .subtitle { color: #57606a; margin: 0 0 24px; } .heatmap-card { background: #fff; border: 1px solid #d0d7de; border-radius: 12px; padding: 20px; overflow-x: auto; } .heatmap-meta { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; font-size: 14px; color: #57606a; } .heatmap { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 12px); gap: 3px; } .day-cell { width: 12px; height: 12px; border-radius: 2px; background-color: #ebedf0; } .day-cell.l1 { background-color: #c6e48b; } .day-cell.l2 { background-color: #7bc96f; } .day-cell.l3 { background-color: #196c2e; } .day-cell.l4 { background-color: #0f4a1d; } .legend { display: flex; align-items: center; gap: 4px; } .legend-level { width: 12px; height: 12px; border-radius: 2px; background-color: #ebedf0; } .legend-level:nth-child(2) { background-color: #c6e48b; } .legend-level:nth-child(3) { background-color: #7bc96f; } .legend-level:nth-child(4) { background-color: #196c2e; } .legend-level:nth-child(5) { background-color: #0f4a1d; } .stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-top: 20px; } .stat-item { background: #fff; border: 1px solid #d0d7de; border-radius: 12px; padding: 16px; text-align: center; } .stat-item strong { display: block; font-size: 28px; margin-bottom: 4px; } .stat-item span { font-size: 14px; color: #57606a; } media (max-width: 600px) { .stats { grid-template-columns: repeat(2, 1fr); } }这里最关键的是.heatmap容器的 CSS Grid 写法。grid-template-rows: repeat(7, 12px)保证了每一列有 7 行对应周一到周日grid-auto-flow: column让子元素按“先填满一整列再进入下一列”的顺序排列正好与 GitHub 的布局一致。手机端可能出现横向滚动所以给.heatmap-card加了overflow-x: auto。5.3 app.js// 文件路径reading-heatmap/app.js const HEATMAP_YEAR 2025; function loadData() { return fetch(data.json) .then((response) { if (!response.ok) { throw new Error(data.json 加载失败请确认文件存在且路径正确); } return response.json(); }) .then((json) json.records || []); } function formatDate(date) { const y date.getFullYear(); const m String(date.getMonth() 1).padStart(2, 0); const d String(date.getDate()).padStart(2, 0); return ${y}-${m}-${d}; } function buildDayMap(records) { const map {}; records.forEach((item) { const date item.date; const minutes Number(item.minutes) || 0; const pages Number(item.pages) || 0; if (map[date]) { map[date].minutes minutes; map[date].pages pages; } else { map[date] { minutes, pages }; } }); return map; } function getLevel(minutes) { if (minutes 0) return 0; if (minutes 10) return 1; if (minutes 20) return 2; if (minutes 30) return 3; return 4; } function renderHeatmap(dayMap) { const container document.getElementById(heatmap); const start new Date(HEATMAP_YEAR, 0, 1); const end new Date(HEATMAP_YEAR, 11, 31); // 回退到 1 月 1 日所在周的周日保证第一列完整 const firstCell new Date(start); firstCell.setDate(firstCell.getDate() - firstCell.getDay()); const cells []; const cursor new Date(firstCell); while (cursor end) { cells.push(new Date(cursor)); cursor.setDate(cursor.getDate() 1); } const fragment document.createDocumentFragment(); cells.forEach((date) { const cell document.createElement(div); cell.className day-cell; if (date.getFullYear() HEATMAP_YEAR) { const key formatDate(date); const rec dayMap[key]; const minutes rec ? rec.minutes : 0; const level getLevel(minutes); if (level 0) { cell.classList.add(l level); } cell.title ${key}阅读 ${minutes} 分钟${ rec rec.pages ? rec.pages 页 : }; } fragment.appendChild(cell); }); container.innerHTML ; container.appendChild(fragment); } function computeStreak(dayMap) { const today new Date(); let streak 0; const cursor new Date(today); while (true) { const key formatDate(cursor); const rec dayMap[key]; if (rec rec.minutes 0) { streak; cursor.setDate(cursor.getDate() - 1); } else { break; } } return streak; } function renderStats(dayMap, records) { const dayKeys Object.keys(dayMap); const totalDays dayKeys.filter((k) dayMap[k].minutes 0).length; const totalMinutes records.reduce( (sum, r) sum (Number(r.minutes) || 0), 0 ); const streak computeStreak(dayMap); document.getElementById(totalDays).textContent totalDays; document.getElementById(totalMinutes).textContent totalMinutes; document.getElementById(streak).textContent streak; document.getElementById(avgMinutes).textContent totalDays ? Math.round(totalMinutes / totalDays) : 0; } async function init() { try { const records await loadData(); const dayMap buildDayMap(records); renderHeatmap(dayMap); renderStats(dayMap, records); } catch (err) { document.getElementById(heatmap).innerHTML p stylecolor:#cf222e加载失败 err.message /p; } } init();这段代码里有两个容易出错的点需要重点解释。第一firstCell的回退逻辑。如果年份的第一天不是周日直接把它放在矩阵第一列会导致这一列排不满 7 个格子后续所有日期都会错位一格。所以代码先new Date(HEATMAP_YEAR, 0, 1)再用setDate(getDate() - getDay())回退到所在周的周日。getDay()返回 0 表示周日1 表示周一依次类推。第二while (cursor end)的循环会生成 365 到 371 个日期这是因为矩阵的起始点会向前回退几天保证整个年份能占满完整列。这也是 GitHub 热力图实际渲染时一年总是 53 列左右的原因。统计部分的“当前连续天数”从今天开始向前数遇到没有阅读记录的天就停止。这里需要注意如果今天还没有记录streak 会直接显示 0。设计上可以接受因为它忠实反映“目前链条是否断掉”。5.4 data.json{ year: 2025, records: [ { date: 2025-01-01, minutes: 25, pages: 12 }, { date: 2025-01-02, minutes: 40, pages: 20 }, { date: 2025-01-05, minutes: 60, pages: 30 }, { date: 2025-01-06, minutes: 15, pages: 8 } ] }数据结构很简单records是一个数组每个元素表示某一天的阅读情况。date使用YYYY-MM-DD格式这样可以避免浏览器解析日期的时区差异。minutes是阅读分钟数pages是阅读页数页数不是必须项但保留它可以让 title 提示更丰富。5.5 GitHub Actions 工作流如果你希望每天自动生成 data.json需要把原始记录放到reading.log然后在.github/workflows/下创建下面的文件。# 文件路径reading-heatmap/.github/workflows/update-heatmap.yml name: Update Reading Heatmap on: schedule: - cron: 0 16 * * * workflow_dispatch: permissions: contents: write jobs: update: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkoutv4 - name: Set up Python uses: actions/setup-pythonv5 with: python-version: 3.12 - name: Generate data.json run: python scripts/generate_data.py - name: Commit changes run: | git config user.name github-actions[bot] git config user.email github-actions[bot]users.noreply.github.com git add data.json git diff --quiet git diff --cached --quiet || git commit -m chore: update reading data git pushschedule中的 cron 表达式使用 UTC 时间。0 16 * * *表示每天 UTC 16:00 运行一次对应北京时间次日 0 点。Actions 的定时任务不保证严格准时尤其在仓库不活跃时可能延迟但作为每日更新阅读数据这种非实时任务完全够用。workflow_dispatch是手动触发入口方便你在不了解 cron 规则时先跑一次流程。permissions里只需要contents: write这是因为脚本要写回data.json。不要把这里改成过大的权限也不要在 YAML 里写自己的个人访问令牌。5.6 scripts/generate_data.py# 文件路径reading-heatmap/scripts/generate_data.py import json from datetime import date LOG_FILE reading.log OUTPUT_FILE data.json def parse_log_line(line): parts line.strip().split() if len(parts) 2: return None date_str parts[0] try: date.fromisoformat(date_str) except ValueError: return None minutes int(parts[1]) if len(parts) 1 else 0 pages int(parts[2]) if len(parts) 2 else 0 return {date: date_str, minutes: minutes, pages: pages} def main(): records [] with open(LOG_FILE, r, encodingutf-8) as f: for line in f: line line.strip() if not line or line.startswith(#): continue record parse_log_line(line) if record: records.append(record) with open(OUTPUT_FILE, w, encodingutf-8) as f: json.dump( {year: date.today().year, records: records}, f, ensure_asciiFalse, indent2, ) print(fgenerated {OUTPUT_FILE}, {len(records)} records) if __name__ __main__: main()reading.log的每行格式为日期 分钟 页数例如2025-06-10 35 18 2025-06-11 20 9脚本会自动跳过空行和#开头的注释行方便你留备注。这个脚本在本地也可以运行只要在项目根目录执行python scripts/generate_data.py就会重新生成data.json。如果你的 Python 版本较低date.fromisoformat也能正常工作因为这里传入的是标准YYYY-MM-DD字符串。6. 运行结果与效果验证代码写完后验证要分三个层次本地验证、部署验证、自动化验证。每一步的预期结果和排查方向如下。6.1 本地验证在项目目录启动本地服务python -m http.server 8080浏览器访问http://localhost:8080。预期看到页面标题显示“Reading Heatmap”。热力图区域出现一整年 7 行多列的格子。有阅读记录的日期显示为绿色等级不同颜色不同。底部统计数字会随着data.json变化。打开浏览器开发者工具F12切到 Console 和 Network。Console 不应报错Network 里data.json请求状态应该是 200。如果双击index.html打开Network 里data.json请求会失败因为浏览器在file://协议下默认不允许 fetch 本地文件。此时只要改用本地 HTTP 服务即可。6.2 部署验证推送代码后在 GitHub 仓库的 Settings → Pages 中设置Source 选择 Deploy from a branchBranch 选择 main目录选择 /root点击 Save。等待几分钟后访问https://你的用户名.github.io/reading-heatmap/如果页面 404先看仓库名是不是reading-heatmap再看看分支名是不是main最后确认 index.html 在仓库根目录而不是子目录。Pages 构建通常需要几分钟到十几分钟不用急着刷新。6.3 自动化验证如果配置了 Actions进入仓库的 Actions 标签页能看到Update Reading Heatmap这个 workflow。第一次可以直接点右上角的 “Run workflow” 手动触发。运行完成后点进日志如果最后出现generated data.json, N records说明脚本执行成功之后检查仓库里的data.json是否被更新。一个容易忽略的点Actions 更新data.json后GitHub Pages 不会立刻生效因为它需要重新构建静态页面。如果你发现数据变了但网页没变等一会儿再刷新即可。7. 常见问题与排查思路在实际操作中最常见的坑我整理成了一张表。建议收藏这篇文章遇到问题时按表排查。问题现象可能原因排查方式解决方案GitHub 页面打开很慢或超时国内访问 GitHub 网络链路不稳定不同时间段多刷新几次查看是否能打开其他网站只查看单文件或 README 时可使用 GitHub 镜像站或代码托管平台预览克隆大仓库时加--depth 1本地打开 index.html 页面空白浏览器的 file:// 协议拦截了 fetch 请求F12 打开 Console看是否有 Failed to fetch用python -m http.server 8080启动本地服务热力图格子对不上日期起始日期没有回退到所在周的周日检查 1 月 1 日的getDay()值使用firstCell.setDate(firstCell.getDate() - firstCell.getDay())所有格子都是灰色data.json 日期不是当前年份或分钟数为 0查看 data.json 的 date 字段在 Console 打印 dayMap更新 HEATMAP_YEAR把分钟数改为正数GitHub Pages 访问 404仓库名、分支名或路径配置不对检查 URL 大小写确认 Pages 设置重新设置 Deploy from a branch 为 main / rootActions 没有自动运行cron 使用 UTC 时间或仓库默认分支不是 main查看 Actions 日志先手动 Run workflow确认脚本可以执行网页上传大量文件容易失败网页拖拽上传不稳定查看是否上报超时改用 git 命令行推送大文件先下载 zip 再解压第 6 个问题需要单独解释一下。Actions 的schedule使用 UTC 时间而且 GitHub 官方明确说明定时任务可能因为仓库长时间不活跃而推迟执行。如果你的仓库只有每天一次更新遇到延迟是正常的不要因此认为脚本写错了。手动触发一次确认逻辑正确后剩下的交给它每天跑即可。8. 最佳实践与工程建议到这里项目已经能跑通了。但作为一个长期记录工具还有几件事值得在正式使用前想清楚。8.1 数据格式要统一data.json里的日期必须全部使用YYYY-MM-DD不要混用2025/1/1或2025-1-1。JavaScript 的new Date()对不同格式的解析结果可能不同尤其在本地时区设置不一样时会出现日期偏移一天的诡异问题。统一格式可以从根上避免这类 bug。8.2 记录粒度建议只保留一个核心指标README 和很多示例都喜欢同时记录“分钟数”和“页数”但你实际使用时建议只把“分钟数”作为唯一决定颜色的核心指标。页数可以作为辅助字段展示但如果同时用它来分色不同书籍的排版差异会让数据失去可比性。比如同样读 30 分钟一本技术书可能只读 10 页一本小说能读 40 页简单用页数画颜色会很不公平。8.3 仓库可见性与隐私边界如果阅读记录涉及个人隐私仓库设为 Private但你仍然想用 GitHub Pages 公开这是矛盾的Pages 只对 Public 仓库开放。所以你有两个选择一是仓库 Private只本地访问二是 Public接受阅读数据公开。更稳妥的做法是数据脱敏后再放到 Public比如data.json里不记录书名和页面内容只保留日期和分钟数。8.4 自动化任务的安全边界在 GitHub Actions 中尽量使用仓库内置的GITHUB_TOKEN不要在 YAML 文件里硬编码自己的个人访问令牌。workflow 的permissions要按最小权限声明我们这个项目只需要contents: write。如果以后把脚本改成发布到其他平台再根据需求扩展权限不要一次性写一个很大的 scope。8.5 跨年后的维护代码里的HEATMAP_YEAR写死为 2025。跨年后要么手动改这个变量要么做成自动获取当前年份。后者实现很简单把HEATMAP_YEAR改为new Date().getFullYear()。但如果你希望页面默认展示“上一年”而不是“今年”就需要保留年份切换的功能这部分已经超出本文范围可以作为进阶练习。8.6 不要为了点亮格子而造假这句话听起来像鸡汤但它其实是热力图数据质量的底线。如果某天没有阅读就让它保持灰色。一旦你开始为了 streak 随意填写分钟数这张图的数据意义就消失了你会慢慢失去记录的动力。诚实的数据才能给你真实的反馈。9. 总结GitHub Heatmap for Reading 不是一个复杂项目它本质上就是“矩阵日历 颜色映射 数据文件”。认识到了这一点你会发现 GitHub 那一整套热力图交互并不神秘。真正有价值的是它背后的行为设计把时间压缩成一屏让长期趋势对肉眼可见。你可以用同样的框架记录冥想、锻炼、写作甚至背单词。建议你先不要急着加功能按本文的代码原样部署一个小站跑通整个流程。等到连续记录一周之后再考虑增加“最近 30 天摘要”“年度统计”“按书籍维度拆分”之类的扩展。热力图的价值会随着数据积累越来越明显前期最重要的反而不是功能而是开始记录。收藏这篇文章并且动手建一个自己的