Skip to content

Add a circadian rhythm tile to the Sleep page - #144

Open
saksham2001 wants to merge 3 commits into
mainfrom
feat/circadian-rhythm
Open

saksham2001 wants to merge 3 commits into
mainfrom
feat/circadian-rhythm

Conversation

@saksham2001

@saksham2001 saksham2001 commented Nov 13, 2026 •

Copy link
Copy Markdown
Owner

Summary

Adds a circadian rhythm tile to the top of the Sleep page, above the Last Sleep card. It shows when you usually wake, eat, train and go to bed, and how far the day or period you're viewing drifted from that.

The tile (9cc2a3d)

  • What it shows. A semicircular dial runs from 4 AM to 4 AM, matching the Sleep page's 4 AM day flip.
    • Each event sits at its usual time.
    • A gold bar from each event shows the drift: outward is later, inward is earlier, and longer is a bigger gap. Large drifts also get a signed label (+32m, −1h 4m).
    • The middle reads the day in one line, e.g. "Off rhythm · 3 of 5 within 30 min".
  • Ranges.
    • Day compares the selected day with your usual.
    • Week and Month compare that period's average with the 30 days before it.
    • Year shows your typical times.
    • A Week or Month with no earlier history shows its own typical day instead of comparing the period with itself.
  • Usual. The circular mean over the 30 days before the day or period, so 11:30 PM and 12:30 AM average to midnight.
    • It uses whatever history exists, but needs at least 5 nights; until then the tile says "Learning your rhythm".
    • Missed nights don't reset it.
    • Naps never move wake or bedtime: each day uses its longest session, and only if it's at least 3 h.
  • Meals. Breakfast, lunch and dinner each sit at the time of their first entry that day; snacks are ignored.
    • Meals appear only while nutrition tracking is on.
    • Smart (default) shows a meal only if it's logged on 4 of the last 7 days or 10 of the last 28. A new habit appears within a week, and a missed week doesn't drop an established one.
    • Custom lets you pick meals by hand.
    • Meal logging already has a breakfast / lunch / dinner / snack picker, so nothing changed there.
  • Workouts. A green band at the time you trained, with a runner for cardio or weights for strength (gym/strength). There is no drift bar. Smart shows workouts only when you train regularly; Always shows every one.

Expanded view. Tapping the tile opens a sheet with:

  • the full 24-hour dial (noon at top, midnight at bottom);
  • a "Reading the dial" legend for outward and inward bars and the violet and green bands;
  • each event's time against its usual time;
  • a note on how "usual" is computed;
  • up to three coach prompts, chosen from what drifted.

Coach hand-off. Tapping a prompt closes the sheet and opens a new coach chat with that question already sent.

  • CoachNavigation.ask(_:context:) and CoachView.askPendingPrompt() carry the question across.
  • The dial's times go to the model through a new hiddenContext parameter on CoachViewModel.send. The chat bubble shows only the question, because no existing coach tool returns bedtimes or wake times.

Settings › Circadian Rhythm (in the Metrics section)

  • The tile is on by default.
  • Meals: off, Smart or Custom.
  • Workouts: off, Smart or Always.
  • Each group explains its rule in a footer. Groups are always drawn and greyed out when disabled, following NutritionSettingsView's rule against inserting glass groups.

Demo data and launch args

  • The demo seed now includes a month of meals and two more workouts, so Smart mode has habits to find.
  • New launch args -openCircadian YES and -openCircadianSettings YES open the expanded sheet and the settings page.

Lint (9a9b11e): fixes a large_tuple error and long lines introduced in the first commit.

Related issues

Related to #118. This adds the learned usual wake and bedtime it needs (naps excluded, averaged correctly across midnight), but not the caffeine, light or wind-down windows.

Type of change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 📟 New / improved wearable support (BLE driver layer)
  • 🤖 Coach / LLM change (tools, prompts, orchestration)
  • 🎨 UI / DesignSystem change
  • 🧹 Refactor / chore (no behavior change)
  • 📝 Docs only
  • ⚠️ Breaking change (existing data, settings, or APIs change)

How was this tested?

  • Added / updated unit tests (PulseLoopTests)
    • New CircadianRhythmTests (40 cases):
      • clock math across midnight;
      • the 30-day / 5-night baseline, gaps and naps;
      • Smart thresholds, Custom choices, and the nutrition gate;
      • workout categories, and Week, Month and Year ranges;
      • headline copy, formatting, and prefs decoding and persistence;
      • dial geometry, and a SwiftData integration test.
  • Ran the test suite locally (⌘U in Xcode): 1286 tests, 0 failures
  • Tested on a physical device with a real ring — model: not yet
  • Tested with demo data (-seedDemo YES, no hardware)
    • iPhone 17e, 17, Air and 17 Pro Max on iOS 26.5; iPhone 16e and 16 Plus on iOS 18.6.
    • Checked:
      • Day, Week, Month and Year;
      • the expanded sheet;
      • the settings page;
      • turning the tile off hides it and turning it on brings it back;
      • a coach prompt opens a new chat with the question sent.
  • N/A (docs / non-code change)

Not checked yet:

  • The coach's reply to a prompt. The on-device model was still working in the Simulator.
  • A 375 pt iPhone SE screen (no simulator installed).
  • Reduce Motion, Reduce Transparency, and the largest text size.
  • A fresh install with no data.
  • Real ring data.

Known limit: on the semicircle one hour is about 14 pt wide, so a workout right before a meal sits partly under that meal's icon. The full dial in the expanded view separates them.

Privacy & data

  • This change does not send health data off-device without explicit user action.
  • No secrets, API keys, or personal data are committed.
  • N/A

The dial's times (wake, meals, workouts, bed) are only sent when the user taps a coach prompt. That requires the AI Coach to be on and goes to whichever provider it uses, the same as typing the question. The tile itself works entirely on device.

Screenshots / recordings

Demo data (-seedDemo YES -enableNutrition YES) on iPhone 17, iOS 26.5.

Sleep page, Day Sleep page, Week Expanded view
Circadian tile above Last Sleep on the Day view: Off rhythm, lunch 32 minutes late, bedtime 1 hour 4 minutes early, an evening run in green Week view tile: On rhythm, all 5 within 30 minutes Expanded view: full 24-hour dial with noon at the top, and the Reading the dial legend
Today's drift against the previous 30 days The week's average against the 30 days before it Full 24-hour dial and how to read it
Event list and coach prompts Settings › Circadian Rhythm Tapping a prompt
Event list with each time against its usual, the note on how usual is computed, and three Ask your coach prompts Circadian Rhythm settings: show toggle, meals Smart or Custom, workouts Smart or Always Coach opens a new chat with the chosen question already sent
Each event against its usual, then prompts chosen from what drifted Meals: off, Smart or Custom. Workouts: off, Smart or Always Opens a new coach chat with the question sent

Across devices. Left to right: iPhone 17e, 17, Air and 17 Pro Max (iOS 26.5), then 16e and 16 Plus (iOS 18.6).

Week view tile on six iPhones

Expanded view on six iPhones

Checklist

  • My code follows the project's style (SwiftLint passes). 0 errors. Four non-blocking warnings remain in the new files: function_body_length on CircadianEngine.snapshot and CircadianDialLayout.init, and function_parameter_count on CircadianEngine.snapshot and the sheet's eventRow.
  • I ran the tests and they pass.
  • I updated docs / README where relevant.
  • I read the Contributing guide.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant