feat(contacts): show source year instead of age in titles

A FREQ=YEARLY event has one static title, so a per-occurrence age is impossible
without heavy per-year exception rows. Replace {age} with {year} — the birth
year (or an anniversary's start year) — which is static and correct on every
occurrence, and shows everywhere (widgets, other apps, exports).

- renderSpecialDateTitle substitutes {year}; drop the age snapshot computation
  and the sync `today` parameter.
- Default templates become "{name}'s birthday ({year})" / "…anniversary ({year})".
- Rename the setting to "Show year" (prefs specialDatesShowYear) and remove the
  now-unneeded {age} snapshot disclaimer in the template editor.
- Update tests, CHANGELOG and the design-doc note.

lint + unit tests + assembleDebug green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 13:19:04 +02:00
parent 52816b327a
commit 4e125e58d5
14 changed files with 78 additions and 136 deletions

View File

@@ -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 "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 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 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 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 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 internet access), and your contacts are only ever read, never changed. Thanks

View File

@@ -17,10 +17,7 @@ import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.time.Clock
/** /**
* Schedules the contact special-dates mirror. Birthdays change rarely, so this * Schedules the contact special-dates mirror. Birthdays change rarely, so this
@@ -97,9 +94,8 @@ class SpecialDatesSyncWorker(
return Result.success() return Result.success()
} }
val today = Clock.System.todayIn(TimeZone.currentSystemDefault())
return try { return try {
val result = deps.syncEngine().sync(today) val result = deps.syncEngine().sync()
val stalled = if (result == SpecialDatesSyncResult.PermissionMissing) { val stalled = if (result == SpecialDatesSyncResult.PermissionMissing) {
SpecialDatesStalledReason.PermissionRevoked SpecialDatesStalledReason.PermissionRevoked
} else { } else {

View File

@@ -14,12 +14,10 @@ import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.contacts.anchorDate 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.managedEventUid
import de.jeanlucmakiola.calendula.domain.contacts.renderSpecialDateTitle import de.jeanlucmakiola.calendula.domain.contacts.renderSpecialDateTitle
import de.jeanlucmakiola.calendula.domain.toRRule import de.jeanlucmakiola.calendula.domain.toRRule
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
import java.time.ZoneId import java.time.ZoneId
@@ -58,11 +56,10 @@ class SpecialDatesSyncEngine @Inject constructor(
) { ) {
/** /**
* Reconcile every enabled type against the device's contacts. [today] anchors * Reconcile every enabled type against the device's contacts. Returns why it
* the age snapshot. Returns why it stopped early (disabled / permission gone) * stopped early (disabled / permission gone) or [SpecialDatesSyncResult.Success].
* or [SpecialDatesSyncResult.Success].
*/ */
suspend fun sync(today: LocalDate): SpecialDatesSyncResult { suspend fun sync(): SpecialDatesSyncResult {
if (!prefs.specialDatesEnabled.first()) return SpecialDatesSyncResult.Disabled if (!prefs.specialDatesEnabled.first()) return SpecialDatesSyncResult.Disabled
if (!contacts.hasPermission()) return SpecialDatesSyncResult.PermissionMissing if (!contacts.hasPermission()) return SpecialDatesSyncResult.PermissionMissing
@@ -72,7 +69,7 @@ class SpecialDatesSyncEngine @Inject constructor(
val desiredByType = contacts.readSpecialDates().groupBy { it.type } val desiredByType = contacts.readSpecialDates().groupBy { it.type }
val reminderCtx = readReminderContext() val reminderCtx = readReminderContext()
val templates = prefs.specialDatesTitleTemplates.first() val templates = prefs.specialDatesTitleTemplates.first()
val showAge = prefs.specialDatesShowAge.first() val showYear = prefs.specialDatesShowYear.first()
calendarByType.forEach { (type, calendarId) -> calendarByType.forEach { (type, calendarId) ->
val template = templates[type]?.takeIf { it.isNotBlank() } val template = templates[type]?.takeIf { it.isNotBlank() }
@@ -81,9 +78,8 @@ class SpecialDatesSyncEngine @Inject constructor(
calendarId = calendarId, calendarId = calendarId,
type = type, type = type,
contactsOfType = desiredByType[type].orEmpty(), contactsOfType = desiredByType[type].orEmpty(),
today = today,
template = template, template = template,
showAge = showAge, showYear = showYear,
reminderCtx = reminderCtx, reminderCtx = reminderCtx,
) )
} }
@@ -165,13 +161,12 @@ class SpecialDatesSyncEngine @Inject constructor(
calendarId: Long, calendarId: Long,
type: SpecialDateType, type: SpecialDateType,
contactsOfType: List<ContactSpecialDate>, contactsOfType: List<ContactSpecialDate>,
today: LocalDate,
template: String, template: String,
showAge: Boolean, showYear: Boolean,
reminderCtx: ReminderContext, reminderCtx: ReminderContext,
) { ) {
val built = contactsOfType.map { sd -> 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 diff = diffManagedEvents(built.map { it.desired }, calendars.queryManagedEvents(calendarId))
val formByUid = built.associate { it.desired.uid to it.form } val formByUid = built.associate { it.desired.uid to it.form }
@@ -186,15 +181,15 @@ class SpecialDatesSyncEngine @Inject constructor(
calendarId: Long, calendarId: Long,
type: SpecialDateType, type: SpecialDateType,
sd: ContactSpecialDate, sd: ContactSpecialDate,
today: LocalDate,
template: String, template: String,
showAge: Boolean, showYear: Boolean,
reminderCtx: ReminderContext, reminderCtx: ReminderContext,
): BuiltManagedEvent { ): BuiltManagedEvent {
val anchor = sd.anchorDate() val anchor = sd.anchorDate()
val start = LocalDateTime(anchor, LocalTime(0, 0)) val start = LocalDateTime(anchor, LocalTime(0, 0))
val age = if (showAge) sd.ageAtNextOccurrence(today) else null // The source year is static and correct on every occurrence (unlike age).
val title = renderSpecialDateTitle(template, sd.displayName, age) val year = if (showYear) sd.year else null
val title = renderSpecialDateTitle(template, sd.displayName, year)
val form = EventForm( val form = EventForm(
calendarId = calendarId, calendarId = calendarId,
title = title, title = title,

View File

@@ -476,11 +476,11 @@ class SettingsPrefs @Inject constructor(
store.edit { it[titleTemplateKey(type)] = template } store.edit { it[titleTemplateKey(type)] = template }
} }
/** Whether `{age}` resolves in the title (only meaningful when the year is known). Default ON. */ /** Whether `{year}` resolves in the title (only meaningful when the year is known). Default ON. */
val specialDatesShowAge: Flow<Boolean> = store.data.map { it[SPECIAL_DATES_SHOW_AGE_KEY] ?: true } val specialDatesShowYear: Flow<Boolean> = store.data.map { it[SPECIAL_DATES_SHOW_YEAR_KEY] ?: true }
suspend fun setSpecialDatesShowAge(enabled: Boolean) { suspend fun setSpecialDatesShowYear(enabled: Boolean) {
store.edit { it[SPECIAL_DATES_SHOW_AGE_KEY] = enabled } store.edit { it[SPECIAL_DATES_SHOW_YEAR_KEY] = enabled }
} }
/** Outcome of the last mirror sync, for the settings status line. */ /** 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 const val MIN_BACKUP_INTERVAL = 30L
internal val SPECIAL_DATES_ENABLED_KEY = booleanPreferencesKey("special_dates_enabled") 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_LAST_RUN_KEY = longPreferencesKey("special_dates_last_run")
internal val SPECIAL_DATES_STALLED_KEY = stringPreferencesKey("special_dates_stalled_reason") internal val SPECIAL_DATES_STALLED_KEY = stringPreferencesKey("special_dates_stalled_reason")
internal val SPECIAL_DATES_LAST_FG_SYNC_KEY = internal val SPECIAL_DATES_LAST_FG_SYNC_KEY =

View File

@@ -1,7 +1,6 @@
package de.jeanlucmakiola.calendula.domain.contacts package de.jeanlucmakiola.calendula.domain.contacts
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.number
/** /**
* The deterministic `Events.UID_2445` that ties a mirrored event to its source * 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) LocalDate(year ?: YEARLESS_ANCHOR_YEAR, month, day)
/** /**
* The age the contact reaches on the *upcoming* occurrence of this date, * Render a title [template] for a contact, substituting `{name}` and `{year}`
* relative to [today], or null when the year is unknown. Because a * (the source year — a birthday's birth year or an anniversary's start year;
* `FREQ=YEARLY` event carries a single static title, this is a snapshot taken * empty when [year] is null). Unlike an age, the year is static and correct on
* at sync time (refreshed on the next sync), not a per-occurrence value. * every occurrence of the yearly event. Collapses the whitespace an empty
*/ * `{year}` may leave behind, so "{name}'s birthday ({year})" degrades cleanly to
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. * "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 template
.replace("{name}", name) .replace("{name}", name)
.replace("{age}", age?.toString().orEmpty()) .replace("{year}", year?.toString().orEmpty())
// Drop an empty "()" left by an unresolved {age}, then tidy spacing. // Drop an empty "()" left by an unresolved {year}, then tidy spacing.
.replace(Regex("""\(\s*\)"""), "") .replace(Regex("""\(\s*\)"""), "")
.replace(Regex("""\s+"""), " ") .replace(Regex("""\s+"""), " ")
.trim() .trim()

View File

@@ -1186,16 +1186,16 @@ private fun SpecialDatesScreen(
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_special_dates_show_age), title = stringResource(R.string.settings_special_dates_show_year),
summary = stringResource(R.string.settings_special_dates_show_age_hint), summary = stringResource(R.string.settings_special_dates_show_year_hint),
position = Position.Top, position = Position.Top,
trailing = { trailing = {
Switch( Switch(
checked = state.showAge, checked = state.showYear,
onCheckedChange = viewModel::setSpecialDatesShowAge, onCheckedChange = viewModel::setSpecialDatesShowYear,
) )
}, },
onClick = { viewModel.setSpecialDatesShowAge(!state.showAge) }, onClick = { viewModel.setSpecialDatesShowYear(!state.showYear) },
) )
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_special_dates_sync_now), title = stringResource(R.string.settings_special_dates_sync_now),
@@ -1302,16 +1302,6 @@ private fun SpecialDatesTemplateDialog(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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 = { confirmButton = {

View File

@@ -91,7 +91,8 @@ data class SpecialDatesUiState(
val titleTemplates: Map<SpecialDateType, String> = emptyMap(), val titleTemplates: Map<SpecialDateType, String> = emptyMap(),
/** The reminder choice per type's managed calendar (applied to all its events). */ /** The reminder choice per type's managed calendar (applied to all its events). */
val reminderChoices: Map<SpecialDateType, CalendarReminderOverride> = emptyMap(), val reminderChoices: Map<SpecialDateType, CalendarReminderOverride> = 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. */ /** True when READ_CONTACTS was revoked after enabling — the mirror is paused. */
val stalledPermission: Boolean = false, val stalledPermission: Boolean = false,
/** Epoch millis of the last sync, or 0 if it has never run. */ /** Epoch millis of the last sync, or 0 if it has never run. */

View File

@@ -186,9 +186,9 @@ class SettingsViewModel @Inject constructor(
prefs.specialDatesEnabled, prefs.specialDatesEnabled,
prefs.specialDatesTypes, prefs.specialDatesTypes,
prefs.specialDatesTitleTemplates, prefs.specialDatesTitleTemplates,
prefs.specialDatesShowAge, prefs.specialDatesShowYear,
prefs.specialDatesStatus, prefs.specialDatesStatus,
) { enabled, types, templates, showAge, status -> ) { enabled, types, templates, showYear, status ->
SpecialDatesUiState( SpecialDatesUiState(
enabled = enabled, enabled = enabled,
types = types, types = types,
@@ -198,7 +198,7 @@ class SettingsViewModel @Inject constructor(
templates[type]?.takeIf { it.isNotBlank() } templates[type]?.takeIf { it.isNotBlank() }
?: specialDatesSpec.defaultTitleTemplate(type) ?: specialDatesSpec.defaultTitleTemplate(type)
}, },
showAge = showAge, showYear = showYear,
stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked, stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked,
lastRun = status.lastRun, lastRun = status.lastRun,
) )
@@ -256,9 +256,9 @@ class SettingsViewModel @Inject constructor(
} }
} }
fun setSpecialDatesShowAge(enabled: Boolean) { fun setSpecialDatesShowYear(enabled: Boolean) {
viewModelScope.launch { viewModelScope.launch {
prefs.setSpecialDatesShowAge(enabled) prefs.setSpecialDatesShowYear(enabled)
SpecialDatesScheduler.runNow(appContext) SpecialDatesScheduler.runNow(appContext)
} }
} }

View File

@@ -415,8 +415,8 @@
<string name="special_dates_calendar_birthday">Geburtstage</string> <string name="special_dates_calendar_birthday">Geburtstage</string>
<string name="special_dates_calendar_anniversary">Jahrestage</string> <string name="special_dates_calendar_anniversary">Jahrestage</string>
<string name="special_dates_calendar_custom">Besondere Tage</string> <string name="special_dates_calendar_custom">Besondere Tage</string>
<string name="special_dates_default_title_birthday">{name}s Geburtstag</string> <string name="special_dates_default_title_birthday">{name}s Geburtstag ({year})</string>
<string name="special_dates_default_title_anniversary">{name}s Jahrestag</string> <string name="special_dates_default_title_anniversary">{name}s Jahrestag ({year})</string>
<string name="special_dates_default_title_custom">{name}</string> <string name="special_dates_default_title_custom">{name}</string>
<string name="event_edit_managed_hint">Verwaltet von „%1$s“ — Titel, Datum und Wiederholung werden aus deinen Kontakten synchron gehalten. Erinnerungen, Ort und Notizen kannst du selbst bearbeiten.</string> <string name="event_edit_managed_hint">Verwaltet von „%1$s“ — Titel, Datum und Wiederholung werden aus deinen Kontakten synchron gehalten. Erinnerungen, Ort und Notizen kannst du selbst bearbeiten.</string>
<string name="settings_special_dates_subtitle">Geburtstage &amp; Jahrestage aus Kontakten</string> <string name="settings_special_dates_subtitle">Geburtstage &amp; Jahrestage aus Kontakten</string>
@@ -427,11 +427,10 @@
<string name="settings_special_dates_type_anniversary">Jahrestage</string> <string name="settings_special_dates_type_anniversary">Jahrestage</string>
<string name="settings_special_dates_type_custom">Weitere Tage</string> <string name="settings_special_dates_type_custom">Weitere Tage</string>
<string name="settings_special_dates_template">Titelformat</string> <string name="settings_special_dates_template">Titelformat</string>
<string name="settings_special_dates_template_hint">Verwende {name} für den Kontakt und {age} für das Alter (ausgeblendet, wenn das Jahr unbekannt ist).</string> <string name="settings_special_dates_template_hint">Verwende {name} für den Kontakt und {year} für das Jahr (Geburtsjahr bzw. Startjahr eines Jahrestags; ausgeblendet, wenn unbekannt).</string>
<string name="settings_special_dates_template_age_note">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.</string>
<string name="settings_special_dates_reminders">Erinnerungen</string> <string name="settings_special_dates_reminders">Erinnerungen</string>
<string name="settings_special_dates_show_age">Alter anzeigen</string> <string name="settings_special_dates_show_year">Jahr anzeigen</string>
<string name="settings_special_dates_show_age_hint">{age} in Titeln anzeigen, wenn das Geburtsjahr bekannt ist</string> <string name="settings_special_dates_show_year_hint">{year} in Titeln anzeigen, wenn es bekannt ist</string>
<string name="settings_special_dates_sync_now">Jetzt synchronisieren</string> <string name="settings_special_dates_sync_now">Jetzt synchronisieren</string>
<string name="settings_special_dates_never_synced">Noch nicht synchronisiert</string> <string name="settings_special_dates_never_synced">Noch nicht synchronisiert</string>
<string name="settings_special_dates_last_synced">Zuletzt synchronisiert %1$s</string> <string name="settings_special_dates_last_synced">Zuletzt synchronisiert %1$s</string>

View File

@@ -356,11 +356,10 @@
<string name="settings_special_dates_type_anniversary">Anniversaries</string> <string name="settings_special_dates_type_anniversary">Anniversaries</string>
<string name="settings_special_dates_type_custom">Other dates</string> <string name="settings_special_dates_type_custom">Other dates</string>
<string name="settings_special_dates_template">Title format</string> <string name="settings_special_dates_template">Title format</string>
<string name="settings_special_dates_template_hint">Use {name} for the contact and {age} for their age (hidden when the year is unknown).</string> <string name="settings_special_dates_template_hint">Use {name} for the contact and {year} for the year (the birth year, or an anniversary\'s start year; hidden when unknown).</string>
<string name="settings_special_dates_template_age_note">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.</string>
<string name="settings_special_dates_reminders">Reminders</string> <string name="settings_special_dates_reminders">Reminders</string>
<string name="settings_special_dates_show_age">Show age</string> <string name="settings_special_dates_show_year">Show year</string>
<string name="settings_special_dates_show_age_hint">Include {age} in titles when the birth year is known</string> <string name="settings_special_dates_show_year_hint">Include {year} in titles when it is known</string>
<string name="settings_special_dates_sync_now">Sync now</string> <string name="settings_special_dates_sync_now">Sync now</string>
<string name="settings_special_dates_never_synced">Not synced yet</string> <string name="settings_special_dates_never_synced">Not synced yet</string>
<!-- %1$s is a relative time, e.g. "5 minutes ago". --> <!-- %1$s is a relative time, e.g. "5 minutes ago". -->
@@ -500,7 +499,7 @@
<string name="special_dates_calendar_birthday">Birthdays</string> <string name="special_dates_calendar_birthday">Birthdays</string>
<string name="special_dates_calendar_anniversary">Anniversaries</string> <string name="special_dates_calendar_anniversary">Anniversaries</string>
<string name="special_dates_calendar_custom">Special dates</string> <string name="special_dates_calendar_custom">Special dates</string>
<string name="special_dates_default_title_birthday">{name}\'s birthday</string> <string name="special_dates_default_title_birthday">{name}\'s birthday ({year})</string>
<string name="special_dates_default_title_anniversary">{name}\'s anniversary</string> <string name="special_dates_default_title_anniversary">{name}\'s anniversary ({year})</string>
<string name="special_dates_default_title_custom">{name}</string> <string name="special_dates_default_title_custom">{name}</string>
</resources> </resources>

View File

@@ -13,15 +13,12 @@ import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path import java.nio.file.Path
class SpecialDatesSyncEngineTest { class SpecialDatesSyncEngineTest {
private val today = LocalDate(2026, 3, 1)
private class FakeContacts( private class FakeContacts(
var permission: Boolean = true, var permission: Boolean = true,
var dates: List<ContactSpecialDate> = emptyList(), var dates: List<ContactSpecialDate> = emptyList(),
@@ -34,7 +31,7 @@ class SpecialDatesSyncEngineTest {
override fun displayName(type: SpecialDateType) = type.name override fun displayName(type: SpecialDateType) = type.name
override fun color(type: SpecialDateType) = 0xFF112233.toInt() override fun color(type: SpecialDateType) = 0xFF112233.toInt()
override fun defaultTitleTemplate(type: SpecialDateType) = when (type) { 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.Anniversary -> "{name}'s anniversary"
SpecialDateType.Custom -> "{name}" SpecialDateType.Custom -> "{name}"
} }
@@ -65,7 +62,7 @@ class SpecialDatesSyncEngineTest {
fun `disabled feature is a no-op`(@TempDir tempDir: Path) = runTest { fun `disabled feature is a no-op`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource() val calendars = FakeCalendarDataSource()
val (engine, _) = engineWith(tempDir, FakeContacts(), calendars, enabled = false) 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() assertThat(calendars.createdManagedCalendars).isEmpty()
} }
@@ -73,7 +70,7 @@ class SpecialDatesSyncEngineTest {
fun `missing permission reports stalled without touching calendars`(@TempDir tempDir: Path) = runTest { fun `missing permission reports stalled without touching calendars`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource() val calendars = FakeCalendarDataSource()
val (engine, _) = engineWith(tempDir, FakeContacts(permission = false), calendars) 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() assertThat(calendars.createdManagedCalendars).isEmpty()
} }
@@ -88,16 +85,16 @@ class SpecialDatesSyncEngineTest {
) )
val (engine, settings) = engineWith(tempDir, contacts, calendars) 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. // All three type calendars exist; the birthday one holds both contacts.
assertThat(calendars.createdManagedCalendars.map { it.type }) assertThat(calendars.createdManagedCalendars.map { it.type })
.containsExactlyElementsIn(SpecialDateType.entries) .containsExactlyElementsIn(SpecialDateType.entries)
assertThat(calendars.insertedManagedEvents).hasSize(2) assertThat(calendars.insertedManagedEvents).hasSize(2)
assertThat(calendars.insertedManagedEvents.map { it.second }) assertThat(calendars.insertedManagedEvents.map { it.second })
.containsExactly("contact-birthday:a@calendula", "contact-birthday:b@calendula") .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 } 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") assertThat(titles).contains("Bob's birthday")
// Calendar ids were persisted for the editor / next sync. // Calendar ids were persisted for the editor / next sync.
assertThat(settings.specialDatesCalendars.first()).containsKey(SpecialDateType.Birthday) 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 contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
val (engine, _) = engineWith(tempDir, contacts, calendars) val (engine, _) = engineWith(tempDir, contacts, calendars)
engine.sync(today) engine.sync()
val insertsAfterFirst = calendars.insertedManagedEvents.size val insertsAfterFirst = calendars.insertedManagedEvents.size
val calendarsAfterFirst = calendars.createdManagedCalendars.size val calendarsAfterFirst = calendars.createdManagedCalendars.size
engine.sync(today) engine.sync()
assertThat(calendars.insertedManagedEvents).hasSize(insertsAfterFirst) assertThat(calendars.insertedManagedEvents).hasSize(insertsAfterFirst)
assertThat(calendars.createdManagedCalendars).hasSize(calendarsAfterFirst) assertThat(calendars.createdManagedCalendars).hasSize(calendarsAfterFirst)
@@ -125,16 +122,16 @@ class SpecialDatesSyncEngineTest {
val calendars = FakeCalendarDataSource() val calendars = FakeCalendarDataSource()
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990))) val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
val (engine, _) = engineWith(tempDir, contacts, calendars) val (engine, _) = engineWith(tempDir, contacts, calendars)
engine.sync(today) engine.sync()
contacts.dates = listOf(birthday("a", "Janet", 5, 14, 1990)) contacts.dates = listOf(birthday("a", "Janet", 5, 14, 1990))
engine.sync(today) engine.sync()
assertThat(calendars.updatedManagedFields).hasSize(1) assertThat(calendars.updatedManagedFields).hasSize(1)
val (_, columns) = calendars.updatedManagedFields.single() val (_, columns) = calendars.updatedManagedFields.single()
// Only managed columns are ever written — never reminders/location. // Only managed columns are ever written — never reminders/location.
assertThat(columns.keys).containsExactly(CalendarContract.Events.TITLE) 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 @Test
@@ -142,10 +139,10 @@ class SpecialDatesSyncEngineTest {
val calendars = FakeCalendarDataSource() val calendars = FakeCalendarDataSource()
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990))) val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
val (engine, _) = engineWith(tempDir, contacts, calendars) val (engine, _) = engineWith(tempDir, contacts, calendars)
engine.sync(today) engine.sync()
contacts.dates = emptyList() contacts.dates = emptyList()
engine.sync(today) engine.sync()
assertThat(calendars.deletedEventIds).hasSize(1) 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 { fun `disabling a type deletes its calendar on the next sync`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource() val calendars = FakeCalendarDataSource()
val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars) val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars)
engine.sync(today) engine.sync()
val anniversaryId = settings.specialDatesCalendars.first().getValue(SpecialDateType.Anniversary) val anniversaryId = settings.specialDatesCalendars.first().getValue(SpecialDateType.Anniversary)
settings.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false) settings.setSpecialDateTypeEnabled(SpecialDateType.Anniversary, false)
engine.sync(today) engine.sync()
assertThat(calendars.deletedCalendarIds).contains(anniversaryId) assertThat(calendars.deletedCalendarIds).contains(anniversaryId)
assertThat(settings.specialDatesCalendars.first()).doesNotContainKey(SpecialDateType.Anniversary) assertThat(settings.specialDatesCalendars.first()).doesNotContainKey(SpecialDateType.Anniversary)
@@ -169,7 +166,7 @@ class SpecialDatesSyncEngineTest {
val calendars = FakeCalendarDataSource() val calendars = FakeCalendarDataSource()
val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990))) val contacts = FakeContacts(dates = listOf(birthday("a", "Jane", 5, 14, 1990)))
val (engine, settings) = engineWith(tempDir, contacts, calendars) val (engine, settings) = engineWith(tempDir, contacts, calendars)
engine.sync(today) engine.sync()
val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday) val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday)
engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.Minutes(listOf(0, 1440))) 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 { fun `applyReminders with None clears the reminders`(@TempDir tempDir: Path) = runTest {
val calendars = FakeCalendarDataSource() val calendars = FakeCalendarDataSource()
val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars) val (engine, settings) = engineWith(tempDir, FakeContacts(), calendars)
engine.sync(today) engine.sync()
val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday) val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday)
engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.None) engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.None)

