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.EventForm
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.Reminder
import de.jeanlucmakiola.calendula.domain.SearchCandidate
@@ -68,7 +68,7 @@ interface CalendarDataSource {
* whitespace-separated token has to appear in the title, description or
* location. Reads the Events table directly so the search is unbounded in
* 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.
*/
fun searchEvents(query: String): List<SearchCandidate>
@@ -591,17 +591,17 @@ class AndroidCalendarDataSource @Inject constructor(
override fun searchEvents(query: String): List<SearchCandidate> {
ensureObserversRegistered()
val tokens = EventSearchRanker.tokenize(query).take(MAX_SEARCH_TOKENS)
val tokens = EventSearch.tokenize(query).take(MAX_SEARCH_TOKENS)
if (tokens.isEmpty()) return emptyList()
// 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.
val match = tokens.joinToString(" AND ") {
"(${CalendarContract.Events.TITLE} LIKE ? ESCAPE '\\' OR " +
"${CalendarContract.Events.DESCRIPTION} LIKE ? ESCAPE '\\' OR " +
"${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 " +
"${CalendarContract.Events.DELETED} = 0 AND " +
"${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
* non-ASCII cased letter is substituted with the single-character wildcard
* `_` — "ä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.
*/
private fun likePattern(token: String): String {

View File

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

View File

@@ -1,5 +1,9 @@
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
/** An event with the free text the search may have matched it on. */
@@ -30,36 +34,49 @@ data class SearchHit(
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
* 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
* ASCII only — "ärzte" has to find "Ärzte".
*
* Ranking answers the complaint behind #80's follow-up: a description hit used
* to outrank a title hit purely by being sooner. A token scores by the field it
* matched ([FIELD_TITLE] > [FIELD_LOCATION] > [FIELD_DESCRIPTION]) and by how it
* 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.
* 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.
* Results are a calendar rather than a ranking: hits are grouped by the month
* they fall in, the current month first, then the months ahead, then the months
* behind counting backwards. Inside a month what is still to come reads
* forwards and what has passed follows it, so an event tomorrow sits above one
* from yesterday. The day is the boundary, not the moment — an event that ended
* at 19:00 is still today's business and keeps its place in today's order.
*
* An event the query only reached through its description is held back in
* [SearchResults.inDescriptions]: those are the matches that made search
* confusing when a stray word in a holiday's notes landed among events actually
* named for the query.
*/
object EventSearchRanker {
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
object EventSearch {
/** Characters of description kept around the match in a row's snippet. */
private const val SNIPPET_LENGTH = 96
@@ -77,44 +94,84 @@ object EventSearchRanker {
query.trim().split(WHITESPACE).filter { it.isNotEmpty() }
/**
* Rank [candidates] against [query], dropping any that don't carry every
* 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.
* Search [candidates] for [query], dropping any that don't carry every token.
* [now] marks an individual event as over; [todayStart] is where an event
* stops being current, so everything still on today's page keeps its place
* 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>,
query: String,
now: Instant,
todayStart: Instant,
): List<SearchHit> {
zone: TimeZone,
): SearchResults {
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) }
return scored
.sortedWith(
compareByDescending<Scored> { it.weakest }
.thenByDescending { it.total }
.thenBy { if (it.current) 0 else 1 }
.thenByDescending { it.titleCoverage }
.thenComparator { a, b ->
val aStart = a.hit.event.start
val bStart = b.hit.event.start
// 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 }
val matched = candidates.mapNotNull { candidate -> match(candidate, tokens, now) }
val order = compareBy<Matched> { if (it.event.end >= todayStart) 0 else 1 }
.thenComparator { a, b ->
// What is left reads forwards; what is behind us reads backwards
// from today, nearest first.
if (a.event.end >= todayStart) {
a.event.start.compareTo(b.event.start)
} else {
b.event.start.compareTo(a.event.start)
}
}
val (inDescriptions, dated) = matched.sortedWith(order).partition { it.descriptionOnly }
return SearchResults(
months = groupByMonth(dated, zone, todayStart),
inDescriptions = inDescriptions.map { it.hit },
)
}
private fun score(
/**
* 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,
tokens: List<String>,
now: Instant,
todayStart: Instant,
): Scored? {
): Matched? {
val title = candidate.event.title
val location = candidate.event.location?.takeIf { it.isNotBlank() }
// Collapsed once so a multi-line description matches and snippets the
@@ -124,80 +181,34 @@ object EventSearchRanker {
?.trim()
?.takeIf { it.isNotEmpty() }
var weakest = Int.MAX_VALUE
var total = 0
for (token in tokens) {
val best = bestScore(token, title, location, description) ?: return null
weakest = minOf(weakest, best)
total += best
// Mid-word counts as a match — German compounds mean "termin" has to keep
// finding "Zahnarzttermin".
val present = { token: String ->
title.contains(token, ignoreCase = true) ||
location?.contains(token, ignoreCase = true) == true ||
description?.contains(token, ignoreCase = true) == true
}
if (!tokens.all(present)) return null
val descriptionSpans = description?.let { spansIn(it, tokens) }.orEmpty()
val titleSpans = spansIn(title, tokens)
return Scored(
val locationSpans = location?.let { spansIn(it, tokens) }.orEmpty()
return Matched(
hit = SearchHit(
event = candidate.event,
titleSpans = titleSpans,
locationSpans = location?.let { spansIn(it, tokens) }.orEmpty(),
locationSpans = locationSpans,
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,
// Nothing in the name or the place the query could have caught on —
// the only reason this event is here is buried in its notes.
descriptionOnly = titleSpans.isEmpty() && locationSpans.isEmpty(),
)
}
/**
* 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. */
private fun spansIn(text: String, tokens: List<String>): List<MatchSpan> {
val raw = mutableListOf<MatchSpan>()
@@ -244,12 +255,7 @@ object EventSearchRanker {
return DescriptionSnippet(text, rebased)
}
private data class Scored(
val hit: SearchHit,
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,
)
private data class Matched(val hit: SearchHit, val descriptionOnly: Boolean) {
val event: EventInstance get() = hit.event
}
}

View File

@@ -22,7 +22,9 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
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.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SearchHit
import de.jeanlucmakiola.calendula.domain.SearchMonth
import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.identity.fadeThrough
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.SnackChipMargin
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.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
@@ -393,6 +397,7 @@ private fun SearchTopBar(
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SearchResults(
results: SearchUiState.Results,
@@ -401,39 +406,93 @@ private fun SearchResults(
onEventClick: (EventInstance) -> Unit,
onToggle: (Long) -> Unit,
) {
val hits = results.hits
LazyColumn(
modifier = Modifier.fillMaxSize(),
// No horizontal inset here: GroupedRow already insets itself, and adding
// another 16dp made the results narrower than every other list.
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
) {
itemsIndexed(
items = hits,
key = { _, hit -> hit.event.eventId },
) { index, hit ->
val event = hit.event
val deletable = results.isDeletable(event)
SearchResultRow(
hit = hit,
position = positionOf(index, hits.size),
modifier = animateItemMotion(),
selected = event.eventId in selection,
// A read-only calendar's row can't join a batch, so in selection
// mode it goes quiet rather than offering a checkbox that fails.
inSelection = inSelection,
selectable = deletable,
onClick = when {
inSelection && deletable -> ({ onToggle(event.eventId) })
inSelection -> null
else -> ({ onEventClick(event) })
},
onLongClick = { onToggle(event.eventId) }.takeIf { deletable && !inSelection },
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(
items = hits,
key = { _, hit -> hit.event.eventId },
) { index, hit ->
val event = hit.event
val deletable = results.isDeletable(event)
SearchResultRow(
hit = hit,
position = positionOf(index, hits.size),
modifier = animateItemMotion(),
selected = event.eventId in selection,
// A read-only calendar's row can't join a batch, so in selection
// mode it goes quiet rather than offering a checkbox that fails.
inSelection = inSelection,
selectable = deletable,
onClick = when {
inSelection && deletable -> ({ onToggle(event.eventId) })
inSelection -> null
else -> ({ onEventClick(event) })
},
onLongClick = { onToggle(event.eventId) }.takeIf { deletable && !inSelection },
)
}
}
/** 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
private fun SearchResultRow(
hit: SearchHit,

View File

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

View File

@@ -323,6 +323,7 @@
<item quantity="one">%d selected</item>
<item quantity="other">%d selected</item>
</plurals>
<string name="search_in_descriptions">Found in descriptions</string>
<string name="search_selection_close">Cancel selection</string>
<string name="search_select_all">Select all</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()
}
}