diff --git a/app/src/main/java/com/mapgie/goflo/MainActivity.kt b/app/src/main/java/com/mapgie/goflo/MainActivity.kt index 11ddf73..33c8b0e 100644 --- a/app/src/main/java/com/mapgie/goflo/MainActivity.kt +++ b/app/src/main/java/com/mapgie/goflo/MainActivity.kt @@ -59,6 +59,8 @@ import com.mapgie.goflo.ui.screens.categories.ManageCategoryValuesScreen import com.mapgie.goflo.ui.screens.categories.ManageCategoryValuesViewModel import com.mapgie.goflo.ui.screens.history.HistoryScreen import com.mapgie.goflo.ui.screens.history.HistoryViewModel +import com.mapgie.goflo.ui.screens.history.PeriodDetailScreen +import com.mapgie.goflo.ui.screens.history.PeriodDetailViewModel import com.mapgie.goflo.ui.screens.home.HomeScreen import com.mapgie.goflo.ui.screens.home.HomeViewModel import com.mapgie.goflo.ui.screens.log.LogCategoryScreen @@ -306,6 +308,24 @@ private fun MainNavHost(app: GoFloApplication, currentTheme: AppTheme, pendingCa HistoryScreen(viewModel = vm, onNavigate = { navController.navigate(it) }) } + composable( + route = Screen.PeriodDetail.route, + arguments = listOf(navArgument("periodId") { type = NavType.LongType }) + ) { backStack -> + val periodId = backStack.arguments?.getLong("periodId") ?: return@composable + val vm: PeriodDetailViewModel = viewModel( + key = "period_detail_$periodId", + factory = PeriodDetailViewModel.Factory( + periodId, app.repository, app.trackingRepository, app.preferencesStore + ) + ) + PeriodDetailScreen( + viewModel = vm, + onBack = { navController.popBackStack() }, + onNavigate = { navController.navigate(it) }, + ) + } + composable(Screen.Stats.route) { val vm: StatsViewModel = viewModel(factory = StatsViewModel.Factory(app.trackingRepository, app.preferencesStore, app.repository)) StatsScreen( 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 600efb4..1d7b750 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 @@ -79,6 +79,17 @@ sealed class Screen(val route: String) { "log_category/$categoryId?logId=$logId" } + // ── Period detail (History drill-in) ─────────────────────────────────────── + + /** + * Read-only view of one period episode expanded into its individual days. + * Opened from a History card; day rows continue to [LogDay] and the Edit + * action continues to [LogPeriod]. + */ + data object PeriodDetail : Screen("period_detail/{periodId}") { + fun forPeriod(periodId: Long) = "period_detail/$periodId" + } + // ── Unified day logging (logging redesign Phase 5) ───────────────────────── /** 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 ea9e4be..0928e88 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 @@ -173,7 +173,9 @@ fun HistoryScreen( } } }, - onClick = { onNavigate(Screen.LogPeriod.withId(period.id)) }, + // Opens the read-only period detail (day-by-day) view; + // the editor stays reachable from its Edit action. + onClick = { onNavigate(Screen.PeriodDetail.forPeriod(period.id)) }, modifier = Modifier, ) } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/history/PeriodDetailScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/history/PeriodDetailScreen.kt new file mode 100644 index 0000000..5d3bd5b --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/history/PeriodDetailScreen.kt @@ -0,0 +1,295 @@ +package com.mapgie.goflo.ui.screens.history + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material3.CircularProgressIndicator +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.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.mapgie.goflo.ui.components.HairlineDivider +import com.mapgie.goflo.ui.components.ListCard +import com.mapgie.goflo.ui.components.SectionHeader +import com.mapgie.goflo.ui.components.ToneHero +import com.mapgie.goflo.ui.navigation.Screen +import com.mapgie.goflo.ui.util.effectiveColorToken +import com.mapgie.goflo.ui.util.toCategoryColor +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +private val monthDay = DateTimeFormatter.ofPattern("MMM d") +private val monthDayYear = DateTimeFormatter.ofPattern("MMM d, yyyy") + +/** + * One period episode expanded into its individual days. + * + * A tonal hero words the episode's range and length; a single list card holds + * one row per logged day (day number, date, the day's flow as a word in the + * Flow category's role colour, and a compact line for symptoms and other + * logged categories). Tapping a day opens the unified day screen for that + * date; the top bar's Edit action opens the existing period editor. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PeriodDetailScreen( + viewModel: PeriodDetailViewModel, + onBack: () -> Unit, + onNavigate: (String) -> Unit, +) { + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(state.notFound) { + if (state.notFound) onBack() + } + + // Day-level data is a one-shot read: refresh whenever the screen returns + // to composition after a day or the episode was edited underneath it. + var composedBefore by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(Unit) { + if (composedBefore) viewModel.refresh() else composedBefore = true + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Period") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + state.period?.let { period -> + IconButton(onClick = { onNavigate(Screen.LogPeriod.withId(period.id)) }) { + Icon(Icons.Outlined.Edit, contentDescription = "Edit period") + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + ) + } + ) { padding -> + val period = state.period + val start = state.startDate + if (period == null || start == null) { + Box( + modifier = Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { + if (state.isLoading) CircularProgressIndicator() + } + return@Scaffold + } + + val flowToken = state.flowCategory?.effectiveColorToken(state.groups) ?: "primary" + val flowRole = flowToken.toCategoryColor() + + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Spacer(Modifier.height(4.dp)) + + ToneHero( + word = rangeWording(start, state.endDate), + role = flowRole, + caption = summaryCaption( + lengthDays = state.lengthDays, + ongoing = state.endDate == null, + cycleLengthDays = state.cycleLengthDays, + ), + ) + + SectionHeader( + label = "Day by day", + value = if (state.days.size == 1) "1 day logged" else "${state.days.size} days logged", + ) + ListCard { + state.days.forEachIndexed { index, day -> + if (index > 0) HairlineDivider() + PeriodDayRow( + day = day, + dateText = formatDayDate(day.date), + flowRole = flowRole, + onClick = { onNavigate(Screen.LogDay.forDate(day.date)) }, + ) + } + } + + if (period.notes.isNotBlank()) { + SectionHeader(label = "Notes") + ListCard { + Text( + text = period.notes, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(16.dp), + ) + } + } + + Spacer(Modifier.height(8.dp)) + } + } +} + +// ── Day row ─────────────────────────────────────────────────────────────────── + +/** + * One logged day: "Day N" with its date, the day's flow as a word in the Flow + * category's role colour (the word carries the meaning; the colour reinforces + * it), and a muted second line for symptoms and other logged categories. + */ +@Composable +private fun PeriodDayRow( + day: PeriodDayDetail, + dateText: String, + flowRole: Color, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .semantics { this.role = Role.Button } + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Day ${day.dayNumber}", + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = dateText, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + // Tabular figures so the date column aligns across rows. + style = TextStyle(fontFeatureSettings = "tnum"), + ) + } + val secondary = buildList { + if (day.symptoms.isNotEmpty()) add(day.symptoms.joinToString(", ")) + if (day.otherLoggedCount > 0) { + add( + if (day.otherLoggedCount == 1) "1 more logged" + else "${day.otherLoggedCount} more logged" + ) + } + }.joinToString(" · ") + if (secondary.isNotEmpty()) { + Text( + text = secondary, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + Text( + text = day.flowLabel ?: "Not logged", + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = if (day.flowLabel != null) flowRole + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +// ── Wording helpers ─────────────────────────────────────────────────────────── + +/** "Mar 3 to Mar 8", "Mar 3 to Mar 8, 2025", or "Started Mar 3, ongoing". */ +private fun rangeWording(start: LocalDate, end: LocalDate?): String { + val currentYear = LocalDate.now().year + if (end == null) { + val startText = + if (start.year == currentYear) monthDay.format(start) else monthDayYear.format(start) + return "Started $startText, ongoing" + } + return when { + start.year != end.year -> + "${monthDayYear.format(start)} to ${monthDayYear.format(end)}" + start.year != currentYear -> + "${monthDay.format(start)} to ${monthDay.format(end)}, ${end.year}" + else -> + "${monthDay.format(start)} to ${monthDay.format(end)}" + } +} + +/** "6 days", "4 days so far", with " · 28-day cycle" appended when known. */ +private fun summaryCaption(lengthDays: Int, ongoing: Boolean, cycleLengthDays: Int?): String { + val length = when { + ongoing && lengthDays == 1 -> "1 day so far" + ongoing -> "$lengthDays days so far" + lengthDays == 1 -> "1 day" + else -> "$lengthDays days" + } + return if (cycleLengthDays != null) "$length · $cycleLengthDays-day cycle" else length +} + +/** "Mar 5" for current-year dates, "Mar 5, 2025" otherwise. */ +private fun formatDayDate(date: LocalDate): String = + if (date.year == LocalDate.now().year) monthDay.format(date) else monthDayYear.format(date) diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/history/PeriodDetailViewModel.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/history/PeriodDetailViewModel.kt new file mode 100644 index 0000000..58616a3 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/history/PeriodDetailViewModel.kt @@ -0,0 +1,193 @@ +package com.mapgie.goflo.ui.screens.history + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.mapgie.goflo.data.database.entities.Group +import com.mapgie.goflo.data.database.entities.PeriodEntry +import com.mapgie.goflo.data.database.entities.TrackingCategory +import com.mapgie.goflo.data.preferences.AppPreferencesStore +import com.mapgie.goflo.data.repository.PeriodRepository +import com.mapgie.goflo.data.repository.TrackingRepository +import com.mapgie.goflo.ui.screens.log.PeriodDaySync +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.time.LocalDate +import java.time.temporal.ChronoUnit + +/** + * One logged day of a period episode, resolved for display. + * + * [flowLabel] is always a word ("Medium"), never a number: numeric_slider flow + * values are mapped back through [PeriodDaySync.flowLabelForSliderValue], and a + * raw label that does not parse as a number (a legacy chip-mode value) is shown + * as-is. Null when the day has no flow log. + */ +data class PeriodDayDetail( + val date: LocalDate, + /** 1-based day number within the episode (from its start date). */ + val dayNumber: Int, + val flowLabel: String?, + val symptoms: List, + /** Distinct categories (beyond flow and symptoms) logged on this day. */ + val otherLoggedCount: Int, +) + +data class PeriodDetailUiState( + val isLoading: Boolean = true, + /** The episode no longer exists; the screen should pop back. */ + val notFound: Boolean = false, + val period: PeriodEntry? = null, + val startDate: LocalDate? = null, + /** Explicit episode end; null while the episode is ongoing. */ + val endDate: LocalDate? = null, + /** Whole-episode length in days (through today while ongoing). */ + val lengthDays: Int = 0, + /** Days from this episode's start to the next episode's start, when plausible. */ + val cycleLengthDays: Int? = null, + val days: List = emptyList(), + /** The Flow system category, for resolving the day rows' role colour. */ + val flowCategory: TrackingCategory? = null, + val groups: List = emptyList(), +) + +/** + * ViewModel for [PeriodDetailScreen]: loads one episode, its period_days rows, + * and the range's tracking logs (flow word, symptoms, and a count of other + * logged categories per day) in a fixed number of queries. + * + * The episode row is observed reactively; day-level tracking logs are one-shot + * reads, so the screen calls [refresh] when it re-enters composition after a + * day was edited through the unified day screen. + */ +class PeriodDetailViewModel( + private val periodId: Long, + private val repository: PeriodRepository, + private val trackingRepository: TrackingRepository, + private val preferencesStore: AppPreferencesStore? = null, +) : ViewModel() { + + private val _uiState = MutableStateFlow(PeriodDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + /** Bumped by [refresh] to re-run the day-level loads. */ + private val refreshTick = MutableStateFlow(0) + + init { + viewModelScope.launch { + combine(repository.getPeriodById(periodId), refreshTick) { period, _ -> period } + .collect { period -> + if (period == null) { + _uiState.update { it.copy(isLoading = false, notFound = true) } + } else { + runCatching { loadDetails(period) } + _uiState.update { it.copy(isLoading = false) } + } + } + } + } + + /** Reloads the day list; called when the screen returns from a day or the editor. */ + fun refresh() = refreshTick.update { it + 1 } + + private suspend fun loadDetails(period: PeriodEntry) { + val tolerance = preferencesStore?.preferences?.first()?.periodGapToleranceDays + ?: PeriodRepository.DEFAULT_GAP_TOLERANCE_DAYS + val start = LocalDate.parse(period.startDate) + val end = period.endDate?.let { LocalDate.parse(it) } + + // The episode's logged days (period_days is the per-day source of + // truth). Defensive: an episode should always have at least its start + // day logged, but fall back to it rather than rendering nothing. + val dayDates = repository.getDaysForEpisode(period, tolerance) + .mapNotNull { runCatching { LocalDate.parse(it) }.getOrNull() } + .sorted() + .ifEmpty { listOf(start) } + + // One range query for the logs, one for their values (exportTrackingLogs + // batches both), then group by date in memory. + val categories = trackingRepository.getAllCategoriesOnce() + val flowCat = categories.firstOrNull { it.systemKey == "flow" } + val symptomsCat = categories.firstOrNull { it.systemKey == "symptoms" } + val logsByDate = trackingRepository + .exportTrackingLogs(categories.map { it.id }, start, dayDates.last()) + .groupBy { it.log.date } + val groups = trackingRepository.getAllGroupsOnce() + + val days = dayDates.map { date -> + val dayLogs = logsByDate[date.toString()].orEmpty() + val flowRaw = dayLogs + .firstOrNull { it.log.categoryId == flowCat?.id } + ?.values?.firstOrNull() + val flowLabel = flowRaw?.let { raw -> + if (flowCat?.categoryType == "numeric_slider") { + // Slider mode stores the numeric step; a value that does not + // parse is a legacy chip-mode label and stands on its own. + raw.toFloatOrNull() + ?.let { PeriodDaySync.flowLabelForSliderValue(it.toInt()) } + ?: raw + } else raw + } + val symptoms = dayLogs + .firstOrNull { it.log.categoryId == symptomsCat?.id } + ?.values.orEmpty() + val otherCount = dayLogs + .filter { it.log.categoryId != flowCat?.id && it.log.categoryId != symptomsCat?.id } + .distinctBy { it.log.categoryId } + .count() + PeriodDayDetail( + date = date, + dayNumber = ChronoUnit.DAYS.between(start, date).toInt() + 1, + flowLabel = flowLabel, + symptoms = symptoms, + otherLoggedCount = otherCount, + ) + } + + // Cycle context: days to the next episode's start, same plausibility + // bounds as the History list. + val allPeriods = repository.getAllPeriodsOnce().sortedBy { it.startDate } + val nextStart = allPeriods + .firstOrNull { LocalDate.parse(it.startDate).isAfter(start) } + ?.let { LocalDate.parse(it.startDate) } + val cycleLength = nextStart + ?.let { ChronoUnit.DAYS.between(start, it).toInt() } + ?.takeIf { it in 15..60 } + + val lengthEnd = end ?: maxOf(LocalDate.now(), dayDates.last()) + val lengthDays = (ChronoUnit.DAYS.between(start, lengthEnd).toInt() + 1) + .coerceAtLeast(1) + + _uiState.update { + it.copy( + isLoading = false, + notFound = false, + period = period, + startDate = start, + endDate = end, + lengthDays = lengthDays, + cycleLengthDays = cycleLength, + days = days, + flowCategory = flowCat, + groups = groups, + ) + } + } + + class Factory( + private val periodId: Long, + private val repository: PeriodRepository, + private val trackingRepository: TrackingRepository, + private val preferencesStore: AppPreferencesStore? = null, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return PeriodDetailViewModel(periodId, repository, trackingRepository, preferencesStore) as T + } + } +} diff --git a/changelog/unreleased/period-detail-view.json b/changelog/unreleased/period-detail-view.json new file mode 100644 index 0000000..e76cd97 --- /dev/null +++ b/changelog/unreleased/period-detail-view.json @@ -0,0 +1,9 @@ +{ + "bump": "minor", + "added": [ + "New period detail screen: tap a period in History to see it day by day, with each day's flow, symptoms, and other logged entries; tap a day to open its log, or use Edit to change the period's dates and notes" + ], + "changed": [ + "Tapping a period in History now opens the new detail view first; the period editor is one tap away via its Edit action" + ] +} diff --git a/docs/design/logging-redesign/subsystem-maps/01-logging-screens.md b/docs/design/logging-redesign/subsystem-maps/01-logging-screens.md index 43fe83b..feb9eaf 100644 --- a/docs/design/logging-redesign/subsystem-maps/01-logging-screens.md +++ b/docs/design/logging-redesign/subsystem-maps/01-logging-screens.md @@ -11,6 +11,8 @@ > **Phase 5 drift (branch `claude/logging-redesign-phase-5`):** a third, additive destination now exists: the unified day screen `LogScreen` (`ui/screens/log/LogScreen.kt`) + `LogViewModel`, route `log_day?date={date}` (`Screen.LogDay`), reached only via an opt-in "Try the new day log (preview)" row in `DayLogSheet` — every pre-existing entry point still targets the two screens below, and both remain registered and byte-for-byte functional. `LogViewModel` holds one `DayMetricEntry` per active non-system category plus the period-day state (episode continuation, flow, symptoms, episode notes) and reuses the period logic through `PeriodDaySync` (`ui/screens/log/PeriodDaySync.kt`), an extraction of `LogPeriodViewModel`'s former private helpers: the 1→Spotting/2→Light/4→Heavy/else-Medium flow mapping, `syncFlowToTrackingLog`, `syncSymptomsToTrackingLog`, and the pinned-category value rules (`computePinnedValues`). `LogPeriodViewModel` now delegates to that object; its public behaviour is unchanged. `LogCategoryScreen`'s `metricConfigFor` and `TimedIncrementTimeline` were widened from `private` to `internal` so `LogScreen` renders the identical config mapping and timed-increment surface. The §4 save-flow description applies to the unified screen as follows: on-period saves run the LogPeriodViewModel sequence (day + episode + meta + fan-out + widget/reminder refresh) and pinned categories keep `computePinnedValues` semantics; off-period saves write only touched categories using `LogCategoryViewModel.save()`'s per-type rules (empty free text / count ≤ 0 skip that category instead of blocking the day). +> **Period detail drift (branch `claude/period-detail-view`, outside the phased plan):** a read-only `PeriodDetailScreen` (`ui/screens/history/`) at route `period_detail/{periodId}` now sits between a History card tap and the destinations below — its day rows open `Screen.LogDay.forDate(date)` and its Edit action opens `Screen.LogPeriod.withId(id)`; no logging screen or save path changed. + ## Overview: two truly separate destinations "Log Period" and "Log Category" are **fully separate screens, routes, ViewModels, and repositories**. They share only two small helpers: the `LogEntryTopBar` composable and a private (duplicated) `DatePickerDialogWrapper`. Period logging is a bespoke multi-section day editor on `PeriodRepository`; category logging is a single generic input on `TrackingRepository`.