View File

@@ -349,7 +349,7 @@ class SettingsPrefsTest {
assertThat(prefs.specialDatesTypes.first()).isEqualTo(SpecialDateType.entries.toSet()) assertThat(prefs.specialDatesTypes.first()).isEqualTo(SpecialDateType.entries.toSet())
assertThat(prefs.specialDatesCalendars.first()).isEmpty() assertThat(prefs.specialDatesCalendars.first()).isEmpty()
assertThat(prefs.managedCalendarIds.first()).isEmpty() assertThat(prefs.managedCalendarIds.first()).isEmpty()
assertThat(prefs.specialDatesShowAge.first()).isTrue() assertThat(prefs.specialDatesShowYear.first()).isTrue()
} }
@Test @Test

View File

@@ -34,36 +34,14 @@ class SpecialDateFormattingTest {
} }
@Test @Test
fun `age is null when the year is unknown`() { fun `title substitutes name and year`() {
assertThat(birthday(5, 14, null).ageAtNextOccurrence(LocalDate(2026, 1, 1))).isNull() assertThat(renderSpecialDateTitle("{name}'s birthday ({year})", "Jane", 1990))
.isEqualTo("Jane's birthday (1990)")
} }
@Test @Test
fun `age counts the upcoming birthday when it is still ahead this year`() { fun `title drops an empty year parenthesis and tidies spacing`() {
// Born 1990-05-14; today 2026-03-01 → next birthday 2026 → turns 36. assertThat(renderSpecialDateTitle("{name}'s birthday ({year})", "Jane", null))
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") .isEqualTo("Jane's birthday")
} }
} }

