Let a still-to-come match outrank a closed day, and fade past hits (#80)

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.
This commit is contained in:
2026-08-08 16:42:29 +02:00
parent efd670f2ff
commit cab18541ce
4 changed files with 86 additions and 31 deletions

View File

@@ -26,6 +26,8 @@ data class SearchHit(
val titleSpans: List<MatchSpan> = emptyList(), val titleSpans: List<MatchSpan> = emptyList(),
val locationSpans: List<MatchSpan> = emptyList(), val locationSpans: List<MatchSpan> = emptyList(),
val descriptionSnippet: DescriptionSnippet? = null, 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 * 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 * 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. * 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 * Equally-scoring events are then split by time: everything from today onwards
* exact title beats one that merely begins the same way, and only then by date. * 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 { object EventSearchRanker {
@@ -72,40 +78,43 @@ object EventSearchRanker {
/** /**
* Rank [candidates] against [query], dropping any that don't carry every * Rank [candidates] against [query], dropping any that don't carry every
* token. Ties fall back to the previous ordering — soonest upcoming (and * token. [now] marks an individual event as over; [todayStart] is where the
* ongoing) first, then the most recent past — measured against [now]. * ordering splits current from past, so everything still on today's page
* ranks with the upcoming events.
*/ */
fun rank( fun rank(
candidates: List<SearchCandidate>, candidates: List<SearchCandidate>,
query: String, query: String,
now: Instant, now: Instant,
todayStart: Instant,
): List<SearchHit> { ): List<SearchHit> {
val tokens = tokenize(query) val tokens = tokenize(query)
if (tokens.isEmpty()) return emptyList() 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 return scored
.sortedWith( .sortedWith(
compareByDescending<Scored> { it.weakest } compareByDescending<Scored> { it.weakest }
.thenByDescending { it.total } .thenByDescending { it.total }
.thenBy { if (it.current) 0 else 1 }
.thenByDescending { it.titleCoverage } .thenByDescending { it.titleCoverage }
.thenBy { if (it.hit.event.end >= now) 0 else 1 }
.thenComparator { a, b -> .thenComparator { a, b ->
val aStart = a.hit.event.start val aStart = a.hit.event.start
val bStart = b.hit.event.start val bStart = b.hit.event.start
// Same side of now by the key above: upcoming reads // Same side of today by the key above: what is left reads
// forwards, the past backwards from today. // forwards, what is behind us backwards from today.
if (a.hit.event.end >= now) { if (a.current) aStart.compareTo(bStart) else bStart.compareTo(aStart)
aStart.compareTo(bStart)
} else {
bStart.compareTo(aStart)
}
}, },
) )
.map { it.hit } .map { it.hit }
} }
private fun score(candidate: SearchCandidate, tokens: List<String>): Scored? { private fun score(
candidate: SearchCandidate,
tokens: List<String>,
now: Instant,
todayStart: Instant,
): Scored? {
val title = candidate.event.title val title = candidate.event.title
val location = candidate.event.location?.takeIf { it.isNotBlank() } val location = candidate.event.location?.takeIf { it.isNotBlank() }
// Collapsed once so a multi-line description matches and snippets the // Collapsed once so a multi-line description matches and snippets the
@@ -133,10 +142,12 @@ object EventSearchRanker {
descriptionSnippet = description descriptionSnippet = description
?.takeIf { descriptionSpans.isNotEmpty() } ?.takeIf { descriptionSpans.isNotEmpty() }
?.let { snippet(it, descriptionSpans) }, ?.let { snippet(it, descriptionSpans) },
isPast = candidate.event.end < now,
), ),
weakest = weakest, weakest = weakest,
total = total, total = total,
titleCoverage = coverage(title, titleSpans), titleCoverage = coverage(title, titleSpans),
current = candidate.event.end >= todayStart,
) )
} }
@@ -238,5 +249,7 @@ object EventSearchRanker {
val weakest: Int, val weakest: Int,
val total: Int, val total: Int,
val titleCoverage: Double, val titleCoverage: Double,
/** Still on today's page or later, as opposed to a closed day. */
val current: Boolean,
) )
} }

View File

@@ -53,6 +53,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.SnackChipHeight
import de.jeanlucmakiola.floret.components.SnackChipMargin import de.jeanlucmakiola.floret.components.SnackChipMargin
import de.jeanlucmakiola.floret.locale.currentLocale 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.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventAccent
@@ -453,7 +455,9 @@ private fun SearchResultRow(
color = if (selected) Color.Unspecified else MaterialTheme.colorScheme.primary, color = if (selected) Color.Unspecified else MaterialTheme.colorScheme.primary,
) )
GroupedRow( 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), title = marked(event.title, hit.titleSpans, highlight),
summary = searchSummary(hit, highlight), summary = searchSummary(hit, highlight),
position = position, position = position,

View File

