Skip to content

Time the day around the user's own sleep schedule - #127

Open
ak710 wants to merge 3 commits into
saksham2001:mainfrom
ak710:feat/circadian-windows
Open

Time the day around the user's own sleep schedule#127
ak710 wants to merge 3 commits into
saksham2001:mainfrom
ak710:feat/circadian-windows

Conversation

@ak710

@ak710 ak710 commented Aug 3, 2026

Copy link
Copy Markdown

Closes #118

Merge order: 8 of 9. ⚠️ Stacked on #124, which is itself stacked on #120 — this diff contains all three commits. Merge #120 then #124 first.

What

Four windows on the Sleep tab: get light by (wake + 2 h), last coffee by (bedtime − 8 h), finish eating by (bedtime − 3 h), wind down from (bedtime − 1 h).

Anchored on sleep, not sunrise — and why

Ultrahuman derives its equivalents from solar times, which needs location. I didn't, for two reasons:

  1. Privacy. The project's principles keep location optional; requiring coordinates to show four times is a poor trade.
  2. It's what the advice actually says. The guidance these encode is already phrased relative to your own sleep — "get bright light within an hour or two of waking", not "at sunrise". Someone who wakes at 13:00 still benefits from light at 13:00.

A night-shift schedule (asleep 09:00, awake 17:00) produces coherent windows for exactly that reason, where a sunrise-based one would produce nonsense — testANightShiftScheduleStillWorks.

The cost, stated plainly in the docs and the source: this cannot tell you whether it is actually light outside. It says when the body clock is most responsive, not when the sun is up. On a dark winter morning that means a lamp, and the app can't know the difference.

The midnight case

Both offsets are placed against the midnight ending today: startOfToday + 24 h + offset. That one expression is what makes the two sides agree — a −60 (23:00) bedtime lands tonight, a +30 (00:30) bedtime lands tomorrow, both being tonight's sleep.

My first version special-cased the two signs and put the caffeine cutoff for a 00:30 sleeper at 16:30 yesterday. testAPastMidnightBedtimeCountsBackIntoToday caught it.

The schedule

Median bedtime and wake over the last 14 nights, needing 7. Days collapsed so each contributes one of each, and only sessions of ≥3 hours count — a 20-minute nap's start and end are not a schedule and would drag both medians (testNapsAreExcludedFromTheSchedule).

Nothing renders until the schedule is established. Each window's reason is disclosed on tap — four bare times with no explanation would be instructions, and these are general rules of thumb applied to a schedule, not findings from the user's data.

Testing

CircadianWindowsTests — 12 tests, concentrated on the date arithmetic. Full suite green (908). Docs: docs/project/circadian-windows.md.

ak710 and others added 3 commits August 2, 2026 14:19
The Colmi big-data timeline (stage 0x04) and the YCBT timeline (tag 3) both
report REM, and both decoders have always stored it as a SleepStageBlock. It
just never got any further: SleepSummary carried light/deep/awake only, so REM
never reached the sleep score, the Sleep tab, or the coach — which was still
hard-coded to tell the model "light/deep/awake only, no REM" on every ring.

Carry remMinutes through SleepSummary, collapseByDay and averageStages, and
report it as remPct. Nil rather than zero throughout when the ring reported no
REM stage at all: a jring's 0x11 timeline genuinely has none, and "absent" and
"you slept no REM" are different claims.

This also fixes a scoring side-effect. hasAwakeSignal falls back to asking
whether the stage timeline accounted for essentially the whole night, and REM
was excluded from that sum — so a fully described REM night looked only ~80%
covered, failed the 0.95 gate, and had its awake reading thrown away as "no
signal", costing it 45% of the awake sub-score. REM now counts toward coverage.

REM is measured but still not scored: the light band (ideal 50-60%) is
calibrated for a no-REM decoder that lumps REM into light, so re-weighting the
score would move every stored night and needs the versioned recompute readiness
does. Noted in SleepScore.calculate for the sleep-score rework.

