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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ 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.47.2-beta.3] - 2026-06-10

### Fixed
- One-time data fixup: existing period entries with overlapping date ranges (e.g. duplicates created by the bug fixed in 0.47.2-beta.2) are automatically merged into a single entry on next app launch, combining notes and symptoms.

---
## [0.47.2-beta.2] - 2026-06-10

### Fixed
- Logging a period for a date that already falls within an existing (or ongoing) period now opens that period for editing instead of creating a new, overlapping entry. Previously, tapping "Log Period" for a date covered by an ongoing period created a duplicate period record.

---
## [0.47.2-beta.1] - 2026-06-10

Expand Down
3 changes: 3 additions & 0 deletions LESSONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ A prediction window (e.g. a 5-day expected period) should remain visible as long
**Don't double-count with offset when SQLite immediately reflects inserts in aggregate queries**
In a Room migration loop that inserts rows one-by-one, calling `MAX(displayOrder)+1` in a subquery correctly reflects all previously-inserted rows in the same transaction — SQLite is not a snapshot. Adding a separate `offset` counter on top of that result double-counts and produces gaps (e.g., displayOrder 7, 9, 11 instead of 7, 8, 9). Remove the offset variable and let the `MAX+1` subquery self-increment across the loop.

**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.


**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
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 = 102
versionName = "0.47.2-beta.1"
versionCode = 104
versionName = "0.47.2-beta.3"
}

signingConfigs {
Expand Down
21 changes: 21 additions & 0 deletions app/src/main/java/com/mapgie/goflo/GoFloApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import com.mapgie.goflo.data.repository.CustomAlarmRepository
import com.mapgie.goflo.data.repository.PeriodRepository
import com.mapgie.goflo.data.repository.TrackingRepository
import com.mapgie.goflo.notifications.ReminderScheduler
import com.mapgie.goflo.widget.GoFloWidget
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
Expand Down Expand Up @@ -48,6 +49,7 @@ class GoFloApplication : Application() {
})
appScope.launch { runFlowBackfillIfNeeded() }
appScope.launch { runSymptomsBackfillIfNeeded() }
appScope.launch { runPeriodOverlapMergeIfNeeded() }
}

