Birthdays: single calendar model + sync on server-sync (iOS)

- one birthday calendar per user; BirthdaysImporter auto-creates/targets it
  (no picker) and reconciles per-device (external_uid contact:<deviceId>:<id>)
  so multiple devices don't clash
- Contacts birthday sync now runs on every "Sync with server" (syncFromServer),
  not just app launch and the manual button
- report the device after each sync (POST /api/birthdays/sync-report) for the
  web device list
- Accounts: Birthdays-from-Contacts section drops the target picker, keeps the
  reminder picker; removed the birthday toggle from the generic new-calendar sheet
- New-birthday sheet targets the single calendar, offers activation if none
- exclude the birthday calendar from the group-visible ("shared calendar") picker

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-13 19:07:46 +02:00
parent 871fb833ba
commit 69ed8bf67d
7 changed files with 144 additions and 104 deletions

View File

@@ -347,6 +347,9 @@ private let strings: [String: [String: String]] = [
// Birthdays // Birthdays
"birthday.new": "Neuer Geburtstag", "birthday.new": "Neuer Geburtstag",
"birthday.calendar_name": "Geburtstage",
"birthday.activate": "Geburtstagskalender aktivieren",
"birthday.activate_hint": "Aktiviere den Geburtstagskalender, um Geburtstage anzulegen. Er erscheint als eigener Kalender in der Seitenleiste.",
"birthday.person": "Name", "birthday.person": "Name",
"birthday.person_placeholder": "Name der Person", "birthday.person_placeholder": "Name der Person",
"birthday.date": "Geburtstag", "birthday.date": "Geburtstag",
@@ -702,6 +705,9 @@ private let strings: [String: [String: String]] = [
// Birthdays // Birthdays
"birthday.new": "New birthday", "birthday.new": "New birthday",
"birthday.calendar_name": "Birthdays",
"birthday.activate": "Enable birthday calendar",
"birthday.activate_hint": "Enable the birthday calendar to add birthdays. It appears as its own calendar in the sidebar.",
"birthday.person": "Name", "birthday.person": "Name",
"birthday.person_placeholder": "Person's name", "birthday.person_placeholder": "Person's name",
"birthday.date": "Birthday", "birthday.date": "Birthday",

View File

@@ -1,5 +1,6 @@
import Foundation import Foundation
import Contacts import Contacts
import UIKit
/// One stored birthday row as returned by GET /api/local/calendars/{id}/birthdays. /// One stored birthday row as returned by GET /api/local/calendars/{id}/birthdays.
/// Contact-sourced rows carry an `externalUid`; manually added ones have `nil`. /// Contact-sourced rows carry an `externalUid`; manually added ones have `nil`.
@@ -19,31 +20,41 @@ struct BirthdayEntry: Codable, Identifiable {
} }
} }
/// Reads birthdays from the system Contacts and mirrors them into a chosen /// Reads birthdays from the system Contacts and mirrors them into the user's
/// birthday `LocalCalendar` on the backend, so they show on every client. /// single birthday calendar on the backend, so they show on every client.
/// ///
/// The sync is a **mirror**: it reconciles contact-sourced rows by /// The sync is a **mirror**, scoped to THIS device: it reconciles rows whose
/// `external_uid` (adds new, updates changed, deletes removed) and never touches /// `external_uid` starts with `contact:<deviceId>:` (adds new, updates changed,
/// manually added birthdays (which have no `external_uid`). The heavy lifting /// deletes removed) and never touches other devices' rows or manually added
/// age suffix, cake icon, "notify N days before" reminder is done server-side; /// birthdays. Age suffix, cake icon and "notify N days before" are done
/// this only uploads name + date + birth year. /// server-side; this only uploads name + date + birth year, and reports the
/// device so the web can list "birthdays come from these devices".
enum BirthdaysImporter { enum BirthdaysImporter {
// MARK: Persisted binding (which calendar receives Contacts birthdays) // MARK: Persisted state
enum Key { enum Key {
static let enabled = "birthdaysSyncEnabled" // Bool static let enabled = "birthdaysSyncEnabled" // Bool
static let calendarId = "birthdaysSyncCalendarId" // Int (0 = none) static let deviceId = "birthdaysDeviceId" // stable per-install UUID
} }
static var isEnabled: Bool { UserDefaults.standard.bool(forKey: Key.enabled) } static var isEnabled: Bool { UserDefaults.standard.bool(forKey: Key.enabled) }
static var targetCalendarId: Int? {
let v = UserDefaults.standard.object(forKey: Key.calendarId) as? Int ?? 0
return v > 0 ? v : nil
}
static func setEnabled(_ on: Bool) { UserDefaults.standard.set(on, forKey: Key.enabled) } static func setEnabled(_ on: Bool) { UserDefaults.standard.set(on, forKey: Key.enabled) }
static func setTargetCalendarId(_ id: Int?) {
UserDefaults.standard.set(id ?? 0, forKey: Key.calendarId) static var deviceId: String {
if let id = UserDefaults.standard.string(forKey: Key.deviceId) { return id }
let id = UUID().uuidString
UserDefaults.standard.set(id, forKey: Key.deviceId)
return id
}
@MainActor static var deviceName: String {
let n = UIDevice.current.name
return n.isEmpty ? "iPhone" : n
}
private static var appLang: String {
UserDefaults.standard.string(forKey: "appLanguage") ?? "system"
} }
// MARK: Contacts access // MARK: Contacts access
@@ -55,7 +66,6 @@ enum BirthdaysImporter {
return false return false
} }
/// Request Contacts access once. Returns true if usable for enumeration.
@discardableResult @discardableResult
static func requestAccess() async -> Bool { static func requestAccess() async -> Bool {
let status = CNContactStore.authorizationStatus(for: .contacts) let status = CNContactStore.authorizationStatus(for: .contacts)
@@ -72,11 +82,11 @@ enum BirthdaysImporter {
// MARK: Reading contact birthdays // MARK: Reading contact birthdays
struct ContactBirthday { struct ContactBirthday {
let externalUid: String // "contact:<identifier>" let contactId: String
let name: String let name: String
let month: Int let month: Int
let day: Int let day: Int
let year: Int? // nil = year unknown let year: Int?
} }
static func readContactBirthdays() throws -> [ContactBirthday] { static func readContactBirthdays() throws -> [ContactBirthday] {
@@ -101,54 +111,77 @@ enum BirthdaysImporter {
guard !name.isEmpty else { return } guard !name.isEmpty else { return }
let year = bday.year.flatMap { $0 > 0 ? $0 : nil } let year = bday.year.flatMap { $0 > 0 ? $0 : nil }
out.append(ContactBirthday( out.append(ContactBirthday(
externalUid: "contact:\(contact.identifier)", contactId: contact.identifier, name: name, month: m, day: d, year: year
name: name, month: m, day: d, year: year
)) ))
} }
return out return out
} }
// MARK: The single birthday calendar
/// The user's birthday calendar, creating the single "Geburtstage" calendar
/// if none exists yet.
static func ensureBirthdayCalendar(api: CalendarrAPI) async -> LocalCalendar? {
let cals = (try? await api.getLocalCalendars()) ?? []
if let existing = cals.first(where: { $0.isBirthday && $0.owned }) { return existing }
let name = L10n.t("birthday.calendar_name", appLang)
return try? await api.addLocalCalendar(name: name, color: "#E0407F", isBirthday: true)
}
static func birthdayCalendar(api: CalendarrAPI) async -> LocalCalendar? {
let cals = (try? await api.getLocalCalendars()) ?? []
return cals.first(where: { $0.isBirthday && $0.owned })
}
// MARK: Sync // MARK: Sync
/// Mirror the address book into the bound birthday calendar. No-op unless the /// Mirror the address book into the birthday calendar (creating it if
/// sync is enabled, a target calendar is set, and access is granted. /// needed). No-op unless enabled and Contacts access is granted.
static func sync(api: CalendarrAPI) async { static func sync(api: CalendarrAPI) async {
guard isEnabled, let calId = targetCalendarId else { return } guard isEnabled else { return }
guard await requestAccess() else { return } guard await requestAccess() else { return }
guard let contacts = try? readContactBirthdays() else { return } guard let contacts = try? readContactBirthdays() else { return }
guard let existing = try? await api.getBirthdayEntries(calendarId: calId) else { return } guard let cal = await ensureBirthdayCalendar(api: api) else { return }
guard let existing = try? await api.getBirthdayEntries(calendarId: cal.id) else { return }
// Only reconcile contact-sourced rows; leave manual entries untouched. // Only reconcile THIS device's contact rows; leave other devices'
// rows and manual entries untouched.
let prefix = "contact:\(deviceId):"
var byExt: [String: BirthdayEntry] = [:] var byExt: [String: BirthdayEntry] = [:]
for e in existing { if let ext = e.externalUid { byExt[ext] = e } } for e in existing {
if let ext = e.externalUid, ext.hasPrefix(prefix) { byExt[ext] = e }
}
var seen = Set<String>() var seen = Set<String>()
for c in contacts { for c in contacts {
seen.insert(c.externalUid) let ext = prefix + c.contactId
seen.insert(ext)
let (start, end) = allDayRange(month: c.month, day: c.day, year: c.year) let (start, end) = allDayRange(month: c.month, day: c.day, year: c.year)
if let match = byExt[c.externalUid] { if let match = byExt[ext] {
let changed = match.title != c.name || match.month != c.month let changed = match.title != c.name || match.month != c.month
|| match.day != c.day || match.birthYear != c.year || match.day != c.day || match.birthYear != c.year
if changed { if changed {
try? await api.updateLocalEvent( try? await api.updateLocalEvent(
uid: match.uid, title: c.name, start: start, end: end, uid: match.uid, title: c.name, start: start, end: end,
isAllDay: true, location: "", description: "", color: nil, isAllDay: true, location: "", description: "", color: nil,
rrule: "FREQ=YEARLY", externalUid: c.externalUid, rrule: "FREQ=YEARLY", externalUid: ext, birthYear: c.year ?? -1
birthYear: c.year ?? -1 // -1 clears birth_year server-side
) )
} }
} else { } else {
_ = try? await api.createLocalEvent( _ = try? await api.createLocalEvent(
calendarId: calId, title: c.name, start: start, end: end, calendarId: cal.id, title: c.name, start: start, end: end,
isAllDay: true, location: "", description: "", color: nil, isAllDay: true, location: "", description: "", color: nil,
rrule: "FREQ=YEARLY", externalUid: c.externalUid, birthYear: c.year rrule: "FREQ=YEARLY", externalUid: ext, birthYear: c.year
) )
} }
} }
// Remove contact-sourced rows whose contact no longer has a birthday.
for (ext, entry) in byExt where !seen.contains(ext) { for (ext, entry) in byExt where !seen.contains(ext) {
try? await api.deleteLocalEvent(uid: entry.uid) try? await api.deleteLocalEvent(uid: entry.uid)
} }
// Report this device so the web can list where birthdays come from.
let name = await deviceName
try? await api.reportBirthdaySync(deviceId: deviceId, deviceName: name, count: contacts.count)
} }
/// All-day [start, end) for a birthday. Anchor year = birth year when known, /// All-day [start, end) for a birthday. Anchor year = birth year when known,

View File

@@ -153,10 +153,10 @@ class CalendarrAPI {
/// Update a local calendar's birthday settings. `is_birthday` marks it as a /// Update a local calendar's birthday settings. `is_birthday` marks it as a
/// birthday calendar; `notifyDaysBefore` sets the reminder (nil sends the -1 /// birthday calendar; `notifyDaysBefore` sets the reminder (nil sends the -1
/// sentinel to clear it server-side). /// sentinel to clear it server-side).
func updateLocalCalendarBirthday(id: Int, isBirthday: Bool?, notifyDaysBefore: Int??) async throws { func updateLocalCalendarBirthday(id: Int, isBirthday: Bool? = nil, notifyDaysBefore: Int? = nil) async throws {
var body: [String: Any] = [:] var body: [String: Any] = [:]
if let b = isBirthday { body["is_birthday"] = b } if let b = isBirthday { body["is_birthday"] = b }
if let n = notifyDaysBefore { body["birthday_notify_days_before"] = n ?? -1 } if let n = notifyDaysBefore { body["birthday_notify_days_before"] = n } // -1 clears server-side
guard !body.isEmpty else { return } guard !body.isEmpty else { return }
_ = try await request("/api/local/calendars/\(id)", method: "PUT", body: body) _ = try await request("/api/local/calendars/\(id)", method: "PUT", body: body)
} }
@@ -168,6 +168,14 @@ class CalendarrAPI {
return (try? JSONDecoder().decode([BirthdayEntry].self, from: data)) ?? [] return (try? JSONDecoder().decode([BirthdayEntry].self, from: data)) ?? []
} }
/// Report that this device just synced its Contacts birthdays, so the web
/// can show "birthdays come from these devices".
func reportBirthdaySync(deviceId: String, deviceName: String, count: Int) async throws {
_ = try await request("/api/birthdays/sync-report", method: "POST", body: [
"device_id": deviceId, "device_name": deviceName, "count": count,
])
}
func getICalSubscriptions() async throws -> [ICalSubscription] { func getICalSubscriptions() async throws -> [ICalSubscription] {
let data = try await request("/api/ical/subscriptions") let data = try await request("/api/ical/subscriptions")
return (try? JSONDecoder().decode([ICalSubscription].self, from: data)) ?? [] return (try? JSONDecoder().decode([ICalSubscription].self, from: data)) ?? []

View File

@@ -26,8 +26,8 @@ struct AccountsView: View {
// Contacts birthday-calendar sync (opt-in, bound to one birthday calendar). // Contacts birthday-calendar sync (opt-in, bound to one birthday calendar).
@AppStorage("birthdaysSyncEnabled") private var birthdaysSyncEnabled = false @AppStorage("birthdaysSyncEnabled") private var birthdaysSyncEnabled = false
@AppStorage("birthdaysSyncCalendarId") private var birthdaysSyncCalendarId = 0
@State private var isSyncingBirthdays = false @State private var isSyncingBirthdays = false
@State private var birthdayNotify = -1
@AppStorage("appLanguage") private var appLang = "system" @AppStorage("appLanguage") private var appLang = "system"
@@ -47,7 +47,13 @@ struct AccountsView: View {
haSection haSection
} }
.onChange(of: birthdaysSyncEnabled) { _, on in .onChange(of: birthdaysSyncEnabled) { _, on in
if on { Task { _ = await BirthdaysImporter.requestAccess() } } if on {
Task {
_ = await BirthdaysImporter.requestAccess()
_ = await BirthdaysImporter.ensureBirthdayCalendar(api: api)
await load()
}
}
} }
} }
} }
@@ -222,20 +228,20 @@ struct AccountsView: View {
} }
} }
private var birthdayCalendars: [LocalCalendar] { /// The user's single birthday calendar (created on first sync/activation).
localCalendars.filter { $0.isBirthday && ($0.owned || $0.permission == "read_write") } private var birthdayCalendar: LocalCalendar? {
localCalendars.first { $0.isBirthday && $0.owned }
} }
@ViewBuilder var birthdayContactsSection: some View { @ViewBuilder var birthdayContactsSection: some View {
Section { Section {
Toggle(L10n.t("birthday.contacts.sync", appLang), isOn: $birthdaysSyncEnabled) Toggle(L10n.t("birthday.contacts.sync", appLang), isOn: $birthdaysSyncEnabled)
if birthdaysSyncEnabled { if birthdaysSyncEnabled {
if birthdayCalendars.isEmpty { if let cal = birthdayCalendar {
Text(L10n.t("birthday.contacts.need_calendar", appLang)) BirthdayNotifyPicker(days: $birthdayNotify, appLang: appLang)
.font(.caption).foregroundStyle(.secondary) .onChange(of: birthdayNotify) { _, v in
} else { Task { try? await api.updateLocalCalendarBirthday(id: cal.id, notifyDaysBefore: v) }
Picker(L10n.t("birthday.contacts.target", appLang), selection: $birthdaysSyncCalendarId) { }
ForEach(birthdayCalendars) { c in Text(c.name).tag(c.id) }
} }
Button { Button {
Task { await syncBirthdays() } Task { await syncBirthdays() }
@@ -245,8 +251,7 @@ struct AccountsView: View {
if isSyncingBirthdays { Spacer(); ProgressView() } if isSyncingBirthdays { Spacer(); ProgressView() }
} }
} }
.disabled(isSyncingBirthdays || birthdaysSyncCalendarId == 0) .disabled(isSyncingBirthdays)
}
} }
} header: { } header: {
Text(L10n.t("birthday.contacts.header", appLang)) Text(L10n.t("birthday.contacts.header", appLang))
@@ -263,6 +268,7 @@ struct AccountsView: View {
return return
} }
await BirthdaysImporter.sync(api: api) await BirthdaysImporter.sync(api: api)
await load() // pick up the (possibly newly created) birthday calendar
infoMessage = L10n.t("birthday.contacts.synced", appLang) infoMessage = L10n.t("birthday.contacts.synced", appLang)
// Let the calendar refresh so imported birthdays show up right away. // Let the calendar refresh so imported birthdays show up right away.
NotificationCenter.default.post(name: .manualSyncRequested, object: nil) NotificationCenter.default.post(name: .manualSyncRequested, object: nil)
@@ -474,10 +480,8 @@ struct AccountsView: View {
CalendarStore.saveBanishedKeys(b) CalendarStore.saveBanishedKeys(b)
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil) NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
} }
// Default the Contacts-sync target to the first birthday calendar. // Reflect the birthday calendar's current reminder setting in the picker.
if birthdaysSyncCalendarId == 0, let first = birthdayCalendars.first?.id { birthdayNotify = birthdayCalendar?.birthdayNotifyDaysBefore ?? -1
birthdaysSyncCalendarId = first
}
isLoading = false isLoading = false
} }
@@ -595,8 +599,6 @@ struct AddLocalCalSheet: View {
@State private var name = "" @State private var name = ""
@State private var color = Color(hex: "#34a853") @State private var color = Color(hex: "#34a853")
@State private var isBirthday = false
@State private var notifyDays = -1 // -1 = off, 0 = on the day, N = days before
@State private var isLoading = false @State private var isLoading = false
@State private var error = "" @State private var error = ""
@@ -607,14 +609,6 @@ struct AddLocalCalSheet: View {
TextField(L10n.t("local.name", appLang), text: $name) TextField(L10n.t("local.name", appLang), text: $name)
ColorPicker(L10n.t("local.color", appLang), selection: $color, supportsOpacity: false) ColorPicker(L10n.t("local.color", appLang), selection: $color, supportsOpacity: false)
} }
Section {
Toggle(L10n.t("birthday.is_calendar", appLang), isOn: $isBirthday)
if isBirthday {
BirthdayNotifyPicker(days: $notifyDays, appLang: appLang)
}
} footer: {
if isBirthday { Text(L10n.t("birthday.is_calendar.desc", appLang)) }
}
if !error.isEmpty { if !error.isEmpty {
Section { Text(error).foregroundStyle(.red) } Section { Text(error).foregroundStyle(.red) }
} }
@@ -637,10 +631,7 @@ struct AddLocalCalSheet: View {
private func save() async { private func save() async {
isLoading = true isLoading = true
do { do {
_ = try await api.addLocalCalendar( _ = try await api.addLocalCalendar(name: name, color: color.toHex())
name: name, color: color.toHex(),
isBirthday: isBirthday,
birthdayNotifyDaysBefore: (isBirthday && notifyDays >= 0) ? notifyDays : nil)
await onDone() await onDone()
dismiss() dismiss()
} catch { self.error = error.localizedDescription } } catch { self.error = error.localizedDescription }

View File

@@ -1,8 +1,9 @@
import SwiftUI import SwiftUI
/// Minimal "new birthday" mask: pick a birthday calendar, enter a name and a /// Minimal "new birthday" mask for the single birthday calendar: enter a name
/// date (day + month, optionally a year). Saves an all-day, yearly-recurring /// and a date (optionally "year unknown"). Saves an all-day, yearly-recurring
/// local event; the server adds the age suffix and cake icon on read. /// local event; the server adds the age suffix and cake icon on read. If no
/// birthday calendar exists yet, offers to activate one.
struct BirthdayEditorSheet: View { struct BirthdayEditorSheet: View {
let api: CalendarrAPI let api: CalendarrAPI
var onDone: () async -> Void var onDone: () async -> Void
@@ -10,20 +11,16 @@ struct BirthdayEditorSheet: View {
@AppStorage("appLanguage") private var appLang = "system" @AppStorage("appLanguage") private var appLang = "system"
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var calendars: [LocalCalendar] = [] @State private var calendar: LocalCalendar? = nil
@State private var selectedCalId: Int? = nil
@State private var name = "" @State private var name = ""
@State private var date = Date() @State private var date = Date()
@State private var yearUnknown = false @State private var yearUnknown = false
@State private var loading = true @State private var loading = true
@State private var saving = false @State private var saving = false
@State private var activating = false
private var birthdayCalendars: [LocalCalendar] {
calendars.filter { $0.isBirthday && ($0.owned || $0.permission == "read_write") }
}
private var canSave: Bool { private var canSave: Bool {
!saving && selectedCalId != nil !saving && calendar != nil
&& !name.trimmingCharacters(in: .whitespaces).isEmpty && !name.trimmingCharacters(in: .whitespaces).isEmpty
} }
@@ -32,9 +29,13 @@ struct BirthdayEditorSheet: View {
Form { Form {
if loading { if loading {
HStack { Spacer(); ProgressView(); Spacer() } HStack { Spacer(); ProgressView(); Spacer() }
} else if birthdayCalendars.isEmpty { } else if calendar == nil {
Text(L10n.t("birthday.no_calendars", appLang)) Section {
Text(L10n.t("birthday.activate_hint", appLang))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Button(L10n.t("birthday.activate", appLang)) { Task { await activate() } }
.disabled(activating)
}
} else { } else {
Section(L10n.t("birthday.person", appLang)) { Section(L10n.t("birthday.person", appLang)) {
TextField(L10n.t("birthday.person_placeholder", appLang), text: $name) TextField(L10n.t("birthday.person_placeholder", appLang), text: $name)
@@ -44,15 +45,6 @@ struct BirthdayEditorSheet: View {
displayedComponents: [.date]) displayedComponents: [.date])
Toggle(L10n.t("birthday.year_unknown", appLang), isOn: $yearUnknown) Toggle(L10n.t("birthday.year_unknown", appLang), isOn: $yearUnknown)
} }
if birthdayCalendars.count > 1 {
Section(L10n.t("birthday.contacts.target", appLang)) {
Picker(L10n.t("birthday.contacts.target", appLang), selection: $selectedCalId) {
ForEach(birthdayCalendars) { c in
Text(c.name).tag(Optional(c.id))
}
}
}
}
} }
} }
.navigationTitle(L10n.t("birthday.new", appLang)) .navigationTitle(L10n.t("birthday.new", appLang))
@@ -71,16 +63,21 @@ struct BirthdayEditorSheet: View {
} }
private func load() async { private func load() async {
calendars = (try? await api.getLocalCalendars()) ?? [] calendar = await BirthdaysImporter.birthdayCalendar(api: api)
if selectedCalId == nil { selectedCalId = birthdayCalendars.first?.id }
loading = false loading = false
} }
private func activate() async {
activating = true
calendar = await BirthdaysImporter.ensureBirthdayCalendar(api: api)
activating = false
}
private func save() async { private func save() async {
guard let calId = selectedCalId else { return } guard let cal = calendar else { return }
saving = true saving = true
let cal = Calendar.current let calc = Calendar.current
let comps = cal.dateComponents([.year, .month, .day], from: date) let comps = calc.dateComponents([.year, .month, .day], from: date)
let month = comps.month ?? 1 let month = comps.month ?? 1
let day = comps.day ?? 1 let day = comps.day ?? 1
let year = yearUnknown ? nil : comps.year let year = yearUnknown ? nil : comps.year
@@ -91,10 +88,10 @@ struct BirthdayEditorSheet: View {
anchor.month = month anchor.month = month
anchor.day = day anchor.day = day
anchor.hour = 12 anchor.hour = 12
let start = cal.date(from: anchor) ?? date let start = calc.date(from: anchor) ?? date
let end = cal.date(byAdding: .day, value: 1, to: start) ?? start let end = calc.date(byAdding: .day, value: 1, to: start) ?? start
_ = try? await api.createLocalEvent( _ = try? await api.createLocalEvent(
calendarId: calId, title: name.trimmingCharacters(in: .whitespaces), calendarId: cal.id, title: name.trimmingCharacters(in: .whitespaces),
start: start, end: end, isAllDay: true, location: "", description: "", start: start, end: end, isAllDay: true, location: "", description: "",
color: nil, rrule: "FREQ=YEARLY", birthYear: year color: nil, rrule: "FREQ=YEARLY", birthYear: year
) )

View File

@@ -535,6 +535,9 @@ struct CalendarHostView: View {
/// shows up without the user opening the filter sheet. /// shows up without the user opening the filter sheet.
private func syncFromServer(force: Bool = false) async { private func syncFromServer(force: Bool = false) async {
await SettingsSync.pull(api: api) await SettingsSync.pull(api: api)
// Mirror Contacts birthdays on every server sync (manual, resume,
// periodic) the user expects "sync with server" to include birthdays.
if BirthdaysImporter.isEnabled { await BirthdaysImporter.sync(api: api) }
let changed = await store.reconcileCalendarVisibility(api: api) let changed = await store.reconcileCalendarVisibility(api: api)
if changed || force { await forceReload() } if changed || force { await forceReload() }
} }

View File

@@ -205,7 +205,9 @@ struct SettingsView: View {
groupVisibleId = s.groupVisibleCalendarId ?? 0 groupVisibleId = s.groupVisibleCalendarId ?? 0
} }
if let cals = try? await api.getLocalCalendars() { if let cals = try? await api.getLocalCalendars() {
ownLocalCals = cals.filter { $0.owned && !$0.group } // A birthday calendar may be shared directly, but never stand in as
// the group-visible personal calendar.
ownLocalCals = cals.filter { $0.owned && !$0.group && !$0.isBirthday }
} }
} }