Calendar: shared store reliability + event notifications

KC-5: Consolidate calendar access into CalendarStore.shared so Today,
Calendar, Onboarding, and Settings share one permission state and event
cache. requestAccess() debounces and reads authoritative status; turning
on "Show Calendar Events" while unauthorized now prompts. New Settings →
Data → Calendar row shows Connected/Connect/Denied.

KC-6: Add calendar-event notifications. CalendarStore.upcomingEventNotifs
snapshots visible events (next 7 days); NotificationManager schedules at
start time plus each event alarm (all-day at 9am), capped at ~20 to stay
under iOS's 64-notification limit. Wired into reschedule().

Log KC-5 and KC-6 in ISSUES.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kutesir
2026-06-07 04:08:23 +03:00
parent 0a4545f042
commit 196dd7b78e
7 changed files with 283 additions and 5 deletions

View File

@@ -140,3 +140,78 @@ Files: `TaskContextMenu.swift`, `TaskActivityAttributes.swift`,
pass (orange accent + timer) and can be refined.
- Uses the iOS 16.1 `Activity.request(...contentState:...)` API (deprecation
warning on 16.2+, still functional).
---
## KC-5 — Calendar "Connect" toggle unreliable / inconsistent across screens
**Status:** In Progress (implemented & build-verified; full reliability win is device-only)
**Reported by:** User
**Area:** Calendar / Permissions
### Description
The iPhone Calendar "Connect" sometimes needed repeated taps, onboarding
sometimes didn't reflect a granted calendar, and there was no place to see/manage
connection status from Settings.
### Root cause
Three independent calendar-permission states that never synced: `TodayView`,
`CalendarView` each had their own `CalendarStore` + `EKEventStore`, and
`OnboardingView` had its own raw `EKEventStore`. Granting in one left the others
showing stale cached status until they happened to re-read it.
### Fix
- `CalendarStore.shared` singleton — one source of truth used by Today, Calendar,
Onboarding, and Settings. Connect once, every screen reflects it.
- `requestAccess()` now debounces in-flight requests and reads the **authoritative**
`EKEventStore.authorizationStatus` instead of trusting the callback bool;
already-decided states re-sync via `refreshStatus()`.
- Turning on "Show Calendar Events" while unauthorized now triggers the prompt.
- New **Settings → Data → Calendar** row showing ● Connected / Connect / Denied,
opening the connect sheet.
Files: `CalendarView.swift`, `TodayView.swift`, `OnboardingView.swift`,
`SettingsView.swift`.
### Verification
Builds cleanly. Cross-screen consistency + the Settings status row are testable in
the Simulator; the request-reliability fix is best confirmed on a device.
---
## KC-6 — No notifications for calendar events
**Status:** In Progress (implemented & build-verified; fires on a real device with calendar access)
**Reported by:** User
**Area:** Calendar / Notifications
### Description
Task reminders fired, but calendar events never produced a KisaniCal
notification — events with no iOS alert (e.g. subscribed F1/TV feeds) notified
the user of nothing.
### Root cause
`NotificationManager` only scheduled workout + per-task notifications. There was
no calendar-event path at all (`EKEvent`/`CalendarStore` were never involved).
### Fix
- `CalendarStore.upcomingEventNotifs(days:)` snapshots visible events for the next
7 days (respects enabled flag + hidden calendars) with start time + alarm offsets.
- `NotificationManager.scheduleCalendarEvents(...)` schedules a notification at the
event start ("on time") plus at each alarm the user set; all-day events fire at
9am morning-of. Capped at ~20 to stay under iOS's 64 pending-notification limit;
recurring instances kept unique via start-time in the id.
- Wired into `reschedule(...)` (runs on foreground / scene-active / task changes).
Files: `CalendarView.swift`, `NotificationManager.swift`.
### How to test (device, calendar access granted)
1. Create an event a few minutes out with **no alert** on a visible calendar.
2. Background the app (triggers a reschedule).
3. Notification fires at the event's start time.
### Known limitations / decisions
- "All visible events" was chosen, so personal events that already have an iOS
alert may double (system + KisaniCal). Feed events (F1/TV) are the main win.
- Tapping a calendar notification opens the Today view (no calendar deeplink).
- The 7-day window re-scans on each foreground; no background refresh beyond that.

