diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventSearch.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventSearch.kt index 7c5324d..194c871 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventSearch.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventSearch.kt @@ -26,6 +26,8 @@ data class SearchHit( val titleSpans: List = emptyList(), val locationSpans: List = emptyList(), val descriptionSnippet: DescriptionSnippet? = null, + /** Already over, so the row can say so. Finished-today counts. */ + val isPast: Boolean = false, ) /** @@ -42,8 +44,12 @@ data class SearchHit( * sat in that field (whole-field start > word start > mid-word), and an event * ranks by the *weakest* field the query needed — one token findable only in the * description drags the whole event below the title matches, however soon it is. - * Equal scores are separated by how much of the title the match covers, so an - * exact title beats one that merely begins the same way, and only then by date. + * Equally-scoring events are then split by time: everything from today onwards + * comes before what is already behind us, so a match tomorrow outranks an + * equally good one from yesterday. The boundary is the *day*, not the moment — + * an event earlier today is still today's business, and ties inside a day fall + * to how much of the title the match covers, so an exact title beats one that + * merely begins the same way. */ object EventSearchRanker { @@ -72,40 +78,43 @@ object EventSearchRanker { /** * Rank [candidates] against [query], dropping any that don't carry every - * token. Ties fall back to the previous ordering — soonest upcoming (and - * ongoing) first, then the most recent past — measured against [now]. + * token. [now] marks an individual event as over; [todayStart] is where the + * ordering splits current from past, so everything still on today's page + * ranks with the upcoming events. */ fun rank( candidates: List, query: String, now: Instant, + todayStart: Instant, ): List { val tokens = tokenize(query) if (tokens.isEmpty()) return emptyList() - val scored = candidates.mapNotNull { candidate -> score(candidate, tokens) } + val scored = candidates.mapNotNull { candidate -> score(candidate, tokens, now, todayStart) } return scored .sortedWith( compareByDescending { it.weakest } .thenByDescending { it.total } + .thenBy { if (it.current) 0 else 1 } .thenByDescending { it.titleCoverage } - .thenBy { if (it.hit.event.end >= now) 0 else 1 } .thenComparator { a, b -> val aStart = a.hit.event.start val bStart = b.hit.event.start - // Same side of now by the key above: upcoming reads - // forwards, the past backwards from today. - if (a.hit.event.end >= now) { - aStart.compareTo(bStart) - } else { - bStart.compareTo(aStart) - } + // Same side of today by the key above: what is left reads + // forwards, what is behind us backwards from today. + if (a.current) aStart.compareTo(bStart) else bStart.compareTo(aStart) }, ) .map { it.hit } } - private fun score(candidate: SearchCandidate, tokens: List): Scored? { + private fun score( + candidate: SearchCandidate, + tokens: List, + now: Instant, + todayStart: Instant, + ): Scored? { val title = candidate.event.title val location = candidate.event.location?.takeIf { it.isNotBlank() } // Collapsed once so a multi-line description matches and snippets the @@ -133,10 +142,12 @@ object EventSearchRanker { descriptionSnippet = description ?.takeIf { descriptionSpans.isNotEmpty() } ?.let { snippet(it, descriptionSpans) }, + isPast = candidate.event.end < now, ), weakest = weakest, total = total, titleCoverage = coverage(title, titleSpans), + current = candidate.event.end >= todayStart, ) } @@ -238,5 +249,7 @@ object EventSearchRanker { val weakest: Int, val total: Int, val titleCoverage: Double, + /** Still on today's page or later, as opposed to a closed day. */ + val current: Boolean, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index cbc4cd2..f57c453 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -53,6 +53,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -88,6 +89,7 @@ import de.jeanlucmakiola.floret.components.SnackChip import de.jeanlucmakiola.floret.components.SnackChipHeight import de.jeanlucmakiola.floret.components.SnackChipMargin import de.jeanlucmakiola.floret.locale.currentLocale +import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog import de.jeanlucmakiola.calendula.ui.common.eventAccent @@ -453,7 +455,9 @@ private fun SearchResultRow( color = if (selected) Color.Unspecified else MaterialTheme.colorScheme.primary, ) GroupedRow( - modifier = modifier, + // Faded like a past event anywhere else in the app: search reaches back + // through the whole history, so an old hit has to read as over. + modifier = if (hit.isPast) modifier.alpha(EventDimAlpha) else modifier, title = marked(event.title, hit.titleSpans, highlight), summary = searchSummary(hit, highlight), position = position, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt index 7f3428d..6850570 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchViewModel.kt @@ -28,6 +28,9 @@ import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toLocalDateTime import kotlin.time.Clock import javax.inject.Inject @@ -100,10 +103,13 @@ class SearchViewModel @Inject constructor( if (q.length < MIN_QUERY_LENGTH) { SearchUiState.Idle } else { + val now = Clock.System.now() + val zone = TimeZone.currentSystemDefault() val hits = EventSearchRanker.rank( candidates = repository.searchEvents(q), query = q, - now = Clock.System.now(), + now = now, + todayStart = now.toLocalDateTime(zone).date.atStartOfDayIn(zone), ) if (hits.isEmpty()) { SearchUiState.Empty(q) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchRankerTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchRankerTest.kt index db99ab2..7271bf5 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchRankerTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventSearchRankerTest.kt @@ -12,6 +12,11 @@ class EventSearchRankerTest { private val now = Instant.fromEpochMilliseconds(1_800_000_000_000L) + /** Midnight of [now]'s day in UTC — the ranker's current/past boundary. */ + private val todayStart = Instant.fromEpochMilliseconds( + 1_800_000_000_000L / 86_400_000L * 86_400_000L, + ) + private fun candidate( id: Long, title: String, @@ -33,8 +38,11 @@ class EventSearchRankerTest { description = description, ) + private fun rank(candidates: List, query: String) = + EventSearchRanker.rank(candidates, query, now, todayStart) + private fun titlesFor(vararg candidates: SearchCandidate, query: String): List = - EventSearchRanker.rank(candidates.toList(), query, now).map { it.event.title } + rank(candidates.toList(), query).map { it.event.title } @Test fun `a title match outranks a description match even when it is further off`() { @@ -84,11 +92,7 @@ class EventSearchRankerTest { .containsExactly("Büro aufräumen", "Frühstück", "Frühstück") .inOrder() // The location hit ranks above the description one. - val ranked = EventSearchRanker.rank( - listOf(inDescription, inLocation, inTitle), - "büro", - now, - ) + val ranked = rank(listOf(inDescription, inLocation, inTitle), "büro") assertThat(ranked.map { it.event.eventId }).containsExactly(3L, 2L, 1L).inOrder() } @@ -105,7 +109,7 @@ class EventSearchRankerTest { val both = candidate(1L, "Zahnarzt", description = "Termin bestätigen") val onlyOne = candidate(2L, "Zahnarzt") - val ranked = EventSearchRanker.rank(listOf(both, onlyOne), "termin zahnarzt", now) + val ranked = rank(listOf(both, onlyOne), "termin zahnarzt") assertThat(ranked.map { it.event.eventId }).containsExactly(1L) } @@ -122,9 +126,10 @@ class EventSearchRankerTest { } @Test - fun `an exact title beats one that only starts the same way, whenever it is`() { + fun `an exact title beats one that only starts the same way, within the day`() { // Reported: "test" at 19:00 had already passed and "test2" at 23:00 was - // still to come, so the date key put the inexact match first. + // still to come, so the date key put the inexact match first. Both are + // today's business, so exactness decides. val exact = candidate(1L, "test", startMillis = now.toEpochMilliseconds() - 3 * 3_600_000L) val longer = candidate(2L, "test2", startMillis = now.toEpochMilliseconds() + 3_600_000L) @@ -133,6 +138,33 @@ class EventSearchRankerTest { .inOrder() } + @Test + fun `a match still to come beats an equally scoring one from a closed day`() { + // Across days the exactness is not enough: "test2" tomorrow is the one + // that can still be acted on, "test" yesterday is history. + val yesterday = + candidate(1L, "test", startMillis = now.toEpochMilliseconds() - 86_400_000L) + val tomorrow = + candidate(2L, "test2", startMillis = now.toEpochMilliseconds() + 86_400_000L) + + assertThat(titlesFor(yesterday, tomorrow, query = "test")) + .containsExactly("test2", "test") + .inOrder() + } + + @Test + fun `a hit that has already finished is marked past, including earlier today`() { + val earlierToday = + candidate(1L, "test", startMillis = now.toEpochMilliseconds() - 3 * 3_600_000L) + val laterToday = + candidate(2L, "test", startMillis = now.toEpochMilliseconds() + 3_600_000L) + + val ranked = rank(listOf(earlierToday, laterToday), "test").associateBy { it.event.eventId } + + assertThat(ranked.getValue(1L).isPast).isTrue() + assertThat(ranked.getValue(2L).isPast).isFalse() + } + @Test fun `a shorter title wins when both merely contain the query`() { val shorter = candidate(1L, "Team Meeting") @@ -152,7 +184,7 @@ class EventSearchRankerTest { val longAgo = candidate(4L, "Test", startMillis = now.toEpochMilliseconds() - 90 * 86_400_000L) - val ranked = EventSearchRanker.rank(listOf(longAgo, later, recentPast, soon), "test", now) + val ranked = rank(listOf(longAgo, later, recentPast, soon), "test") assertThat(ranked.map { it.event.eventId }).containsExactly(1L, 2L, 3L, 4L).inOrder() } @@ -161,7 +193,7 @@ class EventSearchRankerTest { fun `the title carries the spans to highlight, merged where they overlap`() { val event = candidate(1L, "Test the tester") - val hit = EventSearchRanker.rank(listOf(event), "test", now).single() + val hit = rank(listOf(event), "test").single() assertThat(hit.titleSpans).containsExactly(MatchSpan(0, 4), MatchSpan(9, 13)).inOrder() } @@ -171,7 +203,7 @@ class EventSearchRankerTest { val long = "x".repeat(300) + " geheimwort " + "y".repeat(300) val event = candidate(1L, "Notiz", description = long) - val snippet = EventSearchRanker.rank(listOf(event), "geheimwort", now) + val snippet = rank(listOf(event), "geheimwort") .single() .descriptionSnippet @@ -188,7 +220,7 @@ class EventSearchRankerTest { fun `a title-only match carries no description snippet`() { val event = candidate(1L, "Test", description = "nothing relevant here") - assertThat(EventSearchRanker.rank(listOf(event), "test", now).single().descriptionSnippet) + assertThat(rank(listOf(event), "test").single().descriptionSnippet) .isNull() } @@ -196,7 +228,7 @@ class EventSearchRankerTest { fun `a multi-line description matches and snippets as one line`() { val event = candidate(1L, "Notiz", description = "erste Zeile\n\n zweite Zeile") - val snippet = EventSearchRanker.rank(listOf(event), "zweite", now).single().descriptionSnippet + val snippet = rank(listOf(event), "zweite").single().descriptionSnippet requireNotNull(snippet) assertThat(snippet.text).isEqualTo("erste Zeile zweite Zeile") @@ -204,6 +236,6 @@ class EventSearchRankerTest { @Test fun `a blank query matches nothing`() { - assertThat(EventSearchRanker.rank(listOf(candidate(1L, "Test")), " ", now)).isEmpty() + assertThat(rank(listOf(candidate(1L, "Test")), " ")).isEmpty() } }