Built a local-first mood journal in React Native — and the boring sync parts nearly broke it

本文为原文前 6,000 字符的节选翻译,完整内容请查看原文。

After tense conversations, I used to open Notes, type half a paragraph, and delete it. Not because the feeling wasn’t real—because Notes felt like the wrong container. Too easy to sync into places I didn’t want. Too unstructured to help me see patterns later. And every “mood tracker” I tried pulled me toward something else: emoji grids that felt like homework, couple apps that need two people online, or chatty AI coaches that wouldn’t leave me alone. What I actually needed was quieter: A private place to write emotions that are usually about someone—without turning it into therapy homework or a social product. So I built MoodMO ** for myself first, in React Native / Expo. It is currently published on the **iOS App Store —not Android. This post is about the problem, the lines I refused to cross, and a few sync/privacy decisions that burned real engineering time. If it sounds familiar, I’d love for you to try it on iOS and tell me what’s still awkward. Important: This is a personal journaling tool, not therapy, diagnosis, or crisis support. If you need immediate help, contact local emergency services or a trusted hotline in your area.

在经历过紧张的对话后,我过去常常打开备忘录,写下半段话,然后又将其删除。这并不是因为那些感受不真实,而是因为备忘录感觉像是一个错误的容器。它太容易同步到我不希望出现的地方,也缺乏足够的结构来帮助我日后观察规律。而我尝试过的每一个“情绪追踪器”都试图把我引向别处:感觉像家庭作业的表情符号网格、需要两人同时在线的情侣应用,或是那些阴魂不散的健谈 AI 教练。我真正需要的是一种更安静的东西:一个可以记录通常与某人相关的情绪的私密空间,且不会将其变成治疗作业或社交产品。因此,我首先用 React Native / Expo 为自己构建了 MoodMO。它目前已在 iOS App Store 上架,但尚未发布 Android 版本。这篇文章讲述了我所解决的问题、我拒绝逾越的底线,以及一些耗费了大量工程时间的同步与隐私决策。如果这听起来很熟悉,我很乐意让你在 iOS 上试用并告诉我哪些地方仍然别扭。重要提示:这是一个个人日记工具,而非治疗、诊断或危机支持手段。如果你需要紧急帮助,请联系当地的紧急服务部门或你所在地区的信任热线。

The problem I was solving Most mood apps answer: “How do you feel on a scale?” My nights looked more like: “I’m upset **with this person, about **this kind of trigger, and I don’t know whether to talk today, this week, or just digest it alone.” That relationship context kept falling out of generic trackers. Couple apps solve shared journaling. AI coaches solve conversation. I wanted a single-player loop: record → later see whether things softened → optionally release words I no longer needed to keep staring at. Privacy wasn’t a marketing line. If diary body, tags, or voice notes ever felt like telemetry fodder or social content, I simply wouldn’t use the app myself.

我所解决的问题是:大多数情绪应用回答的是“你感觉如何(按量表评分)?”而我的夜晚更像是:“我对这个人感到心烦,是因为这种触发因素,我不知道是今天谈谈、这周谈谈,还是独自消化。”这种人际关系背景在通用追踪器中总是被遗漏。情侣应用解决了共享日记的问题,AI 教练解决了对话的问题。我想要的是一个单人循环:记录 → 稍后观察情况是否有所缓和 → 选择性地释放那些我不再需要盯着看的文字。隐私不是一句营销口号。如果日记正文、标签或语音笔记被当作遥测数据或社交内容,我本人根本就不会使用这个应用。

What I deliberately did not build Saying no early saved me from shipping the wrong product: No medical framing — not a crisis tool, not CBT coursework. No conversational AI coach — no chat that follows you around (details in the next section). No couple co-writing / no in-app social feed — one person’s relationship memory, not a timeline for others. Local-first by default — write without an account; cloud sync is opt-in later. Share cards that don’t dump the diary by default — weather/garden chrome + a closing line; optional short user snippet only if they opt in—not entry bodies or people/trigger lists by default. Those constraints shaped the UX metaphors more than any feature brainstorm did.

我刻意没有构建的功能:尽早说“不”让我避免了发布错误的产品:没有医疗框架——它不是危机工具,也不是认知行为疗法(CBT)课程。没有对话式 AI 教练——没有那种如影随形的聊天功能(详情见下一节)。没有情侣共同写作/没有应用内社交动态——这是个人的关系记忆,而不是他人的时间线。默认“本地优先”——无需账户即可书写;云同步是后续的可选功能。分享卡片默认不会导出日记内容——仅包含天气/花园装饰 + 一句结束语;仅在用户选择时才包含简短的个人片段——默认不包含条目正文或人物/触发因素列表。这些限制比任何头脑风暴更能塑造用户体验的隐喻。

The AI I deliberately kept boring Optional extras are batch-style only: mood forecast text, a short reflective “podcast-style” blurb, suggested next steps, and one review closing line. There is no in-app “paste your API key” screen. When AI runs, it is gated by a Groq key present at build time. That key is an EXPO_PUBLIC_ env var, which means it ships inside the client bundle—anyone who unpacks the IPA can extract it. That is a deliberate trade-off for a small indie app with no backend proxy, not a claim that client-held provider keys are safe. If this grows, the right fix is a server-side proxy (and rotating whatever leaked). If the key is missing, those features fall back to on-device default copy. When the key is present, calls send aggregates (counts, averages, trigger labels)—not full diary paragraphs—but that is still third-party processing. Same honesty class as the at-rest gap below.

