From 5457a3428254ebd527c19dcede22855c3f3c5325 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 23 Jun 2026 10:51:28 +0200 Subject: [PATCH] feat(search): show a recurring result's next occurrence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A recurring master's DTSTART is the series start, which for long-running series reads as an old date and sorts into the past. For recurring hits (non-empty RRULE/RDATE), resolve the occurrence nearest to now via the Instances provider — the soonest upcoming within ~2 years, else the most recent past — and display and sort by that. Falls back to the series start when no occurrence lies in the window. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../data/calendar/CalendarDataSource.kt | 66 ++++++++++++++++++- .../calendula/data/calendar/Projections.kt | 6 ++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index b93b5f8..8e02288 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -329,7 +329,64 @@ class AndroidCalendarDataSource @Inject constructor( selection, arrayOf(like, like, like), CalendarContract.Events.DTSTART + " DESC", - )?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toSearchResult() } } ?: emptyList() + )?.use { c -> + val reader = CursorColumnReader(c) + val out = ArrayList(c.count) + while (c.moveToNext()) { + val base = reader.toSearchResult() ?: continue + // A recurring master's DTSTART is the series start; show its + // nearest occurrence instead so the date is the one the user + // actually cares about (and sorting reflects it). + val recurring = !reader.getString(SearchProjection.IDX_RRULE).isNullOrEmpty() || + !reader.getString(SearchProjection.IDX_RDATE).isNullOrEmpty() + out += if (recurring) { + nearestOccurrenceMillis(base.eventId)?.let { (begin, end) -> + base.copy( + start = begin.toKotlinInstantFromEpochMillis(), + end = end.toKotlinInstantFromEpochMillis(), + ) + } ?: base + } else { + base + } + } + out + } ?: emptyList() + } + + /** + * The occurrence of [eventId] nearest to now: the soonest upcoming one + * within [OCCURRENCE_WINDOW_MILLIS] ahead, else the most recent past one + * within the same window behind. Null when neither exists (e.g. a series + * that only starts further out than the window) — the caller then keeps the + * series-start DTSTART. Returns (begin, end) epoch millis. + */ + private fun nearestOccurrenceMillis(eventId: Long): Pair? { + val now = System.currentTimeMillis() + return occurrenceInWindow(eventId, now, now + OCCURRENCE_WINDOW_MILLIS, soonestFirst = true) + ?: occurrenceInWindow(eventId, now - OCCURRENCE_WINDOW_MILLIS, now, soonestFirst = false) + } + + private fun occurrenceInWindow( + eventId: Long, + beginMillis: Long, + endMillis: Long, + soonestFirst: Boolean, + ): Pair? { + val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply { + ContentUris.appendId(this, beginMillis) + ContentUris.appendId(this, endMillis) + }.build() + val order = CalendarContract.Instances.BEGIN + if (soonestFirst) " ASC" else " DESC" + return resolver.query( + uri, + arrayOf(CalendarContract.Instances.BEGIN, CalendarContract.Instances.END), + "${CalendarContract.Instances.EVENT_ID} = ?", + arrayOf(eventId.toString()), + order, + )?.use { c -> + if (!c.moveToFirst()) null else c.getLong(0) to c.getLong(1) + } } override fun eventDetail(eventId: Long): EventDetail? { @@ -989,5 +1046,12 @@ class AndroidCalendarDataSource @Inject constructor( * together (by account) in the filter sheet and calendar manager. */ const val LOCAL_ACCOUNT_NAME = "Calendula" + + /** + * How far ahead/behind a search looks for a recurring event's nearest + * occurrence (~2 years). Wide enough for everyday series; a series that + * next fires beyond it falls back to its series-start date. + */ + const val OCCURRENCE_WINDOW_MILLIS = 2L * 365 * 24 * 60 * 60 * 1000 } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt index 619ec7c..5a362bf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt @@ -157,6 +157,10 @@ internal object SearchProjection { CalendarContract.Events.EVENT_COLOR, CalendarContract.Events.CALENDAR_COLOR, CalendarContract.Events.EVENT_LOCATION, + // Recurrence markers: a non-empty RRULE or RDATE means the result should + // display its nearest occurrence, not the series-start DTSTART. + CalendarContract.Events.RRULE, + CalendarContract.Events.RDATE, ) const val IDX_ID = 0 @@ -169,6 +173,8 @@ internal object SearchProjection { const val IDX_EVENT_COLOR = 7 const val IDX_CALENDAR_COLOR = 8 const val IDX_LOCATION = 9 + const val IDX_RRULE = 10 + const val IDX_RDATE = 11 } internal object AttendeeProjection {