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

@@ -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 {

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.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<ContactSpecialDate>,
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,

View File

@@ -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<Boolean> = 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<Boolean> = 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 =

View File

@@ -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()

View File

@@ -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 = {

View File

@@ -91,7 +91,8 @@ data class SpecialDatesUiState(
val titleTemplates: Map<SpecialDateType, String> = emptyMap(),
/** The reminder choice per type's managed calendar (applied to all its events). */
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. */
val stalledPermission: Boolean = false,
/** 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.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)
}
}

View File

@@ -415,8 +415,8 @@
<string name="special_dates_calendar_birthday">Geburtstage</string>
<string name="special_dates_calendar_anniversary">Jahrestage</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_anniversary">{name}s Jahrestag</string>
<string name="special_dates_default_title_birthday">{name}s Geburtstag ({year})</string>
<string name="special_dates_default_title_anniversary">{name}s Jahrestag ({year})</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="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_custom">Weitere Tage</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_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_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_reminders">Erinnerungen</string>
<string name="settings_special_dates_show_age">Alter 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">Jahr anzeigen</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_never_synced">Noch nicht synchronisiert</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_custom">Other dates</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_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_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_reminders">Reminders</string>
<string name="settings_special_dates_show_age">Show age</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">Show year</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_never_synced">Not synced yet</string>
<!-- %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_anniversary">Anniversaries</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_anniversary">{name}\'s anniversary</string>
<string name="special_dates_default_title_birthday">{name}\'s birthday ({year})</string>
<string name="special_dates_default_title_anniversary">{name}\'s anniversary ({year})</string>
<string name="special_dates_default_title_custom">{name}</string>
</resources>

View File

@@ -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<ContactSpecialDate> = 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)

View File

@@ -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

View File

@@ -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")
}
}