fix(detail): open events whose series starts before 1970 (#34)

DTSTART is stored as UTC epoch millis, so a recurring series anchored
before 1970-01-01 (common for yearly birthdays/anniversaries synced over
CalDAV) has a legitimately negative DTSTART. The detail and search
mappers dropped any row with dtstart < 0, and since the detail query
reads the series-master DTSTART (the ancient anchor), every occurrence of
such a series became un-openable — surfacing as the generic
"Something went wrong." error screen — and the events vanished from
search too.

Relax the guard to reject only an *absent* DTSTART (isNull), which is the
malformed case it was meant to catch; negative epoch millis flow through
correctly (Instant/formatting and the all-day reminder decode are all
Long-based). Add regression tests for both mappers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 16:05:33 +02:00
parent 2239c38ecc
commit 974d65f619
4 changed files with 76 additions and 7 deletions

View File

@@ -23,12 +23,16 @@ internal fun ColumnReader.toEventDetailCore(
attendees: List<Attendee>,
reminders: List<Reminder>,
): EventDetail? {
val begin = getLong(EventDetailProjection.IDX_DTSTART)
if (begin < 0L) {
Log.w(TAG, "Dropping event with negative dtstart=$begin")
// DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
// negative — only an *absent* DTSTART marks a malformed row worth dropping.
// Dropping negatives made every occurrence of such a series un-openable
// (the detail loads the ancient series-master DTSTART), see issue #34.
if (isNull(EventDetailProjection.IDX_DTSTART)) {
Log.w(TAG, "Dropping event with missing dtstart")
return null
}
val begin = getLong(EventDetailProjection.IDX_DTSTART)
// Recurring events store DURATION instead of DTEND, so the series row's
// DTEND is null. Keep the event (end == begin); callers that opened a

View File

@@ -10,8 +10,11 @@ import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
* of DTEND — reconstruct the end the same way the `.ics` export does.
*/
internal fun ColumnReader.toSearchResult(): EventInstance? {
// A pre-1970 series anchor is a legitimately negative epoch-millis DTSTART
// (see EventDetailMapper / issue #34); drop only a genuinely absent one, so
// long-running birthdays/anniversaries still surface in search.
if (isNull(SearchProjection.IDX_DTSTART)) return null
val dtStart = getLong(SearchProjection.IDX_DTSTART)
if (dtStart < 0L) return null
val end = when {
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))