Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
ab49570
Workouts spec, and the requests spec it builds on
sayed0am Oct 30, 2026
16e1e5a
Spec: adjust or delete sleep and naps
sayed0am Oct 30, 2026
f1eb657
Split the workout screens, and give every live screen one frame
sayed0am Oct 30, 2026
6e6c510
One edit record for sleep and naps: sleep_edit and SleepEdits
sayed0am Oct 30, 2026
ef225a3
Every reader of sleep goes through SleepSource
sayed0am Oct 30, 2026
a056235
Choose and the new Ready, filled from last time
sayed0am Oct 30, 2026
4c68ba6
Adjust times on the main sleep, with one sheet for sleep and naps
sayed0am Oct 30, 2026
09dea49
Delete sleep and naps, with Undo, Restore and Deleted sleep
sayed0am Oct 30, 2026
98da383
A nap whose heart rate stayed as when sitting still says it might not…
sayed0am Oct 30, 2026
55412fc
Live run and indoor screens, Paused, and one glance for Today and the…
sayed0am Oct 30, 2026
c0e8b9f
Strength: sets, rest and an optional log, and the database for this r…
sayed0am Oct 30, 2026
6670a00
Intervals: HIIT presets, run reps, saved workouts, and named buzzes
sayed0am Oct 30, 2026
537f45c
After a big move, adjust the strap's sleep score instead of replacing it
sayed0am Oct 30, 2026
e0b69a3
Name the path You, Your data, Deleted sleep, and say when nothing is …
sayed0am Oct 30, 2026
f4efdbf
A deleted night says so everywhere, with Restore on the Sleep hero, a…
sayed0am Oct 30, 2026
01d55b0
Mind and body, and games
sayed0am Oct 30, 2026
3d11352
Deleted sleep: days left on its own line, so neither line wraps
sayed0am Oct 30, 2026
96d0f23
Sleep times as two boxes on the number pad, the chosen span shaded in…
sayed0am Oct 30, 2026
28f2636
Summaries per kind of workout
sayed0am Oct 30, 2026
22f08aa
Weekly goal, bests, badges and celebrations
sayed0am Oct 30, 2026
54f8973
Summary fallbacks, badges from real workouts, and past workouts as su…
sayed0am Oct 30, 2026
045c712
Delete this workout as a button with a trash icon
sayed0am Oct 30, 2026
628f994
Summary: no goal line for a workout that doesn't count, no drop witho…
sayed0am Oct 30, 2026
baded75
Merge sleep-edit into develop
sayed0am Oct 30, 2026
1a02661
Merge workouts-v1 into develop
sayed0am Oct 30, 2026
0889921
A dev build that installs beside the release: -PdevApp, named with it…
sayed0am Oct 30, 2026
4e34b02
Merge main into develop
sayed0am Oct 31, 2026
1fc11ba
WIP: one screen Start a workout (agent stopped before finishing)
sayed0am Oct 31, 2026
fc7a861
Choose: every sport on one screen, one tap from All sports
sayed0am Oct 31, 2026
3e65751
Choose: picking a search result puts the keyboard away
sayed0am Oct 31, 2026
02f7c22
Merge one-tap-sports: every sport on one screen
sayed0am Oct 31, 2026
d16771c
Heart rate zones: one set for every sport, read wherever a zone is
sayed0am Oct 31, 2026
96cb961
Heart rate zones page: max heart rate as the hero, the five zones, mo…
sayed0am Oct 31, 2026
2a188b8
Ready for distance and cardio sports: no settings sheet, heart card, …
sayed0am Oct 31, 2026
4d86a19
Ready's goal kind in one pill, and fixes from the phone
sayed0am Oct 31, 2026
826c94b
Ready for strength, HIIT, mind and body and games on the run Ready's …
sayed0am Oct 31, 2026
eabd4dd
Save a map of the route by default, and drop Dog walking from the sports
sayed0am Oct 31, 2026
d0aa043
Match the live run, strength and HIIT screens to the boards
sayed0am Oct 31, 2026
413f058
Yoga live screen: quiet header, big ring clock, heart line
sayed0am Nov 1, 2026
668b0be
Workout summary: Done at the top, Share and Done, split bars, Earned …
sayed0am Nov 1, 2026
f04f153
Summary sections: sets bars, round columns, smooth heart lines, zone …
sayed0am Nov 1, 2026
87ec444
Workout share screen: a story card for each kind, a photo behind it, …
sayed0am Nov 1, 2026
191d48d
Merge share-screen into ready-v2
sayed0am Nov 1, 2026
3fda61e
Run views: only what's above the dots slides, dots follow the swipe
sayed0am Nov 1, 2026
f9eb1c4
Share card: route glow as one blurred stroke
sayed0am Nov 1, 2026
e813917
Run intervals get their own live page, rep times without heart rate
sayed0am Nov 1, 2026
95ec8a2
Share week, a search page, and Goals and bests like the board
sayed0am Nov 1, 2026
4fcdc54
Merge pull request #23 from sayed0am/ready-v2
sayed0am Nov 1, 2026
b61470a
Fix the dashboard sleep race and eight bugs from the develop review
sayed0am Nov 1, 2026
523aeae
Speed up live workout screens and rewards, and merge duplicated helpers
sayed0am Nov 1, 2026
cadfeb3
Refresh rewards before the weekly recap reads the weeks
sayed0am Nov 1, 2026
23406ba
Give split view each workout kind's own live screen
sayed0am Nov 1, 2026
ea3108d
Merge branch 'b6-recap' into develop-perf
sayed0am Nov 1, 2026
e57d7ba
Plan cards choose by value, and HIIT presets carry their icons
sayed0am Nov 1, 2026
7da3d28
One shared max heart rate for workouts, the live session and the zone…
sayed0am Nov 1, 2026
30c6cfe
Share card and summary read the same readings, settled and Load wording
sayed0am Nov 1, 2026
871fd75
Live screens read the ticking time only where it shows
sayed0am Nov 1, 2026
a085cb1
Merge develop-cleanup: plan choices, shared max heart rate, share car…
sayed0am Nov 1, 2026
2a0f9b8
Keep the workout glow inside its box on a wide screen
sayed0am Nov 1, 2026
867bfc0
Start a workout shows every sport when unfolded
sayed0am Nov 1, 2026
ff34fc4
Activity's Start card fills an unfolded screen with sports
sayed0am Nov 1, 2026
7e8dc1a
Centre the Start card's sport tiles
sayed0am Nov 1, 2026
1a5bc25
Stop glow cards flashing their flat colour when they resize
sayed0am Nov 1, 2026
2405c07
One lit heart rate pill, and the same no reading words on every live …
sayed0am Nov 1, 2026
430a027
Ready's distance goal types through TypedValue
sayed0am Nov 1, 2026
1ef1c4f
Share the round button, minus and plus stepper, row hairline and clock
sayed0am Nov 1, 2026
5492a24
Hide the route map when the run went nowhere
sayed0am Nov 1, 2026
077bf1c
Remove code nothing uses, and fix the compiler warnings
sayed0am Nov 1, 2026
c58291b
Remove the unreachable branches on the past workout and map pages
sayed0am Nov 1, 2026
3dbed0e
Fit every live screen under the fold of a half-folded phone
sayed0am Nov 1, 2026
69964dc
Soften the heart card's zone edge on a tall card, and drop the repeat…
sayed0am Nov 1, 2026
f07c635
Merge pull request #25 from sayed0am/develop-perf
sayed0am Nov 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,647 changes: 1,647 additions & 0 deletions app/schemas/org.openstrap.app.data.db.OpenStrapDatabase/25.json

