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

相关新闻

最新新闻

C++模板编程:从泛型思想到实战应用,掌握代码复用的核心利器

C++模板编程:从泛型思想到实战应用,掌握代码复用的核心利器

1. 项目概述:为什么模版是C的“奇迹”?刚接触C那会儿,总觉得它像个严肃的老学究,每写一个函数,都得把参数类型定得死死的。想写个比较两个数大小的函数,得为int写一个,为double再写一个&#xf…

2026/8/22 2:38:55
SBCL代码转换工具实践:Assignment Convert All the Things

SBCL代码转换工具实践:Assignment Convert All the Things

这次我们来看一个 SBCL 项目,标题是“Assignment convert all the things”。SBCL 本身是 Steel Bank Common Lisp 的缩写,一个高性能的 Common Lisp 编译器。这个项目听起来像是一个关于代码转换或重构的工具,核心可能是利用 SBCL 的编译时能…

2026/8/22 2:38:55
DeepSeek Harness:一次 Prompt 如何变成 Turn、Step 与工具事件

DeepSeek Harness:一次 Prompt 如何变成 Turn、Step 与工具事件

一次 Prompt 如何变成 Turn、Step 与工具事件用户输入一句“检查这个项目并修复测试”,系统什么时候算接收,什么时候算开始,什么时候算完成? 如果把一次 Prompt 直接等同于一次模型请求,工具一出现,边界马上…

2026/8/22 2:38:55
彻底解决msvcp140.dll丢失问题:从原理到实践的5种修复方案

彻底解决msvcp140.dll丢失问题:从原理到实践的5种修复方案

1. 问题引入:当“msvcp140.dll”成为拦路虎“由于找不到msvcp140.dll,无法继续执行代码”——这个弹窗,对于任何一个在Windows平台上折腾过软件、游戏或者开发环境的用户来说,都绝不陌生。它就像一个不请自来的幽灵,总…

2026/8/22 2:38:55
Java Web实战:超市进销存管理系统毕业设计项目全解析

Java Web实战:超市进销存管理系统毕业设计项目全解析

这次我们来看一个面向计算机专业学生和初学者的实战项目:小型超市进销存管理系统。这个项目不是一个概念演示,而是一个功能完整、附带源码、文档和讲解的毕业设计/课程设计级解决方案。对于正在寻找一个能跑起来、能理解、能作为自己项目基础的开发者来说…

2026/8/22 2:38:55
数学建模实战:从粮食安全问题到三层耦合优化模型

数学建模实战:从粮食安全问题到三层耦合优化模型

1. 这不是“抄作业指南”,而是一份建模老手的实战复盘笔记2023年亚太杯数学建模B题——“全球粮食安全与可持续农业发展路径优化”——当年开赛不到48小时,QQ群和知乎话题页就炸了锅。有人贴出“三天两夜肝出来的代码”,有人晒出“导师改了七…

2026/8/22 2:33:55