Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions LESSONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<DayOfWeek, MutableList<Float>>()
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<Int, MutableList<Float>>()
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<TrackingLog>): List<TimeLogPoint> =
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"
}
}

Expand Down
Loading
Loading