Skip to content

Commit 253a0c0

Browse files
authored
Aggregation support (#1328)
* Implement data aggregation (day, week, month, year) across the Overview and Table screens to improve long-term data visualization and management. ### Core Changes - **Data Aggregation Engine**: Introduced `MeasurementAggregationUseCase` to group and average measurements by time period, including automatic trend and difference calculations between periods. - **Enhanced `MeasurementFacade`**: Updated the data pipeline to support optional aggregation levels as a final processing step. - **Interactive Drill-down**: - Overview and Table screens now support a "drill-down" mode to view raw measurements for a specific aggregated period. - Aggregated Table rows now feature a `ChevronRight` icon to navigate to period details. - **Unified Filtering**: Updated the filter menu in Graph, Table, and Overview screens to include a new "Aggregation" section. - **UI Enhancements**: - Aggregated list items now display the number of raw measurements in the period. - Added an average symbol (⌀) prefix for values representing aggregated periods. - Updated `MeasurementCard` and `TableScreen` to handle period-based selection and batch actions (Delete/Export/Change User). ### Localization & Navigation - Added string resources for aggregation levels (None, Day, Week, Month, Year) in English and German. - Registered new drill-down routes in `AppNavigation` and `Routes`. - Updated `Enums.kt` to include `AggregationLevel` and restored Hebrew (iw) to the language list. * Introduce `AggregatedMeasurement` model and unify data access through a centralized `screenFlow` in `SharedViewModel`. - Create `AggregatedMeasurement` model to encapsulate enriched measurements with period metadata (start/end bounds, count, and stable keys). - Add `screenFlow` to `SharedViewModel` as a reactive, cached entry point for screens, handling user selection, time-range resolution, and aggregation logic internally. - Add `drillDownFlow` to `SharedViewModel` for accessing raw measurements within fixed time windows. - Refactor `OverviewScreen`, `TableScreen`, `GraphScreen`, and `StatisticsScreen` to consume the unified flows, simplifying UI logic. - Move period labeling and bound calculation logic into `AggregationLevel` enum. - Update `MeasurementFacade` to support the new aggregated pipeline and improve type safety. - Optimize chart data loading by reusing the cached `screenFlow`. * Improve data pipeline stability and UI performance in the Overview and Table screens with the following changes: * **ViewModel & Data Flow**: * Switch `StateFlow` sharing strategies from `WhileSubscribed(5000)` to `Eagerly` for `selectedUserId`, `selectedUser`, `measurementTypes`, and measurement flows. This prevents UI flickers, empty-list flashes, and accidental pipeline teardowns during navigation. * Implement a cache for `drillDownFlow` and include `useSmoothing` in the `screenFlow` cache key to prevent redundant cold flow restarts and state collisions. * Fix `MeasurementFilterUseCases` to correctly handle null bounds, ensuring inclusive filtering for start and end timestamps. * **Performance Optimizations**: * Introduce `remember`ed lookup maps (O(1)) in `OverviewScreen` and `TableScreen` for aggregated items and measurement types, replacing linear scans during recomposition. * Reuse pre-computed period bounds from `AggregatedMeasurement` instead of recomputing them in the UI layer. * **Table Screen & Selection Mode**: * Fix selection logic in aggregated mode by using `filter` and `first` on `StateFlows` to avoid capturing initial `Loading` states. * Optimize multi-item operations (delete/change user) by snapshotting data before processing, preventing "missing item" errors when Room emits updates mid-loop. * Enable selection mode and filter actions within the drill-down view. * Refactor selection state management to use `Set<String>` for more efficient toggling and updates. * Refactor measurement smoothing to apply after aggregation. Specific changes: - In `MeasurementFacade`, reordered the processing pipeline to perform data aggregation before applying smoothing algorithms. - Updated `MeasurementSmoothingUseCases` to operate on `AggregatedMeasurement` instead of `EnrichedMeasurement`, ensuring smoothing is applied to averaged period values. - In `SharedViewModel`, refactored `typesToSmoothAndDisplay` to be a reactive `StateFlow` derived directly from `measurementTypes`, automatically filtering for enabled numeric types. - Cleaned up internal logic and improved documentation within `MeasurementSmoothingUseCases` regarding series block splitting and timestamp alignment. * Refactor measurement smoothing and aggregation logic for improved performance and consistency. - Removed redundant filtering of measurement types in `MeasurementSmoothingUseCases`, relying on `typesToSmoothFlow` to provide pre-filtered enabled numeric IDs. - Optimized smoothed timestamp lookups in `MeasurementSmoothingUseCases` by using a `HashSet` for O(1) complexity. - Updated `MeasurementAggregationUseCase` to use the minimum timestamp of a group for period calculations instead of the midpoint, ensuring more consistent boundary mapping. - Simplified the `applySmoothing` signature in `MeasurementFacade` to match these changes.
1 parent 73cfb82 commit 253a0c0

19 files changed

Lines changed: 3151 additions & 2468 deletions

android_app/app/src/main/java/com/health/openscale/core/data/Enums.kt

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ import androidx.compose.ui.graphics.vector.ImageVector
7878
import androidx.compose.ui.res.pluralStringResource
7979
import androidx.compose.ui.res.stringResource
8080
import com.health.openscale.R
81+
import java.time.DayOfWeek
82+
import java.time.Instant
83+
import java.time.ZoneId
84+
import java.time.format.DateTimeFormatter
85+
import java.time.format.FormatStyle
86+
import java.time.temporal.WeekFields
8187
import java.util.Locale
8288

8389
enum class SupportedLanguage(val code: String, val nativeDisplayName: String) {
@@ -99,7 +105,7 @@ enum class SupportedLanguage(val code: String, val nativeDisplayName: String) {
99105
//GALICIAN("gl", "Galician (Galego)"),
100106
GERMAN("de", "German (Deutsch)"),
101107
GREEK("el", "Greek (ελληνικά)"),
102-
//HEBREW("iw", "Hebrew (עברית)"),
108+
HEBREW("iw", "Hebrew (עברית)"),
103109
//HUNGARIAN("hu", "Hungarian (magyar)"),
104110
//INDONESIAN("id", "Indonesian (Bahasa Indonesia)"),
105111
ITALIAN("it", "Italian (Italiano)"),
@@ -458,6 +464,95 @@ enum class TimeRangeFilter(@StringRes val displayNameResId: Int) {
458464
}
459465
}
460466

467+
enum class AggregationLevel(@StringRes val displayNameResId: Int) {
468+
NONE(R.string.aggregation_level_none),
469+
DAY(R.string.aggregation_level_day),
470+
WEEK(R.string.aggregation_level_week),
471+
MONTH(R.string.aggregation_level_month),
472+
YEAR(R.string.aggregation_level_year);
473+
474+
fun getDisplayName(context: Context): String {
475+
return context.getString(displayNameResId)
476+
}
477+
478+
479+
/**
480+
* Returns the inclusive start and exclusive end of the period containing [timestamp]
481+
* as epoch milliseconds.
482+
*
483+
* For [NONE] and [DAY] the period is a single calendar day.
484+
*/
485+
fun periodBounds(
486+
timestamp: Long,
487+
zone: ZoneId = ZoneId.systemDefault(),
488+
): Pair<Long, Long> {
489+
val date = Instant.ofEpochMilli(timestamp).atZone(zone).toLocalDate()
490+
val (start, end) = when (this) {
491+
NONE,
492+
DAY -> date to date.plusDays(1)
493+
WEEK -> { val mon = date.with(DayOfWeek.MONDAY); mon to mon.plusWeeks(1) }
494+
MONTH -> { val f = date.withDayOfMonth(1); f to f.plusMonths(1) }
495+
YEAR -> { val f = date.withDayOfYear(1); f to f.plusYears(1) }
496+
}
497+
return start.atStartOfDay(zone).toInstant().toEpochMilli() to
498+
end.atStartOfDay(zone).toInstant().toEpochMilli()
499+
}
500+
501+
/**
502+
* Returns a stable, locale-independent key for the period containing [timestamp].
503+
* Suitable as a LazyColumn item key or Map key.
504+
*
505+
* Examples: "2025-04-07" (DAY/NONE), "2025-W15" (WEEK), "2025-4" (MONTH), "2025" (YEAR).
506+
*/
507+
fun periodKey(
508+
timestamp: Long,
509+
zone: ZoneId = ZoneId.systemDefault(),
510+
): String {
511+
val date = Instant.ofEpochMilli(timestamp).atZone(zone).toLocalDate()
512+
return when (this) {
513+
NONE,
514+
DAY -> date.toString()
515+
WEEK -> {
516+
val wf = WeekFields.of(Locale.getDefault())
517+
"${date.get(wf.weekBasedYear())}-W${date.get(wf.weekOfWeekBasedYear())}"
518+
}
519+
MONTH -> "${date.year}-${date.monthValue}"
520+
YEAR -> "${date.year}"
521+
}
522+
}
523+
524+
/**
525+
* Returns a human-readable, locale-sensitive label for the period containing [timestamp].
526+
*
527+
* Intentionally separate from [periodKey] — labels are locale-dependent and must
528+
* not be used as stable identifiers.
529+
*
530+
* @param calendarWeekAbbrev Localised abbreviation for "calendar week" (e.g. "CW" / "KW").
531+
* Only used for [WEEK].
532+
*/
533+
fun periodLabel(
534+
timestamp: Long,
535+
calendarWeekAbbrev: String,
536+
locale: Locale = Locale.getDefault(),
537+
zone: ZoneId = ZoneId.systemDefault(),
538+
): String {
539+
val date = Instant.ofEpochMilli(timestamp).atZone(zone).toLocalDate()
540+
return when (this) {
541+
NONE,
542+
DAY -> date.format(
543+
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
544+
)
545+
WEEK -> {
546+
val wf = WeekFields.of(locale)
547+
"${date.get(wf.weekBasedYear())}$calendarWeekAbbrev ${date.get(wf.weekOfWeekBasedYear())}"
548+
}
549+
MONTH -> date.format(DateTimeFormatter.ofPattern("MMMM yyyy", locale))
550+
YEAR -> date.year.toString()
551+
}
552+
}
553+
554+
}
555+
461556
enum class SmoothingAlgorithm(@StringRes val displayNameResId: Int) {
462557
NONE(R.string.smoothing_algorithm_none),
463558
SIMPLE_MOVING_AVERAGE(R.string.smoothing_algorithm_sma),

0 commit comments

Comments
 (0)