Search

Search components, tokens, patterns, architecture

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

Capacity scores, crash risk, recommendations, trends, playbooks and weekly summaries are derived layers recomputed from the two arrays on every render. There is no score table to migrate, no cached insight to invalidate, no stored recommendation to go stale — derived data structurally cannot disagree with the record.

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.

GroupFieldsNotes
Identityid · dateid = Date.now() (+ random for wearable stubs)
Cyclecd?1 = period start (with bleedIntensity) — the anchor everything reads. cd:0 is reserved/repaired; wearable stubs leave cd unset
Body scalesenergy · mood · fog · sleepq · bloat · motiv · sexDrive1–5, number | null — owned by How You Feel
Sleepsleep · sleepTags[]hours + tags — owned by Log Sleep
Trainingtraining[] · trainingFeel[] · trainSym[]owned by Log Workout · legacy ⚡ Hulk ≡ ⚡ Peak
Symptomssymptoms[]owned by Log Symptoms · weighted by symptomBurdenUtils
PeriodbleedIntensity · periodSyms[]owned by Log Period · weighted by periodSymUtils
ProvenancesleepSource · sleepqSourcemanual / wearable / manual_override — how merges are remembered
Wearablewearable?: WearableDataraw normalized device data, embedded per day

Field ownership invariant

Each log screen writes only its own fields and spreads the rest (merge-safe save). Six screens, one record, zero clobbering — the IA mirrors the data model: six doors, one room.

Protocol, UserProfile & cycle history

EntityShapeNotes
Protocolid · start · end(null=ongoing) · title · category · notes · isEvent · medications[] · treatmentTrendMarker · treatmentAffectedAreas[]trendMarker exposes a “Since [Treatment]” anchor in Trends; Medication = {name, dose, frequency}, embedded
UserProfilecycleRegular · cycleLength · sleepHours · sleepQuality · prioritySymptoms[]all nullable — a fully-skipped onboarding is valid; primes interpretation, never substitutes for logged data
Cycle historyderived — no tablegetPeriodStarts(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.

BlockKey fieldsEngines?
sleepstart/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
activitysteps · 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
heartrestingHR · HRV · VO₂maxbaselines feed stress flags
temperatureskinTempC · bodyTempC · overnight deviation · delta series · BBTstored only — never computed on (safety rule)
stress · physiologyderived booleans (HC has no stress score) · weight/BP/glucose/hydrationstored only

Merge policy

sleepDuration 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

StoreContentsSync scope
tuluna-entries / tuluna-protocolsthe two arraysmirrored to Drive
tuluna-data.jsonAppData { entries, protocols } in Drive appdataunion merge on sign-in: by date, field-count tiebreak, cd repair
peri-profile · peri-onboardedprofile + flaglocal only
tuluna-sync-log · tuluna-wearables · custom tags ×9sync audit (~22 imported-data flags per attempt) · device registry · vocabularieslocal only — audit never leaves the device

Derived layers (computed, never persisted)

LayerDerived fromCadence
Strain / Capacity / Crash Risk + recommendationEntry[navDate] + history windowevery render
Fueling floorsentries + DailyFocus outputevery render
What Changed / What Nextper-day capacity / similarity over all entriesevery render
Trends · Playbooks · stats rowwindowed entries (1M/3M/6M or since-treatment) · pattern defs (≥14 entries, ≥3 obs, ≥60%)on Insights render
Cycle history · phases · predictionsperiod-start entriesevery 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

  1. 01One entry per date — enforced at save (dedupe id+date) and at sync (merge by date, keep the more-filled entry).
  2. 02Field ownership — only the owning screen writes a field; merges spread the rest.
  3. 03cd:1 + bleed is the only cycle anchor; cd:0 is corrupt unless a logged period start (repaired on load); unknown cd stays undefined.
  4. 04Manual beats wearable — at merge (policy) and at interpretation (override guards); provenance fields record every resolution.
  5. 05Raw evidence is immutable — originalType/originalLabel and raw wearable blocks are never overwritten; normalization is lazy so improvements apply retroactively.
  6. 06Temperature is stored, never computed on — the safety rule encoded as a data-model rule.
  7. 07Local-first always — localStorage write precedes any network write; Drive merge never discards local entries.
  8. 08Derived data is never persisted — engines are pure functions of (date, entries), pinned by 281 tests.