Architecture · Part 05
Data Model
Peri's entire persistent world is two arrays and a profile — Entry[] (one record per day), Protocol[] (treatments spanning days), UserProfile (onboarding priors) — plus a wearable blob per entry. Everything else the product shows is computed, never stored.
The defining decision
Entity relationship diagram
Entry — one logged day (the atom)
All fields optional except id and date. The date is the true key; id exists for React and dedup. Saves dedupe by both.
| Group | Fields | Notes |
|---|---|---|
| Identity | id · date | id = Date.now() (+ random for wearable stubs) |
| Cycle | cd? | 1 = period start (with bleedIntensity) — the anchor everything reads. cd:0 is reserved/repaired; wearable stubs leave cd unset |
| Body scales | energy · mood · fog · sleepq · bloat · motiv · sexDrive | 1–5, number | null — owned by How You Feel |
| Sleep | sleep · sleepTags[] | hours + tags — owned by Log Sleep |
| Training | training[] · trainingFeel[] · trainSym[] | owned by Log Workout · legacy ⚡ Hulk ≡ ⚡ Peak |
| Symptoms | symptoms[] | owned by Log Symptoms · weighted by symptomBurdenUtils |
| Period | bleedIntensity · periodSyms[] | owned by Log Period · weighted by periodSymUtils |
| Provenance | sleepSource · sleepqSource | manual / wearable / manual_override — how merges are remembered |
| Wearable | wearable?: WearableData | raw normalized device data, embedded per day |
Field ownership invariant
Protocol, UserProfile & cycle history
| Entity | Shape | Notes |
|---|---|---|
| Protocol | id · start · end(null=ongoing) · title · category · notes · isEvent · medications[] · treatmentTrendMarker · treatmentAffectedAreas[] | trendMarker exposes a “Since [Treatment]” anchor in Trends; Medication = {name, dose, frequency}, embedded |
| UserProfile | cycleRegular · cycleLength · sleepHours · sleepQuality · prioritySymptoms[] | all nullable — a fully-skipped onboarding is valid; primes interpretation, never substitutes for logged data |
| Cycle history | derived — no table | getPeriodStarts(entries) = entries where cd===1 && bleedIntensity → avg length, cd per date, phase, cycleMode. One write anchors everything |
Wearable sub-model (Entry.wearable)
Normalized and source-agnostic (Samsung Health, Apple Health, Garmin, Fitbit, Oura, Strava, generic — forward-compatible). All times ISO, durations minutes, temperatures °C.
| Block | Key fields | Engines? |
|---|---|---|
sleep | start/end · timeInBed/total min · efficiency · stages (awake/light/deep/REM) · sleep HR (avg/lowest) · SpO₂ · respiratory rate · sleepHRV (RMSSD) · derivedQuality 1–5 | ✅ HR+HRV → overnightTrend · duration/quality → merge candidates |
activity | steps · distance · calories · activeMinutes · workoutSessions[] | ✅ sessions count toward the 48h counter (manual precedence) |
workoutSessions[] | HC type + label · originalType/Label (raw evidence, never overwritten) · duration · HR · RPE · normalization snapshot: type / intensity / confidence / strainScore 0–10 | ✅ via lazy getNormalizedWorkouts() — improvements apply retroactively |
heart | restingHR · HRV · VO₂max | baselines feed stress flags |
temperature | skinTempC · bodyTempC · overnight deviation · delta series · BBT | stored only — never computed on (safety rule) |
stress · physiology | derived booleans (HC has no stress score) · weight/BP/glucose/hydration | stored only |
Merge policy
auto (fill if empty, skip if manual) · sleepQuality prefer_manual · conflict threshold 30 min. Manual always wins; provenance recorded in sleepSource/sleepqSource. Mapping tables normalize 7 HC sleep-stage codes and 78 exercise-type codes.Persistence map
| Store | Contents | Sync scope |
|---|---|---|
tuluna-entries / tuluna-protocols | the two arrays | mirrored to Drive |
tuluna-data.json | AppData { entries, protocols } in Drive appdata | union merge on sign-in: by date, field-count tiebreak, cd repair |
peri-profile · peri-onboarded | profile + flag | local only |
tuluna-sync-log · tuluna-wearables · custom tags ×9 | sync audit (~22 imported-data flags per attempt) · device registry · vocabularies | local only — audit never leaves the device |
Derived layers (computed, never persisted)
| Layer | Derived from | Cadence |
|---|---|---|
| Strain / Capacity / Crash Risk + recommendation | Entry[navDate] + history window | every render |
| Fueling floors | entries + DailyFocus output | every render |
| What Changed / What Next | per-day capacity / similarity over all entries | every render |
| Trends · Playbooks · stats row | windowed entries (1M/3M/6M or since-treatment) · pattern defs (≥14 entries, ≥3 obs, ≥60%) | on Insights render |
| Cycle history · phases · predictions | period-start entries | every render |
Data flow — one day's life
The loop is closed: cards disclose what's missing → assumption actions open the owning log screen → the entry gains the field → the derived layers change on the next render. See the Decision Engine for what happens inside the engines box.
Integrity rules — the model's contracts
- 01One entry per date — enforced at save (dedupe id+date) and at sync (merge by date, keep the more-filled entry).
- 02Field ownership — only the owning screen writes a field; merges spread the rest.
- 03cd:1 + bleed is the only cycle anchor; cd:0 is corrupt unless a logged period start (repaired on load); unknown cd stays undefined.
- 04Manual beats wearable — at merge (policy) and at interpretation (override guards); provenance fields record every resolution.
- 05Raw evidence is immutable — originalType/originalLabel and raw wearable blocks are never overwritten; normalization is lazy so improvements apply retroactively.
- 06Temperature is stored, never computed on — the safety rule encoded as a data-model rule.
- 07Local-first always — localStorage write precedes any network write; Drive merge never discards local entries.
- 08Derived data is never persisted — engines are pure functions of (date, entries), pinned by 281 tests.