diff --git a/KisaniCal.xcodeproj/project.xcworkspace/xcuserdata/kutesir.xcuserdatad/UserInterfaceState.xcuserstate b/KisaniCal.xcodeproj/project.xcworkspace/xcuserdata/kutesir.xcuserdatad/UserInterfaceState.xcuserstate index 869dee0..adb38c4 100644 Binary files a/KisaniCal.xcodeproj/project.xcworkspace/xcuserdata/kutesir.xcuserdatad/UserInterfaceState.xcuserstate and b/KisaniCal.xcodeproj/project.xcworkspace/xcuserdata/kutesir.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/KisaniCal/ISSUES.md b/KisaniCal/ISSUES.md index 0956653..4aec8e6 100644 --- a/KisaniCal/ISSUES.md +++ b/KisaniCal/ISSUES.md @@ -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. diff --git a/KisaniCal/Managers/NotificationManager.swift b/KisaniCal/Managers/NotificationManager.swift index ed07749..50e5485 100644 --- a/KisaniCal/Managers/NotificationManager.swift +++ b/KisaniCal/Managers/NotificationManager.swift @@ -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]) { diff --git a/KisaniCal/Views/CalendarView.swift b/KisaniCal/Views/CalendarView.swift index bb9f5d4..67590d3 100644 --- a/KisaniCal/Views/CalendarView.swift +++ b/KisaniCal/Views/CalendarView.swift @@ -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 } diff --git a/KisaniCal/Views/SettingsView.swift b/KisaniCal/Views/SettingsView.swift index 5c03dbe..1d7aad2 100644 --- a/KisaniCal/Views/SettingsView.swift +++ b/KisaniCal/Views/SettingsView.swift @@ -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() } } } diff --git a/SERVICES.md b/SERVICES.md new file mode 100644 index 0000000..d076543 --- /dev/null +++ b/SERVICES.md @@ -0,0 +1,48 @@ +# Rackpeek Services + +Inventory of reverse-proxy hosts published via Rackpeek (). +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 diff --git a/Sanctum-Auto-FailOver-Uptime-Kuma.md b/Sanctum-Auto-FailOver-Uptime-Kuma.md index ca25f46..eb07dd5 100644 --- a/Sanctum-Auto-FailOver-Uptime-Kuma.md +++ b/Sanctum-Auto-FailOver-Uptime-Kuma.md @@ -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: + +--- + +# 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 | ---