From a8599ec56bb4e5e6bbf59c11b064ad395e06fae6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 04:46:23 +0000 Subject: [PATCH] feat(workouts): merge close-by Health Connect sessions of the same type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Watches, Strava, and auto-pause often split one long effort — a 5-hour run with breaks — into several back-to-back exercise sessions. The New Workout carousel offered each fragment as its own suggestion. Add WorkoutMerger, which walks the detected sessions in start-time order and folds any run of same-type sessions less than an hour apart into a single DetectedWorkout: distance, calories, steps, elevation and duration are summed, heart rate is duration-weighted, max heart rate is the max, and sessionCount records how many were combined. Gaps are tracked per exercise type so a brief cross-training block mid-run doesn't split the two run segments. The carousel shows the combined count so the user sees it's one thing. HealthConnectManager.readNewWorkouts now applies the merge before returning, so the composer offers the whole effort as one post. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018UHCr6tW2xAXPJUVZjZPvA --- .../workouts/health/DetectedWorkout.kt | 10 +- .../workouts/health/HealthConnectManager.kt | 7 +- .../service/workouts/health/WorkoutMerger.kt | 139 ++++++++++ .../suggestion/DetectedWorkoutCarousel.kt | 3 + amethyst/src/main/res/values/strings.xml | 1 + .../workouts/health/WorkoutMergerTest.kt | 242 ++++++++++++++++++ 6 files changed, 399 insertions(+), 3 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMerger.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMergerTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/DetectedWorkout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/DetectedWorkout.kt index bfb18e2e8b..f8f8637e55 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/DetectedWorkout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/DetectedWorkout.kt @@ -30,7 +30,9 @@ import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType * UI directly. * * [id] is the Health Connect record id, used to remember which sessions the - * user has already handled (accepted or dismissed) so each is offered once. + * user has already handled (accepted or dismissed) so each is offered once. When + * several close-by sessions of the same type are combined by [WorkoutMerger], + * [id] becomes the members' ids joined with `+` and [sessionCount] rises above 1. */ @Immutable data class DetectedWorkout( @@ -47,4 +49,10 @@ data class DetectedWorkout( val elevationGainMeters: Double?, /** Human-readable name of the app/device that wrote the record (e.g. "Samsung Health"). */ val source: String, + /** + * How many Health Connect sessions this workout represents. 1 for a raw + * session; higher when [WorkoutMerger] combined several close-by same-type + * sessions (e.g. a long run split around breaks) into a single suggestion. + */ + val sessionCount: Int = 1, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/HealthConnectManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/HealthConnectManager.kt index 9daaaedb05..051c914ec5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/HealthConnectManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/HealthConnectManager.kt @@ -142,8 +142,11 @@ class HealthConnectManager( ) Log.i(TAG) { "readNewWorkouts: ${response.records.size} exercise session(s) in window $since .. $now" } val mapped = response.records.mapNotNull { mapSession(it) } - Log.i(TAG) { "readNewWorkouts: mapped ${mapped.size} workout(s) after type/duration filtering" } - mapped + // Fold split-up sessions of the same activity (a long run broken around + // breaks) into one suggestion so the composer offers the whole effort. + val merged = WorkoutMerger.mergeCloseWorkouts(mapped) + Log.i(TAG) { "readNewWorkouts: mapped ${mapped.size} -> ${merged.size} workout(s) after type/duration filtering and merging" } + merged } catch (e: Exception) { if (e is CancellationException) throw e Log.w(TAG, "Failed to read workouts from Health Connect", e) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMerger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMerger.kt new file mode 100644 index 0000000000..90e10fa22a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMerger.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.workouts.health + +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType +import kotlin.math.roundToInt + +/** + * Combines Health Connect exercise sessions that belong to the same real-world + * effort. Watches, Strava, and auto-pause features often split one long workout — + * a 5-hour run with coffee/water breaks — into several back-to-back + * `ExerciseSessionRecord`s of the same type. Posting each break as its own + * workout is noise; the user wants the whole run as one thing. + * + * [mergeCloseWorkouts] walks the sessions in start-time order and joins any run + * of same-type sessions whose consecutive gap (start of the next minus the end of + * the previous) is under [DEFAULT_MAX_GAP_SECONDS] into a single [DetectedWorkout]: + * distance, calories, steps, elevation and duration are summed, heart rate is + * duration-weighted, max heart rate is the max, and [DetectedWorkout.sessionCount] + * records how many sessions were folded in. + * + * Sessions of a different type occurring between two same-type sessions do not + * break the chain — a brief cross-training block mid-run still lets the two run + * segments merge — because gaps are tracked per exercise type. + */ +object WorkoutMerger { + /** Sessions less than one hour apart are treated as one workout. */ + const val DEFAULT_MAX_GAP_SECONDS = 3600L + + /** + * Merges close-by same-type sessions in [workouts]. Input order is irrelevant + * (sessions are sorted by start time internally). The result is ordered by + * each combined workout's start time, ascending. A gap of exactly + * [maxGapSeconds] does NOT merge — only strictly closer sessions do. + */ + fun mergeCloseWorkouts( + workouts: List, + maxGapSeconds: Long = DEFAULT_MAX_GAP_SECONDS, + ): List { + if (workouts.size < 2) return workouts + + val sorted = workouts.sortedBy { it.startTimeEpochSeconds } + + val groups = mutableListOf>() + // Most recent still-open group per exercise type, plus the latest end time + // seen for that type, so an interleaved activity of a different type never + // splits a run of same-type sessions. + val openGroupByType = HashMap>() + val lastEndByType = HashMap() + + for (workout in sorted) { + val openGroup = openGroupByType[workout.exercise] + val lastEnd = lastEndByType[workout.exercise] + val closeEnough = lastEnd != null && workout.startTimeEpochSeconds - lastEnd < maxGapSeconds + + if (openGroup != null && closeEnough) { + openGroup.add(workout) + } else { + val newGroup = mutableListOf(workout) + groups.add(newGroup) + openGroupByType[workout.exercise] = newGroup + } + lastEndByType[workout.exercise] = maxOf(lastEnd ?: Long.MIN_VALUE, endOf(workout)) + } + + return groups.map { combine(it) } + } + + /** End of a raw session: its start plus its (contiguous) duration. */ + private fun endOf(workout: DetectedWorkout): Long = workout.startTimeEpochSeconds + workout.durationSeconds + + /** Folds a group (already sorted by start time) into one [DetectedWorkout]. */ + private fun combine(group: List): DetectedWorkout { + if (group.size == 1) return group.first() + + val earliest = group.first() + + val distance = group.mapNotNull { it.distanceMeters }.takeIf { it.isNotEmpty() }?.sum() + val calories = group.mapNotNull { it.calories }.takeIf { it.isNotEmpty() }?.sum() + val steps = group.mapNotNull { it.steps }.takeIf { it.isNotEmpty() }?.sum() + val elevation = group.mapNotNull { it.elevationGainMeters }.takeIf { it.isNotEmpty() }?.sum() + val maxHeartRate = group.mapNotNull { it.maxHeartRate }.maxOrNull() + + return DetectedWorkout( + id = group.joinToString("+") { it.id }, + exercise = earliest.exercise, + title = group.firstNotNullOfOrNull { it.title?.takeIf(String::isNotBlank) }, + startTimeEpochSeconds = earliest.startTimeEpochSeconds, + durationSeconds = group.sumOf { it.durationSeconds }, + distanceMeters = distance, + calories = calories, + avgHeartRate = weightedAvgHeartRate(group), + maxHeartRate = maxHeartRate, + steps = steps, + elevationGainMeters = elevation, + source = earliest.source, + sessionCount = group.sumOf { it.sessionCount }, + ) + } + + /** + * Duration-weighted average heart rate across the members that reported one, + * so a 4-hour leg dominates a 5-minute leg. Null when no member has a heart + * rate; falls back to a plain average if every contributing leg has zero + * duration (shouldn't happen for real sessions). + */ + private fun weightedAvgHeartRate(group: List): Int? { + val withHeartRate = group.filter { it.avgHeartRate != null } + if (withHeartRate.isEmpty()) return null + + val weightSum = withHeartRate.sumOf { it.durationSeconds } + return if (weightSum > 0) { + withHeartRate + .sumOf { it.avgHeartRate!!.toDouble() * it.durationSeconds } + .div(weightSum) + .roundToInt() + } else { + withHeartRate.map { it.avgHeartRate!! }.average().roundToInt() + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/DetectedWorkoutCarousel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/DetectedWorkoutCarousel.kt index 146472d60b..a64f15d1bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/DetectedWorkoutCarousel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/suggestion/DetectedWorkoutCarousel.kt @@ -210,6 +210,9 @@ private fun summaryLine(workout: DetectedWorkout): String { parts.add(stringRes(R.string.workout_suggestion_distance_km, "%.2f".format(it / 1000.0))) } parts.add(formatWorkoutDuration(workout.durationSeconds)) + if (workout.sessionCount > 1) { + parts.add(stringRes(R.string.workout_suggestion_combined_sessions, workout.sessionCount)) + } return parts.joinToString(" · ") } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0253dfbb19..a743a7dadd 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -792,6 +792,7 @@ Connect %1$s km From Health Connect + %1$d activities Running Walking Cycling diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMergerTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMergerTest.kt new file mode 100644 index 0000000000..f8ea473932 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/workouts/health/WorkoutMergerTest.kt @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.workouts.health + +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class WorkoutMergerTest { + private val hour = 3600L + + private fun workout( + id: String, + exercise: ExerciseType = ExerciseType.RUNNING, + startTimeEpochSeconds: Long, + durationSeconds: Long, + distanceMeters: Double? = null, + calories: Int? = null, + avgHeartRate: Int? = null, + maxHeartRate: Int? = null, + steps: Int? = null, + elevationGainMeters: Double? = null, + title: String? = null, + source: String = "Samsung Health", + ) = DetectedWorkout( + id = id, + exercise = exercise, + title = title, + startTimeEpochSeconds = startTimeEpochSeconds, + durationSeconds = durationSeconds, + distanceMeters = distanceMeters, + calories = calories, + avgHeartRate = avgHeartRate, + maxHeartRate = maxHeartRate, + steps = steps, + elevationGainMeters = elevationGainMeters, + source = source, + ) + + @Test + fun emptyListPassesThrough() { + assertTrue(WorkoutMerger.mergeCloseWorkouts(emptyList()).isEmpty()) + } + + @Test + fun singleWorkoutIsReturnedUnchanged() { + val one = workout("a", startTimeEpochSeconds = 0, durationSeconds = 100) + val result = WorkoutMerger.mergeCloseWorkouts(listOf(one)) + assertEquals(1, result.size) + assertSame(one, result.first()) + assertEquals(1, result.first().sessionCount) + } + + @Test + fun twoCloseSameTypeSessionsMergeAndSumMetrics() { + // Run 10:00-11:00, then Run 11:30-12:00 (30 min gap < 1h): one 90-min run. + val first = + workout( + id = "first", + startTimeEpochSeconds = 0, + durationSeconds = hour, + distanceMeters = 10_000.0, + calories = 600, + steps = 12_000, + elevationGainMeters = 50.0, + ) + val second = + workout( + id = "second", + startTimeEpochSeconds = hour + 1800, // starts 30 min after first ends + durationSeconds = 1800, + distanceMeters = 5_000.0, + calories = 300, + steps = 6_000, + elevationGainMeters = 25.0, + ) + + val result = WorkoutMerger.mergeCloseWorkouts(listOf(first, second)) + + assertEquals(1, result.size) + val merged = result.first() + assertEquals("first+second", merged.id) + assertEquals(2, merged.sessionCount) + assertEquals(0, merged.startTimeEpochSeconds) // earliest start + assertEquals(hour + 1800, merged.durationSeconds) // summed active duration + assertEquals(15_000.0, merged.distanceMeters!!, 0.0001) + assertEquals(900, merged.calories) + assertEquals(18_000, merged.steps) + assertEquals(75.0, merged.elevationGainMeters!!, 0.0001) + } + + @Test + fun sameTypeButFarApartDoesNotMerge() { + val first = workout("first", startTimeEpochSeconds = 0, durationSeconds = hour) + // Starts 90 min after the first one ends -> gap exceeds the 1h threshold. + val second = workout("second", startTimeEpochSeconds = hour + hour + 1800, durationSeconds = hour) + + val result = WorkoutMerger.mergeCloseWorkouts(listOf(first, second)) + + assertEquals(2, result.size) + assertEquals(listOf("first", "second"), result.map { it.id }) + } + + @Test + fun gapOfExactlyOneHourDoesNotMerge() { + val first = workout("first", startTimeEpochSeconds = 0, durationSeconds = hour) + // Starts exactly 1h after the first ends -> boundary is exclusive. + val second = workout("second", startTimeEpochSeconds = hour + hour, durationSeconds = hour) + + val result = WorkoutMerger.mergeCloseWorkouts(listOf(first, second)) + + assertEquals(2, result.size) + } + + @Test + fun differentTypesCloseTogetherDoNotMerge() { + val run = workout("run", exercise = ExerciseType.RUNNING, startTimeEpochSeconds = 0, durationSeconds = hour) + val ride = workout("ride", exercise = ExerciseType.CYCLING, startTimeEpochSeconds = hour + 60, durationSeconds = hour) + + val result = WorkoutMerger.mergeCloseWorkouts(listOf(run, ride)) + + assertEquals(2, result.size) + } + + @Test + fun interleavedOtherTypeDoesNotBreakSameTypeChain() { + // Run, then a short walk during a break, then Run again — the two runs + // are close in time and should still combine across the walk. + val run1 = workout("run1", exercise = ExerciseType.RUNNING, startTimeEpochSeconds = 0, durationSeconds = hour) + val walk = workout("walk", exercise = ExerciseType.WALKING, startTimeEpochSeconds = hour + 300, durationSeconds = 600) + val run2 = workout("run2", exercise = ExerciseType.RUNNING, startTimeEpochSeconds = hour + 1200, durationSeconds = hour) + + val result = WorkoutMerger.mergeCloseWorkouts(listOf(run1, walk, run2)) + + assertEquals(2, result.size) + val run = result.first { it.exercise == ExerciseType.RUNNING } + assertEquals("run1+run2", run.id) + assertEquals(2, run.sessionCount) + assertEquals(2 * hour, run.durationSeconds) + assertEquals(1, result.first { it.exercise == ExerciseType.WALKING }.sessionCount) + } + + @Test + fun heartRateIsDurationWeighted() { + // 1h at 120 bpm + 0.5h at 150 bpm -> (120*3600 + 150*1800) / 5400 = 130. + val first = workout("first", startTimeEpochSeconds = 0, durationSeconds = hour, avgHeartRate = 120, maxHeartRate = 140) + val second = workout("second", startTimeEpochSeconds = hour + 60, durationSeconds = 1800, avgHeartRate = 150, maxHeartRate = 175) + + val merged = WorkoutMerger.mergeCloseWorkouts(listOf(first, second)).single() + + assertEquals(130, merged.avgHeartRate) + assertEquals(175, merged.maxHeartRate) + } + + @Test + fun nullMetricsAreSummedOnlyWhenPresent() { + val first = workout("first", startTimeEpochSeconds = 0, durationSeconds = hour, distanceMeters = 10_000.0, calories = null) + val second = workout("second", startTimeEpochSeconds = hour + 60, durationSeconds = 1800, distanceMeters = null, calories = 200) + + val merged = WorkoutMerger.mergeCloseWorkouts(listOf(first, second)).single() + + // Distance present only on one -> that value survives; calories only on the other. + assertEquals(10_000.0, merged.distanceMeters!!, 0.0001) + assertEquals(200, merged.calories) + } + + @Test + fun allNullMetricStaysNull() { + val first = workout("first", startTimeEpochSeconds = 0, durationSeconds = hour) + val second = workout("second", startTimeEpochSeconds = hour + 60, durationSeconds = 1800) + + val merged = WorkoutMerger.mergeCloseWorkouts(listOf(first, second)).single() + + assertNull(merged.distanceMeters) + assertNull(merged.calories) + assertNull(merged.avgHeartRate) + assertNull(merged.maxHeartRate) + assertNull(merged.steps) + assertNull(merged.elevationGainMeters) + } + + @Test + fun titleTakesFirstNonBlank() { + val first = workout("first", startTimeEpochSeconds = 0, durationSeconds = hour, title = " ") + val second = workout("second", startTimeEpochSeconds = hour + 60, durationSeconds = 1800, title = "Morning long run") + + val merged = WorkoutMerger.mergeCloseWorkouts(listOf(first, second)).single() + + assertEquals("Morning long run", merged.title) + } + + @Test + fun unsortedInputProducesResultsOrderedByStart() { + // Three same-type runs, all within an hour of each other, given out of order. + val a = workout("a", startTimeEpochSeconds = 0, durationSeconds = 600) + val b = workout("b", startTimeEpochSeconds = 1200, durationSeconds = 600) + val c = workout("c", startTimeEpochSeconds = 2400, durationSeconds = 600) + + val merged = WorkoutMerger.mergeCloseWorkouts(listOf(c, a, b)).single() + + assertEquals("a+b+c", merged.id) + assertEquals(3, merged.sessionCount) + assertEquals(0, merged.startTimeEpochSeconds) + assertEquals(1800, merged.durationSeconds) + } + + @Test + fun chainMergesEvenWhenAdjacentGapsAreShortButEndsAreFarApart() { + // 45-min sessions each starting 50 min apart: consecutive gaps are 5 min, + // so the whole chain merges though first and last are hours apart. + val sessions = + (0 until 5).map { + workout("s$it", startTimeEpochSeconds = it * 3000L, durationSeconds = 2700) + } + + val merged = WorkoutMerger.mergeCloseWorkouts(sessions) + + assertEquals(1, merged.size) + assertEquals(5, merged.single().sessionCount) + } +}