diff --git a/CHANGELOG.md b/CHANGELOG.md index 32f6971..e4468d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 "Anniversaries" and "Other dates" calendars that stay in sync as your contacts change. Each is a normal local calendar, so you set its colour, visibility and reminders the usual way — new birthdays even start with a reminder a week before - *and* on the day. The title format is yours to customise (`{name}`, `{age}`). + *and* on the day. The title format is yours to customise (`{name}`, `{year}`). This is the first feature to use the contacts permission: it is requested only when you turn the feature on, everything stays on your device (Calendula has no internet access), and your contacts are only ever read, never changed. Thanks diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesScheduler.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesScheduler.kt index f5237b7..05a1bac 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesScheduler.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesScheduler.kt @@ -17,10 +17,7 @@ import dagger.hilt.components.SingletonComponent import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason import kotlinx.coroutines.flow.first -import kotlinx.datetime.TimeZone -import kotlinx.datetime.todayIn import java.util.concurrent.TimeUnit -import kotlin.time.Clock /** * Schedules the contact special-dates mirror. Birthdays change rarely, so this @@ -97,9 +94,8 @@ class SpecialDatesSyncWorker( return Result.success() } - val today = Clock.System.todayIn(TimeZone.currentSystemDefault()) return try { - val result = deps.syncEngine().sync(today) + val result = deps.syncEngine().sync() val stalled = if (result == SpecialDatesSyncResult.PermissionMissing) { SpecialDatesStalledReason.PermissionRevoked } else { 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 index 655a7a1..fa3369f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngine.kt @@ -14,12 +14,10 @@ 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 @@ -58,11 +56,10 @@ class SpecialDatesSyncEngine @Inject constructor( ) { /** - * 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]. + * Reconcile every enabled type against the device's contacts. Returns why it + * stopped early (disabled / permission gone) or [SpecialDatesSyncResult.Success]. */ - suspend fun sync(today: LocalDate): SpecialDatesSyncResult { + suspend fun sync(): SpecialDatesSyncResult { if (!prefs.specialDatesEnabled.first()) return SpecialDatesSyncResult.Disabled if (!contacts.hasPermission()) return SpecialDatesSyncResult.PermissionMissing @@ -72,7 +69,7 @@ class SpecialDatesSyncEngine @Inject constructor( val desiredByType = contacts.readSpecialDates().groupBy { it.type } val reminderCtx = readReminderContext() val templates = prefs.specialDatesTitleTemplates.first() - val showAge = prefs.specialDatesShowAge.first() + val showYear = prefs.specialDatesShowYear.first() calendarByType.forEach { (type, calendarId) -> val template = templates[type]?.takeIf { it.isNotBlank() } @@ -81,9 +78,8 @@ class SpecialDatesSyncEngine @Inject constructor( calendarId = calendarId, type = type, contactsOfType = desiredByType[type].orEmpty(), - today = today, template = template, - showAge = showAge, + showYear = showYear, reminderCtx = reminderCtx, ) } @@ -165,13 +161,12 @@ class SpecialDatesSyncEngine @Inject constructor( calendarId: Long, type: SpecialDateType, contactsOfType: List, - today: LocalDate, template: String, - showAge: Boolean, + showYear: Boolean, reminderCtx: ReminderContext, ) { val built = contactsOfType.map { sd -> - buildManagedEvent(calendarId, type, sd, today, template, showAge, reminderCtx) + buildManagedEvent(calendarId, type, sd, template, showYear, reminderCtx) } val diff = diffManagedEvents(built.map { it.desired }, calendars.queryManagedEvents(calendarId)) val formByUid = built.associate { it.desired.uid to it.form } @@ -186,15 +181,15 @@ class SpecialDatesSyncEngine @Inject constructor( calendarId: Long, type: SpecialDateType, sd: ContactSpecialDate, - today: LocalDate, template: String, - showAge: Boolean, + showYear: 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) + // The source year is static and correct on every occurrence (unlike age). + val year = if (showYear) sd.year else null + val title = renderSpecialDateTitle(template, sd.displayName, year) val form = EventForm( calendarId = calendarId, title = title, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 96d95c3..2f7c87c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -476,11 +476,11 @@ class SettingsPrefs @Inject constructor( store.edit { it[titleTemplateKey(type)] = template } } - /** Whether `{age}` resolves in the title (only meaningful when the year is known). Default ON. */ - val specialDatesShowAge: Flow = store.data.map { it[SPECIAL_DATES_SHOW_AGE_KEY] ?: true } + /** Whether `{year}` resolves in the title (only meaningful when the year is known). Default ON. */ + val specialDatesShowYear: Flow = store.data.map { it[SPECIAL_DATES_SHOW_YEAR_KEY] ?: true } - suspend fun setSpecialDatesShowAge(enabled: Boolean) { - store.edit { it[SPECIAL_DATES_SHOW_AGE_KEY] = enabled } + suspend fun setSpecialDatesShowYear(enabled: Boolean) { + store.edit { it[SPECIAL_DATES_SHOW_YEAR_KEY] = enabled } } /** Outcome of the last mirror sync, for the settings status line. */ @@ -571,7 +571,7 @@ class SettingsPrefs @Inject constructor( const val MIN_BACKUP_INTERVAL = 30L internal val SPECIAL_DATES_ENABLED_KEY = booleanPreferencesKey("special_dates_enabled") - internal val SPECIAL_DATES_SHOW_AGE_KEY = booleanPreferencesKey("special_dates_show_age") + internal val SPECIAL_DATES_SHOW_YEAR_KEY = booleanPreferencesKey("special_dates_show_year") internal val SPECIAL_DATES_LAST_RUN_KEY = longPreferencesKey("special_dates_last_run") internal val SPECIAL_DATES_STALLED_KEY = stringPreferencesKey("special_dates_stalled_reason") internal val SPECIAL_DATES_LAST_FG_SYNC_KEY = 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 index 9c78a36..f356abb 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormatting.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormatting.kt @@ -1,7 +1,6 @@ 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 @@ -21,30 +20,18 @@ 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 + * Render a title [template] for a contact, substituting `{name}` and `{year}` + * (the source year — a birthday's birth year or an anniversary's start year; + * empty when [year] is null). Unlike an age, the year is static and correct on + * every occurrence of the yearly event. Collapses the whitespace an empty + * `{year}` may leave behind, so "{name}'s birthday ({year})" degrades cleanly to * "Jane's birthday" when no year is known. */ -fun renderSpecialDateTitle(template: String, name: String, age: Int?): String = +fun renderSpecialDateTitle(template: String, name: String, year: Int?): String = template .replace("{name}", name) - .replace("{age}", age?.toString().orEmpty()) - // Drop an empty "()" left by an unresolved {age}, then tidy spacing. + .replace("{year}", year?.toString().orEmpty()) + // Drop an empty "()" left by an unresolved {year}, then tidy spacing. .replace(Regex("""\(\s*\)"""), "") .replace(Regex("""\s+"""), " ") .trim() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index f5ad659..b0b7fec 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -1186,16 +1186,16 @@ private fun SpecialDatesScreen( Spacer(Modifier.height(24.dp)) GroupedRow( - title = stringResource(R.string.settings_special_dates_show_age), - summary = stringResource(R.string.settings_special_dates_show_age_hint), + title = stringResource(R.string.settings_special_dates_show_year), + summary = stringResource(R.string.settings_special_dates_show_year_hint), position = Position.Top, trailing = { Switch( - checked = state.showAge, - onCheckedChange = viewModel::setSpecialDatesShowAge, + checked = state.showYear, + onCheckedChange = viewModel::setSpecialDatesShowYear, ) }, - onClick = { viewModel.setSpecialDatesShowAge(!state.showAge) }, + onClick = { viewModel.setSpecialDatesShowYear(!state.showYear) }, ) GroupedRow( title = stringResource(R.string.settings_special_dates_sync_now), @@ -1302,16 +1302,6 @@ private fun SpecialDatesTemplateDialog( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - // Only warn about {age}'s snapshot behaviour once the user has - // actually put it in the template. - if (text.contains("{age}")) { - Spacer(Modifier.height(8.dp)) - Text( - text = stringResource(R.string.settings_special_dates_template_age_note), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.tertiary, - ) - } } }, confirmButton = { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index e516ef7..f905a9e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -91,7 +91,8 @@ data class SpecialDatesUiState( val titleTemplates: Map = emptyMap(), /** The reminder choice per type's managed calendar (applied to all its events). */ val reminderChoices: Map = emptyMap(), - val showAge: Boolean = true, + /** Whether {year} resolves in titles (the source year is static and always correct). */ + val showYear: Boolean = true, /** True when READ_CONTACTS was revoked after enabling — the mirror is paused. */ val stalledPermission: Boolean = false, /** Epoch millis of the last sync, or 0 if it has never run. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index f029392..8b75a1a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -186,9 +186,9 @@ class SettingsViewModel @Inject constructor( prefs.specialDatesEnabled, prefs.specialDatesTypes, prefs.specialDatesTitleTemplates, - prefs.specialDatesShowAge, + prefs.specialDatesShowYear, prefs.specialDatesStatus, - ) { enabled, types, templates, showAge, status -> + ) { enabled, types, templates, showYear, status -> SpecialDatesUiState( enabled = enabled, types = types, @@ -198,7 +198,7 @@ class SettingsViewModel @Inject constructor( templates[type]?.takeIf { it.isNotBlank() } ?: specialDatesSpec.defaultTitleTemplate(type) }, - showAge = showAge, + showYear = showYear, stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked, lastRun = status.lastRun, ) @@ -256,9 +256,9 @@ class SettingsViewModel @Inject constructor( } } - fun setSpecialDatesShowAge(enabled: Boolean) { + fun setSpecialDatesShowYear(enabled: Boolean) { viewModelScope.launch { - prefs.setSpecialDatesShowAge(enabled) + prefs.setSpecialDatesShowYear(enabled) SpecialDatesScheduler.runNow(appContext) } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 93dbdf6..882f2e1 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -415,8 +415,8 @@ Geburtstage Jahrestage Besondere Tage - {name}s Geburtstag - {name}s Jahrestag + {name}s Geburtstag ({year}) + {name}s Jahrestag ({year}) {name} Verwaltet von „%1$s“ — Titel, Datum und Wiederholung werden aus deinen Kontakten synchron gehalten. Erinnerungen, Ort und Notizen kannst du selbst bearbeiten. Geburtstage & Jahrestage aus Kontakten @@ -427,11 +427,10 @@ Jahrestage Weitere Tage Titelformat - Verwende {name} für den Kontakt und {age} für das Alter (ausgeblendet, wenn das Jahr unbekannt ist). - Hinweis: Das Alter ist ein einzelner Wert vom letzten Sync — es wird nicht für jedes Jahr aktualisiert. Jeder jährliche Termin zeigt daher dieselbe Zahl, was bei weiter zurück- oder vorausliegenden Terminen falsch sein kann. + Verwende {name} für den Kontakt und {year} für das Jahr (Geburtsjahr bzw. Startjahr eines Jahrestags; ausgeblendet, wenn unbekannt). Erinnerungen - Alter anzeigen - {age} in Titeln anzeigen, wenn das Geburtsjahr bekannt ist + Jahr anzeigen + {year} in Titeln anzeigen, wenn es bekannt ist Jetzt synchronisieren Noch nicht synchronisiert Zuletzt synchronisiert %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6dbd4b6..5abcfb4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -356,11 +356,10 @@ Anniversaries Other dates Title format - Use {name} for the contact and {age} for their age (hidden when the year is unknown). - Note: the age is a single value set at the last sync — it isn\'t updated for each year, so every yearly occurrence shows the same number and it can be wrong for dates further in the past or future. + Use {name} for the contact and {year} for the year (the birth year, or an anniversary\'s start year; hidden when unknown). Reminders - Show age - Include {age} in titles when the birth year is known + Show year + Include {year} in titles when it is known Sync now Not synced yet @@ -500,7 +499,7 @@ Birthdays Anniversaries Special dates - {name}\'s birthday - {name}\'s anniversary + {name}\'s birthday ({year}) + {name}\'s anniversary ({year}) {name} 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 index ad953bc..dbfcff8 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngineTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesSyncEngineTest.kt @@ -13,15 +13,12 @@ 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(), @@ -34,7 +31,7 @@ class SpecialDatesSyncEngineTest { 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.Birthday -> "{name}'s birthday ({year})" SpecialDateType.Anniversary -> "{name}'s anniversary" SpecialDateType.Custom -> "{name}" } @@ -65,7 +62,7 @@ class SpecialDatesSyncEngineTest { 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(engine.sync()).isEqualTo(SpecialDatesSyncResult.Disabled) assertThat(calendars.createdManagedCalendars).isEmpty() } @@ -73,7 +70,7 @@ class SpecialDatesSyncEngineTest { 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(engine.sync()).isEqualTo(SpecialDatesSyncResult.PermissionMissing) assertThat(calendars.createdManagedCalendars).isEmpty() } @@ -88,16 +85,16 @@ class SpecialDatesSyncEngineTest { ) val (engine, settings) = engineWith(tempDir, contacts, calendars) - assertThat(engine.sync(today)).isEqualTo(SpecialDatesSyncResult.Success) + assertThat(engine.sync()).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. + // Year-less contact drops the year; year-known keeps it. val titles = calendars.insertedManagedEvents.map { it.first.title } - assertThat(titles).contains("Jane's birthday (36)") + assertThat(titles).contains("Jane's birthday (1990)") assertThat(titles).contains("Bob's birthday") // Calendar ids were persisted for the editor / next sync. assertThat(settings.specialDatesCalendars.first()).containsKey(SpecialDateType.Birthday) @@ -109,10 +106,10 @@ class SpecialDatesSyncEngineTest { val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990))) val (engine, _) = engineWith(tempDir, contacts, calendars) - engine.sync(today) + engine.sync() val insertsAfterFirst = calendars.insertedManagedEvents.size val calendarsAfterFirst = calendars.createdManagedCalendars.size - engine.sync(today) + engine.sync() assertThat(calendars.insertedManagedEvents).hasSize(insertsAfterFirst) assertThat(calendars.createdManagedCalendars).hasSize(calendarsAfterFirst) @@ -125,16 +122,16 @@ class SpecialDatesSyncEngineTest { val calendars = FakeCalendarDataSource() val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990))) val (engine, _) = engineWith(tempDir, contacts, calendars) - engine.sync(today) + engine.sync() contacts.dates = listOf(birthday("a", "Janet", 5, 14, 1990)) - engine.sync(today) + engine.sync() 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)") + assertThat(columns[CalendarContract.Events.TITLE]).isEqualTo("Janet's birthday (1990)") } @Test @@ -142,10 +139,10 @@ class SpecialDatesSyncEngineTest { val calendars = FakeCalendarDataSource() val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990))) val (engine, _) = engineWith(tempDir, contacts, calendars) - engine.sync(today) + engine.sync() contacts.dates = emptyList() - engine.sync(today) + engine.sync() assertThat(calendars.deletedEventIds).hasSize(1) } @@ -154,11 +151,11 @@ class SpecialDatesSyncEngineTest { 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) + engine.sync() val anniversaryId = settings.specialDatesCalendars.first().getValue(SpecialDateType.Anniversary) settings.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false) - engine.sync(today) + engine.sync() assertThat(calendars.deletedCalendarIds).contains(anniversaryId) assertThat(settings.specialDatesCalendars.first()).doesNotContainKey(SpecialDateType.Anniversary) @@ -169,7 +166,7 @@ class SpecialDatesSyncEngineTest { val calendars = FakeCalendarDataSource() val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990))) val (engine, settings) = engineWith(tempDir, contacts, calendars) - engine.sync(today) + engine.sync() val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday) engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.Minutes(listOf(0, 1440))) @@ -187,7 +184,7 @@ class SpecialDatesSyncEngineTest { fun `applyReminders with None clears the reminders`(@TempDir tempDir: Path) = runTest { val calendars = FakeCalendarDataSource() val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars) - engine.sync(today) + engine.sync() val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday) engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.None) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index f39c5cb..af7a4ac 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -349,7 +349,7 @@ class SettingsPrefsTest { assertThat(prefs.specialDatesTypes.first()).isEqualTo(SpecialDateType.entries.toSet()) assertThat(prefs.specialDatesCalendars.first()).isEmpty() assertThat(prefs.managedCalendarIds.first()).isEmpty() - assertThat(prefs.specialDatesShowAge.first()).isTrue() + assertThat(prefs.specialDatesShowYear.first()).isTrue() } @Test 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 index 393ccbe..9961f7a 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormattingTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/contacts/SpecialDateFormattingTest.kt @@ -34,36 +34,14 @@ class SpecialDateFormattingTest { } @Test - fun `age is null when the year is unknown`() { - assertThat(birthday(5, 14, null).ageAtNextOccurrence(LocalDate(2026, 1, 1))).isNull() + fun `title substitutes name and year`() { + assertThat(renderSpecialDateTitle("{name}'s birthday ({year})", "Jane", 1990)) + .isEqualTo("Jane's birthday (1990)") } @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)) + fun `title drops an empty year parenthesis and tidies spacing`() { + assertThat(renderSpecialDateTitle("{name}'s birthday ({year})", "Jane", null)) .isEqualTo("Jane's birthday") } } diff --git a/docs/design/contact-special-dates.md b/docs/design/contact-special-dates.md index 2b27c88..e92965d 100644 --- a/docs/design/contact-special-dates.md +++ b/docs/design/contact-special-dates.md @@ -13,14 +13,14 @@ Implementation notes / deviations from this design: (and doubles as a stable iCal UID). The managed **calendar** still carries a `CAL_SYNC2` marker for re-adoption (calendar-row writes already use the sync-adapter URI). -- **Age is a sync-time snapshot.** A `FREQ=YEARLY` row has one static title, so - `{age}` reflects the upcoming birthday at the last sync (refreshed daily / on - foreground), not a per-occurrence value. Because of this, `{age}` is **kept out - of the default templates**; users can add it, and the title-format editor shows - a disclaimer once they do. +- **Year, not age.** A `FREQ=YEARLY` row has one static title, so a per-occurrence + age is impossible without per-year exception rows (rejected as too heavy). The + template instead offers `{year}` — the birth year (or an anniversary's start + year) — which is static and correct on every occurrence. A "Show year" toggle + gates it; it's in the default templates ("{name}'s birthday ({year})"). - New managed calendars seed a per-calendar all-day reminder default of on-the-day + one week before, so birthdays get lead time out of the box. -- Title template is user-editable from day one (`{name}`, `{age}`). +- Title template is user-editable per type from day one (`{name}`, `{year}`). This document captures the full design for surfacing contact birthdays — and, by extension, anniversaries and custom dates — as auto-updating local @@ -239,7 +239,7 @@ rest. - [x] Sync engine: idempotent diff keyed on the deterministic `UID_2445` (see notes; not `SYNC_DATA1`); targeted managed-column updates; seed user-owned fields on insert only. -- [x] `FREQ=YEARLY` event generation + age (when year known; sync-time snapshot). +- [x] `FREQ=YEARLY` event generation + `{year}` in titles (static, always correct). - [x] Auto-update: on enable, on foreground, daily WorkManager. - [x] Editor: disable managed fields for managed-calendar events. - [x] Revoked-permission detection + stalled-state surface.