mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
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:
co-authored by
Claude Code
HAPI
weishu
parent
aed99e22cf
commit
65c8dc727a
@@ -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 }) {
|
||||
|
||||
+91
@@ -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()
|
||||
}
|
||||
+188
@@ -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()))
|
||||
+48
-6
@@ -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
|
||||
},
|
||||
),
|
||||
|
||||
+84
-19
@@ -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>
|
||||
|
||||
+155
@@ -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")),
|
||||
)
|
||||
}
|
||||
}
|
||||
+221
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user