从零数据到可复盘:心情记录与习惯打卡的数据模型设计

为什么不把所有内容塞进“每天一条记录”

最初设计心情日记时,一个直观模型是:

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 中,否则只能表达一个当前状态,无法保存历史。

isActivearchivedAtMillis 同时存在:

  • 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 生命周期

一个习惯在统计窗口内的可参与日期,必须满足:

日期 >= 习惯创建日
且
习惯未归档,或 日期 <= 归档日

这使新建习惯不会被追溯惩罚,归档习惯也不会在之后持续拉低完成率。

模型中的 createdAtMillisarchivedAtMillis 因此不只是审计字段,它们直接决定指标含义。

如果未来允许“暂停习惯再恢复”,单个 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,而是明确了四条不变量:

  1. 每个自然日最多一条心情记录;
  2. 每个习惯每天最多一次有效打卡;
  3. 归档保留历史,删除级联清理;
  4. 统计只覆盖习惯真实存在的日期。

一旦这些边界稳定,今日页、历史页、统计页、导出和多语言都可以围绕同一套数据语义工作。

本文案例来自“心晴手记(MoodMemoir)”HarmonyOS 版的数据模型。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值