From 2cc11fadc81c90e9f81a1df6936478b05a95ec40 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 6 Jun 2026 12:32:27 +0000 Subject: [PATCH 1/3] Fix symptoms not recording time when trackAgainstTime is enabled syncSymptomsToTrackingLog() always passed an empty loggedAt, so the Symptoms category's trackAgainstTime flag was silently ignored for any entry saved from the period log screen. https://claude.ai/code/session_018JqDNoQmTRpYxFJJ9kVhw8 --- CHANGELOG.md | 6 ++++++ LESSONS.md | 3 +++ app/build.gradle.kts | 4 ++-- .../com/mapgie/goflo/ui/screens/log/LogPeriodViewModel.kt | 6 ++++++ 4 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01418b6..30fd44d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ Rules: - Released versions are immutable — never re-tag, never amend, never delete an entry - Merge conflicts must preserve both sides; if both branches used the same version string, renumber the lower-priority one upward +--- +## [0.45.1-beta.1] - 2026-06-06 + +### Fixed +- Symptoms logged from the period log screen now record the time when the Symptoms category has "Track against time" enabled. + --- ## [0.45.0-beta.1] - 2026-06-06 diff --git a/LESSONS.md b/LESSONS.md index 370419b..de67554 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -29,6 +29,9 @@ When a SmallFloatingActionButton contains an Icon with `contentDescription = nul **`ModalBottomSheetProperties` requires all parameters explicitly in Material3 1.2.x** The constructor has no default values in this version — passing only `shouldDismissOnBackPress` fails to compile. Always supply all three: `securePolicy = SecureFlagPolicy.Inherit, isFocusable = true, shouldDismissOnBackPress = false`. `SecureFlagPolicy` also needs an explicit import from `androidx.compose.ui.window`. +**Parallel write paths must each respect every category setting** +When two code paths write to the same store (e.g. `LogPeriodViewModel.syncSymptomsToTrackingLog` and `LogCategoryViewModel.save` both writing to `tracking_logs`), each path must independently read and apply every relevant category flag. If a new flag is added (like `trackAgainstTime`) and only one path is updated, the other silently ignores the setting. When adding a per-category behaviour flag, grep for all call sites of the underlying `saveLog` / `updateLogInPlace` and confirm they all handle the new flag. + **Room generates no SQLite DEFAULTs without `@ColumnInfo(defaultValue=…)` — fresh-install seeds rot as the schema grows** Room emits `NOT NULL` with no SQL `DEFAULT` for every entity field that lacks a `@ColumnInfo(defaultValue=…)` annotation. Migrations protect existing users because `ALTER TABLE … ADD COLUMN … DEFAULT …` always supplies a value. The `onCreate` seed INSERT is hand-written and must list every column explicitly — omitting any `NOT NULL` column causes a constraint violation on first open, crashing the app before any screen is shown. Two defences: (1) enumerate all non-PK columns in seed INSERTs; (2) annotate every entity field with `@ColumnInfo(defaultValue=…)` so Room's generated DDL also includes SQL `DEFAULT` clauses and the two stay in sync automatically. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ce9d39c..ff4f4c0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "com.mapgie.goflo" minSdk = 26 targetSdk = 34 - versionCode = 97 - versionName = "0.45.0-beta.1" + versionCode = 98 + versionName = "0.45.1-beta.1" } signingConfigs { diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogPeriodViewModel.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogPeriodViewModel.kt index 95890e3..f8a0933 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogPeriodViewModel.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogPeriodViewModel.kt @@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.time.LocalDate +import java.time.LocalTime +import java.time.format.DateTimeFormatter data class LogPeriodUiState( val isLoading: Boolean = true, @@ -305,12 +307,16 @@ class LogPeriodViewModel( val existing = tr.getExistingLog(state.startDate, symptomsCategory.id) ?: return tr.deleteLog(existing.log) } else { + val loggedAt = if (symptomsCategory.trackAgainstTime) { + LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm")) + } else "" tr.saveLog( date = state.startDate, categoryId = symptomsCategory.id, selectedValues = state.symptoms, notes = "", allowMultiple = false, + loggedAt = loggedAt, ) } } From 4a3e999713a08eb7f7559a8d1fa4aba548d77766 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 6 Jun 2026 12:54:24 +0000 Subject: [PATCH 2/3] Add By Day, By Hour, and Timing stats charts https://claude.ai/code/session_018JqDNoQmTRpYxFJJ9kVhw8 --- CHANGELOG.md | 8 + app/build.gradle.kts | 4 +- .../goflo/ui/screens/stats/StatsCharts.kt | 209 ++++++++++++++++++ .../goflo/ui/screens/stats/StatsScreen.kt | 95 ++++++-- .../goflo/ui/screens/stats/StatsViewModel.kt | 149 ++++++++++++- 5 files changed, 439 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30fd44d..68c5e38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ Rules: - Released versions are immutable — never re-tag, never amend, never delete an entry - Merge conflicts must preserve both sides; if both branches used the same version string, renumber the lower-priority one upward +--- +## [0.46.0-beta.1] - 2026-06-06 + +### Added +- Stats: "By Day" chart shows how often (or average severity) a tracked category falls on each day of the week, answering questions like "do I get headaches more on Fridays?" +- Stats: "By Hour" chart shows the time-of-day distribution in 2-hour slots for any category with time tracking enabled, answering "what time of day do my headaches happen?" +- Stats: "Timing" chart overlays two time-tracked categories on a date-versus-hour scatter plot, making lead/lag relationships visible, for example seeing that a hormone log at 8am consistently precedes a headache log at 6pm. + --- ## [0.45.1-beta.1] - 2026-06-06 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ff4f4c0..936a41c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "com.mapgie.goflo" minSdk = 26 targetSdk = 34 - versionCode = 98 - versionName = "0.45.1-beta.1" + versionCode = 99 + versionName = "0.46.0-beta.1" } signingConfigs { diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/stats/StatsCharts.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/stats/StatsCharts.kt index c84198b..77f8e63 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/stats/StatsCharts.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/stats/StatsCharts.kt @@ -730,3 +730,212 @@ fun TrendsChart(data: StatsChartData.TrendsData) { } } } + +// ── Weekday chart ───────────────────────────────────────────────────────────────────── + +@Composable +fun WeekdayChart(data: StatsChartData.WeekdayData, modifier: Modifier = Modifier) { + val barColor = MaterialTheme.colorScheme.primary + val maxBarValue = if (data.isNumeric) + data.bars.mapNotNull { it.avgValue }.maxOrNull() ?: 1f + else + data.bars.maxOfOrNull { it.count.toFloat() } ?: 1f + val clampedMax = maxBarValue.coerceAtLeast(0.001f) + + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + data.bars.forEach { bar -> + val displayValue = bar.avgValue ?: bar.count.toFloat() + val fraction = (displayValue / clampedMax).coerceIn(0f, 1f) + val topLabel = when { + bar.count == 0 -> "" + bar.avgValue != null -> "%.1f".format(bar.avgValue) + else -> bar.count.toString() + } + Column( + modifier = Modifier.width(40.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = topLabel, + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center, + modifier = Modifier.height(16.dp) + ) + Column( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .padding(horizontal = 4.dp), + verticalArrangement = Arrangement.Bottom + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(fraction) + .clip(RoundedCornerShape(topStart = 3.dp, topEnd = 3.dp)) + .background(barColor) + ) + } + Text( + text = bar.dayLabel, + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 2.dp) + ) + } + } + } + } +} + +// ── Time-of-day chart ───────────────────────────────────────────────────────────────── + +@Composable +fun TimeOfDayChart(data: StatsChartData.TimeOfDayData, modifier: Modifier = Modifier) { + val barColor = MaterialTheme.colorScheme.primary + val maxBarValue = if (data.isNumeric) + data.bars.mapNotNull { it.avgValue }.maxOrNull() ?: 1f + else + data.bars.maxOfOrNull { it.count.toFloat() } ?: 1f + val clampedMax = maxBarValue.coerceAtLeast(0.001f) + + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + ) { + data.bars.forEach { bar -> + val displayValue = bar.avgValue ?: bar.count.toFloat() + val fraction = (displayValue / clampedMax).coerceIn(0f, 1f) + val topLabel = when { + bar.count == 0 -> "" + bar.avgValue != null -> "%.1f".format(bar.avgValue) + else -> bar.count.toString() + } + Column( + modifier = Modifier.width(40.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = topLabel, + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center, + modifier = Modifier.height(16.dp) + ) + Column( + modifier = Modifier + .fillMaxWidth() + .height(120.dp) + .padding(horizontal = 4.dp), + verticalArrangement = Arrangement.Bottom + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(fraction) + .clip(RoundedCornerShape(topStart = 3.dp, topEnd = 3.dp)) + .background(barColor) + ) + } + Text( + text = bar.hourLabel, + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 2.dp) + ) + } + } + } + } +} + +// ── Time correlation chart ──────────────────────────────────────────────────────────── + +@Composable +fun TimeCorrelationChart(data: StatsChartData.TimeCorrelationData, modifier: Modifier = Modifier) { + val color1 = if (data.colorToken1.isNotEmpty()) data.colorToken1.toCategoryColor() + else MaterialTheme.colorScheme.primary + val color2 = if (data.colorToken2.isNotEmpty()) data.colorToken2.toCategoryColor() + else MaterialTheme.colorScheme.tertiary + val outline = MaterialTheme.colorScheme.outlineVariant + val labelStyle = MaterialTheme.typography.labelSmall + val labelColor = MaterialTheme.colorScheme.onSurfaceVariant + + Column(modifier = modifier.fillMaxWidth()) { + // Legend + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + LegendItem(color = color1, label = data.cat1Name) + LegendItem(color = color2, label = data.cat2Name) + } + + Text( + text = "Hour of day ↑", + style = labelStyle, + color = labelColor, + modifier = Modifier.padding(start = 4.dp, bottom = 2.dp) + ) + + Row(modifier = Modifier.fillMaxWidth().height(220.dp)) { + // Y-axis: 12am at top, 12am at bottom (full 24h) + Column( + modifier = Modifier + .width(36.dp) + .fillMaxHeight() + .padding(end = 4.dp), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.End + ) { + listOf("12am", "6am", "12pm", "6pm", "12am").forEach { label -> + Text(label, style = labelStyle, color = labelColor, textAlign = TextAlign.End) + } + } + + Canvas(modifier = Modifier.weight(1f).fillMaxHeight()) { + val w = size.width + val h = size.height + + // Horizontal grid lines at 6, 12, 18 hours + for (gridHour in listOf(6f, 12f, 18f)) { + val y = gridHour / 24f * h + drawLine(outline.copy(alpha = 0.3f), Offset(0f, y), Offset(w, y), 0.5.dp.toPx()) + } + // Axes + drawLine(outline, Offset(0f, 0f), Offset(0f, h), 1.5.dp.toPx()) + drawLine(outline, Offset(0f, h), Offset(w, h), 1.5.dp.toPx()) + + val dotRadius = 4.dp.toPx() + val td = data.totalDays.toFloat() + + fun toX(dayOffset: Int) = if (td <= 0f) w / 2f else dayOffset / td * w + fun toY(hour: Float) = hour / 24f * h + + // Cat2 first so cat1 draws on top + data.points2.forEach { pt -> + drawCircle(color2.copy(alpha = 0.65f), dotRadius, Offset(toX(pt.dayOffset), toY(pt.hourFraction))) + } + data.points1.forEach { pt -> + drawCircle(color1.copy(alpha = 0.8f), dotRadius, Offset(toX(pt.dayOffset), toY(pt.hourFraction))) + } + } + } + + Text( + text = "Time →", + style = labelStyle, + color = labelColor, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 2.dp, start = 36.dp) + ) + } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/stats/StatsScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/stats/StatsScreen.kt index b70e6fe..df357b4 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/stats/StatsScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/stats/StatsScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.CircleShape @@ -31,6 +32,8 @@ import androidx.compose.material.icons.filled.BarChart import androidx.compose.material.icons.outlined.BookmarkAdd import androidx.compose.material.icons.outlined.BookmarkRemove import androidx.compose.material.icons.filled.BubbleChart +import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Dashboard import androidx.compose.material.icons.filled.DonutLarge @@ -623,42 +626,41 @@ private fun ChartTypeSelector( ) { data class ChartOption(val type: ChartType, val icon: ImageVector, val label: String) - val eitherNumeric = cat1.isNumeric || cat2?.isNumeric == true - val options = buildList { when { cat1.isNumeric && cat2?.isNumeric == true -> { add(ChartOption(ChartType.SCATTER, Icons.Default.BubbleChart, "Scatter")) add(ChartOption(ChartType.NUMERIC_AVERAGE, Icons.Default.BarChart, "Average")) add(ChartOption(ChartType.DUAL_TIME_SERIES, Icons.Default.BarChart, "Compare")) + if (cat1.trackAgainstTime && cat2.trackAgainstTime) + add(ChartOption(ChartType.TIME_CORRELATION, Icons.Default.ScatterPlot, "Timing")) } cat1.isNumeric && cat2 == null -> { - add(ChartOption(ChartType.TIME_SCATTER, Icons.Default.ScatterPlot, "Time")) + add(ChartOption(ChartType.TIME_SCATTER, Icons.Default.ScatterPlot, "Over Time")) add(ChartOption(ChartType.NUMERIC_AVERAGE, Icons.Default.BarChart, "Average")) - add(ChartOption(ChartType.TIME_SERIES, Icons.Default.BarChart, "Over Time")) - add( - ChartOption( - ChartType.NUMERIC_DISTRIBUTION, - Icons.Default.DonutLarge, - "Distribution" - ) - ) + add(ChartOption(ChartType.TIME_SERIES, Icons.Default.BarChart, "Timeline")) + add(ChartOption(ChartType.NUMERIC_DISTRIBUTION, Icons.Default.DonutLarge, "Spread")) + add(ChartOption(ChartType.WEEKDAY, Icons.Default.DateRange, "By Day")) + if (cat1.trackAgainstTime) + add(ChartOption(ChartType.TIME_OF_DAY, Icons.Default.Schedule, "By Hour")) } - eitherNumeric -> { - // One numeric, one non-numeric: only Compare makes sense for two cats + cat1.isNumeric || cat2?.isNumeric == true -> { if (cat2 != null) add(ChartOption(ChartType.DUAL_TIME_SERIES, Icons.Default.BarChart, "Compare")) else add(ChartOption(ChartType.TIME_SERIES, Icons.Default.BarChart, "Over Time")) + if (cat1.trackAgainstTime && cat2?.trackAgainstTime == true) + add(ChartOption(ChartType.TIME_CORRELATION, Icons.Default.ScatterPlot, "Timing")) } cat2 != null -> { - // Two non-numeric categories: Trends and Over Time hidden add(ChartOption(ChartType.PIE, Icons.Default.DonutLarge, "Breakdown")) add(ChartOption(ChartType.COMBO, Icons.Default.TableChart, "Combos")) add(ChartOption(ChartType.DUAL_TIME_SERIES, Icons.Default.BarChart, "Compare")) + if (cat1.trackAgainstTime && cat2.trackAgainstTime) + add(ChartOption(ChartType.TIME_CORRELATION, Icons.Default.ScatterPlot, "Timing")) } else -> { @@ -666,6 +668,9 @@ private fun ChartTypeSelector( add(ChartOption(ChartType.PIE, Icons.Default.DonutLarge, "Breakdown")) add(ChartOption(ChartType.TIME_SERIES, Icons.Default.BarChart, "Over Time")) add(ChartOption(ChartType.PHASE_SUMMARY, Icons.Default.TableChart, "By Phase")) + add(ChartOption(ChartType.WEEKDAY, Icons.Default.DateRange, "By Day")) + if (cat1.trackAgainstTime) + add(ChartOption(ChartType.TIME_OF_DAY, Icons.Default.Schedule, "By Hour")) } } } @@ -677,15 +682,17 @@ private fun ChartTypeSelector( style = MaterialTheme.typography.titleMedium ) Spacer(modifier = Modifier.height(8.dp)) - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - options.forEachIndexed { index, option -> - SegmentedButton( - selected = selectedType == option.type, - onClick = { onSelect(option.type) }, - shape = SegmentedButtonDefaults.itemShape(index, options.size), - icon = { SegmentedButtonDefaults.Icon(active = selectedType == option.type) } - ) { - Text(option.label, style = MaterialTheme.typography.labelSmall, maxLines = 1) + Row(modifier = Modifier.horizontalScroll(rememberScrollState())) { + SingleChoiceSegmentedButtonRow { + options.forEachIndexed { index, option -> + SegmentedButton( + selected = selectedType == option.type, + onClick = { onSelect(option.type) }, + shape = SegmentedButtonDefaults.itemShape(index, options.size), + icon = { SegmentedButtonDefaults.Icon(active = selectedType == option.type) } + ) { + Text(option.label, style = MaterialTheme.typography.labelSmall, maxLines = 1) + } } } } @@ -1025,6 +1032,48 @@ private fun ChartArea( is StatsChartData.PhaseSummaryData -> { PhaseSummaryChart(data = chartData) } + + is StatsChartData.WeekdayData -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp) + ) { + Text( + text = if (chartData.isNumeric) "Average ${chartData.categoryName} by weekday" + else "${chartData.categoryName} by weekday", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + WeekdayChart(data = chartData) + } + } + + is StatsChartData.TimeOfDayData -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp) + ) { + Text( + text = if (chartData.isNumeric) "Average ${chartData.categoryName} by time of day" + else "${chartData.categoryName}: time of day", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + TimeOfDayChart(data = chartData) + } + } + + is StatsChartData.TimeCorrelationData -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + TimeCorrelationChart(data = chartData) + } + } } } } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/stats/StatsViewModel.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/stats/StatsViewModel.kt index 5fdee4c..216a35f 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/stats/StatsViewModel.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/stats/StatsViewModel.kt @@ -15,7 +15,9 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.time.DayOfWeek import java.time.LocalDate +import java.time.LocalTime import java.time.YearMonth import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit @@ -49,6 +51,12 @@ enum class ChartType { TRENDS, /** Per-phase breakdown of logged values across the cycle. */ PHASE_SUMMARY, + /** Frequency or average by day of week (Mon-Sun). Only valid when cat2 == null. */ + WEEKDAY, + /** Time-of-day distribution (2-hour buckets). Only valid when cat1.trackAgainstTime && cat2 == null. */ + TIME_OF_DAY, + /** Two time-tracked categories overlaid on a date x hour scatter. Only valid when both categories have trackAgainstTime. */ + TIME_CORRELATION, } // ── Chart data models ───────────────────────────────────────────────────────── @@ -76,6 +84,10 @@ data class TimeScatterPoint(val dayOffset: Int, val dateLabel: String, val value data class TrendsBar(val label: String, val count: Int, val percentage: Int) +data class WeekdayBar(val dayLabel: String, val count: Int, val avgValue: Float?) +data class TimeOfDayBar(val hourLabel: String, val count: Int, val avgValue: Float?) +data class TimeLogPoint(val dayOffset: Int, val hourFraction: Float, val dateLabel: String) + data class PinnedStat( val id: String, val label: String, @@ -143,6 +155,25 @@ sealed class StatsChartData { val rows: List, val categoryName: String, ) : StatsChartData() + data class WeekdayData( + val bars: List, + val categoryName: String, + val isNumeric: Boolean, + ) : StatsChartData() + data class TimeOfDayData( + val bars: List, + val categoryName: String, + val isNumeric: Boolean, + ) : StatsChartData() + data class TimeCorrelationData( + val points1: List, + val points2: List, + val cat1Name: String, + val cat2Name: String, + val colorToken1: String, + val colorToken2: String, + val totalDays: Int, + ) : StatsChartData() } // ── Pin result ──────────────────────────────────────────────────────────────── @@ -357,7 +388,9 @@ class StatsViewModel( currentType: ChartType ): ChartType { val hiddenForTwoCats = cat2 != null && - (currentType == ChartType.TRENDS || currentType == ChartType.TIME_SERIES) + (currentType == ChartType.TRENDS || currentType == ChartType.TIME_SERIES || + currentType == ChartType.WEEKDAY || currentType == ChartType.TIME_OF_DAY || + currentType == ChartType.PHASE_SUMMARY) if (!hiddenForTwoCats && isValidChartType(currentType, cat1, cat2)) return currentType @@ -514,6 +547,9 @@ class StatsViewModel( ChartType.TRENDS -> !cat1.isNumeric ChartType.PHASE_SUMMARY -> !cat1.isNumeric && cat2 == null ChartType.PIE, ChartType.TIME_SERIES -> true + ChartType.WEEKDAY -> cat2 == null + ChartType.TIME_OF_DAY -> cat1.trackAgainstTime && cat2 == null + ChartType.TIME_CORRELATION -> cat1.trackAgainstTime && cat2?.trackAgainstTime == true } } @@ -741,6 +777,107 @@ class StatsViewModel( else StatsChartData.PhaseSummaryData(rows, cat1.name) } } + + ChartType.WEEKDAY -> { + val logs = repository.getLogsForCategoryInRange(cat1.id, start, end) + if (logs.isEmpty()) { + StatsChartData.Empty + } else { + val dayOrder = listOf( + DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, DayOfWeek.FRIDAY, DayOfWeek.SATURDAY, DayOfWeek.SUNDAY + ) + val dayLabels = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") + val bars = if (cat1.isNumeric) { + val byDay = mutableMapOf>() + for (log in logs) { + val dow = LocalDate.parse(log.date).dayOfWeek + val value = repository.getValuesForLog(log.id).firstOrNull()?.toFloatOrNull() ?: continue + byDay.getOrPut(dow) { mutableListOf() }.add(value) + } + dayOrder.zip(dayLabels).map { (dow, label) -> + val values = byDay[dow] ?: emptyList() + WeekdayBar(label, values.size, if (values.isEmpty()) null else values.average().toFloat()) + } + } else { + val byDay = logs.groupBy { LocalDate.parse(it.date).dayOfWeek } + dayOrder.zip(dayLabels).map { (dow, label) -> + WeekdayBar(label, byDay[dow]?.size ?: 0, null) + } + } + if (bars.all { it.count == 0 }) StatsChartData.Empty + else StatsChartData.WeekdayData(bars, cat1.name, cat1.isNumeric) + } + } + + ChartType.TIME_OF_DAY -> { + val timeFmt = DateTimeFormatter.ofPattern("HH:mm") + val logs = repository.getLogsForCategoryInRange(cat1.id, start, end) + .filter { it.loggedAt.isNotEmpty() } + if (logs.isEmpty()) { + StatsChartData.Empty + } else { + val bars = if (cat1.isNumeric) { + val byBucket = mutableMapOf>() + for (log in logs) { + val bucket = runCatching { LocalTime.parse(log.loggedAt, timeFmt).hour / 2 }.getOrNull() ?: continue + val value = repository.getValuesForLog(log.id).firstOrNull()?.toFloatOrNull() ?: continue + byBucket.getOrPut(bucket) { mutableListOf() }.add(value) + } + (0 until 12).map { b -> + val values = byBucket[b] ?: emptyList() + TimeOfDayBar(hourBucketLabel(b), values.size, if (values.isEmpty()) null else values.average().toFloat()) + } + } else { + val counts = IntArray(12) + for (log in logs) { + val bucket = runCatching { LocalTime.parse(log.loggedAt, timeFmt).hour / 2 }.getOrNull() ?: continue + counts[bucket]++ + } + (0 until 12).map { b -> TimeOfDayBar(hourBucketLabel(b), counts[b], null) } + } + if (bars.all { it.count == 0 }) StatsChartData.Empty + else StatsChartData.TimeOfDayData(bars, cat1.name, cat1.isNumeric) + } + } + + ChartType.TIME_CORRELATION -> { + if (cat2 == null) { + StatsChartData.Empty + } else { + val timeFmt = DateTimeFormatter.ofPattern("HH:mm") + val dateFmt = DateTimeFormatter.ofPattern("d MMM") + fun logsToPoints(logs: List): List = + logs.filter { it.loggedAt.isNotEmpty() }.mapNotNull { log -> + val hour = runCatching { + val t = LocalTime.parse(log.loggedAt, timeFmt) + t.hour + t.minute / 60f + }.getOrNull() ?: return@mapNotNull null + val date = LocalDate.parse(log.date) + TimeLogPoint( + dayOffset = ChronoUnit.DAYS.between(start, date).toInt(), + hourFraction = hour, + dateLabel = date.format(dateFmt), + ) + } + val points1 = logsToPoints(repository.getLogsForCategoryInRange(cat1.id, start, end)) + val points2 = logsToPoints(repository.getLogsForCategoryInRange(cat2.id, start, end)) + if (points1.isEmpty() && points2.isEmpty()) { + StatsChartData.Empty + } else { + val totalDays = ChronoUnit.DAYS.between(start, end).toInt().coerceAtLeast(1) + StatsChartData.TimeCorrelationData( + points1 = points1, + points2 = points2, + cat1Name = cat1.name, + cat2Name = cat2.name, + colorToken1 = cat1.colorToken, + colorToken2 = cat2.colorToken, + totalDays = totalDays, + ) + } + } + } } _uiState.update { it.copy(chartData = newData) } @@ -1060,6 +1197,16 @@ class StatsViewModel( } } + private fun hourBucketLabel(bucket: Int): String { + val h = bucket * 2 + return when { + h == 0 -> "12am" + h == 12 -> "12pm" + h < 12 -> "${h}am" + else -> "${h - 12}pm" + } + } + // ── Factory ─────────────────────────────────────────────────────────────── class Factory( From 38fe70b0e4cc25ffb6cc2ce0b78b4a2b0cf4b932 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 6 Jun 2026 13:11:17 +0000 Subject: [PATCH 3/3] Fix exhaustive when compile errors for new chart types ChartDataComputer and DashboardScreen both had exhaustive when expressions over ChartType / StatsChartData that didn't cover the three new WEEKDAY, TIME_OF_DAY, and TIME_CORRELATION cases. https://claude.ai/code/session_018JqDNoQmTRpYxFJJ9kVhw8 --- .../ui/screens/dashboard/DashboardScreen.kt | 15 +++ .../ui/screens/stats/ChartDataComputer.kt | 112 ++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/dashboard/DashboardScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/dashboard/DashboardScreen.kt index 525ad37..3ef54d7 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/dashboard/DashboardScreen.kt @@ -40,8 +40,11 @@ import com.mapgie.goflo.ui.screens.stats.PieChart import com.mapgie.goflo.ui.screens.stats.PinnedStat import com.mapgie.goflo.ui.screens.stats.ScatterPlot import com.mapgie.goflo.ui.screens.stats.StatsChartData +import com.mapgie.goflo.ui.screens.stats.TimeCorrelationChart +import com.mapgie.goflo.ui.screens.stats.TimeOfDayChart import com.mapgie.goflo.ui.screens.stats.TimeScatterChart import com.mapgie.goflo.ui.screens.stats.TrendsChart +import com.mapgie.goflo.ui.screens.stats.WeekdayChart @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -246,6 +249,18 @@ private fun PinnedChartCard( is StatsChartData.PhaseSummaryData -> { // Phase summary is only shown in the Stats screen, not pinned to the dashboard } + + is StatsChartData.WeekdayData -> { + WeekdayChart(data = data, modifier = Modifier.fillMaxWidth()) + } + + is StatsChartData.TimeOfDayData -> { + TimeOfDayChart(data = data, modifier = Modifier.fillMaxWidth()) + } + + is StatsChartData.TimeCorrelationData -> { + TimeCorrelationChart(data = data, modifier = Modifier.fillMaxWidth()) + } } } } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/stats/ChartDataComputer.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/stats/ChartDataComputer.kt index 42812ce..428fe00 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/stats/ChartDataComputer.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/stats/ChartDataComputer.kt @@ -4,7 +4,9 @@ import com.mapgie.goflo.data.database.entities.TrackingCategory import com.mapgie.goflo.data.database.entities.TrackingLog import com.mapgie.goflo.data.repository.TrackingRepository import com.mapgie.goflo.ui.util.decodeScaleLabels +import java.time.DayOfWeek import java.time.LocalDate +import java.time.LocalTime import java.time.YearMonth import java.time.format.DateTimeFormatter import java.time.format.TextStyle @@ -205,6 +207,116 @@ internal suspend fun computeChartData( } ChartType.PHASE_SUMMARY -> StatsChartData.Empty + + ChartType.WEEKDAY -> { + val logs = repository.getLogsForCategoryInRange(category1.id, start, end) + if (logs.isEmpty()) { + StatsChartData.Empty + } else { + val dayOrder = listOf( + DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, DayOfWeek.FRIDAY, DayOfWeek.SATURDAY, DayOfWeek.SUNDAY + ) + val dayLabels = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") + val bars = if (category1.isNumeric) { + val byDay = mutableMapOf>() + for (log in logs) { + val dow = LocalDate.parse(log.date).dayOfWeek + val value = repository.getValuesForLog(log.id).firstOrNull()?.toFloatOrNull() ?: continue + byDay.getOrPut(dow) { mutableListOf() }.add(value) + } + dayOrder.zip(dayLabels).map { (dow, label) -> + val values = byDay[dow] ?: emptyList() + WeekdayBar(label, values.size, if (values.isEmpty()) null else values.average().toFloat()) + } + } else { + val byDay = logs.groupBy { LocalDate.parse(it.date).dayOfWeek } + dayOrder.zip(dayLabels).map { (dow, label) -> + WeekdayBar(label, byDay[dow]?.size ?: 0, null) + } + } + if (bars.all { it.count == 0 }) StatsChartData.Empty + else StatsChartData.WeekdayData(bars, category1.name, category1.isNumeric) + } + } + + ChartType.TIME_OF_DAY -> { + val timeFmt = DateTimeFormatter.ofPattern("HH:mm") + val logs = repository.getLogsForCategoryInRange(category1.id, start, end) + .filter { it.loggedAt.isNotEmpty() } + if (logs.isEmpty()) { + StatsChartData.Empty + } else { + val bars = if (category1.isNumeric) { + val byBucket = mutableMapOf>() + for (log in logs) { + val bucket = runCatching { LocalTime.parse(log.loggedAt, timeFmt).hour / 2 }.getOrNull() ?: continue + val value = repository.getValuesForLog(log.id).firstOrNull()?.toFloatOrNull() ?: continue + byBucket.getOrPut(bucket) { mutableListOf() }.add(value) + } + (0 until 12).map { b -> + val values = byBucket[b] ?: emptyList() + TimeOfDayBar(chartHourBucketLabel(b), values.size, if (values.isEmpty()) null else values.average().toFloat()) + } + } else { + val counts = IntArray(12) + for (log in logs) { + val bucket = runCatching { LocalTime.parse(log.loggedAt, timeFmt).hour / 2 }.getOrNull() ?: continue + counts[bucket]++ + } + (0 until 12).map { b -> TimeOfDayBar(chartHourBucketLabel(b), counts[b], null) } + } + if (bars.all { it.count == 0 }) StatsChartData.Empty + else StatsChartData.TimeOfDayData(bars, category1.name, category1.isNumeric) + } + } + + ChartType.TIME_CORRELATION -> { + if (category2 == null) { + StatsChartData.Empty + } else { + val timeFmt = DateTimeFormatter.ofPattern("HH:mm") + val dateFmt = DateTimeFormatter.ofPattern("d MMM") + fun logsToPoints(logs: List): List = + logs.filter { it.loggedAt.isNotEmpty() }.mapNotNull { log -> + val hour = runCatching { + val t = LocalTime.parse(log.loggedAt, timeFmt) + t.hour + t.minute / 60f + }.getOrNull() ?: return@mapNotNull null + val date = LocalDate.parse(log.date) + TimeLogPoint( + dayOffset = ChronoUnit.DAYS.between(start, date).toInt(), + hourFraction = hour, + dateLabel = date.format(dateFmt), + ) + } + val points1 = logsToPoints(repository.getLogsForCategoryInRange(category1.id, start, end)) + val points2 = logsToPoints(repository.getLogsForCategoryInRange(category2.id, start, end)) + if (points1.isEmpty() && points2.isEmpty()) { + StatsChartData.Empty + } else { + StatsChartData.TimeCorrelationData( + points1 = points1, + points2 = points2, + cat1Name = category1.name, + cat2Name = category2.name, + colorToken1 = category1.colorToken, + colorToken2 = category2.colorToken, + totalDays = ChronoUnit.DAYS.between(start, end).toInt().coerceAtLeast(1), + ) + } + } + } + } +} + +private fun chartHourBucketLabel(bucket: Int): String { + val h = bucket * 2 + return when { + h == 0 -> "12am" + h == 12 -> "12pm" + h < 12 -> "${h}am" + else -> "${h - 12}pm" } }