feat: event reminders + default reminder setting + local notifications (iOS)

Per-event reminders (multiple, local calendars only) in the editor, prefilled
from a new "default reminder" setting that applies to all events otherwise.
CalEvent gains `reminders`; AppSettings/SettingsSync sync default_reminder_minutes
(always group). New NotificationScheduler requests permission and schedules the
soonest ≤60 upcoming reminders via UNUserNotificationCenter, rescheduling on
load/sync/edit and when the default changes (skipped in group overlay).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-06-06 16:21:08 +02:00
parent e7d8effb47
commit 587a0e65fa
10 changed files with 187 additions and 5 deletions

View File

@@ -229,7 +229,7 @@ class CalendarrAPI {
func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date,
isAllDay: Bool, location: String, description: String, color: String?,
isPrivate: Bool = false) async throws -> CalEvent {
isPrivate: Bool = false, reminders: [Int]? = nil) async throws -> CalEvent {
var body: [String: Any] = [
"calendar_id": calendarId,
"title": title,
@@ -241,6 +241,7 @@ class CalendarrAPI {
"private": isPrivate
]
if let c = color, !c.isEmpty { body["color"] = c }
if let reminders { body["reminders"] = reminders }
let data = try await request("/api/local/events", method: "POST", body: body)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let ev = CalEvent.from(json: json) else { throw APIError.decodingError }
@@ -249,7 +250,7 @@ class CalendarrAPI {
func updateLocalEvent(uid: String, title: String, start: Date, end: Date,
isAllDay: Bool, location: String, description: String, color: String?,
isPrivate: Bool = false) async throws {
isPrivate: Bool = false, reminders: [Int]? = nil) async throws {
var body: [String: Any] = [
"title": title,
"start": formatISO(start, allDay: isAllDay),
@@ -260,6 +261,7 @@ class CalendarrAPI {
"private": isPrivate
]
if let c = color { body["color"] = c }
if let reminders { body["reminders"] = reminders }
_ = try await request("/api/local/events/\(uid)", method: "PUT", body: body)
}

View File

@@ -0,0 +1,65 @@
import Foundation
import UserNotifications
extension Notification.Name {
/// Posted when the default-reminder setting changes, so the calendar host
/// can recompute the scheduled local notifications from its cached events.
static let rescheduleReminders = Notification.Name("rescheduleReminders")
}
/// Schedules local OS notifications for upcoming events. Per-event reminders
/// (local events) take precedence; otherwise the user's default reminder applies
/// to every event (incl. external). Re-run whenever events or the default change.
enum NotificationScheduler {
static func requestAuthorizationIfNeeded() {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in }
}
/// Recompute and (re)schedule notifications from the given events. The iOS
/// pending-notification cap is 64, so only the soonest are scheduled.
static func reschedule(events: [CalEvent]) {
let center = UNUserNotificationCenter.current()
center.getNotificationSettings { settings in
guard settings.authorizationStatus == .authorized
|| settings.authorizationStatus == .provisional else { return }
let defaultMin = (UserDefaults.standard.object(forKey: "defaultReminderMinutes") as? Int) ?? -1
let now = Date()
var pending: [(fire: Date, event: CalEvent)] = []
for ev in events {
let offsets = ev.reminders.isEmpty
? (defaultMin >= 0 ? [defaultMin] : [])
: ev.reminders
for m in offsets {
let fire = ev.startDate.addingTimeInterval(-Double(m) * 60)
if fire > now { pending.append((fire, ev)) }
}
}
pending.sort { $0.fire < $1.fire }
let limited = pending.prefix(60) // stay safely under the 64 system cap
center.removeAllPendingNotificationRequests()
for item in limited {
let content = UNMutableNotificationContent()
content.title = item.event.title
content.body = bodyText(item.event)
content.sound = .default
let comps = Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute, .second], from: item.fire)
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: false)
center.add(UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger))
}
}
}
private static func bodyText(_ ev: CalEvent) -> String {
var parts: [String] = []
if !ev.isAllDay {
let f = DateFormatter(); f.timeStyle = .short; f.dateStyle = .none
parts.append(f.string(from: ev.startDate))
}
if !ev.location.isEmpty { parts.append(ev.location) }
return parts.joined(separator: " · ")
}
}

View File

@@ -37,6 +37,7 @@ enum SettingsSync {
static let defaultView = "defaultView"
static let weekStartDay = "weekStartDay"
static let dimPastEvents = "dimPastEvents"
static let defaultReminder = "defaultReminderMinutes" // Int, -1 = off
// master switch
static let enabled = "settingsSync"
}
@@ -71,6 +72,8 @@ enum SettingsSync {
s.defaultView = str(Key.defaultView, "month")
s.weekStartDay = str(Key.weekStartDay, "monday")
s.dimPastEvents = UserDefaults.standard.bool(forKey: Key.dimPastEvents)
let rem = int(Key.defaultReminder, -1)
s.defaultReminderMinutes = rem < 0 ? nil : rem
return s
}
@@ -84,6 +87,7 @@ enum SettingsSync {
d.set(s.defaultView, forKey: Key.defaultView)
d.set(s.weekStartDay, forKey: Key.weekStartDay)
d.set(s.dimPastEvents, forKey: Key.dimPastEvents)
d.set(s.defaultReminderMinutes ?? -1, forKey: Key.defaultReminder)
guard includeOptional else { return }
// NOTE: textColor / backgroundColor / lineColor are intentionally NOT
// synced the server has no columns for them (iOS-only). Writing the
@@ -134,6 +138,7 @@ enum SettingsSync {
merged.defaultView = local.defaultView
merged.weekStartDay = local.weekStartDay
merged.dimPastEvents = local.dimPastEvents
merged.defaultReminderMinutes = local.defaultReminderMinutes
if isEnabled {
merged.primaryColor = local.primaryColor
merged.accentColor = local.accentColor