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