为什么不把所有内容塞进“每天一条记录”
最初设计心情日记时,一个直观模型是:
interface DayRecord {
date: string;
mood: string;
note: string;
tags: string[];
habits: string[];
}
它适合原型,但功能增加后会遇到问题:
- 习惯改名后,历史记录是否全部重写?
- 习惯归档后,历史打卡如何保留?
- 没有心情、只有习惯打卡的日期如何出现?
- 删除一个习惯时,哪些历史数据应该删除?
- 统计习惯创建之前的日期是否合理?
因此“心晴手记”把数据拆成三个独立实体:
MoodEntry
Habit
HabitCompletion
标签目前是稳定枚举,作为 MoodEntry.tags 中的 Key 保存。
一、MoodEntry:每天唯一的心情记录
export interface MoodEntry {
id: string;
timestampMillis: number;
dayIdentifier: string;
mood: MoodKind;
note: string;
tags: string[];
createdAtMillis: number;
updatedAtMillis: number;
}
几个时间字段分别承担不同职责:
dayIdentifier:业务日期主键,如2026-08-14;timestampMillis:最近一次记录的时间,可用于排序;createdAtMillis:首次创建时间;updatedAtMillis:最后更新时间。
保存今日记录时执行 Upsert:
const existing: MoodEntry | undefined = this.todayEntry();
if (existing) {
const updated: MoodEntry = {
id: existing.id,
timestampMillis: now,
dayIdentifier: id,
mood: this.selectedMood,
note: this.journalNote,
tags: this.selectedTags.slice(),
createdAtMillis: existing.createdAtMillis,
updatedAtMillis: now
};
this.moodEntries = this.moodEntries.map(
(entry) => entry.id === existing.id ? updated : entry
);
} else {
// 创建新记录
}
关键不变量是:
同一个 dayIdentifier 最多一条 MoodEntry
它应该由业务层持续保证,而不是只依赖 UI 上“今天已经保存”的提示。
二、为什么同时保存 ID 和日期主键
既然每天唯一,能否直接把 dayIdentifier 当作 ID?可以,但独立 ID 仍有价值:
- 未来可能允许一天多条记录;
- 导入数据时可以保留记录身份;
- 更新对象时不必改变引用标识;
- 可以把日期从身份中解耦,支持纠正错误日期。
当前使用本地生成 ID:
export function newId(): string {
const random: string = Math.floor(
Math.random() * 1000000000
).toString(36);
return `${Date.now().toString(36)}-${random}`;
}
它适合单设备轻量应用。如果未来多设备合并数据,应使用冲突概率更低的标准 UUID,并建立同步冲突策略。
三、Habit:定义目标本身,而不是某天是否完成
export interface Habit {
id: string;
name: string;
icon: string;
color: string;
isActive: boolean;
createdAtMillis: number;
updatedAtMillis: number;
archivedAtMillis: number;
}
习惯实体保存的是:名称、图标、颜色和生命周期。
“今天是否完成”不放在 Habit 中,否则只能表达一个当前状态,无法保存历史。
isActive 与 archivedAtMillis 同时存在:
isActive便于今日页快速筛选;archivedAtMillis让统计知道从哪一天停止计入分母。
归档时不删除 Habit:
return {
...habit,
isActive: active,
updatedAtMillis: now,
archivedAtMillis: active ? 0 : now
};
因此历史仍可以显示曾经的习惯和打卡。
四、HabitCompletion:关联习惯与自然日
export interface HabitCompletion {
id: string;
habitId: string;
timestampMillis: number;
dayIdentifier: string;
isCompleted: boolean;
createdAtMillis: number;
updatedAtMillis: number;
}
当前业务采用“存在即完成”的简化模型:
找得到记录 → 已完成
找不到记录 → 未完成
唯一条件是:
habitId + dayIdentifier
切换打卡时:
const existing = this.completions.find(
(completion: HabitCompletion) =>
completion.habitId === habit.id &&
completion.dayIdentifier === dayId
);
if (existing) {
this.completions = this.completions.filter(
(completion) => completion.id !== existing.id
);
} else {
this.completions = [completion, ...this.completions];
}
模型中仍保留 isCompleted,为兼容旧数据或未来扩展留出空间。若未来需要记录“跳过”“部分完成”,可以升级为明确状态枚举,而不是继续依赖布尔值。
五、历史日期为什么要合并两类来源
用户可能某天只完成了习惯,没有记录心情。如果历史列表只遍历 MoodEntry,这一天会完全消失。
项目从两类数据合并日期:
private historyDayIds(): string[] {
const ids: string[] = [];
this.moodEntries.forEach((entry: MoodEntry) => {
if (!ids.includes(entry.dayIdentifier)) {
ids.push(entry.dayIdentifier);
}
});
this.completions.forEach((completion: HabitCompletion) => {
if (
completion.isCompleted &&
!ids.includes(completion.dayIdentifier)
) {
ids.push(completion.dayIdentifier);
}
});
return ids.sort(
(left, right) => right.localeCompare(left)
);
}
这说明“历史中的一天”是一个派生视图,不必在磁盘中再维护第四张 Day 表。
六、删除和归档必须是两种不同语义
用户管理习惯时至少需要:
归档
- 从今日列表移除;
- 保留 Habit;
- 保留历史打卡;
- 统计只计算归档前的有效日期;
- 将来可以恢复。
删除
- 删除 Habit;
- 删除所有关联 HabitCompletion;
- 不可恢复;
- 必须二次确认。
删除实现:
private deleteHabit(habitId: string): void {
this.habits = this.habits.filter(
(habit: Habit) => habit.id !== habitId
);
this.completions = this.completions.filter(
(completion: HabitCompletion) =>
completion.habitId !== habitId
);
this.persist();
}
如果只删 Habit,不删关联打卡,就会产生孤儿数据:历史统计仍可能计算到一个 UI 中已经找不到的习惯。
七、统计分母来自 Habit 生命周期
一个习惯在统计窗口内的可参与日期,必须满足:
日期 >= 习惯创建日
且
习惯未归档,或 日期 <= 归档日
这使新建习惯不会被追溯惩罚,归档习惯也不会在之后持续拉低完成率。
模型中的 createdAtMillis 和 archivedAtMillis 因此不只是审计字段,它们直接决定指标含义。
如果未来允许“暂停习惯再恢复”,单个 archivedAtMillis 就不够了,需要增加生命周期区间或状态变更事件表。
八、标签为什么暂时不是独立实体
当前标签是固定的八种情境:工作、学习、睡眠、运动、人际、家庭、天气和放松。
它们使用稳定 Key:
export interface TagDefinition {
key: string;
resourceName: string;
icon: string;
color: string;
}
MoodEntry 只保存:
tags: string[]
展示时根据 Key 获取当前语言资源。固定标签没有用户自定义生命周期,因此暂时不需要单独的 Tag 实体和关联表。
如果未来支持自定义标签、改名、归档或合并,才需要升级为:
Tag
MoodEntryTag
不要为了“数据库看起来正规”提前拆分,也不要在需求已经变化后继续硬塞字符串。
九、默认习惯使用稳定存储名
内置默认习惯需要随应用语言显示,但磁盘中的数据不能在切换语言时反复改名。
因此默认习惯保存稳定名称:
storedName: 'sleep-early'
展示时通过 resourceName 本地化。用户自定义名称则直接保存和显示。
这种设计把“业务身份”和“用户看到的翻译”分开,也让历史数据在中英日切换时保持一致。
十、根状态和导出格式要版本化
所有实体最终进入:
export interface PersistedState {
exportVersion: number;
settings: AppSettings;
moodEntries: MoodEntry[];
habits: Habit[];
habitCompletions: HabitCompletion[];
}
新增字段时,读取旧状态要提供默认值;改变引用关系时,需要显式迁移;导出 JSON 中也保留 exportVersion。
一个可靠的数据模型不仅描述“今天怎么保存”,还必须回答“明年版本升级后,旧记录怎么继续使用”。
最终关系图
MoodEntry
├─ dayIdentifier:每天唯一
├─ mood:稳定枚举
└─ tags[]:稳定标签 Key
Habit
├─ id:习惯身份
├─ isActive:当前是否显示
└─ createdAt / archivedAt:统计生命周期
HabitCompletion
├─ habitId → Habit.id
└─ dayIdentifier:完成日期
唯一约束:habitId + dayIdentifier
总结
这套模型的核心不是用了几个 interface,而是明确了四条不变量:
- 每个自然日最多一条心情记录;
- 每个习惯每天最多一次有效打卡;
- 归档保留历史,删除级联清理;
- 统计只覆盖习惯真实存在的日期。
一旦这些边界稳定,今日页、历史页、统计页、导出和多语言都可以围绕同一套数据语义工作。
本文案例来自“心晴手记(MoodMemoir)”HarmonyOS 版的数据模型。

2021

被折叠的 条评论
为什么被折叠?



