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 @@
11import Library
22import SwiftUI
3import UserNotifications
34
45/// UserDefaults keys shared by the settings screen and the models that read them.
56enum SettingsKey {
@@ -11,6 +12,9 @@ enum SettingsKey {
1112 static let onboarded = "onboarded"
1213 /// Whether the Sky tab is currently drawn in night mode (written by the Sky tab).
1314 static let nightActive = "nightActive"
15 static let dailySummary = "dailySummary"
16 /// Minutes after local midnight.
17 static let dailySummaryMinutes = "dailySummaryMinutes"
1418}
1519
1620struct City: Hashable, Identifiable {
@@ -59,6 +63,9 @@ struct SettingsScreen: View {
5963 @AppStorage(SettingsKey.nightMode) private var nightMode = "auto"
6064 @Environment(\.dismiss) private var dismiss
6165 @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?
6269 var onChange: () -> Void
6370
6471 var body: some View {
@@ -91,6 +98,20 @@ struct SettingsScreen: View {
9198 } footer: {
9299 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.")
93100 }
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 }
94115 Section {
95116 Picker("Night mode", selection: $nightMode) {
96117 Text("Off").tag("off")
@@ -123,6 +144,28 @@ struct SettingsScreen: View {
123144 .onChange(of: locationMode) { onChange() }
124145 }
125146 }
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 }
126169}
127170
128171struct CreditsScreen: View {
App/Sources/SkyScreen.swift +8 −1
@@ -18,6 +18,7 @@ struct SkyScreen: View {
1818 @AppStorage(SettingsKey.onboarded) private var onboarded = false
1919 @AppStorage(SettingsKey.nightActive) private var nightActive = false
2020 @State private var showToday = false
21 @Environment(\.scenePhase) private var scenePhase
2122 @State private var libraryPage: LibraryPage?
2223 @State private var selection: SkyObject?
2324 @State private var details: BodyDetails?
@@ -72,7 +73,7 @@ struct SkyScreen: View {
7273 .preferredColorScheme(.dark)
7374 .tint(.blue)
7475 }
75 .sheet(isPresented: $showSettings) {
76 .sheet(isPresented: $showSettings, onDismiss: rescheduleSummary) {
7677 SettingsScreen { model.applyLocationSettings() }
7778 .preferredColorScheme(.dark)
7879 .tint(.blue)
@@ -88,6 +89,8 @@ struct SkyScreen: View {
8889 .onChange(of: model.offsetHours) { select(selection) }
8990 .onChange(of: onboarded) { if onboarded { model.start() } }
9091 .onChange(of: night, initial: true) { nightActive = night }
92 .onChange(of: model.observer) { rescheduleSummary() }
93 .onChange(of: scenePhase) { if scenePhase == .active { rescheduleSummary() } }
9194 .task {
9295 if onboarded { model.start() }
9396 while !Task.isCancelled {
@@ -97,6 +100,10 @@ struct SkyScreen: View {
97100 }
98101 }
99102
103 private func rescheduleSummary() {
104 Task { await DailySummaryNotifications.reschedule(model) }
105 }
106
100107 private func select(_ object: SkyObject?) {
101108 selection = object
102109 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}