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
3 changes: 3 additions & 0 deletions LESSONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 39 additions & 1 deletion app/src/main/java/com/mapgie/goflo/GoFloApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

/**
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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] ?: "",
Expand Down Expand Up @@ -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 }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,80 @@ 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
}

/**
* 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<PeriodEntry> {
val periods = periodDao.getAllPeriodsOnce()
if (periods.size < 2) return emptyList()

val absorbed = mutableListOf<PeriodEntry>()
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
Expand Down Expand Up @@ -352,14 +426,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<PeriodEntry>, 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<PeriodEntry>): Int? {
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/java/com/mapgie/goflo/ui/navigation/Screen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<PeriodEntry?>(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()
Expand All @@ -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,
Expand Down
Loading
Loading