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