krz/moonrise

Night-sky companion for iPhone, Apple Watch and Vision Pro: what's up tonight, the Moon, planets, launches and missions, and a Library of the solar system. SwiftUI app with a Cloudflare Workers backend. astronomy cloudflare-workers ios night-sky space swiftui visionos watchos

Commit 8bc05d7e5c

8bc05d7e5c465c64f1dbfaed2f10e812f2a93232

parent: bc5e532ba3

Unsigned

cmc <hello@cleberg.net> · 2026-10-18 20:54 UTC

v2: Daily summary notification (!29)

Adds an optional daily summary notification, off by default, set in Settings with a time (default 18:00).

- =DailySummary= (SkyEngine) builds the text: Moon phase and illumination, the day's events, naked-eye planets up that night, the highest visible satellite pass ("tomorrow" if it is after midnight) and sunset.
- The app schedules the next seven as local notifications and replaces them when it becomes active, when the location changes and when Settings closes. Nothing is sent to the server.
- Satellite passes cover three days, so days 4 to 7 have no pass line until the app is opened again.
- Checked on the simulator: the notification arrives at the set time with the expected text.

Layout: unified · split

App/Sources/DailySummaryNotifications.swift added +44
@@ -0,0 +1,44 @@
1import SkyEngine
2import SwiftUI
3import UserNotifications
4
5/// Local notifications for the daily summary. The next seven are worked out on the device and
6/// replaced each time the app becomes active or the location or setting changes.
7enum DailySummaryNotifications {
8 static let ids = (0..<7).map { "daily-summary-\($0)" }
9 static let defaultMinutes = 18 * 60
10
11 @MainActor
12 static func reschedule(_ model: SkyModel) async {
13 let center = UNUserNotificationCenter.current()
14 center.removePendingNotificationRequests(withIdentifiers: ids)
15 let defaults = UserDefaults.standard
16 guard defaults.bool(forKey: SettingsKey.dailySummary) else { return }
17 await model.refreshPasses()
18 let observer = model.observer, passes = model.passes
19 let minutes = defaults.object(forKey: SettingsKey.dailySummaryMinutes) as? Int ?? defaultMinutes
20 let planned = await Task.detached { plan(observer: observer, passes: passes, minutes: minutes) }.value
21 let calendar = Calendar.current
22 for (id, fire, summary) in planned {
23 let content = UNMutableNotificationContent()
24 content.title = summary.title
25 content.body = summary.body
26 let parts = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: fire)
27 try? await center.add(UNNotificationRequest(identifier: id, content: content, trigger: UNCalendarNotificationTrigger(dateMatching: parts, repeats: false)))
28 }
29 }
30
31 nonisolated static func plan(observer: Observer, passes: [SatellitePass], minutes: Int, now: Date = .now) -> [(String, Date, DailySummary)] {
32 let calendar = Calendar.current
33 let today = calendar.startOfDay(for: now)
34 let events = SkyEngine.events(from: today, days: 8, observer: observer)
35 return ids.enumerated().compactMap { i, id in
36 guard let day = calendar.date(byAdding: .day, value: i, to: today),
37 let fire = calendar.date(bySettingHour: minutes / 60, minute: minutes % 60, second: 0, of: day),
38 fire > now,
39 let summary = try? DailySummary.make(at: fire, observer: observer, passes: passes, events: events)
40 else { return nil }
41 return (id, fire, summary)
42 }
43 }
44}
App/Sources/SettingsScreen.swift +43
@@ -1,5 +1,6 @@
1import Library 1import Library
2import SwiftUI 2import SwiftUI
3import UserNotifications
3 4
4/// UserDefaults keys shared by the settings screen and the models that read them. 5/// UserDefaults keys shared by the settings screen and the models that read them.
5enum SettingsKey { 6enum SettingsKey {
@@ -11,6 +12,9 @@ enum SettingsKey {
11 static let onboarded = "onboarded" 12 static let onboarded = "onboarded"
12 /// Whether the Sky tab is currently drawn in night mode (written by the Sky tab). 13 /// Whether the Sky tab is currently drawn in night mode (written by the Sky tab).
13 static let nightActive = "nightActive" 14 static let nightActive = "nightActive"
15 static let dailySummary = "dailySummary"
16 /// Minutes after local midnight.
17 static let dailySummaryMinutes = "dailySummaryMinutes"
14} 18}
15 19
16struct City: Hashable, Identifiable { 20struct City: Hashable, Identifiable {
@@ -59,6 +63,9 @@ struct SettingsScreen: View {
59 @AppStorage(SettingsKey.nightMode) private var nightMode = "auto" 63 @AppStorage(SettingsKey.nightMode) private var nightMode = "auto"
60 @Environment(\.dismiss) private var dismiss 64 @Environment(\.dismiss) private var dismiss
61 @State private var alerts = AuroraAlerts.shared 65 @State private var alerts = AuroraAlerts.shared
66 @AppStorage(SettingsKey.dailySummary) private var dailySummary = false
67 @AppStorage(SettingsKey.dailySummaryMinutes) private var dailySummaryMinutes = DailySummaryNotifications.defaultMinutes
68 @State private var summaryStatus: String?
62 var onChange: () -> Void 69 var onChange: () -> Void
63 70
64 var body: some View { 71 var body: some View {
@@ -91,6 +98,20 @@ struct SettingsScreen: View {
91 } footer: { 98 } footer: {
92 Text("Moonrise notifies you when the aurora may be visible from your area at night. To do this it sends our server this device's notification token, a 5° band of geomagnetic latitude and your time zone offset, not your location.") 99 Text("Moonrise notifies you when the aurora may be visible from your area at night. To do this it sends our server this device's notification token, a 5° band of geomagnetic latitude and your time zone offset, not your location.")
93 } 100 }
101 Section {
102 Toggle("Daily summary", isOn: Binding(
103 get: { dailySummary },
104 set: { on in Task { await setDailySummary(on) } }
105 ))
106 if dailySummary {
107 DatePicker("Time", selection: summaryTime, displayedComponents: .hourAndMinute)
108 }
109 if let summaryStatus {
110 Text(summaryStatus).font(.footnote).foregroundStyle(.secondary)
111 }
112 } footer: {
113 Text("A note each day with the Moon, the planets up that night, the best satellite pass and any events. It is worked out on this device; nothing is sent to our server.")
114 }
94 Section { 115 Section {
95 Picker("Night mode", selection: $nightMode) { 116 Picker("Night mode", selection: $nightMode) {
96 Text("Off").tag("off") 117 Text("Off").tag("off")
@@ -123,6 +144,28 @@ struct SettingsScreen: View {
123 .onChange(of: locationMode) { onChange() } 144 .onChange(of: locationMode) { onChange() }
124 } 145 }
125 } 146 }
147
148 private var summaryTime: Binding<Date> {
149 Binding(
150 get: { Calendar.current.startOfDay(for: .now).addingTimeInterval(Double(dailySummaryMinutes) * 60) },
151 set: { d in
152 let c = Calendar.current.dateComponents([.hour, .minute], from: d)
153 dailySummaryMinutes = (c.hour ?? 0) * 60 + (c.minute ?? 0)
154 }
155 )
156 }
157
158 private func setDailySummary(_ on: Bool) async {
159 if on {
160 let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])) ?? false
161 guard granted else {
162 summaryStatus = "Notifications are off for Moonrise. Turn them on in the Settings app."
163 return
164 }
165 }
166 summaryStatus = nil
167 dailySummary = on
168 }
126} 169}
127 170
128struct CreditsScreen: View { 171struct CreditsScreen: View {
App/Sources/SkyScreen.swift +8 −1
@@ -18,6 +18,7 @@ struct SkyScreen: View {
18 @AppStorage(SettingsKey.onboarded) private var onboarded = false 18 @AppStorage(SettingsKey.onboarded) private var onboarded = false
19 @AppStorage(SettingsKey.nightActive) private var nightActive = false 19 @AppStorage(SettingsKey.nightActive) private var nightActive = false
20 @State private var showToday = false 20 @State private var showToday = false
21 @Environment(\.scenePhase) private var scenePhase
21 @State private var libraryPage: LibraryPage? 22 @State private var libraryPage: LibraryPage?
22 @State private var selection: SkyObject? 23 @State private var selection: SkyObject?
23 @State private var details: BodyDetails? 24 @State private var details: BodyDetails?
@@ -72,7 +73,7 @@ struct SkyScreen: View {
72 .preferredColorScheme(.dark) 73 .preferredColorScheme(.dark)
73 .tint(.blue) 74 .tint(.blue)
74 } 75 }
75 .sheet(isPresented: $showSettings) { 76 .sheet(isPresented: $showSettings, onDismiss: rescheduleSummary) {
76 SettingsScreen { model.applyLocationSettings() } 77 SettingsScreen { model.applyLocationSettings() }
77 .preferredColorScheme(.dark) 78 .preferredColorScheme(.dark)
78 .tint(.blue) 79 .tint(.blue)
@@ -88,6 +89,8 @@ struct SkyScreen: View {
88 .onChange(of: model.offsetHours) { select(selection) } 89 .onChange(of: model.offsetHours) { select(selection) }
89 .onChange(of: onboarded) { if onboarded { model.start() } } 90 .onChange(of: onboarded) { if onboarded { model.start() } }
90 .onChange(of: night, initial: true) { nightActive = night } 91 .onChange(of: night, initial: true) { nightActive = night }
92 .onChange(of: model.observer) { rescheduleSummary() }
93 .onChange(of: scenePhase) { if scenePhase == .active { rescheduleSummary() } }
91 .task { 94 .task {
92 if onboarded { model.start() } 95 if onboarded { model.start() }
93 while !Task.isCancelled { 96 while !Task.isCancelled {
@@ -97,6 +100,10 @@ struct SkyScreen: View {
97 } 100 }
98 } 101 }
99 102
103 private func rescheduleSummary() {
104 Task { await DailySummaryNotifications.reschedule(model) }
105 }
106
100 private func select(_ object: SkyObject?) { 107 private func select(_ object: SkyObject?) {
101 selection = object 108 selection = object
102 details = nil 109 details = nil
Packages/MoonriseKit/Sources/SkyEngine/DailySummary.swift added +60
@@ -0,0 +1,60 @@
1import Foundation
2
3/// The text of the daily summary notification: tonight's Moon, planets, the best satellite pass and the day's events.
4public struct DailySummary: Sendable, Equatable {
5 public var title: String
6 public var body: String
7
8 public static func phaseName(_ angle: Double) -> String {
9 switch angle {
10 case ..<10, 350...: "New moon"
11 case ..<80: "Waxing crescent"
12 case ..<100: "First quarter"
13 case ..<170: "Waxing gibbous"
14 case ..<190: "Full moon"
15 case ..<260: "Waning gibbous"
16 case ..<280: "Last quarter"
17 default: "Waning crescent"
18 }
19 }
20
21 /// Summary for the night following `date`. `passes` and `events` may cover any span; only those
22 /// for this night (passes) and this calendar day or night (events) are used.
23 public static func make(at date: Date, observer: Observer, passes: [SatellitePass], events: [SkyEvent],
24 locale: Locale = .current, timeZone: TimeZone = .current) throws -> DailySummary {
25 let tonight = try Tonight(at: date, from: observer)
26 let time = Date.FormatStyle(date: .omitted, time: .shortened, locale: locale, timeZone: timeZone)
27 var calendar = Calendar(identifier: .gregorian)
28 calendar.timeZone = timeZone
29
30 let lit = Int((tonight.moonIllumination * 100).rounded())
31 let title = "Tonight: \(phaseName(tonight.moonPhase).lowercased().replacingOccurrences(of: " moon", with: "")) Moon, \(lit)% lit"
32
33 var lines: [String] = []
34
35 let todays = events.filter { calendar.isDate($0.date, inSameDayAs: date) || ($0.date >= tonight.start && $0.date <= tonight.end) }
36 if !todays.isEmpty {
37 lines.append(todays.map(\.title).joined(separator: ". ") + ".")
38 }
39
40 let planets = tonight.planets.filter { $0.best != nil }.map { $0.body.rawValue.capitalized }
41 if planets.isEmpty {
42 lines.append("No bright planets above 10° tonight.")
43 } else {
44 lines.append("Up tonight: \(ListFormatter.localizedString(byJoining: planets)).")
45 }
46
47 if let pass = passes
48 .filter({ $0.visible && $0.rise >= tonight.start.addingTimeInterval(-3600) && $0.rise <= tonight.end })
49 .max(by: { $0.maxAltitude < $1.maxAltitude }) {
50 let when = pass.rise.formatted(time) + (calendar.isDate(pass.rise, inSameDayAs: date) ? "" : " tomorrow")
51 lines.append("\(pass.name) passes at \(when), \(Int(pass.maxAltitude.rounded()))° high.")
52 }
53
54 if let sunset = tonight.sunset {
55 lines.append("Sunset \(sunset.formatted(time)).")
56 }
57
58 return DailySummary(title: title, body: lines.joined(separator: " "))
59 }
60}
Packages/MoonriseKit/Tests/SkyEngineTests/DailySummaryTests.swift added +43
@@ -0,0 +1,43 @@
1import Foundation
2import Testing
3@testable import SkyEngine
4
5private let summaryObserver = Observer(latitude: 41.878, longitude: -87.630)
6private let central = TimeZone(identifier: "America/Chicago")!
7private let gb = Locale(identifier: "en_GB")
8
9@Test func phaseNames() {
10 #expect(DailySummary.phaseName(3) == "New moon")
11 #expect(DailySummary.phaseName(45) == "Waxing crescent")
12 #expect(DailySummary.phaseName(90) == "First quarter")
13 #expect(DailySummary.phaseName(180) == "Full moon")
14 #expect(DailySummary.phaseName(271) == "Last quarter")
15 #expect(DailySummary.phaseName(320) == "Waning crescent")
16}
17
18@Test func summaryForAnEvening() throws {
19 // 18:00 CDT, 18 October 2026.
20 let date = Date(timeIntervalSince1970: 1_792_364_400)
21 let pass = SatellitePass(name: "ISS (ZARYA)", rise: date.addingTimeInterval(5400), set: date.addingTimeInterval(5760), peak: date.addingTimeInterval(5580), maxAltitude: 54.4, riseAzimuth: 240, setAzimuth: 60, visible: true)
22 let faint = SatellitePass(name: "HST", rise: date.addingTimeInterval(7200), set: date.addingTimeInterval(7500), peak: date.addingTimeInterval(7350), maxAltitude: 70, riseAzimuth: 200, setAzimuth: 80, visible: false)
23 let event = SkyEvent(kind: .meteorShower, date: date.addingTimeInterval(3 * 3600), title: "Orionids peak", detail: "", visibleHere: nil)
24 let tomorrow = SkyEvent(kind: .moonPhase, date: date.addingTimeInterval(30 * 3600), title: "Full moon", detail: "", visibleHere: nil)
25
26 let s = try DailySummary.make(at: date, observer: summaryObserver, passes: [pass, faint], events: [event, tomorrow], locale: gb, timeZone: central)
27 #expect(s.title.hasPrefix("Tonight: "))
28 #expect(s.title.hasSuffix("% lit"))
29 #expect(s.body.hasPrefix("Orionids peak. "))
30 #expect(!s.body.contains("Full moon"))
31 #expect(s.body.contains("Up tonight: "))
32 #expect(s.body.contains("ISS (ZARYA) passes at 19:30, 54° high."))
33 #expect(!s.body.contains("HST"))
34 #expect(s.body.contains("Sunset 18:"))
35}
36
37@Test func morningPassSaysTomorrow() throws {
38 let date = Date(timeIntervalSince1970: 1_792_364_400)
39 // 05:37 CDT on 19 October, before dawn.
40 let pass = SatellitePass(name: "ISS (ZARYA)", rise: date.addingTimeInterval(41_820), set: date.addingTimeInterval(42_180), peak: date.addingTimeInterval(42_000), maxAltitude: 40, riseAzimuth: 240, setAzimuth: 60, visible: true)
41 let s = try DailySummary.make(at: date, observer: summaryObserver, passes: [pass], events: [], locale: gb, timeZone: central)
42 #expect(s.body.contains("ISS (ZARYA) passes at 5:37 tomorrow, 40° high."))
43}