站点里有个页面叫 /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 这种既是值又是默认值的字段,最怕的就是在数据还没到的时候被当成「现有值」提交。先读回来,再允许写。
