From cab18541ce8d0ee33fc5c69f3ea4d257b1988343 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 8 Aug 2026 16:42:29 +0200 Subject: [PATCH] Let a still-to-come match outrank a closed day, and fade past hits (#80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exactness sat above the date entirely, so an exact match from last week beat an equally reasonable one tomorrow. Equal scores now split on time first — today and later before what is already behind us — and only then on how much of the title the match covers. The split is by day, not by the moment: an event at 19:00 that has just ended is still today's business and ranks with the upcoming ones, so the exact hit keeps winning within the day. Rows that have already finished are faded to EventDimAlpha, the same marking past events carry in agenda and week. --- .../calendula/domain/EventSearch.kt | 41 +++++++----- .../calendula/ui/search/SearchScreen.kt | 6 +- .../calendula/ui/search/SearchViewModel.kt | 8 ++- .../calendula/domain/EventSearchRankerTest.kt | 62 ++++++++++++++----- 4 files changed, 86 insertions(+), 31 deletions(-) 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() } }