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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user