Large diffs are not rendered by default.

1,904 changes: 1,904 additions & 0 deletions app/schemas/org.openstrap.app.data.db.OpenStrapDatabase/26.json

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
<!-- INTERNET is used only for Zepp sign-in (device list), strap firmware checks and downloads, map tiles, and the
daily check for a new OpenStrap on GitHub and its download. Health data is never uploaded. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Saving a share card to the gallery: Android 10 and later need no permission for the app's own pictures. -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<!-- Installing a new OpenStrap downloaded from its GitHub releases (update/AppUpdater.kt). Android asks the user to
allow it the first time. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
Expand Down Expand Up @@ -230,6 +232,16 @@
android:name="android.service.notification.disabled_filter_types"
android:value="ongoing|silent" />
</service>
<!-- A workout's share card, handed to another app from the cache (ui/glow/ShareScreen.kt). -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.share"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/share_paths" />
</provider>
<!-- WorkManager starts from OpenStrapApp's configuration, so Glance renders off the main thread
(widget/WidgetWork.kt), not from its own startup initializer. -->
<provider
Expand Down
318 changes: 281 additions & 37 deletions app/src/main/kotlin/org/openstrap/app/MainActivity.kt

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion app/src/main/kotlin/org/openstrap/app/OpenStrapApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package org.openstrap.app
import android.Manifest
import android.app.Application
import android.content.pm.PackageManager
import kotlinx.coroutines.launch
import org.openstrap.app.ble.BleScanner
import org.openstrap.app.data.HealthRepository
import org.openstrap.app.data.db.OpenStrapDatabase
Expand All @@ -19,6 +20,9 @@ class AppContainer(val app: Application) {
val database = OpenStrapDatabase.create(app)
val settings = SettingsStore(app)
val health = HealthRepository(database.healthDao())
/** The max heart rate every zone goes by: `maxHeartRates.current` to follow it, `maxHeartRates.now()` to read it once. */
val maxHeartRates = org.openstrap.app.data.MaxHeartRates(database.healthDao(), settings.profile)
val rewards = org.openstrap.app.data.RewardsKeeper(database.healthDao(), settings, maxHeartRates)
val scanner = BleScanner(app)
val csv = CsvExporter(database.healthDao())
val healthConnect = HealthConnectExporter(app, database.healthDao())
Expand All @@ -27,7 +31,7 @@ class AppContainer(val app: Application) {
val phoneAlerts = PhoneAlerts(app, settings, strap)
val autoHealthConnect = org.openstrap.app.health.HealthConnectAutoSync(app, settings, healthConnect, strap)
val workout = org.openstrap.app.device.WorkoutSession(
app, database.healthDao(), settings, strap, callRinging = { phoneAlerts.callRinging }, onSaved = autoHealthConnect::workoutSaved,
app, database.healthDao(), settings, strap, maxHeartRates, callRinging = { phoneAlerts.callRinging }, onSaved = autoHealthConnect::workoutSaved,
)
val updater = org.openstrap.app.update.AppUpdater(app, settings, recording = { workout.state.value is org.openstrap.app.device.WorkoutState.Recording })
}
Expand All @@ -43,6 +47,10 @@ class OpenStrapApp : Application(), androidx.work.Configuration.Provider {
override fun onCreate() {
super.onCreate()
container = AppContainer(this)
// Your heart rate zones, kept in use for everything that reads a zone, a restored backup's included.
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Default).launch {
container.settings.heartZones.collect { org.openstrap.app.data.HeartZones.use(it) }
}
container.companion.refresh()
// A workout still recording when the process died carries on (the service restarts it too).
container.workout.restore()
Expand All @@ -55,5 +63,7 @@ class OpenStrapApp : Application(), androidx.work.Configuration.Provider {
org.openstrap.app.device.WidgetUpdater.publishPreviews(this)
// The daily check for a new OpenStrap, unless it was turned off on the Updates page.
container.updater.start()
// Looks for the first morning of the week, for the weekly recap (workouts spec 3.4).
org.openstrap.app.device.WeeklyRecap.schedule(this)
}
}
10 changes: 0 additions & 10 deletions app/src/main/kotlin/org/openstrap/app/data/Activity.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package org.openstrap.app.data

import kotlin.math.abs

/**
* Steps, distance and moderate-to-vigorous activity (docs/algorithms/evidence-review.md, "Energy and activity").
Expand All @@ -23,8 +22,6 @@ object Activity {
const val HR_RUN_MINUTES = 10
/** WHO 2020: 150 min of moderate a week, a vigorous minute counting double. */
const val MVPA_WEEKLY_TARGET = 150
/** Flag a divergence between the strap's daily counter and the summed minutes above this fraction. */
const val STEP_DIVERGENCE = 0.05f

data class Mvpa(
val moderateMinutes: Int,
Expand Down Expand Up @@ -74,13 +71,6 @@ object Activity {
return Mvpa(moderate, vigorous, fromHr)
}

/** True when the strap's daily counter and the summed minutes differ by more than [STEP_DIVERGENCE]. */
fun stepsDiverge(strapTotal: Int?, minuteSum: Int): Boolean {
if (strapTotal == null || strapTotal <= 0 && minuteSum <= 0) return false
val ref = maxOf(strapTotal, minuteSum).toFloat()
return abs(strapTotal - minuteSum) / ref > STEP_DIVERGENCE
}

/**
* Step length in metres for a minute at [cadence] steps/min and height [heightCm]: 0.414·H below 120 spm, rising
* linearly to 0.45·H at 130 and above. An estimate; distance is always shown with "≈".
Expand Down
45 changes: 35 additions & 10 deletions app/src/main/kotlin/org/openstrap/app/data/Coaching.kt
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
package org.openstrap.app.data

/**
* What a workout aims for, chosen on the ready screen: a zone or a range of zones (0..4, inclusive), a time, or active
* kcal. Stored on the workout as [code] ("z:1-2", "t:2700", "k:300").
* What a workout aims for, chosen on the ready screen: a zone or a range of zones (0..4, inclusive), a time, active
* kcal or a distance. Stored on the workout as [code] ("z:1-2", "t:2700", "k:300", "d:5000").
*/
sealed interface WorkoutTarget {
val code: String

data class Zones(val low: Int, val high: Int) : WorkoutTarget {
override val code get() = "z:$low-$high"
val label: String get() = if (low == high) "Zone ${low + 1}" else "Zone ${low + 1}–${high + 1}"
val label: String get() = if (low == high) "Zone ${low + 1}" else "Zone ${low + 1} to ${high + 1}"
}

data class Time(val seconds: Long) : WorkoutTarget {
Expand All @@ -23,6 +23,17 @@ sealed interface WorkoutTarget {
val label: String get() = "$kcal kcal"
}

/** Metres: from GPS, or the strap's steps without it, as the workout counts its distance. */
data class Distance(val meters: Int) : WorkoutTarget {
override val code get() = "d:$meters"
/** "5 km", "21.1 km", "800 m". */
val label: String get() = when {
meters < 1000 -> "$meters m"
meters % 1000 == 0 -> "${meters / 1000} km"
else -> String.format(java.util.Locale.ROOT, "%.1f km", meters / 1000f)
}
}

companion object {
fun parse(code: String?): WorkoutTarget? {
code ?: return null
Expand All @@ -31,6 +42,7 @@ sealed interface WorkoutTarget {
"z" -> v.split('-').mapNotNull { it.toIntOrNull() }.takeIf { it.size == 2 }?.let { (a, b) -> Zones(a, b) }
"t" -> v.toLongOrNull()?.let(::Time)
"k" -> v.toIntOrNull()?.takeIf { it > 0 }?.let(::Kcal)
"d" -> v.toIntOrNull()?.takeIf { it > 0 }?.let(::Distance)
else -> null
}
}
Expand All @@ -53,12 +65,12 @@ enum class Cue(val pattern: List<Int>) {
BelowTarget(listOf(700)),
/** Back in the target: one short pulse. */
BackInTarget(listOf(200)),
/** Halfway through a time target: two short pulses. */
Halfway(listOf(150, 150, 150)),
/** Time or kcal target reached: long, short, long. */
GoalReached(listOf(500, 200, 200, 200, 500)),
/** Strength: rest is over, start the next set. Two medium pulses. */
RestOver(listOf(400, 150, 400)),
/** Halfway through a time or distance goal: one long ([WorkoutBuzz.LONG]). */
Halfway(WorkoutBuzz.LONG),
/** A time, distance or kcal goal reached: three short ([WorkoutBuzz.GOAL]). */
GoalReached(WorkoutBuzz.GOAL),
/** Strength: rest is over, start the next set. Two short, as work starting is everywhere ([WorkoutBuzz.WORK_STARTS]). */
RestOver(WorkoutBuzz.WORK_STARTS),
}

/**
Expand Down Expand Up @@ -88,7 +100,7 @@ class Coach(private val target: WorkoutTarget?, reachedAt: Long? = null) {
else -> null
}
is WorkoutTarget.Zones -> zone?.let { zoneCue(t, it, target) }
is WorkoutTarget.Kcal, null -> null
is WorkoutTarget.Kcal, is WorkoutTarget.Distance, null -> null
}

/**
Expand All @@ -102,6 +114,19 @@ class Coach(private val target: WorkoutTarget?, reachedAt: Long? = null) {
return Cue.GoalReached
}

/**
* The distance so far, [meters] at [t]: the halfway cue, then the goal cue the first time it reaches a distance
* target, each once. Distance only grows, so a pause holds it where it is.
*/
fun distance(t: Long, meters: Float): Cue? {
if (target !is WorkoutTarget.Distance || goalDone) return null
return when {
meters >= target.meters -> { goalDone = true; halfwayDone = true; reachedAt = t; Cue.GoalReached }
!halfwayDone && meters * 2 >= target.meters -> { halfwayDone = true; Cue.Halfway }
else -> null
}
}

private fun zoneCue(t: Long, zone: Int, target: WorkoutTarget.Zones): Cue? {
val side = when { zone > target.high -> 1; zone < target.low -> -1; else -> 0 }
if (side != pendingSide) { pendingSide = side; pendingSince = t; return null }
Expand Down
43 changes: 43 additions & 0 deletions app/src/main/kotlin/org/openstrap/app/data/Games.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package org.openstrap.app.data

/**
* Games and sports played with the phone in a bag (workouts spec 2.7): how hard it has been, from the time in each
* zone, and the hard efforts in it. Pure, like [Strength].
*/
object Games {
/** A spell in zone 5 counts as a hard effort from this long. */
const val HARD_EFFORT_S = 10L

/**
* Effort 1 to 5 from [zoneSeconds], the seconds in each zone so far: the time-weighted average zone, rounded, at
* least 1. Null with no time in any zone yet.
*/
fun effort(zoneSeconds: IntArray): Int? {
val total = zoneSeconds.sum()
if (total <= 0) return null
val mean = zoneSeconds.withIndex().sumOf { (z, s) -> (z + 1) * s.toDouble() } / total
return Math.round(mean).toInt().coerceIn(1, Workouts.ZONES)
}

/**
* Spells of at least [HARD_EFFORT_S] in zone 5 in [readings] (time, bpm, ascending): one effort per spell, however
* long. A gap of more than a few seconds between readings ends a spell.
*/
fun hardEfforts(readings: List<Pair<Long, Int>>, maxHr: Int): Int {
var count = 0
var since: Long? = null
var last: Long? = null
var counted = false
for ((t, bpm) in readings) {
val top = WorkoutRecorder.liveZone(bpm, maxHr) == Workouts.ZONES - 1
val gap = last != null && t - last > WorkoutRecorder.MAX_STEP_S
if (!top || gap) { since = null; counted = false }
if (top) {
if (since == null) since = t
if (!counted && t - since >= HARD_EFFORT_S) { count++; counted = true }
}
last = t
}
return count
}
}
45 changes: 36 additions & 9 deletions app/src/main/kotlin/org/openstrap/app/data/HealthRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ data class DayWindow(val from: Long, val to: Long) {
class HealthRepository(private val dao: HealthDao) {
private val recoveryEngine = RecoveryEngine(dao)
private val healthspanEngine = HealthspanEngine(dao)
/** The one reader of sleep for everything shown ([SleepSource]). */
val sleep = SleepSource(dao)

/**
* Readiness and charge for [date]; heavy, so call it off the main thread. Also logs the night's own inputs next to
Expand Down Expand Up @@ -91,7 +93,7 @@ class HealthRepository(private val dao: HealthDao) {
*/
fun changes(): Flow<Unit> = kotlinx.coroutines.flow.combine(
dao.latestHeartRateTime(), dao.latestStressTime(), dao.latestSleepEnd(), dao.workoutCountFlow().distinctUntilChanged(),
dao.napEdits().distinctUntilChanged(),
dao.sleepEdits().distinctUntilChanged(),
) { _, _, _, _, _ -> }

/** Local days that have any heart rate, steps or sleep, for the calendar. */
Expand All @@ -101,15 +103,40 @@ class HealthRepository(private val dao: HealthDao) {

fun heartRate(w: DayWindow): Flow<List<HeartRateSample>> = dao.heartRate(w.from, w.to)
fun activity(w: DayWindow): Flow<List<ActivityMinute>> = dao.activity(w.from, w.to)
fun sleepSessions(w: DayWindow): Flow<List<SleepSession>> = dao.sleepSessions(w.from, w.to)
fun sleepStages(sessionStart: Long): Flow<List<SleepStage>> = dao.sleepStages(sessionStart)
fun napEdits(): Flow<List<org.openstrap.app.data.db.NapEdit>> = dao.napEdits()

/** Keeps [start] to [end] as the strap's nap [strap]'s times; its own times again drop the edit. */
suspend fun setNapTimes(strap: SleepSession, start: Long, end: Long) {
val e = NapTimes.edit(strap, start, end)
if (e == null) dao.deleteNapEdit(strap.start) else dao.upsertNapEdit(e)
/** Sleep and naps as shown, ending in [w]: with the user's times, deleted ones left out ([SleepSource]). */
fun sleepSessions(w: DayWindow): Flow<List<SleepSession>> = sleep.sessions(w.from, w.to)
/** Each strap session ending in [w] with its edit, deleted ones too, for the screens that change them. */
fun sleepResolved(w: DayWindow): Flow<List<SleepEdits.Resolved>> = sleep.resolved(w.from, w.to)
/** [resolved] as [sleepSessions] shows them. */
suspend fun sleepShown(resolved: List<SleepEdits.Resolved>): List<SleepSession> = sleep.shown(resolved)
/** [r]'s stages as shown. */
fun sleepStages(r: SleepEdits.Resolved): Flow<List<SleepStage>> = sleep.stages(r)
/** The strap's own stages for its session [strap], unclipped. */
fun strapStages(strap: SleepSession): Flow<List<SleepStage>> = sleep.strapStages(strap)
fun sleepEdits(): Flow<List<org.openstrap.app.data.db.SleepEdit>> = dao.sleepEdits()

/** Keeps [start] to [end] as the strap's session [strap]'s times; its own times again drop the edit. */
suspend fun setSleepTimes(strap: SleepSession, start: Long, end: Long) {
val current = sleep.editOf(strap)
val e = SleepEdits.edit(strap, start, end, current, System.currentTimeMillis() / 1000)
if (e == null) dao.deleteSleepEdit(current?.strapStart ?: strap.start) else dao.upsertSleepEdit(e)
}

/** Deletes the strap's session [strap], keeping any times the user gave it, so [restoreSleep] brings back exactly that. */
suspend fun deleteSleep(strap: SleepSession) {
val current = sleep.editOf(strap)
dao.upsertSleepEdit(SleepEdits.delete(strap, current, System.currentTimeMillis() / 1000))
}

/** Brings back the strap's deleted session [strap], with the times it had: Undo and Restore. */
suspend fun restoreSleep(strap: SleepSession) {
val current = sleep.editOf(strap) ?: return
val restored = SleepEdits.restore(current, System.currentTimeMillis() / 1000)
if (restored == null) dao.deleteSleepEdit(current.strapStart) else dao.upsertSleepEdit(restored)
}

/** Sleep and naps deleted in the last [SleepEdits.KEEP_DAYS], newest first, for You, Your data, Deleted sleep. */
fun deletedSleep(): Flow<List<SleepEdits.Resolved>> = sleep.deleted { System.currentTimeMillis() / 1000 }
fun spo2(w: DayWindow): Flow<List<Spo2Sample>> = dao.spo2(w.from, w.to)
/**
* The day's stress readings that count as daily stress: those outside the exclusion around workouts
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import java.time.temporal.TemporalAdjusters
* thread. The last result is kept until a sync adds data to its window.
*/
class HealthspanEngine(private val dao: HealthDao) {
/** Sleep as shown, with the user's edits ([SleepEdits]). */
private val sleepSource = SleepSource(dao)
private data class Key(val asOf: Long, val profile: UserProfile, val fingerprint: Long)
private var cached: Pair<Key, Healthspan.Outcome>? = null

Expand Down Expand Up @@ -76,7 +78,7 @@ class HealthspanEngine(private val dao: HealthDao) {
}.toMap()

// Main sleeps: the longest non-nap session per morning, as Recovery picks them.
val sessions = dao.sleepSessionsOnce(from, to)
val sessions = sleepSource.sessionsOnce(from, to)
val mains = sessions.filter { !it.nap }
.groupBy { RecoveryEngine.morningOf(it, zone) }
.filterKeys { it in from until asOf }
Expand All @@ -97,7 +99,7 @@ class HealthspanEngine(private val dao: HealthDao) {
}.toMap()

// Sleep regularity: one SRI per week, each over 8 noon-to-noon grid days ending at a Saturday noon.
val stages = sessions.flatMap { s -> dao.sleepStagesOnce(s.start).map { Triple(it.start, it.end, it.stage) } }
val stages = sessions.flatMap { s -> sleepSource.stagesOnce(s).map { Triple(it.start, it.end, it.stage) } }
val weeks = (Healthspan.WINDOW_DAYS - 1) / 7
val weeklySri = (0 until weeks).mapNotNull { k ->
val end = sunday.minusDays(7L * k + 1)
Expand Down
Loading
Loading