Release v2.19.4 (#259)
Renovate / renovate (push) Successful in 2m21s
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped

Release v2.19.4.

**Fixed**
- A deleted occurrence stays deleted through a series re-timing, and through backup/restore ([#225]).
- "Only this event" saves the edit on calendars that never synced, and on a row that doesn't recur it edits or deletes the event itself instead of filing an exception against nothing.
- A reminder left on the account's default is no longer read as a lead time of Calendula's own.
- The widgets turn the page at midnight; the re-arm backstop for the inexact rollover alarm is back, throttled to once per 15 minutes on calendar changes.
- An event running past midnight can be dragged by either half ([#253]).
- One event the provider refuses no longer costs the rest of the import, and an import cut short reports honestly how far it got.
- EXDATE zone prefixes are read in the bare `<zone>;` form the provider and DAVx5 actually write, not only the iCalendar `TZID=` spelling; a list holding a stamp we can't parse is kept whole rather than re-emitted stripped of its zone; RDATE travels with RRULE when the recurrence set is rewritten.

**Added**
- Belarusian, Hungarian and Slovak, from community translators on Weblate.

**Changed**
- Arabic, Polish and Simplified Chinese are complete; Czech grew, and Russian is now at about half.

Bumps `versionName` to 2.19.4, so merging this triggers the release pipeline.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/259
This commit is contained in:
Jean-Luc Makiola
2026-09-02 12:44:40 +02:00
co-authored by makiolaj
parent b19493e925
commit b3fe98907e
70 changed files with 5418 additions and 359 deletions
@@ -143,6 +143,76 @@ class AllDayReminderEncodingTest {
assertThat(fireFromNext).isEqualTo(LocalTime.of(9, 0)) // correct
}
@Test
fun `an imported yearly series samples at its next occurrence, not its anchor`() {
// Fossify anchors a year-less contact birthday at 1970, when Berlin had
// no DST; sampling there fires a modern June occurrence at 10:00 instead
// of the 09:00 the user picked (Codeberg #225).
val anchor = LocalDate.of(1970, 6, 3)
val today = LocalDate.of(2026, 6, 1)
val sampled = importedAllDayReminderDate(anchor, "FREQ=YEARLY;BYMONTH=6", today)
assertThat(sampled).isEqualTo(LocalDate.of(2026, 6, 3))
val raw = toProviderAllDayMinutes(0, sampled, berlin, nineAm)
val fire = actualFire(raw, LocalDate.of(2027, 6, 3))
.let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime()
assertThat(fire).isEqualTo(LocalTime.of(9, 0))
}
@Test
fun `the lead time still reads back from the anchor's own date`() {
// The offset is sampled at the next occurrence but decoded against
// DTSTART, so the two must still agree on the whole-day lead time.
val anchor = LocalDate.of(1970, 6, 3)
val sampled = importedAllDayReminderDate(anchor, "FREQ=YEARLY", LocalDate.of(2026, 6, 1))
for (semantic in listOf(0, 1_440, 2_880)) {
val raw = toProviderAllDayMinutes(semantic, sampled, berlin, nineAm)
assertThat(fromProviderAllDayMinutes(raw, anchor, berlin, nineAm)).isEqualTo(semantic)
}
}
@Test
fun `a one-off import samples at its own date, a stale series at today`() {
val past = LocalDate.of(1970, 6, 3)
val today = LocalDate.of(2026, 6, 1)
val future = LocalDate.of(2027, 3, 4)
assertThat(importedAllDayReminderDate(past, null, today)).isEqualTo(past)
assertThat(importedAllDayReminderDate(past, "FREQ=WEEKLY;BYDAY=MO", today)).isEqualTo(today)
assertThat(importedAllDayReminderDate(future, "FREQ=WEEKLY", today)).isEqualTo(future)
}
@Test
fun `the provider default sentinel survives a round trip untouched`() {
// -1 is Reminders.MINUTES_DEFAULT, not an offset. Decoded it landed on
// the start date and came back as 0 — a concrete at-start alarm on the
// detail screen and in an exported file.
assertThat(toProviderAllDayMinutes(-1, winter, berlin, nineAm)).isEqualTo(-1)
assertThat(fromProviderAllDayMinutes(-1, winter, berlin, nineAm)).isEqualTo(-1)
}
@Test
fun `a real encoding that is negative is still decoded`() {
// "At time of event" encodes negative — the sentinel check must be the
// exact value, not any negative one.
val raw = toProviderAllDayMinutes(0, winter, berlin, nineAm)
assertThat(raw).isLessThan(0)
assertThat(fromProviderAllDayMinutes(raw, winter, berlin, nineAm)).isEqualTo(0)
}
@Test
fun `an encoding that would land on the sentinel is nudged off it`() {
// 01:01 Berlin in winter (CET, +1) is 00:01 UTC — one minute past the
// event's UTC-midnight DTSTART, i.e. exactly -1. Left there it would read
// back as "use the account default" and the real reminder would be gone
// from the screen and from an export.
val raw = toProviderAllDayMinutes(0, winter, berlin, 61)
assertThat(raw).isEqualTo(-2)
assertThat(fromProviderAllDayMinutes(raw, winter, berlin, 61)).isEqualTo(0)
}
@Test
fun `a winter-anchored offset drifts one hour on a summer occurrence`() {
// Known limitation: one fixed MINUTES per series can't track DST. An
@@ -22,6 +22,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlin.time.Instant
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
@@ -642,7 +643,12 @@ class CalendarRepositoryImplTest {
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)),
)
val id = repo.updateOccurrence(eventId = 42L, beginMillis = 1_000L, form = form)
val id = repo.updateOccurrence(
eventId = 42L,
beginMillis = 1_000L,
original = form,
form = form,
)
assertThat(id).isEqualTo(88L)
assertThat(fake.updatedOccurrences).containsExactly(Triple(42L, 1_000L, form))
@@ -801,6 +807,113 @@ class CalendarRepositoryImplTest {
assertThat(fake.importedEvents.map { it.second }).containsExactly(3L, 3L)
}
@Test
fun `importEvents carries on past an event the provider rejects`(
@TempDir tempDir: Path,
) = runTest {
// A foreign export can hold a row the provider throws on outright; that
// must cost the user one event, not the whole file (Codeberg #225).
val fake = FakeCalendarDataSource().apply { failingImportSummaries += "bad" }
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val events = listOf(
parsedEvent("a@x", summary = "good"),
parsedEvent("b@x", summary = "bad"),
parsedEvent("c@x", summary = "good"),
)
val summary = repo.importEvents(targetCalendarId = 3L, events = events)
assertThat(summary.imported).isEqualTo(2)
assertThat(summary.failed).isEqualTo(1)
assertThat(fake.importedEvents.map { it.first.uid }).containsExactly("a@x", "c@x")
}
@Test
fun `importEvents stops at a permission failure and reports what landed`(
@TempDir tempDir: Path,
) = runTest {
// The permission or the target calendar is gone, so every remaining insert
// would fail the same way. Counted per event, the screen reported "N events
// couldn't be added" for a file it never had a chance at; thrown, it
// reported that nothing landed — and since only events carrying a UID dedup,
// the retry that invites duplicated everything that already had.
val fake = FakeCalendarDataSource().apply {
importsBeforeError = 2
writeError = SecurityException("WRITE_CALENDAR revoked")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val summary = repo.importEvents(
targetCalendarId = 3L,
events = List(5) { parsedEvent("e$it@x") },
)
assertThat(summary.imported).isEqualTo(2)
// The one that threw and the two never reached.
assertThat(summary.notAttempted).isEqualTo(3)
assertThat(summary.failed).isEqualTo(0)
}
@Test
fun `a stopped import does not count known duplicates as work left to do`(
@TempDir tempDir: Path,
) = runTest {
// The remainder is not all work: a UID already in the calendar would have
// been skipped whatever happened, so counting it against a retry
// overstates how much of the file is missing.
val fake = FakeCalendarDataSource().apply {
existingUidsResult = setOf("e3@x", "e4@x")
importsBeforeError = 1
writeError = SecurityException("WRITE_CALENDAR revoked")
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
val summary = repo.importEvents(
targetCalendarId = 3L,
events = List(5) { parsedEvent("e$it@x") },
)
assertThat(summary.imported).isEqualTo(1)
// e1 threw and e2 was never reached; e3 and e4 were duplicates either way.
assertThat(summary.notAttempted).isEqualTo(2)
assertThat(summary.skippedDuplicate).isEqualTo(2)
}
@Test
fun `importEvents looks the target palette up once, not per event`(
@TempDir tempDir: Path,
) = runTest {
var lookups = 0
val fake = FakeCalendarDataSource().apply {
publishedEventColorsResult = { lookups++; emptyList() }
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
repo.importEvents(3L, List(5) { parsedEvent("e$it@x") })
assertThat(lookups).isEqualTo(1)
}
@Test
fun `importEvents matches colours against the uncurated palette`(
@TempDir tempDir: Path,
) = runTest {
// Curation is a picker concession — it folds look-alikes and drops the
// neutrals from an oversized palette — but every published key is one
// the calendar accepts, so an imported colour matches against all of
// them (Codeberg #225).
val published = listOf(EventColorOption("1", 0xFF808080.toInt()))
val fake = FakeCalendarDataSource().apply {
publishedEventColorsResult = { published }
eventColorPaletteResult = { emptyList() }
}
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
repo.importEvents(3L, listOf(parsedEvent("a@x")))
assertThat(fake.lastImportPalette).isEqualTo(published)
}
@Test
fun `exportEvents forwards the chosen calendar-id subset to the data source`(
@TempDir tempDir: Path,
@@ -825,9 +938,10 @@ class CalendarRepositoryImplTest {
assertThat(fake.lastExportableEventsCalendarIds).isNull()
}
private fun parsedEvent(uid: String?) = de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
private fun parsedEvent(uid: String?, summary: String = "E") =
de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
uid = uid,
summary = "E",
summary = summary,
start = Instant.fromEpochMilliseconds(1_000_000_000L),
end = Instant.fromEpochMilliseconds(1_000_003_600L),
isAllDay = false,
@@ -498,6 +498,7 @@ class EventWriteMapperTest {
occurrenceMillis = 1_784_102_400_000L,
dtStartMillis = 1_783_929_600_000L,
rrule = "FREQ=DAILY;COUNT=5",
rdate = null,
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
@@ -521,6 +522,7 @@ class EventWriteMapperTest {
occurrenceMillis = 1_784_102_400_000L,
dtStartMillis = 1_783_929_600_000L,
rrule = "FREQ=DAILY;COUNT=5",
rdate = null,
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
@@ -536,6 +538,7 @@ class EventWriteMapperTest {
occurrenceMillis = 1_784_102_400_000L,
dtStartMillis = 1_783_929_600_000L,
rrule = "FREQ=DAILY;COUNT=5",
rdate = null,
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
@@ -543,6 +546,27 @@ class EventWriteMapperTest {
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715T080000Z")
}
@Test
fun `an all-day drop folds away a padded repeat of the same day`() {
// Compared verbatim, the two spellings of 15 July are different strings:
// the day ends up excluded twice, and exdateContains — the guard against
// detaching the same occurrence twice — misses it entirely.
val occurrence = 1_784_073_600_000L // 2026-07-15T00:00:00Z
val values = buildOccurrenceExdateValues(
existingExdate = "20260715T000000Z",
occurrenceMillis = occurrence,
dtStartMillis = 1_783_900_800_000L,
rrule = "FREQ=WEEKLY",
rdate = null,
duration = "P1D",
timezone = "UTC",
allDay = 1,
)
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260715")
assertThat(exdateContains("20260715T000000Z", occurrence, isAllDay = true, timezone = null))
.isTrue()
}
@Test
fun `all-day exdate drop uses the date-only form`() {
// An all-day DTSTART sits at UTC midnight, so the date reads off UTC.
@@ -551,6 +575,7 @@ class EventWriteMapperTest {
occurrenceMillis = 1_784_073_600_000L, // 2026-07-15T00:00:00Z
dtStartMillis = 1_783_900_800_000L,
rrule = "FREQ=YEARLY",
rdate = null,
duration = "P1D",
timezone = "UTC",
allDay = 1,
@@ -559,6 +584,90 @@ class EventWriteMapperTest {
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
}
@Test
fun `an exdate list zoned the way the provider writes it is read in that zone`() {
// AOSP's RecurrenceSet keeps the TZID parameter's *value*, not the
// parameter: `Europe/Berlin;<stamps>`, no `TZID=`. Matching only the
// iCalendar spelling read every list the provider itself wrote as if its
// floating stamps were UTC — hours off for any zone but UTC.
assertThat(exdateListZone("Europe/Berlin;20260708T100000", "UTC"))
.isEqualTo(ZoneId.of("Europe/Berlin"))
assertThat(exdateListZone("TZID=Europe/Berlin;20260708T100000", "UTC"))
.isEqualTo(ZoneId.of("Europe/Berlin"))
// No prefix, or one this device can't place: the row's own zone.
assertThat(exdateListZone("20260708T100000", "Europe/Berlin"))
.isEqualTo(ZoneId.of("Europe/Berlin"))
assertThat(exdateListZone("W. Europe Standard Time;20260708T100000", "Europe/Berlin"))
.isEqualTo(ZoneId.of("Europe/Berlin"))
}
@Test
fun `a bare-zoned exclusion list splits on the instants its prefix names`() {
assertThat(
exdateAfter(
"Europe/Berlin;20260708T100000,20260729T100000",
instantAt("2026-07-15T08:00", "UTC"),
isAllDay = false,
timezone = null,
),
).isEqualTo("20260729T080000Z")
}
@Test
fun `an exdate drop keeps a list it cannot read whole, prefix and all`() {
// Stripping the prefix off a list and re-emitting the stamps it can't
// parse leaves them floating: "09:00 in Berlin" silently becomes 09:00 in
// whatever zone the next reader picks.
val values = buildOccurrenceExdateValues(
existingExdate = "Europe/Berlin;20260722T090000",
occurrenceMillis = 1_784_073_600_000L, // 2026-07-15T00:00:00Z
dtStartMillis = 1_783_900_800_000L,
rrule = "FREQ=WEEKLY",
rdate = null,
duration = "P1D",
timezone = "Europe/Berlin",
allDay = 1,
)
assertThat(values[CalendarContract.Events.EXDATE])
.isEqualTo("Europe/Berlin;20260722T090000,20260715")
}
@Test
fun `a new exclusion joins an unreadable timed list in that list's own form`() {
val values = buildOccurrenceExdateValues(
existingExdate = "Europe/Berlin;sometime next Tuesday",
occurrenceMillis = 1_784_102_400_000L, // 2026-07-15T08:00:00Z == 10:00 Berlin
dtStartMillis = 1_783_929_600_000L,
rrule = "FREQ=DAILY",
rdate = null,
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
)
assertThat(values[CalendarContract.Events.EXDATE])
.isEqualTo("Europe/Berlin;sometime next Tuesday,20260715T100000")
}
@Test
fun `an exdate drop rewrites RDATE along with the rule`() {
// A series may be a list of dates with no rule at all. Left out of the
// rewrite, the provider has only DTSTART to re-expand from and collapses
// the series to its first occurrence.
val values = buildOccurrenceExdateValues(
existingExdate = null,
occurrenceMillis = 1_784_102_400_000L,
dtStartMillis = 1_783_929_600_000L,
rrule = null,
rdate = "20260716T080000Z,20260722T080000Z",
duration = "PT1H",
timezone = "Europe/Berlin",
allDay = 0,
)
assertThat(values[CalendarContract.Events.RDATE])
.isEqualTo("20260716T080000Z,20260722T080000Z")
assertThat(values[CalendarContract.Events.RRULE]).isNull()
}
// --- EXDATE follows the series when its times change (Codeberg #248) ---
/** A weekly series whose displayed occurrence runs 15 July 2026, 09:0010:00. */
@@ -627,6 +736,65 @@ class EventWriteMapperTest {
.isEqualTo("20260724")
}
@Test
fun `an all-day exclusion a sync adapter padded still moves with the series`() {
// The bug: only the bare yyyyMMdd form parsed, so a padded midnight stamp
// bailed the whole list and buildEventUpdateValues wrote no EXDATE at all
// — the deleted occurrence came back the moment the series was re-timed.
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)),
)
val values = update(original, moved, series, "20260722T000000Z")
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260724")
}
@Test
fun `an all-day exclusion padded without a Z is read the same way`() {
// DAVx5 and AOSP's own RecurrenceSet write a floating midnight for a
// date value. Only the trailing-Z spelling parsed, so the floating one
// bailed the whole list and the deleted occurrence came back.
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)),
)
val values = update(original, moved, series, "20260722T000000")
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260724")
}
@Test
fun `a padded all-day stamp naming any other time is still left alone`() {
// Not a form an all-day series is written in, so it could be naming
// something else entirely — guessing could exclude the wrong occurrence.
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)),
)
val values = update(original, moved, series, "20260722T090000Z")
assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
}
@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
@@ -673,9 +841,10 @@ class EventWriteMapperTest {
}
@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.
fun `a zoned exdate list is read in the zone its prefix names`() {
// AOSP's RecurrenceSet form, TZID=<zone>;<floating stamps>. Read as UTC the
// stamps name the wrong instants; skipped, every exclusion of the series is
// dropped the moment it is re-timed.
val original = julySeries()
val values = update(
original,
@@ -683,6 +852,35 @@ class EventWriteMapperTest {
instantAt("2026-07-01T09:00", "Europe/Berlin"),
"TZID=Europe/Berlin;20260722T090000",
)
// 22 July 09:00 Berlin (CEST, +2) == 07:00Z; the +1h edit puts it at 08:00Z.
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260722T080000Z")
}
@Test
fun `a zoned list whose zone this device cannot place falls back to the event's`() {
// Exchange spells zones its own way, so ZoneId.of refuses the name. The
// series' own zone is what such a list is written in anyway.
val original = julySeries()
val values = update(
original,
original.atHour(10),
instantAt("2026-07-01T09:00", "Europe/Berlin"),
"TZID=W. Europe Standard Time;20260722T090000",
)
assertThat(values[CalendarContract.Events.EXDATE]).isEqualTo("20260722T080000Z")
}
@Test
fun `an exdate form we do not write is left untouched`() {
// A stale stamp excludes nothing; a mangled one could exclude the wrong
// occurrence, so anything unreadable is left exactly as it stands.
val original = julySeries()
val values = update(
original,
original.atHour(10),
instantAt("2026-07-01T09:00", "Europe/Berlin"),
"sometime next Tuesday",
)
assertThat(values).doesNotContainKey(CalendarContract.Events.EXDATE)
}
@@ -737,6 +935,7 @@ class EventWriteMapperTest {
existingExdate = "20260708T080000Z,20260715T080000Z,20260722T080000Z",
beginMillis = instantAt("2026-07-15T08:00", "UTC"),
isAllDay = false,
timezone = null,
),
).isEqualTo("20260722T080000Z")
}
@@ -744,15 +943,25 @@ class EventWriteMapperTest {
@Test
fun `a split carries nothing when every exclusion is behind it`() {
assertThat(
exdateAfter("20260708T080000Z", instantAt("2026-07-15T08:00", "UTC"), isAllDay = false),
exdateAfter(
"20260708T080000Z",
instantAt("2026-07-15T08:00", "UTC"),
isAllDay = false,
timezone = null,
),
).isNull()
assertThat(exdateAfter(null, 0L, isAllDay = false)).isNull()
assertThat(exdateAfter(null, 0L, isAllDay = false, timezone = null)).isNull()
}
@Test
fun `all-day exclusions split on their UTC date`() {
assertThat(
exdateAfter("20260708,20260722", instantAt("2026-07-15T00:00", "UTC"), isAllDay = true),
exdateAfter(
"20260708,20260722",
instantAt("2026-07-15T00:00", "UTC"),
isAllDay = true,
timezone = null,
),
).isEqualTo("20260722")
}
@@ -760,13 +969,28 @@ class EventWriteMapperTest {
fun `an unreadable exclusion carries nothing across a split`() {
assertThat(
exdateAfter(
"20260722T080000Z,TZID=Europe/Berlin;20260729T100000",
"20260722T080000Z,sometime next Tuesday",
instantAt("2026-07-15T08:00", "UTC"),
isAllDay = false,
timezone = null,
),
).isNull()
}
@Test
fun `a zoned exclusion list splits on the instants its prefix names`() {
// Canonicalised on the way out: the prefix that explained how to read the
// stamps doesn't survive the filter, so the survivors can't stay floating.
assertThat(
exdateAfter(
"TZID=Europe/Berlin;20260708T100000,20260729T100000",
instantAt("2026-07-15T08:00", "UTC"),
isAllDay = false,
timezone = null,
),
).isEqualTo("20260729T080000Z")
}
@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
@@ -777,6 +1001,7 @@ class EventWriteMapperTest {
"20260716T070000Z",
instantAt("2026-07-15T07:00", "UTC"),
isAllDay = false,
timezone = null,
),
original = original,
updated = original.atHour(11),
@@ -980,4 +1205,173 @@ class EventWriteMapperTest {
val values = buildCopiedExceptionValues(exceptionSnapshot(duration = "P900S", dtEndMillis = null))
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P900S")
}
@Test
fun `a detached all-day occurrence matches its date-only exdate stamp`() {
val parent = buildOccurrenceExdateValues(
existingExdate = null,
occurrenceMillis = 1_781_136_000_000L, // 2026-06-11T00:00:00Z
dtStartMillis = 1_749_600_000_000L,
rrule = "FREQ=YEARLY",
rdate = null,
duration = "P1D",
timezone = "UTC",
allDay = 1,
)
val detached = form(
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
).copy(rrule = "FREQ=YEARLY").toDetachedOccurrence()
val inserted = buildEventInsertValues(
form = detached,
uid = "uid@calendula",
times = detached.toWriteTimes(berlin),
)
assertThat(parent[CalendarContract.Events.EXDATE]).isEqualTo("20260611")
assertThat(inserted[CalendarContract.Events.DTSTART]).isEqualTo(1_781_136_000_000L)
}
@Test
fun `a detached all-day occurrence stays on UTC midnights`() {
val edited = form(
isAllDay = true,
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)),
).copy(title = "Birthday", rrule = "FREQ=YEARLY")
val detached = edited.toDetachedOccurrence()
val values = buildEventInsertValues(
form = detached,
uid = "uid@calendula",
times = detached.toWriteTimes(berlin),
)
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_136_000_000L)
// Exclusive DTEND — the next UTC midnight.
assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_222_400_000L)
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
}
@Test
fun `a detached occurrence carries every edited field onto the new row`() {
// Built from the form, not cloned from the parent: a field dropped here
// is an edit silently lost.
val edited = form(timezone = "America/New_York").copy(
title = " Standup ",
location = "Room 2",
description = "notes",
reminders = listOf(10),
availability = Availability.Free,
accessLevel = AccessLevel.Private,
rrule = "FREQ=DAILY",
)
val detached = edited.toDetachedOccurrence()
val values = buildEventInsertValues(
form = detached,
uid = "uid@calendula",
times = detached.toWriteTimes(berlin),
)
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Standup")
assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Room 2")
assertThat(values[CalendarContract.Events.DESCRIPTION]).isEqualTo("notes")
assertThat(values[CalendarContract.Events.AVAILABILITY])
.isEqualTo(CalendarContract.Events.AVAILABILITY_FREE)
assertThat(values[CalendarContract.Events.ACCESS_LEVEL])
.isEqualTo(CalendarContract.Events.ACCESS_PRIVATE)
// The pinned zone survives — never re-anchored to the device.
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("America/New_York")
assertThat(values[CalendarContract.Events.UID_2445]).isEqualTo("uid@calendula")
// Reminders aren't columns; the insert path seeds them from the form.
assertThat(detached.reminders).containsExactly(10)
}
@Test
fun `a detached occurrence drops the series rule and becomes a one-off row`() {
val edited = form().copy(title = "Moved", rrule = "FREQ=WEEKLY;BYDAY=TH")
val detached = edited.toDetachedOccurrence()
val values = buildEventInsertValues(
form = detached,
uid = "uid@calendula",
times = detached.toWriteTimes(berlin),
)
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
// A surviving rule would insert a second *series* overlapping the first
// (Codeberg #234's stray duplicate).
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
// A one-off row carries DTEND rather than a duration.
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
assertThat(values[CalendarContract.Events.DTEND]).isEqualTo(1_781_170_200_000L)
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
}
@Test
fun `an exclusion is found anywhere in a multi-entry exdate list`() {
// Whitespace after a comma is legal in the stored column.
assertThat(
exdateContains(
"20260604T080000Z, 20260611T080000Z,20260618T080000Z",
1_781_164_800_000L,
isAllDay = false,
timezone = null,
),
).isTrue()
}
@Test
fun `an occurrence already excluded is recognised, timed and all-day`() {
// Detaching twice would leave a second standalone copy.
assertThat(
exdateContains("20260611T080000Z", 1_781_164_800_000L, isAllDay = false, timezone = null),
).isTrue()
assertThat(
exdateContains("20260611", 1_781_136_000_000L, isAllDay = true, timezone = null),
).isTrue()
}
@Test
fun `an occurrence not in the exdate list is not mistaken for an excluded one`() {
assertThat(exdateContains(null, 1_781_164_800_000L, isAllDay = false, timezone = null))
.isFalse()
assertThat(exdateContains("", 1_781_164_800_000L, isAllDay = false, timezone = null))
.isFalse()
// A neighbouring occurrence must not match — the guard is per-instant.
assertThat(
exdateContains("20260610T080000Z", 1_781_164_800_000L, isAllDay = false, timezone = null),
).isFalse()
}
@Test
fun `the detached row lands exactly where the parent's exdate removes it`() {
// The two halves must agree on the instant, or the user sees the
// occurrence twice or not at all.
val edited = form().copy(title = "Renamed", rrule = "FREQ=WEEKLY")
val occurrenceMillis = 1_781_164_800_000L
val parent = buildOccurrenceExdateValues(
existingExdate = null,
occurrenceMillis = occurrenceMillis,
dtStartMillis = 1_780_560_000_000L,
rrule = "FREQ=WEEKLY",
rdate = null,
duration = "P5400S",
timezone = "Europe/Berlin",
allDay = 0,
)
val detached = edited.toDetachedOccurrence()
val inserted = buildEventInsertValues(
form = detached,
uid = "uid@calendula",
times = detached.toWriteTimes(berlin),
)
assertThat(parent[CalendarContract.Events.EXDATE]).isEqualTo("20260611T080000Z")
assertThat(inserted[CalendarContract.Events.DTSTART]).isEqualTo(occurrenceMillis)
// The parent keeps its own anchor and rule — only this occurrence leaves.
assertThat(parent[CalendarContract.Events.DTSTART]).isEqualTo(1_780_560_000_000L)
assertThat(parent[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY")
}
}
@@ -23,6 +23,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
var searchResult: (String) -> List<SearchCandidate> = { _ -> emptyList() }
var eventDetailResult: (Long) -> EventDetail? = { null }
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
var publishedEventColorsResult: (Long) -> List<EventColorOption> = { emptyList() }
var exportableEventsResult: List<IcsEvent> = emptyList()
/** The [calendarIds] the last [exportableEvents] call received (null = all). */
var lastExportableEventsCalendarIds: Set<Long>? = null
@@ -81,7 +82,12 @@ internal class FakeCalendarDataSource : CalendarDataSource {
eventDetailResult(eventId)
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
eventColorPaletteResult(calendarId)
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
override fun publishedEventColors(calendarId: Long): List<EventColorOption> =
publishedEventColorsResult(calendarId)
override fun exportableEvents(
calendarIds: Set<Long>?,
allDayReminderTimeMinutes: Int,
): List<IcsEvent> {
lastExportableEventsCalendarIds = calendarIds
return exportableEventsResult
}
@@ -91,8 +97,25 @@ internal class FakeCalendarDataSource : CalendarDataSource {
/** (event, targetCalendarId) pairs passed to [insertImportedEvent]. */
val importedEvents = mutableListOf<Pair<ParsedIcsEvent, Long>>()
override fun insertImportedEvent(event: ParsedIcsEvent, calendarId: Long): Long {
writeError?.let { throw it }
/** Thrown instead of [writeError] for events whose summary is in this set. */
val failingImportSummaries = mutableSetOf<String>()
/** Imports let through before [writeError] applies, for part-way failures. */
var importsBeforeError: Int = 0
private var imports = 0
/** The [colorPalette] the last [insertImportedEvent] call received. */
var lastImportPalette: List<EventColorOption> = emptyList()
override fun insertImportedEvent(
event: ParsedIcsEvent,
calendarId: Long,
allDayReminderTimeMinutes: Int,
colorPalette: List<EventColorOption>,
): Long {
lastImportPalette = colorPalette
if (imports++ >= importsBeforeError) writeError?.let { throw it }
if (event.summary in failingImportSummaries) error("rejected: ${event.summary}")
importedEvents += event to calendarId
return nextInsertId
}
@@ -173,6 +196,7 @@ internal class FakeCalendarDataSource : CalendarDataSource {
override fun updateOccurrence(
eventId: Long,
beginMillis: Long,
original: EventForm,
form: EventForm,
allDayReminderTimeMinutes: Int,
): Long {
@@ -4,6 +4,7 @@ import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventStatus
import org.junit.jupiter.api.Test
import java.time.ZoneId
class IcsExportMapperTest {
@@ -20,7 +21,11 @@ class IcsExportMapperTest {
EventExportProjection.IDX_AVAILABILITY to CalendarContract.Events.AVAILABILITY_BUSY,
)
val event = reader.toIcsEvent(reminderMinutes = listOf(10), calendarName = "Personal")
val event = reader.toIcsEvent(
reminderMinutes = listOf(10),
calendarName = "Personal",
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.uid).isEqualTo("abc@host")
assertThat(event.summary).isEqualTo("Standup")
@@ -47,7 +52,11 @@ class IcsExportMapperTest {
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
val event = reader.toIcsEvent(reminderMinutes = emptyList(), calendarName = null)
val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.uid).isEqualTo("7-1000000@calendula")
assertThat(event.recurrenceRule).isEqualTo("FREQ=WEEKLY")
@@ -65,6 +74,224 @@ class IcsExportMapperTest {
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
assertThat(reader.toIcsEvent(emptyList(), null).isAllDay).isTrue()
assertThat(reader.toIcsEvent(emptyList(), null, NINE_AM).isAllDay).isTrue()
}
@Test
fun `an all-day reminder is exported as its whole-day lead time`() {
// The raw offset encodes the firing time and is normally negative; left
// raw it would be written as a trigger after the event and dropped, so
// the backup would come back with no reminder at all.
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 1L,
EventExportProjection.IDX_TITLE to "Anna Birthday",
EventExportProjection.IDX_DTSTART to 1_782_864_000_000L, // 2026-07-01 UTC, CEST
EventExportProjection.IDX_DTEND to 1_782_950_400_000L,
EventExportProjection.IDX_ALL_DAY to 1,
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
val onTheDay = reader.toIcsEvent(listOf(-420), null, NINE_AM, BERLIN)
val dayBefore = reader.toIcsEvent(listOf(1_020), null, NINE_AM, BERLIN)
assertThat(onTheDay.reminderMinutes).containsExactly(0)
assertThat(dayBefore.reminderMinutes).containsExactly(1_440)
}
private companion object {
const val NINE_AM = 9 * 60
val BERLIN: ZoneId = ZoneId.of("Europe/Berlin")
}
@Test
fun `a recurring row carries its EXDATE exclusions`() {
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 7L,
EventExportProjection.IDX_TITLE to "Weekly",
EventExportProjection.IDX_DTSTART to 1_000_000L,
EventExportProjection.IDX_DURATION to "P3600S",
EventExportProjection.IDX_ALL_DAY to 0,
EventExportProjection.IDX_RRULE to "FREQ=WEEKLY",
EventExportProjection.IDX_EXDATE to "20260901T080000Z,20260908T080000Z",
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.exDates)
.containsExactly("20260901T080000Z", "20260908T080000Z").inOrder()
}
@Test
fun `an all-day EXDATE keeps only the day, however the adapter padded it`() {
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 8L,
EventExportProjection.IDX_TITLE to "Holiday",
EventExportProjection.IDX_DTSTART to 0L,
EventExportProjection.IDX_DURATION to "P1D",
EventExportProjection.IDX_ALL_DAY to 1,
EventExportProjection.IDX_RRULE to "FREQ=YEARLY",
EventExportProjection.IDX_EXDATE to "20260901,20270901T000000Z",
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.exDates).containsExactly("20260901", "20270901").inOrder()
}
@Test
fun `a TZID-prefixed EXDATE is resolved to UTC, not handed on verbatim`() {
// The provider stores a zoned exclusion as AOSP RecurrenceSet does. Passed
// through, the writer would emit `EXDATE:TZID=Europe/Berlin;2026...` — the
// parameter belongs before the colon, so the line is unreadable.
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 11L,
EventExportProjection.IDX_TITLE to "Weekly",
EventExportProjection.IDX_DTSTART to 1_000_000L,
EventExportProjection.IDX_DURATION to "P3600S",
EventExportProjection.IDX_ALL_DAY to 0,
EventExportProjection.IDX_RRULE to "FREQ=WEEKLY",
EventExportProjection.IDX_EXDATE to
"TZID=Europe/Berlin;20260722T090000,20260729T090000",
EventExportProjection.IDX_EVENT_TIMEZONE to "Europe/Berlin",
)
val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
// 09:00 Berlin in July is CEST (+2) == 07:00Z.
assertThat(event.exDates)
.containsExactly("20260722T070000Z", "20260729T070000Z").inOrder()
}
@Test
fun `a zoned EXDATE in the provider's own bare form resolves in that zone`() {
// AOSP's RecurrenceSet stores the TZID parameter's value alone. Read as
// if it were unzoned, 09:00 Berlin exported as 09:00Z — two hours out,
// so the exclusion no longer matched its occurrence in the file.
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 14L,
EventExportProjection.IDX_TITLE to "Weekly",
EventExportProjection.IDX_DTSTART to 1_000_000L,
EventExportProjection.IDX_DURATION to "P3600S",
EventExportProjection.IDX_ALL_DAY to 0,
EventExportProjection.IDX_RRULE to "FREQ=WEEKLY",
EventExportProjection.IDX_EXDATE to "Europe/Berlin;20260722T090000",
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.exDates).containsExactly("20260722T070000Z")
}
@Test
fun `a TZID this device cannot place falls back to the row's own zone`() {
// Exchange spells zones its own way, so ZoneId.of refuses the name.
// Stripping the prefix and emitting the stamp as it stands would export a
// floating time against a `DTSTART;TZID=…` line — every reader in another
// zone resolves it to another instant and the exclusion stops matching.
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 13L,
EventExportProjection.IDX_TITLE to "Weekly",
EventExportProjection.IDX_DTSTART to 1_000_000L,
EventExportProjection.IDX_DURATION to "P3600S",
EventExportProjection.IDX_ALL_DAY to 0,
EventExportProjection.IDX_RRULE to "FREQ=WEEKLY",
EventExportProjection.IDX_EXDATE to
"TZID=W. Europe Standard Time;20260722T090000",
EventExportProjection.IDX_EVENT_TIMEZONE to "Europe/Berlin",
)
val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.exDates).containsExactly("20260722T070000Z")
}
@Test
fun `an all-day EXDATE loses a TZID prefix along with its time`() {
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 12L,
EventExportProjection.IDX_TITLE to "Holiday",
EventExportProjection.IDX_DTSTART to 0L,
EventExportProjection.IDX_DURATION to "P1D",
EventExportProjection.IDX_ALL_DAY to 1,
EventExportProjection.IDX_RRULE to "FREQ=YEARLY",
EventExportProjection.IDX_EXDATE to "TZID=Europe/Berlin;20260901T000000",
EventExportProjection.IDX_EVENT_TIMEZONE to "Europe/Berlin",
)
val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.exDates).containsExactly("20260901")
}
@Test
fun `a default-lead all-day reminder is not decoded into an at-start alarm`() {
// MINUTES_DEFAULT (-1) says "use the account default", not "0 minutes
// before". Decoded, it landed on the start date and became a real alarm
// that IcsWriter no longer dropped.
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 13L,
EventExportProjection.IDX_TITLE to "Birthday",
EventExportProjection.IDX_DTSTART to 0L,
EventExportProjection.IDX_DURATION to "P1D",
EventExportProjection.IDX_ALL_DAY to 1,
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
val event = reader.toIcsEvent(
reminderMinutes = listOf(-1),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
zone = BERLIN,
)
assertThat(event.reminderMinutes).containsExactly(-1)
}
@Test
fun `a one-off row exports no exclusions even if the column is set`() {
val reader = MapColumnReader(
EventExportProjection.IDX_ID to 9L,
EventExportProjection.IDX_TITLE to "Standup",
EventExportProjection.IDX_DTSTART to 1_000_000L,
EventExportProjection.IDX_DTEND to 1_900_000L,
EventExportProjection.IDX_ALL_DAY to 0,
EventExportProjection.IDX_EXDATE to "20260901T080000Z",
EventExportProjection.IDX_EVENT_TIMEZONE to "UTC",
)
val event = reader.toIcsEvent(
reminderMinutes = emptyList(),
calendarName = null,
allDayReminderTimeMinutes = NINE_AM,
)
assertThat(event.exDates).isEmpty()
}
}
@@ -0,0 +1,119 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class ImportedEventValuesTest {
private val dayStart = Instant.parse("2026-08-19T00:00:00Z")
private fun allDay(
rrule: String? = null,
exDates: List<String> = emptyList(),
color: Int? = null,
days: Int = 1,
) = ParsedIcsEvent(
uid = "u@x",
summary = "Anna Birthday",
start = dayStart,
end = Instant.fromEpochMilliseconds(dayStart.toEpochMilliseconds() + days * 86_400_000L),
isAllDay = true,
zoneId = "UTC",
recurrenceRule = rrule,
exDates = exDates,
color = color,
)
private fun values(event: ParsedIcsEvent, palette: List<EventColorOption> = emptyList()) =
buildImportedEventValues(event, calendarId = 7L, uid = "u@x", palette = palette)
@Test
fun `a one-off carries DTEND and no recurrence`() {
val v = values(allDay())
assertThat(v[CalendarContract.Events.DTEND])
.isEqualTo(dayStart.toEpochMilliseconds() + 86_400_000L)
assertThat(v).doesNotContainKey(CalendarContract.Events.DURATION)
assertThat(v[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
assertThat(v[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
}
@Test
fun `a recurring row carries DURATION instead of DTEND`() {
val v = values(allDay(rrule = "FREQ=YEARLY;INTERVAL=1"))
assertThat(v).doesNotContainKey(CalendarContract.Events.DTEND)
assertThat(v[CalendarContract.Events.RRULE]).isEqualTo("FREQ=YEARLY;INTERVAL=1")
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P1D")
}
@Test
fun `an all-day series is never zero days long`() {
// A degenerate span would expand into no instances at all — the series
// would simply not exist (Codeberg #225).
val v = values(allDay(rrule = "FREQ=YEARLY;INTERVAL=1", days = 0))
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P1D")
}
@Test
fun `EXDATEs ride along with the recurrence`() {
val v = values(allDay(rrule = "FREQ=DAILY", exDates = listOf("20260820", "20260822")))
assertThat(v[CalendarContract.Events.EXDATE]).isEqualTo("20260820,20260822")
}
@Test
fun `EXDATEs are not written without a recurrence to exclude from`() {
val v = values(allDay(exDates = listOf("20260820")))
assertThat(v).doesNotContainKey(CalendarContract.Events.EXDATE)
}
@Test
fun `an imported colour is written raw when the account publishes no palette`() {
val v = values(allDay(color = -2818048))
assertThat(v[CalendarContract.Events.EVENT_COLOR]).isEqualTo(-2818048)
assertThat(v[CalendarContract.Events.EVENT_COLOR_KEY]).isNull()
}
@Test
fun `an imported colour snaps to the nearest published palette key`() {
val palette = listOf(
EventColorOption(key = "1", argb = 0xFF0000FF.toInt()), // blue
EventColorOption(key = "2", argb = 0xFFFF4500.toInt()), // orangered
EventColorOption(key = "3", argb = 0xFF008000.toInt()), // green
)
// Tomato — visually an orangered, nothing like the blue or the green.
val v = values(allDay(color = 0xFFFF6347.toInt()), palette)
assertThat(v[CalendarContract.Events.EVENT_COLOR_KEY]).isEqualTo("2")
// A raw colour alongside a key is what a palette calendar rejects.
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
}
@Test
fun `an event with no colour touches neither colour column`() {
val v = values(allDay())
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR)
assertThat(v).doesNotContainKey(CalendarContract.Events.EVENT_COLOR_KEY)
}
@Test
fun `a timed event keeps its own zone and a seconds duration`() {
val start = Instant.parse("2026-08-19T08:00:00Z")
val event = ParsedIcsEvent(
uid = "u@x",
summary = "Standup",
start = start,
end = Instant.parse("2026-08-19T08:15:00Z"),
isAllDay = false,
zoneId = "Europe/Berlin",
recurrenceRule = "FREQ=WEEKLY",
)
val v = values(event)
assertThat(v[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
assertThat(v[CalendarContract.Events.DURATION]).isEqualTo("P900S")
assertThat(v[CalendarContract.Events.ALL_DAY]).isEqualTo(0)
}
}
@@ -0,0 +1,33 @@
package de.jeanlucmakiola.calendula.data.calendar
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* The provider's "use the account default" sentinel is translated out here, so
* the pure planner only ever sees offsets it can resolve (#75).
*/
class ProviderReminderMinutesTest {
@Test
fun `the account-default sentinel is dropped`() {
// Taken as an offset it armed an alarm a minute *after* the event began,
// while the detail screen said "Default reminder" for the same row.
assertThat(mapOf(1L to listOf(-1)).withoutProviderDefaults())
.containsExactly(1L, emptyList<Int>())
}
@Test
fun `a real reminder beside a default one survives`() {
assertThat(mapOf(1L to listOf(-1, 10)).withoutProviderDefaults())
.containsExactly(1L, listOf(10))
}
@Test
fun `an all-day encoding that is negative is not mistaken for the sentinel`() {
// An all-day offset carries the firing time of day, so it is routinely
// negative; a `< 0` test would silence reminders that do fire.
assertThat(mapOf(1L to listOf(-420, -2_000)).withoutProviderDefaults())
.containsExactly(1L, listOf(-420, -2_000))
}
}
@@ -0,0 +1,40 @@
package de.jeanlucmakiola.calendula.domain.ics
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class IcsColorTest {
@Test
fun `a raw Android colour int round-trips`() {
assertThat(parseIcsColorValue("-2818048")).isEqualTo(-2818048)
}
@Test
fun `a CSS3 name resolves opaque`() {
assertThat(parseIcsColorValue("tomato")).isEqualTo(0xFFFF6347.toInt())
assertThat(parseIcsColorValue("REBECCAPURPLE")).isEqualTo(0xFF663399.toInt())
}
@Test
fun `hex forms are accepted with and without alpha`() {
assertThat(parseIcsColorValue("#ff6347")).isEqualTo(0xFFFF6347.toInt())
assertThat(parseIcsColorValue("#80ff6347")).isEqualTo(0x80FF6347.toInt())
}
@Test
fun `a colour with no alpha byte is made opaque`() {
// 0x00FF6347 as a decimal int — an RGB triple that lost its alpha.
assertThat(parseIcsColorValue("16737095")).isEqualTo(0xFFFF6347.toInt())
}
@Test
fun `unusable values resolve to null`() {
assertThat(parseIcsColorValue(null)).isNull()
assertThat(parseIcsColorValue("")).isNull()
// Fossify interpolates a nullable calendar straight into the property.
assertThat(parseIcsColorValue("null")).isNull()
assertThat(parseIcsColorValue("chartreusey")).isNull()
assertThat(parseIcsColorValue("#abc")).isNull()
}
}
@@ -0,0 +1,607 @@
package de.jeanlucmakiola.calendula.domain.ics
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
import kotlin.time.Instant
/**
* Reading the Simple Calendar / Fossify export dialect (Codeberg #225).
*
* Every fixture is shaped as `IcsExporter.writeEvent` emits it — property order,
* the `X-FOSSIFY-*` extensions and the `P0DT1H5M0S` duration spelling included.
*
* Their all-day `DTEND` is *usually* RFC-correct and must not be "corrected":
* `Event.endTS` anchors a UI-created all-day event at noon of its last day
* (`EventActivity.getStartEndTimes`), or at the exclusive midnight for a
* CalDAV-sourced one, and the exporter's `+ TWELVE_HOURS` rounds either to the
* following midnight. Shifting those by a day would corrupt them.
*
* The exception — and the whole of #225 — is events mirrored from Contacts.
* `MainActivity` builds those with `startTS = endTS = timestamp`, so `+ 12h`
* lands back on the starting day and they export zero length.
*/
class IcsFossifyImportTest {
private val parser = IcsParser(TimeZone.of("Europe/Berlin"))
private fun fossify(vararg body: String) = (
listOf(
"BEGIN:VCALENDAR",
"PRODID:-//Fossify//NONSGML Event Calendar//EN",
"VERSION:2.0",
) + body + listOf("END:VCALENDAR")
).joinToString("\r\n")
private fun days(event: ParsedIcsEvent): Long =
(event.end - event.start).inWholeMilliseconds / 86_400_000L
@Test
fun `an all-day event keeps the exact span the file gives it`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"UID:abc123",
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
"DTSTART;VALUE=DATE:19900412",
"DTEND;VALUE=DATE:19900413",
"X-FOSSIFY-MISSING-YEAR:0",
"RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4",
"END:VEVENT",
),
)
val event = result.events.single()
assertThat(event.isAllDay).isTrue()
assertThat(days(event)).isEqualTo(1)
}
@Test
fun `contact-mirrored birthdays export zero length and must not vanish`() {
// The reported failure (#225). Fossify mirrors Contacts birthdays and
// anniversaries with startTS == endTS, so the exporter's +12h rounds
// back to the starting day: DTSTART == DTEND. Read literally these are
// yearly series of zero-length occurrences, which the provider expands
// into nothing at all — the birthdays import "successfully" and are
// then nowhere to be seen.
val text = checkNotNull(
javaClass.classLoader?.getResourceAsStream("ics/fossify-contact-birthdays.ics"),
).use { it.readBytes().toString(Charsets.UTF_8) }
val result = parser.parse(text)
assertThat(result.events).hasSize(3)
result.events.forEach {
assertThat(it.isAllDay).isTrue()
assertThat(days(it)).isEqualTo(1)
assertThat(it.recurrenceRule).startsWith("FREQ=YEARLY")
// Their all-day reminder encoding: "on the day", not midnight UTC.
assertThat(it.semanticReminderMinutes()).containsExactly(0)
}
}
@Test
fun `a real Fossify holiday file imports unchanged`() {
// Shipped inside Fossify Calendar (assets/holidays/AT/public.ics). Its
// PRODID names Fossify, so any producer-sniffing shortcut would corrupt
// it — the file is plain, conformant iCalendar.
val text = checkNotNull(
javaClass.classLoader?.getResourceAsStream("ics/fossify-holidays-at.ics"),
).use { it.readBytes().toString(Charsets.UTF_8) }
val result = parser.parse(text)
assertThat(result.events.map { it.summary })
.containsExactly("Neujahr", "Heilige Drei Könige").inOrder()
assertThat(result.events.map { days(it) }).containsExactly(1L, 1L)
assertThat(result.events.map { it.recurrenceRule }).containsExactly("FREQ=YEARLY", "FREQ=YEARLY")
}
@Test
fun `an all-day event with no DTEND lasts one day`() {
// RFC 5545 3.6.1, and the span the provider needs to expand a series.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"DTSTART;VALUE=DATE:20260819",
"END:VEVENT",
),
)
assertThat(days(result.events.single())).isEqualTo(1)
}
@Test
fun `a zero-length all-day event is widened rather than left to vanish`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260819",
"END:VEVENT",
),
)
assertThat(days(result.events.single())).isEqualTo(1)
}
@Test
fun `a malformed repeat rule is repaired rather than passed to the provider`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART:20260819T080000Z",
"DTEND:20260819T081500Z",
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=",
"END:VEVENT",
),
)
assertThat(result.events.single().recurrenceRule).isEqualTo("FREQ=WEEKLY;INTERVAL=1")
assertThat(result.warnings).contains(IcsParseWarning.RecurrenceRuleRepaired)
}
@Test
fun `a task is imported as an event and reported`() {
val result = parser.parse(
fossify(
"BEGIN:VTODO",
"SUMMARY:Pay rent",
"DTSTART;VALUE=DATE:20260901",
"END:VTODO",
),
)
val task = result.events.single()
assertThat(task.isTask).isTrue()
assertThat(task.summary).isEqualTo("Pay rent")
assertThat(days(task)).isEqualTo(1)
assertThat(result.warnings).contains(IcsParseWarning.TasksImportedAsEvents)
}
@Test
fun `a task dated only by DUE still imports`() {
val result = parser.parse(
fossify(
"BEGIN:VTODO",
"SUMMARY:File taxes",
"DUE;VALUE=DATE:20260901",
"END:VTODO",
),
)
assertThat(result.events.single().summary).isEqualTo("File taxes")
}
@Test
fun `a task with both DTSTART and DUE spans between them`() {
val result = parser.parse(
fossify(
"BEGIN:VTODO",
"SUMMARY:Write report",
"DTSTART:20260901T090000Z",
"DUE:20260901T170000Z",
"END:VTODO",
),
)
val task = result.events.single()
assertThat(task.start).isEqualTo(Instant.parse("2026-09-01T09:00:00Z"))
assertThat(task.end).isEqualTo(Instant.parse("2026-09-01T17:00:00Z"))
}
@Test
fun `a DUE ahead of the DTSTART is still the end`() {
// Property order isn't guaranteed; DUE must not be taken for the start.
val result = parser.parse(
fossify(
"BEGIN:VTODO",
"SUMMARY:Write report",
"DUE:20260901T170000Z",
"DTSTART:20260901T090000Z",
"END:VTODO",
),
)
val task = result.events.single()
assertThat(task.start).isEqualTo(Instant.parse("2026-09-01T09:00:00Z"))
assertThat(task.end).isEqualTo(Instant.parse("2026-09-01T17:00:00Z"))
}
@Test
fun `the per-event colour wins over the calendar colour`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
"COLOR:tomato",
"X-FOSSIFY-EVENT-COLOR:-2818048",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
),
)
assertThat(result.events.single().color).isEqualTo(-2818048)
}
@Test
fun `the calendar colour stands in when the event has none of its own`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
),
)
assertThat(result.events.single().color).isEqualTo(-1155931)
}
@Test
fun `the legacy Simple Calendar colour spellings are read too`() {
val result = parser.parse(
listOf(
"BEGIN:VCALENDAR",
"PRODID:-//Simple Mobile Tools//NONSGML Event Calendar//EN",
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"X-SMT-CATEGORY-COLOR:-1155931",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
"END:VCALENDAR",
).joinToString("\r\n"),
)
assertThat(result.events.single().color).isEqualTo(-1155931)
}
@Test
fun `the literal null Fossify writes for a missing calendar is not a colour`() {
// Both properties interpolate a nullable calendar straight into the value.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Holiday",
"X-FOSSIFY-CATEGORY-COLOR:null",
"CATEGORIES:null",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
),
)
val event = result.events.single()
assertThat(event.color).isNull()
assertThat(event.calendarName).isNull()
}
@Test
fun `CATEGORIES names the source calendar`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"CATEGORIES:Birthdays",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"END:VEVENT",
),
)
assertThat(result.events.single().calendarName).isEqualTo("Birthdays")
}
@Test
fun `CATEGORIES does not outrank a property that names the calendar outright`() {
// Elsewhere CATEGORIES is a tag list, so it only stands in where nothing
// else named the calendar — X-WR-CALNAME and our own label win.
val result = IcsParser().parse(
listOf(
"BEGIN:VCALENDAR",
"X-WR-CALNAME:Work",
"BEGIN:VEVENT",
"SUMMARY:Standup",
"CATEGORIES:Holiday",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
"END:VCALENDAR",
).joinToString("\r\n"),
)
assertThat(result.events.single().calendarName).isEqualTo("Work")
}
@Test
fun `X-WR-CALNAME names the calendar wherever in the file it sits`() {
// Why the property is scanned up front instead of in document order: an
// exporter that writes it at the tail still names the calendar every
// event in the file came from.
val result = IcsParser().parse(
listOf(
"BEGIN:VCALENDAR",
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
"X-WR-CALNAME:Work",
"END:VCALENDAR",
).joinToString("\r\n"),
)
assertThat(result.events.single().calendarName).isEqualTo("Work")
}
@Test
fun `a multi-valued CATEGORIES is a tag list, not a calendar name`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"CATEGORIES:Meeting,Personal",
"DTSTART:20260819T080000Z",
"DTEND:20260819T090000Z",
"END:VEVENT",
),
)
assertThat(result.events.single().calendarName).isNull()
}
@Test
fun `a reminder written as a positive trigger is not read as a lead time`() {
// The exporter flips the sign for reminders stored below -1
// (`sign = if (reminder.minutes < -1) "" else "-"`), so a trigger can
// point after the start. On an all-day event that means a time of day.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"BEGIN:VALARM",
"ACTION:DISPLAY",
"TRIGGER:P0DT9H0M0S",
"END:VALARM",
"END:VEVENT",
),
)
val event = result.events.single()
assertThat(event.reminderMinutes).containsExactly(-540)
assertThat(event.semanticReminderMinutes()).containsExactly(0)
}
@Test
fun `an all-day reminder whose firing time is after noon keeps its full day`() {
// The raw offset is `days * 1440 - timeOfDay`, so an 18:00 notification
// time puts "1 day before" only six hours ahead of the UTC midnight.
// Rounding to the nearest day would read that as "on the day".
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"BEGIN:VALARM",
"ACTION:DISPLAY",
"TRIGGER:-P0DT6H0M0S",
"END:VALARM",
"END:VEVENT",
),
)
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(1440)
}
@Test
fun `an all-day reminder rounds to whole days before`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"BEGIN:VALARM",
"ACTION:DISPLAY",
// 15h before midnight == "1 day before at 09:00".
"TRIGGER:-P0DT15H0M0S",
"END:VALARM",
"END:VEVENT",
),
)
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(1440)
}
@Test
fun `a timed reminder keeps its exact lead time`() {
// Their P0DT1H5M0S duration spelling, from Parser.getDurationCode.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART:20260819T080000Z",
"DTEND:20260819T081500Z",
"BEGIN:VALARM",
"ACTION:DISPLAY",
"TRIGGER:-P0DT0H10M0S",
"END:VALARM",
"END:VEVENT",
),
)
assertThat(result.events.single().semanticReminderMinutes()).containsExactly(10)
}
@Test
fun `a timed trigger that fires after the start is dropped, not clamped`() {
// A legal RFC 5545 follow-up alarm. Modelling it as a lead time of zero
// would invent a notification at the start the file never asked for.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART:20260819T080000Z",
"DTEND:20260819T081500Z",
"BEGIN:VALARM",
"ACTION:DISPLAY",
"TRIGGER:PT30M",
"END:VALARM",
"END:VEVENT",
),
)
assertThat(result.events.single().semanticReminderMinutes()).isEmpty()
}
@Test
fun `an all-day series' EXDATE day codes carry over`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"RRULE:FREQ=DAILY;INTERVAL=1",
"EXDATE:20260820",
"EXDATE:20260822",
"END:VEVENT",
),
)
assertThat(result.events.single().exDates).containsExactly("20260820", "20260822").inOrder()
}
@Test
fun `an all-day EXDATE keeps the day it spells out, whatever zone it carries`() {
// A midnight east of UTC resolves to an instant on the previous day;
// reading that back in UTC would exclude a day that isn't an occurrence
// and leave the one the user deleted in place.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART;VALUE=DATE:20260819",
"DTEND;VALUE=DATE:20260820",
"RRULE:FREQ=DAILY",
"EXDATE;TZID=Europe/Berlin:20260821T000000",
"EXDATE;VALUE=DATE:20260823",
"END:VEVENT",
),
)
assertThat(result.events.single().exDates)
.containsExactly("20260821", "20260823").inOrder()
}
@Test
fun `a timed series' bare EXDATE day code is resolved against the series time`() {
// They store excluded occurrences as day codes and write them out that
// way even for a timed series, where the property alone is ambiguous.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Gym",
"DTSTART:20260819T170000Z",
"DTEND:20260819T180000Z",
"RRULE:FREQ=DAILY;INTERVAL=1",
"EXDATE:20260820",
"END:VEVENT",
),
)
assertThat(result.events.single().exDates).containsExactly("20260820T170000Z")
}
@Test
fun `a floating EXDATE is read in the series' zone, not the device's`() {
// RFC 5545 ties EXDATE to DTSTART's form: with no Z and no TZID the
// value is New York wall time. Reading it in the device's Berlin zone
// would land on an instant no occurrence has, and the excluded
// occurrence would come back.
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Standup",
"DTSTART;TZID=America/New_York:20260819T100000",
"DTEND;TZID=America/New_York:20260819T103000",
"RRULE:FREQ=WEEKLY",
"EXDATE:20260826T100000",
"END:VEVENT",
),
)
assertThat(result.events.single().exDates).containsExactly("20260826T140000Z")
}
@Test
fun `EXDATE is dropped along with an unusable recurrence rule`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Gym",
"DTSTART:20260819T170000Z",
"DTEND:20260819T180000Z",
"RRULE:INTERVAL=1",
"EXDATE:20260820",
"END:VEVENT",
),
)
val event = result.events.single()
assertThat(event.recurrenceRule).isNull()
assertThat(event.exDates).isEmpty()
}
@Test
fun `a whole export imports every component`() {
val result = parser.parse(
fossify(
"BEGIN:VEVENT",
"SUMMARY:Anna Birthday",
"UID:abc123",
"X-FOSSIFY-CATEGORY-COLOR:-1155931",
"CATEGORIES:Birthdays",
"LAST-MODIFIED:20260101T120000Z",
"TRANSP:TRANSPARENT",
"DTSTART;VALUE=DATE:19900412",
"DTEND;VALUE=DATE:19900413",
"X-FOSSIFY-MISSING-YEAR:0",
"DTSTAMP:20260819T100000Z",
"CLASS:PUBLIC",
"STATUS:CONFIRMED",
"RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4",
"END:VEVENT",
"BEGIN:VEVENT",
"SUMMARY:Standup",
"UID:def456",
"DTSTART:20260819T080000Z",
"DTEND:20260819T081500Z",
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU",
"END:VEVENT",
"BEGIN:VTODO",
"SUMMARY:Pay rent",
"UID:ghi789",
"DTSTART;VALUE=DATE:20260901",
"END:VTODO",
),
)
assertThat(result.events.map { it.summary })
.containsExactly("Anna Birthday", "Standup", "Pay rent").inOrder()
// Nothing may reach the provider as a zero-length all-day row.
assertThat(result.events.none { it.isAllDay && days(it) == 0L }).isTrue()
}
}
@@ -165,4 +165,36 @@ class IcsParserTest {
assertThat(result.events.map { it.uid }).containsExactly("good")
assertThat(result.warnings).contains(IcsParseWarning.EventWithoutStartSkipped)
}
@Test
fun `round-trips a timed series' deleted occurrences`() {
val event = IcsEvent(
uid = "u20@calendula",
summary = "Weekly",
start = instantUtc(2026, 6, 18, 13, 0),
end = instantUtc(2026, 6, 18, 13, 30),
isAllDay = false,
zoneId = "UTC",
recurrenceRule = "FREQ=WEEKLY",
exDates = listOf("20260625T130000Z"),
)
assertThat(roundTrip(event).exDates).containsExactly("20260625T130000Z")
}
@Test
fun `round-trips an all-day series' deleted occurrences`() {
val event = IcsEvent(
uid = "u21@calendula",
summary = "Holiday",
start = LocalDate(2026, 6, 18).atStartOfDayIn(TimeZone.UTC),
end = LocalDate(2026, 6, 19).atStartOfDayIn(TimeZone.UTC),
isAllDay = true,
zoneId = "UTC",
recurrenceRule = "FREQ=YEARLY",
exDates = listOf("20270618"),
)
assertThat(roundTrip(event).exDates).containsExactly("20270618")
}
}
@@ -0,0 +1,166 @@
package de.jeanlucmakiola.calendula.domain.ics
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class IcsRecurrenceTest {
@Test
fun `a well-formed rule is unchanged`() {
val result = sanitizeRrule("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE")
assertThat(result.rule).isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE")
assertThat(result.repaired).isFalse()
}
@Test
fun `the RRULE prefix is stripped`() {
assertThat(sanitizeRrule("RRULE:FREQ=DAILY").rule).isEqualTo("FREQ=DAILY")
}
@Test
fun `an empty list part is dropped`() {
val bareByDay = sanitizeRrule("FREQ=WEEKLY;INTERVAL=1;BYDAY=")
assertThat(bareByDay.rule).isEqualTo("FREQ=WEEKLY;INTERVAL=1")
assertThat(bareByDay.repaired).isTrue()
assertThat(sanitizeRrule("FREQ=WEEKLY;BYDAY=MO,,WE;BYMONTH=").rule)
.isEqualTo("FREQ=WEEKLY;BYDAY=MO,WE")
}
@Test
fun `a nonsensical INTERVAL or COUNT is dropped, not fatal`() {
assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=0").rule).isEqualTo("FREQ=DAILY")
assertThat(sanitizeRrule("FREQ=DAILY;INTERVAL=every").rule).isEqualTo("FREQ=DAILY")
assertThat(sanitizeRrule("FREQ=DAILY;COUNT=0").rule).isEqualTo("FREQ=DAILY")
}
@Test
fun `a rule without a usable FREQ is unsalvageable`() {
assertThat(sanitizeRrule("INTERVAL=1;BYDAY=MO").rule).isNull()
assertThat(sanitizeRrule("FREQ=FORTNIGHTLY;INTERVAL=1").rule).isNull()
assertThat(sanitizeRrule("FREQ=;INTERVAL=1").rule).isNull()
assertThat(sanitizeRrule("").rule).isNull()
assertThat(sanitizeRrule(null).rule).isNull()
}
@Test
fun `no rule at all is not a repair`() {
assertThat(sanitizeRrule(null).repaired).isFalse()
assertThat(sanitizeRrule("").repaired).isFalse()
assertThat(sanitizeRrule("FREQ=FORTNIGHTLY").repaired).isTrue()
}
@Test
fun `normalising is not repairing`() {
// Case and a trailing separator change the text but lose nothing, and
// telling the user their file was faulty over either is crying wolf.
val lowercase = sanitizeRrule("freq=daily;count=3")
assertThat(lowercase.rule).isEqualTo("FREQ=DAILY;COUNT=3")
assertThat(lowercase.repaired).isFalse()
val trailing = sanitizeRrule("FREQ=DAILY;")
assertThat(trailing.rule).isEqualTo("FREQ=DAILY")
assertThat(trailing.repaired).isFalse()
}
@Test
fun `parts the provider's parser has no entry for are dropped`() {
// EventRecurrence.parse throws on any part name outside its own table,
// so passing one on would cost the whole event (Codeberg #225).
val result = sanitizeRrule("FREQ=MONTHLY;RSCALE=GREGORIAN;BYMONTHDAY=15")
assertThat(result.rule).isEqualTo("FREQ=MONTHLY;BYMONTHDAY=15")
assertThat(result.repaired).isTrue()
assertThat(sanitizeRrule("FREQ=YEARLY;X-THING=7").rule).isEqualTo("FREQ=YEARLY")
}
@Test
fun `WKST survives, a nonsense weekday does not`() {
assertThat(sanitizeRrule("FREQ=WEEKLY;WKST=SU").rule).isEqualTo("FREQ=WEEKLY;WKST=SU")
assertThat(sanitizeRrule("FREQ=WEEKLY;WKST=XX").rule).isEqualTo("FREQ=WEEKLY")
}
@Test
fun `a BYDAY item that isn't a weekday is dropped`() {
assertThat(sanitizeRrule("FREQ=MONTHLY;BYDAY=-1FR,1,MO").rule)
.isEqualTo("FREQ=MONTHLY;BYDAY=-1FR,MO")
assertThat(sanitizeRrule("FREQ=WEEKLY;BYDAY=mo,tu").rule)
.isEqualTo("FREQ=WEEKLY;BYDAY=MO,TU")
}
@Test
fun `a zero is dropped from the by-position parts only`() {
assertThat(sanitizeRrule("FREQ=MONTHLY;BYMONTHDAY=0,15").rule)
.isEqualTo("FREQ=MONTHLY;BYMONTHDAY=15")
assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=0,9").rule)
.isEqualTo("FREQ=DAILY;BYHOUR=0,9")
}
@Test
fun `an out-of-range numeric item is dropped`() {
// EventRecurrence.parseNumberList range-checks every one of these and
// throws out of insert, so being a number isn't enough (Codeberg #225).
assertThat(sanitizeRrule("FREQ=MONTHLY;BYMONTHDAY=15,32").rule)
.isEqualTo("FREQ=MONTHLY;BYMONTHDAY=15")
assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=25").rule).isEqualTo("FREQ=DAILY")
assertThat(sanitizeRrule("FREQ=YEARLY;BYMONTH=13").rule).isEqualTo("FREQ=YEARLY")
assertThat(sanitizeRrule("FREQ=YEARLY;BYWEEKNO=60").rule).isEqualTo("FREQ=YEARLY")
assertThat(sanitizeRrule("FREQ=YEARLY;BYYEARDAY=400").rule).isEqualTo("FREQ=YEARLY")
assertThat(sanitizeRrule("FREQ=DAILY;BYSECOND=60").rule).isEqualTo("FREQ=DAILY")
assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=25").repaired).isTrue()
}
@Test
fun `the edges of each range survive`() {
assertThat(sanitizeRrule("FREQ=MONTHLY;BYMONTHDAY=-31,31").repaired).isFalse()
assertThat(sanitizeRrule("FREQ=YEARLY;BYYEARDAY=-366,366;BYWEEKNO=-53,53").repaired)
.isFalse()
assertThat(sanitizeRrule("FREQ=DAILY;BYHOUR=23;BYMINUTE=59;BYSECOND=59").repaired)
.isFalse()
// BYSETPOS is the one part the provider leaves unbounded.
assertThat(sanitizeRrule("FREQ=MONTHLY;BYDAY=MO;BYSETPOS=-1,400").rule)
.isEqualTo("FREQ=MONTHLY;BYDAY=MO;BYSETPOS=-1,400")
}
@Test
fun `an UNTIL that isn't a timestamp is dropped`() {
assertThat(sanitizeRrule("FREQ=DAILY;UNTIL=20260819T235959Z").rule)
.isEqualTo("FREQ=DAILY;UNTIL=20260819T235959Z")
assertThat(sanitizeRrule("FREQ=DAILY;UNTIL=2026-08-19").rule).isEqualTo("FREQ=DAILY")
}
@Test
fun `UNTIL and COUNT together keep UNTIL`() {
// EventRecurrence.parse throws outright on a rule carrying both.
val both = sanitizeRrule("FREQ=WEEKLY;COUNT=10;UNTIL=20260101T000000Z")
assertThat(both.rule).isEqualTo("FREQ=WEEKLY;UNTIL=20260101T000000Z")
assertThat(both.repaired).isTrue()
}
@Test
fun `a COUNT the UNTIL check never sees is left alone`() {
// An UNTIL dropped for its shape doesn't take the COUNT down with it.
val staleUntil = sanitizeRrule("FREQ=WEEKLY;COUNT=10;UNTIL=2026-01-01")
assertThat(staleUntil.rule).isEqualTo("FREQ=WEEKLY;COUNT=10")
assertThat(staleUntil.repaired).isTrue()
}
@Test
fun `a part without a value separator is reported as repaired`() {
val noEquals = sanitizeRrule("FREQ=WEEKLY;BYDAY")
assertThat(noEquals.rule).isEqualTo("FREQ=WEEKLY")
assertThat(noEquals.repaired).isTrue()
}
@Test
fun `a trailing separator is normalisation, not a repair`() {
val trailing = sanitizeRrule("FREQ=DAILY;")
assertThat(trailing.rule).isEqualTo("FREQ=DAILY")
assertThat(trailing.repaired).isFalse()
}
}
@@ -149,4 +149,55 @@ class IcsWriterTest {
// Stable across calls — a re-export of the same row yields the same UID.
assertThat(deriveIcsUid(null, 7, 1000)).isEqualTo(deriveIcsUid(null, 7, 1000))
}
@Test
fun `a timed series writes its exclusions as UTC stamps`() {
val event = IcsEvent(
uid = "u9@calendula",
summary = "Weekly",
start = instantUtc(2026, 6, 18, 13, 0),
end = instantUtc(2026, 6, 18, 13, 30),
isAllDay = false,
zoneId = "UTC",
recurrenceRule = "FREQ=WEEKLY",
exDates = listOf("20260625T130000Z", "20260702T130000Z"),
)
assertThat(lines(listOf(event)))
.contains("EXDATE:20260625T130000Z,20260702T130000Z")
}
@Test
fun `an all-day series marks its exclusions VALUE=DATE`() {
val start = LocalDate(2026, 6, 18).atStartOfDayIn(TimeZone.UTC)
val event = IcsEvent(
uid = "u10@calendula",
summary = "Holiday",
start = start,
end = LocalDate(2026, 6, 19).atStartOfDayIn(TimeZone.UTC),
isAllDay = true,
zoneId = "UTC",
recurrenceRule = "FREQ=YEARLY",
exDates = listOf("20270618"),
)
// RFC 5545 ties EXDATE's value type to DTSTART's; a bare day code
// without VALUE=DATE reads as a malformed DATE-TIME.
assertThat(lines(listOf(event))).contains("EXDATE;VALUE=DATE:20270618")
}
@Test
fun `a one-off event never writes an EXDATE`() {
val event = IcsEvent(
uid = "u11@calendula",
summary = "Standup",
start = instantUtc(2026, 6, 18, 13, 0),
end = instantUtc(2026, 6, 18, 13, 30),
isAllDay = false,
zoneId = "UTC",
exDates = listOf("20260625T130000Z"),
)
assertThat(lines(listOf(event)).none { it.startsWith("EXDATE") }).isTrue()
}
}
@@ -286,9 +286,53 @@ class RescheduleViewModelTest {
vm.move(toWednesday())
advanceUntilIdle()
val abandoned = vm.abandoned.value
assertThat(vm.undo(undo)).isFalse()
advanceUntilIdle()
assertThat(fake.updatedEvents).hasSize(1)
// Nothing ticks: the refusal started no journey of its own, and the
// counter is global — a tick here would release the copy the drop that
// *is* in flight is holding, before the grid has drawn it.
assertThat(vm.abandoned.value).isEqualTo(abandoned)
}
@Test
fun `an undo that fails lets the held chip go`(@TempDir tempDir: Path) = runTest(dispatcher) {
// undoStarted has already sent the chip back on its journey by the time
// the inverse write is attempted. Without a tick here nothing ever seats
// it, and the overlay waits out its whole settle timeout for a chip that
// is never coming.
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
val abandoned = vm.abandoned.value
fake.writeError = SecurityException("permission revoked")
assertThat(vm.undo(undo)).isTrue()
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied)
assertThat(vm.abandoned.value).isEqualTo(abandoned + 1)
}
@Test
fun `an undo that lands leaves the abandoned count alone`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
val undo = (vm.outcome.value as MoveOutcome.Moved).undo!!
val abandoned = vm.abandoned.value
vm.undo(undo)
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Undone)
assertThat(vm.abandoned.value).isEqualTo(abandoned)
}
@Test
@@ -494,4 +538,61 @@ class RescheduleViewModelTest {
assertThat(fake.updatedEvents).isEmpty()
assertThat(fake.updatedOccurrences).isEmpty()
}
@Test
fun `a drop that lands nothing says so, since move() already answered yes`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = viewModel(tempDir, fake)
// Refused in prepare: nowhere to move to.
assertThat(vm.move(
MoveRequest(
eventId = 42L,
beginMillis = beginMillis,
endMillis = endMillis,
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)),
),
)).isTrue()
advanceUntilIdle()
assertThat(vm.abandoned.value).isEqualTo(1)
// Refused by the provider, after the write was attempted.
fake.writeError = SecurityException("revoked")
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied)
assertThat(vm.abandoned.value).isEqualTo(2)
}
@Test
fun `a scope dialog dismissed leaves nothing waiting on the write`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply {
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
}
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.abandoned.value).isEqualTo(0)
vm.cancelScope()
advanceUntilIdle()
assertThat(vm.abandoned.value).isEqualTo(1)
}
@Test
fun `a written move never counts as abandoned`(@TempDir tempDir: Path) = runTest(dispatcher) {
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
val vm = viewModel(tempDir, fake)
vm.move(oneHourLater())
advanceUntilIdle()
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
assertThat(vm.abandoned.value).isEqualTo(0)
}
}
@@ -0,0 +1,209 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
import de.jeanlucmakiola.calendula.ui.week.layoutDay
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.ui.unit.dp
import org.junit.jupiter.api.Test
/**
* Which edges a timed block is cut on, and how much of a dragged piece its day
* can show (#253).
*/
class TimedBlockShapeTest {
private val zone = TimeZone.UTC
private val day1 = LocalDate(2026, 9, 5)
private val day2 = day1.plus(1, DateTimeUnit.DAY)
private val day3 = day2.plus(1, DateTimeUnit.DAY)
private fun event(from: LocalDateTimeIsh, to: LocalDateTimeIsh) = EventInstance(
instanceId = 1L, eventId = 1L, calendarId = 1L, title = "E",
start = from.date.atTime(from.hour, 0).toInstant(zone),
end = to.date.atTime(to.hour, 0).toInstant(zone),
isAllDay = false, color = 0xFF112233.toInt(), location = null,
)
data class LocalDateTimeIsh(val date: LocalDate, val hour: Int)
private fun at(date: LocalDate, hour: Int) = LocalDateTimeIsh(date, hour)
@Test
fun `a same-day block is cut on neither edge`() {
val ev = event(at(day1, 9), at(day1, 10))
val block = layoutDay(listOf(ev), day1, zone).single()
assertThat(block.continuesBefore(day1, zone)).isFalse()
assertThat(block.continuesAfter(day1, zone)).isFalse()
}
@Test
fun `an overnight event is cut at the foot of its first day and the head of its second`() {
val ev = event(at(day1, 20), at(day2, 8))
val head = layoutDay(listOf(ev), day1, zone).single()
assertThat(head.continuesBefore(day1, zone)).isFalse()
assertThat(head.continuesAfter(day1, zone)).isTrue()
val tail = layoutDay(listOf(ev), day2, zone).single()
assertThat(tail.continuesBefore(day2, zone)).isTrue()
assertThat(tail.continuesAfter(day2, zone)).isFalse()
}
@Test
fun `a day wholly inside a long event is cut on both edges`() {
val ev = event(at(day1, 20), at(day3, 8))
val middle = layoutDay(listOf(ev), day2, zone).single()
assertThat(middle.continuesBefore(day2, zone)).isTrue()
assertThat(middle.continuesAfter(day2, zone)).isTrue()
}
@Test
fun `an event ending exactly at midnight is not cut`() {
val ev = event(at(day1, 20), at(day2, 0))
val block = layoutDay(listOf(ev), day1, zone).single()
assertThat(block.continuesAfter(day1, zone)).isFalse()
}
@Test
fun `only the cut edges lose their corners`() {
val square = 0.dp
assertThat(timedBlockShape(continuesBefore = true, continuesAfter = false).topStart)
.isEqualTo(CornerSize(square))
assertThat(timedBlockShape(continuesBefore = true, continuesAfter = false).bottomStart)
.isEqualTo(CornerSize(EVENT_CHIP_CORNER))
assertThat(timedBlockShape(continuesBefore = false, continuesAfter = true).topStart)
.isEqualTo(CornerSize(EVENT_CHIP_CORNER))
assertThat(timedBlockShape(continuesBefore = false, continuesAfter = true).bottomEnd)
.isEqualTo(CornerSize(square))
}
private val fourHours = 4 * 60
@Test
fun `an event that fits its day is drawn as one uncut piece`() {
val pieces = dragSlices(eventStartMin = 10 * 60, spanMin = fourHours)
assertThat(pieces).hasSize(1)
assertThat(pieces.single().dayOffset).isEqualTo(0)
assertThat(pieces.single().spanMin).isEqualTo(fourHours)
assertThat(pieces.single().continuesBefore).isFalse()
assertThat(pieces.single().continuesAfter).isFalse()
}
@Test
fun `dragging the start half re-splits both days as it moves`() {
// Held at 22:00: two hours on this day, two on the next.
assertThat(dragSlices(22 * 60, fourHours).map { it.spanMin })
.containsExactly(2 * 60, 2 * 60).inOrder()
// Carried up to 21:00 the first day takes three, so the second day's end
// comes up by exactly the hour the first day gained.
assertThat(dragSlices(21 * 60, fourHours).map { it.spanMin })
.containsExactly(3 * 60, 60).inOrder()
}
@Test
fun `dragging the end half splits the same way, one day back`() {
// The tail's top edge is at midnight and the event began two hours
// earlier, so it starts at -120 relative to the day the finger is on.
val pieces = dragSlices(eventStartMin = -2 * 60, spanMin = fourHours)
assertThat(pieces.map { it.dayOffset }).containsExactly(-1, 0).inOrder()
assertThat(pieces.map { it.spanMin }).containsExactly(2 * 60, 2 * 60).inOrder()
// The piece on the earlier day ends at midnight; the held one starts there.
assertThat(pieces.first().startMin).isEqualTo(22 * 60)
assertThat(pieces.first().continuesAfter).isTrue()
assertThat(pieces.last().startMin).isEqualTo(0)
assertThat(pieces.last().continuesBefore).isTrue()
}
@Test
fun `the earlier half shrinks away as the end half is pulled down its own day`() {
// Tail top dragged from midnight down to 01:00: one hour left behind.
assertThat(dragSlices(-2 * 60 + 60, fourHours).map { it.spanMin })
.containsExactly(60, 3 * 60).inOrder()
// At 02:00 the event no longer crosses midnight at all.
val whole = dragSlices(0, fourHours)
assertThat(whole).hasSize(1)
assertThat(whole.single().continuesBefore).isFalse()
}
@Test
fun `a day wholly inside a long event is cut on both sides`() {
val pieces = dragSlices(eventStartMin = 22 * 60, spanMin = 30 * 60)
assertThat(pieces.map { it.dayOffset }).containsExactly(0, 1, 2).inOrder()
assertThat(pieces[1].spanMin).isEqualTo(MINUTES_PER_DAY)
assertThat(pieces[1].continuesBefore).isTrue()
assertThat(pieces[1].continuesAfter).isTrue()
}
@Test
fun `an event ending exactly at midnight takes no piece of the next day`() {
val pieces = dragSlices(eventStartMin = 22 * 60, spanMin = 2 * 60)
assertThat(pieces).hasSize(1)
assertThat(pieces.single().continuesAfter).isFalse()
}
@Test
fun `a zero-length event still has a piece to draw`() {
val pieces = dragSlices(eventStartMin = 9 * 60, spanMin = 0)
assertThat(pieces).hasSize(1)
assertThat(pieces.single().dayOffset).isEqualTo(0)
assertThat(pieces.single().startMin).isEqualTo(9 * 60)
assertThat(pieces.single().spanMin).isEqualTo(0)
}
@Test
fun `only the days there are columns for are sliced`() {
// A week's worth of event, previewed on a timeline showing three days
// from the one the finger is on.
val pieces = dragSlices(0, 7 * MINUTES_PER_DAY, within = 0..2)
assertThat(pieces.map { it.dayOffset }).containsExactly(0, 1, 2).inOrder()
assertThat(pieces.last().continuesAfter).isTrue()
}
@Test
fun `an event outside the columns shown is sliced into nothing`() {
assertThat(dragSlices(-3 * MINUTES_PER_DAY, 60, within = 0..2)).isEmpty()
}
// --- the copy never blinks out from under the finger (#253) ---
@Test
fun `a short tail dragged to its floor still has a piece to draw`() {
// 23:50-00:05: on the tail's day the clip offset is 10 and the floor is
// one snap step above midnight, which puts the whole event on the day
// before. Day view has no column for that day, so dragSlices hands back
// nothing and the floating copy used to vanish mid-gesture.
val span = 15
val floor = dragFloorMin(clipOffsetMin = 10, eventSpanMin = span)
val eventStartMin = floor - 10
val visible = 0..0
assertThat(dragSlices(eventStartMin, span, visible)).isEmpty()
val edge = edgeDragSlice(eventStartMin, span, visible)
assertThat(edge).isNotNull()
assertThat(edge!!.dayOffset).isEqualTo(0)
assertThat(edge.startMin).isEqualTo(0)
assertThat(edge.continuesBefore).isTrue()
}
@Test
fun `an event a visible column does hold gets no edge piece`() {
assertThat(edgeDragSlice(9 * 60, 60, 0..0)).isNull()
}
}
@@ -0,0 +1,156 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import de.jeanlucmakiola.calendula.ui.week.layoutDay
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/**
* What a timeline drop asks for, for the head and the tail of an event that
* crosses midnight (#253). The tail's top edge is midnight rather than the
* event's start, so the drop has to take that offset back off.
*/
class TimelineDropTest {
private val zone = TimeZone.UTC
private val sat = LocalDate(2026, 9, 5)
private val sun = sat.plus(1, DateTimeUnit.DAY)
// Sat 5 Sep 20:00 to Sun 6 Sep 08:00.
private val overnight = EventInstance(
instanceId = 1L,
eventId = 1L,
calendarId = 1L,
title = "Night shift",
start = sat.atTime(20, 0).toInstant(zone),
end = sun.atTime(8, 0).toInstant(zone),
isAllDay = false,
color = 0xFF112233.toInt(),
location = null,
)
private fun blockOn(day: LocalDate): TimedBlock =
layoutDay(listOf(overnight), day, zone).single()
@Test
fun `the head block carries no clip offset`() {
val head = blockOn(sat)
assertThat(head.startMin).isEqualTo(20 * 60)
assertThat(head.clipOffsetMinutes(sat, zone)).isEqualTo(0)
}
@Test
fun `the tail block is offset by the part that already ran`() {
val tail = blockOn(sun)
assertThat(tail.startMin).isEqualTo(0)
// 20:00 to midnight is four hours of the event already gone.
assertThat(tail.clipOffsetMinutes(sun, zone)).isEqualTo(4 * 60)
}
@Test
fun `dropping the head asks for exactly where it landed`() {
val target = LocalDate(2026, 9, 2)
val drop = TimelineDrop(overnight, target, 20 * 60, clipOffsetMin = 0)
assertThat(drop.startInstant(zone)).isEqualTo(target.atTime(20, 0).toInstant(zone))
}
@Test
fun `dropping the tail moves the event by the days the tail travelled`() {
val tail = blockOn(sun)
val offset = tail.clipOffsetMinutes(sun, zone)
// Dragged three days back, same height in the column: Sun 6 -> Thu 3.
val drop = TimelineDrop(overnight, LocalDate(2026, 9, 3), 0, offset)
// The event began Sat 5 Sep 20:00, so it now begins Wed 2 Sep 20:00 —
// the whole event moved three days, and no time of day was invented.
assertThat(drop.startInstant(zone))
.isEqualTo(LocalDate(2026, 9, 2).atTime(20, 0).toInstant(zone))
}
@Test
fun `dragging the tail down the column re-times the event across midnight`() {
val offset = blockOn(sun).clipOffsetMinutes(sun, zone)
// Tail top pulled from 00:00 to 03:00 on its own day.
val drop = TimelineDrop(overnight, sun, 3 * 60, offset)
assertThat(drop.startInstant(zone)).isEqualTo(sat.atTime(23, 0).toInstant(zone))
}
@Test
fun `pulling the tail above its own midnight moves the event earlier`() {
val offset = blockOn(sun).clipOffsetMinutes(sun, zone)
// The tail's top edge dragged an hour above the midnight it rests on.
val drop = TimelineDrop(overnight, sun, -60, offset)
// Event was Sat 20:00 to Sun 08:00; it now begins an hour earlier.
assertThat(drop.startInstant(zone)).isEqualTo(sat.atTime(19, 0).toInstant(zone))
}
@Test
fun `a top edge dragged past the next midnight resolves onto the day after`() {
val drop = TimelineDrop(overnight, sat, 25 * 60, clipOffsetMin = 0)
assertThat(drop.startInstant(zone)).isEqualTo(sun.atTime(1, 0).toInstant(zone))
}
@Test
fun `a short tail can still be dragged a step earlier`() {
// 23:50 to 00:05: five minutes of tail, less than one snap step, which
// a floor pinned at midnight left with nowhere to go (#253).
assertThat(dragFloorMin(clipOffsetMin = 10, eventSpanMin = 15))
.isEqualTo(-DRAG_SNAP_MINUTES)
}
@Test
fun `a long tail may rise until its event ends just inside the day`() {
// Sat 20:00 to Sun 08:00, held by the Sunday half.
assertThat(dragFloorMin(clipOffsetMin = 4 * 60, eventSpanMin = 12 * 60))
.isEqualTo(DRAG_SNAP_MINUTES - 8 * 60)
}
@Test
fun `an unclipped block cannot be dragged above its own midnight`() {
assertThat(dragFloorMin(clipOffsetMin = 0, eventSpanMin = 60)).isEqualTo(0)
}
@Test
fun `a drop across a DST boundary keeps the time of day it was let go at`() {
// Europe/Berlin springs forward at 02:00 on 29 March 2026, between the
// event's start and the midnight its tail is cut at.
val berlin = TimeZone.of("Europe/Berlin")
val sun = LocalDate(2026, 3, 29)
val overnightIntoDst = EventInstance(
instanceId = 3L, eventId = 3L, calendarId = 1L, title = "Night shift",
start = LocalDate(2026, 3, 28).atTime(22, 0).toInstant(berlin),
end = sun.atTime(6, 0).toInstant(berlin),
isAllDay = false, color = 0xFF112233.toInt(), location = null,
)
val tail = layoutDay(listOf(overnightIntoDst), sun, berlin).single()
val offset = tail.clipOffsetMinutes(sun, berlin)
// Dropped back onto its own slot a week earlier, clear of the boundary.
val drop = TimelineDrop(overnightIntoDst, LocalDate(2026, 3, 22), 0, offset)
assertThat(drop.startInstant(berlin))
.isEqualTo(LocalDate(2026, 3, 21).atTime(22, 0).toInstant(berlin))
}
@Test
fun `a block that is not clipped is unaffected wherever it sits`() {
val plain = EventInstance(
instanceId = 2L, eventId = 2L, calendarId = 1L, title = "Standup",
start = sat.atTime(9, 0).toInstant(zone),
end = sat.atTime(10, 0).toInstant(zone),
isAllDay = false, color = 0xFF112233.toInt(), location = null,
)
val block = layoutDay(listOf(plain), sat, zone).single()
assertThat(block.clipOffsetMinutes(sat, zone)).isEqualTo(0)
}
}
@@ -0,0 +1,89 @@
package de.jeanlucmakiola.calendula.ui.imports
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import org.junit.jupiter.api.Test
import kotlin.time.Instant
/** Which calendar a bulk import lands on before the user touches the picker. */
class ImportTargetTest {
private fun cal(id: Long, name: String, local: Boolean = false) = CalendarSource(
id = id,
displayName = name,
accountName = "acc@local",
accountType = if (local) "LOCAL" else "com.google",
color = 0,
isVisibleInSystem = true,
isLocal = local,
)
private val calendars = listOf(
cal(1L, "Work"),
cal(2L, "Personal", local = true),
cal(3L, "Birthdays", local = true),
)
@Test
fun `a calendar named like the file's wins`() {
assertThat(defaultImportTarget(calendars, "Birthdays")).isEqualTo(3L)
}
@Test
fun `the name match ignores case`() {
assertThat(defaultImportTarget(calendars, "birthdays")).isEqualTo(3L)
}
@Test
fun `an unmatched name falls back to the first local calendar`() {
assertThat(defaultImportTarget(calendars, "Holidays")).isEqualTo(2L)
}
@Test
fun `no name at all falls back to the first local calendar`() {
assertThat(defaultImportTarget(calendars, null)).isEqualTo(2L)
}
@Test
fun `with no local calendars the first of any kind is taken`() {
assertThat(defaultImportTarget(listOf(cal(1L, "Work")), null)).isEqualTo(1L)
}
@Test
fun `an empty list preselects nothing`() {
assertThat(defaultImportTarget(emptyList(), "Birthdays")).isNull()
}
private fun event(calendarName: String?) = ParsedIcsEvent(
uid = "u@x",
summary = "E",
start = Instant.fromEpochMilliseconds(0L),
end = Instant.fromEpochMilliseconds(3_600_000L),
isAllDay = false,
zoneId = "UTC",
calendarName = calendarName,
)
@Test
fun `a file whose events all name one calendar names it`() {
assertThat(fileCalendarName(List(3) { event("Birthdays") })).isEqualTo("Birthdays")
}
@Test
fun `disagreeing events name none`() {
assertThat(fileCalendarName(listOf(event("Birthdays"), event("Anniversaries")))).isNull()
}
@Test
fun `one tagged event among unnamed ones names none`() {
// CATEGORIES is a tag list everywhere but the Fossify family, so a lone
// tagged event must not decide where the whole file lands.
assertThat(fileCalendarName(listOf(event("Holiday")) + List(9) { event(null) })).isNull()
}
@Test
fun `a file that names nothing names none`() {
assertThat(fileCalendarName(List(3) { event(null) })).isNull()
}
}
@@ -0,0 +1,120 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
/**
* Where a week row draws a moved chip — what the settling copy glides onto
* (#68, #253).
*/
class ChipSeatTest {
private val monday = LocalDate(2026, 6, 8)
private val days = (0..6).map { monday.plus(it, DateTimeUnit.DAY) }
private fun event(id: Long, title: String = "E") = EventInstance(
instanceId = id,
eventId = id,
calendarId = 1L,
title = title,
start = monday.atTime(9, 0).toInstant(TimeZone.UTC),
end = monday.atTime(10, 0).toInstant(TimeZone.UTC),
isAllDay = false,
color = 0xFF112233.toInt(),
location = null,
)
/**
* A grid holding [seated] in lane 0 as one bar across [cols], and nothing
* else — the seating a [MonthWeek] gives a span.
*/
private fun row(seated: EventInstance, cols: IntRange) = MonthWeek(
days = days,
spans = listOf(
MonthSpan(
event = seated,
startCol = cols.first,
endCol = cols.last,
lane = 0,
continuesLeft = false,
continuesRight = false,
),
),
timedByDay = emptyMap(),
countByDay = emptyMap(),
)
/** A grid holding one single-day pill per day of [cols], all of [seated]. */
private fun pills(seated: EventInstance, cols: IntRange) = MonthWeek(
days = days,
spans = emptyList(),
timedByDay = cols.associate { days[it] to listOf(seated) },
countByDay = emptyMap(),
)
private fun seat(week: MonthWeek, event: EventInstance, date: LocalDate) = chipSeat(
days = days,
laneCount = 3,
chipAt = { col, lane -> week.chipAt(col, lane, 3) },
chipStart = { col, lane -> week.chipStartCol(col, lane) },
event = event,
date = date,
)
@Test
fun `a single-day chip is seated in its own column`() {
val ev = event(1L)
assertThat(seat(row(ev, 3..3), ev, days[3])).isEqualTo(3 to 0)
}
@Test
fun `a bar is seated from its first column, not the day it was dropped on`() {
val ev = event(1L)
// Covers WedThu; dropped on the Thursday it now runs into.
assertThat(seat(row(ev, 2..3), ev, days[3])).isEqualTo(2 to 0)
}
@Test
fun `a bar carried in from the previous week is seated at column zero`() {
val ev = event(1L)
assertThat(seat(row(ev, 0..4), ev, days[4])).isEqualTo(0 to 0)
}
@Test
fun `a row that does not hold the event seats nothing`() {
val ev = event(1L)
assertThat(seat(row(ev, 2..3), ev, days[5])).isNull()
assertThat(seat(row(ev, 2..3), ev, LocalDate(2026, 7, 1))).isNull()
}
@Test
fun `a re-read chip is matched by title once its event id has changed`() {
// A single-occurrence move writes a new event id; the title survives.
val moved = event(1L, title = "Standup")
val reRead = event(9L, title = "Standup")
assertThat(seat(row(reRead, 3..3), moved, days[3])).isEqualTo(3 to 0)
}
@Test
fun `two untitled events are not the same event`() {
val moved = event(1L, title = "")
val other = event(2L, title = "")
assertThat(isSameEvent(other, moved)).isFalse()
assertThat(seat(row(other, 3..3), moved, days[3])).isNull()
}
@Test
fun `a daily series seats each occurrence in its own column`() {
// Every column holds an occurrence of the same event id, so a walk left
// by identity would run to the start of the row (#253).
val ev = event(1L, title = "Standup")
assertThat(seat(pills(ev, 0..6), ev, days[4])).isEqualTo(4 to 0)
}
}
@@ -44,6 +44,19 @@ class MonthLayoutTest {
location = null,
)
/** A timed event running from [date] 20:00 into the next day at [endHour]. */
private fun overnight(date: LocalDate, endHour: Int = 8) = EventInstance(
instanceId = 7L,
eventId = 7L,
calendarId = 1L,
title = "Night shift",
start = at(date, 20),
end = at(date.plus(1, DateTimeUnit.DAY), endHour),
isAllDay = false,
color = 0xFF112233.toInt(),
location = null,
)
/** All-day events live at UTC midnights with an *exclusive* end. */
private fun allDay(
from: LocalDate,
@@ -162,6 +175,43 @@ class MonthLayoutTest {
assertThat(week.timedByDay[LocalDate(2026, 6, 11)]).isEmpty()
}
@Test
fun `an event crossing midnight is one bar, not two pills, on both sides of a week seam`() {
// Sun 14 Jun 20:00 to Mon 15 Jun 08:00 — the seam of a Monday-anchored
// grid: one day in this row, one in the next. Counting per row made each
// a standalone pill with nothing saying they were one event (#253).
val overnight = overnight(LocalDate(2026, 6, 14))
val head = layoutCalendarWeek(weekOf8th, listOf(overnight), zone)
assertThat(head.timedByDay.values.flatten()).isEmpty()
val headSpan = head.spans.single()
assertThat(headSpan.startCol).isEqualTo(6)
assertThat(headSpan.endCol).isEqualTo(6)
assertThat(headSpan.continuesLeft).isFalse()
assertThat(headSpan.continuesRight).isTrue()
val tailRow = layoutCalendarWeek(
weekOf8th.map { it.plus(7, DateTimeUnit.DAY) },
listOf(overnight),
zone,
)
assertThat(tailRow.timedByDay.values.flatten()).isEmpty()
val tailSpan = tailRow.spans.single()
assertThat(tailSpan.startCol).isEqualTo(0)
assertThat(tailSpan.endCol).isEqualTo(0)
assertThat(tailSpan.continuesLeft).isTrue()
assertThat(tailSpan.continuesRight).isFalse()
}
@Test
fun `an event ending exactly at midnight stays a single-day pill`() {
val untilMidnight = overnight(LocalDate(2026, 6, 10), endHour = 0)
val week = layoutCalendarWeek(weekOf8th, listOf(untilMidnight), zone)
assertThat(week.spans).isEmpty()
assertThat(week.timedByDay[LocalDate(2026, 6, 10)]).hasSize(1)
assertThat(week.timedByDay[LocalDate(2026, 6, 11)]).isEmpty()
}
@Test
fun `countByDay totals bars and pills on each date`() {
val span = allDay(LocalDate(2026, 6, 10), LocalDate(2026, 6, 12), id = 1L)
@@ -0,0 +1,189 @@
package de.jeanlucmakiola.calendula.widget
import android.content.Intent
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
/**
* The rollover alarm is what makes the widgets stop highlighting yesterday
* (#228), so the "when is the next local midnight" arithmetic is the one piece
* worth pinning down — especially where midnight is not 00:00.
*/
class WidgetRolloverSchedulerTest {
private val berlin = TimeZone.of("Europe/Berlin")
private fun at(local: String, zone: TimeZone): Instant =
LocalDateTime.parse(local).toInstant(zone)
private fun nextRollover(local: String, zone: TimeZone = berlin): Instant =
WidgetRolloverScheduler.nextRolloverAt(at(local, zone), zone)
// --- the ordinary day ----------------------------------------------------
@Test
fun `midday rolls over at the coming midnight`() {
val next = nextRollover("2026-08-27T12:00:00")
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
}
@Test
fun `a second before midnight still targets tonight, not tomorrow night`() {
val next = nextRollover("2026-08-27T23:59:59")
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
}
@Test
fun `at midnight exactly the target is the next day, never the current instant`() {
// Re-arming after a firing must move a whole day on, or the widget wakes
// itself in a tight loop.
val now = at("2026-08-28T00:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin))
assertThat(next - now).isGreaterThan(Duration.ZERO)
}
@Test
fun `re-arming from the slack instant itself moves a full day on`() {
// What actually happens in practice: the receiver runs at midnight + slack.
val now = at("2026-08-28T00:00:05", berlin)
assertThat(WidgetRolloverScheduler.nextRolloverAt(now, berlin))
.isEqualTo(at("2026-08-29T00:00:05", berlin))
}
@Test
fun `the result is always in the future for every minute of a day`() {
val zone = berlin
// Spans Berlin's 2024 spring-forward: the likeliest source of a target
// in the past, i.e. an alarm that fires immediately, forever.
var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone)
val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone)
while (probe < end) {
assertThat(WidgetRolloverScheduler.nextRolloverAt(probe, zone)).isGreaterThan(probe)
probe += 1.minutes
}
}
// --- daylight saving -----------------------------------------------------
@Test
fun `spring forward keeps the rollover one day away, not one hour short`() {
// Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long.
// A rollover computed as "now + 24h" would land at 01:00 on 1 April.
val now = at("2024-03-30T12:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin))
assertThat(next - now).isLessThan(24.hours)
}
@Test
fun `fall back does not overshoot into the repeated hour`() {
// Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so
// "now + 24h" would land at 23:00 on the 26th and never roll over.
val now = at("2024-10-26T12:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin))
assertThat(next.toLocalDateTime(berlin).date).isEqualTo(LocalDate.parse("2024-10-27"))
}
@Test
fun `a zone that repeats midnight takes the first start of day`() {
// Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day
// began twice. Pinned as the accepted trade: the widget runs an hour
// ahead until the next redraw. No live zone does this since 2019.
val saoPaulo = TimeZone.of("America/Sao_Paulo")
val next = WidgetRolloverScheduler.nextRolloverAt(
at("2018-02-16T12:00:00", saoPaulo), saoPaulo,
)
val local = next.toLocalDateTime(saoPaulo)
assertThat(local.date).isEqualTo(LocalDate.parse("2018-02-17"))
assertThat(local.hour).isEqualTo(0)
}
@Test
fun `a zone where midnight does not exist rolls over at the real start of day`() {
// Cuba starts DST at 00:00, so 11 March 2018 began at 01:00 in Havana.
// Targeting a literal 00:00 there would arm an instant on the wrong day.
val havana = TimeZone.of("America/Havana")
val next = WidgetRolloverScheduler.nextRolloverAt(at("2018-03-10T12:00:00", havana), havana)
val local = next.toLocalDateTime(havana)
assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11"))
assertThat(local.hour).isEqualTo(1)
assertThat(local.minute).isEqualTo(0)
// And it is genuinely the first instant of that date, not a guess.
assertThat(next).isEqualTo(
LocalDate.parse("2018-03-11").atStartOfDayIn(havana) +
WidgetRolloverScheduler.ROLLOVER_SLACK,
)
}
// --- timezone changes ----------------------------------------------------
@Test
fun `the same instant rolls over at different times in different zones`() {
// TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic
// follows the zone, not a cached offset.
val instant = at("2026-08-27T12:00:00", berlin)
val tokyo = TimeZone.of("Asia/Tokyo")
val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin)
val tokyoNext = WidgetRolloverScheduler.nextRolloverAt(instant, tokyo)
assertThat(tokyoNext).isNotEqualTo(berlinNext)
assertThat(tokyoNext).isLessThan(berlinNext)
assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0)
}
@Test
fun `a half-hour offset zone still lands on its own midnight`() {
val kathmandu = TimeZone.of("Asia/Kathmandu")
val next = WidgetRolloverScheduler.nextRolloverAt(
at("2026-08-27T12:00:00", kathmandu), kathmandu,
)
val local = next.toLocalDateTime(kathmandu)
assertThat(local.date.toString()).isEqualTo("2026-08-28")
assertThat(local.hour).isEqualTo(0)
}
// --- the wiring ----------------------------------------------------------
@Test
fun `the receiver actually handles the action the alarm is sent with`() {
// The single point where the whole fix would die silently: the alarm
// fires, the receiver drops it on the action guard, nothing redraws.
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
.contains(WidgetUpdateReceiver.ACTION_ROLLOVER)
}
@Test
fun `a data change is handled, and re-arms only on the throttle`() {
// PROVIDER_CHANGED fires on every calendar write — each event of a bulk
// import, each drop of a drag — so it is not an unconditional re-arm.
// It is not dropped either: the alarm is inexact and the system may lose
// it, and this is the only signal that keeps arriving to notice.
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
.containsAtLeast(Intent.ACTION_PROVIDER_CHANGED, Intent.ACTION_DATE_CHANGED)
assertThat(WidgetUpdateReceiver.REARM_ACTIONS)
.doesNotContain(Intent.ACTION_PROVIDER_CHANGED)
}
@Test
fun `everything that loses the alarm re-arms it`() {
assertThat(WidgetUpdateReceiver.REARM_ACTIONS).containsAtLeast(
WidgetUpdateReceiver.ACTION_ROLLOVER,
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED,
Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED,
Intent.ACTION_DATE_CHANGED,
)
}
}
@@ -0,0 +1,64 @@
BEGIN:VCALENDAR
PRODID:-//Fossify//NONSGML Event Calendar//EN
VERSION:2.0
BEGIN:VEVENT
SUMMARY:Anna Schmidt
UID:101
X-FOSSIFY-CATEGORY-COLOR:-1155931
CATEGORIES:Birthdays
LAST-MODIFIED:20250615T150640Z
TRANSP:TRANSPARENT
DTSTART;VALUE=DATE:19900412
DTEND;VALUE=DATE:19900412
X-FOSSIFY-MISSING-YEAR:0
DTSTAMP:20260819T120000Z
CLASS:PUBLIC
STATUS:CONFIRMED
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=4
BEGIN:VALARM
DESCRIPTION:Reminder
ACTION:DISPLAY
TRIGGER:P0DT9H0M0S
END:VALARM
END:VEVENT
BEGIN:VEVENT
SUMMARY:Opa
UID:102
X-FOSSIFY-CATEGORY-COLOR:-1155931
CATEGORIES:Birthdays
LAST-MODIFIED:20250615T150640Z
TRANSP:TRANSPARENT
DTSTART;VALUE=DATE:19700603
DTEND;VALUE=DATE:19700603
X-FOSSIFY-MISSING-YEAR:1
DTSTAMP:20260819T120000Z
CLASS:PUBLIC
STATUS:CONFIRMED
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=6
BEGIN:VALARM
DESCRIPTION:Reminder
ACTION:DISPLAY
TRIGGER:P0DT9H0M0S
END:VALARM
END:VEVENT
BEGIN:VEVENT
SUMMARY:Hochzeitstag
UID:103
X-FOSSIFY-CATEGORY-COLOR:-1155931
CATEGORIES:Anniversaries
LAST-MODIFIED:20250615T150640Z
TRANSP:TRANSPARENT
DTSTART;VALUE=DATE:20050917
DTEND;VALUE=DATE:20050917
X-FOSSIFY-MISSING-YEAR:0
DTSTAMP:20260819T120000Z
CLASS:PUBLIC
STATUS:CONFIRMED
RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=9
BEGIN:VALARM
DESCRIPTION:Reminder
ACTION:DISPLAY
TRIGGER:P0DT9H0M0S
END:VALARM
END:VEVENT
END:VCALENDAR
@@ -0,0 +1,25 @@
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
PRODID:Fossify Calendar Holiday Generator
METHOD:PUBLISH
X-PUBLISHED-TTL:PT1H
BEGIN:VEVENT
UID:fossify_c10f9ad245dc2e57fb3652abee6ed8a06744f0bd
SUMMARY:Neujahr
DTSTAMP:20260526T132315Z
DTSTART;VALUE=DATE:19800101
DTEND;VALUE=DATE:19800102
STATUS:CONFIRMED
RRULE:FREQ=YEARLY
END:VEVENT
BEGIN:VEVENT
UID:fossify_8a60ccf65b65265f768dd33a0b7aefc7620e3ee2
SUMMARY:Heilige Drei Könige
DTSTAMP:20260526T132315Z
DTSTART;VALUE=DATE:19800106
DTEND;VALUE=DATE:19800107
STATUS:CONFIRMED
RRULE:FREQ=YEARLY
END:VEVENT
END:VCALENDAR