Group search results by month (#80)

Relevance scoring never fit a calendar: a list of events is easier to read as
a calendar than as a ranking. Results are now grouped into months under sticky
headers — this month, then the months ahead, then the months behind counting
backwards — with what is still to come listed before what has passed inside
each month, so a month spanning today needs only one header.

That replaces the field/position scoring and the title-coverage tie-break;
matching itself is unchanged (every token, any field, case folded past ASCII,
mid-word allowed), as are the highlight spans, the description excerpt and the
past-event fade. To keep the ordering from bringing back what started this,
events the query only reached through their description are held out of the
calendar and listed under their own heading at the end.

Months come from spanFirstDay, so an all-day event is filed by its own day
rather than slipping a month back west of UTC (#82).
This commit is contained in:
2026-08-08 17:05:23 +02:00
parent cab18541ce
commit 7b5de34822
8 changed files with 472 additions and 393 deletions

View File

@@ -27,7 +27,7 @@ import de.jeanlucmakiola.calendula.domain.EventDetail
import de.jeanlucmakiola.calendula.domain.curatedForPicker import de.jeanlucmakiola.calendula.domain.curatedForPicker
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventSearchRanker import de.jeanlucmakiola.calendula.domain.EventSearch
import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.SearchCandidate import de.jeanlucmakiola.calendula.domain.SearchCandidate
@@ -68,7 +68,7 @@ interface CalendarDataSource {
* whitespace-separated token has to appear in the title, description or * whitespace-separated token has to appear in the title, description or
* location. Reads the Events table directly so the search is unbounded in * location. Reads the Events table directly so the search is unbounded in
* time; exception rows are excluded (see [SearchProjection]). A deliberate * time; exception rows are excluded (see [SearchProjection]). A deliberate
* superset — [EventSearchRanker] makes the final call and orders the hits. * superset — [EventSearch] makes the final call and orders the hits.
* [query] is assumed non-blank. * [query] is assumed non-blank.
*/ */
fun searchEvents(query: String): List<SearchCandidate> fun searchEvents(query: String): List<SearchCandidate>
@@ -591,17 +591,17 @@ class AndroidCalendarDataSource @Inject constructor(
override fun searchEvents(query: String): List<SearchCandidate> { override fun searchEvents(query: String): List<SearchCandidate> {
ensureObserversRegistered() ensureObserversRegistered()
val tokens = EventSearchRanker.tokenize(query).take(MAX_SEARCH_TOKENS) val tokens = EventSearch.tokenize(query).take(MAX_SEARCH_TOKENS)
if (tokens.isEmpty()) return emptyList() if (tokens.isEmpty()) return emptyList()
// Every token has to appear somewhere, so the groups are ANDed. This is // Every token has to appear somewhere, so the groups are ANDed. This is
// only a pre-filter — EventSearchRanker re-checks each token with proper // only a pre-filter — EventSearch re-checks each token with proper
// case folding, and re-checks the tokens dropped by the cap above. // case folding, and re-checks the tokens dropped by the cap above.
val match = tokens.joinToString(" AND ") { val match = tokens.joinToString(" AND ") {
"(${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " + "(${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " + "${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
"${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\')" "${CalendarContract.Events.EVENT_LOCATION} LIKE ? ESCAPE '\\')"
} }
val args = tokens.flatMap { token -> List(3) { likePattern(token) } }.toTypedArray() val args = tokens.flatMap { token: String -> List(3) { likePattern(token) } }.toTypedArray()
val selection = "($match) AND " + val selection = "($match) AND " +
"${CalendarContract.Events.DELETED} = 0 AND " + "${CalendarContract.Events.DELETED} = 0 AND " +
"${CalendarContract.Events.ORIGINAL_ID} IS NULL" "${CalendarContract.Events.ORIGINAL_ID} IS NULL"
@@ -640,7 +640,7 @@ class AndroidCalendarDataSource @Inject constructor(
* One token as a `LIKE` pattern. SQLite folds case for ASCII only, so a * One token as a `LIKE` pattern. SQLite folds case for ASCII only, so a
* non-ASCII cased letter is substituted with the single-character wildcard * non-ASCII cased letter is substituted with the single-character wildcard
* `_` — "ärzte" queries as `%_rzte%` and so still reaches "Ärzte". That * `_` — "ärzte" queries as `%_rzte%` and so still reaches "Ärzte". That
* matches more than it should on purpose; [EventSearchRanker] does the real * matches more than it should on purpose; [EventSearch] does the real
* comparison. Uncased scripts stay literal, keeping the filter selective. * comparison. Uncased scripts stay literal, keeping the filter selective.
*/ */
private fun likePattern(token: String): String { private fun likePattern(token: String): String {

View File

@@ -21,7 +21,7 @@ interface CalendarRepository {
* Candidate matches for [query] with hidden calendars removed; empty when * Candidate matches for [query] with hidden calendars removed; empty when
* [query] is blank. Searches the whole history/future (see * [query] is blank. Searches the whole history/future (see
* [CalendarDataSource.searchEvents]) and leaves ranking to * [CalendarDataSource.searchEvents]) and leaves ranking to
* [de.jeanlucmakiola.calendula.domain.EventSearchRanker]. * [de.jeanlucmakiola.calendula.domain.EventSearch].
*/ */
suspend fun searchEvents(query: String): List<SearchCandidate> suspend fun searchEvents(query: String): List<SearchCandidate>

View File

@@ -1,5 +1,9 @@
package de.jeanlucmakiola.calendula.domain package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant import kotlin.time.Instant
/** An event with the free text the search may have matched it on. */ /** An event with the free text the search may have matched it on. */
@@ -30,36 +34,49 @@ data class SearchHit(
val isPast: Boolean = false, val isPast: Boolean = false,
) )
/** One month's worth of hits, as the results list draws them under a header. */
data class SearchMonth(
val year: Int,
/** 112, so the UI can build the month's first day for a localized label. */
val monthNumber: Int,
val hits: List<SearchHit>,
)
/** /**
* Turning a typed query into ranked hits. * A finished search: a calendar of hits, plus the ones that only turned up
* inside a description and would otherwise clutter it.
*/
data class SearchResults(
val months: List<SearchMonth>,
val inDescriptions: List<SearchHit>,
) {
val isEmpty: Boolean get() = months.isEmpty() && inDescriptions.isEmpty()
/** Every hit, whichever section it sits in — for select-all and delete. */
val allHits: List<SearchHit> get() = months.flatMap { it.hits } + inDescriptions
}
/**
* Turning a typed query into the results list.
* *
* Matching is token-wise: the query splits on whitespace and an event has to * Matching is token-wise: the query splits on whitespace and an event has to
* satisfy *every* token, each in any of title / location / description. Case is * satisfy *every* token, each in any of title / location / description. Case is
* folded through Kotlin's [String.indexOf] rather than SQL's `LIKE`, which folds * folded through Kotlin's [String.indexOf] rather than SQL's `LIKE`, which folds
* ASCII only — "ärzte" has to find "Ärzte". * ASCII only — "ärzte" has to find "Ärzte".
* *
* Ranking answers the complaint behind #80's follow-up: a description hit used * Results are a calendar rather than a ranking: hits are grouped by the month
* to outrank a title hit purely by being sooner. A token scores by the field it * they fall in, the current month first, then the months ahead, then the months
* matched ([FIELD_TITLE] > [FIELD_LOCATION] > [FIELD_DESCRIPTION]) and by how it * behind counting backwards. Inside a month what is still to come reads
* sat in that field (whole-field start > word start > mid-word), and an event * forwards and what has passed follows it, so an event tomorrow sits above one
* ranks by the *weakest* field the query needed — one token findable only in the * from yesterday. The day is the boundary, not the moment — an event that ended
* description drags the whole event below the title matches, however soon it is. * at 19:00 is still today's business and keeps its place in today's order.
* 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 * An event the query only reached through its description is held back in
* equally good one from yesterday. The boundary is the *day*, not the moment — * [SearchResults.inDescriptions]: those are the matches that made search
* an event earlier today is still today's business, and ties inside a day fall * confusing when a stray word in a holiday's notes landed among events actually
* to how much of the title the match covers, so an exact title beats one that * named for the query.
* merely begins the same way.
*/ */
object EventSearchRanker { object EventSearch {
private const val FIELD_TITLE = 3
private const val FIELD_LOCATION = 2
private const val FIELD_DESCRIPTION = 1
private const val KIND_MID_WORD = 1
private const val KIND_WORD_START = 2
private const val KIND_FIELD_START = 3
/** Characters of description kept around the match in a row's snippet. */ /** Characters of description kept around the match in a row's snippet. */
private const val SNIPPET_LENGTH = 96 private const val SNIPPET_LENGTH = 96
@@ -77,44 +94,84 @@ object EventSearchRanker {
query.trim().split(WHITESPACE).filter { it.isNotEmpty() } query.trim().split(WHITESPACE).filter { it.isNotEmpty() }
/** /**
* Rank [candidates] against [query], dropping any that don't carry every * Search [candidates] for [query], dropping any that don't carry every token.
* token. [now] marks an individual event as over; [todayStart] is where the * [now] marks an individual event as over; [todayStart] is where an event
* ordering splits current from past, so everything still on today's page * stops being current, so everything still on today's page keeps its place
* ranks with the upcoming events. * among the upcoming ones. [zone] resolves each event to a calendar month —
* via [spanFirstDay], so an all-day event doesn't slip into the month before
* it west of UTC (#82).
*/ */
fun rank( fun search(
candidates: List<SearchCandidate>, candidates: List<SearchCandidate>,
query: String, query: String,
now: Instant, now: Instant,
todayStart: Instant, todayStart: Instant,
): List<SearchHit> { zone: TimeZone,
): SearchResults {
val tokens = tokenize(query) val tokens = tokenize(query)
if (tokens.isEmpty()) return emptyList() if (tokens.isEmpty()) return SearchResults(emptyList(), emptyList())
val scored = candidates.mapNotNull { candidate -> score(candidate, tokens, now, todayStart) } val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now) }
return scored val order = compareBy<Matched> { if (it.event.end >= todayStart) 0 else 1 }
.sortedWith(
compareByDescending<Scored> { it.weakest }
.thenByDescending { it.total }
.thenBy { if (it.current) 0 else 1 }
.thenByDescending { it.titleCoverage }
.thenComparator { a, b -> .thenComparator { a, b ->
val aStart = a.hit.event.start // What is left reads forwards; what is behind us reads backwards
val bStart = b.hit.event.start // from today, nearest first.
// Same side of today by the key above: what is left reads if (a.event.end >= todayStart) {
// forwards, what is behind us backwards from today. a.event.start.compareTo(b.event.start)
if (a.current) aStart.compareTo(bStart) else bStart.compareTo(aStart) } else {
}, b.event.start.compareTo(a.event.start)
) }
.map { it.hit }
} }
private fun score( val (inDescriptions, dated) = matched.sortedWith(order).partition { it.descriptionOnly }
return SearchResults(
months = groupByMonth(dated, zone, todayStart),
inDescriptions = inDescriptions.map { it.hit },
)
}
/**
* The dated hits as months: the one we are in, then forwards, then backwards.
* Each month keeps the order it was handed, so a month holding both sides of
* today lists what is left before what has passed.
*/
private fun groupByMonth(
dated: List<Matched>,
zone: TimeZone,
todayStart: Instant,
): List<SearchMonth> {
val currentKey = monthKey(todayStart.toLocalDateTime(zone).date)
return dated
.groupBy { monthKey(it.event.spanFirstDay(zone)) }
.entries
.sortedWith(
compareBy<Map.Entry<Int, List<Matched>>> {
when {
it.key == currentKey -> 0
it.key > currentKey -> 1
else -> 2
}
}.thenComparator { a, b ->
if (a.key > currentKey) a.key.compareTo(b.key) else b.key.compareTo(a.key)
},
)
.map { (key, entries) ->
SearchMonth(
year = key / 12,
monthNumber = key % 12 + 1,
hits = entries.map { it.hit },
)
}
}
/** Year and month as one comparable number. */
private fun monthKey(date: LocalDate): Int = date.year * 12 + (date.month.number - 1)
private fun match(
candidate: SearchCandidate, candidate: SearchCandidate,
tokens: List<String>, tokens: List<String>,
now: Instant, now: Instant,
todayStart: Instant, ): Matched? {
): 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
@@ -124,80 +181,34 @@ object EventSearchRanker {
?.trim() ?.trim()
?.takeIf { it.isNotEmpty() } ?.takeIf { it.isNotEmpty() }
var weakest = Int.MAX_VALUE // Mid-word counts as a match — German compounds mean "termin" has to keep
var total = 0 // finding "Zahnarzttermin".
for (token in tokens) { val present = { token: String ->
val best = bestScore(token, title, location, description) ?: return null title.contains(token, ignoreCase = true) ||
weakest = minOf(weakest, best) location?.contains(token, ignoreCase = true) == true ||
total += best description?.contains(token, ignoreCase = true) == true
} }
if (!tokens.all(present)) return null
val descriptionSpans = description?.let { spansIn(it, tokens) }.orEmpty() val descriptionSpans = description?.let { spansIn(it, tokens) }.orEmpty()
val titleSpans = spansIn(title, tokens) val titleSpans = spansIn(title, tokens)
return Scored( val locationSpans = location?.let { spansIn(it, tokens) }.orEmpty()
return Matched(
hit = SearchHit( hit = SearchHit(
event = candidate.event, event = candidate.event,
titleSpans = titleSpans, titleSpans = titleSpans,
locationSpans = location?.let { spansIn(it, tokens) }.orEmpty(), locationSpans = locationSpans,
descriptionSnippet = description descriptionSnippet = description
?.takeIf { descriptionSpans.isNotEmpty() } ?.takeIf { descriptionSpans.isNotEmpty() }
?.let { snippet(it, descriptionSpans) }, ?.let { snippet(it, descriptionSpans) },
isPast = candidate.event.end < now, isPast = candidate.event.end < now,
), ),
weakest = weakest, // Nothing in the name or the place the query could have caught on —
total = total, // the only reason this event is here is buried in its notes.
titleCoverage = coverage(title, titleSpans), descriptionOnly = titleSpans.isEmpty() && locationSpans.isEmpty(),
current = candidate.event.end >= todayStart,
) )
} }
/**
* How much of [text] the match accounts for — 1.0 when the query *is* the
* title. What separates an exact hit from one that merely starts the same
* way: "test" and "test2" score identically otherwise, and the shorter title
* is the closer answer whichever of the two happens to be sooner.
*/
private fun coverage(text: String, spans: List<MatchSpan>): Double {
if (text.isEmpty() || spans.isEmpty()) return 0.0
return spans.sumOf { it.end - it.start }.toDouble() / text.length
}
/** The best a single token does across the fields, or null if it is absent. */
private fun bestScore(
token: String,
title: String,
location: String?,
description: String?,
): Int? {
val scores = listOfNotNull(
kindIn(title, token)?.let { FIELD_TITLE * 10 + it },
location?.let { kindIn(it, token) }?.let { FIELD_LOCATION * 10 + it },
description?.let { kindIn(it, token) }?.let { FIELD_DESCRIPTION * 10 + it },
)
return scores.maxOrNull()
}
/**
* How well [token] sits in [text]: at its very start, at the start of a word,
* or inside one. Mid-word still counts — German compounds mean "termin" has
* to keep finding "Zahnarzttermin" — it just ranks last.
*/
private fun kindIn(text: String, token: String): Int? {
var best: Int? = null
var index = text.indexOf(token, startIndex = 0, ignoreCase = true)
while (index >= 0) {
val kind = when {
index == 0 -> KIND_FIELD_START
!text[index - 1].isLetterOrDigit() -> KIND_WORD_START
else -> KIND_MID_WORD
}
if (kind == KIND_FIELD_START) return KIND_FIELD_START
if (best == null || kind > best) best = kind
index = text.indexOf(token, startIndex = index + 1, ignoreCase = true)
}
return best
}
/** Every occurrence of every token in [text], merged where they overlap. */ /** Every occurrence of every token in [text], merged where they overlap. */
private fun spansIn(text: String, tokens: List<String>): List<MatchSpan> { private fun spansIn(text: String, tokens: List<String>): List<MatchSpan> {
val raw = mutableListOf<MatchSpan>() val raw = mutableListOf<MatchSpan>()
@@ -244,12 +255,7 @@ object EventSearchRanker {
return DescriptionSnippet(text, rebased) return DescriptionSnippet(text, rebased)
} }
private data class Scored( private data class Matched(val hit: SearchHit, val descriptionOnly: Boolean) {
val hit: SearchHit, val event: EventInstance get() = hit.event
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

@@ -22,7 +22,9 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -79,6 +81,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.MatchSpan import de.jeanlucmakiola.calendula.domain.MatchSpan
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SearchHit import de.jeanlucmakiola.calendula.domain.SearchHit
import de.jeanlucmakiola.calendula.domain.SearchMonth
import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.floret.identity.predictiveBack
@@ -89,6 +92,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.floret.locale.localizedDateFormatter
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha 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
@@ -393,6 +397,7 @@ private fun SearchTopBar(
) )
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun SearchResults( private fun SearchResults(
results: SearchUiState.Results, results: SearchUiState.Results,
@@ -401,12 +406,44 @@ private fun SearchResults(
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
onToggle: (Long) -> Unit, onToggle: (Long) -> Unit,
) { ) {
val hits = results.hits
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
// No horizontal inset here: GroupedRow already insets itself, and adding // No horizontal inset here: GroupedRow already insets itself, and adding
// another 16dp made the results narrower than every other list. // another 16dp made the results narrower than every other list.
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp), contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
) {
results.results.months.forEach { month ->
stickyHeader(key = "month-${month.year}-${month.monthNumber}") {
SearchSectionHeader(text = monthLabel(month))
}
hitRows(month.hits, results, selection, inSelection, onEventClick, onToggle)
}
// Held back from the calendar above: these matched nothing in the name or
// the place, only somewhere in the notes.
if (results.results.inDescriptions.isNotEmpty()) {
stickyHeader(key = "in-descriptions") {
SearchSectionHeader(text = stringResource(R.string.search_in_descriptions))
}
hitRows(
results.results.inDescriptions,
results,
selection,
inSelection,
onEventClick,
onToggle,
)
}
}
}
/** One section's rows, sharing a grouped-card run. */
private fun LazyListScope.hitRows(
hits: List<SearchHit>,
results: SearchUiState.Results,
selection: Set<Long>,
inSelection: Boolean,
onEventClick: (EventInstance) -> Unit,
onToggle: (Long) -> Unit,
) { ) {
itemsIndexed( itemsIndexed(
items = hits, items = hits,
@@ -432,6 +469,28 @@ private fun SearchResults(
) )
} }
} }
/** Pinned section label, styled like the agenda's day headers. */
@Composable
private fun SearchSectionHeader(text: String) {
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
)
}
}
/** "August 2026" in the locale's own field order. */
@Composable
private fun monthLabel(month: SearchMonth): String {
val locale = currentLocale()
return remember(month.year, month.monthNumber, locale) {
localizedDateFormatter(locale, "LLLLy")
.format(java.time.LocalDate.of(month.year, month.monthNumber, 1))
}
} }
@Composable @Composable

View File

@@ -6,9 +6,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventSearchRanker import de.jeanlucmakiola.calendula.domain.EventSearch
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SearchHit import de.jeanlucmakiola.calendula.domain.SearchResults
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -44,9 +44,9 @@ sealed interface SearchUiState {
/** A query ran but matched nothing. */ /** A query ran but matched nothing. */
data class Empty(val query: String) : SearchUiState data class Empty(val query: String) : SearchUiState
/** Matches, best first — see [de.jeanlucmakiola.calendula.domain.EventSearchRanker]. */ /** Matches by month — see [de.jeanlucmakiola.calendula.domain.EventSearch]. */
data class Results( data class Results(
val hits: List<SearchHit>, val results: SearchResults,
/** /**
* Calendars among the results that can't be written to (WebCal, birthday * Calendars among the results that can't be written to (WebCal, birthday
* mirrors, …). Their rows are excluded from selection rather than failing * mirrors, …). Their rows are excluded from selection rather than failing
@@ -54,7 +54,7 @@ sealed interface SearchUiState {
*/ */
val readOnlyCalendarIds: Set<Long> = emptySet(), val readOnlyCalendarIds: Set<Long> = emptySet(),
) : SearchUiState { ) : SearchUiState {
val events: List<EventInstance> get() = hits.map { it.event } val events: List<EventInstance> get() = results.allHits.map { it.event }
fun isDeletable(event: EventInstance): Boolean = fun isDeletable(event: EventInstance): Boolean =
event.calendarId !in readOnlyCalendarIds event.calendarId !in readOnlyCalendarIds
@@ -105,17 +105,18 @@ class SearchViewModel @Inject constructor(
} else { } else {
val now = Clock.System.now() val now = Clock.System.now()
val zone = TimeZone.currentSystemDefault() val zone = TimeZone.currentSystemDefault()
val hits = EventSearchRanker.rank( val results = EventSearch.search(
candidates = repository.searchEvents(q), candidates = repository.searchEvents(q),
query = q, query = q,
now = now, now = now,
todayStart = now.toLocalDateTime(zone).date.atStartOfDayIn(zone), todayStart = now.toLocalDateTime(zone).date.atStartOfDayIn(zone),
zone = zone,
) )
if (hits.isEmpty()) { if (results.isEmpty) {
SearchUiState.Empty(q) SearchUiState.Empty(q)
} else { } else {
SearchUiState.Results( SearchUiState.Results(
hits = hits, results = results,
readOnlyCalendarIds = readOnlyCalendarIds(), readOnlyCalendarIds = readOnlyCalendarIds(),
) )
} }

View File

@@ -323,6 +323,7 @@
<item quantity="one">%d selected</item> <item quantity="one">%d selected</item>
<item quantity="other">%d selected</item> <item quantity="other">%d selected</item>
</plurals> </plurals>
<string name="search_in_descriptions">Found in descriptions</string>
<string name="search_selection_close">Cancel selection</string> <string name="search_selection_close">Cancel selection</string>
<string name="search_select_all">Select all</string> <string name="search_select_all">Select all</string>
<string name="search_delete_selected">Delete selected</string> <string name="search_delete_selected">Delete selected</string>

View File

@@ -1,241 +0,0 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import kotlin.time.Instant
/**
* Ranking and matching for search (#80 follow-up): where a query matched decides
* the order, and matching folds case and splits on whitespace.
*/
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,
location: String? = null,
description: String? = null,
startMillis: Long = now.toEpochMilliseconds() + 86_400_000L,
) = SearchCandidate(
event = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = title,
start = Instant.fromEpochMilliseconds(startMillis),
end = Instant.fromEpochMilliseconds(startMillis + 3_600_000L),
isAllDay = false,
color = 0,
location = location,
),
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> =
rank(candidates.toList(), query).map { it.event.title }
@Test
fun `a title match outranks a description match even when it is further off`() {
// The reported case: "protestantischer" in a holiday's description put it
// above the events actually called Test, because it was sooner.
val holiday = candidate(
id = 1L,
title = "Reformationstag",
description = "Feiertag der protestantischer Kirchen",
startMillis = now.toEpochMilliseconds() + 86_400_000L,
)
val real = candidate(
id = 2L,
title = "Test",
startMillis = now.toEpochMilliseconds() + 30 * 86_400_000L,
)
assertThat(titlesFor(holiday, real, query = "test"))
.containsExactly("Test", "Reformationstag")
.inOrder()
}
@Test
fun `a word start outranks a mid-word match inside the same field`() {
val midWord = candidate(1L, "Zahnarzttermin")
val wordStart = candidate(2L, "Neuer Termin beim Amt")
assertThat(titlesFor(midWord, wordStart, query = "termin"))
.containsExactly("Neuer Termin beim Amt", "Zahnarzttermin")
.inOrder()
}
@Test
fun `a mid-word match is still found, so compounds keep working`() {
val compound = candidate(1L, "Zahnarzttermin")
assertThat(titlesFor(compound, query = "termin")).containsExactly("Zahnarzttermin")
}
@Test
fun `the field ranking runs title over location over description`() {
val inDescription = candidate(1L, "Frühstück", description = "im Büro besprechen")
val inLocation = candidate(2L, "Frühstück", location = "Büro")
val inTitle = candidate(3L, "Büro aufräumen")
assertThat(titlesFor(inDescription, inLocation, inTitle, query = "büro"))
.containsExactly("Büro aufräumen", "Frühstück", "Frühstück")
.inOrder()
// The location hit ranks above the description one.
val ranked = rank(listOf(inDescription, inLocation, inTitle), "büro")
assertThat(ranked.map { it.event.eventId }).containsExactly(3L, 2L, 1L).inOrder()
}
@Test
fun `case folds beyond ASCII`() {
val upper = candidate(1L, "ÄRZTE Termin")
assertThat(titlesFor(upper, query = "ärzte")).containsExactly("ÄRZTE Termin")
assertThat(titlesFor(candidate(2L, "ärzte"), query = "ÄRZTE")).containsExactly("ärzte")
}
@Test
fun `every token has to match, in any field and any order`() {
val both = candidate(1L, "Zahnarzt", description = "Termin bestätigen")
val onlyOne = candidate(2L, "Zahnarzt")
val ranked = rank(listOf(both, onlyOne), "termin zahnarzt")
assertThat(ranked.map { it.event.eventId }).containsExactly(1L)
}
@Test
fun `an event ranks by the weakest field the query needed`() {
// Both tokens in the title beats one token stranded in the description.
val allInTitle = candidate(1L, "Zahnarzt Termin")
val split = candidate(2L, "Zahnarzt", description = "Termin bestätigen")
assertThat(titlesFor(split, allInTitle, query = "zahnarzt termin"))
.containsExactly("Zahnarzt Termin", "Zahnarzt")
.inOrder()
}
@Test
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. 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)
assertThat(titlesFor(longer, exact, query = "test"))
.containsExactly("test", "test2")
.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")
val longer = candidate(2L, "Team Meeting with the whole department")
assertThat(titlesFor(longer, shorter, query = "meeting"))
.containsExactly("Team Meeting", "Team Meeting with the whole department")
.inOrder()
}
@Test
fun `ties keep the nearest-first order, upcoming before past`() {
val soon = candidate(1L, "Test", startMillis = now.toEpochMilliseconds() + 86_400_000L)
val later = candidate(2L, "Test", startMillis = now.toEpochMilliseconds() + 5 * 86_400_000L)
val recentPast =
candidate(3L, "Test", startMillis = now.toEpochMilliseconds() - 86_400_000L)
val longAgo =
candidate(4L, "Test", startMillis = now.toEpochMilliseconds() - 90 * 86_400_000L)
val ranked = rank(listOf(longAgo, later, recentPast, soon), "test")
assertThat(ranked.map { it.event.eventId }).containsExactly(1L, 2L, 3L, 4L).inOrder()
}
@Test
fun `the title carries the spans to highlight, merged where they overlap`() {
val event = candidate(1L, "Test the tester")
val hit = rank(listOf(event), "test").single()
assertThat(hit.titleSpans).containsExactly(MatchSpan(0, 4), MatchSpan(9, 13)).inOrder()
}
@Test
fun `a description snippet is excerpted around its match`() {
val long = "x".repeat(300) + " geheimwort " + "y".repeat(300)
val event = candidate(1L, "Notiz", description = long)
val snippet = rank(listOf(event), "geheimwort")
.single()
.descriptionSnippet
requireNotNull(snippet)
assertThat(snippet.text).contains("geheimwort")
assertThat(snippet.text.length).isLessThan(long.length)
assertThat(snippet.text).startsWith("")
// The span points at the match inside the excerpt, not the full text.
val span = snippet.spans.single()
assertThat(snippet.text.substring(span.start, span.end)).isEqualTo("geheimwort")
}
@Test
fun `a title-only match carries no description snippet`() {
val event = candidate(1L, "Test", description = "nothing relevant here")
assertThat(rank(listOf(event), "test").single().descriptionSnippet)
.isNull()
}
@Test
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 = rank(listOf(event), "zweite").single().descriptionSnippet
requireNotNull(snippet)
assertThat(snippet.text).isEqualTo("erste Zeile zweite Zeile")
}
@Test
fun `a blank query matches nothing`() {
assertThat(rank(listOf(candidate(1L, "Test")), " ")).isEmpty()
}
}

View File

@@ -0,0 +1,253 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
import kotlin.time.Instant
/**
* Search matching and how the results are laid out (#80 follow-up): a calendar
* of months rather than a ranking, with description-only hits held back.
*/
class EventSearchTest {
private val zone = TimeZone.UTC
/** 2027-01-15T08:00Z — mid-month and mid-day, so both sides of each are testable. */
private val now = Instant.parse("2027-01-15T08:00:00Z")
private val todayStart = Instant.parse("2027-01-15T00:00:00Z")
private var nextId = 1L
private fun candidate(
title: String,
start: String = "2027-01-20T09:00:00Z",
location: String? = null,
description: String? = null,
): SearchCandidate {
val begin = Instant.parse(start)
return SearchCandidate(
event = EventInstance(
instanceId = nextId,
eventId = nextId++,
calendarId = 1L,
title = title,
start = begin,
end = begin.plus(kotlin.time.Duration.parse("1h")),
isAllDay = false,
color = 0,
location = location,
),
description = description,
)
}
private fun search(vararg candidates: SearchCandidate, query: String): SearchResults =
EventSearch.search(candidates.toList(), query, now, todayStart, zone)
private fun datedTitles(results: SearchResults): List<String> =
results.months.flatMap { month -> month.hits.map { it.event.title } }
@Test
fun `hits are grouped into months, this one first, then ahead, then back`() {
val thisMonth = candidate("Test A", start = "2027-01-20T09:00:00Z")
val nextMonth = candidate("Test B", start = "2027-02-03T09:00:00Z")
val lastMonth = candidate("Test C", start = "2026-12-10T09:00:00Z")
val yearBefore = candidate("Test D", start = "2026-03-10T09:00:00Z")
val results = search(yearBefore, nextMonth, lastMonth, thisMonth, query = "test")
assertThat(results.months.map { it.year to it.monthNumber })
.containsExactly(2027 to 1, 2027 to 2, 2026 to 12, 2026 to 3)
.inOrder()
assertThat(datedTitles(results)).containsExactly("Test A", "Test B", "Test C", "Test D")
.inOrder()
}
@Test
fun `inside a month what is still to come comes before what has passed`() {
// Both sides of today share one January card rather than two headers.
val earlierThisMonth = candidate("Test past", start = "2027-01-05T09:00:00Z")
val laterThisMonth = candidate("Test soon", start = "2027-01-20T09:00:00Z")
val results = search(earlierThisMonth, laterThisMonth, query = "test")
assertThat(results.months).hasSize(1)
assertThat(datedTitles(results)).containsExactly("Test soon", "Test past").inOrder()
}
@Test
fun `an event still running today keeps its place among the upcoming ones`() {
// Reported: "test" at 19:00 having passed must not fall behind "test2" at
// 23:00 — the day is the boundary, not the moment.
val earlierToday = candidate("test", start = "2027-01-15T05:00:00Z")
val laterToday = candidate("test2", start = "2027-01-15T20:00:00Z")
val results = search(laterToday, earlierToday, query = "test")
assertThat(datedTitles(results)).containsExactly("test", "test2").inOrder()
}
@Test
fun `a match tomorrow comes before one from yesterday`() {
val yesterday = candidate("test", start = "2027-01-14T09:00:00Z")
val tomorrow = candidate("test2", start = "2027-01-16T09:00:00Z")
val results = search(yesterday, tomorrow, query = "test")
assertThat(datedTitles(results)).containsExactly("test2", "test").inOrder()
}
@Test
fun `a description-only hit is held back from the dated months`() {
// The original complaint: "protestantischer" in a holiday's notes landed
// among the events actually named Test.
val holiday = candidate(
"Reformationstag",
start = "2027-01-16T09:00:00Z",
description = "Feiertag der protestantischer Kirchen",
)
val real = candidate("Test", start = "2027-06-01T09:00:00Z")
val results = search(holiday, real, query = "test")
assertThat(datedTitles(results)).containsExactly("Test")
assertThat(results.inDescriptions.map { it.event.title }).containsExactly("Reformationstag")
}
@Test
fun `a location match stays in the dated months`() {
val located = candidate("Frühstück", location = "Büro")
val results = search(located, query = "büro")
assertThat(datedTitles(results)).containsExactly("Frühstück")
assertThat(results.inDescriptions).isEmpty()
}
@Test
fun `an event named for one token stays dated even if another is in the notes`() {
val mixed = candidate("Zahnarzt", description = "Termin bestätigen")
val results = search(mixed, query = "zahnarzt termin")
assertThat(datedTitles(results)).containsExactly("Zahnarzt")
assertThat(results.inDescriptions).isEmpty()
}
@Test
fun `every token has to match, in any field and any order`() {
val both = candidate("Zahnarzt", description = "Termin bestätigen")
val onlyOne = candidate("Zahnarzt")
val results = search(both, onlyOne, query = "termin zahnarzt")
assertThat(results.allHits.map { it.event.eventId }).containsExactly(both.event.eventId)
}
@Test
fun `a mid-word match is still found, so compounds keep working`() {
val compound = candidate("Zahnarzttermin")
assertThat(datedTitles(search(compound, query = "termin"))).containsExactly("Zahnarzttermin")
}
@Test
fun `case folds beyond ASCII`() {
assertThat(datedTitles(search(candidate("ÄRZTE Termin"), query = "ärzte")))
.containsExactly("ÄRZTE Termin")
assertThat(datedTitles(search(candidate("ärzte"), query = "ÄRZTE")))
.containsExactly("ärzte")
}
@Test
fun `an all-day event is filed by its own day, not by UTC`() {
// Behind UTC an all-day event sits at midnight UTC, which is the previous
// month locally (#82) — the month has to come from the span's first day.
val newYear = SearchCandidate(
event = EventInstance(
instanceId = 99L,
eventId = 99L,
calendarId = 1L,
title = "Test holiday",
start = Instant.parse("2027-02-01T00:00:00Z"),
end = Instant.parse("2027-02-02T00:00:00Z"),
isAllDay = true,
color = 0,
location = null,
),
)
val results = EventSearch.search(
listOf(newYear),
"test",
now,
todayStart,
TimeZone.of("America/New_York"),
)
assertThat(results.months.single().let { it.year to it.monthNumber }).isEqualTo(2027 to 2)
}
@Test
fun `a hit that has already finished is marked past, including earlier today`() {
val earlierToday = candidate("test", start = "2027-01-15T05:00:00Z")
val laterToday = candidate("test", start = "2027-01-15T20:00:00Z")
val hits = search(earlierToday, laterToday, query = "test")
.allHits
.associateBy { it.event.eventId }
assertThat(hits.getValue(earlierToday.event.eventId).isPast).isTrue()
assertThat(hits.getValue(laterToday.event.eventId).isPast).isFalse()
}
@Test
fun `the title carries the spans to highlight, merged where they overlap`() {
val hit = search(candidate("Test the tester"), query = "test").allHits.single()
assertThat(hit.titleSpans).containsExactly(MatchSpan(0, 4), MatchSpan(9, 13)).inOrder()
}
@Test
fun `a description snippet is excerpted around its match`() {
val long = "x".repeat(300) + " geheimwort " + "y".repeat(300)
val snippet = search(candidate("Notiz", description = long), query = "geheimwort")
.allHits
.single()
.descriptionSnippet
requireNotNull(snippet)
assertThat(snippet.text).contains("geheimwort")
assertThat(snippet.text.length).isLessThan(long.length)
assertThat(snippet.text).startsWith("")
val span = snippet.spans.single()
assertThat(snippet.text.substring(span.start, span.end)).isEqualTo("geheimwort")
}
@Test
fun `a title-only match carries no description snippet`() {
val hit = search(candidate("Test", description = "nothing relevant here"), query = "test")
.allHits
.single()
assertThat(hit.descriptionSnippet).isNull()
}
@Test
fun `a multi-line description matches and snippets as one line`() {
val snippet = search(
candidate("Notiz", description = "erste Zeile\n\n zweite Zeile"),
query = "zweite",
).allHits.single().descriptionSnippet
requireNotNull(snippet)
assertThat(snippet.text).isEqualTo("erste Zeile zweite Zeile")
}
@Test
fun `a blank query matches nothing`() {
assertThat(search(candidate("Test"), query = " ").isEmpty).isTrue()
}
}