Skip to content

feat(sleep): elevate sleep to first-party tables#226

Open
ryceg wants to merge 42 commits into
mainfrom
feature/sleep-first-party-table
Open

feat(sleep): elevate sleep to first-party tables#226
ryceg wants to merge 42 commits into
mainfrom
feature/sleep-first-party-table

Conversation

@ryceg

@ryceg ryceg commented May 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Introduces three new tenant-scoped tables (sleep_sessions, sleep_stages, sleep_biometric_samples) with RLS, replacing the generic StateSpanCategory.Sleep approach
  • Schema designed from cross-platform research (Apple HealthKit, Google Health Connect, Fitbit, Oura, Samsung Health, Garmin) with a unified stage enum and connector-ready dedup
  • Full stack: domain models + enums, EF entities, mapper, repository (transaction-safe upsert), service, V4 REST controller (/api/v4/sleep/sessions)
  • Migrates actogram report and chart pipeline to query from the new table
  • Silently reroutes v1 Activity API sleep operations to ISleepService
  • Data migration moves existing StateSpan sleep rows to the new schema
  • Removes Sleep from StateSpanCategory and cleans up all backend + frontend references

Design

See docs/plans/2026-05-16-sleep-first-party-table-design.md for the full design rationale, cross-platform stage mapping, and connector integration contract.

Test plan

  • 14 new repository unit tests (date range, tenant isolation, upsert dedup, cascade delete, update)
  • 3,108 existing API unit tests pass (1 pre-existing golden file failure unrelated)
  • 329 infrastructure tests pass
  • Verify NSwag client regeneration succeeds after merge (Sleep removed from TS enum)
  • Manual: start Aspire, confirm migration applies, confirm sleep report page loads
  • Manual: POST a sleep activity via v1 Activity API, confirm it appears in /api/v4/sleep/sessions

ryceg added 22 commits May 16, 2026 11:26
SleepStageType, SleepSessionType, SleepDetectionMethod, and SleepSource
enums with JSON string serialization for the dedicated sleep table system.
…ometric samples

Three new EF Core entities for first-party sleep data: SleepSessionEntity
(ITenantScoped + IAuditable), SleepStageEntity, and SleepBiometricSampleEntity.
All use snake_case columns, UUID v7 PKs, and follow the StateSpanEntity pattern.
Add DbSets, indexes, FK relationships, and UUID v7 generators for
SleepSessionEntity, SleepStageEntity, and SleepBiometricSampleEntity.
Creates sleep_sessions, sleep_stages, and sleep_biometric_samples tables
with Row Level Security tenant isolation policies.
CRUD + query/count repository port for SleepSession, following
the same shape as IStateSpanRepository.
…ert dedup

Implements ISleepSessionRepository using the ITenantDbContextFactory pattern.
Includes 12 unit tests covering date-range filtering, tenant isolation,
child entity includes, source+originalId dedup on upsert, and delete behavior.
Wrap upsert/update delete-then-insert operations in explicit transactions
to prevent partial writes. Replace bare catch clauses with specific
exception types (JsonException). Fix ToDomainModel to use entity.Id for
round-trip safety. Avoid mutating caller's input in UpdateSessionAsync.
Remove redundant .AsQueryable(). Add UpdateSessionAsync test coverage.
…, and DI registrations

Introduces the service layer for sleep sessions: ISleepService contract
in Core.Contracts.Sleep, thin SleepService passthrough in API.Services.Sleep,
and scoped DI registrations for both the service and repository.
Paginated list (GET), detail by ID (GET), create/upsert (POST),
update (PUT), and delete (DELETE) for sleep sessions.
…panService

Replace the StateSpan-based sleep query with ISleepService.GetSessionsAsync,
expanding sleep sessions into per-stage spans for richer actogram rendering.
…ions table

Sleep-type activities (sleep/nap/rest/sleeping) submitted via the v1
Activity API are now persisted as SleepSession records via ISleepService
instead of StateSpan records. GET endpoints merge sleep sessions
(projected back to Activity format) alongside non-sleep StateSpan
activities. ActivityCategories no longer includes StateSpanCategory.Sleep.
Data migration that converts existing StateSpanCategory.Sleep rows into
the new first-party sleep tables. Groups adjacent spans by calendar date
into sessions, maps state strings to SleepStage enum values, and deletes
the migrated StateSpan rows. One-way migration (Down is a no-op).
Sleep data now lives in the dedicated sleep_sessions table, so the
StateSpanCategory.Sleep enum value, the /sleep convenience endpoint,
and all references in mappers, chart stages, and tests are removed.
…e Sleep references

