feat(android): session list date-range filter with activity-aware picker (#2141)

* feat(android): add session date-range filter with activity-aware picker

- Pure SessionDateRangeFilter port of the web SessionList.tsx semantics:
  strict YYYY-MM-DD parsing with calendar round-trip validation,
  [start local midnight, end local midnight + 1 day) windows anchored to
  the injected zone, session.updatedAt as the activity field
- M3 DateRangePicker dialog (opens on the selected start's month, Clear
  resets without closing) plus a lightweight recent-activity legend —
  declared divergence: M3 exposes no per-day decoration API, so the web's
  in-cell "has session activity" emphasis surfaces beside the picker
  instead; explicit Apply replaces the web's close-after-second-click
- Wired as a transient, unpersisted lens (web useState parity) applied
  before the machine scope; the activity-day set and machine chips both
  derive from the unfiltered list so filtering never hides alternatives
- Top-bar calendar action with active dot badge, active-range summary
  row with clear, date-specific empty state; strings in en/zh-rCN/ru
  (en-ru key order KEYSYNC clean)
- JVM tests: predicate window edges, DST spring-forward, zone-anchored
  midnights, strict parsing, VM lens combination and pre-filter
  activity-day derivation

Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>

* fix(android): refresh session date filters after timezone changes

* Deduplicate Android sessions before applying date filters

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: weishu <twsxtd@gmail.com>
This commit is contained in:
admin
2026-10-26 15:20:03 +08:00
committed by GitHub
co-authored by Claude Code HAPI weishu
parent aed99e22cf
commit 65c8dc727a
11 changed files with 842 additions and 25 deletions
@@ -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<String>,
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 }) {
@@ -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()
}
@@ -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<Long>,
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<Long>) {
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()))
@@ -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<SessionRowUi?>(null) }
var deleteRow by remember { mutableStateOf<SessionRowUi?>(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
},
),
@@ -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<Long> = 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<String?>(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<SessionDateRange?>(null)
private val timeZone = MutableStateFlow(zoneProvider())
private val _errors = MutableSharedFlow<SessionListError>(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 {
@@ -0,0 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#000000"
android:pathData="M20,3h-1V1h-2v2H7V1H5v2H4C2.9,3,2,3.9,2,5v16c0,1.1,0.9,2,2,2h16c1.1,0,2,-0.9,2,-2V5C22,3.9,21.1,3,20,3zM20,21H4V8h16V21z" />
</vector>
@@ -583,6 +583,13 @@
<string name="sessions_clear_filter">Сбросить фильтр</string>
<string name="sessions_machine_fallback">Машина · %1$s</string>
<string name="sessions_filter_summary">Фильтр: %1$s</string>
<string name="sessions_time_filter_label">Фильтр сессий по последней активности</string>
<string name="sessions_time_filter_apply">Применить</string>
<string name="sessions_time_filter_clear">Очистить</string>
<string name="sessions_time_filter_summary">Активность: %1$s – %2$s</string>
<string name="sessions_time_filter_empty_title">В этом диапазоне дат нет сессий</string>
<string name="sessions_time_filter_empty_hint">Измените или сбросьте фильтр по дате, чтобы увидеть больше сессий.</string>
<string name="sessions_time_filter_recent_activity">Недавняя активность</string>
<string name="privacy_policy">Политика конфиденциальности</string>
<string name="privacy_open_failed">Нет приложения, способного открыть эту ссылку.</string>
</resources>
@@ -587,6 +587,13 @@
<string name="sessions_clear_filter">清除筛选</string>
<string name="sessions_machine_fallback">机器 · %1$s</string>
<string name="sessions_filter_summary">筛选:%1$s</string>
<string name="sessions_time_filter_label">按最后活动时间筛选会话</string>
<string name="sessions_time_filter_apply">应用</string>
<string name="sessions_time_filter_clear">清除</string>
<string name="sessions_time_filter_summary">活动时间:%1$s – %2$s</string>
<string name="sessions_time_filter_empty_title">此日期范围内没有会话</string>
<string name="sessions_time_filter_empty_hint">调整或清除日期筛选以查看更多会话。</string>
<string name="sessions_time_filter_recent_activity">近期活动</string>
<string name="privacy_policy">隐私政策</string>
<string name="privacy_open_failed">没有可打开此链接的应用。</string>
<string name="chat_tool_resolved">已处理</string>
@@ -583,6 +583,13 @@
<string name="sessions_clear_filter">Clear filter</string>
<string name="sessions_machine_fallback">Machine · %1$s</string>
<string name="sessions_filter_summary">Filtered by: %1$s</string>
<string name="sessions_time_filter_label">Filter sessions by last activity</string>
<string name="sessions_time_filter_apply">Apply</string>
<string name="sessions_time_filter_clear">Clear</string>
<string name="sessions_time_filter_summary">Activity: %1$s – %2$s</string>
<string name="sessions_time_filter_empty_title">No sessions in this date range</string>
<string name="sessions_time_filter_empty_hint">Adjust or clear the date filter to see more sessions.</string>
<string name="sessions_time_filter_recent_activity">Recent activity</string>
<string name="privacy_policy">Privacy Policy</string>
<string name="privacy_open_failed">No app can open this link.</string>
</resources>
@@ -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")),
)
}
}
@@ -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<SessionListViewModel, FakeSessionStore, FakeMachineStore> {
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