我刻意让 AI 保持“无聊”:可选的附加功能仅以批处理方式提供:情绪预测文本、简短的反思性“播客风格”短文、建议的后续步骤以及一行回顾结束语。应用内没有“粘贴你的 API 密钥”界面。当 AI 运行时,它由构建时存在的 Groq 密钥控制。该密钥是一个 EXPO_PUBLIC_ 环境变量,这意味着它会随客户端包一起发布——任何解包 IPA 的人都可以提取它。对于一个没有后端代理的小型独立应用来说,这是一个刻意的权衡,而不是声称客户端持有的提供商密钥是安全的。如果应用规模扩大,正确的解决方法是使用服务器端代理(并轮换任何泄露的密钥)。如果密钥缺失,这些功能将回退到设备上的默认文案。当密钥存在时,调用会发送聚合数据(计数、平均值、触发标签)——而不是完整的日记段落——但这仍然属于第三方处理。这与下文提到的静态存储漏洞属于同一类诚实说明。

How the product took shape Weather for intensity. A garden for growth. People/trigger tags, a gentle “when might I talk about this?”, a person timeline, “on this day,” a ritual to release words, and a small home-screen widget (Soft Stack—garden/weather chrome only, no diary text). I am not claiming the best architecture in the industry—only consistency with the problem I set out to solve.

产品的成型过程:用天气代表强度,用花园代表成长。包含人物/触发因素标签、温和的“我什么时候可以谈论这件事?”提示、人物时间线、“那年今日”、释放文字的仪式,以及一个小巧的主屏幕小组件(Soft Stack——仅显示花园/天气装饰,不显示日记文本)。我并不声称这是业内最好的架构,我只追求与我最初设定的问题保持一致。

How the boring parts actually worked This is the section I almost skipped—and the one that made sync feel trustworthy enough to ask strangers to try the app. Local-first storage (and an honest at-rest gap). Entries live on-device first as JSON in AsyncStorage (guest key vs per-user key). Guest mode never requires a network. Optional sync uses Supabase when you sign in. Auth session tokens use SecureStore; diary entries do not. Entries sit in the iOS app sandbox and rely on platform protections. I have not added application-layer encryption at rest. That is a known gap, not a claim. Soft delete vs “gone forever.” Soft delete stamps deletedAt (recycle bin can restore). Permanent delete inserts a tombstone id for signed-in users; pull/push filter those ids so another device cannot resurrect a purged entry. Tombstones live in a cloud table with no automatic expiry job today—the guarantee holds as long as those rows remain. Upload and pull are separate actions—not one automatic pipeline. In Profile you choose upload (push) or pull (recover from cloud). Pull shows a confirm dialog that cloud wins on id collision. Semantics: Pull → for each shared id, the server version replaces local; local-only ids stay. Push → only rows whose revision field (updatedAt—not a logical clock; a known simplification) advanced since the last successful sync. Result → after you deliberately pull, devices look like the server for shared ids. So yes: if you edit offline and pull without uploading first, cloud can overwrite you. That is a deliberate trade-off with a confirm prompt—not a silent magic sync. Clearer “you have unsynced local edits” UX before pull is still on my list.

枯燥部分的实际运作方式:这是我差点跳过的一节,但正是这一节让同步功能显得足够可靠,让我敢于邀请陌生人试用该应用。本地优先存储(以及一个诚实的静态存储漏洞说明)。条目首先以 JSON 格式存储在设备上的 AsyncStorage 中(访客密钥与用户密钥区分)。访客模式从不需要网络。登录后,可选的同步功能使用 Supabase。身份验证会话令牌使用 SecureStore,但日记条目不使用。条目位于 iOS 应用沙盒中,依赖于平台保护。我没有添加应用层的静态加密。这是一个已知的漏洞,而非声明。软删除与“永久消失”:软删除会标记 deletedAt(回收站可恢复)。永久删除会为已登录用户插入一个墓碑 ID;拉取/推送操作会过滤这些 ID,因此其他设备无法恢复已清除的条目。墓碑 ID 目前存储在云端表中,没有自动过期任务——只要这些行存在,保证就有效。上传和拉取是独立的操作,而不是一个自动化的流水线。在个人资料中,你可以选择上传(推送)或拉取(从云端恢复)。拉取时会显示确认对话框,提示在 ID 冲突时云端数据优先。语义:拉取 → 对于每个共享 ID,服务器版本会替换本地版本;仅本地存在的 ID 会保留。推送 → 仅推送自上次成功同步以来修订字段(updatedAt——不是逻辑时钟,这是一种已知的简化处理)发生变化的行。结果 → 在你主动拉取后,设备上共享 ID 的数据将与服务器一致。所以是的:如果你在离线状态下编辑,且在上传前先执行了拉取,云端数据可能会覆盖你的本地数据。这是一个带有确认提示的刻意权衡,而不是静默的魔法同步。在拉取前提供更清晰的“你有未同步的本地编辑”用户体验,仍在我后续的开发列表中。

Widget as hostile multi-process storage (iOS). Soft Stack never receives diary body, people names, or audio paths—only a whitelist snapshot (weather, growth stage, active-count hint, schema version). Publish runs when weather recalculates; clear runs on logout, serialized so a late publish cannot overwrite a clear. On iOS the snapshot is JSON in an App Group UserDefaults suite shared with the widget extension. (Android widget code exists in the repo; Android is not published.) Telemetry we r

作为敌对多进程存储的小组件(iOS)。Soft Stack 从不接收日记正文、人名或音频路径——仅接收白名单快照(天气、成长阶段、活跃计数提示、架构版本)。发布操作在天气重新计算时运行;清除操作在注销时运行,并进行了序列化处理,以防止延迟的发布覆盖清除操作。在 iOS 上,快照是存储在与小组件扩展共享的 App Group UserDefaults 套件中的 JSON。(Android 小组件代码存在于仓库中;Android 版本未发布。)我们所做的遥测……