@@ -28,6 +28,9 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock import kotlin.time.Clock
import javax.inject.Inject import javax.inject.Inject
@@ -100,10 +103,13 @@ class SearchViewModel @Inject constructor(
if (q.length < MIN_QUERY_LENGTH) { if (q.length < MIN_QUERY_LENGTH) {
SearchUiState.Idle SearchUiState.Idle
} else { } else {
val now = Clock.System.now()
val zone = TimeZone.currentSystemDefault()
val hits = EventSearchRanker.rank( val hits = EventSearchRanker.rank(
candidates = repository.searchEvents(q), candidates = repository.searchEvents(q),
query = q, query = q,
now = Clock.System.now(), now = now,
todayStart = now.toLocalDateTime(zone).date.atStartOfDayIn(zone),
) )
if (hits.isEmpty()) { if (hits.isEmpty()) {
SearchUiState.Empty(q) SearchUiState.Empty(q)

View File

@@ -12,6 +12,11 @@ class EventSearchRankerTest {
private val now = Instant.fromEpochMilliseconds(1_800_000_000_000L) 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( private fun candidate(
id: Long, id: Long,
title: String, title: String,
@@ -33,8 +38,11 @@ class EventSearchRankerTest {
description = description, description = description,
) )
private fun rank(candidates: List<SearchCandidate>, query: String) =
EventSearchRanker.rank(candidates, query, now, todayStart)
private fun titlesFor(vararg candidates: SearchCandidate, query: String): List<String> = private fun titlesFor(vararg candidates: SearchCandidate, query: String): List<String> =
EventSearchRanker.rank(candidates.toList(), query, now).map { it.event.title } rank(candidates.toList(), query).map { it.event.title }
@Test @Test
fun `a title match outranks a description match even when it is further off`() { 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") .containsExactly("Büro aufräumen", "Frühstück", "Frühstück")
.inOrder() .inOrder()
// The location hit ranks above the description one. // The location hit ranks above the description one.
val ranked = EventSearchRanker.rank( val ranked = rank(listOf(inDescription, inLocation, inTitle), "büro")
listOf(inDescription, inLocation, inTitle),
"büro",
now,
)
assertThat(ranked.map { it.event.eventId }).containsExactly(3L, 2L, 1L).inOrder() 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 both = candidate(1L, "Zahnarzt", description = "Termin bestätigen")
val onlyOne = candidate(2L, "Zahnarzt") 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) assertThat(ranked.map { it.event.eventId }).containsExactly(1L)
} }
@@ -122,9 +126,10 @@ class EventSearchRankerTest {
} }
@Test @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 // 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 exact = candidate(1L, "test", startMillis = now.toEpochMilliseconds() - 3 * 3_600_000L)
val longer = candidate(2L, "test2", startMillis = now.toEpochMilliseconds() + 3_600_000L) val longer = candidate(2L, "test2", startMillis = now.toEpochMilliseconds() + 3_600_000L)
@@ -133,6 +138,33 @@ class EventSearchRankerTest {
.inOrder() .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 @Test
fun `a shorter title wins when both merely contain the query`() { fun `a shorter title wins when both merely contain the query`() {
val shorter = candidate(1L, "Team Meeting") val shorter = candidate(1L, "Team Meeting")
@@ -152,7 +184,7 @@ class EventSearchRankerTest {
val longAgo = val longAgo =
candidate(4L, "Test", startMillis = now.toEpochMilliseconds() - 90 * 86_400_000L) 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() 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`() { fun `the title carries the spans to highlight, merged where they overlap`() {
val event = candidate(1L, "Test the tester") 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() 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 long = "x".repeat(300) + " geheimwort " + "y".repeat(300)
val event = candidate(1L, "Notiz", description = long) val event = candidate(1L, "Notiz", description = long)
val snippet = EventSearchRanker.rank(listOf(event), "geheimwort", now) val snippet = rank(listOf(event), "geheimwort")
.single() .single()
.descriptionSnippet .descriptionSnippet
@@ -188,7 +220,7 @@ class EventSearchRankerTest {
fun `a title-only match carries no description snippet`() { fun `a title-only match carries no description snippet`() {
val event = candidate(1L, "Test", description = "nothing relevant here") 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() .isNull()
} }
@@ -196,7 +228,7 @@ class EventSearchRankerTest {
fun `a multi-line description matches and snippets as one line`() { fun `a multi-line description matches and snippets as one line`() {
val event = candidate(1L, "Notiz", description = "erste Zeile\n\n zweite Zeile") 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) requireNotNull(snippet)
assertThat(snippet.text).isEqualTo("erste Zeile zweite Zeile") assertThat(snippet.text).isEqualTo("erste Zeile zweite Zeile")
@@ -204,6 +236,6 @@ class EventSearchRankerTest {
@Test @Test
fun `a blank query matches nothing`() { fun `a blank query matches nothing`() {
assertThat(EventSearchRanker.rank(listOf(candidate(1L, "Test")), " ", now)).isEmpty() assertThat(rank(listOf(candidate(1L, "Test")), " ")).isEmpty()
} }
} }