From 76846420a2b80ab7f0eaedf9bdb1cfc9856b3c19 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 17:11:51 +0200 Subject: [PATCH] fix(detail): clamp a backwards DTEND instead of dropping the event (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toEventDetailCore returned null when a present DTEND preceded DTSTART, the only remaining false-drop that surfaces as the generic "Something went wrong." error screen — the same un-openable trap as the pre-1970 DTSTART bug, and worse because the user can't even open the malformed event to fix it. Clamp the end to DTSTART (a zero-length event) instead, matching how SearchMapper already coerces its end. After this the detail mapper drops a row only when DTSTART is genuinely absent (unrenderable). Co-Authored-By: Claude Opus 4.8 --- .../calendula/data/calendar/EventDetailMapper.kt | 13 ++++++------- .../data/calendar/EventDetailMapperTest.kt | 9 +++++++-- 2 files changed, 13 insertions(+), 9 deletions(-) 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 d441fd8..e6de991 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 @@ -37,16 +37,15 @@ internal fun ColumnReader.toEventDetailCore( // Recurring events store DURATION instead of DTEND, so the series row's // DTEND is null. Keep the event (end == begin); callers that opened a // specific occurrence supply the real per-occurrence times from - // CalendarContract.Instances. Only a present-but-backwards DTEND is malformed. + // CalendarContract.Instances. A present-but-backwards DTEND is malformed, + // but dropping the row would make the event un-openable — the same trap as + // the pre-1970 DTSTART bug above (issue #34): it would surface as the + // generic error screen with no way to open the event and fix it. Clamp to a + // zero-length event instead (matching SearchMapper's coerceAtLeast). val end = if (isNull(EventDetailProjection.IDX_DTEND)) { begin } else { - val rawEnd = getLong(EventDetailProjection.IDX_DTEND) - if (rawEnd < begin) { - Log.w(TAG, "Dropping event with dtend=$rawEnd < dtstart=$begin") - return null - } - rawEnd + getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin) } // Kept raw (no untitled fallback): the detail screen substitutes its own 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 9870cde..299d2fe 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 @@ -120,9 +120,14 @@ class EventDetailMapperTest { } @Test - fun `dtend before dtstart drops detail`() { + fun `dtend before dtstart is clamped to a zero-length event, not dropped`() { + // A backwards DTEND is malformed, but dropping it would make the event + // un-openable (the "Something went wrong" trap, same as issue #34); keep + // it as a zero-length event so the user can still open and fix it. val detail = detailReader(dtstart = 2000L, dtend = 1000L).toDetail() - assertThat(detail).isNull() + assertThat(detail).isNotNull() + assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(2000L) + assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(2000L) } @Test