From 5d079fe6321d5447a159f829a33eb3ec72f6fa01 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 01:15:30 +0000 Subject: [PATCH 1/3] Untangle the day log: focused single-category logging, period switch, plain headers Five confusions on the unified day screen, plus the bug behind one of them: - Opening the screen for one category (speed dial, widget, or a day-sheet entry) now shows only that category's input as the hero, with a "Log more for this day" button that expands to the whole day. The focus follows a re-file and survives a day switch. - The "Period started today" footer was a chevron row that acted as a toggle. It is now a SwitchRow inside the card, staying in place while the start is pending so toggling it off is the undo. Its subtitle says what saving will do (start, continue, or move the start back). - The Day section duplicated the title's date. It is removed; tapping the title opens the date picker directly, and the title sheet's category "jump" (which only expanded a grouped row) goes with it. - Metric headers looked like collapsible headers but opened the re-file sheet. They are plain SectionHeaders now; re-filing lives in a "Move to another category" text button that appears once a value has been entered, since only unsaved values can be re-filed. - Saving a period day just before an episode's start silently dropped it: logPeriodDay moved the start back, then updateEpisode was handed the start loaded before that and trimmed the new day away. The save now passes min(loaded start, day), and tapping the switch moves the displayed start (and day number) so the screen shows the boundary the save will produce. Regression test covers the sequence. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ELZpYZSRB1DMaJiXvkcVSD --- LESSONS.md | 3 + .../mapgie/goflo/ui/screens/log/LogScreen.kt | 308 +++++++----------- .../goflo/ui/screens/log/LogViewModel.kt | 72 +++- .../data/repository/PeriodRepositoryTest.kt | 25 ++ .../unreleased/logging-ui-confusions.json | 14 + .../logging-redesign/handover/README.md | 5 +- 6 files changed, 220 insertions(+), 207 deletions(-) create mode 100644 changelog/unreleased/logging-ui-confusions.json diff --git a/LESSONS.md b/LESSONS.md index 815790d..4d3aaf8 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -112,6 +112,9 @@ When a form has section labels ("Flow", "Symptoms") and entered values ("Medium" **Room migrations can be tested on the JVM: real SQLite via sqlite-jdbc + a reflection proxy for `SupportSQLiteDatabase`** Room's `MigrationTestHelper` needs instrumented tests *and* exported schema JSON (`exportSchema = true`); a project with neither can still test migrations properly. Build the pre-migration schema by hand in an in-memory database (`org.xerial:sqlite-jdbc`, test-only dependency), seed representative data, then run the actual `Migration` object through a `java.lang.reflect.Proxy` implementing `SupportSQLiteDatabase` that routes `execSQL` to JDBC and throws for anything else — migrations that only `execSQL` need nothing more, and the proxy compiles regardless of the interface's exact member list (hand-implementing the ~35-member interface risks a CI-only compile break). Assert the post-migration schema with `PRAGMA table_info` against the exact shape Room generates for the entity — including `DEFAULT` clauses, which must match the entity's `@ColumnInfo(defaultValue=…)` annotations or Room throws `IllegalStateException` at first open on device. This exercises the real migration SQL on a real SQLite engine in a plain unit test. +**A save that composes "absorb, then edit boundaries" must feed the edit the post-absorb boundary, not the one loaded before** +The day screen saved a period day as `logPeriodDay(day)` (which re-derives episodes, so a day just before an episode moves its start back) followed by `updateEpisode(id, start = loadedStart, …)` (which trims day rows outside `start..end`). For any day before the loaded start, the second call silently deleted the row the first had just added: the UI said "continues the period", the save reported success, and nothing changed. When one step can move a boundary and a later step re-asserts a boundary captured before that step, derive the re-asserted value from both (`minOf(loadedStart, day)`) and show that derived boundary in the UI before saving, so the screen never promises a shape the save will undo. + **Gate prediction display on window end, not window start** A prediction window (e.g. a 5-day expected period) should remain visible as long as any part of the window is current — gate on `windowEnd >= today`, not `windowStart >= today`. Gating on the start collapses the display to zero the moment the window begins, which is precisely when it matters most. Apply the same principle to any "active range" feature: fertility windows, ovulation windows, reminders that span multiple days. diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogScreen.kt index 1a209ab..0bfd077 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogScreen.kt @@ -82,6 +82,7 @@ import com.mapgie.goflo.ui.components.MetricValue import com.mapgie.goflo.ui.components.PrimarySaveBar import com.mapgie.goflo.ui.components.SectionHeader import com.mapgie.goflo.ui.components.SelectableChip +import com.mapgie.goflo.ui.components.SwitchRow import com.mapgie.goflo.ui.components.ToneHero import com.mapgie.goflo.ui.components.roleContainerTint import com.mapgie.goflo.ui.components.usesStepScale @@ -95,20 +96,19 @@ import java.time.format.DateTimeFormatter private val displayFormat = DateTimeFormatter.ofPattern("MMM d, yyyy") -// Sentinels for the switch sheet: closed / opened from the title (jump) / -// opened from a metric header (re-file, value = source category id). -private const val SHEET_CLOSED = 0L -private const val SHEET_JUMP = -1L - /** * The unified day screen: one screen logs a day, and a running period is a * state of that day rather than a separate destination. * * Off-period, the first tracked category leads as a tonal hero and the footer - * is a quiet "Period started today" row. On-period, the Flow group slots in at + * is a "Period started today" switch. On-period, the Flow group slots in at * the top, the lead category compresses into the tracked list, and the footer * becomes a filled status row with an End action. Everything between renders * identically in both states. + * + * Opened for one category (speed dial, widget, a day-sheet entry) the screen + * shows only that category's input until "Log more for this day" expands it + * to the whole day. */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable @@ -130,8 +130,8 @@ fun LogScreen( var showAddSymptomDialog by rememberSaveable { mutableStateOf(false) } var showUnsavedChangesDialog by rememberSaveable { mutableStateOf(false) } var showOverflowMenu by rememberSaveable { mutableStateOf(false) } - /** SHEET_CLOSED, SHEET_JUMP, or the category id a re-file was opened from. */ - var switchSheetMode by rememberSaveable { mutableStateOf(SHEET_CLOSED) } + /** Category id the re-file sheet was opened from, or 0 when closed. */ + var refileSourceId by rememberSaveable { mutableStateOf(0L) } /** Category id awaiting delete-entry confirmation, or 0 when none. */ var pendingDeleteEntryId by rememberSaveable { mutableStateOf(0L) } /** Day picked while unsaved changes exist, awaiting discard confirmation. */ @@ -282,21 +282,16 @@ fun LogScreen( ) } - if (switchSheetMode != SHEET_CLOSED) { - DaySwitchSheet( - refileSourceId = switchSheetMode.takeIf { it > 0L }, + if (refileSourceId != 0L) { + RefileSheet( + sourceId = refileSourceId, state = state, - onPickDay = { - switchSheetMode = SHEET_CLOSED - showDayPicker = true - }, onPickCategory = { categoryId -> - val mode = switchSheetMode - switchSheetMode = SHEET_CLOSED - if (mode > 0L) viewModel.refileEntry(mode, categoryId) - else viewModel.setActiveCategory(categoryId) + val from = refileSourceId + refileSourceId = 0L + viewModel.refileEntry(from, categoryId) }, - onDismiss = { switchSheetMode = SHEET_CLOSED }, + onDismiss = { refileSourceId = 0L }, ) } @@ -307,7 +302,7 @@ fun LogScreen( LogDayTopBar( state = state, onBack = handleBack, - onTitleClick = { switchSheetMode = SHEET_JUMP }, + onTitleClick = { showDayPicker = true }, showOverflowMenu = showOverflowMenu, onOverflowChange = { showOverflowMenu = it }, onDisablePeriodTracking = { @@ -325,6 +320,10 @@ fun LogScreen( return@Scaffold } + val focused = state.focusedCategoryId?.let { id -> + state.categories.firstOrNull { it.id == id } + } + Box(Modifier.fillMaxSize().padding(padding)) { Column( modifier = Modifier @@ -334,7 +333,24 @@ fun LogScreen( .padding(top = 16.dp, bottom = 104.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - DaySection(state, onPickDay = { showDayPicker = true }) + if (focused != null) { + // Opened for one thing: just that thing, and a way out to + // the rest of the day. + CategoryMetricSection( + category = focused, + state = state, + viewModel = viewModel, + hero = true, + onSwitchCategory = { refileSourceId = focused.id }, + onDeleteEntry = { pendingDeleteEntryId = focused.id }, + ) + OutlinedButton( + onClick = viewModel::showFullDay, + modifier = Modifier.fillMaxWidth(), + ) { Text("Log more for this day") } + ErrorText(state.error) + return@Column + } if (state.periodActive) { PeriodDatesSection( @@ -356,7 +372,7 @@ fun LogScreen( category = cat, state = state, viewModel = viewModel, - onSwitchCategory = { switchSheetMode = cat.id }, + onSwitchCategory = { refileSourceId = cat.id }, onDeleteEntry = { pendingDeleteEntryId = cat.id }, ) } @@ -371,7 +387,7 @@ fun LogScreen( state = state, viewModel = viewModel, hero = true, - onSwitchCategory = { switchSheetMode = cat.id }, + onSwitchCategory = { refileSourceId = cat.id }, onDeleteEntry = { pendingDeleteEntryId = cat.id }, ) } @@ -382,7 +398,7 @@ fun LogScreen( state = state, viewModel = viewModel, excludeIds = (pinned.map { it.id } + listOfNotNull(lead?.id)).toSet(), - onSwitchCategory = { switchSheetMode = it }, + onSwitchCategory = { refileSourceId = it }, onDeleteEntry = { pendingDeleteEntryId = it }, ) @@ -426,13 +442,7 @@ fun LogScreen( } } - state.error?.let { - Text( - text = "Error: $it", - color = MaterialTheme.colorScheme.error, - modifier = Modifier.semantics { liveRegion = LiveRegionMode.Assertive }, - ) - } + ErrorText(state.error) } PrimarySaveBar( @@ -448,6 +458,16 @@ fun LogScreen( } } +@Composable +private fun ErrorText(error: String?) { + if (error == null) return + Text( + text = "Error: $error", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Assertive }, + ) +} + // ── Top bar ─────────────────────────────────────────────────────────────────── @OptIn(ExperimentalMaterial3Api::class) @@ -487,7 +507,7 @@ private fun LogDayTopBar( } Icon( imageVector = Icons.Default.ExpandMore, - contentDescription = "Switch day or category", + contentDescription = "Change day", modifier = Modifier.padding(start = 4.dp).size(20.dp), ) } @@ -525,45 +545,7 @@ private fun LogDayTopBar( ) } -// ── Day + period dates ──────────────────────────────────────────────────────── - -@Composable -private fun DaySection(state: LogUiState, onPickDay: () -> Unit) { - SectionHeader(label = "Day") - ListCard { - ListRow( - key = "Date", - value = state.date.format(displayFormat), - valueEmphasis = true, - onClick = onPickDay, - ) - } - // Continuation context changes as the user picks days and toggles the - // period state, so announce it politely to screen readers. - if (state.periodActive) { - val text = when { - state.startPeriodToday && state.continuesEpisodeStart != null -> { - val dayNo = state.episodeDayNumber - if (dayNo != null && dayNo > 1) { - "Day $dayNo of the period started ${state.continuesEpisodeStart.format(displayFormat)}" - } else { - "Continues the period started ${state.continuesEpisodeStart.format(displayFormat)}" - } - } - state.startPeriodToday -> "Starts a new period" - state.episodeDayNumber != null -> "Day ${state.episodeDayNumber} of this period" - else -> null - } - if (text != null) { - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, - ) - } - } -} +// ── Period dates ────────────────────────────────────────────────────────────── @Composable private fun PeriodDatesSection( @@ -833,12 +815,14 @@ private fun GroupCardSection( } /** - * One tracked category's full input surface: header (the category name is a - * button that opens the re-file sheet), the [MetricInput] for its type (or the - * timed-increment timeline), "previously recorded" chips for stored labels no - * longer in the catalog, the track-against-time checkbox, per-entry notes, and - * a delete action when an entry already exists. As the off-period [hero], the - * input nests inside a [ToneHero] that shows the current reading as words. + * One tracked category's full input surface: a plain section header with the + * current reading, the [MetricInput] for its type (or the timed-increment + * timeline), "previously recorded" chips for stored labels no longer in the + * catalog, the track-against-time checkbox, per-entry notes, a "move to + * another category" action once something has been entered (opens the re-file + * sheet), and a delete action when an entry already exists. As the off-period + * [hero], the input nests inside a [ToneHero] that shows the current reading + * as words. */ @Composable private fun CategoryMetricSection( @@ -858,75 +842,22 @@ private fun CategoryMetricSection( val summary = entrySummary(category, entry, config) if (hero) { - MetricHeaderButton( - name = category.name, - value = null, - valueColor = role, - onClick = onSwitchCategory, - ) + SectionHeader(label = category.name) ToneHero( word = summary ?: "Not logged yet", role = role, ) { - MetricSectionBody(category, entry, availableValues, config, role, onRole, viewModel, onDeleteEntry) + MetricSectionBody( + category, entry, availableValues, config, role, onRole, viewModel, + onSwitchCategory, onDeleteEntry, + ) } } else { - MetricHeaderButton( - name = category.name, - value = summary, - valueColor = role, - onClick = onSwitchCategory, + SectionHeader(label = category.name, value = summary, valueColor = role) + MetricSectionBody( + category, entry, availableValues, config, role, onRole, viewModel, + onSwitchCategory, onDeleteEntry, ) - MetricSectionBody(category, entry, availableValues, config, role, onRole, viewModel, onDeleteEntry) - } -} - -/** - * The category-name header row: the name is a button opening the re-file - * sheet ("logged the wrong thing?"), with the current value right-aligned. - */ -@Composable -private fun MetricHeaderButton( - name: String, - value: String?, - valueColor: Color, - onClick: () -> Unit, -) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(8.dp)) - .semantics { this.role = Role.Button } - .clickable(onClick = onClick) - .heightIn(min = 44.dp), - ) { - Text( - text = name.uppercase(), - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 0.11.em, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Icon( - imageVector = Icons.Default.ExpandMore, - contentDescription = "File under another category", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 2.dp).size(16.dp), - ) - } - if (value != null) { - Text( - text = value, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - color = valueColor, - ) - } } } @@ -940,6 +871,7 @@ private fun MetricSectionBody( role: Color, onRole: Color, viewModel: LogViewModel, + onSwitchCategory: () -> Unit, onDeleteEntry: () -> Unit, ) { val type = category.categoryType.toCategoryType() @@ -1048,10 +980,15 @@ private fun MetricSectionBody( }, ) } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { if (entry.notes.isEmpty() && !noteOpen) { TextButton(onClick = { noteOpen = true }) { Text("Add note") } } + // "Logged the wrong thing?": only an unsaved value can be + // re-filed, so the action appears once something is entered. + if (entry.touched) { + TextButton(onClick = onSwitchCategory) { Text("Move to another category") } + } if (entry.existingLog != null) { TextButton( onClick = onDeleteEntry, @@ -1067,8 +1004,10 @@ private fun MetricSectionBody( // ── Period footer ───────────────────────────────────────────────────────────── /** - * Off-period: a quiet hairline row that starts (or continues) a period today. - * On-period: a filled status row naming the period state, with End/Undo. + * Off-period, or while a start is pending: a switch that marks this day as a + * period day (starting, continuing, or extending one), applied on save. + * Once the day is a stored period day: a filled status row naming the period + * state, with End/Undo. */ @Composable private fun PeriodFooter( @@ -1078,41 +1017,47 @@ private fun PeriodFooter( onEndPeriod: () -> Unit, onUndoEnd: () -> Unit, ) { - if (!state.periodActive) { + if (state.startPeriodToday || !state.periodActive) { if (!state.periodTrackingEnabled) return val continues = state.continuesEpisodeStart - ListCard { - ListRow( - key = if (continues != null) "Log as a period day" else "Period started today", - onClick = onStartPeriod, - ) + val pending = state.startPeriodToday + val isToday = state.date == LocalDate.now() + val title = when { + continues != null -> "Log as a period day" + isToday -> "Period started today" + else -> "Period started this day" } - if (continues != null) { - Text( - "Continues the period started ${continues.format(displayFormat)}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + val subtitle = when { + continues != null && state.date.isBefore(continues) -> + "Moves the start of the period from ${continues.format(displayFormat)} to this day" + continues != null -> + "Continues the period started ${continues.format(displayFormat)}" + pending && state.endDate != null -> + "Starts a new period, until ${state.endDate.format(displayFormat)}" + pending -> "Starts a new period. Save to log it." + else -> null + } + ListCard { + SwitchRow( + title = title, + subtitle = subtitle, + checked = pending, + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onCheckedChange = { if (it) onStartPeriod() else onUndoStart() }, ) } return } - val pendingStart = state.startPeriodToday && state.episodeId == null val endedToday = state.endDate != null && state.endDate == state.date && state.loadedEndDate != state.endDate val title = when { - pendingStart -> "Period starts today" - state.startPeriodToday -> "Period day added" endedToday -> "Period ends today" state.endDate == null -> "Period ongoing" else -> "Period recorded" } val since = (state.episodeStart ?: state.date).format(displayFormat) - val subtitle = when { - pendingStart -> state.endDate?.let { "Until ${it.format(displayFormat)}" } ?: "Save to log it" - state.startPeriodToday -> "Continues the period started $since" - else -> "Since $since" - } Surface( modifier = Modifier.fillMaxWidth(), @@ -1138,13 +1083,12 @@ private fun PeriodFooter( color = MaterialTheme.colorScheme.onPrimaryContainer, ) Text( - text = subtitle, + text = "Since $since", fontSize = 11.5.sp, color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), ) } when { - state.startPeriodToday -> TextButton(onClick = onUndoStart) { Text("Undo") } endedToday -> TextButton(onClick = onUndoEnd) { Text("Undo") } state.endDate == null -> TextButton(onClick = onEndPeriod) { Text("End") } } @@ -1152,27 +1096,23 @@ private fun PeriodFooter( } } -// ── Switch sheet ────────────────────────────────────────────────────────────── +// ── Re-file sheet ───────────────────────────────────────────────────────────── /** - * The title/header switcher: every category organised by group and tinted by - * its role, so the colour you're about to log in is visible before you commit. - * - * Opened from the screen title it jumps between sections and offers a day - * change; opened from a metric header ([refileSourceId] set) it re-files the - * entered value under the picked category, keeping the value. + * "Logged the wrong thing?": every category organised by group and tinted by + * its role. Picking one re-files the value entered under [sourceId] under the + * picked category, keeping the value. */ @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun DaySwitchSheet( - refileSourceId: Long?, +private fun RefileSheet( + sourceId: Long, state: LogUiState, - onPickDay: () -> Unit, onPickCategory: (Long) -> Unit, onDismiss: () -> Unit, ) { val sheetState = rememberModalBottomSheetState() - val selectedId = refileSourceId ?: state.activeCategoryId + val selectedId = sourceId ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { Column( @@ -1185,29 +1125,15 @@ private fun DaySwitchSheet( verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = if (refileSourceId != null) "File this entry under…" else "Switch day or category", + text = "File this entry under…", style = MaterialTheme.typography.titleMedium, ) Text( - text = if (refileSourceId != null) { - "The value you entered is kept; only the category it is filed under changes." - } else { - "Jump to a category, or pick another day to log." - }, + text = "The value you entered is kept; only the category it is filed under changes.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - if (refileSourceId == null) { - ListCard { - ListRow( - key = "Change day", - value = state.date.format(displayFormat), - onClick = onPickDay, - ) - } - } - val groupIds = state.groups.map { it.id }.toSet() val byGroup = state.categories .filter { cat -> cat.groupId.let { it != null && it in groupIds } } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogViewModel.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogViewModel.kt index 7672e2d..04f00e4 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogViewModel.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogViewModel.kt @@ -67,6 +67,8 @@ data class LogUiState( /** The episode covering (or within tolerance reach of) [date], if any. */ val episodeId: Long? = null, val episodeStart: LocalDate? = null, + /** The episode start as loaded, restored when a pending start is undone. */ + val loadedEpisodeStart: LocalDate? = null, /** Editable explicit episode end ("until"), null = open. */ val endDate: LocalDate? = null, /** The end date as loaded, so the End action can be undone before saving. */ @@ -112,6 +114,12 @@ data class LogUiState( // ── Screen state ───────────────────────────────────────────────────────── /** The category whose input is currently expanded from a grouped card row. */ val activeCategoryId: Long? = null, + /** + * When set, the screen shows only this category's input (opened for one + * thing from the speed dial, the widget, or a day-sheet entry) until the + * user asks for the whole day. + */ + val focusedCategoryId: Long? = null, val hasChanges: Boolean = false, val saved: Boolean = false, val deleted: Boolean = false, @@ -132,10 +140,11 @@ data class LogUiState( * the migration. Generic category behaviour keeps the retired category * screen's save rules per entry (see [entryValuesToSave]). * - * Deep-link targeting: [focusCategoryId] expands that category's input on - * first load (quick-log widget, speed dial); [editLogId] loads that one - * specific log into its category's entry for in-place editing, which is how a - * single log of an allow-multiple category is edited from the day sheet. + * Deep-link targeting: [focusCategoryId] opens the screen focused on that one + * category (quick-log widget, speed dial), hiding the rest of the day until + * the user asks for it; [editLogId] loads that one specific log into its + * category's entry for in-place editing, which is how a single log of an + * allow-multiple category is edited from the day sheet. */ class LogViewModel( private val repository: PeriodRepository, @@ -259,6 +268,7 @@ class LogViewModel( date = date, episodeId = episode?.id, episodeStart = epStart, + loadedEpisodeStart = epStart, endDate = effectiveEnd, loadedEndDate = effectiveEnd, isPeriodDay = isPeriodDay, @@ -281,6 +291,10 @@ class LogViewModel( categoryValues = valuesMap, entries = entriesMap, activeCategoryId = focusId, + // Focus survives day switches ("log this for another day") and + // is only ever set by the first, deep-linked load. + focusedCategoryId = state.focusedCategoryId + ?: focusId?.takeIf { id -> categories.any { it.id == id } }, hasChanges = false, ) } @@ -383,13 +397,30 @@ class LogViewModel( // ── Period actions ──────────────────────────────────────────────────────── - /** Marks the day to be logged as a period day (starting or continuing one) on save. */ - fun startPeriodToday() = _uiState.update { - it.copy(startPeriodToday = true, hasChanges = true) + /** + * Marks the day to be logged as a period day (starting or continuing one) + * on save. A day before its episode's start moves that start back to the + * day, so the screen shows the boundary the save will produce. + */ + fun startPeriodToday() = _uiState.update { state -> + val start = state.episodeStart + val movesStart = state.episodeId != null && start != null && state.date.isBefore(start) + state.copy( + startPeriodToday = true, + episodeStart = if (movesStart) state.date else start, + episodeDayNumber = if (movesStart) 1 else state.episodeDayNumber, + hasChanges = true, + ) } - fun undoStartPeriod() = _uiState.update { - it.copy(startPeriodToday = false, hasChanges = true) + fun undoStartPeriod() = _uiState.update { state -> + val start = state.loadedEpisodeStart + state.copy( + startPeriodToday = false, + episodeStart = start, + episodeDayNumber = start?.let { PeriodDaySync.dayNumber(minOf(it, state.date), state.date) }, + hasChanges = true, + ) } /** Moves the episode start (existing episodes only). */ @@ -502,6 +533,9 @@ class LogViewModel( it.copy(activeCategoryId = categoryId) } + /** Leaves the single-category focus and shows the whole day. */ + fun showFullDay() = _uiState.update { it.copy(focusedCategoryId = null) } + /** Deletes a category's existing log for this day and reloads its entry. */ fun deleteEntry(categoryId: Long) { val log = _uiState.value.entries[categoryId]?.existingLog ?: return @@ -562,14 +596,14 @@ class LogViewModel( fun refileEntry(fromId: Long, toId: Long) { val state = _uiState.value if (fromId == toId) { - _uiState.update { it.copy(activeCategoryId = toId) } + _uiState.update { it.focusOn(toId) } return } val fromCat = state.categories.firstOrNull { it.id == fromId } val toCat = state.categories.firstOrNull { it.id == toId } val fromEntry = state.entries[fromId] if (fromCat == null || toCat == null || fromEntry == null || !fromEntry.touched) { - _uiState.update { it.copy(activeCategoryId = toId) } + _uiState.update { it.focusOn(toId) } return } val labels = serialisedValues(fromCat, fromEntry) @@ -578,15 +612,20 @@ class LogViewModel( _uiState.update { s -> val target = s.entries[toId] ?: DayMetricEntry(trackTime = toCat.trackAgainstTime) val refiled = if (labels.isNullOrEmpty()) target else hydrateEntry(toCat, labels, target) - s.copy( + s.focusOn(toId).copy( entries = s.entries + (fromId to reset) + (toId to refiled), - activeCategoryId = toId, hasChanges = true, ) } } } + /** Expands [categoryId]'s input; a focused screen follows it to the new category. */ + private fun LogUiState.focusOn(categoryId: Long): LogUiState = copy( + activeCategoryId = categoryId, + focusedCategoryId = focusedCategoryId?.let { categoryId }, + ) + /** Serialises an entry's current value to the labels a save would store. */ private fun serialisedValues(cat: TrackingCategory, entry: DayMetricEntry): Set? = when (cat.categoryType) { @@ -631,9 +670,14 @@ class LogViewModel( if (periodSave) { val episode: PeriodEntry? = if (state.episodeId != null) { repository.logPeriodDay(state.date, tolerance) + // The day just logged is part of the episode whatever + // start was loaded: a day before the loaded start has + // just moved that start back, and the boundary edit + // must not trim it away again. + val start = minOf(state.episodeStart ?: state.date, state.date) repository.updateEpisode( id = state.episodeId, - start = state.episodeStart ?: state.date, + start = start, end = state.endDate, notes = state.periodNotes, toleranceDays = tolerance, 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 dba397e..d31b223 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 @@ -306,6 +306,31 @@ class PeriodRepositoryTest { assertEquals(4, f.dayDao.days.size) } + @Test + fun `logging the day before an episode then editing with the earlier start keeps the day`() = runBlocking { + // The day screen's save sequence for a day just before a stored + // start: absorb the day, then apply the boundary edit. The edit must + // be fed the post-absorb start (min of loaded start and the day) or it + // trims the day straight back out. + val f = buildRepository("2024-06-09", "2024-06-10") + f.repo.reconcile(1, today = date("2024-06-10")) + val id = f.periodDao.periods.single().id + val loadedStart = date("2024-06-09") + val day = date("2024-06-08") + + f.repo.logPeriodDay(day, 1, today = date("2024-06-10")) + val episode = f.repo.updateEpisode( + id, minOf(loadedStart, day), null, "", 1, today = date("2024-06-10") + )!! + + assertEquals("2024-06-08", episode.startDate) + assertEquals( + listOf("2024-06-08", "2024-06-09", "2024-06-10"), + f.dayDao.days.map { it.date }.sorted(), + ) + assertEquals(1, f.periodDao.periods.size) + } + // ── Removing days ───────────────────────────────────────────────────────── @Test diff --git a/changelog/unreleased/logging-ui-confusions.json b/changelog/unreleased/logging-ui-confusions.json new file mode 100644 index 0000000..41a568a --- /dev/null +++ b/changelog/unreleased/logging-ui-confusions.json @@ -0,0 +1,14 @@ +{ + "bump": "minor", + "added": [ + "Logging one category from the speed dial, the widget, or a day's entry now opens just that category, with a \"Log more for this day\" button to expand to the whole day" + ], + "changed": [ + "\"Period started today\" is now a switch instead of a row that looked like a menu", + "The Day section is gone; tap the screen title to change the day", + "Category headers on the log screen are plain labels; re-filing an entry moved to a \"Move to another category\" action under the entered value" + ], + "fixed": [ + "Logging a period day just before a period's start now moves the start back to that day instead of silently dropping it" + ] +} diff --git a/docs/design/logging-redesign/handover/README.md b/docs/design/logging-redesign/handover/README.md index 3dd645a..a0a3211 100644 --- a/docs/design/logging-redesign/handover/README.md +++ b/docs/design/logging-redesign/handover/README.md @@ -41,13 +41,14 @@ The canvas is organised in rows. Left→right, top→bottom: - **Single-metric log:** the metric *is* the page — tonal `primaryContainer` hero holds the reading as words ("Barely noticeable"), 5-step scale, notes fill former dead space, a small 7-day sparkline. ### Row 2 — One log screen, two states -- **Off-period daily log:** mood leads in an **amber hero** ("How did today feel"); flow not rendered; symptoms, tracked metrics, notes below; a hairline "Period started today" footer row. +- **Off-period daily log:** mood leads in an **amber hero** ("How did today feel"); flow not rendered; symptoms, tracked metrics, notes below; a "Period started today" switch row as the footer. - **On-period:** a blue **Flow** group slots in at the top, mood hero compresses to one row, footer becomes a filled `primaryContainer` status row ("Period ongoing · since Aug 6 · End"). **Everything between is byte-for-byte identical** — one screen, period is a state of the day, not a separate destination. ### Row 3 — Groups tie it together - **Settings → Categories & groups:** theme swatch row on top; each group is a row with a colour dot + role label. Switching theme recolours every group. - **Grouped multi-metric card:** Environment (Weather/Rainfall/Dampness) = **one card of rows**, not three separate logs. A group of one renders as the single-metric page. -- **Header switcher:** the screen title is a button → a sheet of categories **organised by group, tinted by role**. Switching re-files the entry; the value already entered is preserved. +- **Re-file sheet:** a "Move to another category" action under an entered value → a sheet of categories **organised by group, tinted by role**. Picking one re-files the entry; the value already entered is preserved. (The screen title changes the day; section headers are plain labels.) +- **Focused log:** opened for one category (speed dial, widget, day-sheet entry) the day screen shows only that category, with "Log more for this day" expanding to the whole day. ### Row 4 — What You Track (management home) - **Grouped / Ungrouped** segmented toggle. From 6d9a73a8c0d5182cee58f6f2354da9386c756f32 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 01:33:06 +0000 Subject: [PATCH 2/3] Stop period saves writing back deleted pinned-category logs On a period day, every save fanned out to each "Log with period" category whether or not the user touched it, writing a slider's minimum or a count of 0. That fabricated logs the user never entered, and made "Delete entry" useless: the next "Save day" (or reopening the day and saving) recreated the log with its default. Pinned categories now follow the same rule as every other entry and save only when touched. The pinned value rules (slider min fallback, count including 0) still apply to a touched entry. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ELZpYZSRB1DMaJiXvkcVSD --- LESSONS.md | 2 +- .../goflo/ui/screens/log/LogViewModel.kt | 22 ++++++++++--------- .../unreleased/logging-ui-confusions.json | 3 ++- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/LESSONS.md b/LESSONS.md index 4d3aaf8..0de9924 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -42,7 +42,7 @@ An app that promises "export all your data" and "delete all your data" has hidde `TrackingCategory.isNumeric` was `categoryType != "default"`, which was correct while every non-default type happened to store numbers. Adding the label-valued "yes_no" and "time" types would have silently routed "Yes"/"HH:mm" strings into numeric chart math (`toFloatOrNull()` returning null everywhere) with no compile error, because a negated predicate auto-includes every future variant. When a derived property gates behaviour, define membership positively (enumerate the types that ARE numeric); then a new variant defaults to the safe side and the property's KDoc records why. Grep for `!=` against discriminator fields whenever adding a variant to a string-keyed or enum type. **A batch save surface must re-derive per-entry rules from the single-entry screen it replaces — "block the save" becomes "skip the entry", and "untouched" must be distinguished from "empty"** -A screen that saves one entry can block its Save button on invalid input (empty numeric field, zero count). A unified surface that saves many entries at once cannot block the whole save on one bad entry — each single-entry blocking rule must be translated to "skip this entry, leave any stored log untouched". The batch surface also introduces a state the single screen never had: an entry the user never interacted with. Saving those with their displayed defaults fabricates logs for every category on every save; track a per-entry `touched` flag and only persist entries that are touched or already stored. Exception: preserve any existing always-save semantics verbatim (GoFlo's pinned-category period fan-out deliberately saves untouched pinned entries), or the two surfaces silently produce different data for the same user action. +A screen that saves one entry can block its Save button on invalid input (empty numeric field, zero count). A unified surface that saves many entries at once cannot block the whole save on one bad entry — each single-entry blocking rule must be translated to "skip this entry, leave any stored log untouched". The batch surface also introduces a state the single screen never had: an entry the user never interacted with. Saving those with their displayed defaults fabricates logs for every category on every save; track a per-entry `touched` flag and only persist entries that are touched or already stored. Be suspicious of any inherited always-save rule when porting: GoFlo carried the period screen's pinned-category fan-out (save every pinned category on every period save, untouched or not) into the unified screen for parity, and the result was logs the user could not delete, because "Delete entry" then "Save" wrote the default straight back. A rule that fabricates a value the user never entered is a bug the moment the same screen also offers delete. **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. diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogViewModel.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogViewModel.kt index 04f00e4..5aec490 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogViewModel.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogViewModel.kt @@ -32,9 +32,9 @@ import java.time.format.DateTimeFormatter * category, held once per category here. [touched] records whether the user changed anything * this session: untouched entries are skipped on save, so the screen neither * fabricates logs for ignored categories nor rewrites stored entries (which - * would re-stamp or clear their recorded time). Pinned categories are the - * exception while the day is on-period: they keep the period screen's - * always-save fan-out semantics. + * would re-stamp or clear their recorded time). This holds for pinned ("Log + * with period") categories too: an always-save fan-out made their logs + * impossible to delete, since the next period save wrote them straight back. */ data class DayMetricEntry( val selectedValues: Set = emptySet(), @@ -729,19 +729,21 @@ class LogViewModel( // Timed increments save per tap; never through the day save. if (cat.categoryType == "increment" && cat.trackAgainstTime) continue val entry = state.entries[cat.id] ?: continue + // Only touched entries save: an ignored category must neither + // gain a fabricated log nor have its stored entry rewritten + // (rewriting would re-stamp or clear its recorded time). Pinned + // categories are no exception: saving them untouched on every + // period day wrote back any log the user had just deleted. + if (!entry.touched) continue val pinnedContext = periodSave && cat.showInLogPeriod val values: Set? = if (pinnedContext) { - // Exact parity with the period screen's pinned fan-out - // (slider falls back to min, count saves including 0). + // The period screen's pinned value rules (slider falls back + // to min, count saves including 0) still apply once touched. PeriodDaySync.computePinnedValues( cat, entry.numericValue, entry.freeText, entry.selectedValues, ) } else { - // Only touched entries save: an ignored category must neither - // gain a fabricated log nor have its stored entry rewritten - // (rewriting would re-stamp or clear its recorded time). - if (!entry.touched) null - else entryValuesToSave(cat, entry) + entryValuesToSave(cat, entry) } if (values == null) continue val loggedAt = if (entry.trackTime) { diff --git a/changelog/unreleased/logging-ui-confusions.json b/changelog/unreleased/logging-ui-confusions.json index 41a568a..1b71f5f 100644 --- a/changelog/unreleased/logging-ui-confusions.json +++ b/changelog/unreleased/logging-ui-confusions.json @@ -9,6 +9,7 @@ "Category headers on the log screen are plain labels; re-filing an entry moved to a \"Move to another category\" action under the entered value" ], "fixed": [ - "Logging a period day just before a period's start now moves the start back to that day instead of silently dropping it" + "Logging a period day just before a period's start now moves the start back to that day instead of silently dropping it", + "Deleting an entry on a period day now sticks: categories set to \"Log with period\" are no longer written back with default values on every save, so a deleted log stays deleted and untouched categories no longer gain fabricated logs" ] } From 81648d0bae9a289d9d0088fa1c53de0f14801397 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 01:36:30 +0000 Subject: [PATCH 3/3] Make every stored log reachable for delete from the day screen Two more ways a log could not be deleted: - Allow-multiple categories start a fresh entry on the day screen, so a saved entry had no delete button unless reached through the day sheet's "edit" deep link. The day screen now lists the day's stored entries under the input, each with Edit (loads it into the input in place) and Delete. Deleting a listed row leaves the input alone; deleting the row loaded in the input resets it. The header reads "N logged" when the input is empty but entries exist. - "Remove this day from period" kept the day's flow log, which then had no surface left to edit or delete it from. It now removes the flow entry with the day; symptoms and tracked categories are still kept. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ELZpYZSRB1DMaJiXvkcVSD --- .../mapgie/goflo/ui/screens/log/LogScreen.kt | 59 +++++++++++++- .../goflo/ui/screens/log/LogViewModel.kt | 80 +++++++++++++++++-- .../unreleased/logging-ui-confusions.json | 4 +- 3 files changed, 132 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogScreen.kt index 0bfd077..0e9b765 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogScreen.kt @@ -71,6 +71,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import com.mapgie.goflo.data.database.entities.TrackingCategory +import com.mapgie.goflo.data.repository.TrackingLogWithValues import com.mapgie.goflo.ui.components.ChipRow import com.mapgie.goflo.ui.components.DatePickerDialogWrapper import com.mapgie.goflo.ui.components.HairlineDivider @@ -83,6 +84,7 @@ import com.mapgie.goflo.ui.components.PrimarySaveBar import com.mapgie.goflo.ui.components.SectionHeader import com.mapgie.goflo.ui.components.SelectableChip import com.mapgie.goflo.ui.components.SwitchRow +import com.mapgie.goflo.ui.components.TimelineEntry import com.mapgie.goflo.ui.components.ToneHero import com.mapgie.goflo.ui.components.roleContainerTint import com.mapgie.goflo.ui.components.usesStepScale @@ -216,8 +218,8 @@ fun LogScreen( onDismissRequest = { showRemoveDayConfirm = false }, title = { Text("Remove this day?") }, text = { Text( - "${state.date.format(displayFormat)} will no longer count as a period day. " + - "Anything else logged for this day is kept." + "${state.date.format(displayFormat)} will no longer count as a period day, " + + "and its flow entry is removed. Anything else logged for this day is kept." ) }, confirmButton = { TextButton( @@ -885,7 +887,7 @@ private fun MetricSectionBody( category = category, entries = entry.timedEntries, onAddOne = { viewModel.addTimedIncrement(category.id) }, - onDeleteEntry = { viewModel.deleteTimedEntry(category.id, it) }, + onDeleteEntry = { viewModel.deleteDayLog(category.id, it) }, ) return@Column } @@ -950,6 +952,29 @@ private fun MetricSectionBody( } } + // Allow-multiple: what is already stored this day, each row editable + // in place or deletable, so a saved entry never becomes unreachable. + if (entry.dayLogs.isNotEmpty()) { + Text( + text = if (entry.existingLog != null) "Other entries this day" else "Already logged this day", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + ListCard { + entry.dayLogs.forEachIndexed { index, logged -> + TimelineEntry( + time = logged.log.loggedAt.ifEmpty { "#${index + 1}" }, + value = loggedValuesText(category, logged, config), + role = role, + sub = logged.log.notes.takeIf { it.isNotEmpty() }, + onEdit = { viewModel.editDayLog(category.id, logged) }, + onDelete = { viewModel.deleteDayLog(category.id, logged.log) }, + ) + if (index < entry.dayLogs.lastIndex) HairlineDivider() + } + } + } + if (category.trackAgainstTime) { Row( modifier = Modifier.fillMaxWidth(), @@ -1261,6 +1286,21 @@ private fun metricValueForEntry( CategoryType.TIME -> MetricValue.TimeOfDay(entry.selectedValues.firstOrNull()) } +/** Words for one stored log's values, as the reading it recorded. */ +private fun loggedValuesText( + category: TrackingCategory, + logged: TrackingLogWithValues, + config: MetricConfig, +): String { + val first = logged.values.firstOrNull() + val stub = DayMetricEntry( + selectedValues = logged.values.toSet(), + numericValue = first?.toFloatOrNull(), + freeText = first ?: "", + ) + return entrySummary(category, stub, config) ?: logged.values.joinToString(", ") +} + /** Words for the current reading, or null when nothing is set for the day. */ private fun entrySummary( category: TrackingCategory, @@ -1275,6 +1315,19 @@ private fun entrySummary( val n = entry.timedEntries.size return if (n > 0) withUnit(n.toString()) else null } + // An allow-multiple day with nothing in the input still has a reading: + // how many entries are stored. + val stored = entry.dayLogs.size.takeIf { it > 0 }?.let { "$it logged" } + return current(type, entry, config, ::withUnit, category) ?: stored +} + +private fun current( + type: CategoryType, + entry: DayMetricEntry, + config: MetricConfig, + withUnit: (String) -> String, + category: TrackingCategory, +): String? { return when (type) { CategoryType.NUMERIC_SLIDER -> entry.numericValue?.let { v -> if (!category.allowDecimals) { diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogViewModel.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogViewModel.kt index 5aec490..b3b633d 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogViewModel.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogViewModel.kt @@ -49,6 +49,12 @@ data class DayMetricEntry( val existingLog: TrackingLog? = null, /** Timed entries already logged this day (increment + trackAgainstTime only). */ val timedEntries: List = emptyList(), + /** + * Logs already stored this day for an allow-multiple category, other than + * the one loaded into the input ([existingLog]); each can be edited in + * place or deleted. Empty for single-entry and timed categories. + */ + val dayLogs: List = emptyList(), val touched: Boolean = false, ) @@ -258,7 +264,11 @@ class LogViewModel( // Timed increments render the whole day's timeline already; // only collect-then-save types load the one targeted log. if (!(cat.categoryType == "increment" && cat.trackAgainstTime)) { - entriesMap[cat.id] = entryFromLog(cat, editTarget) + entriesMap[cat.id] = entryFromLog(cat, editTarget).copy( + dayLogs = entriesMap[cat.id]?.dayLogs + ?.filter { it.log.id != editTarget.log.id } + .orEmpty(), + ) } } } @@ -330,6 +340,10 @@ class LogViewModel( // loaded only through the editLogId deep link. val existing = if (timed || cat.allowMultiple) null else trackingRepository.getExistingLog(date, cat.id) + // Allow-multiple days list what is already stored, so each entry can + // be edited or deleted from the day screen itself. + val dayLogs = if (!timed && cat.allowMultiple) + trackingRepository.getLogsForDateAndCategory(date, cat.id) else emptyList() val numeric = if (cat.categoryType == "numeric_slider" || cat.categoryType == "increment") existing?.values?.firstOrNull()?.toFloatOrNull() @@ -344,6 +358,7 @@ class LogViewModel( trackTime = cat.trackAgainstTime, existingLog = existing?.log, timedEntries = timedEntries, + dayLogs = dayLogs, ) } @@ -379,6 +394,22 @@ class LogViewModel( _uiState.update { it.copy(entries = it.entries + (categoryId to fresh)) } } + /** + * Refreshes only the stored-entries list of an allow-multiple category, + * leaving whatever is in its input (unsaved or being edited) alone. + */ + private suspend fun refreshDayLogs(categoryId: Long) { + val state = _uiState.value + val all = trackingRepository.getLogsForDateAndCategory(state.date, categoryId) + _uiState.update { s -> + val entry = s.entries[categoryId] ?: return@update s + val editingId = entry.existingLog?.id + s.copy(entries = s.entries + (categoryId to entry.copy( + dayLogs = all.filter { it.log.id != editingId }, + ))) + } + } + // ── Day switching ───────────────────────────────────────────────────────── /** @@ -571,18 +602,48 @@ class LogViewModel( } } - /** Deletes a specific timed entry (increment + trackAgainstTime undo). */ - fun deleteTimedEntry(categoryId: Long, log: TrackingLog) { + /** + * Deletes one of the day's stored logs for [categoryId]: a timed increment + * entry, or one row of an allow-multiple category's list. Deleting the + * log currently loaded in the input resets the input; deleting any other + * row leaves the input as it is. + */ + fun deleteDayLog(categoryId: Long, log: TrackingLog) { + val state = _uiState.value + val cat = state.categories.firstOrNull { it.id == categoryId } ?: return + val timed = cat.categoryType == "increment" && cat.trackAgainstTime + val loadedInInput = state.entries[categoryId]?.existingLog?.id == log.id viewModelScope.launch { runCatching { trackingRepository.deleteLog(log) - reloadEntry(categoryId) + if (timed || loadedInInput) reloadEntry(categoryId) else refreshDayLogs(categoryId) }.onFailure { _uiState.update { s -> s.copy(error = "Could not delete the entry. Please try again.") } } } } + /** + * Loads one of the day's stored logs of an allow-multiple category into + * its input for in-place editing (the day sheet's "edit" deep link, done + * from the day screen itself). The list then shows the other entries. + */ + fun editDayLog(categoryId: Long, log: TrackingLogWithValues) { + val state = _uiState.value + val cat = state.categories.firstOrNull { it.id == categoryId } ?: return + viewModelScope.launch { + val all = trackingRepository.getLogsForDateAndCategory(state.date, categoryId) + _uiState.update { s -> + s.copy( + entries = s.entries + (categoryId to entryFromLog(cat, log).copy( + dayLogs = all.filter { it.log.id != log.log.id }, + )), + activeCategoryId = categoryId, + ) + } + } + } + // ── Re-filing (the header switcher) ────────────────────────────────────── /** @@ -800,15 +861,20 @@ class LogViewModel( // ── Period day removal and episode deletion ─────────────────────────────── /** - * Removes this day from the period without touching the day's own tracking - * logs — a flow or symptom logged on a day that turns out not to be a - * period day is still a valid, dated record. + * Removes this day from the period, along with the day's flow log: flow + * is a property of a period day, and with the day gone there would be no + * surface left to edit or delete it from. Everything else logged for the + * day (symptoms, tracked categories) is kept as a valid, dated record. */ fun removeDay() { val state = _uiState.value viewModelScope.launch { try { repository.unlogPeriodDay(state.date, state.toleranceDays) + trackingRepository.getSystemCategoryByKey("flow")?.let { flow -> + trackingRepository.getExistingLog(state.date, flow.id) + ?.let { trackingRepository.deleteLog(it.log) } + } application?.let { GoFloWidget.updateAllWidgets(it) } application?.let { runCatching { ReminderScheduler.refreshPredictionReminders(it) } } _uiState.update { it.copy(deleted = true) } diff --git a/changelog/unreleased/logging-ui-confusions.json b/changelog/unreleased/logging-ui-confusions.json index 1b71f5f..be95ec4 100644 --- a/changelog/unreleased/logging-ui-confusions.json +++ b/changelog/unreleased/logging-ui-confusions.json @@ -10,6 +10,8 @@ ], "fixed": [ "Logging a period day just before a period's start now moves the start back to that day instead of silently dropping it", - "Deleting an entry on a period day now sticks: categories set to \"Log with period\" are no longer written back with default values on every save, so a deleted log stays deleted and untouched categories no longer gain fabricated logs" + "Deleting an entry on a period day now sticks: categories set to \"Log with period\" are no longer written back with default values on every save, so a deleted log stays deleted and untouched categories no longer gain fabricated logs", + "Categories that allow multiple entries per day now list the day's stored entries under the input, each with Edit and Delete, so a saved entry is always reachable from the day screen", + "\"Remove this day from period\" now also removes that day's flow entry, which otherwise lingered with no way to delete it" ] }