diff --git a/CHANGELOG.md b/CHANGELOG.md index 5435a0a..0c7cbe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **A deleted occurrence no longer comes back when the series is re-timed.** + Delete a single occurrence of a repeating event, then change the time of the + whole series, and the occurrence you had removed reappeared. Doing it the + other way round — "This and all following events" — lost every removal in the + part of the series being changed. Removals now travel with the event: they + keep their place in the series across a time change, a move to a different + day, a timezone change and a switch to or from an all-day event, and they + carry over when a series is split. Repeating events on your device's own + calendars are affected, including the birthday and anniversary calendars + Calendula creates from your contacts ([#248]). + ## [2.19.3] — 2026-08-22 ### Added @@ -1471,3 +1483,4 @@ automatically, with zero telemetry and no internet permission. [#192]: https://codeberg.org/jlmakiola/calendula/issues/192 [#196]: https://codeberg.org/jlmakiola/calendula/issues/196 [#214]: https://codeberg.org/jlmakiola/calendula/issues/214 +[#248]: https://codeberg.org/jlmakiola/calendula/issues/248 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 1b8507f..9f3e74d 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 @@ -976,10 +976,12 @@ class AndroidCalendarDataSource @Inject constructor( updated: EventForm, allDayReminderTimeMinutes: Int, ) { + val row = querySeriesRow(eventId) val values = buildEventUpdateValues( original = original, updated = updated, - seriesDtStartMillis = querySeriesRow(eventId).dtStartMillis, + seriesDtStartMillis = row.dtStartMillis, + seriesExdate = row.exdate, zone = ZoneId.systemDefault(), ) if (values.isNotEmpty()) { @@ -1228,10 +1230,66 @@ class AndroidCalendarDataSource @Inject constructor( } // Insert the new series first: if it fails, the original is untouched. val newEventId = insertEvent(updated, allDayReminderTimeMinutes) + carrySplitExdate(newEventId, row, beginMillis, original, updated) truncateSeries(eventId, row, beginMillis) return newEventId } + /** + * Carry the [parent]'s exclusions for occurrences past [beginMillis] onto the + * series [newEventId] that now owns them, re-timed by the shift the split + * applied ([shiftedExdate]/[exdateAfter]). [insertEvent] builds the new row + * from the form, which knows nothing about them, so without this every + * occurrence the user had deleted from the tail of the series comes back. + * + * The whole time/recurrence set rides along with EXDATE for the reason + * [buildOccurrenceExdateValues] documents: on its own the provider does not + * read an EXDATE write as a recurrence change, and leaves the instances it + * expanded on insert standing. + * + * A failure rolls the new series back and throws, so the split fails whole + * rather than landing with the exclusions quietly dropped — the parent is + * still untruncated at this point, so the event is left exactly as it was. + */ + private fun carrySplitExdate( + newEventId: Long, + parent: SeriesRow, + beginMillis: Long, + original: EventForm, + updated: EventForm, + ) { + // Dropping the recurrence in the same save leaves a one-off tail, and an + // exclusion means nothing on a row that doesn't recur. + if (updated.rrule.isNullOrBlank()) return + val carried = shiftedExdate( + existingExdate = exdateAfter(parent.exdate, beginMillis, original.isAllDay), + original = original, + updated = updated, + zone = ZoneId.systemDefault(), + ) ?: return + val row = querySeriesRow(newEventId) + val values = ContentValues().apply { + put(CalendarContract.Events.EXDATE, carried) + put(CalendarContract.Events.DTSTART, row.dtStartMillis) + put(CalendarContract.Events.RRULE, row.rrule) + put(CalendarContract.Events.DURATION, row.duration) + put(CalendarContract.Events.EVENT_TIMEZONE, row.timezone) + put(CalendarContract.Events.ALL_DAY, row.allDay) + } + try { + val rows = resolver.update( + ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, newEventId), + values, null, null, + ) + if (rows == 0) { + throw WriteFailedException("carry exdate onto split series id=$newEventId") + } + } catch (t: Throwable) { + runCatching { deleteEvent(newEventId) } + throw t + } + } + override fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long) { val row = querySeriesRow(eventId) // From the first occurrence on = the whole series; also the fallback diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt index 8e64db5..f82a390 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt @@ -10,6 +10,9 @@ import java.time.Duration import java.time.Instant import java.time.ZoneId import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.time.format.ResolverStyle +import java.time.LocalDate as JavaLocalDate import java.time.LocalDateTime as JavaLocalDateTime /** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */ @@ -134,18 +137,21 @@ internal fun buildEventInsertValues( * * Time fields travel together (the provider validates them as a unit): * - unchanged times, all-day flag and rrule → no time columns at all; - * - non-recurring result → DTSTART/DTEND, DURATION and RRULE cleared; + * - non-recurring result → DTSTART/DTEND, DURATION, RRULE and EXDATE cleared; * - recurring result → the *series* DTSTART moves by the same **wall-clock** * shift the user applied to the displayed occurrence and is re-resolved in the * event's zone ([seriesDtStartMillis] is the row's current DTSTART), DURATION - * replaces DTEND, RRULE is written. This keeps past occurrences intact when - * someone edits a later occurrence's time, and keeps the anchor's time-of-day - * stable across a DST boundary or a zone change between the two. + * replaces DTEND, RRULE is written, and the row's exclusions + * ([seriesExdate], the current EXDATE) travel with the anchor. This keeps past + * occurrences intact when someone edits a later occurrence's time, and keeps + * the anchor's time-of-day stable across a DST boundary or a zone change + * between the two. */ internal fun buildEventUpdateValues( original: EventForm, updated: EventForm, seriesDtStartMillis: Long, + seriesExdate: String?, zone: ZoneId, ): Map = buildMap { if (updated.title.trim() != original.title.trim()) { @@ -185,6 +191,10 @@ internal fun buildEventUpdateValues( put(CalendarContract.Events.DTEND, newTimes.dtEndMillis) put(CalendarContract.Events.RRULE, null) put(CalendarContract.Events.DURATION, null) + // The exclusions named occurrences of a series that no longer exists. + // Left behind they are dormant rather than harmless: adding a recurrence + // back later would punch the old holes into the new one. + put(CalendarContract.Events.EXDATE, null) } else { // Move the series anchor by the *wall-clock* shift the user applied to the // displayed occurrence, then re-resolve it in the event's (possibly new) @@ -206,6 +216,14 @@ internal fun buildEventUpdateValues( put(CalendarContract.Events.DTEND, null) put(CalendarContract.Events.RRULE, updated.rrule) put(CalendarContract.Events.DURATION, newTimes.toRfc2445Duration(updated.isAllDay)) + // An EXDATE stamp is an absolute instant, so it excludes an occurrence + // only while the series keeps generating one at exactly that instant. The + // anchor has just moved, so every occurrence regenerates elsewhere and a + // stamp left behind matches none of them — the occurrence the user deleted + // comes back. Move the stamps the same way the anchor moved. + shiftedExdate(seriesExdate, original, updated, zone) + ?.takeIf { it != seriesExdate } + ?.let { put(CalendarContract.Events.EXDATE, it) } } } @@ -420,11 +438,7 @@ internal fun buildOccurrenceExdateValues( allDay: Int, ): Map { val stamp = formatExdateStamp(occurrenceMillis, isAllDay = allDay != 0) - val existing = existingExdate?.split(',') - ?.map { it.trim() } - ?.filter { it.isNotEmpty() } - .orEmpty() - val merged = (existing + stamp).distinct().joinToString(",") + val merged = (exdateStamps(existingExdate) + stamp).distinct().joinToString(",") return mapOf( CalendarContract.Events.EXDATE to merged, CalendarContract.Events.DTSTART to dtStartMillis, @@ -435,22 +449,122 @@ internal fun buildOccurrenceExdateValues( ) } +/** + * [existingExdate] with every stamp re-timed by the same **wall-clock** shift the + * series anchor takes from [original] to [updated] — the exclusions' half of the + * anchor move [buildEventUpdateValues] performs, and what carries them onto the + * new series when "this and following" splits one. + * + * A stamp is an absolute instant, so it keeps naming its occurrence only if it + * moves exactly as the occurrence does: shifted in wall clock and re-resolved in + * the event's (possibly new) zone. A millisecond delta would instead bake in the + * offset that happened to apply at the edited occurrence — an hour off for any + * exclusion on the far side of a DST boundary. All-day-ness is read from + * [original] and written from [updated], so the `VALUE=DATE` and date-time forms + * convert into each other when the event switches. + * + * Null when there is nothing to carry: no stamps, or a stamp in a form Calendula + * never writes (a `TZID=`-parameterised or floating one from a sync adapter). Those + * are left exactly as they are rather than guessed at — a stale stamp excludes + * nothing, but a mangled one could exclude the wrong occurrence. + */ +internal fun shiftedExdate( + existingExdate: String?, + original: EventForm, + updated: EventForm, + zone: ZoneId, +): String? { + val stamps = exdateStamps(existingExdate) + if (stamps.isEmpty()) return null + val fromZone = original.writeZone(zone) + val toZone = updated.writeZone(zone) + val wallClockShift = Duration.between(original.anchorLocal(), updated.anchorLocal()) + return stamps + .map { stamp -> + val local = parseExdateStamp(stamp, original.isAllDay, fromZone) ?: return null + formatExdateStamp(local.plus(wallClockShift), updated.isAllDay, toZone) + } + .distinct() + .joinToString(",") +} + +/** + * The stamps of [existingExdate] naming occurrences after [beginMillis] — the ones + * that belong to the *new* series once "this and following" splits a recurring + * event there. The parent keeps the full list; its stamps past the split point are + * simply inert once it stops generating those occurrences. + * + * The occurrence at [beginMillis] itself is never carried. It is the one the user + * is editing, so it exists by definition, and honouring a stale exclusion for it + * would swallow the edit whole. + * + * Null when nothing qualifies, or when a stamp can't be read (see [shiftedExdate]). + */ +internal fun exdateAfter(existingExdate: String?, beginMillis: Long, isAllDay: Boolean): String? { + val stamps = exdateStamps(existingExdate) + if (stamps.isEmpty()) return null + return stamps + .filter { stamp -> + val utc = parseExdateStamp(stamp, isAllDay, ZoneOffset.UTC) ?: return null + utc.toInstant(ZoneOffset.UTC).toEpochMilli() > beginMillis + } + .takeIf { it.isNotEmpty() } + ?.joinToString(",") +} + +/** The individual stamps of an EXDATE column value; it is a comma-separated list. */ +private fun exdateStamps(exdate: String?): List = exdate + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() + /** * One EXDATE entry for the occurrence starting at [occurrenceMillis]. Both forms * are UTC: the provider stores an all-day DTSTART at UTC midnight, so its date * reads off the UTC calendar day. */ -private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String { - val utc = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC) - return if (isAllDay) { - "%04d%02d%02d".format(utc.year, utc.monthValue, utc.dayOfMonth) +private fun formatExdateStamp(occurrenceMillis: Long, isAllDay: Boolean): String = + formatExdateStamp( + local = Instant.ofEpochMilli(occurrenceMillis).atZone(ZoneOffset.UTC).toLocalDateTime(), + isAllDay = isAllDay, + zone = ZoneOffset.UTC, + ) + +/** + * One EXDATE entry for the occurrence whose wall clock in [zone] is [local]. An + * all-day entry keeps only the date (its time-of-day is the anchor's, not the + * occurrence's); a timed one is resolved in [zone] and written as a UTC instant. + */ +private fun formatExdateStamp(local: JavaLocalDateTime, isAllDay: Boolean, zone: ZoneId): String = + if (isAllDay) { + local.toLocalDate().format(ALL_DAY_EXDATE) } else { - "%04d%02d%02dT%02d%02d%02dZ".format( - utc.year, utc.monthValue, utc.dayOfMonth, - utc.hour, utc.minute, utc.second, - ) + local.atZone(zone).withZoneSameInstant(ZoneOffset.UTC).format(TIMED_EXDATE) } -} + +/** + * [stamp] as the wall clock it names in [zone] — the inverse of + * [formatExdateStamp]. Null for anything but the two forms Calendula writes, so a + * caller can tell "not ours, leave it alone" from a value it may safely re-time. + */ +private fun parseExdateStamp(stamp: String, isAllDay: Boolean, zone: ZoneId): JavaLocalDateTime? = + runCatching { + if (isAllDay) { + JavaLocalDate.parse(stamp, ALL_DAY_EXDATE).atStartOfDay() + } else { + JavaLocalDateTime.parse(stamp, TIMED_EXDATE) + .atZone(ZoneOffset.UTC).withZoneSameInstant(zone).toLocalDateTime() + } + }.getOrNull() + +/** `VALUE=DATE` EXDATE form, for an all-day series. */ +private val ALL_DAY_EXDATE: DateTimeFormatter = + DateTimeFormatter.ofPattern("uuuuMMdd").withResolverStyle(ResolverStyle.STRICT) + +/** UTC date-time EXDATE form, for a timed series. */ +private val TIMED_EXDATE: DateTimeFormatter = + DateTimeFormatter.ofPattern("uuuuMMdd'T'HHmmss'Z'").withResolverStyle(ResolverStyle.STRICT) /** * The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt index e8f4a75..3cb50a8 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt @@ -128,7 +128,8 @@ class EventWriteMapperTest { original: EventForm, updated: EventForm, series: Long = seriesStart, - ): Map = buildEventUpdateValues(original, updated, series, berlin) + exdate: String? = null, + ): Map = buildEventUpdateValues(original, updated, series, exdate, berlin) /** The instant [local] names in [zoneId], as the provider would store it. */ private fun instantAt(local: String, zoneId: String): Long = @@ -558,6 +559,239 @@ class EventWriteMapperTest { assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1) } + // --- EXDATE follows the series when its times change (Codeberg #248) --- + + /** A weekly series whose displayed occurrence runs 15 July 2026, 09:00–10:00. */ + private fun julySeries(): EventForm = form( + start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)), + ).copy(rrule = "FREQ=WEEKLY") + + /** [julySeries] pushed to [hour]:00, the shift an "all events" time edit makes. */ + private fun EventForm.atHour(hour: Int): EventForm = copy( + start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour, 0)), + end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(hour + 1, 0)), + ) + + @Test + fun `a series time edit moves its exclusions with the anchor`() { + // The bug: the stamp stayed at the old instant, which the moved series no + // longer generates, so the occurrence the user deleted came back. + val series = instantAt("2026-07-01T09:00", "Europe/Berlin") + val original = julySeries() + // 8 July 09:00 Berlin (CEST, +2) == 07:00Z; at 10:00 it must read 08:00Z. + val values = update(original, original.atHour(10), series, "20260708T070000Z") + assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260708T080000Z") + } + + @Test + fun `an exclusion keeps its wall clock across a DST boundary`() { + // Anchor and edited occurrence are in July (CEST, +2); the excluded + // occurrence sits in January (CET, +1). Shifting by the millisecond delta + // measured at the edited occurrence would leave it an hour off. + val series = instantAt("2026-01-07T09:00", "Europe/Berlin") + val original = julySeries() + // 14 January 09:00 Berlin == 08:00Z; at 10:00 it must read 09:00Z. + val values = update(original, original.atHour(10), series, "20260114T080000Z") + assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260114T090000Z") + } + + @Test + fun `pinning a series to another zone re-resolves its exclusions there`() { + val series = instantAt("2026-07-01T09:00", "Europe/Berlin") + val original = julySeries() + val values = update( + original, + original.copy(timezone = "Asia/Tokyo"), + series, + "20260716T070000Z", + ) + // The exclusion still reads 09:00 — now 09:00 in Tokyo (+9) == 00:00Z. + assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260716T000000Z") + } + + @Test + fun `an all-day series move shifts its exclusions by whole days`() { + val series = instantAt("2026-07-01T00:00", "UTC") + val original = form( + isAllDay = true, + start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)), + end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)), + ).copy(rrule = "FREQ=WEEKLY") + val moved = original.copy( + start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)), + end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(0, 0)), + ) + + assertThat(update(original, moved, series, "20260722")[CalendarContract.Events.EXDATE]) + .isEqualTo("20260724") + } + + @Test + fun `switching a series to all-day rewrites its exclusions as dates`() { + // The two forms aren't interchangeable: a date-time stamp on an all-day + // series matches no occurrence, so the exclusion would be lost. + val series = instantAt("2026-07-01T09:00", "Europe/Berlin") + val original = julySeries() + val values = update(original, original.copy(isAllDay = true), series, "20260722T070000Z") + assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260722") + } + + @Test + fun `switching a series back to timed rewrites its exclusions as instants`() { + val series = instantAt("2026-07-01T00:00", "UTC") + val original = form( + isAllDay = true, + start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)), + end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(0, 0)), + ).copy(rrule = "FREQ=WEEKLY") + val timed = original.copy( + isAllDay = false, + start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)), + ) + // The excluded day gains the new 09:00 Berlin time-of-day == 07:00Z. + assertThat(update(original, timed, series, "20260722")[CalendarContract.Events.EXDATE]) + .isEqualTo("20260722T070000Z") + } + + @Test + fun `a text-only edit leaves the exclusions alone`() { + val original = julySeries() + val values = update(original, original.copy(title = "Renamed"), exdate = "20260722T070000Z") + assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE) + } + + @Test + fun `changing only the rule rewrites no exclusions`() { + // The times are untouched, so every surviving occurrence keeps its instant + // and the stamps still name the right ones. + val original = julySeries() + val values = update(original, original.copy(rrule = "FREQ=DAILY"), exdate = "20260722T070000Z") + assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=DAILY") + assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE) + } + + @Test + fun `an exdate form we do not write is left untouched`() { + // A sync adapter may store a TZID-parameterised or floating stamp. A stale + // stamp excludes nothing; a mangled one could exclude the wrong occurrence. + val original = julySeries() + val values = update( + original, + original.atHour(10), + instantAt("2026-07-01T09:00", "Europe/Berlin"), + "TZID=Europe/Berlin;20260722T090000", + ) + assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE) + } + + @Test + fun `removing the recurrence clears the exclusions with it`() { + // Dormant, not harmless: adding a recurrence back later would punch the old + // holes into the new one. + val original = julySeries() + val values = update(original, original.copy(rrule = null), exdate = "20260722T070000Z") + assertThat(values).containsEntry(CalendarContract.Events.EXDATE, null) + } + + @Test + fun `undoing a series move lands the exclusions back where they started`() { + val series = instantAt("2026-01-07T09:00", "Europe/Berlin") + val original = julySeries() + val moved = original.copy( + start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30)), + end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30)), + ) + val exdate = "20260722T070000Z" + + val forward = update(original, moved, series, exdate) + val movedExdate = forward[CalendarContract.Events.EXDATE] as String + assertThat(movedExdate).isNotEqualTo(exdate) + + val back = update( + moved, + original, + forward[CalendarContract.Events.DTSTART] as Long, + movedExdate, + ) + assertThat(back[CalendarContract.Events.EXDATE]).isEqualTo(exdate) + } + + @Test + fun `a series with no exclusions writes no exdate column`() { + val series = instantAt("2026-07-01T09:00", "Europe/Berlin") + val original = julySeries() + assertThat(update(original, original.atHour(10), series, exdate = null)) + .doesNotContainKey(CalendarContract.Events.EXDATE) + } + + // --- exdateAfter (the exclusions a "this and following" split inherits) --- + + @Test + fun `a split carries only the exclusions past the split point`() { + // The occurrence at the split point is the one being edited, so it exists + // by definition — a stale exclusion for it would swallow the edit whole. + assertThat( + exdateAfter( + existingExdate = "20260708T080000Z,20260715T080000Z,20260722T080000Z", + beginMillis = instantAt("2026-07-15T08:00", "UTC"), + isAllDay = false, + ), + ).isEqualTo("20260722T080000Z") + } + + @Test + fun `a split carries nothing when every exclusion is behind it`() { + assertThat( + exdateAfter("20260708T080000Z", instantAt("2026-07-15T08:00", "UTC"), isAllDay = false), + ).isNull() + assertThat(exdateAfter(null, 0L, isAllDay = false)).isNull() + } + + @Test + fun `all-day exclusions split on their UTC date`() { + assertThat( + exdateAfter("20260708,20260722", instantAt("2026-07-15T00:00", "UTC"), isAllDay = true), + ).isEqualTo("20260722") + } + + @Test + fun `an unreadable exclusion carries nothing across a split`() { + assertThat( + exdateAfter( + "20260722T080000Z,TZID=Europe/Berlin;20260729T100000", + instantAt("2026-07-15T08:00", "UTC"), + isAllDay = false, + ), + ).isNull() + } + + @Test + fun `split exclusions move by the same shift as the new series start`() { + // What the split path composes: filter to the tail, then re-time it by the + // shift the user applied to the occurrence they split at. + val original = julySeries() + val carried = shiftedExdate( + existingExdate = exdateAfter( + "20260716T070000Z", + instantAt("2026-07-15T07:00", "UTC"), + isAllDay = false, + ), + original = original, + updated = original.atHour(11), + zone = berlin, + ) + // 16 July 09:00 Berlin, pushed two hours, is 11:00 Berlin == 09:00Z. + assertThat(carried).isEqualTo("20260716T090000Z") + } + + @Test + fun `nothing to shift yields no exdate`() { + assertThat(shiftedExdate(null, julySeries(), julySeries().atHour(10), berlin)).isNull() + assertThat(shiftedExdate(" ", julySeries(), julySeries().atHour(10), berlin)).isNull() + } + // --- per-event colour --- @Test