diff --git a/CHANGELOG.md b/CHANGELOG.md index 01418b6..68c5e38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,20 @@ 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 + +### 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..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 = 97 - versionName = "0.45.0-beta.1" + versionCode = 99 + versionName = "0.46.0-beta.1" } signingConfigs { 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/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, ) } } 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" } } 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(