---
description: "本篇是《让 Kindle 常显一块 Token 看板》的决策日志 —— 主文把结论整理成了五条达标线和五个设计决策，这一页按时间记当时为什么这么选，包括几条后来被自己推翻的"
---

# 决策日志：这块看板是怎么一条条定下来的

!!! abstract "这是过程记录"
    本篇是[《让 Kindle 常显一块 Token 看板》](../index.md)的决策日志 —— 主文把结论整理成了五条达标线和五个设计决策，这一页按时间记**当时为什么这么选**，包括几条后来被自己推翻的。

    规矩是**只增不改**：一条决策被推翻时，新写一条说明理由，旧的那条留着。所以往下读会看到互相矛盾的条目，那是有意的 —— 矛盾本身就是信息。

    [← 回到主文：设计](../index.md)

## 2026-07-25 · 骨架成型

- **契约模型字段名与 `test/mock.js` 完全一致**，前端组件零改动直接换数据源。
- **调度不用 APScheduler / cron**，`asyncio` 循环挂在 lifespan 上 —— 单进程内自洽，部署只有 uvicorn 一个入口。
- **采样历史用 SQLite 而非内存缓冲**：重启后 15 分钟差分不归零。
- **源返回累计值、无状态**；差分统一由 `SampleStore` 计算。
- **限额环排序约定：重置周期短的在外圈** —— 外圈弧长大、变化醒目。
- **Kindle 为横屏 1448×1072**（framebuffer 1072×1448 旋转 270°），双栏构图。
- **差分表达：Web 用同色系亮色，Kindle 用 45° 斜纹**；差分窗口 15 分钟。
- **时间戳记的是"数据最后变化时刻"而不是"最后拉取时刻"**。否则 PNG 每分钟都不同，墨水屏会无意义闪刷一次；代价是没有新用量时看板时间会停住 —— 这正是想要的信号。
- **极短的差分弧再加一条白色起点分隔线**：内圈 +2% 这类差分连一条完整斜纹都放不下，只有分隔线能表达"这里起是新增"。
- **整图 2× 超采样后 LANCZOS 缩回**：Pillow 不抗锯齿，圆弧直接画毛刺明显。单帧约 90ms，可接受。
- **环灰度改为 `#000/#5a/#9c` + 轨道 `#dc`**：墨水屏只有 16 级灰，原来 `#ab` 内圈与 `#e6` 轨道糊在一起。

## 2026-07-26 · 上报链路

- **旋转放在推送侧而非渲染侧**：`fbink -i` 按 framebuffer 原生方向（竖屏 1072×1448）贴图、自己不转，横图会被裁掉右边。故渲染仍产出横屏 1448×1072（Web 预览可读），`push.to_framebuffer()` 再转成竖屏落 `-fb.png` 后推送。角度由 `kindle_rotate` 控制（PIL 逆时针语义），真机实测取 `90`；它一并计入推送指纹，改了方向能立刻重推，不会被"内容没变"挡掉。
- **限额数据走上报而非拉取**：那些额度只有跑着 CLI 的机器才捞得到，Pi 不该装一堆 CLI、更不该 ssh 到每台机器去执行。`PushSource` 缓存最近一次上报、`fetch()` 直接返回它，于是 SampleStore / 差分 / 快照组装 / 状态跟踪一行都不用改。
- **状态条的 `updated_at` 改用数据自己的时刻**（新增 `DataSource.data_at()`，上报型源返回 `_received_at`）。`SourceTracker._digest` 是纯内存的，重启后第一轮必然判定"数据变了" → 原来会把时间写成本轮 tick 时刻，于是屏上显示成刚刚更新过，而那份数据可能是几小时前报上来的（回放上线后这个假象尤其明显）。同时跨天的时间带上日期（`07-25 18:36`），否则一份昨天的数据只显示时分、跟刚更新的没区别。重置文案也改成 `notes()` 里按当前时间现算 —— 存下来的"明日 18:00 重置"过一夜就成了错话。
- **启动时从 `reports` 表回放每个上报源最近一次数据**（`_restore_push_sources`）。上报是稀疏的：Claude 靠手动敲命令、Codex 靠 hook 在对话结束时触发，重启后干等下一次上报可能要等几小时，而那期间 metric 不进采样、环会被 `ring_stale_cycles` 判成"不再上报"直接消失（Codex 尤其难受，它没法手动补一次）。原始信封本来就存着，回放成本只有一条 SQL。
- **限额环的差分口径按维度分开**（`Dimension.delta_mode`）：5 小时窗口看"最近 15 分钟"、周窗口看"今日新增"（新增 `store.delta_since()`）。跟两条 tier-bar 是同一个道理 —— 拿 15 分钟去切一个 7 天的额度看不出任何东西。顺带把图例那句文案收进后端 `Ring.delta_label`：口径按维度不同，让两个渲染端各自拼这句话就一定会拼错（之前两边都硬编码着"最近 15 分钟"）。

