站点里有个页面叫 /masturbation,公开展示每天的次数和一张年度热力图,后台 /dash/masturbation 供管理员补记、修改、清除。数据很私人,页面很普通:一条按年查询的 RPC、一张日历、一个日期输入框加一个次数输入框。

上线当天就出了个会静默吃掉数据的 bug。

0 既是「这天没有」,也是「把这天清零」

热力图按年加载,编辑器的 selectedCount 从当前这一年的查询结果里取。管理员在 2025 年的页面上,把日期改成 2024-03-12——

  • 输入框里的日期变了,但页面还是 2025 年的数据;
  • selectedCount 在 2025 年的数组里找不到这一天,回落到 0;
  • 次数输入框显示 0;
  • 点保存,写入 0。

如果 2024-03-12 原本记了 7 次,就被这一次「什么都没改」的保存抹掉了。0 在这里同时是「没有记录」的展示默认值和「清除记录」的写入值,两者长得一模一样。

让日期自己带上年份

第一反应可能是加一句提示「跨年日期请先切换年份」。但更可靠的是把年份变成选中状态的一部分:

  • 选中一个不属于当前年的日期时,直接 navigate('?year=2024&date=2024-03-12');
  • loader 按 year 参数加载那一年的数据,并从 date 参数带回初始选中值;
  • 路由组件用 year:initialDate 当 key,跨年时整个编辑器重挂载;
  • 在新数据到达之前,保存按钮是禁用的。

关键的守卫是这一行:

const unavailable =
  busy || navigation.state !== 'idle' || Number(selected.slice(0, 4)) !== year

只要选中的年份和当前页面的年份不一致,就不允许保存。这样即使导航很慢、用户手快,也不会把「上一年还没读回来」的 0 写下去。

保存成功后不再整年重新拉取,而是用返回值更新本地 days:次数大于 0 就替换或插入,等于 0 就从数组里删掉。原来的 useQuery + invalidateQueries 一并撤了,省掉一次往返,也不用再维护 query.isError 那条刷新失败的重试分支。

热力图本身没那么玄

日历用 UTC 日期铺格:从当年 1 月 1 日往前退到那一周的周日,逐日递增到次年 1 月 1 日之后补满一周。页面上是 365 或 366 个按钮,未来日期禁用,计数只取 <= today 的行。月份标签按每月 1 日落在第几周算列号,全年合计、记录天数、单日最高都从计数里推。

today 用 Intl.DateTimeFormat 固定 Asia/Shanghai,不读服务端进程时区。/masturbation 和后台都发 Cache-Control: no-store,匿名用户点保存会拿到 401。

公开页和后台的链接、上一年的 ←、下一年的 → 都在,年份只允许 1970 到今年,非法年份直接 400,不生成假记录。初始表就是空的,不塞示例数据。

手机宽度

同一轮还修了后台在 390×844 下的溢出:外层 flex-wrap,日期输入 min-w-0 max-w-full,统计卡片从固定三列改成 repeat(auto-fit, minmax(min(100%, 100px), 1fr)),年份标题 whitespace-nowrap。e2e 里断言 documentElement.scrollWidth <= innerWidth。

热力图本身在窄屏允许横向滚动,但只滚图内,不把整页撑开。

回归怎么钉

新增的 e2e 先把 2024-03-12 存成 7,然后打开 2025 年的后台,填入这个跨年日期,断言次数输入框在 1.5 秒内变成 7,再点保存,最后按年接口核对仍然是 7。finally 里把原值写回去,不污染别的用例。

另一条用例只做手机宽度检查。两条都不依赖「用户会记得先切年份」。

收尾

时间序列表单的隐式主键是(年,日)。跨年选择不是改一个字符串,是切换数据源;把「加载中」当成禁用条件,比任何提示都可靠。

0 这种既是值又是默认值的字段,最怕的就是在数据还没到的时候被当成「现有值」提交。先读回来,再允许写。