/**
Expand Down Expand Up @@ -115,4 +117,23 @@ class GoFloApplication : Application() {

preferencesStore.setSymptomsBackfillDone(true)
}

/**
* One-time data fixup: merges period entries whose date ranges overlap
* (e.g. a new period logged for a date already covered by an ongoing period
* before the entry-point fix existed) into a single entry.
*
* Only runs once — guarded by the [periodOverlapMergeDone] preference flag.
*/
private suspend fun runPeriodOverlapMergeIfNeeded() {
val prefs = preferencesStore.preferences.first()
if (prefs.periodOverlapMergeDone) return

val mergedCount = repository.mergeOverlappingPeriods()
if (mergedCount > 0) {
GoFloWidget.updateAllWidgets(this)
}

preferencesStore.setPeriodOverlapMergeDone(true)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ data class AppPreferences(
val flowBackfillDone: Boolean = false,
/** True once the one-time migration of period symptoms into TrackingLog has been completed. */
val symptomsBackfillDone: Boolean = false,
/** True once the one-time merge of overlapping/duplicate period entries has been completed. */
val periodOverlapMergeDone: 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 @@ -150,6 +152,7 @@ class AppPreferencesStore(private val context: Context) {
val BANNER_STYLE = stringPreferencesKey("banner_style")
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 WIDGET_DATA_VISIBLE = booleanPreferencesKey("widget_data_visible")
val DASHBOARD_ENABLED = booleanPreferencesKey("dashboard_enabled")
val PINNED_STATS = stringPreferencesKey("pinned_stats")
Expand Down Expand Up @@ -192,6 +195,7 @@ class AppPreferencesStore(private val context: Context) {
bannerStyle = prefs[Keys.BANNER_STYLE] ?: "PLAIN",
flowBackfillDone = prefs[Keys.FLOW_BACKFILL_DONE] ?: false,
symptomsBackfillDone = prefs[Keys.SYMPTOMS_BACKFILL_DONE] ?: false,
periodOverlapMergeDone = prefs[Keys.PERIOD_OVERLAP_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 @@ -322,6 +326,10 @@ class AppPreferencesStore(private val context: Context) {
context.dataStore.edit { it[Keys.SYMPTOMS_BACKFILL_DONE] = done }
}

suspend fun setPeriodOverlapMergeDone(done: Boolean) {
context.dataStore.edit { it[Keys.PERIOD_OVERLAP_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 @@ -65,6 +65,63 @@ class PeriodRepository(
periodDao.deletePeriod(entry)
}

/**
* One-time data fixup: merges period entries whose date ranges overlap into a
* single entry. An ongoing period (endDate == null) is treated as extending
* through today when checking for overlap.
*
* Periods are processed in start-date order. When a period's start date falls
* within the running merged range, it is folded into that range (notes are
* concatenated, symptoms moved over, and the duplicate entry deleted).
*
* @return the number of duplicate entries removed.
*/
suspend fun mergeOverlappingPeriods(): Int {
val periods = periodDao.getAllPeriodsOnce()
if (periods.size < 2) return 0

var merged = 0
var current = periods.first()
var currentEnd = current.endDate?.let { LocalDate.parse(it) }

for (next in periods.drop(1)) {
val currentEffectiveEnd = currentEnd ?: LocalDate.now()
val nextStart = LocalDate.parse(next.startDate)

if (!nextStart.isAfter(currentEffectiveEnd)) {
val nextEnd = next.endDate?.let { LocalDate.parse(it) }
currentEnd = when {
currentEnd == null || nextEnd == null -> null
nextEnd.isAfter(currentEnd) -> nextEnd
else -> currentEnd
}

val currentSymptoms = symptomDao.getSymptomsForPeriodOnce(current.id).map { it.symptomType }.toSet()
symptomDao.getSymptomsForPeriodOnce(next.id)
.map { it.symptomType }
.filter { it.isNotBlank() && it !in currentSymptoms }
.forEach { symptomDao.insertSymptom(SymptomEntry(periodId = current.id, symptomType = it)) }

current = current.copy(endDate = currentEnd?.toString(), notes = mergeNotes(current.notes, next.notes))
periodDao.updatePeriod(current)
periodDao.deletePeriod(next)
merged++
} else {
current = next
currentEnd = next.endDate?.let { LocalDate.parse(it) }
}
}

return merged
}

private fun mergeNotes(a: String, b: String): String = when {
b.isBlank() -> a
a.isBlank() -> b
a.contains(b) -> a
else -> "$a\n$b"
}

// ── Symptom read operations ───────────────────────────────────────────────

/** Returns all symptom labels for a period as a flat set of strings. */
Expand Down Expand Up @@ -289,6 +346,17 @@ class PeriodRepository(
fun activePeriod(periods: List<PeriodEntry>): PeriodEntry? =
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.
*/
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)
}

fun cycleDay(periods: List<PeriodEntry>): Int? {
val last = periods.maxByOrNull { it.startDate } ?: return null
val start = LocalDate.parse(last.startDate)
Expand Down
17 changes: 15 additions & 2 deletions app/src/main/java/com/mapgie/goflo/ui/screens/home/HomeScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.mapgie.goflo.BuildConfig
import com.mapgie.goflo.data.repository.PeriodRepository
import com.mapgie.goflo.ui.components.CalendarGrid
import com.mapgie.goflo.ui.components.DayLogSheet
import com.mapgie.goflo.ui.navigation.Screen
Expand Down Expand Up @@ -110,6 +111,18 @@ fun HomeScreen(

// ── Quick Log helper ──────────────────────────────────────────────────────

// If [date] already falls within an existing period's range (including an
// ongoing period, which extends through today), edit that period instead of
// creating a new, overlapping entry.
fun navigateToLogPeriod(date: LocalDate) {
val existing = PeriodRepository.periodForDate(state.periods, date)
if (existing != null) {
onNavigate(Screen.LogPeriod.withId(existing.id))
} else {
onNavigate(Screen.LogPeriod.newEntryForDate(date))
}
}

fun handleQuickLog(date: LocalDate) {
val id = state.quickLogCategoryId
val cat = state.trackingCategories.firstOrNull { it.id == id }
Expand All @@ -118,7 +131,7 @@ fun HomeScreen(
showLogMenu = true
}
id == -1L ->
onNavigate(Screen.LogPeriod.newEntryForDate(date))
navigateToLogPeriod(date)
cat?.categoryType == "increment" ->
// Instantly add one for the tapped day; no screen navigation.
viewModel.incrementCategory(id, date)
Expand Down Expand Up @@ -202,7 +215,7 @@ fun HomeScreen(
onLogPeriod = {
showLogMenu = false
logMenuTargetDate = null
onNavigate(Screen.LogPeriod.newEntryForDate(targetDate))
navigateToLogPeriod(targetDate)
},
periodTrackingEnabled = state.periodTrackingEnabled,
categories = state.trackingCategories,
Expand Down
Loading