## 2026-07-26 · 时间线与去重

- **限额环上加"时间线"**（`Ring.time_pct`）：箱形图那样的横杠—竖线—横杠，标出这个额度窗口走到哪儿了，线在填充前面 = 用得比时间慢。窗口起点 = `resets_at - window`，`window` 写在 `dimensions.py`（5 小时 / 7 天是产品常量，CC 的输出里根本没这个信息），所以**上报方不用改任何东西**。
- **时间线不进数据指纹**：它每分钟都在走，进了就等于每分钟刷屏，推翻当天刚修好的去重。改用 `force_push_interval_min=10` 兜底 —— 数据没变也隔 10 分钟强刷一次，让线走起来。顺带治了墨水屏长期不刷积累残影的老毛病。上次推送时刻直接读 `last-sent.sha256` 的 mtime，不用多存一个文件。
- **时间线与用量末端之间沿环连一串稀疏的点**（`_gap_arc` / `stroke-dasharray`）：这段弧的长度就是"用量领先/落后时间多少"，比单看两个位置好读得多。用点线而非实线是因为它只是辅助刻度，实线会跟主体弧抢注意力；点长 3 / 间隔 13（viewBox），间隔给到点长的四倍以上 —— 等长的 dash 密得发腻。**只在用量落后于时间时画**：超前时那段已经被填充盖住，再叠一条纯属干扰 —— 填充自己越过时间线这件事本身已经说明问题。所以点线固定深色，不必按状态换色。差不到 1.5° 就不画（挤在一起反而脏）。点长与间隔都按弧长换算成角度，否则内圈的点会比外圈密得多。

    （中间一度把环间距 `GAP` 从 12 加到 20 想给"须"腾地方，是理解错了需求 —— 要的从来不是径向的长须而是这条沿环的连线，那个改动已撤回。）

- **时间线画描边**（先粗白底、再压深色细芯）而不是按状态换颜色：用量超前时深色填充弧会盖住这条线，而那恰恰是最该看清的时候。有了描边，压在深填充上靠白边显形、压在浅轨道上靠黑芯显形，也不必在临界点上反复换色。
- **两条进度条的差分口径分开**：today 是滚动窗口（`today_delta_window_min`），week 改成"今天新增" —— 拿 15 分钟去切一条按周累计的条子没有意义。而"今天新增"就是 `today_tokens` 的当前值，不用另算（同一条 datain SQL 出来的，今天的量必然属于本周）。标签也跟着分开写（"最近 15 分钟 +N 亿" / "今日 +N 亿"），否则读者会默认两条是同一个窗口。
- **推送去重改为比对数据指纹**（`snapshot.data_digest()`）而非 PNG 字节。图上印着「更新 HH:MM」，按图去重等于每分钟都判定「有变化」—— 原先靠 07-25 那条「时间戳记数据最后变化时刻」的约定间接兜住，是**拿一个约定兜另一层的漏洞**，任何展示性文案（重置提示、状态文字）变动都会误触发闪刷。指纹只认 `metrics` / `layout` / `source.status`，剔除 `generated_at` 与各源 `updated_at`。07-25 那条时间戳约定仍保留，但语义已回归它本身（屏上时间 = 数据时刻），不再承担推送去重的职责。
- **指纹判断前置到渲染之前**（`push_if_changed` 拆成 `should_push` + `push_now`）：原先每分钟无条件跑一次 2× 超采样的 Pillow 整屏绘制、再判断要不要推，数据没变的那些分钟全是白烧的 CPU。代价是 `/kindle.png` 预览从「最近一轮渲染」变成「最近一次推送的渲染」—— 两者本来就该一致。

## 2026-07-26 · 接口收敛

