-
-
Notifications
You must be signed in to change notification settings - Fork 72
Fix Health Connect sleep session fragmentation #196
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7a2de9e
65dec51
044d507
93fb654
1bec1d8
79f4604
cce401f
b1fae02
2f8b929
2d7f96d
4a76d86
c7b3145
c8d0c77
3b9914d
581da81
56bebf3
78fd9d9
e83640d
273ed4f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| package wtf.openstrap.openstrap_edge | ||
|
|
||
| import android.content.Context | ||
| import android.util.Log | ||
| import androidx.health.connect.client.HealthConnectClient | ||
| import androidx.health.connect.client.records.HeartRateRecord | ||
| import androidx.health.connect.client.records.metadata.Metadata | ||
| import androidx.health.connect.client.time.TimeRangeFilter | ||
| import io.flutter.embedding.engine.FlutterEngine | ||
| import io.flutter.plugin.common.MethodCall | ||
| import io.flutter.plugin.common.MethodChannel | ||
| import java.time.Instant | ||
| import java.time.ZoneId | ||
| import kotlinx.coroutines.CancellationException | ||
| import kotlinx.coroutines.CoroutineScope | ||
| import kotlinx.coroutines.Dispatchers | ||
| import kotlinx.coroutines.SupervisorJob | ||
| import kotlinx.coroutines.launch | ||
| import kotlinx.coroutines.sync.Mutex | ||
| import kotlinx.coroutines.sync.withLock | ||
| import kotlinx.coroutines.withContext | ||
|
|
||
| /** Replaces one day's normalized minute heart-rate samples in Health Connect. */ | ||
| object HealthConnectHeartRateWriter { | ||
| private const val TAG = "OpenStrapHeartRateExport" | ||
| private const val CHANNEL = "openstrap/health_connect_heart_rate" | ||
| private const val REPLACE_HEART_RATE_DAY = "replaceHeartRateDay" | ||
| private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) | ||
| private val replaceMutex = Mutex() | ||
|
|
||
| fun register(engine: FlutterEngine, context: Context) { | ||
| val app = context.applicationContext | ||
| MethodChannel(engine.dartExecutor.binaryMessenger, CHANNEL) | ||
| .setMethodCallHandler { call, result -> | ||
| if (call.method != REPLACE_HEART_RATE_DAY) { | ||
| result.notImplemented() | ||
| return@setMethodCallHandler | ||
| } | ||
| scope.launch { | ||
| val replaced = withContext(Dispatchers.IO) { replace(app, call) } | ||
| result.success(replaced) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @Suppress("TooGenericExceptionCaught") | ||
| private suspend fun replace(context: Context, call: MethodCall): Boolean { | ||
| return replaceMutex.withLock { | ||
| try { | ||
| val request = buildRequest(call) ?: return@withLock false | ||
| if (request.samples.isEmpty()) return@withLock true | ||
| if (HealthConnectClient.getSdkStatus(context) != HealthConnectClient.SDK_AVAILABLE) { | ||
| false | ||
| } else { | ||
| val client = HealthConnectClient.getOrCreate(context) | ||
| client.deleteRecords( | ||
| HeartRateRecord::class, | ||
| TimeRangeFilter.between(request.start, request.end), | ||
| ) | ||
| client.insertRecords( | ||
| listOf( | ||
| HeartRateRecord( | ||
| startTime = request.start, | ||
| endTime = request.end, | ||
| startZoneOffset = ZoneId.systemDefault().rules.getOffset(request.start), | ||
| endZoneOffset = ZoneId.systemDefault().rules.getOffset(request.end), | ||
| samples = request.samples, | ||
| metadata = Metadata( | ||
| recordingMethod = Metadata.RECORDING_METHOD_AUTOMATICALLY_RECORDED, | ||
| ), | ||
| ), | ||
| ), | ||
| ).recordIdsList.size == 1 | ||
| } | ||
| } catch (cancelled: CancellationException) { | ||
| throw cancelled | ||
| } catch (error: Exception) { | ||
| Log.e(TAG, "HeartRateRecord replace failed", error) | ||
| false | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private data class Request( | ||
| val start: Instant, | ||
| val end: Instant, | ||
| val samples: List<HeartRateRecord.Sample>, | ||
| ) | ||
|
|
||
| private fun buildRequest(call: MethodCall): Request? { | ||
| val start = (call.argument<Any>("startTime") as? Number) | ||
| ?.toLong()?.let(Instant::ofEpochMilli) ?: return null | ||
| val end = (call.argument<Any>("endTime") as? Number) | ||
| ?.toLong()?.let(Instant::ofEpochMilli) ?: return null | ||
| if (!start.isBefore(end)) return null | ||
|
|
||
| val rawSamples = call.argument<List<*>>("samples") ?: return null | ||
| val samples = ArrayList<HeartRateRecord.Sample>(rawSamples.size) | ||
| var previous: Instant? = null | ||
| for (raw in rawSamples) { | ||
| val sample = (raw as? Map<*, *>)?.let(::buildSample) ?: return null | ||
| if (sample.time.isBefore(start) || !sample.time.isBefore(end)) return null | ||
| if (previous != null && !previous.isBefore(sample.time)) return null | ||
| samples.add(sample) | ||
| previous = sample.time | ||
| } | ||
| return Request(start, end, samples) | ||
| } | ||
|
|
||
| private fun buildSample(raw: Map<*, *>): HeartRateRecord.Sample? { | ||
| val time = (raw["time"] as? Number)?.toLong()?.let(Instant::ofEpochMilli) | ||
| ?: return null | ||
| val beatsPerMinute = (raw["beatsPerMinute"] as? Number)?.toLong() ?: return null | ||
| if (beatsPerMinute !in 1..300) return null | ||
| return HeartRateRecord.Sample(time, beatsPerMinute) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| package wtf.openstrap.openstrap_edge | ||
|
|
||
| import android.content.Context | ||
| import android.util.Log | ||
| import androidx.health.connect.client.HealthConnectClient | ||
| import androidx.health.connect.client.records.SleepSessionRecord | ||
| import androidx.health.connect.client.records.metadata.Metadata | ||
| import androidx.health.connect.client.time.TimeRangeFilter | ||
| import io.flutter.embedding.engine.FlutterEngine | ||
| import io.flutter.plugin.common.MethodCall | ||
| import io.flutter.plugin.common.MethodChannel | ||
| import java.time.Instant | ||
| import java.time.LocalTime | ||
| import java.time.ZoneId | ||
| import java.time.ZonedDateTime | ||
| import kotlinx.coroutines.CancellationException | ||
| import kotlinx.coroutines.CoroutineScope | ||
| import kotlinx.coroutines.Dispatchers | ||
| import kotlinx.coroutines.SupervisorJob | ||
| import kotlinx.coroutines.launch | ||
| import kotlinx.coroutines.sync.Mutex | ||
| import kotlinx.coroutines.sync.withLock | ||
| import kotlinx.coroutines.withContext | ||
|
|
||
| internal data class SleepCleanupRange(val start: Instant, val end: Instant) | ||
|
|
||
| internal fun sleepCleanupRange(start: Instant, end: Instant, zoneId: ZoneId): SleepCleanupRange { | ||
| require(start.isBefore(end)) | ||
| val localEnd = end.atZone(zoneId) | ||
| val endDate = if (localEnd.toLocalTime().isBefore(LocalTime.NOON)) { | ||
| localEnd.toLocalDate() | ||
| } else { | ||
| localEnd.toLocalDate().plusDays(1) | ||
| } | ||
| val cleanupEnd = endDate.atTime(LocalTime.NOON).atZone(zoneId).toInstant() | ||
| val calculatedStart = endDate.minusDays(1).atTime(LocalTime.NOON).atZone(zoneId).toInstant() | ||
| val cleanupStart = if (start.isBefore(calculatedStart)) start else calculatedStart | ||
| return SleepCleanupRange(cleanupStart, cleanupEnd) | ||
| } | ||
|
|
||
| /** Writes one Health Connect sleep parent containing all normalized stages. */ | ||
| object HealthConnectSleepWriter { | ||
| private const val TAG = "OpenStrapSleepExport" | ||
| private const val CHANNEL = "openstrap/health_connect_sleep" | ||
| private const val REPLACE_SLEEP_SESSION = "replaceSleepSession" | ||
| private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) | ||
| private val replaceMutex = Mutex() | ||
|
|
||
| fun register(engine: FlutterEngine, context: Context) { | ||
| val app = context.applicationContext | ||
| MethodChannel(engine.dartExecutor.binaryMessenger, CHANNEL) | ||
| .setMethodCallHandler { call, result -> | ||
| if (call.method != REPLACE_SLEEP_SESSION) { | ||
| result.notImplemented() | ||
| return@setMethodCallHandler | ||
| } | ||
| scope.launch { | ||
| val replaced = withContext(Dispatchers.IO) { replace(app, call) } | ||
| result.success(replaced) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @Suppress("TooGenericExceptionCaught") | ||
| private suspend fun replace(context: Context, call: MethodCall): Boolean { | ||
| return replaceMutex.withLock { | ||
| try { | ||
| if (HealthConnectClient.getSdkStatus(context) != HealthConnectClient.SDK_AVAILABLE) { | ||
| false | ||
| } else { | ||
| val session = buildSession(call) | ||
| if (session == null) { | ||
| false | ||
| } else { | ||
| val client = HealthConnectClient.getOrCreate(context) | ||
| val cleanupRange = sleepCleanupRange( | ||
| session.startTime, | ||
| session.endTime, | ||
| ZoneId.systemDefault(), | ||
| ) | ||
|
|
||
| // This removes both records created by this writer and legacy | ||
| // one-stage fragments whose intervals fall inside the real | ||
| // overnight window, including the portion before midnight. | ||
| client.deleteRecords( | ||
| SleepSessionRecord::class, | ||
| TimeRangeFilter.between(cleanupRange.start, cleanupRange.end), | ||
| ) | ||
| client.insertRecords(listOf(session)).recordIdsList.size == 1 | ||
| } | ||
| } | ||
| } catch (cancelled: CancellationException) { | ||
| throw cancelled | ||
| } catch (error: Exception) { | ||
| // Health Connect may surface several unrelated platform and | ||
| // transport exceptions; the channel reports all of them as false. | ||
| Log.e(TAG, "SleepSessionRecord replace failed", error) | ||
| false | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| } | ||
|
|
||
| private fun buildSession(call: MethodCall): SleepSessionRecord? { | ||
| val start = (call.argument<Any>("startTime") as? Number) | ||
| ?.toLong()?.let(Instant::ofEpochMilli) ?: return null | ||
| val end = (call.argument<Any>("endTime") as? Number) | ||
| ?.toLong()?.let(Instant::ofEpochMilli) ?: return null | ||
| if (!start.isBefore(end)) return null | ||
|
|
||
| val rawStages = call.argument<List<*>>("stages").orEmpty() | ||
| val stages = rawStages | ||
| .mapNotNull { (it as? Map<*, *>)?.let(::buildStage) } | ||
| .sortedBy { it.startTime } | ||
| if (stages.isEmpty()) return null | ||
| var previousEnd = start | ||
| for (stage in stages) { | ||
| if (stage.startTime.isBefore(start) || stage.endTime.isAfter(end)) return null | ||
| if (!stage.startTime.isBefore(stage.endTime)) return null | ||
| if (stage.startTime.isBefore(previousEnd)) return null | ||
| previousEnd = stage.endTime | ||
| } | ||
|
|
||
| val zoneRules = ZoneId.systemDefault().rules | ||
| return SleepSessionRecord( | ||
| startTime = start, | ||
| startZoneOffset = zoneRules.getOffset(start), | ||
| endTime = end, | ||
| endZoneOffset = zoneRules.getOffset(end), | ||
| title = "OpenStrap sleep", | ||
| stages = stages, | ||
| metadata = Metadata( | ||
| recordingMethod = Metadata.RECORDING_METHOD_AUTOMATICALLY_RECORDED, | ||
| ), | ||
| ) | ||
| } | ||
|
|
||
| private fun buildStage(raw: Map<*, *>): SleepSessionRecord.Stage? { | ||
| val start = (raw["startTime"] as? Number)?.toLong()?.let(Instant::ofEpochMilli) | ||
| ?: return null | ||
| val end = (raw["endTime"] as? Number)?.toLong()?.let(Instant::ofEpochMilli) | ||
| ?: return null | ||
| val type = when (raw["stage"] as? String) { | ||
| "awake" -> SleepSessionRecord.STAGE_TYPE_AWAKE | ||
| "rem" -> SleepSessionRecord.STAGE_TYPE_REM | ||
| "light" -> SleepSessionRecord.STAGE_TYPE_LIGHT | ||
| "deep" -> SleepSessionRecord.STAGE_TYPE_DEEP | ||
| else -> return null | ||
| } | ||
| return SleepSessionRecord.Stage(start, end, type) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,42 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| package wtf.openstrap.openstrap_edge | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.time.Duration | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.time.Instant | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.time.ZoneId | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import java.time.ZonedDateTime | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.junit.Assert.assertEquals | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.junit.Assert.assertTrue | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import org.junit.Test | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class HealthConnectSleepWriterTest { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private val berlin = ZoneId.of("Europe/Berlin") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private fun localInstant(year: Int, month: Int, day: Int, hour: Int, minute: Int): Instant = | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ZonedDateTime.of(year, month, day, hour, minute, 0, 0, berlin).toInstant() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Test | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fun cleanupRangeIncludesLegacyFragmentThatStartsBeforeRecomputedSession() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val sessionStart = localInstant(2026, 8, 6, 1, 36) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val sessionEnd = localInstant(2026, 8, 6, 8, 20) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val staleFragmentStart = localInstant(2026, 8, 5, 23, 34) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val range = sleepCleanupRange(sessionStart, sessionEnd, berlin) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assertEquals(localInstant(2026, 8, 5, 12, 0), range.start) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assertEquals(localInstant(2026, 8, 6, 12, 0), range.end) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assertTrue(!staleFragmentStart.isBefore(range.start)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assertTrue(staleFragmentStart.isBefore(range.end)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @Test | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fun cleanupRangeUsesLocalNoonAcrossDstTransition() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val sessionStart = localInstant(2026, 10, 25, 1, 30) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val sessionEnd = localInstant(2026, 10, 25, 9, 0) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| val range = sleepCleanupRange(sessionStart, sessionEnd, berlin) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assertEquals(localInstant(2026, 10, 24, 12, 0), range.start) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assertEquals(localInstant(2026, 10, 25, 12, 0), range.end) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| assertEquals(25, Duration.between(range.start, range.end).toHours()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+17
to
+41
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win Add a test for the early-start branch of Both tests exercise only the path where ♻️ Proposed test+ `@Test`
+ fun cleanupRangeExtendsBackToAnEarlySessionStart() {
+ val sessionStart = localInstant(2026, 8, 5, 9, 15)
+ val sessionEnd = localInstant(2026, 8, 6, 8, 20)
+
+ val range = sleepCleanupRange(sessionStart, sessionEnd, berlin)
+
+ assertEquals(sessionStart, range.start)
+ assertEquals(localInstant(2026, 8, 6, 12, 0), range.end)
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: OpenStrap/edge
Length of output: 4333
🏁 Script executed:
Repository: OpenStrap/edge
Length of output: 243
🏁 Script executed:
Repository: OpenStrap/edge
Length of output: 13949
🏁 Script executed:
Repository: OpenStrap/edge
Length of output: 50371
🏁 Script executed:
Repository: OpenStrap/edge
Length of output: 10473
🏁 Script executed:
Repository: OpenStrap/edge
Length of output: 15423
Keep sleep cleanup inside the day window.
_exportDaymarks success if the sleep replace succeeds, then deletes other data only for[dayStart, dayEnd). If a sleep window exits the local day,replace()can then delete records beyonddayEndwhile the day is considered exported, and later data for that exported day is not re-published in that pass. ClampcleanupRange.endto the exported day interval, or avoid exporting a sleep window that crosses the day boundary without handling the cleanup boundary separately.🤖 Prompt for AI Agents