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 locationSpans: List<MatchSpan> = 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<SearchCandidate>,
query: String,
now: Instant,
todayStart: Instant,
): List<SearchHit> {
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<Scored> { 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<String>): Scored? {
private fun score(
candidate: SearchCandidate,
tokens: List<String>,
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,
)
}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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<SearchCandidate>, query: String) =
EventSearchRanker.rank(candidates, query, now, todayStart)
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
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()
}
}