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:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user