The coach's caveat is now chosen per night from that night's own blocks rather
than from the connected ring's capabilities — stored nights outlive the ring
that recorded them, so switching rings must not retro-actively disclaim last
week's REM.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v1 scored duration (35), deep % (30), light % (20) and awake % (15). Three of
those four had problems.

Duration scored SleepSession.totalMinutes, which SleepSegmentation sets from
end - start — time in bed, not time asleep. A night with 8h in bed and 90 min
awake was credited as 8h of sleep. Awake minutes now come off the top.

Light % was scored against a band (ideal 50-60%) calibrated for a no-REM decoder
that lumps REM minutes into light. On a ring that tags REM separately, the same
night reads ~20 points lower and was scored harshly for a split that was
correct. Light is now the residual of deep and REM, so it is reported and not
scored — scoring it counted the same night twice.

Awake % was scored at 55% of its points when the ring gave no usable wake
signal, which quietly docked every jring night for a sensor it never had.
Missing signals now leave the denominator instead, exactly as readiness does: a
jring is scored out of 80, a first week out of 90, and coverage is reported
alongside the number. That is what makes one score comparable across hardware —
an ideal night now scores the same on both.

REM joins as a contributor (20 pts, ideal 20-25%), and bedtime consistency as a
new one (10 pts), computed from the user's own median bedtime over the previous
14 nights on an axis wrapped around midnight, so 23:40 and 00:20 average to
midnight rather than noon. The night being scored is excluded from its own
baseline — including it would drag the median toward it and forgive exactly the
drift the contributor exists to notice.

Sleep efficiency is deliberately absent: with totalMinutes being time in bed it
works out to exactly 1 - awake %, so it would restate restfulness while looking
like an independent sixth signal.

No migration needed. The production sync path stores every SleepSession with a
nil score and every screen computes live from the stage blocks, so v2 took
effect immediately; only demo and imported data carry a stored score.
algorithmVersion is stamped anyway, for a future change that does need one.

Full contributor table, thresholds and rationale in docs/project/sleep-score.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four windows on the Sleep tab: get light by, last coffee by, finish eating by,
wind down from. Ultrahuman's equivalents are one of the most-cited reasons
people pick it over Oura.

Anchored on the user's own bedtime and wake rather than on sunrise. Ultrahuman
derives its windows from solar times, which needs location; this doesn't, for
two reasons. The project's principles keep location optional, and the guidance
these windows encode is already phrased relative to your own sleep — "get bright
light within an hour or two of waking", not "at sunrise". A night-shift
schedule (asleep 09:00, awake 17:00) produces a coherent set of windows for
exactly that reason, where a sunrise-based one would produce nonsense.

The cost is stated plainly in the docs and in the source: the light window
cannot say whether it is actually light outside. It says when the body clock is
most responsive, not when the sun is up.

The schedule is a median bedtime and wake over the last 14 nights, needing
seven. Days are collapsed so each contributes one of each, and only sessions of
three hours or more count — a 20-minute nap's start and end are not a schedule
and would drag both medians. Times are averaged on an axis wrapped around
midnight, reusing the placement BedtimeBaseline already defines.

Both offsets are placed against the midnight *ending* today, so a −60 (23:00)
bedtime lands tonight and a +30 (00:30) bedtime lands tomorrow — both being
tonight's sleep. An earlier version special-cased the two signs and put the
caffeine cutoff for a 00:30 sleeper at 16:30 yesterday; the midnight test caught
it, and the uniform expression is what makes the two sides agree.

Nothing renders until the schedule is established, and each window's reason is
disclosed on tap — four bare times with no explanation would be instructions
rather than guidance, and these are general rules of thumb applied to a
schedule, not findings from the user's data.

Offsets and rationale in docs/project/circadian-windows.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ak710
ak710 requested a review from saksham2001 as a code owner August 3, 2026 06:25
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.

[Feature]: No circadian timing guidance (caffeine / light / wind-down windows)

1 participant