Skip to content

UNDA — Design Document

A women's menstrual cycle tracker with a phase-aware individual workout planner. Local-first, Flutter (iOS + Android), Supabase for optional cloud sync.

Visual system: UNDA. See design_handoff_unda_app/README.md for the authoritative visual tokens and the interactive HTML prototype (UNDA.dc.html). This doc captures product/architecture; the handoff captures colors, type, spacing, radii, and screen layouts. When they disagree, the handoff wins on visual details, this doc wins on product/architecture.

Design tokens (mirrored in app/lib/core/theme/tokens.dart):

  • Background outer #051F27 · screen #082F3A · surface #0E3D49
  • Text #EAF6F4 · divider = text @ 16%
  • Accent #2AAE9B · Accent secondary #5CC9AE
  • Font: Inter (via google_fonts), headings weight 500 (never bolder), body 400
  • Type scale: H1 44, H2 26–32, body 13–15, micro-labels 10–11 uppercase with 0.08em spacing
  • Radii: sm 4 · md 8 · lg 14 (pill only for chips + tab indicators)
  • Buttons: outlined primary (transparent fill, accent border, accent text) — never solid-filled
  • Effects: accent glow (drop-shadow 5–6px on strokes), hairline borders instead of large shadows

1. Product principles

  1. Local-first. The app is fully usable offline. Cloud sync is opt-in and additive.
  2. Individual, not generic. Every workout recommendation is a function of the user's current phase, fitness level, goals, and available equipment.
  3. Evidence-based, humble. Cycle-phase training research is real but still evolving; individual variance is large. The app frames recommendations as "typical for this phase" and lets users override.
  4. Privacy-respecting. Cycle data is sensitive. Nothing leaves the device unless the user signs in and enables sync.
  5. Compassionate copy. No "punish yourself" language, no fear-based messaging around PMS or "hormone imbalance". Neutral, informative, encouraging.

2. Cycle phase model

The app models a canonical ~28-day cycle in four biological phases, with the luteal phase split into early and late segments because their training implications differ meaningfully. Real cycles vary between 21–35 days; the engine scales phase boundaries proportionally to the user's average cycle length.

PhaseCanonical daysHormonal profileTypical energyTraining implications
Menstrual1–5Estrogen and progesterone at their lowestOften lower; some feel fineLight movement, walking, yoga, mobility. If energy is high, moderate lifting is fine — the lack of hormones can even mimic a "male hormonal baseline" some athletes perform well in.
Follicular (late)~6–13Rising estrogen, low progesteroneRising, often peakBest window for strength gains, HIIT, plyometrics, PRs. Body tolerates volume and recovers well.
Ovulatory~14 (±2)Estrogen peak, LH surge, small testosterone bumpOften peakPeak strength — but ligament laxity from estrogen is highest, so warm-up thoroughly. Great for max lifts, sprints.
Luteal (early)~15–21Rising progesteroneModerateEndurance base work, moderate strength. Slightly higher core body temp — hydrate more, favor cooler workouts.
Luteal (late) / premenstrual~22–28Progesterone falling, estrogen also fallingOften lower, higher perceived exertionAerobic base (Zone 2), mobility, yoga, technique work. Reduce intensity. Higher injury risk from fatigue.

Important caveats the app surfaces to users:

  • Hormonal contraceptives suppress the natural cycle — phase-based programming does not apply the same way. The app detects this in onboarding and offers a flat-programming mode.
  • Cycles vary. Phase length is an estimate until we have 3+ cycles of logged data.
  • Individual response varies more than average response. The app tracks how users feel per phase and adjusts recommendations over time.

Source references (for the design record, not surfaced verbatim in-app):

  • Sims, S. — ROAR: How to Match Your Food and Fitness to Your Unique Female Physiology (2016). Framework for phase-based nutrition and training.
  • McNulty et al. (2020) — Sports Medicine systematic review on the effects of the menstrual cycle on exercise performance. Concluded effects are trivial-to-small on average, but with high inter-individual variability. The app reflects this by treating recommendations as defaults, not prescriptions.
  • Bruinvels et al. (2021) — female athlete symptom prevalence.
  • Kissow et al. (2022) — effect of follicular vs. luteal phase on resistance training adaptations.
  • Janse de Jonge (2003) — early foundational review on menstrual cycle and exercise performance.

Any specific claim in the app UI should link to one of these or a similar peer-reviewed source.


3. Architecture

