From 3aecb19a0cbd8f23b09268e38ba43dac0be88fde Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 10:58:26 +0200 Subject: [PATCH] feat(contacts): idempotent special-dates sync engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine reconciles device contact dates into the per-type local calendars. Each run is an idempotent diff keyed on the deterministic UID_2445: - new contacts inserted (seeding reminders via resolveDefaultReminder, plus a useful per-calendar all-day default of on-the-day + a week before so birthdays get lead time out of the box); - changed contacts get a targeted managed-column update (title/dtstart/rrule only) — reminders/location/notes are never re-touched, so user edits survive; - removed contacts deleted. Managed calendars are created/adopted/removed per enabled type (reconcileCalendars, self-healing against a stored-id that no longer exists), all-day FREQ=YEARLY events anchored at the birth year (or a leap anchor when year-less). Pure helpers (uid, anchor, age snapshot, title templating) and the diff are extracted for unit testing; a stateful fake exercises full-run idempotency and user-data preservation. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AndroidSpecialDatesCalendarSpec.kt | 41 +++ .../data/contacts/SpecialDatesSyncEngine.kt | 277 ++++++++++++++++++ .../calendula/data/di/DataModule.kt | 8 + .../domain/contacts/SpecialDateFormatting.kt | 50 ++++ app/src/main/res/values/strings.xml | 9 + .../data/calendar/FakeCalendarDataSource.kt | 13 +- .../contacts/SpecialDatesSyncEngineTest.kt | 196 +++++++++++++ .../contacts/SpecialDateFormattingTest.kt | 69 +++++ 8 files changed, 661 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/AndroidSpecialDatesCalendarSpec.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormatting.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngineTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormattingTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/AndroidSpecialDatesCalendarSpec.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/AndroidSpecialDatesCalendarSpec.kt new file mode 100644 index 0000000..cacfd54 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/AndroidSpecialDatesCalendarSpec.kt @@ -0,0 +1,41 @@ +package de.jeanlucmakiola.calendula.data.contacts + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Resolves the localized calendar names/templates and per-type colours the + * mirror creates its calendars with. The colours are picked from the shared + * palette so managed calendars look native alongside user calendars. + */ +@Singleton +class AndroidSpecialDatesCalendarSpec @Inject constructor( + @ApplicationContext private val context: Context, +) : SpecialDatesCalendarSpec { + + override fun displayName(type: SpecialDateType): String = context.getString( + when (type) { + SpecialDateType.Birthday -> R.string.special_dates_calendar_birthday + SpecialDateType.Anniversary -> R.string.special_dates_calendar_anniversary + SpecialDateType.Custom -> R.string.special_dates_calendar_custom + }, + ) + + override fun defaultTitleTemplate(type: SpecialDateType): String = context.getString( + when (type) { + SpecialDateType.Birthday -> R.string.special_dates_default_title_birthday + SpecialDateType.Anniversary -> R.string.special_dates_default_title_anniversary + SpecialDateType.Custom -> R.string.special_dates_default_title_custom + }, + ) + + override fun color(type: SpecialDateType): Int = when (type) { + SpecialDateType.Birthday -> 0xFF8E24AA.toInt() // purple + SpecialDateType.Anniversary -> 0xFFD50000.toInt() // red + SpecialDateType.Custom -> 0xFF039BE5.toInt() // blue + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt new file mode 100644 index 0000000..4a4b370 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt @@ -0,0 +1,277 @@ +package de.jeanlucmakiola.calendula.data.contacts + +import android.provider.CalendarContract +import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource +import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow +import de.jeanlucmakiola.calendula.data.calendar.toWriteTimes +import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder +import de.jeanlucmakiola.calendula.domain.Availability +import de.jeanlucmakiola.calendula.domain.EventForm +import de.jeanlucmakiola.calendula.domain.RecurrenceFreq +import de.jeanlucmakiola.calendula.domain.SimpleRecurrence +import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate +import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType +import de.jeanlucmakiola.calendula.domain.contacts.anchorDate +import de.jeanlucmakiola.calendula.domain.contacts.ageAtNextOccurrence +import de.jeanlucmakiola.calendula.domain.contacts.managedEventUid +import de.jeanlucmakiola.calendula.domain.contacts.renderSpecialDateTitle +import de.jeanlucmakiola.calendula.domain.toRRule +import kotlinx.coroutines.flow.first +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import java.time.ZoneId +import javax.inject.Inject +import javax.inject.Singleton + +/** Outcome of one mirror reconcile, reported back so the worker can record status. */ +enum class SpecialDatesSyncResult { Success, Disabled, PermissionMissing } + +/** + * Localized, per-type presentation the engine needs but can't derive purely + * (calendar display name, colour, and the default title template). Kept behind + * an interface so the engine's diff stays unit-testable without Android + * resources. + */ +interface SpecialDatesCalendarSpec { + fun displayName(type: SpecialDateType): String + fun color(type: SpecialDateType): Int + fun defaultTitleTemplate(type: SpecialDateType): String +} + +/** + * Mirrors contact birthdays/anniversaries/custom dates into local calendars, + * one per type. Every reconcile is an idempotent diff keyed on the deterministic + * `UID_2445` ([managedEventUid]): new contacts are inserted (seeding user-owned + * fields once), changed contacts get a *targeted* managed-column update, and + * removed contacts are deleted — so a user's own edits (reminders, location, + * notes) are never clobbered. See docs/design/contact-special-dates.md. + */ +@Singleton +class SpecialDatesSyncEngine @Inject constructor( + private val contacts: ContactSpecialDatesDataSource, + private val calendars: CalendarDataSource, + private val prefs: SettingsPrefs, + private val spec: SpecialDatesCalendarSpec, +) { + + /** + * Reconcile every enabled type against the device's contacts. [today] anchors + * the age snapshot. Returns why it stopped early (disabled / permission gone) + * or [SpecialDatesSyncResult.Success]. + */ + suspend fun sync(today: LocalDate): SpecialDatesSyncResult { + if (!prefs.specialDatesEnabled.first()) return SpecialDatesSyncResult.Disabled + if (!contacts.hasPermission()) return SpecialDatesSyncResult.PermissionMissing + + val enabledTypes = prefs.specialDatesTypes.first() + val calendarByType = reconcileCalendars(enabledTypes) + + val desiredByType = contacts.readSpecialDates().groupBy { it.type } + val reminderCtx = readReminderContext() + val templates = prefs.specialDatesTitleTemplates.first() + val showAge = prefs.specialDatesShowAge.first() + + calendarByType.forEach { (type, calendarId) -> + val template = templates[type]?.takeIf { it.isNotBlank() } + ?: spec.defaultTitleTemplate(type) + syncType( + calendarId = calendarId, + type = type, + contactsOfType = desiredByType[type].orEmpty(), + today = today, + template = template, + showAge = showAge, + reminderCtx = reminderCtx, + ) + } + return SpecialDatesSyncResult.Success + } + + /** Delete every managed calendar and forget its id — used when the feature is turned off. */ + suspend fun teardown() { + calendars.findManagedCalendars().forEach { calendars.deleteCalendar(it.id) } + SpecialDateType.entries.forEach { prefs.setSpecialDatesCalendarId(it, null) } + } + + /** + * Ensure each enabled type has exactly one managed calendar (adopting an + * existing one, or the stored id if it still exists, else creating one) and + * that disabled types have none. Returns the calendar id per enabled type. + */ + private suspend fun reconcileCalendars(enabledTypes: Set): Map { + val foundByType = calendars.findManagedCalendars() + .groupBy({ it.type }, { it.id }) + .mapValues { it.value.first() } + val stored = prefs.specialDatesCalendars.first() + val result = LinkedHashMap() + for (type in SpecialDateType.entries) { + // A stored id counts only if the calendar still exists (the user may + // have deleted it in system settings); otherwise adopt a found one. + val existingId = stored[type]?.takeIf { id -> foundByType.containsValue(id) } + ?: foundByType[type] + if (type in enabledTypes) { + val id = existingId ?: createCalendar(type) + if (stored[type] != id) prefs.setSpecialDatesCalendarId(type, id) + result[type] = id + } else { + if (existingId != null) calendars.deleteCalendar(existingId) + if (stored.containsKey(type)) prefs.setSpecialDatesCalendarId(type, null) + } + } + return result + } + + private suspend fun createCalendar(type: SpecialDateType): Long { + val id = calendars.createManagedCalendar(spec.displayName(type), spec.color(type), type) + prefs.setSpecialDatesCalendarId(type, id) + // Seed a useful all-day reminder default (on the day + a week before), so + // birthdays get lead time out of the box. Per-calendar, user-adjustable — + // only set when the user hasn't already configured this calendar. + if (!prefs.perCalendarAllDayReminderOverride.first().containsKey(id)) { + prefs.setCalendarAllDayReminderOverride( + id, + CalendarReminderOverride.Minutes(DEFAULT_REMINDER_MINUTES), + ) + } + return id + } + + private suspend fun syncType( + calendarId: Long, + type: SpecialDateType, + contactsOfType: List, + today: LocalDate, + template: String, + showAge: Boolean, + reminderCtx: ReminderContext, + ) { + val built = contactsOfType.map { sd -> + buildManagedEvent(calendarId, type, sd, today, template, showAge, reminderCtx) + } + val diff = diffManagedEvents(built.map { it.desired }, calendars.queryManagedEvents(calendarId)) + val formByUid = built.associate { it.desired.uid to it.form } + diff.insertUids.forEach { uid -> + calendars.insertManagedEvent(formByUid.getValue(uid), uid, reminderCtx.allDayTimeMinutes) + } + diff.updates.forEach { calendars.updateManagedFields(it.eventId, it.columns) } + diff.deleteEventIds.forEach { calendars.deleteEvent(it) } + } + + private fun buildManagedEvent( + calendarId: Long, + type: SpecialDateType, + sd: ContactSpecialDate, + today: LocalDate, + template: String, + showAge: Boolean, + reminderCtx: ReminderContext, + ): BuiltManagedEvent { + val anchor = sd.anchorDate() + val start = LocalDateTime(anchor, LocalTime(0, 0)) + val age = if (showAge) sd.ageAtNextOccurrence(today) else null + val title = renderSpecialDateTitle(template, sd.displayName, age) + val form = EventForm( + calendarId = calendarId, + title = title, + isAllDay = true, + start = start, + end = start, + reminders = reminderCtx.resolveAllDay(calendarId), + availability = Availability.Free, + rrule = YEARLY_RRULE, + ) + val dtStartMillis = form.toWriteTimes(ZoneId.systemDefault()).dtStartMillis + return BuiltManagedEvent( + desired = ManagedEventDesired( + uid = managedEventUid(type, sd.lookupKey), + title = title, + dtStartMillis = dtStartMillis, + rrule = YEARLY_RRULE, + ), + form = form, + ) + } + + private suspend fun readReminderContext(): ReminderContext = ReminderContext( + timedGlobal = prefs.defaultReminderMinutes.first(), + allDayGlobal = prefs.defaultAllDayReminderMinutes.first(), + timedOverrides = prefs.perCalendarReminderOverride.first(), + allDayOverrides = prefs.perCalendarAllDayReminderOverride.first(), + allDayTimeMinutes = prefs.allDayReminderTimeMinutes.first(), + ) + + private data class ReminderContext( + val timedGlobal: List, + val allDayGlobal: List, + val timedOverrides: Map>, + val allDayOverrides: Map>, + val allDayTimeMinutes: Int, + ) { + fun resolveAllDay(calendarId: Long): List = resolveDefaultReminder( + timedGlobal = timedGlobal, + allDayGlobal = allDayGlobal, + timedOverrides = timedOverrides, + allDayOverrides = allDayOverrides, + calendarId = calendarId, + isAllDay = true, + ) + } + + private data class BuiltManagedEvent(val desired: ManagedEventDesired, val form: EventForm) + + private companion object { + /** "On the day" (0) + one week before (7 days), as all-day lead minutes. */ + val DEFAULT_REMINDER_MINUTES = listOf(0, 7 * 24 * 60) + val YEARLY_RRULE = SimpleRecurrence(freq = RecurrenceFreq.Yearly).toRRule() + } +} + +/** The managed columns of a desired event, compared against the existing row. */ +internal data class ManagedEventDesired( + val uid: String, + val title: String, + val dtStartMillis: Long, + val rrule: String?, +) + +internal data class ManagedFieldUpdate(val eventId: Long, val columns: Map) + +internal data class ManagedDiff( + val insertUids: List, + val updates: List, + val deleteEventIds: List, +) + +/** + * The idempotent diff at the heart of the mirror, keyed on `UID_2445`: + * desired-not-existing → insert, existing-not-desired → delete, and for events + * in both only the *changed* managed columns (title/dtstart/rrule) are emitted — + * so a re-run with no contact changes produces nothing, and a managed update + * never touches user-owned columns or reminder rows. Pure, so it's unit-tested. + */ +internal fun diffManagedEvents( + desired: List, + existing: List, +): ManagedDiff { + val desiredByUid = desired.associateBy { it.uid } + val existingByUid = existing.associateBy { it.uid } + val insertUids = desired.filter { it.uid !in existingByUid }.map { it.uid } + val deleteEventIds = existing.filter { it.uid !in desiredByUid }.map { it.eventId } + val updates = buildList { + for (d in desired) { + val row = existingByUid[d.uid] ?: continue + val columns = buildMap { + if (row.title != d.title) put(CalendarContract.Events.TITLE, d.title) + if (row.dtStartMillis != d.dtStartMillis) { + put(CalendarContract.Events.DTSTART, d.dtStartMillis) + } + if (row.rrule != d.rrule) put(CalendarContract.Events.RRULE, d.rrule) + } + if (columns.isNotEmpty()) add(ManagedFieldUpdate(row.eventId, columns)) + } + } + return ManagedDiff(insertUids, updates, deleteEventIds) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt index d90a96f..8b001d4 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt @@ -15,7 +15,9 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataSource +import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource +import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore import kotlinx.coroutines.CoroutineDispatcher @@ -53,6 +55,12 @@ abstract class DataBindModule { abstract fun bindContactSpecialDatesDataSource( impl: AndroidContactSpecialDatesDataSource, ): ContactSpecialDatesDataSource + + @Binds + @Singleton + abstract fun bindSpecialDatesCalendarSpec( + impl: AndroidSpecialDatesCalendarSpec, + ): SpecialDatesCalendarSpec } @Module diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormatting.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormatting.kt new file mode 100644 index 0000000..9c78a36 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormatting.kt @@ -0,0 +1,50 @@ +package de.jeanlucmakiola.calendula.domain.contacts + +import kotlinx.datetime.LocalDate +import kotlinx.datetime.number + +/** + * The deterministic `Events.UID_2445` that ties a mirrored event to its source + * contact date. Stable across syncs (the reconciliation key), and namespaced by + * type so the same contact's birthday and anniversary never collide. + */ +fun managedEventUid(type: SpecialDateType, lookupKey: String): String = + "contact-${type.name.lowercase()}:$lookupKey@calendula" + +/** + * The all-day date the recurring `FREQ=YEARLY` series is anchored at: the real + * date when the year is known (so age can be derived), otherwise the month/day + * on a fixed leap anchor year so a `--02-29` date stays representable and the + * anchor never drifts between syncs. + */ +fun ContactSpecialDate.anchorDate(): LocalDate = + LocalDate(year ?: YEARLESS_ANCHOR_YEAR, month, day) + +/** + * The age the contact reaches on the *upcoming* occurrence of this date, + * relative to [today], or null when the year is unknown. Because a + * `FREQ=YEARLY` event carries a single static title, this is a snapshot taken + * at sync time (refreshed on the next sync), not a per-occurrence value. + */ +fun ContactSpecialDate.ageAtNextOccurrence(today: LocalDate): Int? { + val birthYear = year ?: return null + val monthDayPassedThisYear = month < today.month.number || + (month == today.month.number && day < today.day) + val nextYear = if (monthDayPassedThisYear) today.year + 1 else today.year + return nextYear - birthYear +} + +/** + * Render a title [template] for a contact, substituting `{name}` and `{age}` + * (the latter empty when [age] is null). Collapses the whitespace the empty + * `{age}` may leave behind, so "{name}'s birthday ({age})" degrades cleanly to + * "Jane's birthday" when no year is known. + */ +fun renderSpecialDateTitle(template: String, name: String, age: Int?): String = + template + .replace("{name}", name) + .replace("{age}", age?.toString().orEmpty()) + // Drop an empty "()" left by an unresolved {age}, then tidy spacing. + .replace(Regex("""\(\s*\)"""), "") + .replace(Regex("""\s+"""), " ") + .trim() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1f96a98..46446bd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -462,4 +462,13 @@ _(The report was too long for this link — paste it from your clipboard here.)_ https://codeberg.org/jlmakiola/calendula/issues/new https://codeberg.org/jlmakiola/calendula/issues/new/choose + + + Birthdays + Anniversaries + Special dates + {name}\'s birthday ({age}) + {name}\'s anniversary ({age}) + {name} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt index b2832b8..edbff7f 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt @@ -86,6 +86,8 @@ internal class FakeCalendarDataSource : CalendarDataSource { override fun deleteCalendar(id: Long) { writeError?.let { throw it } deletedCalendarIds += id + managedCalendarRows.removeAll { it.id == id } + managedEvents.remove(id) } /** All-day reminder fire-time minute-of-day passed into the last write. */ @@ -147,9 +149,9 @@ internal class FakeCalendarDataSource : CalendarDataSource { // --- Managed special-dates surface (stateful, so a re-sync sees inserts) --- - var findManagedCalendarsResult: List = emptyList() var nextManagedEventId: Long = 1_000L private var managedCalendarSeq: Long = 700L + private val managedCalendarRows = mutableListOf() private val managedEvents = mutableMapOf>() data class CreatedManagedCalendar(val displayName: String, val color: Int, val type: SpecialDateType) @@ -157,15 +159,22 @@ internal class FakeCalendarDataSource : CalendarDataSource { val insertedManagedEvents = mutableListOf>() val updatedManagedFields = mutableListOf>>() + /** Preset an orphan managed calendar (e.g. to test adoption after a prefs wipe). */ + fun presetManagedCalendar(id: Long, type: SpecialDateType) { + managedCalendarRows += ManagedCalendarRow(id, type) + managedEvents.getOrPut(id) { mutableListOf() } + } + override fun createManagedCalendar(displayName: String, color: Int, type: SpecialDateType): Long { writeError?.let { throw it } createdManagedCalendars += CreatedManagedCalendar(displayName, color, type) val id = managedCalendarSeq++ + managedCalendarRows += ManagedCalendarRow(id, type) managedEvents.getOrPut(id) { mutableListOf() } return id } - override fun findManagedCalendars(): List = findManagedCalendarsResult + override fun findManagedCalendars(): List = managedCalendarRows.toList() override fun queryManagedEvents(calendarId: Long): List = managedEvents[calendarId]?.toList() ?: emptyList() diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngineTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngineTest.kt new file mode 100644 index 0000000..c24a411 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngineTest.kt @@ -0,0 +1,196 @@ +package de.jeanlucmakiola.calendula.data.contacts + +import android.provider.CalendarContract +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource +import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate +import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +class SpecialDatesSyncEngineTest { + + private val today = LocalDate(2026, 3, 1) + + private class FakeContacts( + var permission: Boolean = true, + var dates: List = emptyList(), + ) : ContactSpecialDatesDataSource { + override fun hasPermission() = permission + override fun readSpecialDates() = dates + } + + private class FakeSpec : SpecialDatesCalendarSpec { + override fun displayName(type: SpecialDateType) = type.name + override fun color(type: SpecialDateType) = 0xFF112233.toInt() + override fun defaultTitleTemplate(type: SpecialDateType) = when (type) { + SpecialDateType.Birthday -> "{name}'s birthday ({age})" + SpecialDateType.Anniversary -> "{name}'s anniversary" + SpecialDateType.Custom -> "{name}" + } + } + + private fun prefs(tempDir: Path): SettingsPrefs { + val store: DataStore = PreferenceDataStoreFactory.create( + produceFile = { tempDir.resolve("settings_test.preferences_pb").toFile() }, + ) + return SettingsPrefs(store) + } + + private fun birthday(key: String, name: String, month: Int, day: Int, year: Int?) = + ContactSpecialDate(key, name, SpecialDateType.Birthday, month, day, year) + + private suspend fun engineWith( + tempDir: Path, + contacts: FakeContacts, + calendars: FakeCalendarDataSource = FakeCalendarDataSource(), + enabled: Boolean = true, + ): Pair { + val settings = prefs(tempDir) + if (enabled) settings.setSpecialDatesEnabled(true) + return SpecialDatesSyncEngine(contacts, calendars, settings, FakeSpec()) to settings + } + + @Test + fun `disabled feature is a no-op`(@TempDir tempDir: Path) = runTest { + val calendars = FakeCalendarDataSource() + val (engine, _) = engineWith(tempDir, FakeContacts(), calendars, enabled = false) + assertThat(engine.sync(today)).isEqualTo(SpecialDatesSyncResult.Disabled) + assertThat(calendars.createdManagedCalendars).isEmpty() + } + + @Test + fun `missing permission reports stalled without touching calendars`(@TempDir tempDir: Path) = runTest { + val calendars = FakeCalendarDataSource() + val (engine, _) = engineWith(tempDir, FakeContacts(permission = false), calendars) + assertThat(engine.sync(today)).isEqualTo(SpecialDatesSyncResult.PermissionMissing) + assertThat(calendars.createdManagedCalendars).isEmpty() + } + + @Test + fun `first sync creates calendars and inserts one event per contact`(@TempDir tempDir: Path) = runTest { + val calendars = FakeCalendarDataSource() + val contacts = FakeContacts( + dates = listOf( + birthday("a", "Jane", 5, 14, 1990), + birthday("b", "Bob", 12, 25, null), + ), + ) + val (engine, settings) = engineWith(tempDir, contacts, calendars) + + assertThat(engine.sync(today)).isEqualTo(SpecialDatesSyncResult.Success) + // All three type calendars exist; the birthday one holds both contacts. + assertThat(calendars.createdManagedCalendars.map { it.type }) + .containsExactlyElementsIn(SpecialDateType.entries) + assertThat(calendars.insertedManagedEvents).hasSize(2) + assertThat(calendars.insertedManagedEvents.map { it.second }) + .containsExactly("contact-birthday:a@calendula", "contact-birthday:b@calendula") + // Year-less contact drops the age; year-known keeps it. + val titles = calendars.insertedManagedEvents.map { it.first.title } + assertThat(titles).contains("Jane's birthday (36)") + assertThat(titles).contains("Bob's birthday") + // Calendar ids were persisted for the editor / next sync. + assertThat(settings.specialDatesCalendars.first()).containsKey(SpecialDateType.Birthday) + } + + @Test + fun `a second sync with no changes writes nothing`(@TempDir tempDir: Path) = runTest { + val calendars = FakeCalendarDataSource() + val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990))) + val (engine, _) = engineWith(tempDir, contacts, calendars) + + engine.sync(today) + val insertsAfterFirst = calendars.insertedManagedEvents.size + val calendarsAfterFirst = calendars.createdManagedCalendars.size + engine.sync(today) + + assertThat(calendars.insertedManagedEvents).hasSize(insertsAfterFirst) + assertThat(calendars.createdManagedCalendars).hasSize(calendarsAfterFirst) + assertThat(calendars.updatedManagedFields).isEmpty() + assertThat(calendars.deletedEventIds).isEmpty() + } + + @Test + fun `a renamed contact triggers a title-only managed update`(@TempDir tempDir: Path) = runTest { + val calendars = FakeCalendarDataSource() + val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990))) + val (engine, _) = engineWith(tempDir, contacts, calendars) + engine.sync(today) + + contacts.dates = listOf(birthday("a", "Janet", 5, 14, 1990)) + engine.sync(today) + + assertThat(calendars.updatedManagedFields).hasSize(1) + val (_, columns) = calendars.updatedManagedFields.single() + // Only managed columns are ever written — never reminders/location. + assertThat(columns.keys).containsExactly(CalendarContract.Events.TITLE) + assertThat(columns[CalendarContract.Events.TITLE]).isEqualTo("Janet's birthday (36)") + } + + @Test + fun `a removed contact deletes its event`(@TempDir tempDir: Path) = runTest { + val calendars = FakeCalendarDataSource() + val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990))) + val (engine, _) = engineWith(tempDir, contacts, calendars) + engine.sync(today) + + contacts.dates = emptyList() + engine.sync(today) + + assertThat(calendars.deletedEventIds).hasSize(1) + } + + @Test + fun `disabling a type deletes its calendar on the next sync`(@TempDir tempDir: Path) = runTest { + val calendars = FakeCalendarDataSource() + val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars) + engine.sync(today) + val anniversaryId = settings.specialDatesCalendars.first().getValue(SpecialDateType.Anniversary) + + settings.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false) + engine.sync(today) + + assertThat(calendars.deletedCalendarIds).contains(anniversaryId) + assertThat(settings.specialDatesCalendars.first()).doesNotContainKey(SpecialDateType.Anniversary) + } + + // --- pure diff --- + + private fun desired(uid: String, title: String, dt: Long, rrule: String? = "FREQ=YEARLY") = + ManagedEventDesired(uid, title, dt, rrule) + + private fun row(id: Long, uid: String, title: String, dt: Long, rrule: String? = "FREQ=YEARLY") = + ManagedEventRow(id, uid, title, dt, rrule) + + @Test + fun `diff inserts new, deletes gone, and leaves unchanged alone`() { + val diff = diffManagedEvents( + desired = listOf(desired("keep", "Jane", 100), desired("new", "Ada", 200)), + existing = listOf(row(1, "keep", "Jane", 100), row(2, "gone", "Old", 300)), + ) + assertThat(diff.insertUids).containsExactly("new") + assertThat(diff.deleteEventIds).containsExactly(2L) + assertThat(diff.updates).isEmpty() + } + + @Test + fun `diff emits only the changed managed columns`() { + val diff = diffManagedEvents( + desired = listOf(desired("a", "Janet", 150)), + existing = listOf(row(9, "a", "Jane", 100)), + ) + val update = diff.updates.single() + assertThat(update.eventId).isEqualTo(9L) + assertThat(update.columns.keys) + .containsExactly(CalendarContract.Events.TITLE, CalendarContract.Events.DTSTART) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormattingTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormattingTest.kt new file mode 100644 index 0000000..393ccbe --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormattingTest.kt @@ -0,0 +1,69 @@ +package de.jeanlucmakiola.calendula.domain.contacts + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Test + +class SpecialDateFormattingTest { + + private fun birthday(month: Int, day: Int, year: Int?) = ContactSpecialDate( + lookupKey = "k", + displayName = "Jane", + type = SpecialDateType.Birthday, + month = month, + day = day, + year = year, + ) + + @Test + fun `uid is deterministic and namespaced by type`() { + assertThat(managedEventUid(SpecialDateType.Birthday, "abc")) + .isEqualTo("contact-birthday:abc@calendula") + assertThat(managedEventUid(SpecialDateType.Anniversary, "abc")) + .isEqualTo("contact-anniversary:abc@calendula") + } + + @Test + fun `anchor date uses the known year`() { + assertThat(birthday(5, 14, 1990).anchorDate()).isEqualTo(LocalDate(1990, 5, 14)) + } + + @Test + fun `anchor date falls back to a leap year when year-less`() { + assertThat(birthday(2, 29, null).anchorDate()).isEqualTo(LocalDate(YEARLESS_ANCHOR_YEAR, 2, 29)) + } + + @Test + fun `age is null when the year is unknown`() { + assertThat(birthday(5, 14, null).ageAtNextOccurrence(LocalDate(2026, 1, 1))).isNull() + } + + @Test + fun `age counts the upcoming birthday when it is still ahead this year`() { + // Born 1990-05-14; today 2026-03-01 → next birthday 2026 → turns 36. + assertThat(birthday(5, 14, 1990).ageAtNextOccurrence(LocalDate(2026, 3, 1))).isEqualTo(36) + } + + @Test + fun `age rolls to next year once the birthday has passed`() { + // Born 1990-05-14; today 2026-06-01 → next birthday 2027 → turns 37. + assertThat(birthday(5, 14, 1990).ageAtNextOccurrence(LocalDate(2026, 6, 1))).isEqualTo(37) + } + + @Test + fun `age counts today's birthday as this year`() { + assertThat(birthday(5, 14, 1990).ageAtNextOccurrence(LocalDate(2026, 5, 14))).isEqualTo(36) + } + + @Test + fun `title substitutes name and age`() { + assertThat(renderSpecialDateTitle("{name}'s birthday ({age})", "Jane", 30)) + .isEqualTo("Jane's birthday (30)") + } + + @Test + fun `title drops an empty age parenthesis and tidies spacing`() { + assertThat(renderSpecialDateTitle("{name}'s birthday ({age})", "Jane", null)) + .isEqualTo("Jane's birthday") + } +}