diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/home/HomeScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/home/HomeScreen.kt index 481f135d..934858ef 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/home/HomeScreen.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/home/HomeScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import app.hapi.companion.R +import app.hapi.companion.feature.sessions.SessionDateRangePickerDialog import app.hapi.companion.feature.sessions.SessionFilterSheet import app.hapi.companion.feature.sessions.SessionListScreen import app.hapi.companion.feature.sessions.SessionListViewModel @@ -59,6 +60,7 @@ fun HomeScreen( ) { val state by viewModel.uiState.collectAsState() var showFilters by rememberSaveable(activeHubUrl) { mutableStateOf(false) } + var showDateFilter by rememberSaveable(activeHubUrl) { mutableStateOf(false) } var showSignOutConfirm by rememberSaveable(activeHubUrl) { mutableStateOf(false) } Scaffold( @@ -68,7 +70,9 @@ fun HomeScreen( pairedHubs = pairedHubs, hasMachineFilters = state.hasMachineFilters, hasActiveFilter = state.activeMachineFilter != null, + hasActiveDateRange = state.dateRange != null, onOpenFilters = { showFilters = true }, + onOpenDateFilter = { showDateFilter = true }, onSwitchHub = onSwitchHub, onPairAnotherHub = onPairAnotherHub, onOpenSettings = onOpenSettings, @@ -87,6 +91,18 @@ fun HomeScreen( if (showFilters) { SessionFilterSheet(state, select = { viewModel.setMachineFilter(it); showFilters = false }, dismiss = { showFilters = false }) } + if (showDateFilter) { + SessionDateRangePickerDialog( + current = state.dateRange, + activityEpochDays = state.dateActivityEpochDays, + onApply = { startEpochDay, endEpochDay -> + viewModel.setDateRange(startEpochDay, endEpochDay) + showDateFilter = false + }, + onClear = { viewModel.clearDateRange() }, + onDismiss = { showDateFilter = false }, + ) + } if (showSignOutConfirm) { AlertDialog( onDismissRequest = { showSignOutConfirm = false }, @@ -118,6 +134,9 @@ internal fun HomeTopBar( pairedHubs: List, hasMachineFilters: Boolean, hasActiveFilter: Boolean, + hasActiveDateRange: Boolean = false, + /** Opens the activity date-range picker; null hides the calendar action. */ + onOpenDateFilter: (() -> Unit)? = null, onOpenFilters: () -> Unit, onSwitchHub: (String) -> Unit, onPairAnotherHub: () -> Unit, @@ -174,6 +193,15 @@ internal fun HomeTopBar( } }, actions = { + // Always reachable: the date lens applies even with a single machine. + if (onOpenDateFilter != null) { + IconButton(onClick = onOpenDateFilter, modifier = Modifier.size(48.dp)) { + // Dot badge while a range is active mirrors the web calendar icon. + BadgedBox(badge = { if (hasActiveDateRange) Badge() }) { + Icon(painterResource(R.drawable.ic_calendar), contentDescription = stringResource(R.string.sessions_time_filter_label)) + } + } + } if (hasMachineFilters) { val filterState = stringResource(if (hasActiveFilter) R.string.sessions_filters_active else R.string.sessions_filter_all) IconButton(onClick = onOpenFilters, modifier = Modifier.size(48.dp).semantics { stateDescription = filterState }) { diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionDateRangeFilter.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionDateRangeFilter.kt new file mode 100644 index 00000000..4492d128 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionDateRangeFilter.kt @@ -0,0 +1,91 @@ +package app.hapi.companion.feature.sessions + +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** + * Selected activity-date window: inclusive `[startEpochDay, endEpochDay]` + * stored as ISO epoch days (zone-free). The filter window resolves against a + * zone at match time (see [SessionDateRangeFilter]). + */ +data class SessionDateRange( + /** First day of the window (inclusive). */ + val startEpochDay: Long, + /** Last day of the window (inclusive; the millisecond window extends to the next local midnight). */ + val endEpochDay: Long, +) + +/** + * Web port of the session-list date-range filter (`SessionList.tsx`: + * `parseLocalDate` / `getSessionTimeRange` / `sessionMatchesTimeRange` plus + * the `sessionActivityDates` derivation). + * + * Window semantics: `[start local midnight, end local midnight + 1 day)` — + * both endpoint days are fully included (the web picker's half-open + * implementation of a closed `[start, end]` day range). All comparisons are + * epoch-millisecond numeric; the zone only decides which instant is a day's + * local midnight. + * + * Pure Kotlin so JVM tests cover the semantics without Compose. + */ +object SessionDateRangeFilter { + private val DAY_PATTERN = Regex("^(\\d{4})-(\\d{2})-(\\d{2})$") + + /** + * Strict `YYYY-MM-DD` parse. Returns null for anything off-pattern and — + * mirroring the web's calendar round-trip check — for calendar-invalid + * dates such as `2026-02-31` or `2025-02-29`. (Divergence: the web's + * `Date` constructor remaps two-digit years to 19xx and thereby rejects + * `00xx` years; ISO years 0-9999 are valid here.) + */ + fun parseEpochDay(value: String): Long? { + val match = DAY_PATTERN.find(value) ?: return null + val (year, month, day) = match.destructured + val localDate = runCatching { + LocalDate.of(year.toInt(), month.toInt(), day.toInt()) + }.getOrNull() ?: return null + return localDate.toEpochDay() + } + + /** Machine-readable `YYYY-MM-DD` rendering (web `formatDateValue`). */ + fun formatEpochDay(epochDay: Long): String = LocalDate.ofEpochDay(epochDay).toString() + + /** Local-midnight epoch millis of [epochDay] in [zone]. */ + fun startMillis(epochDay: Long, zone: ZoneId): Long = + LocalDate.ofEpochDay(epochDay).atStartOfDay(zone).toInstant().toEpochMilli() + + /** + * Exclusive window end: the local midnight after the end day (web: + * `endDate.setDate(endDate.getDate() + 1)` — a calendar-day jump, so DST + * days resolve to 23/24/25-hour windows correctly). + */ + fun endExclusiveMillis(epochDay: Long, zone: ZoneId): Long = + LocalDate.ofEpochDay(epochDay).plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() + + /** + * `sessionMatchesTimeRange`: a null range keeps every session; otherwise + * the session's `updatedAt` (hub-maintained last-activity timestamp) must + * fall inside `[start midnight, end midnight + 1 day)`. + */ + fun matches( + updatedAtMillis: Long, + range: SessionDateRange?, + zone: ZoneId = ZoneId.systemDefault(), + ): Boolean { + if (range == null) return true + val start = startMillis(range.startEpochDay, zone) + val end = endExclusiveMillis(range.endEpochDay, zone) + return updatedAtMillis >= start && updatedAtMillis < end + } + + /** + * Which local day an `updatedAt` timestamp falls on — the per-day key + * behind the web's `sessionActivityDates` set. Zone defaults to the + * device zone; tests pass a fixed zone. + */ + fun activityEpochDay( + updatedAtMillis: Long, + zone: ZoneId = ZoneId.systemDefault(), + ): Long = Instant.ofEpochMilli(updatedAtMillis).atZone(zone).toLocalDate().toEpochDay() +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionDateRangePickerDialog.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionDateRangePickerDialog.kt new file mode 100644 index 00000000..7943b571 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionDateRangePickerDialog.kt @@ -0,0 +1,188 @@ +package app.hapi.companion.feature.sessions + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DateRangePicker +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDateRangePickerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import app.hapi.companion.R +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale as JavaLocale + +/** M3 `DateRangePicker` counts dates as UTC midnights; epoch day is exactly that unit. */ +private const val MILLIS_PER_DAY = 86_400_000L + +/** How many of the most recent active days the lightweight legend shows. */ +private const val LEGEND_DAY_CAP = 10 + +/** + * Activity date-range picker dialog — Android shape of the web + * `SessionDateRangePicker` (`SessionList.tsx`). + * + * Parity: pick a start and an end day (M3 keeps `start <= end` itself, the + * web picker sorts the two clicks); the filter window is + * `[start local midnight, end local midnight + 1 day)`; opening on the + * selected start's month, else the current month; a `Clear` action while + * anything is selected; nothing is persisted (the selection lives in the + * ViewModel as transient state, like the web's `useState`). + * + * Declared divergence: M3 `DateRangePicker` has no public per-day decoration + * API, so the web's in-cell "has session activity" text emphasis cannot be + * reproduced inside the calendar. The activity days surface as a lightweight + * legend row under the picker instead ([ActivityDayLegend]). The M3 dialog + * also applies via an explicit `Apply` button rather than the web's + * close-after-second-click. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun SessionDateRangePickerDialog( + current: SessionDateRange?, + activityEpochDays: Set, + onApply: (startEpochDay: Long, endEpochDay: Long) -> Unit, + onClear: () -> Unit, + onDismiss: () -> Unit, +) { + // Web parity: open on the selected start's month, otherwise the current month. + val initialMonthEpochDay = current?.startEpochDay ?: LocalDate.now(ZoneId.systemDefault()).toEpochDay() + val pickerState = rememberDateRangePickerState( + initialSelectedStartDateMillis = current?.startEpochDay?.times(MILLIS_PER_DAY), + initialSelectedEndDateMillis = current?.endEpochDay?.times(MILLIS_PER_DAY), + initialDisplayedMonthMillis = initialMonthEpochDay * MILLIS_PER_DAY, + ) + val hasSelection = pickerState.selectedStartDateMillis != null + + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Surface( + shape = MaterialTheme.shapes.extraLarge, + tonalElevation = 6.dp, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 640.dp), + ) { + Column { + DateRangePicker( + state = pickerState, + title = { + Text( + text = stringResource(R.string.sessions_time_filter_label), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + showModeToggle = false, + modifier = Modifier.weight(1f), + ) + ActivityDayLegend(activityEpochDays) + HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(end = 8.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + // Web footer `Clear`: resets the in-progress selection and the + // active filter without closing the picker. + TextButton( + onClick = { + pickerState.setSelection(null, null) + onClear() + }, + enabled = hasSelection || current != null, + ) { + Text(stringResource(R.string.sessions_time_filter_clear)) + } + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.home_cancel)) + } + TextButton( + onClick = { + val start = pickerState.selectedStartDateMillis ?: return@TextButton + val end = pickerState.selectedEndDateMillis ?: return@TextButton + onApply(start / MILLIS_PER_DAY, end / MILLIS_PER_DAY) + }, + enabled = pickerState.selectedStartDateMillis != null && + pickerState.selectedEndDateMillis != null, + ) { + Text(stringResource(R.string.sessions_time_filter_apply)) + } + } + } + } + } +} + +/** + * Lightweight activity marking next to the picker (declared divergence, see + * [SessionDateRangePickerDialog]): the most recent days with session + * activity, derived before any list filtering — mirroring the web + * `sessionActivityDates` set that keeps marking days while a filter is + * active. Hidden when there is no activity at all. + */ +@Composable +private fun ActivityDayLegend(activityEpochDays: Set) { + if (activityEpochDays.isEmpty()) return + val recentDays = activityEpochDays.sortedDescending().take(LEGEND_DAY_CAP) + val formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(JavaLocale.getDefault()) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.sessions_time_filter_recent_activity), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + LazyRow( + modifier = Modifier + .weight(1f) + .padding(start = 8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(recentDays) { epochDay -> + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + shape = RoundedCornerShape(6.dp), + ) { + Text( + text = LocalDate.ofEpochDay(epochDay).format(formatter), + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } + } + } + } +} + +/** Locale-aware day label for the active-range summary row (machine `YYYY-MM-DD` stays in [SessionDateRangeFilter]). */ +internal fun formatEpochDayLocalized(epochDay: Long): String = + LocalDate.ofEpochDay(epochDay) + .format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(JavaLocale.getDefault())) diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListScreen.kt index 59a2597e..91e45e7b 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListScreen.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListScreen.kt @@ -1,5 +1,9 @@ package app.hapi.companion.feature.sessions +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -57,6 +61,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import app.hapi.companion.R import app.hapi.companion.ui.components.AgentFlavorIcon import app.hapi.companion.ui.theme.HapiTheme @@ -73,7 +81,8 @@ import app.hapi.protocol.wire.TodoProgress * Inventory (mirrors the web sidebar semantics): * - offline banner over snapshot data, search box (relevance-ranked, port of * the web sidebar search), machine filter chips (≥ 2 machines), - * pull-to-refresh, empty state + no-search-results state; + * activity date-range summary row + clear, pull-to-refresh, empty state + * and no-search-results state; * - duplicate agent-session views are collapsed and fresh "new session" * stubs hidden (ViewModel pipeline, web sidebar parity); * - pinned section first (the sort already puts globalPinned/pinned rows on @@ -108,12 +117,29 @@ fun SessionListScreen( var renameRow by remember { mutableStateOf(null) } var deleteRow by remember { mutableStateOf(null) } - DisposableEffect(viewModel) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(viewModel, context, lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) viewModel.refreshTimeZone() + } + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == Intent.ACTION_TIMEZONE_CHANGED) viewModel.refreshTimeZone() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + ContextCompat.registerReceiver( + context, receiver, IntentFilter(Intent.ACTION_TIMEZONE_CHANGED), ContextCompat.RECEIVER_NOT_EXPORTED, + ) viewModel.start() - onDispose { viewModel.stop() } + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + context.unregisterReceiver(receiver) + viewModel.stop() + } } - val context = LocalContext.current LaunchedEffect(viewModel, context) { viewModel.errors.collect { error -> val label = context.getString( @@ -162,6 +188,20 @@ fun SessionListScreen( TextButton(onClick = { viewModel.setMachineFilter(null) }) { Text(stringResource(R.string.sessions_clear_filter)) } } } + state.dateRange?.let { range -> + Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource( + R.string.sessions_time_filter_summary, + formatEpochDayLocalized(range.startEpochDay), + formatEpochDayLocalized(range.endEpochDay), + ), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = viewModel::clearDateRange) { Text(stringResource(R.string.sessions_clear_filter)) } + } + } PullToRefreshBox( isRefreshing = state.isRefreshing, onRefresh = viewModel::refresh, @@ -173,7 +213,7 @@ fun SessionListScreen( if (state.isSearching) { SearchEmptyState(query = state.searchQuery) } else { - EmptyState(hasLoaded = state.hasLoaded, isOffline = state.isOffline) + EmptyState(hasLoaded = state.hasLoaded, isOffline = state.isOffline, hasDateFilter = state.dateRange != null) } } else { SessionRows( @@ -529,7 +569,7 @@ private fun SearchEmptyState(query: String) { } @Composable -private fun EmptyState(hasLoaded: Boolean, isOffline: Boolean) { +private fun EmptyState(hasLoaded: Boolean, isOffline: Boolean, hasDateFilter: Boolean = false) { // verticalScroll keeps the pull-to-refresh gesture available even though // there is nothing to scroll. Column( @@ -545,6 +585,7 @@ private fun EmptyState(hasLoaded: Boolean, isOffline: Boolean) { when { !hasLoaded && !isOffline -> R.string.sessions_empty_loading_title isOffline -> R.string.sessions_empty_offline_title + hasDateFilter -> R.string.sessions_time_filter_empty_title else -> R.string.sessions_empty_title }, ), @@ -556,6 +597,7 @@ private fun EmptyState(hasLoaded: Boolean, isOffline: Boolean) { when { !hasLoaded && !isOffline -> R.string.sessions_empty_loading_hint isOffline -> R.string.sessions_empty_offline_hint + hasDateFilter -> R.string.sessions_time_filter_empty_hint else -> R.string.sessions_empty_hint }, ), diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListViewModel.kt index 607021a4..67012391 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListViewModel.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListViewModel.kt @@ -7,6 +7,7 @@ import app.hapi.data.store.SessionListStore import app.hapi.protocol.wire.Machine import app.hapi.protocol.wire.SessionSummary import app.hapi.protocol.wire.sortSessionSummaries +import java.time.ZoneId import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -67,6 +68,15 @@ data class SessionListUiState( val hasLoaded: Boolean, /** Last refresh failed — show the offline banner over snapshot data. */ val isOffline: Boolean, + /** Transient date-range lens (web `customStart`/`customEnd` `useState`); null = off, never persisted. */ + val dateRange: SessionDateRange? = null, + /** + * Local epoch-days that carry any session activity — the picker's + * activity hint, derived before any list filtering (web + * `sessionActivityDates`, which keeps marking all days while a filter is + * active). Duplicate views and hidden empty stubs are excluded first. + */ + val dateActivityEpochDays: Set = emptySet(), /** Live search-box text; blank = no filtering/ranking. */ val searchQuery: String = "", ) { @@ -94,6 +104,8 @@ class SessionListViewModel( private val scope: CoroutineScope, /** Last-seen baseline scope, e.g. the hub origin. */ private val hubKey: String = "default", + /** Current clock zone; resolved for every date derivation, including after resume. */ + private val zoneProvider: () -> ZoneId = ZoneId::systemDefault, ) { private val machineFilter = MutableStateFlow(null) private val searchQuery = MutableStateFlow("") @@ -102,6 +114,10 @@ class SessionListViewModel( private val isOffline = MutableStateFlow(false) private val hasRefreshedOnce = MutableStateFlow(false) + /** Transient date-range lens; null = no date filtering (web: unpersisted `useState`). */ + private val dateRange = MutableStateFlow(null) + private val timeZone = MutableStateFlow(zoneProvider()) + private val _errors = MutableSharedFlow(extraBufferCapacity = 8) /** Transient action failures (pin/archive/rename/delete/reopen) for a snackbar. */ @@ -149,10 +165,10 @@ class SessionListViewModel( combine(machineFilter, searchQuery, selectedSessionId) { filter, query, selected -> Triple(filter, query, selected) }, - combine(isRefreshing, isOffline, hasRefreshedOnce) { refreshing, offline, loaded -> - Triple(refreshing, offline, loaded) + combine(isRefreshing, isOffline, hasRefreshedOnce, dateRange, timeZone) { refreshing, offline, loaded, range, _ -> + ChromeState(refreshing, offline, loaded, range) }, - ) { (sessions, machines, lastSeen), (filter, query, selected), (refreshing, offline, refreshedOnce) -> + ) { (sessions, machines, lastSeen), (filter, query, selected), chrome -> buildUiState( sessions = sessions, machines = machines, @@ -160,9 +176,10 @@ class SessionListViewModel( filter = filter, searchQuery = query, selectedSessionId = selected, - isRefreshing = refreshing, - isOffline = offline, - hasLoaded = refreshedOnce || sessions.isNotEmpty(), + isRefreshing = chrome.refreshing, + isOffline = chrome.offline, + hasLoaded = chrome.refreshedOnce || sessions.isNotEmpty(), + dateRange = chrome.dateRange, ) }.stateIn( scope = scope, @@ -194,6 +211,7 @@ class SessionListViewModel( /** Pull-to-refresh / initial load. Coalesces concurrent calls. */ fun refresh() { + refreshTimeZone() if (refreshJob?.isActive == true) return refreshJob = scope.launch { isRefreshing.value = true @@ -230,6 +248,29 @@ class SessionListViewModel( machineFilter.value = machineId } + /** Re-evaluate local dates on timezone broadcasts and foreground resume, even offline. */ + fun refreshTimeZone() { + timeZone.value = zoneProvider() + } + + /** + * Sets the transient date-range lens. The two picks are normalized into + * `[start, end]` — the web picker sorts its two clicks the same way. Not + * persisted: a fresh ViewModel (process death, hub switch) starts clear, + * matching the web's unpersisted `useState`. + */ + fun setDateRange(startEpochDay: Long, endEpochDay: Long) { + dateRange.value = SessionDateRange( + startEpochDay = minOf(startEpochDay, endEpochDay), + endEpochDay = maxOf(startEpochDay, endEpochDay), + ) + } + + /** Clears the date-range lens (web: the picker footer `Clear` link). */ + fun clearDateRange() { + dateRange.value = null + } + /** Search-box input; blank disables filtering/ranking entirely. */ fun setSearchQuery(query: String) { searchQuery.value = query @@ -332,7 +373,10 @@ class SessionListViewModel( isRefreshing: Boolean, isOffline: Boolean, hasLoaded: Boolean, + dateRange: SessionDateRange? = null, ): SessionListUiState { + // Use one current zone for both filtering and the calendar activity marks. + val zone = zoneProvider() val names = machines.mapNotNull { machine -> val metadata = machine.metadata ?: return@mapNotNull null val name = metadata.displayName?.trim()?.takeIf { it.isNotEmpty() } @@ -368,31 +412,42 @@ class SessionListViewModel( val activeFilter = filter ?.takeIf { filters.size >= 2 && filters.any { chip -> chip.id == it } } - val visible = if (activeFilter == null) { - sessions + // Choose the canonical agent-session views before applying filters, + // so excluding a winner cannot resurrect an older duplicate. Restore + // pinned/active order after dedup regroups the rows. + val prepared = sortSessionSummaries( + SessionListFilters.prepareSidebarSessions(sessions, selectedSessionId), + ) + + // Calendar hints derive from the full sidebar corpus, before date, + // machine, or search filtering (web `sessionActivityDates`). Machine + // choices above retain their full source-snapshot counts. + val activityDays = prepared + .map { SessionDateRangeFilter.activityEpochDay(it.updatedAt, zone) } + .toSet() + val timeScoped = if (dateRange == null) { + prepared } else { - sessions.filter { (it.metadata?.machineId ?: UNKNOWN_MACHINE_ID) == activeFilter } + prepared.filter { SessionDateRangeFilter.matches(it.updatedAt, dateRange, zone) } } - // Sidebar list body, matching the web list: collapse duplicate - // agent-session views (resume/reopen chains) and hide fresh "new - // session" stubs, then re-apply the canonical order — dedup regroups - // rows and must not leave the store's pinned/active ordering behind. - val prepared = sortSessionSummaries( - SessionListFilters.prepareSidebarSessions(visible, selectedSessionId), - ) + val visible = if (activeFilter == null) { + timeScoped + } else { + timeScoped.filter { (it.metadata?.machineId ?: UNKNOWN_MACHINE_ID) == activeFilter } + } // Text search: relevance ranking over the already-prepared corpus, // pins kept contiguous ahead of ordinary rows. val searched = if (searchQuery.isBlank()) { - prepared + visible } else { val machineLabels = filters.associate { it.id to it.label } - val index = SessionListSearch.buildSessionSearchScoreIndex(prepared, searchQuery) { machineId -> + val index = SessionListSearch.buildSessionSearchScoreIndex(visible, searchQuery) { machineId -> machineLabels[machineId ?: UNKNOWN_MACHINE_ID] ?: "" } SessionListSearch.sortSessionsBySearchRelevancePreservingPins( - prepared.filter { it.id in index.matchedIds }, + visible.filter { it.id in index.matchedIds }, index, ) } @@ -426,9 +481,19 @@ class SessionListViewModel( isRefreshing = isRefreshing, hasLoaded = hasLoaded, isOffline = isOffline, + dateRange = dateRange, + dateActivityEpochDays = activityDays, ) } + /** Aggregated non-session inputs so [combine] stays within its typed arity. */ + private data class ChromeState( + val refreshing: Boolean, + val offline: Boolean, + val refreshedOnce: Boolean, + val dateRange: SessionDateRange?, + ) + companion object { /** `getSessionTitle` (`web/src/lib/sessionTitle.ts`). */ fun sessionTitle(summary: SessionSummary): String { diff --git a/android/app/src/main/res/drawable/ic_calendar.xml b/android/app/src/main/res/drawable/ic_calendar.xml new file mode 100644 index 00000000..d3ff744b --- /dev/null +++ b/android/app/src/main/res/drawable/ic_calendar.xml @@ -0,0 +1,6 @@ + + + diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 952ea77d..809d51aa 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -583,6 +583,13 @@ Сбросить фильтр Машина · %1$s Фильтр: %1$s + Фильтр сессий по последней активности + Применить + Очистить + Активность: %1$s – %2$s + В этом диапазоне дат нет сессий + Измените или сбросьте фильтр по дате, чтобы увидеть больше сессий. + Недавняя активность Политика конфиденциальности Нет приложения, способного открыть эту ссылку. diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 43419fe9..e64e2eb5 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -587,6 +587,13 @@ 清除筛选 机器 · %1$s 筛选:%1$s + 按最后活动时间筛选会话 + 应用 + 清除 + 活动时间:%1$s – %2$s + 此日期范围内没有会话 + 调整或清除日期筛选以查看更多会话。 + 近期活动 隐私政策 没有可打开此链接的应用。 已处理 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 233cef46..54f1ec91 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -583,6 +583,13 @@ Clear filter Machine · %1$s Filtered by: %1$s + Filter sessions by last activity + Apply + Clear + Activity: %1$s – %2$s + No sessions in this date range + Adjust or clear the date filter to see more sessions. + Recent activity Privacy Policy No app can open this link. diff --git a/android/app/src/test/kotlin/app/hapi/companion/feature/sessions/SessionDateRangeFilterTest.kt b/android/app/src/test/kotlin/app/hapi/companion/feature/sessions/SessionDateRangeFilterTest.kt new file mode 100644 index 00000000..f99014c5 --- /dev/null +++ b/android/app/src/test/kotlin/app/hapi/companion/feature/sessions/SessionDateRangeFilterTest.kt @@ -0,0 +1,155 @@ +package app.hapi.companion.feature.sessions + +import java.time.ZoneId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Web-parity fixtures — hand-derived from the epoch-day ladder: + * - 1970-01-01 = epoch day 0 + * - 2024-01-01 = 19723 (1970..2023 = 54y · 365d + 13 leap days) + * - 2024-02-29 = 19782 (19723 + 31 Jan + 28 into February) + * - 2026-01-01 = 20454 (+ leap 2024's 366d + 2025's 365d) + * - 2026-03-07 = 20519 (20454 + 31 + 28 + 6) + * - 2026-03-08 = 20520 (US spring-forward Sunday, second of March 2026) + * - 2026-10-05 = 20731 (20454 + 277: 31+28+31+30+31+30+31+31+30 + 4) + * Milliseconds multiply an epoch day by 86_400_000. + */ +class SessionDateRangeFilterTest { + private val utc = ZoneId.of("UTC") + + // ------------------------------------------------------------ parsing -- + + @Test + fun `parseEpochDay accepts strict padded calendar dates only`() { + assertEquals(0L, SessionDateRangeFilter.parseEpochDay("1970-01-01")) + assertEquals(20454L, SessionDateRangeFilter.parseEpochDay("2026-01-01")) + assertEquals(20731L, SessionDateRangeFilter.parseEpochDay("2026-10-05")) + // Leap day round trip (web validates by re-reading year/month/day). + assertEquals(19782L, SessionDateRangeFilter.parseEpochDay("2024-02-29")) + // Declared divergence: two-digit-millennium years are a JS Date remap + // quirk on web (rejected there); ISO calendar truth accepts them here. + assertEquals("0000-01-01", SessionDateRangeFilter.formatEpochDay(SessionDateRangeFilter.parseEpochDay("0000-01-01")!!)) + } + + @Test + fun `parseEpochDay rejects pattern violations`() { + for (value in listOf( + "2026-1-05", "2026-10-5", "26-10-05", "2026/10/05", " 2026-10-05", + "2026-10-05 ", "20261005", "", "2026-10-05T00:00", "x2026-10-05", + )) { + assertNull(SessionDateRangeFilter.parseEpochDay(value), "expected rejection: \"$value\"") + } + } + + @Test + fun `parseEpochDay rejects calendar-invalid dates`() { + for (value in listOf( + "2026-13-01", "2026-00-15", // month bounds + "2026-10-32", "2026-10-00", // day bounds + "2026-02-30", "2026-02-31", // February overflow + "2025-02-29", // non-leap year + )) { + assertNull(SessionDateRangeFilter.parseEpochDay(value), "expected rejection: \"$value\"") + } + } + + @Test + fun `formatEpochDay mirrors web formatDateValue`() { + assertEquals("2026-10-05", SessionDateRangeFilter.formatEpochDay(20731L)) + assertEquals("2024-02-29", SessionDateRangeFilter.formatEpochDay(19782L)) + assertEquals("1970-01-01", SessionDateRangeFilter.formatEpochDay(0L)) + } + + // -------------------------------------------------------------- match -- + + @Test + fun `matches treats the window as start-midnight inclusive to end-next-midnight exclusive`() { + val range = SessionDateRange(startEpochDay = 20731L, endEpochDay = 20733L) // 2026-10-05..07 + val start = 20731L * 86_400_000L // 2026-10-05T00:00Z = 1_791_158_400_000 + val endExclusive = 20734L * 86_400_000L // 2026-10-08T00:00Z = 1_791_417_600_000 + assertTrue(SessionDateRangeFilter.matches(start, range, utc), "start-day midnight is included") + assertFalse(SessionDateRangeFilter.matches(start - 1, range, utc), "before the window") + assertTrue(SessionDateRangeFilter.matches(endExclusive - 1, range, utc), "last millisecond of the end day is included") + assertFalse(SessionDateRangeFilter.matches(endExclusive, range, utc), "the midnight after the end day is excluded") + } + + @Test + fun `single-day range covers exactly one local day`() { + val range = SessionDateRange(startEpochDay = 20731L, endEpochDay = 20731L) + assertTrue(SessionDateRangeFilter.matches(20731L * 86_400_000L, range, utc)) + assertTrue(SessionDateRangeFilter.matches(20732L * 86_400_000L - 1, range, utc)) + assertFalse(SessionDateRangeFilter.matches(20731L * 86_400_000L - 1, range, utc)) + assertFalse(SessionDateRangeFilter.matches(20732L * 86_400_000L, range, utc)) + } + + @Test + fun `null range keeps every session`() { + assertTrue(SessionDateRangeFilter.matches(Long.MIN_VALUE, null, utc)) + assertTrue(SessionDateRangeFilter.matches(0L, null, utc)) + assertTrue(SessionDateRangeFilter.matches(Long.MAX_VALUE, null, utc)) + } + + @Test + fun `inverted raw range matches nothing`() { + // setDateRange normalizes picks, but the predicate stays total on raw data. + val inverted = SessionDateRange(startEpochDay = 20733L, endEpochDay = 20731L) + assertFalse(SessionDateRangeFilter.matches(20731L * 86_400_000L, inverted, utc)) + assertFalse(SessionDateRangeFilter.matches(20732L * 86_400_000L, inverted, utc)) + assertFalse(SessionDateRangeFilter.matches(20733L * 86_400_000L, inverted, utc)) + } + + @Test + fun `matches anchors windows to the zone's own midnight`() { + val shanghai = ZoneId.of("Asia/Shanghai") // UTC+8, no DST + val range = SessionDateRange(startEpochDay = 20731L, endEpochDay = 20731L) // local day 2026-10-05 + // 2026-10-05T15:59:59.999Z = 23:59:59.999 +08 → inside the Shanghai day and the UTC day. + assertTrue(SessionDateRangeFilter.matches(1_791_215_999_999L, range, shanghai)) + assertTrue(SessionDateRangeFilter.matches(1_791_215_999_999L, range, utc)) + // 2026-10-05T16:00:00Z = 2026-10-06T00:00 +08 → already past the Shanghai day… + assertFalse(SessionDateRangeFilter.matches(1_791_216_000_000L, range, shanghai)) + // …while in UTC it is still midday of Oct 5 → the same instant, a different verdict. + assertTrue(SessionDateRangeFilter.matches(1_791_216_000_000L, range, utc)) + } + + @Test + fun `matches keeps the whole end day across a DST spring-forward`() { + val newYork = ZoneId.of("America/New_York") + // Range 2026-03-07..03-08; 03-08 springs forward at 02:00, so the + // exclusive end is 2026-03-09T00:00-04:00 = 2026-03-09T04:00Z. + val range = SessionDateRange(startEpochDay = 20519L, endEpochDay = 20520L) + // Start edge: 2026-03-07T00:00-05:00 = 05:00Z on 03-07. + assertTrue(SessionDateRangeFilter.matches(1_772_859_600_000L, range, newYork)) + // 2026-03-09T03:30Z = 23:30 EDT on 03-08 → inside. + assertTrue(SessionDateRangeFilter.matches(1_773_027_000_000L, range, newYork)) + // Last millisecond before the end midnight → inside. + assertTrue(SessionDateRangeFilter.matches(1_773_028_799_999L, range, newYork)) + // 2026-03-09T04:00:00Z = local midnight 03-09 → outside. + assertFalse(SessionDateRangeFilter.matches(1_773_028_800_000L, range, newYork)) + } + + // ---------------------------------------------------- activity days -- + + @Test + fun `activityEpochDay maps a timestamp to its local day`() { + val ts = 1_791_230_400_000L // 2026-10-05T20:00Z + assertEquals(20731L, SessionDateRangeFilter.activityEpochDay(ts, utc)) // still Oct 5 + assertEquals(20732L, SessionDateRangeFilter.activityEpochDay(ts, ZoneId.of("Asia/Shanghai"))) // Oct 6, 04:00 +08 + assertEquals(20731L, SessionDateRangeFilter.activityEpochDay(ts, ZoneId.of("America/New_York"))) // Oct 5, 16:00 -04 + } + + @Test + fun `epoch day helpers stay exact at day boundaries`() { + assertNotNull(SessionDateRangeFilter.parseEpochDay("1970-01-01")) + assertEquals(86_400_000L * 20731L, SessionDateRangeFilter.startMillis(20731L, utc)) + // +1 calendar day, not +86_400_000 of the start midnight (DST zones differ). + assertEquals( + SessionDateRangeFilter.startMillis(20520L + 1, ZoneId.of("America/New_York")), + SessionDateRangeFilter.endExclusiveMillis(20520L, ZoneId.of("America/New_York")), + ) + } +} diff --git a/android/app/src/test/kotlin/app/hapi/companion/feature/sessions/SessionListViewModelTest.kt b/android/app/src/test/kotlin/app/hapi/companion/feature/sessions/SessionListViewModelTest.kt index daa7628e..2faabfad 100644 --- a/android/app/src/test/kotlin/app/hapi/companion/feature/sessions/SessionListViewModelTest.kt +++ b/android/app/src/test/kotlin/app/hapi/companion/feature/sessions/SessionListViewModelTest.kt @@ -11,6 +11,7 @@ import app.hapi.protocol.wire.SummaryText import app.hapi.protocol.wire.SyncEvent import app.hapi.protocol.wire.WorktreeMetadata import app.hapi.protocol.wire.sortSessionSummaries +import java.time.ZoneId import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -162,6 +163,7 @@ private fun machine(id: String, host: String, displayName: String? = null): Mach private fun TestScope.buildViewModel( sessions: FakeSessionStore = FakeSessionStore(), machines: FakeMachineStore = FakeMachineStore(), + zoneProvider: () -> ZoneId = { ZoneId.of("UTC") }, ): Triple { val viewModel = SessionListViewModel( sessionStore = sessions, @@ -169,6 +171,7 @@ private fun TestScope.buildViewModel( lastSeenStore = LastSeenStore(backgroundScope), scope = backgroundScope, hubKey = "hub-test", + zoneProvider = zoneProvider, ) return Triple(viewModel, sessions, machines) } @@ -449,6 +452,224 @@ class SessionListViewModelTest { assertEquals("Cannot reopen (missing: cursorSessionId)", error.message) } + // -------------------------------------------------- date-range lens -- + + /** Epoch days: 2026-10-05 = 20731 (see SessionDateRangeFilterTest's ladder). */ + private val day = 20_731L + private val dayMillis get() = day * 86_400_000L + + @Test + fun `date range scopes rows to the selected activity window and clears back`() = runTest { + val (viewModel, sessions, _) = buildViewModel() + sessions.set( + summary("in-range", updatedAt = dayMillis + 3_600_000L), + summary("day-before", updatedAt = dayMillis - 86_400_000L), + summary("day-after", updatedAt = dayMillis + 86_400_000L), + ) + viewModel.uiState.first { it.rows.size == 3 } + + viewModel.setDateRange(day, day) + val scoped = viewModel.uiState.first { it.rows.size == 1 } + assertEquals(listOf("in-range"), scoped.rows.map { it.id }) + assertEquals(SessionDateRange(day, day), scoped.dateRange) + + viewModel.clearDateRange() + val cleared = viewModel.uiState.first { it.dateRange == null } + assertEquals(3, cleared.rows.size) + } + + @Test + fun `setDateRange normalizes reversed picks like the web picker's two clicks`() = runTest { + val (viewModel, _, _) = buildViewModel() + viewModel.setDateRange(day + 2, day) + assertEquals(SessionDateRange(day, day + 2), viewModel.uiState.first { it.dateRange != null }.dateRange) + } + + @Test + fun `date range combines with machine filter and machine choices stay unfiltered`() = runTest { + val (viewModel, sessions, _) = buildViewModel() + sessions.set( + summary("m1-old", machineId = "m1", updatedAt = 20_000L * 86_400_000L), + summary("m1-new", machineId = "m1", updatedAt = dayMillis), + summary("m2-new", machineId = "m2", updatedAt = dayMillis), + ) + viewModel.uiState.first { it.rows.size == 3 } + + viewModel.setDateRange(day, day) + viewModel.setMachineFilter("m1") + val state = viewModel.uiState.first { it.activeMachineFilter == "m1" && it.rows.size == 1 } + assertEquals(listOf("m1-new"), state.rows.map { it.id }) + // Choices derive from the unfiltered list (web: machineFilters from allSessions). + assertEquals(listOf("m1", "m2"), state.machineFilters.map { it.id }) + assertEquals(1, state.machineFilters.first { it.id == "m2" }.sessionCount) + } + + @Test + fun `activity days derive before date and machine filtering`() = runTest { + // 2026-10-05T20:00Z = day 20731 in UTC but day 20732 in Shanghai. + val (viewModel, sessions, _) = buildViewModel(zoneProvider = { ZoneId.of("Asia/Shanghai") }) + sessions.set( + summary("utc-day", updatedAt = dayMillis), + summary("shanghai-next-day", updatedAt = 1_791_230_400_000L), + summary("old", updatedAt = 20_600L * 86_400_000L), + ) + viewModel.uiState.first { it.rows.size == 3 } + + viewModel.setDateRange(day, day) + val state = viewModel.uiState.first { it.dateRange != null } + // Only the UTC-midnight session survives the Shanghai-window filter… + assertEquals(listOf("utc-day"), state.rows.map { it.id }) + // …but the activity set still marks every pre-filter day (web + // sessionActivityDates keeps full-activity marking while filtering). + assertEquals(setOf(20_600L, day, day + 1), state.dateActivityEpochDays) + } + + @Test + fun `timezone changes update the active date lens and activity marks without new sessions`() = runTest { + var zone = ZoneId.of("UTC") + val (viewModel, sessions, _) = buildViewModel(zoneProvider = { zone }) + sessions.set(summary("boundary", updatedAt = dayMillis + 20 * 3_600_000L)) + viewModel.setDateRange(day, day) + val initial = viewModel.uiState.first { it.dateRange != null } + assertEquals(listOf("boundary"), initial.rows.map { it.id }) + assertEquals(setOf(day), initial.dateActivityEpochDays) + val calls = sessions.calls.value + + zone = ZoneId.of("Asia/Shanghai") + viewModel.refreshTimeZone() + val changed = viewModel.uiState.first { it.dateActivityEpochDays == setOf(day + 1) } + assertTrue(changed.rows.isEmpty()) + assertEquals(SessionDateRange(day, day), changed.dateRange) + assertEquals(calls, sessions.calls.value) + } + + @Test + fun `reentering the same viewmodel refreshes the timezone with an unchanged snapshot`() = runTest { + var zone = ZoneId.of("UTC") + val (viewModel, sessions, _) = buildViewModel(zoneProvider = { zone }) + sessions.set(summary("boundary", updatedAt = dayMillis + 20 * 3_600_000L)) + viewModel.setDateRange(day, day) + viewModel.start() + viewModel.uiState.first { it.dateRange != null && it.rows.size == 1 } + viewModel.stop() + + zone = ZoneId.of("Asia/Shanghai") + viewModel.start() + val changed = viewModel.uiState.first { it.dateActivityEpochDays == setOf(day + 1) } + assertTrue(changed.rows.isEmpty()) + assertEquals(SessionDateRange(day, day), changed.dateRange) + } + + @Test + fun `live session updates use the current timezone for both rows and activity marks`() = runTest { + var zone = ZoneId.of("UTC") + val (viewModel, sessions, _) = buildViewModel(zoneProvider = { zone }) + sessions.set(summary("boundary", updatedAt = dayMillis + 20 * 3_600_000L)) + viewModel.setDateRange(day, day) + viewModel.uiState.first { it.dateRange != null && it.rows.size == 1 } + + zone = ZoneId.of("Asia/Shanghai") + sessions.set(summary("boundary", updatedAt = dayMillis + 20 * 3_600_000L + 1)) + val changed = viewModel.uiState.first { it.dateActivityEpochDays == setOf(day + 1) } + assertTrue(changed.rows.isEmpty()) + } + + @Test + fun `date search and machine filters compose and clear independently`() = runTest { + val (viewModel, sessions, _) = buildViewModel() + sessions.set( + summary("home-today", machineId = "m1", updatedAt = dayMillis + 100, name = "Home today"), + summary("parser-today", machineId = "m1", updatedAt = dayMillis + 200, name = "Parser today"), + summary("home-yesterday", machineId = "m1", updatedAt = dayMillis - 1, name = "Home yesterday"), + summary("home-other-machine", machineId = "m2", updatedAt = dayMillis + 300, name = "Home elsewhere"), + ) + viewModel.uiState.first { it.rows.size == 4 } + viewModel.setDateRange(day, day) + viewModel.setSearchQuery("home") + viewModel.setMachineFilter("m1") + val scoped = viewModel.uiState.first { + it.dateRange != null && it.searchQuery == "home" && it.activeMachineFilter == "m1" + } + assertEquals(listOf("home-today"), scoped.rows.map { it.id }) + assertEquals(setOf(day - 1, day), scoped.dateActivityEpochDays) + assertEquals(mapOf("m1" to 3, "m2" to 1), scoped.machineFilters.associate { it.id to it.sessionCount }) + + viewModel.clearDateRange() + val withoutDate = viewModel.uiState.first { it.dateRange == null } + assertEquals(setOf("home-today", "home-yesterday"), withoutDate.rows.map { it.id }.toSet()) + + viewModel.setDateRange(day, day) + viewModel.setSearchQuery("") + val withoutSearch = viewModel.uiState.first { it.dateRange != null && !it.isSearching } + assertEquals(setOf("home-today", "parser-today"), withoutSearch.rows.map { it.id }.toSet()) + + viewModel.setMachineFilter(null) + val withoutMachine = viewModel.uiState.first { it.activeMachineFilter == null } + assertEquals(setOf("home-today", "parser-today", "home-other-machine"), withoutMachine.rows.map { it.id }.toSet()) + } + + @Test + fun `date scoped search preserves global and project pin sections`() = runTest { + val (viewModel, sessions, _) = buildViewModel() + sessions.set( + summary("ordinary", updatedAt = dayMillis + 300, name = "home"), + summary("project", updatedAt = dayMillis + 200, name = "Home automation").copy(pinned = true), + summary("global", updatedAt = dayMillis + 100, name = "homelab").copy(globalPinned = true, pinned = true), + summary("outside-date", updatedAt = dayMillis - 1, name = "home").copy(globalPinned = true), + ) + viewModel.setDateRange(day, day) + viewModel.setSearchQuery("home") + val state = viewModel.uiState.first { it.dateRange != null && it.isSearching } + assertEquals(listOf("global", "project", "ordinary"), state.rows.map { it.id }) + assertEquals(setOf(day - 1, day), state.dateActivityEpochDays) + } + + @Test + fun `date filtering cannot revive a stale duplicate view`() = runTest { + val (viewModel, sessions, _) = buildViewModel() + sessions.set( + summary("stale", machineId = "m1", updatedAt = dayMillis - 1, agentSessionId = "agent-1"), + summary("fresh", machineId = "m1", updatedAt = dayMillis, agentSessionId = "agent-1"), + summary("other", machineId = "m2", updatedAt = dayMillis - 2 * 86_400_000L), + ) + viewModel.uiState.first { it.rows.size == 2 } + + viewModel.setDateRange(day - 1, day - 1) + val yesterday = viewModel.uiState.first { it.dateRange != null } + assertTrue(yesterday.rows.isEmpty()) + assertEquals(setOf(day - 2, day), yesterday.dateActivityEpochDays) + assertEquals(mapOf("m1" to 2, "m2" to 1), yesterday.machineFilters.associate { it.id to it.sessionCount }) + + viewModel.setDateRange(day, day) + val today = viewModel.uiState.first { it.dateRange == SessionDateRange(day, day) } + assertEquals(listOf("fresh"), today.rows.map { it.id }) + + viewModel.clearDateRange() + val cleared = viewModel.uiState.first { it.dateRange == null } + assertEquals(listOf("fresh", "other"), cleared.rows.map { it.id }) + } + + @Test + fun `date search and activity marks use the active winner and exclude hidden stubs`() = runTest { + val (viewModel, sessions, _) = buildViewModel() + sessions.set( + summary("active", updatedAt = dayMillis - 1, active = true, agentSessionId = "agent-1", name = "Home live"), + summary("idle", updatedAt = dayMillis, agentSessionId = "agent-1", name = "Home idle"), + summary("stub", updatedAt = dayMillis - 2 * 86_400_000L, hasConversationContent = false), + ) + viewModel.uiState.first { it.rows.size == 1 } + + viewModel.setDateRange(day, day) + viewModel.setSearchQuery("home") + val today = viewModel.uiState.first { it.dateRange != null && it.isSearching } + assertTrue(today.rows.isEmpty()) + assertEquals(setOf(day - 1), today.dateActivityEpochDays) + + viewModel.setDateRange(day - 1, day - 1) + val yesterday = viewModel.uiState.first { it.dateRange == SessionDateRange(day - 1, day - 1) } + assertEquals(listOf("active"), yesterday.rows.map { it.id }) + } + // --------------------------------------------- search / dedup / stubs -- @Test