┌─────────────────────────────────────────────┐
│                 Flutter app                 │
│  ┌─────────────────────────────────────┐    │
│  │ features/  (screens + view models)  │    │
│  │  onboarding · home · cycle ·        │    │
│  │  workouts · insights                │    │
│  └───────────────┬─────────────────────┘    │
│  ┌───────────────▼─────────────────────┐    │
│  │ core/services                       │    │
│  │  CyclePhaseCalculator ·             │    │
│  │  WorkoutRecommender ·               │    │
│  │  NotificationService                │    │
│  └───────────────┬─────────────────────┘    │
│  ┌───────────────▼─────────────────────┐    │
│  │ core/repositories                   │    │
│  │  CycleRepository · WorkoutRepo      │    │
│  └────┬────────────────────────────┬───┘    │
│       ▼                            ▼        │
│  ┌────────────┐              ┌────────────┐ │
│  │ Local DB   │◄────sync────►│ Supabase   │ │
│  │ (Drift)    │              │ (Postgres) │ │
│  └────────────┘              └────────────┘ │
└─────────────────────────────────────────────┘
  • State management: Riverpod. Feature-scoped providers, no god-object.
  • Local persistence: Drift (SQLite). Schema mirrors Supabase.
  • Remote sync: Supabase. Row-level security keyed on auth.uid(). Sync is last-write-wins per row with updated_at; no CRDT.
  • Auth: Supabase magic-link email or "skip for now" (local-only account).
  • Notifications: flutter_local_notifications. Local-only for the prototype (period reminder, workout reminder).

4. Screen flow (prototype)

4.1 Onboarding (one-time)

Five deliberate steps, in this order. Eligibility and explicit health-data consent are captured before any cycle data is entered — no processing without a lawful basis (§4.2 of the Legal Rules).

  1. Welcome + tagline.
  2. 18+ age gate (§16) — confirmation checkbox; attestation timestamp saved on the profile row.
  3. Health-data processing consent (§4.2, Art. 9(2)(a)) — its own screen, its own affirmative action, its own logged consent grant. No bundling.
  4. Average cycle length (21–35 slider, default 28).
  5. Last period start date.

Fitness level, goals, equipment, sport interests are collected later (Personalize screen) rather than in onboarding — data minimisation (§3.1). Notifications default OFF (§3.3 privacy by default) and are opt-in from Settings.

4.2 Home (daily hub)

  • Big card at top: current phase, day-of-cycle, days-to-next-period estimate.
  • Workout of the day card: title, duration, intensity, "Start" button. Explanation blurb: "why this workout for today".
  • Quick-log row: period flow · symptoms · mood · energy (1–5).
  • Small insight tile that rotates: hydration note, nutrition tip, recovery tip — always framed as the phase-specific default.

4.3 Cycle log

  • Calendar view (monthly) with each day tinted by phase color.
  • Tap a day to log/edit: period flow (none/spot/light/med/heavy), symptoms (multi-select), mood, energy, notes.
  • "Log period start" / "Log period end" quick actions.

4.4 Workouts

  • Today tab: same workout of the day, with "swap" button (picks another workout matching current phase and profile).
  • Library tab: browsable by phase / duration / equipment.
  • History tab: completed sessions with a subjective "how did it feel" 1–5 rating that feeds back into the recommender.

