Mood Tracker
A private iOS mood journal where you design the form you fill in. No account, no analytics, no network calls at all — every entry lives in a local database on the device, and the app has no server to send it to even if it wanted to.
- Project type
- Native iOS app (offline, no server, no account)
- Role
- Solo developer — product, data model, UI, native release pipeline
- Created
- 2026
- Status
- Shipping; distributed via TestFlight and the App Store
- Storage
- On-device SQLite only — zero network calls
- Links
- App Store · GitHub (below)
// 01 — The problem
People won't log honest, sensitive data into something they don't trust.
Mood, sleep, energy, self-harm ideation, medication side effects — the data that's genuinely useful to track is also the data people are most reluctant to put anywhere with an account, a sync server, or an analytics SDK attached. A mood tracker that phones home, even just for crash reports, is asking for trust it hasn't earned.
The second problem is just as real: everyone's check-in looks different. One person wants a single mood slider. Another wants sleep hours, a word picker for triggers, and a free-text box. A fixed form fits nobody for long, and shipping an update every time someone wants a different field isn't a real answer.
Four constraints shaped the whole app:
| Constraint | Consequence |
|---|---|
| Mental health data is the most sensitive data a phone holds | No account, no analytics, no network calls — there is nothing to leak because nothing leaves the device |
| Everyone's check-in looks different | A fixed form fits nobody for long, so the form itself had to be user-defined, not hardcoded |
| A log you don't open isn't a log | The app needed to interrupt the user's day, gently, without becoming the notification they swipe away unread |
| A journal someone keeps for years outlives this app | Data has to be able to leave in a format a human can still read with the app gone |
So the brief wasn't "build a journaling app." It was: build one where the safest, most trustworthy choice and the simplest engineering choice are the same choice — store nothing anywhere but the device, and let the user define what they're even logging.
// 02 — My responsibilities
- → Defined the product: a single-screen daily check-in, backed by a form the user designs themselves rather than one the app imposes.
- → Designed the data model — a single SQLite document-store table holding arbitrary JSON per form and per entry, so adding a field kind never requires a migration.
- → Built the form builder: add, rename, reorder, reconfigure, and archive fields, with a live preview as you edit.
- → Implemented five field kinds (slider, word picker, free text, date/time, yes/no) as a discriminated union, plus the renderer and per-field editor for each.
- → Wrote the legacy-table migration that moves existing users off an earlier fixed-column schema, with deterministic IDs and a verify-before-stamp step so an interrupted migration safely resumes.
- → Built the JSON import/export pipeline through the OS share sheet, including a replace-everything mode and an additive merge mode that preserves an existing form while appending archived fields for any answers the backup knows about but the current form doesn't.
- → Built the on-device reminder system: three adjustable daily notifications, time-of-day-aware rotating copy, and a 60-notification queue that tops itself up on every launch to work around iOS's 64-pending-notification ceiling.
- → Built the Trends screen: a hand-rolled multi-series SVG line chart with no charting dependency, showing daily averages and summary stats across 7/14/30/60-day windows.
- → Wrote the nothing-read-from-disk-is-trusted normalization layer, so malformed JSON from an older build or an imported backup degrades a single field instead of crashing a screen.
- → Navigated the full native release pipeline solo: Apple Developer Program signing, a from-scratch Xcode archive/export script, Transporter upload, App Store Connect metadata, and TestFlight.
- → Wrote a deterministic simulator seeder that writes 60+ days of plausible history straight into the app's SQLite file for screenshot and demo purposes, with no seeding code shipped in the app itself.
// 03 — The approach
Seven decisions, in the order they had to be made.
Deciding the form was user-defined data, not app code, set the shape of everything downstream: the storage model, the migration strategy, and even how reminders and charts had to stay generic instead of tied to specific fields.
-
01
Design the form, not the app.
Every field — a mood slider, a word picker, a yes/no question, a free-text box, a date/time picker — is data, not code. The Log screen just renders whatever the current form document says to render, grouped into cards. Building a new check-in experience means editing JSON, not shipping an update.
-
02
Store everything as documents, not columns.
One SQLite table, two collections: one `form` document and many `entry` documents, both just JSON blobs with a handful of real columns for what actually needs to be queried (collection, id, timestamps). A new field kind or option never needs a migration — only a change to what's queryable does.
-
03
Trust nothing you didn't just write.
Every document loaded from disk — from this build, an older build, or an imported backup file — passes through a normalizer that coerces or drops what it can't salvage. A corrupted field degrades to a warning, never a crash.
-
04
Make deletion reversible by default.
Archiving a field hides it from new check-ins but keeps its definition, so old answers still render correctly. Erasing the underlying data is a second, explicit, separately confirmed action — the two are never the same button.
-
05
Remind, without becoming noise.
Three per-day reminders, each independently retimed by the user, with wording that rotates by calendar date and is pooled by the reminder's actual hour — so moving a reminder to evening also re-themes its copy, and the same two sentences don't show up two days running.
-
06
Chart what's already there.
Any slider marked as charted gets a daily-average line in Trends, over a window the user picks. The chart is hand-rolled in react-native-svg rather than a charting library, kept small and dependency-free.
-
07
Get it onto an actual phone.
A from-scratch Xcode archive-and-export pipeline, signed with a real Apple Distribution certificate, uploaded through Transporter or `eas submit`, with build-number bumping and provisioning handled explicitly rather than left to defaults that silently disagree with each other.
Privacy as architecture, not as a policy page
| Decision | Why it matters |
|---|---|
| No account | There's nothing to sign up for and nothing tied to an identity — the app doesn't know who you are |
| No analytics, no crash reporting, no telemetry | Not even anonymized. Nothing about how you use the app, or that you use it at all, goes anywhere |
| No network calls, period | The app has no server component. The only bytes that ever leave the device are a backup file you explicitly export |
| On-device reminders | Notifications are scheduled locally by the OS — no push token, no server round-trip, works fully offline and in the Simulator |
| Plain-language disclosure in the app itself | Settings › Privacy & data states exactly what's stored and where, next to the export, import, and erase-everything controls |
Settings › Privacy & data states this in plain language inside the app itself, next to the actual controls — backup export, import, and an erase-everything button — rather than as a separate document nobody reads before typing in something private.
// 04 — Technical insights
A document store, because the schema is the user's choice
The form is user-defined, so the shape of an entry isn't known until runtime and can't be expressed as typed columns. Everything lives in one SQLite table used as a document store, with real columns only for what has to be queried.
A FormField is a discriminated union on kind, so adding a sixth kind someday is additive, not a migration:
| Kind | Stores | Notable options |
|---|---|---|
| slider | number | min/max, end labels, a "higher is better" flag, optional charting |
| words | string | a word list, optional free-text custom entry, optional link to a slider |
| text | string | placeholder, line count, footnote |
| datetime | ISO string | empty-state label, "suggest the previous value" |
| yesno | boolean | custom yes/no labels |
Field ids are permanent and answers are stored against the id, never the label — renaming a field updates it everywhere, including entries saved years earlier. Deleting a field archives it by default; erasing the data it recorded is a second, explicitly confirmed action, because without a field's definition a stored value can't be interpreted at all.
Migrating a journal someone already relies on
An earlier build stored tracked fields as fixed columns on a logs table. Moving existing users onto the document model meant migrating real, already-written entries without a single one going missing or duplicating — on a background job that has to survive the app being killed mid-run.
Two properties made that safe: deterministic ids (row 7 of the old table always becomes entry legacy-7, so re-running the migration replaces rather than duplicates), and verify before stamping (the "already migrated" marker is only written once the count of migrated documents matches the count of source rows — an interrupted run just resumes on the next launch instead of silently half-finishing).
The old logs table is deliberately left in place afterward rather than dropped — it costs almost nothing, and it's the only fallback copy if a mapping bug ever surfaces. Erasing everything from Privacy & data removes it too.
Reminders: working around a silent iOS ceiling
iOS caps pending local notifications at 64 per app and drops the rest with no error and no callback — a repeating daily trigger would never hit the cap but couldn't vary its wording day to day, and wording that never changes is exactly what gets swiped away unread after a week.
| Measure | Value |
|---|---|
| Reminders per day | 3, independently adjustable (default 9:00 / 14:00 / 20:00) |
| iOS pending-notification ceiling | 64 — exceeded silently if you don't account for it |
| Scheduled queue depth | 60 individually dated notifications, refilled on every app launch |
| Effective runway | ~20 days before the queue needs a refill from opening the app |
| Copy rotation | Keyed on calendar date and on the reminder's actual hour, so retiming a reminder also re-themes it |
The trade-off is explicit: if the app goes unopened for twenty days, the queue empties and reminders stop until it's reopened. Rotating, time-of-day-aware copy was worth that cost. Every scheduled notification carries the times it was built from in its own payload, so a rebuild is skipped only when the queue is both full and still matches the configured times — meaning repeated launches do no redundant work, but re-timing a reminder always takes effect immediately.
Import, export, and the problem of merging a backup
With no account and no server, a lost phone is a lost journal, so backup isn't a nice-to-have — it's the only thing standing between a user and losing years of entries. Export writes a versioned, human-readable JSON file to the cache directory and hands it to the OS share sheet; the app never learns or controls where it ends up next.
Import offers two modes: replace everything, the real-restore path, and add missing entries, which keeps the current form and only appends entries whose ids aren't already present. If the backup references a field the current form doesn't have, it's appended archived rather than dropped — the imported answers stay readable in History instead of silently vanishing because their field no longer exists.
// 05 — Results
A journal that keeps working even if this app someday doesn't.
| Measure | Result |
|---|---|
| Network calls made by the app | Zero — verifiable by inspection, not just claimed in a privacy policy |
| Field kinds supported | 5 (slider, words, text, datetime, yes/no), composable into grouped cards |
| Migration safety | Deterministic IDs + verify-before-stamp means an interrupted legacy migration resumes correctly rather than duplicating or losing entries |
| Backup format | Human-readable JSON, versioned, restorable even if this exact app no longer exists |
| Chart dependency footprint | None — SVG line chart hand-rolled over react-native-svg |
| Reminder delivery | Fully offline, works in the Simulator, no push infrastructure |
Qualitatively, what distinguishes it from a typical tracker:
- Every field in the check-in is optional to answer — the form never blocks on a question the user doesn't feel like answering that day.
- New installs choose a starting point during onboarding: the full shipped check-in, a minimal mood-only form, or a blank slate to build from scratch.
- A removed field's old answers still render correctly in History, because archiving never deletes the definition needed to interpret them.
- The backup file is plain, versioned JSON — legible to a human with a text editor, not a proprietary blob that needs this exact app to open.
- Timestamps are stored in UTC and bucketed to local days in JavaScript, so Trends stays correct across timezone changes and daylight saving.
// 06 — What I learned
Privacy as architecture beats privacy as policy.
The form is the product; the app is just a renderer.
Once fields became data instead of hardcoded screens, the hardest product question, "what should people track?", stopped being a decision I had to get right for every user. Mood tracking needs differ wildly between people; a user-defined form sidesteps the need to guess.
A document store beats a normalized schema when the shape is the user's choice.
I don't control what fields exist, so I can't design columns for them. One JSON-payload table with a couple of queryable columns gave me arbitrary structure without a single migration for anything the user configures — only genuinely new storage needs (a new collection, a new index) ever touch the schema.
Untrusted input isn't just the network — it's your own old code.
Every document on disk came from some previous version of this exact app, or from a backup file. Writing the normalizer to assume disagreement, rather than assume its own shape, is what let later changes (new field kinds, renamed options) ship without a forced migration step.
Notification limits are a silent failure mode, not a documented error.
iOS caps pending local notifications at 64 and just drops the rest — no error, no callback. The fix wasn't cleverness, it was arithmetic: schedule enough dated notifications to cover a real runway (60, ~20 days at 3/day) and refill on every launch rather than trying to maintain an indefinite repeating schedule that can't vary its wording.
Privacy as architecture beats privacy as policy.
The meaningful privacy claim isn't a sentence in a privacy policy — it's that the app has no server to send data to in the first place. No account and no analytics weren't a feature cut for later; they were the actual product decision that made people comfortable logging sensitive entries honestly.
Deletion needs two verbs, not one.
"Remove this field" and "delete this data" feel like the same user intent and are not. Archiving solves the first without foreclosing the second, and keeping them as separate, explicit actions means an accidental tap never destroys years of entries.
The native release pipeline is its own project.
Certificates, provisioning profiles, build-number bumps, DerivedData cache invalidation, and where EAS's remote version source disagrees with a local app.json are all failure points that have nothing to do with the app's code and everything to do with shipping it. None of that complexity is optional once the target is an actual device in someone's pocket.
// Technologies used
| Layer | Technology | Why it was chosen |
|---|---|---|
| Framework | Expo SDK 58, React Native 0.88, React 19.3 | Managed native tooling (prebuild, EAS) without giving up a from-scratch Xcode release path when needed |
| Language | TypeScript, strict mode | The form model is a discriminated union across 5 field kinds — strict typing catches a missing case at compile time, not in the Simulator |
| Navigation | expo-router | File-based routes for tabs (Log, History, Trends) plus stack screens (entry detail, field editor, settings) with no manual navigator wiring |
| Storage | expo-sqlite, as a local JSON document store in WAL mode | User-defined form fields mean the schema of an entry isn't known until runtime — a document store sidesteps needing one |
| Charts | react-native-svg, hand-rolled multi-series line chart | The only chart in the app is one kind of chart; a full charting library would have been overhead for one component |
| Notifications | expo-notifications, scheduled entirely on-device | No server, no push token, no account — reminders have to work with nothing behind the app |
| Backup | expo-file-system + expo-sharing | Writes a JSON file to the cache directory and hands it to the OS share sheet — the app never learns or controls where it ends up |
| Release | Local Xcode archive/export script + EAS Build/Submit as an alternative path | A free local pipeline for day-to-day builds, with EAS available for TestFlight submission without local signing setup |
// Appendix — the app at a glance
| Metric | Value |
|---|---|
| Field kinds | 5 (slider, words, text, datetime, yes/no) |
| Default reminder times | 09:00 / 14:00 / 20:00, all independently adjustable |
| Scheduled notification queue | 60 dated notifications, ~20-day runway, refilled per launch |
| Trend windows | 7 / 14 / 30 / 60 days |
| Storage tables | 1 document table (`docs`) + a preserved legacy table for safety |
| Network dependencies | 0 |
| Bundle identifier | com.kalobtaulien.moodtracker |