From 974d65f619d1239a4e73d3f775e935f291a1f778 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 16:05:33 +0200 Subject: [PATCH] fix(detail): open events whose series starts before 1970 (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../data/calendar/EventDetailMapper.kt | 12 +++-- .../calendula/data/calendar/SearchMapper.kt | 5 ++- .../data/calendar/EventDetailMapperTest.kt | 21 ++++++++- .../data/calendar/SearchMapperTest.kt | 45 +++++++++++++++++++ 4 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapperTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt index 4a59358..d441fd8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt @@ -23,12 +23,16 @@ internal fun ColumnReader.toEventDetailCore( attendees: List, reminders: List, ): 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt index 4c09781..f83733d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt @@ -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)) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt index 6ef9528..9870cde 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt @@ -22,8 +22,8 @@ class EventDetailMapperTest { eventColor: Any? = null, eventColorKey: String? = null, calendarColor: Int = 0xFFAABBCC.toInt(), - dtstart: Long = 1_000_000_000L, - dtend: Long = 1_000_003_600L, + dtstart: Long? = 1_000_000_000L, + dtend: Long? = 1_000_003_600L, allDay: Int = 0, location: String? = "Berlin", calendarId: Long = 7L, @@ -125,6 +125,23 @@ class EventDetailMapperTest { assertThat(detail).isNull() } + @Test + fun `pre-1970 negative dtstart is kept, not dropped (issue #34)`() { + // A yearly birthday/anniversary anchored before the epoch has a + // legitimately negative UTC epoch-millis DTSTART; recurring rows carry + // no DTEND (they use DURATION), so it stays end == begin. + val begin = -157_766_400_000L // 1965-01-01T00:00:00Z + val detail = detailReader(dtstart = begin, dtend = null).toDetail() + assertThat(detail).isNotNull() + assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(begin) + assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(begin) + } + + @Test + fun `absent dtstart drops detail`() { + assertThat(detailReader(dtstart = null).toDetail()).isNull() + } + @Test fun `rrule passes through when present`() { val detail = detailReader(rrule = "FREQ=WEEKLY;BYDAY=MO").toDetail() diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapperTest.kt new file mode 100644 index 0000000..9b1bb30 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapperTest.kt @@ -0,0 +1,45 @@ +package de.jeanlucmakiola.calendula.data.calendar + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class SearchMapperTest { + + private fun searchReader( + id: Long = 1L, + calendarId: Long = 7L, + title: String? = "Birthday", + dtstart: Long? = 1_000_000_000L, + dtend: Long? = null, + duration: String? = "P1D", + allDay: Int = 1, + eventColor: Any? = null, + calendarColor: Int = 0xFFAABBCC.toInt(), + location: String? = null, + ): MapColumnReader = MapColumnReader( + SearchProjection.IDX_ID to id, + SearchProjection.IDX_CALENDAR_ID to calendarId, + SearchProjection.IDX_TITLE to title, + SearchProjection.IDX_DTSTART to dtstart, + SearchProjection.IDX_DTEND to dtend, + SearchProjection.IDX_DURATION to duration, + SearchProjection.IDX_ALL_DAY to allDay, + SearchProjection.IDX_EVENT_COLOR to eventColor, + SearchProjection.IDX_CALENDAR_COLOR to calendarColor, + SearchProjection.IDX_LOCATION to location, + ) + + @Test + fun `pre-1970 negative dtstart still surfaces in search (issue #34)`() { + val begin = -157_766_400_000L // 1965-01-01T00:00:00Z + val result = searchReader(dtstart = begin, dtend = null, duration = "P1D").toSearchResult() + assertThat(result).isNotNull() + assertThat(result!!.start.toEpochMilliseconds()).isEqualTo(begin) + assertThat(result.title).isEqualTo("Birthday") + } + + @Test + fun `absent dtstart drops the search hit`() { + assertThat(searchReader(dtstart = null).toSearchResult()).isNull() + } +}