Skip to content

Commit 3dd0e2e

Browse files
committed
Introduce a new "Insights" screen to provide rich, data-driven analysis of measurement history.
Key changes include: * **Core Logic**: Added `MeasurementInsightsUseCase` to compute body composition shifts, weekday habits, seasonal patterns, and unusual value detection (anomalies) using rolling z-scores and trend analysis. * **Data Models**: Introduced `MeasurementInsight` and associated immutable models to represent computed trends, volatility, and confidence levels. * **UI Implementation**: Created `InsightsScreen` featuring a story-style layout with animated visualizations (bar charts and heatmaps) and natural language summaries for each insight section. * **Infrastructure**: Updated `MeasurementFacade` and `SharedViewModel` to support reactive, asynchronous computation of insights dispatched to `Dispatchers.Default`. * **Components**: Enhanced `MeasurementTypeFilterRow` with a `singleSelect` mode to allow focused analysis on a specific measurement type. * **Navigation**: Registered the new Insights route with a corresponding icon and localized string resources.
1 parent ebe60ef commit 3dd0e2e

10 files changed

Lines changed: 1947 additions & 7 deletions

File tree

android_app/app/src/main/java/com/health/openscale/core/facade/MeasurementFacade.kt

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,22 +27,28 @@ import com.health.openscale.core.data.SmoothingAlgorithm
2727
import com.health.openscale.core.data.User
2828
import com.health.openscale.core.model.AggregatedMeasurement
2929
import com.health.openscale.core.model.EnrichedMeasurement
30+
import com.health.openscale.core.model.MeasurementInsight
3031
import com.health.openscale.core.model.MeasurementWithValues
3132
import com.health.openscale.core.model.UserEvaluationContext
3233
import com.health.openscale.core.service.MeasurementEnricher
3334
import com.health.openscale.core.usecase.MeasurementAggregationUseCase
3435
import com.health.openscale.core.usecase.MeasurementCrudUseCases
3536
import com.health.openscale.core.usecase.MeasurementEvaluationUseCases
3637
import com.health.openscale.core.usecase.MeasurementFilterUseCases
38+
import com.health.openscale.core.usecase.MeasurementInsightsUseCase
3739
import com.health.openscale.core.usecase.MeasurementQueryUseCases
3840
import com.health.openscale.core.usecase.MeasurementSmoothingUseCases
3941
import com.health.openscale.core.usecase.MeasurementTransformationUseCase
4042
import com.health.openscale.core.usecase.MeasurementTypeCrudUseCases
43+
import kotlinx.coroutines.Dispatchers
4144
import kotlinx.coroutines.ExperimentalCoroutinesApi
4245
import kotlinx.coroutines.flow.Flow
4346
import kotlinx.coroutines.flow.combine
47+
import kotlinx.coroutines.flow.distinctUntilChanged
4448
import kotlinx.coroutines.flow.flatMapLatest
4549
import kotlinx.coroutines.flow.flowOf
50+
import kotlinx.coroutines.flow.map
51+
import kotlinx.coroutines.withContext
4652
import javax.inject.Inject
4753
import javax.inject.Singleton
4854

@@ -70,6 +76,7 @@ class MeasurementFacade @Inject constructor(
7076
private val enricher: MeasurementEnricher,
7177
private val evaluationUseCases: MeasurementEvaluationUseCases,
7278
private val aggregation: MeasurementAggregationUseCase,
79+
private val insights: MeasurementInsightsUseCase,
7380
) {
7481

7582
private var pendingReferenceUser: User? = null
@@ -187,6 +194,29 @@ class MeasurementFacade @Inject constructor(
187194
}
188195
}
189196

197+
// -------------------------------------------------------------------------
198+
// Insights
199+
// -------------------------------------------------------------------------
200+
201+
/**
202+
* Returns a [Flow] emitting a computed [MeasurementInsight] for the given user.
203+
* Reacts automatically to measurement data changes.
204+
* Heavy computation is dispatched to [kotlinx.coroutines.Dispatchers.Default].
205+
*
206+
* @param userId Database id of the user.
207+
* @param primaryTypeId Optional explicit primary type ID for weekday and seasonal
208+
* pattern computation. If null, the use case selects the type
209+
* with the most measurements automatically.
210+
*/
211+
fun insightsForUser(userId: Int, primaryTypeId: Int? = null): Flow<MeasurementInsight> =
212+
getMeasurementsForUser(userId)
213+
.distinctUntilChanged()
214+
.map { measurements ->
215+
withContext(Dispatchers.Default) {
216+
insights.compute(measurements, primaryTypeId)
217+
}
218+
}
219+
190220
// -------------------------------------------------------------------------
191221
// BLE
192222
// -------------------------------------------------------------------------

