diff --git a/app/src/main/java/com/pulseloop/AppServices.kt b/app/src/main/java/com/pulseloop/AppServices.kt index 6c740795..8c59e441 100644 --- a/app/src/main/java/com/pulseloop/AppServices.kt +++ b/app/src/main/java/com/pulseloop/AppServices.kt @@ -13,7 +13,6 @@ import com.pulseloop.service.GpsRouteRecorder import com.pulseloop.service.LiveWorkoutManager import com.pulseloop.service.RingSyncCoordinator import com.pulseloop.settings.ApiKeyStore -import com.pulseloop.widgets.WidgetSnapshotPublisher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -39,12 +38,8 @@ class AppServices private constructor(context: Context) { val gpsRecorder = GpsRouteRecorder(context, db) val liveWorkout = LiveWorkoutManager(coordinator, db, gpsRecorder, context) - // Every persisted ring-sync batch republishes the widget snapshot (debounced 2 s), - // mirroring the iOS PulseDataChange → WidgetSnapshotPublisher pipeline. - val persistence = EventPersistenceSubscriber( - context, db, - onDataPersisted = { WidgetSnapshotPublisher.publishDebounced(context) }, - ) + /** Process-wide, not per-[AppServices]: background workers need it without the UI's services. */ + val persistence = EventPersistenceSubscriber.shared(context) val batteryAlerts = BatteryAlertMonitor(context) val providerStore = CoachProviderSettingsStore(context) val summaryCoordinator = CoachSummaryCoordinator(db, apiKeyStore, providerStore) @@ -85,6 +80,10 @@ class AppServices private constructor(context: Context) { instance ?: synchronized(this) { instance ?: AppServices(context.applicationContext).also { instance = it } } + + /** The services if the UI already built them, without building them: a background worker + * must not create the app's ring client, whose watchdog would start reconnecting. */ + fun peek(): AppServices? = instance } } diff --git a/app/src/main/java/com/pulseloop/PulseLoopApplication.kt b/app/src/main/java/com/pulseloop/PulseLoopApplication.kt index abceff5f..9689d3dd 100644 --- a/app/src/main/java/com/pulseloop/PulseLoopApplication.kt +++ b/app/src/main/java/com/pulseloop/PulseLoopApplication.kt @@ -8,6 +8,7 @@ import com.pulseloop.data.DataRepairs import com.pulseloop.data.PulseLoopDatabase import com.pulseloop.diagnostics.CrashLogger import com.pulseloop.diagnostics.DiagnosticsSubscriber +import com.pulseloop.service.EventPersistenceSubscriber import com.pulseloop.settings.QuietHoursPrefs import com.pulseloop.widgets.WidgetRefreshWorker import com.pulseloop.widgets.WidgetSnapshotPublisher @@ -31,6 +32,9 @@ class PulseLoopApplication : Application() { QuietHoursPrefs.migrateIndependentBedtime(this) // Application-scoped so capture runs whether or not the Debug screen is open. DiagnosticsSubscriber(PulseLoopDatabase.getInstance(this)).start(appScope) + // Same reason, for the ring data itself: a process launched only for RingSyncWorker never + // runs the UI's startup, and without a collector its events are dropped. + EventPersistenceSubscriber.shared(this).start() // One-time cleanup of activity totals corrupted by the old bucket handling; runs off the // main thread and is prefs-gated so it executes once. Safe to race the first sync — the // ring can't connect before this completes a couple of DELETE statements. diff --git a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt index dbeb21dc..487bb444 100644 --- a/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt +++ b/app/src/main/java/com/pulseloop/service/EventPersistenceSubscriber.kt @@ -8,6 +8,7 @@ import com.pulseloop.data.entity.* import com.pulseloop.health.HealthConnectExportWorker import com.pulseloop.settings.QuietHoursPrefs import com.pulseloop.ring.* +import com.pulseloop.widgets.WidgetSnapshotPublisher import kotlinx.coroutines.* /** @@ -866,6 +867,28 @@ class EventPersistenceSubscriber( /** How far back [spotReadings] is primed from the table on first use. */ const val SPOT_LOOKBACK_MS = 7L * 24 * 60 * 60_000 private const val MAX_SLEEP_TIMELINE_MINUTES = 24 * 60 + + @Volatile private var shared: EventPersistenceSubscriber? = null + + /** + * The process's one subscriber, started from `PulseLoopApplication.onCreate`. + * + * It used to be built and started only by the UI's startup (`AppServices` / + * `PulseLoopApp`). A process Android launched just to run `RingSyncWorker` never got + * there, and the bus keeps no backlog (`replay = 0`), so everything that background sync + * pulled from the ring was published to no one and dropped. + */ + fun shared(context: Context): EventPersistenceSubscriber = + shared ?: synchronized(this) { + shared ?: context.applicationContext.let { app -> + // Every persisted ring-sync batch republishes the widget snapshot (debounced + // 2 s), mirroring the iOS PulseDataChange → WidgetSnapshotPublisher pipeline. + EventPersistenceSubscriber( + app, PulseLoopDatabase.getInstance(app), + onDataPersisted = { WidgetSnapshotPublisher.publishDebounced(app) }, + ) + }.also { shared = it } + } } } diff --git a/app/src/main/java/com/pulseloop/service/RingSyncWorker.kt b/app/src/main/java/com/pulseloop/service/RingSyncWorker.kt index 134b4f01..44551eea 100644 --- a/app/src/main/java/com/pulseloop/service/RingSyncWorker.kt +++ b/app/src/main/java/com/pulseloop/service/RingSyncWorker.kt @@ -63,6 +63,10 @@ class RingSyncWorker( } override suspend fun doWork(): Result { + // The scheduled spot SpO₂ chain (issue #103) dies if one of its runs is cancelled; this + // periodic job is what reliably comes back, so it revives it. + ScheduledSpo2Worker.ensureScheduled(applicationContext) + // The foreground app owns the live GATT. An overdue periodic job can start at the same // moment as MainActivity after process launch; opening a second client interleaves both // command queues and lets this worker's cleanup disconnect the UI-owned session. diff --git a/app/src/main/java/com/pulseloop/service/ScheduledSpo2Slots.kt b/app/src/main/java/com/pulseloop/service/ScheduledSpo2Slots.kt new file mode 100644 index 00000000..944b87b5 --- /dev/null +++ b/app/src/main/java/com/pulseloop/service/ScheduledSpo2Slots.kt @@ -0,0 +1,56 @@ +package com.pulseloop.service + +import java.time.LocalTime +import java.time.ZonedDateTime + +/** + * When the app starts its own spot SpO₂ (issue #103). + * + * The ring runs its own all-day reading on a grid from local midnight at the configured interval + * (hourly on the reporter's ring: 06:00:42, 07:00:39, 08:00:26) and serves one measurement at a + * time: a second start dropped the first. So the app aims at the **midpoint** between two of the + * ring's readings — 60 min → :30, 30 min → :15 and :45 — the slot furthest from both. + * + * WorkManager runs late, and overnight Doze can hold a run for a long time. [isClear] is the guard + * for that: a run that would start within a few minutes of a ring reading, or still be measuring + * when the next one starts, is skipped instead. + * + * Local wall-clock seconds rather than epoch arithmetic, so a DST day keeps its slots on the half + * hour instead of shifting them by the hour the clock jumped. + */ +internal object ScheduledSpo2Slots { + + /** The ring ends a spot SpO₂ with `04 0e` at 50–63 s (issue #59 captures); the coordinator's + * ceiling for it is 75 s. */ + const val MEASUREMENT_SECONDS = 75 + + /** How far a run must start from the ring's own reading: five minutes, or a quarter of a + * shorter interval so a 15-minute grid still has room. */ + fun marginSeconds(intervalMinutes: Int): Int = minOf(5 * 60, intervalMinutes * 60 / 4) + + /** The first slot strictly after [now]. */ + fun nextSlot(now: ZonedDateTime, intervalMinutes: Int): ZonedDateTime { + val interval = intervalMinutes * 60 + val half = interval / 2 + val secs = now.toLocalTime().toSecondOfDay() + val k = if (secs < half) 0 else (secs - half) / interval + 1 + val slot = half + k * interval + val day = if (slot >= SECONDS_PER_DAY) now.toLocalDate().plusDays(1) else now.toLocalDate() + val slotOfDay = if (slot >= SECONDS_PER_DAY) half else slot + return day.atTime(LocalTime.ofSecondOfDay(slotOfDay.toLong())).atZone(now.zone) + } + + /** Whether a measurement starting at [now] stays clear of the ring's readings either side. */ + fun isClear(now: ZonedDateTime, intervalMinutes: Int): Boolean { + val interval = intervalMinutes * 60 + val margin = marginSeconds(intervalMinutes) + val secs = now.toLocalTime().toSecondOfDay() + val sincePrevious = secs % interval + // The grid restarts at midnight, so an interval that doesn't divide the day has a short + // last step before it. + val untilNext = minOf(interval - sincePrevious, SECONDS_PER_DAY - secs) + return sincePrevious >= margin && untilNext >= margin + MEASUREMENT_SECONDS + } + + private const val SECONDS_PER_DAY = 24 * 60 * 60 +} diff --git a/app/src/main/java/com/pulseloop/service/ScheduledSpo2Worker.kt b/app/src/main/java/com/pulseloop/service/ScheduledSpo2Worker.kt new file mode 100644 index 00000000..7364bab5 --- /dev/null +++ b/app/src/main/java/com/pulseloop/service/ScheduledSpo2Worker.kt @@ -0,0 +1,170 @@ +package com.pulseloop.service + +import android.content.Context +import android.util.Log +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.work.* +import com.pulseloop.AppServices +import com.pulseloop.data.PulseLoopDatabase +import com.pulseloop.ring.PulseEvent +import com.pulseloop.ring.PulseEventBus +import com.pulseloop.ring.RingBLEClient +import com.pulseloop.ring.RingConnectionState +import com.pulseloop.settings.ApiKeyStore +import com.pulseloop.settings.ScheduledSpo2Prefs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import java.time.Duration +import java.time.ZonedDateTime +import java.util.concurrent.TimeUnit + +/** + * Starts a real spot SpO₂ at each slot of the SpO₂ interval (issue #103), when the user opted in + * ([ScheduledSpo2Prefs]). + * + * One-time work that enqueues the next slot as it finishes, rather than periodic work: a periodic + * request can't be pinned to the half hour, and its 15-minute floor and flex would put runs on top + * of the ring's own reading. It is best effort — Doze defers it, and [ScheduledSpo2Slots.isClear] + * skips a run that lands too close to the ring's reading. A cancelled chain is revived by + * [ensureScheduled] from app start and from every [RingSyncWorker] run. + * + * The ring stops the measurement the moment the link drops (the reporter's test: start, disconnect + * 3 s later, LED off, nothing stored), so the run holds the connection until the ring's `04 0e`. + * It uses the app's own connection when there is one, and otherwise opens a private one the way + * [RingSyncWorker] does, with its own [RingSyncCoordinator] so the reading is settled and stored + * exactly as the Vitals button's is. + */ +class ScheduledSpo2Worker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + + companion object { + private const val WORK_NAME = "scheduled_spot_spo2" + private const val TAG = "ScheduledSpo2Worker" + private const val CONNECT_TIMEOUT_MS = 20_000L + /** Startup history has to drain before the sensor is taken — the ring serves one thing at a time. */ + private const val SYNC_DRAIN_TIMEOUT_MS = 45_000L + + /** After a settings change: replace whatever is queued with the next slot, or cancel. */ + suspend fun reschedule(context: Context) { + enqueueNext(context, ExistingWorkPolicy.REPLACE) + } + + /** Revive the chain if it died (cancelled, or never started); a queued run is left alone. */ + suspend fun ensureScheduled(context: Context) { + enqueueNext(context, ExistingWorkPolicy.KEEP) + } + + private suspend fun enqueueNext(context: Context, policy: ExistingWorkPolicy) { + val wm = WorkManager.getInstance(context) + if (!ScheduledSpo2Prefs(context).enabled) { + wm.cancelUniqueWork(WORK_NAME) + return + } + // A run re-checks the interval when it starts, so a change after this only moves one run. + val interval = withContext(Dispatchers.IO) { + intervalMinutes(PulseLoopDatabase.getInstance(context)) + } ?: run { wm.cancelUniqueWork(WORK_NAME); return } + val now = ZonedDateTime.now() + val delayMs = Duration.between(now, ScheduledSpo2Slots.nextSlot(now, interval)).toMillis() + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(delayMs, TimeUnit.MILLISECONDS) + .setConstraints(Constraints.Builder().setRequiresBatteryNotLow(true).build()) + .build() + wm.enqueueUniqueWork(WORK_NAME, policy, request) + } + + /** The SpO₂ interval the ring is set to, or null when its SpO₂ monitor is off or unset. */ + private suspend fun intervalMinutes(db: PulseLoopDatabase): Int? { + val settings = loadPersistedMeasurementSettings(db) ?: return null + if (!settings.spo2Enabled) return null + val minutes = settings.spo2IntervalMinutes.takeIf { it > 0 } ?: settings.hrIntervalMinutes + return minutes.coerceIn(5, 60) + } + } + + override suspend fun doWork(): Result { + if (!ScheduledSpo2Prefs(applicationContext).enabled) return Result.success() + val db = PulseLoopDatabase.getInstance(applicationContext) + try { + val interval = intervalMinutes(db) ?: return Result.success() + if (!ScheduledSpo2Prefs.isSupported(db.deviceDao().currentReal())) return Result.success() + if (!ScheduledSpo2Slots.isClear(ZonedDateTime.now(), interval)) { + Log.i(TAG, "Skipped: too close to the ring's own reading") + return Result.success() + } + measure(db) + } catch (e: Exception) { + Log.w(TAG, "Scheduled SpO2 failed", e) + } finally { + // Append: the next run waits for this one, and REPLACE from inside a running worker + // would cancel it. Always succeed, since a failed run fails its appended successor. + enqueueNext(applicationContext, ExistingWorkPolicy.APPEND_OR_REPLACE) + } + return Result.success() + } + + private suspend fun measure(db: PulseLoopDatabase) { + // The app's own link, if it has one: a second client can't connect while it holds GATT. + AppServices.peek()?.coordinator?.let { coordinator -> + val ran = withContext(Dispatchers.Main) { + if (!coordinator.isConnected) return@withContext false + // A workout polls SpO₂ itself, and a measurement in flight holds the sensor. + if (!coordinator.workoutHRActive) coordinator.measureSpO2Only() + true + } + if (ran) return + } + // The app is opening and owns the connection it is about to make. + if (isAppForeground()) return + + val client = RingBLEClient(applicationContext, transientOwner = true) + if (!client.hasPermissions()) { client.destroy(); return } + val coordinator = RingSyncCoordinator(client, db, ApiKeyStore(applicationContext)) + try { + withContext(Dispatchers.Main) { coordinator.start() } + // The same startup the app runs on connect: claims the ring and pushes settings, and + // pulls history while it is at it. + client.onConnected = { withContext(Dispatchers.Main) { coordinator.runStartupSequence() } } + client.connectLastKnown() + + val connected = withTimeoutOrNull(CONNECT_TIMEOUT_MS) { + while (!coordinator.isConnected) delay(500) + true + } ?: false + if (!connected) return + + // runStartupSequence opens the progress just after connect; wait for it to open, then + // to drain (the same wait factoryResetRing uses before it takes the ring). + withTimeoutOrNull(3_000L) { coordinator.syncProgress.first { it != null } } + withTimeoutOrNull(SYNC_DRAIN_TIMEOUT_MS) { + coordinator.syncProgress.first { it == null || it >= 100 } + } + delay(800) + // Hand the ring over to the app if it opened meanwhile; once measuring, finish — the + // reading is lost if the link drops, and the app only waits for one minute. + if (isAppForeground() || !coordinator.isConnected) return + val value = withContext(Dispatchers.Main) { coordinator.measureSpO2Only() } + Log.i(TAG, "Scheduled SpO2: ${value ?: "no reading"}") + // The stop frame is only enqueued; let it reach the ring before the link closes. + client.awaitOpsFlushed() + } finally { + withContext(Dispatchers.Main) { coordinator.stop() } + val releasedConnection = client.destroy() + if (releasedConnection && !isAppForeground()) { + PulseEventBus.publishBlocking( + PulseEvent.DeviceStateChanged(RingConnectionState.DISCONNECTED, null) + ) + } + } + } + + private suspend fun isAppForeground(): Boolean = withContext(Dispatchers.Main.immediate) { + ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + } +} diff --git a/app/src/main/java/com/pulseloop/settings/ScheduledSpo2Prefs.kt b/app/src/main/java/com/pulseloop/settings/ScheduledSpo2Prefs.kt new file mode 100644 index 00000000..f1edbb73 --- /dev/null +++ b/app/src/main/java/com/pulseloop/settings/ScheduledSpo2Prefs.kt @@ -0,0 +1,40 @@ +package com.pulseloop.settings + +import android.content.Context +import com.pulseloop.data.entity.DeviceEntity +import com.pulseloop.ring.RingDeviceType +import com.pulseloop.ring.WearableCapability + +/** + * The opt-in behind issue #103: have the app start a real spot SpO₂ on the SpO₂ interval. + * + * On a YCBT ring the hourly SpO₂ is computed from heart rate (one HR always maps to the same + * value), so the history line is an estimate. The ring's manual measurement is real — red LED, + * logged on its own — so the app runs that instead, between the ring's own readings + * ([com.pulseloop.service.ScheduledSpo2Slots]). + * + * Off by default: each run holds the connection and the optical sensor for about a minute. + * In SharedPreferences rather than `device_measurement_configs` to stay clear of a schema bump. + */ +class ScheduledSpo2Prefs(context: Context) { + + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + var enabled: Boolean + get() = prefs.getBoolean(KEY_ENABLED, false) + set(value) { prefs.edit().putBoolean(KEY_ENABLED, value).apply() } + + companion object { + private const val PREFS_NAME = "scheduled_spo2" + private const val KEY_ENABLED = "enabled" + + /** + * Rings this is offered for: a manual SpO₂ that ends with the ring's own `04 0e` verdict, + * so the run has a known end and the connection can be released right after it. Only the + * YCBT family signals that today (issue #59). + */ + fun isSupported(device: DeviceEntity?): Boolean = + device != null && device.deviceType == RingDeviceType.YCBT && + WearableCapability.MANUAL_SPO2 in device.capabilities + } +} diff --git a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt index ec098ca1..bbb8b065 100644 --- a/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt +++ b/app/src/main/java/com/pulseloop/ui/PulseLoopApp.kt @@ -211,6 +211,8 @@ fun PulseLoopApp() { // Schedule periodic background sync (matches official app behavior) RingSyncWorker.schedule(context.applicationContext) + // Opt-in app-started spot SpO₂ (issue #103); revives the chain if it was cancelled. + com.pulseloop.service.ScheduledSpo2Worker.ensureScheduled(context.applicationContext) // NOTE: demo data is never auto-seeded. A clean install starts empty so the UI // reflects only real ring data. Demo data is seeded exclusively via the diff --git a/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt b/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt index c7feeac0..57816e09 100644 --- a/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt +++ b/app/src/main/java/com/pulseloop/ui/screens/SettingsSubScreens.kt @@ -70,11 +70,13 @@ import com.pulseloop.service.GlucoseUnit import com.pulseloop.service.MetricZone import com.pulseloop.service.RingSyncCoordinator import com.pulseloop.service.RingSyncWorker +import com.pulseloop.service.ScheduledSpo2Worker import com.pulseloop.service.VitalColorToken import com.pulseloop.service.VitalSample import com.pulseloop.service.ZoneSeverity import com.pulseloop.settings.ApiKeyStore import com.pulseloop.settings.QuietHoursPrefs +import com.pulseloop.settings.ScheduledSpo2Prefs import com.pulseloop.settings.UnitSystem import com.pulseloop.settings.ZenModeListener import com.pulseloop.ui.components.DeviceHeroStatus @@ -1518,6 +1520,9 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U var cfgStress by remember { mutableStateOf(true) } var cfgHrv by remember { mutableStateOf(true) } var cfgTemp by remember { mutableStateOf(true) } + val scheduledSpo2Prefs = remember { ScheduledSpo2Prefs(context) } + val supportsScheduledSpo2 = ScheduledSpo2Prefs.isSupported(device) + var cfgAppSpo2 by remember { mutableStateOf(false) } var cfgLoaded by remember { mutableStateOf(false) } var cfgSavedMsg by remember { mutableStateOf(null) } @@ -1534,6 +1539,7 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U cfgStress = (config?.stressEnabled ?: true) && supportsStressSetting cfgHrv = (config?.hrvEnabled ?: true) && WearableCapability.HRV in capabilities cfgTemp = (config?.temperatureEnabled ?: true) && WearableCapability.TEMPERATURE in capabilities + cfgAppSpo2 = scheduledSpo2Prefs.enabled cfgLoaded = true } @@ -1606,6 +1612,16 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U steps = spo2IntervalSteps, ) } + // Issue #103: this ring's own SpO₂ is computed from heart rate, so the app + // can run the ring's real spot measurement on the interval instead. + if (cfgSpo2 && supportsScheduledSpo2) { + VitalToggle("Measure SpO₂ from the app", cfgAppSpo2) { cfgAppSpo2 = it } + Text( + "This ring's all-day SpO₂ is estimated from heart rate. When on, PulseLoop starts a real spot measurement halfway between the ring's readings, about a minute each, which uses more battery. Android may delay or skip runs while the phone is asleep.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } if (supportsStressSetting) { VitalToggle("Stress", cfgStress) { cfgStress = it } @@ -1637,6 +1653,8 @@ fun MeasurementSettingsScreen(coordinator: RingSyncCoordinator?, onBack: () -> U ) ) coordinator?.applyMeasurementSettings() + scheduledSpo2Prefs.enabled = cfgAppSpo2 && cfgSpo2 && supportsScheduledSpo2 + ScheduledSpo2Worker.reschedule(context.applicationContext) cfgSavedMsg = if (coordinator?.isConnected == true) "Saved & sent to ring ✓" else "Saved — will sync on next connect" } diff --git a/app/src/test/java/com/pulseloop/service/ScheduledSpo2SlotsTest.kt b/app/src/test/java/com/pulseloop/service/ScheduledSpo2SlotsTest.kt new file mode 100644 index 00000000..b5c5060e --- /dev/null +++ b/app/src/test/java/com/pulseloop/service/ScheduledSpo2SlotsTest.kt @@ -0,0 +1,85 @@ +package com.pulseloop.service + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZonedDateTime + +/** Issue #103: the app's own spot SpO₂ lands between the ring's readings, never on top of them. */ +class ScheduledSpo2SlotsTest { + + private val zone = ZoneId.of("Europe/Paris") + + private fun at(h: Int, m: Int, s: Int = 0, day: Int = 21, month: Int = 10): ZonedDateTime = + LocalDateTime.of(2026, month, day, h, m, s).atZone(zone) + + @Test + fun `hourly lands at half past`() { + // Just after the ring's own 10:00:42 reading — the reporter's timing. + assertEquals(at(10, 30), ScheduledSpo2Slots.nextSlot(at(10, 0, 42), 60)) + assertEquals(at(10, 30), ScheduledSpo2Slots.nextSlot(at(9, 30, 1), 60)) + } + + @Test + fun `next slot is strictly after now`() { + assertEquals(at(11, 30), ScheduledSpo2Slots.nextSlot(at(10, 30), 60)) + } + + @Test + fun `half hour interval lands at quarter past and quarter to`() { + assertEquals(at(10, 15), ScheduledSpo2Slots.nextSlot(at(10, 5), 30)) + assertEquals(at(10, 45), ScheduledSpo2Slots.nextSlot(at(10, 20), 30)) + } + + @Test + fun `last slot of the day rolls over to the first of the next`() { + assertEquals(at(0, 30, day = 22), ScheduledSpo2Slots.nextSlot(at(23, 45), 60)) + } + + @Test + fun `interval that does not divide the day restarts its grid at midnight`() { + // 35 min: grid …23:20, 23:55, then 00:00; slots 23:37:30, then 00:17:30 the next day. + assertEquals(at(23, 37, 30), ScheduledSpo2Slots.nextSlot(at(23, 20), 35)) + assertEquals(at(0, 17, 30, day = 22), ScheduledSpo2Slots.nextSlot(at(23, 40), 35)) + // 23:57 sits two minutes after the 23:55 reading. + assertFalse(ScheduledSpo2Slots.isClear(at(23, 57), 35)) + } + + @Test + fun `slots stay on the half hour across a DST change`() { + // Europe/Paris falls back on 2026-10-25 (03:00 → 02:00): the slot after 01:40 is still 02:30. + val slot = ScheduledSpo2Slots.nextSlot(at(1, 40, day = 25), 60) + assertEquals(2, slot.hour) + assertEquals(30, slot.minute) + } + + @Test + fun `a late run near the top of the hour is not clear`() { + assertTrue(ScheduledSpo2Slots.isClear(at(10, 30), 60)) + assertFalse(ScheduledSpo2Slots.isClear(at(10, 2), 60)) + // Starting at 10:57 would still be measuring at 10:58:15 — too close to 11:00. + assertFalse(ScheduledSpo2Slots.isClear(at(10, 57), 60)) + assertTrue(ScheduledSpo2Slots.isClear(at(10, 53), 60)) + } + + @Test + fun `short intervals use a smaller margin`() { + // 15 min: margin 3:45, so a run at 10:07:30 (the slot) is clear and 10:02 is not. + assertTrue(ScheduledSpo2Slots.isClear(at(10, 7, 30), 15)) + assertFalse(ScheduledSpo2Slots.isClear(at(10, 2), 15)) + } + + @Test + fun `every slot is itself clear`() { + for (interval in listOf(15, 20, 30, 35, 45, 60)) { + var t = at(0, 0) + repeat(200) { + t = ScheduledSpo2Slots.nextSlot(t, interval) + assertTrue("interval $interval slot $t", ScheduledSpo2Slots.isClear(t, interval)) + } + } + } +}