4.5 Insights

  • Cycle length distribution (once ≥3 cycles logged).
  • Symptom patterns per phase.
  • Workout completion + subjective-feel per phase (does the user actually feel worse in late luteal? we don't assume — we measure).
  • Export cycle data as CSV.

4.6 Privacy Center

Every GDPR-execution surface lives under Settings → Privacy & data (per Legal Rules §12.4). Screens under app/lib/features/privacy_center/.

  • Export my data — builds UNDA-export-YYYYMMDD-HHmmss.zip in the app's temp dir and hands it to the OS share sheet (UNDA never receives the file). Contents: README.txt, profile.json, cycle.csv, sessions.csv, consents.csv, integrations.json. Implementation: features/privacy_center/export/data_exporter.dart.
  • Manage consents — every ConsentType shown with current state, granted/withdrawn timestamps, source, and per-type controls:
    • health_data_processing withdrawal → confirm dialog → deleteAllMyData() (§4.4 — no lawful basis to keep the data).
    • cloud_sync — toggle mirrors syncEnabledProvider; every flip records a grant/withdrawal row.
    • notifications — withdraw button when active; grant path lands with the notifications feature.
    • product_analytics — informational; not offered in v1.
    • Provider consents (Strava / Intervals / Garmin / Apple Health / Google Health Connect) — informational until the respective integrations land.
  • Delete all my data — wipes workout_sessions, cycle_days, cycles, consents, profiles. Router returns to onboarding.

Every action here is local-only. When Supabase sync arrives (Phase 7), delete-account also propagates remotely and the delete flow will wait for confirmation before returning.

5. Phase prediction algorithm

Given:

  • lastPeriodStart (date)
  • avgCycleLength (int, default 28)
  • avgPeriodLength (int, default 5)
  • today (date)

Compute:

dayOfCycle = ((today - lastPeriodStart).days % avgCycleLength) + 1

// scale phase boundaries proportionally to avgCycleLength
scale = avgCycleLength / 28
menstrualEnd = round(avgPeriodLength)               // e.g. 5
follicularEnd = round(13 * scale)                   // e.g. 13
ovulatoryEnd = round(15 * scale)                    // e.g. 15
lutealEnd = avgCycleLength                          // e.g. 28

phase =
  dayOfCycle <= menstrualEnd     ? Menstrual  :
  dayOfCycle <= follicularEnd    ? Follicular :
  dayOfCycle <= ovulatoryEnd     ? Ovulatory  :
                                   Luteal

Confidence is high if ≥3 completed cycles logged, medium if 1–2, low if only onboarding data.

Uncertainty propagation (per Legal Rules §9.4):

  • daysUntilNextPeriodUncertainty — ± window on the next-period countdown. Baseline: high=±2, medium=±3, low=±5.
  • ovulationWindowStartDay / ovulationWindowEndDay — the estimated ovulation window in day-of-cycle numbers. Radius from the canonical mid-cycle centre: high=1 day, medium=2 days, low=3 days.
  • The Calendar renders the ovulation ring across the whole window, not one day. Legend copy is "Estimated ovulation".
  • The Today card shows an Estimated pill next to the phase name and surfaces Confidence: {level} explicitly, with a tap-to-explain sheet.

Once the user logs a new period start, the prediction resets from that anchor. The app never blocks the user from logging a period earlier or later than predicted — data is source of truth, model is estimate.


6. Workout recommendation algorithm

Inputs:

  • current phase
  • fitnessLevel
  • goals (set)
  • equipment
  • recent history (avoid same workout 2 days in a row)
  • user's phase-feel history (if user historically feels wiped in late luteal, downshift further)

Selection:

  1. Filter the library to workouts tagged with the current phase.
  2. Filter by equipment ⊆ user's equipment.
  3. Filter by fitness level (each workout has a min level).
  4. Rank by (goals overlap) - (recent-history penalty) + (small randomization).
  5. Return the top one; expose the next 3 as "swap options".

See docs/WORKOUT_LIBRARY.md for the full content library and docs/product/recommendation-engine.md for the versioned engine spec.

6.1 Output shape (§9.5, §9.7)

The recommender returns a RecommendationResult:

dart
RecommendationResult(
  workout,
  factors: [ RecommendationFactor(type, impact), ... ],
  algorithmVersion: 'unda-workout-recommender-0.1.0',
)

factors[] are surfaced on the Today card under "What influenced today's recommendation?" as up/down/flat arrows next to human-readable labels. When the user marks a session completed, both algorithm_version and the factors JSON are persisted onto workout_sessions, so retrospective analysis can attribute past picks to the exact scorer version that produced them.

6.2 Known gap (blocker B7)

Phase is currently a hard filter in _rankAll(). Per Legal Rules §9.1 this is deterministic prescription and must become a weight in v1.0.0 of the recommender. See docs/product/recommendation-engine.md §"Target design".


7. Data privacy

  • Local DB is unencrypted-at-rest for the prototype (OS-level file protection). A follow-up ticket adds SQLCipher.
  • Supabase sync is optional and requires explicit sign-in.
  • No third-party analytics in the prototype. If added later: no cycle data ever leaves in an analytics payload.
  • Symptom/period data is never sent to any ad network. This is a hard rule.

9. External integrations (export + sync)

Users of Garmin, Wahoo, Intervals.icu, TrainingPeaks, Strava, and Apple Health/Google Fit already have workout tooling they trust. app_wellness should augment those platforms, not replace them: we own the cycle context and daily prescription, they own the execution and long-term training log.

Because every workout in our library is stored as structured steps (duration + intensity target + description), each of the following exports is a rendering of the same underlying object.

TargetDirectionFormatNotes
Garmin Connectpush workout.fit (Workout file)Structured steps → FIT WORKOUT_STEP records. User connects via Garmin Connect IQ OAuth. MVP: generate .fit locally and let user import manually; v2: direct API push.
Intervals.icupush workout + read completed activitiesJSON APITheir workout JSON supports our step model directly (duration/target/repeat). Two-way: we push planned workouts to their calendar and read back what the user actually did to feed the recommender.
TrainingPeakspush workout.zwo / TP JSON via partner APIv2.
Stravaread activitiesOAuthRead-only, for logging what the user completed.
Apple Healthread + writeHealthKitRead cycle events (if the user tracks them there already), write workout sessions, active energy, HRV context.
Google Fit / Health Connectread + writeHealth Connect APIAndroid analogue.
.ics calendarpushICS fileSimplest fallback — dump the week's planned workouts as calendar events with the structured steps in the description.

Design implications this creates in the schema:

  1. Every Workout has steps[] with durationSeconds, target (RPE 1–10 or Zone 1–5 or HR range or power range), and repeatCount for intervals. Prose-only workouts can't be exported and are marked exportable=false.
  2. Every WorkoutSession (a completed instance) has a nullable externalId map keyed by provider, so we can dedupe when Strava/Garmin sends back the "same" workout the user did.
  3. We store user zone calibration once (HR_MAX, LTHR, FTP if available) so Zone-based steps render to concrete HR/power on export.
  4. Integrations are strictly opt-in per provider, revocable in Settings, and never a precondition for the app to work.

Deep integration is v2; MVP ships .fit file export and .ics export because both are one-file-download flows that need no OAuth partner approval.

Shipped 2026-08-08:

  • .icsfeatures/workouts/export/ics_exporter.dart. RFC 5545 VEVENT with the workout title as summary, structured steps in the description, folded lines at 72 chars, UTF-8 escaping. Universal — any calendar app on any platform accepts it.
  • .fitfeatures/workouts/export/fit_exporter.dart. Minimal spec-compliant Garmin FIT file: header (14 bytes) + file_id (workout) + workout (sport + name + step count) + workout_step (one per step) + CRC-16. Repeat blocks are flattened to linear steps (real FIT repeat_until_steps loops are a follow-up). Targets recorded as open — RPE has no direct FIT equivalent, so we don't lie about it. Untested against real Garmin hardware; should work; not guaranteed until a user pushes to an Edge/Fenix.

Both exports write to getTemporaryDirectory() and hand off to share_plus for the OS share sheet.

9.1 Activity-import → profile inference (individualization)

The single most valuable outbound integration is reading the user's recent activity from Strava or Intervals.icu so the plan is individual from day one, not "the app's guess about a beginner." Concretely:

  1. Connect a source (Strava / Intervals.icu / Garmin) during onboarding — optional, skippable.
  2. Backfill ~90 days of activities via ActivityHistoryImporter.fetchRecent(since: 90d ago).
  3. Infer sport interests, typical session duration, weekly frequency, and fitness level via ProfileInferenceService.infer(activities).
  4. Show the inference to the user as an editable proposal ("we see you mostly ride and lift — is that right?"), never a silent write. This is a trust point and it matters more than being right.
  5. Keep syncing in the background. If the user drifts from riding to bouldering, the recommender follows.

The inference feeds the recommender's sport-overlap term (+5 per matched sport) and the level filter. It also unlocks completion feedback: when we push a workout to Garmin/Intervals and the user does it, the returning activity gives us a "how did it feel" signal (avg HR / TSS / user note) to refine future picks per phase.

Interface: core/services/activity_history_importer.dart (provider-neutral). Concrete implementations under core/services/integrations/ are stubbed for the prototype except for MockImporter, which returns fixture data so onboarding can be demoed end-to-end without OAuth.



8. Out of scope for prototype

  • Pregnancy / fertility tracking (basal body temp, cervical mucus, LH tests).
  • Video workout playback (workouts are text + timer for now).
  • Social/sharing features.
  • Wearable integration (Apple Health / Google Fit) — planned for v2.
  • AI symptom summaries.

These are noted so the data model doesn't paint us into a corner — see DATA_MODEL.md for the extension points.

UNDA is a fitness and training support product. It is not a medical device.