View File

@@ -79,6 +79,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
let progress = workoutVM.progress
let recorded = workoutVM.isTodayRecorded
let tasks = taskVM.tasks
let calEvents = CalendarStore.shared.upcomingEventNotifs(days: 7)
center.getNotificationSettings { [weak self] settings in
guard let self else { return }
@@ -88,10 +89,67 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
self.scheduleWorkout(schedule: schedule, programs: programs, progress: progress, recordedToday: recorded)
self.scheduleWorkoutConfirm(schedule: schedule, programs: programs)
self.scheduleTasks(snapshot: tasks)
self.scheduleCalendarEvents(snapshot: calEvents)
self.updateBadge(snapshot: tasks)
}
}
// MARK: - Calendar event notifications
/// Notifies for visible calendar events: at start time ("on time") plus each
/// alarm the user set on the event. Capped to stay under iOS's 64-notification
/// limit. Recurring instances stay unique via their start time in the id.
private func scheduleCalendarEvents(snapshot: [CalEventNotif]) {
center.getPendingNotificationRequests { [weak self] pending in
guard let self else { return }
let old = pending.filter { $0.identifier.hasPrefix("kisani.calevent.") }.map { $0.identifier }
self.center.removePendingNotificationRequests(withIdentifiers: old)
let now = Date()
let cal = Calendar.current
var budget = 20 // leave headroom under the 64 pending-notification ceiling
for ev in snapshot where budget > 0 {
var fireTimes: [Date] = []
if ev.isAllDay {
var c = cal.dateComponents([.year, .month, .day], from: ev.start)
c.hour = 9; c.minute = 0
if let d = cal.date(from: c) { fireTimes.append(d) } // 9am morning-of
} else {
fireTimes.append(ev.start) // on time
for off in ev.alarmOffsets { fireTimes.append(ev.start.addingTimeInterval(off)) }
}
let times = Array(Set(fireTimes)).filter { $0 > now }.sorted()
for (i, t) in times.enumerated() where budget > 0 {
let content = UNMutableNotificationContent()
content.title = ev.title
content.body = self.calEventBody(fire: t, start: ev.start, isAllDay: ev.isAllDay)
content.sound = .default
content.userInfo = ["deeplink": "calendar"]
let comps = cal.dateComponents([.year, .month, .day, .hour, .minute], from: t)
let stamp = Int(ev.start.timeIntervalSince1970)
self.center.add(UNNotificationRequest(
identifier: "kisani.calevent.\(ev.id).\(stamp).\(i)",
content: content,
trigger: UNCalendarNotificationTrigger(dateMatching: comps, repeats: false)
))
budget -= 1
}
}
}
}
private func calEventBody(fire: Date, start: Date, isAllDay: Bool) -> String {
if isAllDay { return "Today" }
let delta = start.timeIntervalSince(fire)
if delta <= 60 { return "Starting now" }
let mins = Int((delta / 60).rounded())
if mins < 60 { return "Starts in \(mins) min" }
let hrs = mins / 60
return "Starts in \(hrs)h\(mins % 60 == 0 ? "" : " \(mins % 60)m")"
}
// MARK: - Badge
private func updateBadge(snapshot: [TaskItem]) {

View File

@@ -2,6 +2,15 @@ import SwiftUI
import EventKit
import EventKitUI
// Plain snapshot of a calendar event for scheduling notifications off the main actor.
struct CalEventNotif {
let id: String
let title: String
let start: Date
let isAllDay: Bool
let alarmOffsets: [TimeInterval] // seconds relative to start (negative = before)
}
// MARK: - Calendar Store
@MainActor
final class CalendarStore: ObservableObject {
@@ -133,6 +142,33 @@ final class CalendarStore: ObservableObject {
return authStatus == .authorized
}
/// Upcoming visible events over the next `days`, as plain snapshots for the
/// notification scheduler. Honors the calendar-enabled flag and hidden calendars.
func upcomingEventNotifs(days: Int) -> [CalEventNotif] {
guard isAuthorized, localCalendarsEnabled else { return [] }
let now = Date()
guard let end = Calendar.current.date(byAdding: .day, value: days, to: now) else { return [] }
let cals = ekStore.calendars(for: .event).filter { !hiddenCalendarIDs.contains($0.calendarIdentifier) }
guard !cals.isEmpty else { return [] }
let pred = ekStore.predicateForEvents(withStart: now, end: end, calendars: cals)
return ekStore.events(matching: pred)
.sorted { ($0.startDate ?? .distantFuture) < ($1.startDate ?? .distantFuture) }
.compactMap { ev in
guard let start = ev.startDate else { return nil }
let offsets: [TimeInterval] = (ev.alarms ?? []).map { alarm in
if let abs = alarm.absoluteDate { return abs.timeIntervalSince(start) }
return alarm.relativeOffset
}
return CalEventNotif(
id: ev.eventIdentifier ?? UUID().uuidString,
title: ev.title ?? "Event",
start: start,
isAllDay: ev.isAllDay,
alarmOffsets: offsets
)
}
}
/// Exposed so the edit sheet can hand it to EKEventEditViewController.
var eventStore: EKEventStore { ekStore }

View File

@@ -5,6 +5,7 @@ struct SettingsView: View {
@EnvironmentObject var workoutVM: WorkoutViewModel
@EnvironmentObject var taskVM: TaskViewModel
@ObservedObject private var auth = AuthManager.shared
@ObservedObject private var calStore = CalendarStore.shared
// Persisted settings
@AppStorage("appearanceMode") private var appearanceMode = 0
@@ -30,6 +31,7 @@ struct SettingsView: View {
@State private var showWidgets = false
@State private var showGeneral = false
@State private var showImport = false
@State private var showCalendar = false
@State private var showBackup = false
@State private var showWorkoutSettings = false
@State private var showRestTimer = false
@@ -107,6 +109,7 @@ struct SettingsView: View {
// DATA
SttSection(label: "Data") {
SttNavRow(icon: "calendar", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Calendar", value: calStore.isAuthorized ? "● Connected" : (calStore.isDenied ? "Denied" : "Connect"), valueColor: calStore.isAuthorized ? AppColors.green : nil) { showCalendar = true }
SttNavRow(icon: "arrow.up.right", bg: AppColors.greenSoft, fg: AppColors.green, label: "Connected Sources", value: googleCalSync || iCloudCalSync ? "Connected" : nil) { showImport = true }
SttNavRow(icon: "cloud", bg: AppColors.blueSoft, fg: AppColors.blue, label: "Library Sync", value: iCloudSync ? "● Synced" : "Off", valueColor: iCloudSync ? AppColors.green : nil, isLast: true) { showBackup = true }
}
@@ -163,12 +166,14 @@ struct SettingsView: View {
.sheet(isPresented: $showWidgets) { WidgetsSheet().presentationDetents([.height(320)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showGeneral) { GeneralSheet(showCompleted: $showCompleted, mondayFirst: $firstDayMonday).presentationDetents([.height(260)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showImport) { ImportSheet(googleCal: $googleCalSync, iCloudCal: $iCloudCalSync).presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showCalendar) { CalendarConnectSheet(calStore: calStore).presentationDetents([.large]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showBackup) { BackupSheet(iCloudSync: $iCloudSync).presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showWorkoutSettings) { WorkoutSettingsSheet(unit: $weightUnit, sets: $defaultSets, reps: $defaultReps).environmentObject(workoutVM).presentationDetents([.large]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showRestTimer) { RestTimerSheet(seconds: $workoutVM.restTimerSeconds, enabled: $workoutVM.restTimerEnabled).presentationDetents([.height(360)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showHelp) { HelpSheet().presentationDetents([.height(300)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showFollow) { FollowSheet().presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
.sheet(isPresented: $showAbout) { AboutSheet().presentationDetents([.height(340)]).presentationDragIndicator(.visible) }
.onAppear { calStore.refreshStatus() }
}
}

48
SERVICES.md Normal file
View File

@@ -0,0 +1,48 @@
# Rackpeek Services
Inventory of reverse-proxy hosts published via Rackpeek (<http://10.10.1.70:18080/services/list>).
Format: **Service**`IP:port` — URL. All `Public`, `Custom Certificate` unless noted; `?` = name unconfirmed.
- **Rackpeek** — `10.10.1.70:18080` — http://10.10.1.70:18080
- **Backup** (Proxmox Backup Server) — `10.10.1.11:8007` — http://10.10.1.11:8007
- **Proxmox VE** — `10.10.1.44:8006` — http://10.10.1.44:8006
- **Uptime Kuma (Canal+)** — `10.10.1.50:3001` — http://10.10.1.50:3001
- **Uptime Kuma (Airtel)** — `10.10.1.51:3001` — http://10.10.1.51:3001
- **Uptime Kuma** — `10.10.1.20:3001` — http://10.10.1.20:3001
- **Nginx Proxy Manager** — `10.10.1.5:81` — http://10.10.1.5:81
- **Portainer** — `10.10.1.70:9000` — http://10.10.1.70:9000
- **Netdata** — `10.10.1.20:19999` — http://10.10.1.20:19999
- **Homarr** — `10.10.1.20:7575` — http://10.10.1.20:7575
- **Plex** — `10.10.1.70:32400` — http://10.10.1.70:32400 _(HTTP Only)_
- **Radarr** — `10.10.1.70:7878` — http://10.10.1.70:7878
- **Sonarr** — `10.10.1.70:8989` — http://10.10.1.70:8989
- **Prowlarr** — `10.10.1.70:9696` — http://10.10.1.70:9696
- **Bazarr** — `10.10.1.70:6767` — http://10.10.1.70:6767
- **Overseerr/Jellyseerr ?** — `10.10.1.70:5055` — http://10.10.1.70:5055
- **Synology DSM ?** — `10.10.1.20:5000` — http://10.10.1.20:5000
- **?** — `10.10.1.78:8181` — http://10.10.1.78:8181
- **?** — `10.10.1.6:8181` — http://10.10.1.6:8181
- **?** — `10.10.1.21:8181` — http://10.10.1.21:8181
- **?** — `10.10.1.70:8181` — http://10.10.1.70:8181
- **?** — `10.10.1.7:8181` — http://10.10.1.7:8181
- **?** — `10.10.1.5:8181` — http://10.10.1.5:8181
- **?** — `10.10.1.21:80` — http://10.10.1.21:80
- **?** — `10.10.1.20:80` — http://10.10.1.20:80
- **?** — `10.10.1.13:80` — http://10.10.1.13:80
- **?** — `10.10.1.78:80` — http://10.10.1.78:80
- **?** — `10.10.1.7:8080` — http://10.10.1.7:8080
- **?** — `10.10.1.70:8080` — http://10.10.1.70:8080
- **?** — `10.10.1.21:8080` — http://10.10.1.21:8080
- **?** — `10.10.1.70:8888` — http://10.10.1.70:8888
- **?** — `10.10.1.20:8888` — http://10.10.1.20:8888
- **?** — `10.10.1.21:8888` — http://10.10.1.21:8888
- **?** — `10.10.1.70:3030` — http://10.10.1.70:3030
- **?** — `10.10.1.70:9080` — http://10.10.1.70:9080
- **?** — `10.10.1.21:3002` — http://10.10.1.21:3002
- **?** — `10.10.1.7:8281` — http://10.10.1.7:8281 _(HTTP Only)_
- **?** — `10.10.1.70:6246` — http://10.10.1.70:6246 _(HTTP Only)_
- **?** — `10.10.1.21:18789` — http://10.10.1.21:18789
- **?** — `10.10.1.70:8212` — http://10.10.1.70:8212
- **?** — `10.10.1.10:7655` — http://10.10.1.10:7655
- **?** — `10.10.1.70:7476` — http://10.10.1.70:7476 _(HTTP Only)_
- **?** — `10.10.1.20:9412` — http://10.10.1.20:9412

View File

@@ -7,11 +7,67 @@
# Network Layout
| Device | IP | Purpose |
|--------------|------------|-----------------|
| Kuma-Canal | 10.10.1.50 | Monitor Canal+ |
| Kuma-Airtel | 10.10.1.51 | Monitor Airtel |
| Omada Gateway| 10.10.1.1 | Policy Routing |
| Device | IP | Purpose |
|--------------|----------------------|----------------------------|
| Kuma-Canal | 10.10.1.50 | Monitor Canal+ |
| Kuma-Airtel | 10.10.1.51 | Monitor Airtel |
| Omada Gateway| 10.10.1.1 | Policy Routing |
| Rackpeek | 10.10.1.70:18080 | Rack/services dashboard |
Rackpeek services list: <http://10.10.1.70:18080/services/list>
---
# Proxy Hosts / Services
Reverse-proxy hosts published via Rackpeek. All `Public` access, all `Online` as of last sync.
Service names are the proxy source (`.sanctum` suffix omitted). Entries marked `?` are inferred
from the port or still need confirmation.
| Service | Destination | SSL | Access | Status | Created |
|------------------------|------------------------|--------------------|--------|--------|-------------------------------|
| ? | http://10.10.1.78:8181 | Custom Certificate | Public | Online | Nov 20, 2025 at 3:47:23 AM |
| Uptime Kuma (Airtel) | http://10.10.1.51:3001 | Custom Certificate | Public | Online | Nov 25, 2025 at 12:18:08 AM |
| ? | http://10.10.1.6:8181 | Custom Certificate | Public | Online | Nov 20, 2025 at 3:48:33 AM |
| Backup | http://10.10.1.11:8007 | Custom Certificate | Public | Online | Dec 4, 2025 at 1:09:02 PM |
| Bazarr | http://10.10.1.70:6767 | Custom Certificate | Public | Online | Nov 25, 2025 at 2:54:35 AM |
| ? | http://10.10.1.21:8181 | Custom Certificate | Public | Online | Nov 20, 2025 at 3:49:44 AM |
| Uptime Kuma (Canal+) | http://10.10.1.50:3001 | Custom Certificate | Public | Online | Nov 25, 2025 at 12:16:37 AM |
| ? | http://10.10.1.21:80 | Custom Certificate | Public | Online | Nov 20, 2025 at 3:51:05 AM |
| ? | http://10.10.1.20:80 | Custom Certificate | Public | Online | Nov 20, 2025 at 3:51:50 AM |
| Synology DSM ? | http://10.10.1.20:5000 | Custom Certificate | Public | Online | Nov 25, 2025 at 1:19:01 AM |
| ? | http://10.10.1.7:8080 | Custom Certificate | Public | Online | Nov 20, 2025 at 3:53:28 AM |
| ? | http://10.10.1.70:3030 | Custom Certificate | Public | Online | Nov 20, 2025 at 3:56:57 AM |
| Homarr | http://10.10.1.20:7575 | Custom Certificate | Public | Online | Dec 4, 2025 at 1:05:58 PM |
| ? | http://10.10.1.70:9080 | Custom Certificate | Public | Online | Nov 21, 2025 at 2:37:33 AM |
| ? | http://10.10.1.70:8181 | Custom Certificate | Public | Online | Nov 21, 2025 at 2:41:13 AM |
| ? | http://10.10.1.21:3002 | Custom Certificate | Public | Online | Nov 21, 2025 at 2:43:06 AM |
| ? | http://10.10.1.70:8080 | Custom Certificate | Public | Online | Nov 21, 2025 at 2:43:56 AM |
| ? | http://10.10.1.7:8181 | Custom Certificate | Public | Online | Nov 21, 2025 at 2:45:03 AM |
| ? | http://10.10.1.7:8281 | HTTP Only | Public | Online | Nov 25, 2025 at 12:07:57 AM |
| Uptime Kuma | http://10.10.1.20:3001 | Custom Certificate | Public | Online | Nov 21, 2025 at 2:50:20 AM |
| ? | http://10.10.1.70:6246 | HTTP Only | Public | Online | Nov 24, 2025 at 1:33:47 PM |
| Netdata | http://10.10.1.20:19999| Custom Certificate | Public | Online | Nov 30, 2025 at 10:25:35 PM |
| ? | http://10.10.1.21:18789| Custom Certificate | Public | Online | Feb 18, 2026 at 7:30:40 PM |
| Overseerr/Jellyseerr ? | http://10.10.1.70:5055 | Custom Certificate | Public | Online | Nov 21, 2025 at 2:51:13 AM |
| ? | http://10.10.1.70:8212 | Custom Certificate | Public | Online | Nov 25, 2025 at 2:00:57 AM |
| Plex | http://10.10.1.70:32400| HTTP Only | Public | Online | Nov 25, 2025 at 3:04:23 AM |
| Portainer | http://10.10.1.70:9000 | Custom Certificate | Public | Online | Nov 21, 2025 at 2:52:39 AM |
| Prowlarr | http://10.10.1.70:9696 | Custom Certificate | Public | Online | Nov 21, 2025 at 2:53:47 AM |
| Proxmox VE | http://10.10.1.44:8006 | Custom Certificate | Public | Online | Nov 21, 2025 at 2:54:29 AM |
| Nginx Proxy Manager | http://10.10.1.5:81 | Custom Certificate | Public | Online | Nov 21, 2025 at 2:55:02 AM |
| ? | http://10.10.1.10:7655 | Custom Certificate | Public | Online | Nov 21, 2025 at 2:56:01 AM |
| ? | http://10.10.1.70:8888 | Custom Certificate | Public | Online | Nov 21, 2025 at 2:56:35 AM |
| Radarr | http://10.10.1.70:7878 | Custom Certificate | Public | Online | Nov 21, 2025 at 2:57:22 AM |
| ? | http://10.10.1.5:8181 | Custom Certificate | Public | Online | Nov 21, 2025 at 2:58:12 AM |
| ? | http://10.10.1.13:80 | Custom Certificate | Public | Online | Nov 21, 2025 at 3:00:30 AM |
| ? | http://10.10.1.78:80 | Custom Certificate | Public | Online | Nov 21, 2025 at 3:01:36 AM |
| Sonarr | http://10.10.1.70:8989 | Custom Certificate | Public | Online | Nov 21, 2025 at 3:02:24 AM |
| ? | http://10.10.1.20:8888 | Custom Certificate | Public | Online | Nov 21, 2025 at 3:03:28 AM |
| ? | http://10.10.1.21:8888 | Custom Certificate | Public | Online | Nov 21, 2025 at 3:04:31 AM |
| ? | http://10.10.1.21:8080 | Custom Certificate | Public | Online | Dec 4, 2025 at 1:03:19 PM |
| ? | http://10.10.1.70:7476 | HTTP Only | Public | Online | Feb 6, 2026 at 7:53:14 PM |
| ? | http://10.10.1.20:9412 | Custom Certificate | Public | Online | Nov 25, 2025 at 1:16:55 AM |
---