日期为什么比想象中复杂
心情日记的核心问题看起来很简单:今天保存一条记录。
但代码很快会遇到:
- 北京凌晨 00:30 保存,UTC 仍是前一天;
- 某些地区进入夏令时,一天不是固定 24 小时;
- 用户切换时区后,"今天"发生变化;
- 月历需要展示上月和下月补位日期;
- 历史编辑传递的只是日期,没有具体时间;
- 7/30 天统计到底包含哪些边界。
如果没有统一日期规则,今日页、历史页和统计页很容易互相矛盾。
一、先区分"时刻"和"日历日期"
项目中的记录同时保存:
typescript
timestampMillis: number;
dayIdentifier: string;
两者语义不同:
text
timestampMillis:世界时间轴上的某个时刻
dayIdentifier:用户本地日历中的某一天
例如:
text
2026-08-21
是日期身份,不表示当天零点对应的绝对时间。
这种区分让排序、审计和日期唯一性都更清楚。
二、不要用 toISOString 截取本地日期
一个常见错误是:
typescript
date.toISOString().slice(0, 10)
toISOString() 使用 UTC。北京时间凌晨保存时,结果可能仍是 UTC 的前一天。
项目直接读取本地年月日:
typescript
export function dayIdentifier(date: Date): string {
const year: string = date.getFullYear().toString();
const month: string = (date.getMonth() + 1)
.toString().padStart(2, '0');
const day: string = date.getDate()
.toString().padStart(2, '0');
return `${year}-${month}-${day}`;
}
这个字符串有两个额外优点:
- 固定宽度,字符串排序与日期顺序一致;
- 语言无关,不会随中文、英文、日文切换。
三、为什么日期型时间戳常选择当地中午
历史编辑需要把 2026-08-21 转为 Date 以便格式化和排序。
项目使用:
typescript
new Date(`${dayId}T12:00:00`).getTime()
月历单元也在当地中午构造:
typescript
new Date(year, month, day, 12, 0, 0).getTime()
为什么不是 00:00?
在存在夏令时或历史时区调整的地区,凌晨附近更容易遇到时间跳变、重复或不存在的本地时刻。当地中午通常远离切换边界,更适合代表"只关心日期"的 UI 单元。
但要注意:中午只是工程策略,不会把日期变成真正的无时区类型。业务主键仍应使用 dayIdentifier,不能把中午时间戳当作唯一身份。
四、月历网格如何生成补位日期
先找到所选月份第一天和最后一天:
typescript
const selected: Date = new Date(
this.selectedMonthMillis
);
const first: Date = new Date(
selected.getFullYear(),
selected.getMonth(),
1,
12, 0, 0
);
const last: Date = new Date(
selected.getFullYear(),
selected.getMonth() + 1,
0,
12, 0, 0
);
JavaScript Date 的"下月第 0 天"会回到本月最后一天,因此可以得到当月天数。
随后根据第一天是星期几,向前加入上月日期,再补足后续单元:
typescript
for (let day = 1 - first.getDay(); day <= total; day++) {
cells.push(
new Date(
selected.getFullYear(),
selected.getMonth(),
day,
12, 0, 0
).getTime()
);
}
具体循环上限取决于希望固定显示 5 行还是 6 行,但所有单元应通过同一个日期函数转换为 dayIdentifier。
五、切换月份不要加固定毫秒数
错误方式:
typescript
selectedMonthMillis += 30 * 86400000;
月份不是固定 30 天,一天也未必始终是 86400000 毫秒。
正确做法是使用日历字段:
typescript
private shiftMonth(offset: number): void {
const selected: Date = new Date(
this.selectedMonthMillis
);
this.selectedMonthMillis = new Date(
selected.getFullYear(),
selected.getMonth() + offset,
1,
12, 0, 0
).getTime();
}
Date 会正确处理跨年和不同月份天数。
六、7/30 天窗口应该是自然日窗口
项目将统计起点设为:
typescript
private insightStartMillis(): number {
const today: Date = new Date();
return new Date(
today.getFullYear(),
today.getMonth(),
today.getDate() - this.insightDays + 1,
0, 0, 0
).getTime();
}
结束点是明天 00:00:
typescript
private insightEndMillis(): number {
const today: Date = new Date();
return new Date(
today.getFullYear(),
today.getMonth(),
today.getDate() + 1,
0, 0, 0
).getTime();
}
筛选使用左闭右开:
typescript
timestamp >= start && timestamp < end
这表示"包含今天在内的最近 N 个本地自然日",而不是"从当前时刻向前 N×24 小时"。
对于日记统计,前者通常更符合用户预期。
七、习惯统计也要基于日期,不要只比较时间差
习惯创建于今天晚上 23:00,当天是否应该算作可打卡日?当前产品选择"创建日就算"。
因此先把创建时间归一到本地当天:
typescript
const createdDay = new Date(habit.createdAtMillis);
const createdStart = new Date(
createdDay.getFullYear(),
createdDay.getMonth(),
createdDay.getDate()
).getTime();
归档时间也做相同处理,再逐个自然日判断是否位于生命周期内。
这比用:
text
(now - createdAt) / 86400000
更符合"创建日、归档日"的业务语义。
八、星期名称应该交给本地化系统
不要硬编码:
text
日 一 二 三 四 五 六
项目通过当前应用 Locale 格式化:
typescript
date.toLocaleDateString(
this.localeTag(),
{ weekday: 'narrow' }
)
这里还需要注意:如果通过 baseTime + index * 86400000 推进七天,在夏令时切换附近可能出现小时偏移。更稳妥的通用写法是逐次用日历字段构造:
typescript
new Date(year, month, baseDay + index, 12, 0, 0)
官方 Localization Kit 还提供 Calendar、时区和夏令时相关能力。若应用需要多历法、一周起始日或指定时区,应优先使用对应本地化接口,而不是继续增加手工规则。
九、跨时区后记录归属要提前定义
假设用户在上海 8 月 21 日记录,飞到洛杉矶后设备当地仍是 8 月 20 日。应用可以有不同策略:
策略 A:记录固定属于创建时的本地日期
保存的 dayIdentifier 不变。适合日记历史稳定性。
策略 B:始终根据当前时区重新计算
可能导致历史记录跨日移动,一般不符合日记直觉。
策略 C:同时保存创建时区
适合旅行日志和需要解释日期来源的产品,但模型更复杂。
当前项目采用策略 A:dayIdentifier 保存后就是业务日期,不根据当前时区重算。
十、建议的日期测试集
日常边界
- 当天 00:01 保存;
- 当天 23:59 保存;
- 同一天更新两次;
- 月末和月初;
- 2 月与闰年;
- 12 月到次年 1 月。
时区与夏令时
- 东八区;
- 负时区;
- 有夏令时的地区;
- 夏令时开始日;
- 夏令时结束日;
- 记录后切换设备时区。
月历
- 月初从周日开始;
- 月初从周六开始;
- 28、29、30、31 天月份;
- 上下月补位单元;
- 点击补位日期后打开正确历史。
统计
- 7 天窗口第一天的 00:00;
- 窗口结束点前 1 毫秒;
- 明天 00:00 不应计入;
- 创建和归档发生在窗口中间。
总结
日记类 App 的日期设计可以记住五条原则:
- 时刻和日历日期分开保存;
- 本地日期不要从 UTC ISO 字符串截取;
- 日期型 UI 单元可用当地中午降低边界风险;
- 月份和自然日使用日历字段运算,不加固定毫秒;
- 跨时区后记录归属必须有明确产品规则。
日期逻辑越早统一,今日、历史、统计和多语言之间越不容易产生难以复现的偏差。
本文案例来自"心晴手记(MoodMemoir)"HarmonyOS 版的日期主键、月历和统计窗口实现。
体验心晴手记 App Store 下载 | 华为应用市场下载