Move UpdateSessionAsync's entity fetch inside the retry strategy lambda
to prevent tracked-entity issues on retry. Inject ISleepService into
DataFetchStage and project sleep sessions as activity spans in
DtoMappingStage. Remove StateSpanCategory.Sleep references from frontend
components (time-spans, alert rule builder, activity icon) and clean up
XML doc comments in StateSpansController.
@github-actions

github-actions Bot commented May 16, 2026

Copy link
Copy Markdown
Contributor

Preview Container Images

Published for commit 34efef6 with tag pr-226-34efef6.

Image Status Package URI
nocturne-api ✅ Published package ghcr.io/nightscout/nocturne/nocturne-api:pr-226-34efef6
nocturne-demo ✅ Published package ghcr.io/nightscout/nocturne/nocturne-demo:pr-226-34efef6
nocturne-web ✅ Published package ghcr.io/nightscout/nocturne/nocturne-web:pr-226-34efef6

This comment is updated on each push to this PR.

Comment thread src/API/Nocturne.API/Services/Health/ActivityService.cs Fixed
new { collection = "activity", id }
);
await _events.OnDeletedAsync(null, cancellationToken);
_logger.LogDebug("Successfully deleted sleep session for activity ID: {Id}", id);
{
_logger.LogDebug(
"Upserting sleep session with ID: {Id}, Type: {Type}, Source: {Source}",
session.Id, session.Type, session.Source);
ryceg added 5 commits May 16, 2026 17:44
…tch types

Extract nullable .Value accesses to local variables in BuildFilteredQuery
so the analyzer can prove null-safety through the expression tree closure.

Replace generic catch(Exception) in the sleep activity loop with specific
InvalidOperationException + ArgumentException catches, re-throwing
OperationCanceledException to preserve cancellation semantics.
Resolves conflicts in portal package.json, svelte.config.js, and pnpm-lock.yaml.
Portal adapter-cloudflare → adapter-static revert from main is authoritative;
sleep branch does not own those portal changes.
ryceg added 14 commits May 17, 2026 15:29
Create Sleep/Report/ with SleepStageBreakdown, SleepOvernightTir,
SleepHypoEvent, SleepDawnPhenomenon, SleepWakeEvent, and
SleepSingleNightReport. Add [JsonConverter(JsonStringEnumConverter<T>)]
to SleepScoreSource and SleepHypoSeverity for consistent string
serialization with the rest of the sleep domain enums.
- SleepReportService: on-demand report assembly joining session + CGM data;
  GetSingleNightReportAsync and GetTrendsReportAsync with batch glucose query
- SleepReportController: GET api/v4/sleep/report/single-night/{sessionId} and
  GET api/v4/sleep/report/trends with 90-day cap; [RemoteQuery] on both endpoints
- Register ISleepReportService in DI
- Generated sleepReports.generated.remote.ts (getSingleNight, getTrends)

Review fixes:
- DawnRiseDelta in 7d-vs-prior-7d delta was always null; now computed
- ResolveScore returns (int?, SleepScoreSource?) — null when no stage data
- ScoreSource is null in SleepNightSummary when SleepScore is null
- LowestBg in SleepNightSummary is the session CGM minimum, not hypo-only
- Dawn phenomenon DeltaBg and RateOfClimbPerHour use last-vs-first direction
- TIR and hypo event windows start at asleepAt (StartTime + SleepLatencyMs)
- SleepSingleNightReport.Score surfaces the resolved score value
- SleepStageReferenceRangeSet.Default properties are now init-only

Tests: 33 sleep unit tests, all passing
this.sensorGlucose = new SensorGlucoseClient(apiBaseUrl, http);
this.services = new ServicesClient(apiBaseUrl, http);
this.setup = new SetupClient(apiBaseUrl, http);
this.sleep = new SleepClient(apiBaseUrl, http);
this.services = new ServicesClient(apiBaseUrl, http);
this.setup = new SetupClient(apiBaseUrl, http);
this.sleep = new SleepClient(apiBaseUrl, http);
this.sleepReport = new SleepReportClient(apiBaseUrl, http);
var efficiency = (breakdown.DeepMinutes + breakdown.RemMinutes + breakdown.LightMinutes) / total;
var deepFrac = breakdown.DeepMinutes / total;
var remFrac = breakdown.RemMinutes / total;
var disruption = Math.Min(20, breakdown.AwakeMinutes * 0.6 + hypoCount * 4);
return (session.SleepScore.Value, SleepScoreSource.Device);

var total = (double)breakdown.TotalMinutes;
if (total == 0) return (null, null);
Back-to-back pushes to main raced two regenerate jobs, with the loser
hitting a content conflict in generated schemas.ts during git pull
--rebase. Add a concurrency group so only the latest source regenerates,
and fall back to checking out our freshly regenerated files if the
rebase still conflicts.
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.

2 participants