From d7d5a4180a636565d927a3b6731f7227b0671fab Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 01:04:22 +0000 Subject: [PATCH 1/2] Fix period logging fragmentation and add merge action in History periodForDate only matched dates within an existing period's stored range, treating an ongoing period as extending through today's wall-clock date rather than indefinitely, and had no adjacency check for the day right after an explicit end date. Logging consecutive days (especially after closing a period with an explicit end date instead of leaving it ongoing, or reopening the app on a later day) could silently create a new, disconnected period instead of continuing the existing one. - periodForDate now treats ongoing periods as unbounded, and matches the day immediately after an explicit end date so consecutive-day logging continues the period instead of fragmenting it. - LogPeriodViewModel auto-extends the end date to the tapped date when continuing an adjacent period, so a single Save commits the extension. - Added a manual merge action to History (period card overflow menu) so already-fragmented periods can be joined back together, combining notes and symptoms. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QezQYsNLctrCF2mST3Z2Lf --- LESSONS.md | 3 + .../goflo/data/repository/PeriodRepository.kt | 56 ++++++++++- .../com/mapgie/goflo/ui/navigation/Screen.kt | 3 +- .../goflo/ui/screens/history/HistoryScreen.kt | 97 +++++++++++++++++-- .../ui/screens/history/HistoryViewModel.kt | 19 ++++ .../goflo/ui/screens/home/HomeScreen.kt | 2 +- .../ui/screens/log/LogPeriodViewModel.kt | 13 ++- .../data/repository/PeriodRepositoryTest.kt | 45 +++++++++ .../period-logging-fragmentation.json | 10 ++ 9 files changed, 233 insertions(+), 15 deletions(-) create mode 100644 changelog/unreleased/period-logging-fragmentation.json diff --git a/LESSONS.md b/LESSONS.md index 2e3275a..3bd003c 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -100,6 +100,9 @@ In a Room migration loop that inserts rows one-by-one, calling `MAX(displayOrder **Date-range entities with an "ongoing" (null end) state need an existence check before quick-log entry points create a new record** When an entity represents a date range and a null end date means "ongoing, extends through today" (e.g. an active period), any UI shortcut that creates a *new* record for a tapped date (calendar tap, FAB, speed dial) must first check whether that date already falls within an existing record's range. Otherwise the user ends up with two overlapping "ongoing" records for what they consider a single continuous span. Add a shared `recordForDate(records, date)` helper that treats a null end as `today`, and route quick-log entry points through it: if a match exists, navigate to edit it; otherwise create new. +**"Ongoing" should mean unbounded, not "through today" — and adjacency needs its own check, separate from containment** +A `recordForDate` helper that treats a null end date as `LocalDate.now()` only works while "today" is on or after the date being checked, and only reads clean while a session stays open past midnight without new writes. Reopen the app on a later date, or check a future date, and the same ongoing record silently stops matching — a fresh "new record" branch fires instead of "extend existing," fragmenting what the user considers one continuous span. Treat the ongoing case as having no upper bound at all (any date `>= start` matches) instead of substituting `now()`. Separately, containment (`start <= date <= end`) only catches dates *inside* a closed record's range — it does nothing for the day immediately *after* a closed record's end, which is the single most common way these entities fragment in practice (a user who closes a date-range record explicitly each day, rather than leaving it open, and logs again the next day). Add `date == end.plusDays(1)` as an explicit adjacency match alongside containment; don't assume containment implies "continues." + **Insert/upsert flags need a separate edit-by-ID path** A flag like `allowMultiple` controls whether saving a log upserts an existing row (keyed by date + category) or always inserts a new one. Neither branch handles "update this specific existing row by ID." Routing an edit through `allowMultiple = false` works only when the existing row is uniquely keyed by the natural key; using `allowMultiple = true` creates a duplicate instead. The correct pattern is a dedicated `updateInPlace(existingLog, …)` method. Callers check `existingLog != null` and take this path directly, bypassing the insert/upsert decision entirely. diff --git a/app/src/main/java/com/mapgie/goflo/data/repository/PeriodRepository.kt b/app/src/main/java/com/mapgie/goflo/data/repository/PeriodRepository.kt index 98ac1bb..e69d85f 100644 --- a/app/src/main/java/com/mapgie/goflo/data/repository/PeriodRepository.kt +++ b/app/src/main/java/com/mapgie/goflo/data/repository/PeriodRepository.kt @@ -120,6 +120,42 @@ class PeriodRepository( return merged } + /** + * Merges [a] and [b] into a single period entry spanning both, combining their + * notes and symptoms. Used for the manual "Merge with…" action in History, so + * a user can join two entries that were split apart (e.g. by a logging bug, or + * by an accidental extra tap) back into one continuous period. + * + * The earlier period (by start date) is kept and updated in place; the later + * period is deleted. The merged result is ongoing only if the later period was + * ongoing — an ongoing period is always the most recent one, so an earlier + * period can never legitimately outlast it. + * + * @return the surviving, merged period entry. + */ + suspend fun mergePeriods(a: PeriodEntry, b: PeriodEntry): PeriodEntry { + val (earlier, later) = if (a.startDate <= b.startDate) a to b else b to a + + val laterEnd = later.endDate?.let { LocalDate.parse(it) } + val earlierEnd = earlier.endDate?.let { LocalDate.parse(it) } + val mergedEnd = when { + later.endDate == null -> null + earlierEnd != null && earlierEnd.isAfter(laterEnd) -> earlierEnd + else -> laterEnd + } + + val earlierSymptoms = symptomDao.getSymptomsForPeriodOnce(earlier.id).map { it.symptomType }.toSet() + symptomDao.getSymptomsForPeriodOnce(later.id) + .map { it.symptomType } + .filter { it.isNotBlank() && it !in earlierSymptoms } + .forEach { symptomDao.insertSymptom(SymptomEntry(periodId = earlier.id, symptomType = it)) } + + val merged = earlier.copy(endDate = mergedEnd?.toString(), notes = mergeNotes(earlier.notes, later.notes)) + periodDao.updatePeriod(merged) + periodDao.deletePeriod(later) + return merged + } + private fun mergeNotes(a: String, b: String): String = when { b.isBlank() -> a a.isBlank() -> b @@ -352,14 +388,26 @@ class PeriodRepository( periods.firstOrNull { it.endDate == null } /** - * Returns the period whose date range covers [date], if any. - * An ongoing period (endDate == null) is treated as extending through today. + * Returns the period that logging [date] should be treated as part of, if any. + * + * An ongoing period (endDate == null) extends indefinitely into the future — + * any date on or after its start is covered, not just "up to today". Using + * today's wall-clock date as the cutoff meant an ongoing period silently + * stopped covering new days once the app was reopened on a later date, + * causing the next tap to create a duplicate period instead of continuing it. + * + * A period with an explicit end date also covers the day immediately after + * that end date, so logging the very next day continues the period instead + * of starting a new, disconnected one (the single most common way period + * entries get fragmented: closing a period each day with an explicit end + * date rather than leaving it ongoing). */ fun periodForDate(periods: List, date: LocalDate): PeriodEntry? = periods.firstOrNull { entry -> val start = LocalDate.parse(entry.startDate) - val end = entry.endDate?.let { LocalDate.parse(it) } ?: LocalDate.now() - !date.isBefore(start) && !date.isAfter(end) + if (date.isBefore(start)) return@firstOrNull false + val end = entry.endDate?.let { LocalDate.parse(it) } ?: return@firstOrNull true + !date.isAfter(end) || date == end.plusDays(1) } fun cycleDay(periods: List): Int? { diff --git a/app/src/main/java/com/mapgie/goflo/ui/navigation/Screen.kt b/app/src/main/java/com/mapgie/goflo/ui/navigation/Screen.kt index 17d7b46..fde49d8 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/navigation/Screen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/navigation/Screen.kt @@ -10,7 +10,8 @@ sealed class Screen(val route: String) { data object StatsGrid : Screen("stats_grid") data object Settings : Screen("settings") data object LogPeriod : Screen("log_period?periodId={periodId}&startDate={startDate}") { - fun withId(periodId: Long) = "log_period?periodId=$periodId" + fun withId(periodId: Long, targetDate: LocalDate? = null) = + if (targetDate != null) "log_period?periodId=$periodId&startDate=$targetDate" else "log_period?periodId=$periodId" val newEntry = "log_period?periodId=-1" fun newEntryForDate(date: LocalDate) = "log_period?periodId=-1&startDate=$date" } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/history/HistoryScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/history/HistoryScreen.kt index 08cdc0c..fb3dfd9 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/history/HistoryScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/history/HistoryScreen.kt @@ -14,14 +14,18 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Snackbar @@ -142,10 +146,16 @@ fun HistoryScreen( } } - items(periods, key = { it.id }) { period -> + // `periods` is DB-ordered by startDate DESC, so list neighbours are + // also chronological neighbours: the item above is more recent, the + // item below is older. + itemsIndexed(periods, key = { _, period -> period.id }) { index, period -> SwipeablePeriodCard( period = period, cycleLength = cycleLengthMap[period.id], + newerNeighbor = periods.getOrNull(index - 1), + olderNeighbor = periods.getOrNull(index + 1), + onMerge = { other -> viewModel.mergePeriods(period, other) }, onDelete = { // Stage the deletion — card disappears from the list immediately. viewModel.stageDeletion(period) @@ -199,6 +209,9 @@ fun HistoryScreen( private fun SwipeablePeriodCard( period: PeriodEntry, cycleLength: Int?, + newerNeighbor: PeriodEntry?, + olderNeighbor: PeriodEntry?, + onMerge: (PeriodEntry) -> Unit, onDelete: () -> Unit, onClick: () -> Unit, modifier: Modifier = Modifier, @@ -265,17 +278,58 @@ private fun SwipeablePeriodCard( } }, ) { - PeriodCard(period = period, cycleLength = cycleLength, onClick = onClick) + PeriodCard( + period = period, + cycleLength = cycleLength, + newerNeighbor = newerNeighbor, + olderNeighbor = olderNeighbor, + onMerge = onMerge, + onClick = onClick, + ) } } // ── Period card ─────────────────────────────────────────────────────────────── +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun PeriodCard(period: PeriodEntry, cycleLength: Int? = null, onClick: () -> Unit) { +private fun PeriodCard( + period: PeriodEntry, + cycleLength: Int? = null, + newerNeighbor: PeriodEntry? = null, + olderNeighbor: PeriodEntry? = null, + onMerge: (PeriodEntry) -> Unit = {}, + onClick: () -> Unit, +) { val start = LocalDate.parse(period.startDate) val end = period.endDate?.let { LocalDate.parse(it) } val duration = if (end != null) "${ChronoUnit.DAYS.between(start, end) + 1} days" else "Ongoing" + + var showMenu by remember { mutableStateOf(false) } + var mergeTarget by remember { mutableStateOf(null) } + + mergeTarget?.let { target -> + val targetStart = LocalDate.parse(target.startDate) + val (earlier, later) = if (start <= targetStart) start to targetStart else targetStart to start + AlertDialog( + onDismissRequest = { mergeTarget = null }, + title = { Text("Merge periods?") }, + text = { + Text( + "This will combine them into one period from ${earlier.format(displayFormat)} " + + "to ${later.format(displayFormat)}, keeping both entries' notes and symptoms. " + + "This can't be undone." + ) + }, + confirmButton = { + TextButton(onClick = { mergeTarget = null; onMerge(target) }) { Text("Merge") } + }, + dismissButton = { + TextButton(onClick = { mergeTarget = null }) { Text("Cancel") } + } + ) + } + Card( modifier = Modifier .fillMaxWidth() @@ -285,10 +339,37 @@ private fun PeriodCard(period: PeriodEntry, cycleLength: Int? = null, onClick: ( elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text( - text = start.format(displayFormat), - style = MaterialTheme.typography.titleMedium - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = start.format(displayFormat), + style = MaterialTheme.typography.titleMedium + ) + if (newerNeighbor != null || olderNeighbor != null) { + Box { + IconButton(onClick = { showMenu = true }) { + Icon(Icons.Default.MoreVert, contentDescription = "More options") + } + DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { + newerNeighbor?.let { neighbor -> + DropdownMenuItem( + text = { Text("Merge with ${LocalDate.parse(neighbor.startDate).format(displayFormat)} period") }, + onClick = { showMenu = false; mergeTarget = neighbor } + ) + } + olderNeighbor?.let { neighbor -> + DropdownMenuItem( + text = { Text("Merge with ${LocalDate.parse(neighbor.startDate).format(displayFormat)} period") }, + onClick = { showMenu = false; mergeTarget = neighbor } + ) + } + } + } + } + } Text( text = if (end != null) "Until ${end.format(displayFormat)} · $duration" else "Ongoing", style = MaterialTheme.typography.bodyMedium, diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/history/HistoryViewModel.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/history/HistoryViewModel.kt index 406acef..63c76ca 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/history/HistoryViewModel.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/history/HistoryViewModel.kt @@ -103,6 +103,25 @@ class HistoryViewModel( _pendingDeleteIds.update { it - period.id } } + /** + * Merges [first] and [second] into a single continuous period, combining their + * notes and symptoms. The tracking-log entries (flow, symptoms, pinned + * categories) belonging to whichever period is absorbed are also removed, + * mirroring the cleanup performed on delete — otherwise its flow/symptom + * entry would linger, orphaned, under a date no longer tied to any period. + */ + fun mergePeriods(first: PeriodEntry, second: PeriodEntry) { + viewModelScope.launch { + val absorbed = if (first.startDate <= second.startDate) second else first + repository.mergePeriods(first, second) + trackingRepository?.deleteLogsForPeriod( + LocalDate.parse(absorbed.startDate), + absorbed.endDate?.let { LocalDate.parse(it) } + ) + application?.let { GoFloWidget.updateAllWidgets(it) } + } + } + class Factory( private val repository: PeriodRepository, private val application: Application? = null, diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/home/HomeScreen.kt index 4b38753..330fe13 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/home/HomeScreen.kt @@ -117,7 +117,7 @@ fun HomeScreen( fun navigateToLogPeriod(date: LocalDate) { val existing = PeriodRepository.periodForDate(state.periods, date) if (existing != null) { - onNavigate(Screen.LogPeriod.withId(existing.id)) + onNavigate(Screen.LogPeriod.withId(existing.id, date)) } else { onNavigate(Screen.LogPeriod.newEntryForDate(date)) } 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 e7860c3..0c821b6 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 @@ -79,13 +79,24 @@ class LogPeriodViewModel( viewModelScope.launch { val period = repository.getPeriodById(periodId).first() if (period != null) { + val storedEnd = period.endDate?.let { d -> LocalDate.parse(d) } + // If this screen was opened for a date that continues the period + // (the day right after its stored end date) rather than a date + // already inside its range, extend the end date to that day so + // saving naturally continues the period instead of leaving the + // new day unaccounted for. + val effectiveEnd = if (storedEnd != null && prefilledDate != null && prefilledDate.isAfter(storedEnd)) { + prefilledDate + } else { + storedEnd + } _uiState.update { it.copy( isLoading = false, isEditing = true, existingId = period.id, startDate = LocalDate.parse(period.startDate), - endDate = period.endDate?.let { d -> LocalDate.parse(d) }, + endDate = effectiveEnd, notes = period.notes ) } diff --git a/app/src/test/java/com/mapgie/goflo/data/repository/PeriodRepositoryTest.kt b/app/src/test/java/com/mapgie/goflo/data/repository/PeriodRepositoryTest.kt index ea17ac3..f30a5b8 100644 --- a/app/src/test/java/com/mapgie/goflo/data/repository/PeriodRepositoryTest.kt +++ b/app/src/test/java/com/mapgie/goflo/data/repository/PeriodRepositoryTest.kt @@ -132,4 +132,49 @@ class PeriodRepositoryTest { fun `activePeriod returns null for empty list`() { assertNull(PeriodRepository.activePeriod(emptyList())) } + + // ── periodForDate ───────────────────────────────────────────────────────── + + @Test + fun `periodForDate matches a date inside a closed range`() { + val period = entry("2024-01-01", "2024-01-05", id = 1) + assertEquals(period, PeriodRepository.periodForDate(listOf(period), LocalDate.of(2024, 1, 3))) + } + + @Test + fun `periodForDate does not match a date before the start`() { + val period = entry("2024-01-01", "2024-01-05", id = 1) + assertNull(PeriodRepository.periodForDate(listOf(period), LocalDate.of(2023, 12, 31))) + } + + @Test + fun `periodForDate matches any future date for an ongoing period, not just up to today`() { + // Regression: matching used to fall back to LocalDate.now(), so an ongoing + // period silently stopped covering new days once "today" moved past the + // last check — reopening the app on a later date created a duplicate + // period instead of continuing the ongoing one. + val ongoing = entry("2024-01-01", null, id = 1) + assertEquals(ongoing, PeriodRepository.periodForDate(listOf(ongoing), LocalDate.of(2030, 6, 15))) + } + + @Test + fun `periodForDate matches the day immediately after an explicit end date`() { + // Regression: logging the day right after a period was closed (given an + // explicit end date rather than left ongoing) used to fall outside the + // range entirely and create a brand new, disconnected period. + val period = entry("2024-06-28", "2024-06-28", id = 1) + assertEquals(period, PeriodRepository.periodForDate(listOf(period), LocalDate.of(2024, 6, 29))) + } + + @Test + fun `periodForDate does not match a date more than one day after an explicit end date`() { + val period = entry("2024-06-28", "2024-06-28", id = 1) + assertNull(PeriodRepository.periodForDate(listOf(period), LocalDate.of(2024, 6, 30))) + } + + @Test + fun `periodForDate returns null when no period matches`() { + val periods = listOf(entry("2024-01-01", "2024-01-05", id = 1)) + assertNull(PeriodRepository.periodForDate(periods, LocalDate.of(2024, 1, 10))) + } } diff --git a/changelog/unreleased/period-logging-fragmentation.json b/changelog/unreleased/period-logging-fragmentation.json new file mode 100644 index 0000000..788bb7c --- /dev/null +++ b/changelog/unreleased/period-logging-fragmentation.json @@ -0,0 +1,10 @@ +{ + "bump": "minor", + "added": [ + "History: merge two periods into one from a period's overflow menu, combining their notes and symptoms" + ], + "fixed": [ + "Logging consecutive days no longer splits a period into disconnected entries when the previous day was saved with an explicit end date instead of left ongoing", + "An ongoing period now continues to cover new days after reopening the app on a later date, instead of only through the day it was last checked" + ] +} From f2f1849e9ca4227ef63afc8d0ddab0563b80d239 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 01:10:55 +0000 Subject: [PATCH 2/2] Add one-time migration to auto-repair periods split by the day-after-close bug Users who already hit the periodForDate adjacency bug are left with fragmented periods on disk; the entry-point fix alone doesn't touch existing data. Adds PeriodRepository.mergeAdjacentPeriods(), a one-time backfill (same pattern as the existing overlap-merge migration) that rejoins periods sitting exactly one day apart, then cleans up the absorbed period's tracking-log entries. Sequenced after the overlap merge, since resolving an overlap can make a period newly adjacent to a third one. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QezQYsNLctrCF2mST3Z2Lf --- .../java/com/mapgie/goflo/GoFloApplication.kt | 40 ++++++++++++++++++- .../data/preferences/ReminderPreferences.kt | 8 ++++ .../goflo/data/repository/PeriodRepository.kt | 38 ++++++++++++++++++ .../period-logging-fragmentation.json | 3 +- 4 files changed, 87 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/mapgie/goflo/GoFloApplication.kt b/app/src/main/java/com/mapgie/goflo/GoFloApplication.kt index 5d70078..aa8286c 100644 --- a/app/src/main/java/com/mapgie/goflo/GoFloApplication.kt +++ b/app/src/main/java/com/mapgie/goflo/GoFloApplication.kt @@ -60,8 +60,16 @@ class GoFloApplication : Application() { }) appScope.launch { runFlowBackfillIfNeeded() } appScope.launch { runSymptomsBackfillIfNeeded() } - appScope.launch { runPeriodOverlapMergeIfNeeded() } appScope.launch { runFlowLevelRestoreIfNeeded() } + // Sequenced (not launched concurrently): the adjacency merge must see the + // period list *after* the overlap merge has resolved, since consolidating + // an overlap can change a period's end date such that it newly becomes + // adjacent to a third period. Running both against a live table at once + // would also race on the same rows. + appScope.launch { + runPeriodOverlapMergeIfNeeded() + runPeriodAdjacencyMergeIfNeeded() + } } /** @@ -186,4 +194,34 @@ class GoFloApplication : Application() { preferencesStore.setPeriodOverlapMergeDone(true) } + + /** + * One-time data fixup: merges period entries that sit exactly one day apart + * (e.g. a period closed with an explicit end date, then a new period logged + * for the very next day, before the entry-point fix existed) into a single + * entry. + * + * Each absorbed period's own tracking-log entries (flow, symptoms, pinned + * categories) are removed too, mirroring the cleanup performed on delete — + * otherwise they'd linger, orphaned, under a date no longer tied to any period. + * + * Only runs once — guarded by the [periodAdjacencyMergeDone] preference flag. + */ + private suspend fun runPeriodAdjacencyMergeIfNeeded() { + val prefs = preferencesStore.preferences.first() + if (prefs.periodAdjacencyMergeDone) return + + val absorbed = repository.mergeAdjacentPeriods() + for (period in absorbed) { + trackingRepository.deleteLogsForPeriod( + LocalDate.parse(period.startDate), + period.endDate?.let { LocalDate.parse(it) } + ) + } + if (absorbed.isNotEmpty()) { + GoFloWidget.updateAllWidgets(this) + } + + preferencesStore.setPeriodAdjacencyMergeDone(true) + } } diff --git a/app/src/main/java/com/mapgie/goflo/data/preferences/ReminderPreferences.kt b/app/src/main/java/com/mapgie/goflo/data/preferences/ReminderPreferences.kt index 3560006..d72801d 100644 --- a/app/src/main/java/com/mapgie/goflo/data/preferences/ReminderPreferences.kt +++ b/app/src/main/java/com/mapgie/goflo/data/preferences/ReminderPreferences.kt @@ -67,6 +67,8 @@ data class AppPreferences( val symptomsBackfillDone: Boolean = false, /** True once the one-time merge of overlapping/duplicate period entries has been completed. */ val periodOverlapMergeDone: Boolean = false, + /** True once the one-time merge of adjacent (1-day-gap) period entries has been completed. */ + val periodAdjacencyMergeDone: Boolean = false, /** * When true, the GoFlo Status home-screen widget shows live cycle data even * if a PIN is set. Users who trust their home screen can opt in; the default @@ -166,6 +168,7 @@ class AppPreferencesStore(private val context: Context) { val FLOW_BACKFILL_DONE = booleanPreferencesKey("flow_backfill_done") val SYMPTOMS_BACKFILL_DONE = booleanPreferencesKey("symptoms_backfill_done") val PERIOD_OVERLAP_MERGE_DONE = booleanPreferencesKey("period_overlap_merge_done") + val PERIOD_ADJACENCY_MERGE_DONE = booleanPreferencesKey("period_adjacency_merge_done") val WIDGET_DATA_VISIBLE = booleanPreferencesKey("widget_data_visible") val DASHBOARD_ENABLED = booleanPreferencesKey("dashboard_enabled") val PINNED_STATS = stringPreferencesKey("pinned_stats") @@ -212,6 +215,7 @@ class AppPreferencesStore(private val context: Context) { flowBackfillDone = prefs[Keys.FLOW_BACKFILL_DONE] ?: false, symptomsBackfillDone = prefs[Keys.SYMPTOMS_BACKFILL_DONE] ?: false, periodOverlapMergeDone = prefs[Keys.PERIOD_OVERLAP_MERGE_DONE] ?: false, + periodAdjacencyMergeDone = prefs[Keys.PERIOD_ADJACENCY_MERGE_DONE] ?: false, widgetDataVisible = prefs[Keys.WIDGET_DATA_VISIBLE] ?: false, dashboardEnabled = prefs[Keys.DASHBOARD_ENABLED] ?: false, pinnedStats = prefs[Keys.PINNED_STATS] ?: "", @@ -349,6 +353,10 @@ class AppPreferencesStore(private val context: Context) { context.dataStore.edit { it[Keys.PERIOD_OVERLAP_MERGE_DONE] = done } } + suspend fun setPeriodAdjacencyMergeDone(done: Boolean) { + context.dataStore.edit { it[Keys.PERIOD_ADJACENCY_MERGE_DONE] = done } + } + suspend fun setWidgetDataVisible(visible: Boolean) { context.dataStore.edit { it[Keys.WIDGET_DATA_VISIBLE] = visible } } diff --git a/app/src/main/java/com/mapgie/goflo/data/repository/PeriodRepository.kt b/app/src/main/java/com/mapgie/goflo/data/repository/PeriodRepository.kt index e69d85f..0e6c2b5 100644 --- a/app/src/main/java/com/mapgie/goflo/data/repository/PeriodRepository.kt +++ b/app/src/main/java/com/mapgie/goflo/data/repository/PeriodRepository.kt @@ -156,6 +156,44 @@ class PeriodRepository( return merged } + /** + * One-time data fixup: merges period entries that are adjacent — the day right + * after one period's explicit end date is exactly the start of the next — into + * a single entry. + * + * This repairs periods fragmented by the entry-point bug where logging a day + * right after a period was closed with an explicit end date (rather than left + * ongoing) created a new, disconnected period instead of continuing the + * existing one. Only a one-day gap is merged; larger gaps are left alone since + * they represent a genuinely new cycle, not a fragmented one. + * + * Periods are processed in start-date order, reusing [mergePeriods] for each + * adjacent pair found. + * + * @return the periods that were absorbed (deleted) by a merge, so callers can + * clean up their tracking-log entries. + */ + suspend fun mergeAdjacentPeriods(): List { + val periods = periodDao.getAllPeriodsOnce() + if (periods.size < 2) return emptyList() + + val absorbed = mutableListOf() + var current = periods.first() + + for (next in periods.drop(1)) { + val currentEnd = current.endDate?.let { LocalDate.parse(it) } + val nextStart = LocalDate.parse(next.startDate) + if (currentEnd != null && nextStart == currentEnd.plusDays(1)) { + absorbed.add(next) + current = mergePeriods(current, next) + } else { + current = next + } + } + + return absorbed + } + private fun mergeNotes(a: String, b: String): String = when { b.isBlank() -> a a.isBlank() -> b diff --git a/changelog/unreleased/period-logging-fragmentation.json b/changelog/unreleased/period-logging-fragmentation.json index 788bb7c..edd6aae 100644 --- a/changelog/unreleased/period-logging-fragmentation.json +++ b/changelog/unreleased/period-logging-fragmentation.json @@ -5,6 +5,7 @@ ], "fixed": [ "Logging consecutive days no longer splits a period into disconnected entries when the previous day was saved with an explicit end date instead of left ongoing", - "An ongoing period now continues to cover new days after reopening the app on a later date, instead of only through the day it was last checked" + "An ongoing period now continues to cover new days after reopening the app on a later date, instead of only through the day it was last checked", + "One-time repair on app start automatically rejoins periods that were already split apart by the day-after-close bug" ] }