- **上报只有一个正式接口** `POST /api/report`，所有源共用同一个 `ReportEnvelope`，区别只在 `limits` 报几条。曾经给 Codex 也加过一个 `/api/report/codex/raw` 专用端点，**已删**：那等于把 Pi 绑死在上游的字段名上，每加一个源都要在这边写 adapter，上游改字段还得改 Pi。上游格式与信封的差异由上报脚本消化（`docs/report-api.md` 里给了转换模板）。
- **唯一例外是 `claude_code/raw`**：CC 的用量输出是给人看的纯文本，管道里没法用纯 curl 转成 JSON，不特事特办就做不到"一行命令上报"。它产出的仍是同一个信封、走同一条 `_accept()` 通路，不是第二套契约。代价是 CC 改文案要改 `sources/claude_usage.py` —— 但只有一处，胜过去每台机器上改脚本。
- **上报不做保鲜期判定**（原本设计了 `report_max_age_s`，是错的）：上报可能是手动敲的、也可能只在有用量时才发，静默几小时是正常状态。真正让数据失效的是额度自己重置 —— 过了 `resets_at` 那个百分比就不成立了，此时归 0。这个判据与上报频率无关。
- **上报默认不立即刷新**，只有带 `?refresh=1` 才唤醒调度循环马上采样一轮。这个参数是给手动敲命令用的：自动上报本来就该等下一个周期，没必要每次都催、也没必要为此多刷一次墨水屏。做成唤醒 `asyncio.Event` 而不是让接口自己跑一次 `tick()`：那样两个 tick 会并发，同时 scp 到 Kindle 的临时文件 `dashboard.next.png` 会互相覆盖，fbink 可能读到写了一半的图。配 5 秒最小间隔防抖，免得上报端抽风重试就把外部拉取和墨水屏刷新也带成高频。
- **限额维度的顺序 / 标题 / 映射合并进 `dimensions.py` 一张表，并继续由看板写死、不跟随上报**。理由：①「重置周期短的在外圈」是展示决策，上报方没有这个上下文；②环半径 `148 - i*44`、灰度只有三档，第四环既看不清又会撞色，三环是硬上限，跟随上报等于让上游决定画不画得下；③上报偶发少一条（网络重试半截、CLI 输出变化）会让环数抖动、墨水屏跟着刷，多机上报时更会来回跳。合并成一张表是为了消掉"上报侧删了维度、展示侧忘了删"这类隐蔽错误。
- **`store.latest()` 加 `max_age`**：某个 metric 连续 3 个周期没进采样就当它不再被采集，该环直接不画，而不是拿几天前的历史值凑一个永不更新的百分比。⚠️ 只对限额环生效，tier-bars 不用 —— datain 断几分钟不代表今日 token 累计值失效，而且那两条主进度条消失会让双栏布局塌掉。

## 2026-07-26 · 上线前重构

- **`DataSource` 收敛为单通道结构化返回**。原先是 `fetch()/notes()/resets()/data_at()` 四个方法平行输出，且 notes 用的键约定（`*_reset`）跟 fetch/resets（metric 名）不一样，需要 `note_key/note_map/note_from` 一整套胶水；更糟的是三份字典在调度层被 `dict.update()` 进全局状态、键只增不删 —— `resets()` 刻意不返回已过期维度的设计被彻底架空，时间线会钉在 100% 而不是消失。现在 `fetch()` 返回 `Reading(samples={metric: Sample(value, resets_at)}, data_at)`，调度器按源**整体替换** `state.readings`，源不再产出的键自然消失；重置文案是展示逻辑，由快照层从 `resets_at` 现算（`fmt_reset` 也从 mock 模块迁回 `snapshot.py` —— 生产格式化函数寄生在 mock 文件里本身就是错位的信号）。净删约百行胶水。
- **启动回放改为逐条向前找可用留档**（`store.latest_reports`）：`reports` 表里混着解析失败的留档（`{"error":...}`），只取最新一条的话，重启前最后一次上报恰好是坏的就会把前面那条好数据也挡住。
- **修渲染端两处 viewBox 换算**：tier-bar 档位刻度线宽多乘了一次超采样倍数（画成规格 2 倍宽）；`_gap_arc` 的点线用 `s()` 换算 viewBox 坐标（应该像 `_time_mark` 一样用 `stroke/s(STROKE)` 反推卡片缩放比）。两个坑同源：`sx`（像素 / viewBox 单位）和 `sy`（纯比值）量纲不同但写法雷同。
- **补 pytest 套件（54 例）**：解析 / 差分 / 维度表 / 指纹剔除规则 / PushSource 契约 / 快照组装 / 调度 tick 集成（假源 + tmp 路径，不触网不碰真库）。mock 源的 metric 名与 `dimensions.py` 的一致性也有测试兜底。
- **Claude/Codex 两个 mock 源合并为参数化的 `MockLimitsSource`**：原先两个类 ~90% 相同，各带一个不可达的 `NotImplementedError` 守卫（`all_sources()` 只在 mock 开关开着时才实例化它们）。

---

往后继续记。设计层面的结论已经整理进[主文](../index.md)，这里只按时间留底。