android_app/app/src/main/java/com/health/openscale/core/facade/SettingsFacade.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ object SettingsPreferenceKeys {
126126
const val GRAPH_SCREEN_CONTEXT = "graph_screen"
127127
const val TABLE_SCREEN_CONTEXT = "table_screen"
128128
const val STATISTICS_SCREEN_CONTEXT = "statistics_screen"
129+
const val INSIGHTS_SCREEN_CONTEXT = "insights_screen"
129130
}
130131

131132
@Module
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
/*
2+
* openScale
3+
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
package com.health.openscale.core.model
19+
20+
import androidx.compose.runtime.Immutable
21+
import com.health.openscale.core.data.MeasurementType
22+
import java.time.DayOfWeek
23+
import java.time.LocalDate
24+
import java.time.Month
25+
26+
/**
27+
* Represents the confidence level of a computed [MeasurementInsight].
28+
*
29+
* - [HIGH]: Sufficient, regular data — conclusions are reliable.
30+
* - [LOW]: Limited or irregular data — conclusions should be treated with caution.
31+
* - [INSUFFICIENT]: Not enough data to draw any meaningful conclusion — insight is hidden.
32+
*/
33+
enum class InsightConfidence { HIGH, LOW, INSUFFICIENT }
34+
35+
/** Direction of a long- or short-term value trend. */
36+
enum class ShiftTrend { UP, DOWN, STABLE }
37+
38+
/**
39+
* How much a value fluctuates around its mean, derived from its standard deviation
40+
* relative to the overall value range.
41+
*
42+
* - [STABLE]: Low fluctuation — the value changes gradually and predictably.
43+
* - [MODERATE]: Medium fluctuation — some variation but no extreme swings.
44+
* - [HIGH]: High fluctuation — the value swings significantly day to day.
45+
*/
46+
enum class Volatility { STABLE, MODERATE, HIGH }
47+
48+
/**
49+
* A rich analysis of how a single measurement type has evolved over the user's
50+
* full measurement history.
51+
*
52+
* Beyond a simple first-to-last delta, this captures the rate of change,
53+
* plateau detection, best period, and short- vs. long-term trend direction —
54+
* giving the user actionable context rather than raw numbers.
55+
*
56+
* @property type The measurement type being analysed.
57+
* @property firstValue Value of the first recorded measurement.
58+
* @property lastValue Value of the most recent measurement.
59+
* @property deltaAbsolute Signed absolute change: [lastValue] − [firstValue].
60+
* @property deltaPercent Relative change in percent: ([deltaAbsolute] / [firstValue]) × 100.
61+
* @property minValue Lowest recorded value across the full history.
62+
* @property minValueDate Date on which [minValue] was recorded.
63+
* @property maxValue Highest recorded value across the full history.
64+
* @property maxValueDate Date on which [maxValue] was recorded.
65+
* @property volatility How much the value fluctuates around its mean.
66+
* @property shortTermTrend Trend direction computed over the last 30 days of data.
67+
* @property longTermTrend Trend direction computed over the full history.
68+
* @property ratePerMonth Average absolute change per calendar month
69+
* (positive = increasing, negative = decreasing).
70+
* @property plateauDays Number of consecutive days without a significant change
71+
* (> 0.5 % of mean) at the end of the history, or null if
72+
* no plateau is currently active.
73+
* @property bestPeriodStart Start of the calendar month with the largest improvement,
74+
* null if fewer than two months of data are available.
75+
* @property bestPeriodDelta The signed delta achieved in [bestPeriodStart]'s month,
76+
* null when [bestPeriodStart] is null.
77+
* @property firstMeasuredOn Date of the first measurement included in this analysis.
78+
* @property lastMeasuredOn Date of the most recent measurement included.
79+
* @property confidence Overall reliability of this insight.
80+
*/
81+
@Immutable
82+
data class BodyCompositionShift(
83+
val type: MeasurementType,
84+
val firstValue: Float,
85+
val lastValue: Float,
86+
val deltaAbsolute: Float,
87+
val deltaPercent: Float,
88+
val minValue: Float,
89+
val minValueDate: LocalDate,
90+
val maxValue: Float,
91+
val maxValueDate: LocalDate,
92+
val volatility: Volatility,
93+
val shortTermTrend: ShiftTrend,
94+
val longTermTrend: ShiftTrend,
95+
val ratePerMonth: Float,
96+
val plateauDays: Int?,
97+
val bestPeriodStart: LocalDate?,
98+
val bestPeriodDelta: Float?,
99+
val firstMeasuredOn: LocalDate,
100+
val lastMeasuredOn: LocalDate,
101+
val confidence: InsightConfidence,
102+
)
103+
104+
/**
105+
* Represents the average value deviation per day of the week for a specific measurement type.
106+
*
107+
* The primary type is chosen by the caller rather than being hardcoded,
108+
* so custom types are fully supported.
109+
*
110+
* Requires at least [MIN_MEASUREMENTS_PER_DAY] measurements per weekday to produce
111+
* a [InsightConfidence.HIGH] confidence result.
112+
*
113+
* @property type The measurement type this pattern is based on.
114+
* @property overallMean Average value across all days of the week.
115+
* @property deviationByDay Map of [DayOfWeek] to average deviation from the overall mean
116+
* (positive = above average, negative = below average).
117+
* @property measurementCountByDay Number of measurements available per weekday.
118+
* @property heaviestDay Weekday with the highest average value, null if insufficient data.
119+
* @property lightestDay Weekday with the lowest average value, null if insufficient data.
120+
* @property confidence Reliability of this insight based on data availability.
121+
*/
122+
@Immutable
123+
data class WeekdayPattern(
124+
val type: MeasurementType,
125+
val overallMean: Float,
126+
val deviationByDay: Map<DayOfWeek, Float>,
127+
val measurementCountByDay: Map<DayOfWeek, Int>,
128+
val heaviestDay: DayOfWeek?,
129+
val lightestDay: DayOfWeek?,
130+
val confidence: InsightConfidence,
131+
) {
132+
companion object {
133+
/** Minimum measurements per weekday for [InsightConfidence.HIGH]. */
134+
const val MIN_MEASUREMENTS_PER_DAY = 5
135+
}
136+
}
137+
138+
/**
139+
* Represents the average value per calendar month grouped by year for a specific measurement type.
140+
* Used to detect recurring seasonal patterns across multiple years.
141+
*
142+
* Requires data spanning at least [MIN_YEARS_FOR_PATTERN] years to produce
143+
* a [InsightConfidence.HIGH] confidence result.
144+
*
145+
* @property type The measurement type this pattern is based on.
146+
* @property averageValueByMonthAndYear Nested map of year → month → average value in the type's unit.
147+
* @property highestMonth Month with the highest cross-year average, null if insufficient.
148+
* @property lowestMonth Month with the lowest cross-year average, null if insufficient.
149+
* @property yearsWithData Number of distinct years covered by the data.
150+
* @property confidence Reliability of this insight based on data availability.
151+
*/
152+
@Immutable
153+
data class SeasonalPattern(
154+
val type: MeasurementType,
155+
val averageValueByMonthAndYear: Map<Int, Map<Month, Float>>,
156+
val highestMonth: Month?,
157+
val lowestMonth: Month?,
158+
val yearsWithData: Int,
159+
val confidence: InsightConfidence,
160+
) {
161+
companion object {
162+
/** Minimum distinct years required for [InsightConfidence.HIGH]. */
163+
const val MIN_YEARS_FOR_PATTERN = 2
164+
}
165+
}
166+
167+
/**
168+
* A single detected anomaly in a measurement series.
169+
*
170+
* Detected via a rolling z-score over a sliding window of recent measurements.
171+
* A gap longer than [com.health.openscale.core.usecase.MeasurementInsightsUseCase.ANOMALY_GAP_RESET_DAYS]
172+
* resets the baseline to avoid false positives after measurement breaks.
173+
*
174+
* @property measurementId ID of the measurement that triggered the anomaly.
175+
* @property date Date of the anomalous measurement.
176+
* @property type The [MeasurementType] in which the anomaly was detected.
177+
* @property value The actual measured value.
178+
* @property expectedValue Expected value based on the local rolling average.
179+
* @property deviation Signed difference between [value] and [expectedValue].
180+
* @property zScore Standardised deviation — values with |zScore| ≥ threshold are flagged.
181+
* @property comment Optional user comment on that measurement date, if any.
182+
*/
183+
@Immutable
184+
data class MeasurementAnomaly(
185+
val measurementId: Int,
186+
val date: LocalDate,
187+
val type: MeasurementType,
188+
val value: Float,
189+
val expectedValue: Float,
190+
val deviation: Float,
191+
val zScore: Float,
192+
val comment: String?,
193+
)
194+
195+
/**
196+
* Top-level container for all computed insights derived from a user's measurement history.
197+
*
198+
* Each insight field is nullable — null means [InsightConfidence.INSUFFICIENT] data
199+
* for that specific analysis.
200+
*
201+
* Note: [MeasurementInsight] sits alongside the aggregation hierarchy rather than
202+
* extending it — it is derived from the full measurement history, not from a single entry.
203+
*
204+
* @property bodyCompositionShift Rich analysis of how the primary type evolved over time, or null.
205+
* @property weekdayPattern Average deviation per weekday for the primary type, or null.
206+
* @property seasonalPattern Average value per month grouped by year, or null.
207+
* @property anomalies Detected anomalies sorted by date descending, empty if none found.
208+
* @property basedOnCount Total raw measurements used to compute these insights.
209+
* @property computedAt Date on which these insights were last computed.
210+
*/
211+
@Immutable
212+
data class MeasurementInsight(
213+
val bodyCompositionShift: BodyCompositionShift?,
214+
val weekdayPattern: WeekdayPattern?,
215+
val seasonalPattern: SeasonalPattern?,
216+
val anomalies: List<MeasurementAnomaly>,
217+
val basedOnCount: Int,
218+
val computedAt: LocalDate,
219+
)

0 commit comments

Comments
 (0)