View File

@@ -13,14 +13,14 @@ Implementation notes / deviations from this design:
(and doubles as a stable iCal UID). The managed **calendar** still carries a (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 `CAL_SYNC2` marker for re-adoption (calendar-row writes already use the
sync-adapter URI). sync-adapter URI).
- **Age is a sync-time snapshot.** A `FREQ=YEARLY` row has one static title, so - **Year, not age.** A `FREQ=YEARLY` row has one static title, so a per-occurrence
`{age}` reflects the upcoming birthday at the last sync (refreshed daily / on age is impossible without per-year exception rows (rejected as too heavy). The
foreground), not a per-occurrence value. Because of this, `{age}` is **kept out template instead offers `{year}` — the birth year (or an anniversary's start
of the default templates**; users can add it, and the title-format editor shows year) — which is static and correct on every occurrence. A "Show year" toggle
a disclaimer once they do. gates it; it's in the default templates ("{name}'s birthday ({year})").
- New managed calendars seed a per-calendar all-day reminder default of - 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. 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 — This document captures the full design for surfacing contact birthdays —
and, by extension, anniversaries and custom dates — as auto-updating local 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` - [x] Sync engine: idempotent diff keyed on the deterministic `UID_2445`
(see notes; not `SYNC_DATA1`); targeted managed-column updates; seed (see notes; not `SYNC_DATA1`); targeted managed-column updates; seed
user-owned fields on insert only. 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] Auto-update: on enable, on foreground, daily WorkManager.
- [x] Editor: disable managed fields for managed-calendar events. - [x] Editor: disable managed fields for managed-calendar events.
- [x] Revoked-permission detection + stalled-state surface. - [x] Revoked-permission detection + stalled-state surface.