跳转至

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

这是过程记录

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

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

← 回到主文:设计

2026-07-25 · 骨架成型

  • 契约模型字段名与 test/mock.js 完全一致,前端组件零改动直接换数据源。
  • 调度不用 APScheduler / cronasyncio 循环挂在 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 - windowwindow 写在 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 开关开着时才实例化它们)。

往后继续记。设计层面的结论已经整理进主文,这里只按时间留底。