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