HarmonyOS 日记类 App 的日期设计:本地自然日、月历与夏令时边界

日期为什么比想象中复杂

心情日记的核心问题看起来很简单:今天保存一条记录。

但代码很快会遇到:

  • 北京凌晨 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 的日期设计可以记住五条原则:

  1. 时刻和日历日期分开保存;
  2. 本地日期不要从 UTC ISO 字符串截取;
  3. 日期型 UI 单元可用当地中午降低边界风险;
  4. 月份和自然日使用日历字段运算,不加固定毫秒;
  5. 跨时区后记录归属必须有明确产品规则。

日期逻辑越早统一,今日、历史、统计和多语言之间越不容易产生难以复现的偏差。

本文案例来自"心晴手记(MoodMemoir)"HarmonyOS 版的日期主键、月历和统计窗口实现。
体验心晴手记 App Store 下载华为应用市场下载

参考资料


相关推荐
贾伟康1 小时前
【中国方言题库|12】HarmonyOS ArkTS 题库列表组件实战:减少多地区页面重复并保证点击反馈
harmonyos·arkts·arkui·组件化·多设备适配
梦想不只是梦与想2 小时前
鸿蒙 AGC:华为开放能力管理(四)
harmonyos·agc·开发能力
大锅盖12 小时前
ArkUI声明式范式下的暗夜紫调沉浸式剧本杀组局社区:迷雾粒子双层特效与四套差异化弹框的工程化实践
华为·harmonyos
见山是山-见水是水10 小时前
鸿蒙Divider 分割线组件完全指南:内容分组、视觉分区与自定义样式
华为·harmonyos
贾伟康11 小时前
【知律|18】HarmonyOS ArkTS 权限与隐私实战:让 module.json5、功能说明和拒绝路径一致
harmonyos·arkts·隐私合规·appgallery·应用权限
Kevin Coding1 天前
JsonConvert:适用于 Android、鸿蒙与 Flutter 的 JSON 转 Model 插件
android·flutter·harmonyos
m0_749690231 天前
【寻迹校园 HarmonyOS NEXT 实战 28】不交换手机号也能交接:固定校内交接点的隐私设计
华为·harmonyos·arkts·产品设计·隐私设计·安全交接
Magic-ZYJ1 天前
HarmonyOS Stage 模型实战:UIAbility 生命周期如何驱动页面安全状态
安全·华为·harmonyos·鸿蒙·移动端开发·独立开发者·心晴手记
贾伟康1 天前
【中国方言题库|09】HarmonyOS ArkTS 方言搜索实战:实现词语检索和无结果反馈
harmonyos·arkts·状态管理·arkui·本地搜索