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 78a9ae10fc

78a9ae10fc8f1c22013a9c5b7977d7895cd569ed

parent: 77668688ab

Unsigned

cmc <hello@cleberg.net> · 2026-10-20 01:43 UTC

Fix launch Live Activity expiry, meteor shower Moon, Org properties (!60)

- **Launch Live Activity**: a launch more than an hour out now gets a scheduled activity (iOS 26 `request(…, start:)`) that starts at T−60 min, so the 8-hour limit no longer ends it before liftoff. Closer launches still start immediately. Following, relaunch detection and unfollow work for the pending activity (checked in the iOS 26.5 simulator).
- **Meteor showers**: Moon illumination is computed at the observer's local solar midnight after the peak date instead of 12:00 UTC, and the detail says whether the Moon is below the horizon, up all through, sets or rises during the dark hours (Sun below −18°).
- **Observing log**: `Observation` and `Equipment` keep unknown Org properties in file order and write them back after the known ones. Timestamp format unchanged.

Tests: `swift test` (91 tests, new tests for the meteor Moon text and both property round-trips), server `node --test` (18). `AccessibilityAuditTests` fails the same way on main: it taps a "Settings" button the Sky screen no longer has.

Layout: unified · split

App/Sources/LaunchesScreen.swift +11 −2
@@ -208,10 +208,19 @@ struct LaunchDetail: View {
208208 }
209209 let attributes = LaunchActivityAttributes(launchID: launch.id, rocket: launch.rocket, mission: launch.mission ?? launch.name, location: launch.location)
210210 let state = LaunchActivityAttributes.ContentState(net: launch.net, status: launch.status)
211 let content = ActivityContent(state: state, staleDate: launch.net.addingTimeInterval(3600))
212 // Live Activities end after 8 hours, so one for a later launch starts an hour before it.
213 let start = launch.net.addingTimeInterval(-3600)
211214 do {
212 _ = try Activity.request(attributes: attributes, content: .init(state: state, staleDate: launch.net.addingTimeInterval(3600)))
215 if start > .now {
216 let alert = AlertConfiguration(title: "\(attributes.rocket) launches in an hour", body: "\(attributes.mission)", sound: .default)
217 _ = try Activity.request(attributes: attributes, content: content, style: .standard, alertConfiguration: alert, start: start)
218 message = "Countdown will appear on the Lock Screen an hour before launch."
219 } else {
220 _ = try Activity.request(attributes: attributes, content: content)
221 message = "Countdown added to the Lock Screen."
222 }
213223 following = true
214 message = "Countdown added to the Lock Screen."
215224 } catch {
216225 message = "Couldn't start the countdown: \(error.localizedDescription)"
217226 }
Packages/MoonriseKit/Sources/ObservingLog/Equipment.swift +13 −4
@@ -51,8 +51,12 @@ public struct Equipment: Sendable, Identifiable, Equatable, Hashable {
5151 public var notes: String
5252 /// Retired items stay in the inventory for old entries but leave the picker.
5353 public var retired: Bool
54 /// Properties added by hand, in file order.
55 public var otherProperties: [OrgProperty]
5456
55 public init(id: String = UUID().uuidString, kind: Kind, name: String, aperture: Double? = nil, focalLength: Double? = nil, magnification: Double? = nil, fieldOfView: Double? = nil, sensor: String = "", notes: String = "", retired: Bool = false) {
57 static let knownKeys: Set = [":ID:", ":TYPE:", ":APERTURE:", ":FOCAL_LENGTH:", ":FOCAL_RATIO:", ":MAGNIFICATION:", ":FIELD_OF_VIEW:", ":SENSOR:", ":RETIRED:"]
58
59 public init(id: String = UUID().uuidString, kind: Kind, name: String, aperture: Double? = nil, focalLength: Double? = nil, magnification: Double? = nil, fieldOfView: Double? = nil, sensor: String = "", notes: String = "", retired: Bool = false, otherProperties: [OrgProperty] = []) {
5660 self.id = id
5761 self.kind = kind
5862 self.name = name
@@ -63,6 +67,7 @@ public struct Equipment: Sendable, Identifiable, Equatable, Hashable {
6367 self.sensor = sensor
6468 self.notes = notes
6569 self.retired = retired
70 self.otherProperties = otherProperties
6671 }
6772
6873 /// Focal length over aperture, when both are known.
@@ -87,6 +92,7 @@ public struct Equipment: Sendable, Identifiable, Equatable, Hashable {
8792 if retired { props.append((":RETIRED:", "t")) }
8893 var lines = ["* \(name)", ":PROPERTIES:"]
8994 lines += props.map { "\($0.0) \($0.1)" }
95 lines += otherProperties.map(\.line)
9096 lines.append(":END:")
9197 let body = notes.trimmingCharacters(in: .whitespacesAndNewlines)
9298 if !body.isEmpty { lines += ["", body] }
@@ -98,12 +104,14 @@ public struct Equipment: Sendable, Identifiable, Equatable, Hashable {
98104 let lines = text.components(separatedBy: "\n")
99105 guard let head = lines.first, head.hasPrefix("* ") else { return nil }
100106 var props: [String: String] = [:]
107 var other: [OrgProperty] = []
101108 var i = 1
102109 if i < lines.count, lines[i] == ":PROPERTIES:" {
103110 i += 1
104111 while i < lines.count, lines[i] != ":END:" {
105 let parts = lines[i].split(separator: " ", maxSplits: 1).map(String.init)
106 if parts.count == 2 { props[parts[0]] = parts[1] }
112 if let p = OrgProperty.parse(lines[i]) {
113 if !Self.knownKeys.contains(p.key) { other.append(p) } else if !p.value.isEmpty { props[p.key] = p.value }
114 }
107115 i += 1
108116 }
109117 i += 1
@@ -122,7 +130,8 @@ public struct Equipment: Sendable, Identifiable, Equatable, Hashable {
122130 fieldOfView: leadingNumber(":FIELD_OF_VIEW:"),
123131 sensor: props[":SENSOR:"] ?? "",
124132 notes: lines.dropFirst(i).joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines),
125 retired: props[":RETIRED:"] == "t"
133 retired: props[":RETIRED:"] == "t",
134 otherProperties: other
126135 )
127136 }
128137}
Packages/MoonriseKit/Sources/ObservingLog/Observation.swift +34 −4
@@ -1,5 +1,26 @@
11import Foundation
22
3/// A property-drawer entry the app does not use, kept so saving does not drop it.
4public struct OrgProperty: Sendable, Equatable, Hashable {
5 /// With colons, e.g. ":TRANSPARENCY:".
6 public var key: String
7 public var value: String
8
9 public init(key: String, value: String) {
10 self.key = key
11 self.value = value
12 }
13
14 var line: String { value.isEmpty ? key : "\(key) \(value)" }
15
16 /// Splits a drawer line into key and value; nil for lines that are not a property.
17 static func parse(_ line: String) -> OrgProperty? {
18 let parts = line.split(separator: " ", maxSplits: 1).map(String.init)
19 guard let key = parts.first, key.count > 2, key.hasPrefix(":"), key.hasSuffix(":") else { return nil }
20 return OrgProperty(key: key, value: parts.count == 2 ? parts[1] : "")
21 }
22}
23
324/// One logged observation, stored as an Org file that Emacs and orgstar can read.
425public struct Observation: Sendable, Identifiable, Equatable {
526 public var id: String
@@ -15,8 +36,12 @@ public struct Observation: Sendable, Identifiable, Equatable {
1536 /// 1 (poor) to 5 (excellent); nil when not noted.
1637 public var seeing: Int?
1738 public var notes: String
39 /// Properties added by hand, in file order.
40 public var otherProperties: [OrgProperty]
41
42 static let knownKeys: Set = [":ID:", ":PLACE:", ":COORDINATES:", ":EQUIPMENT:", ":EQUIPMENT_IDS:", ":SEEING:"]
1843
19 public init(id: String = UUID().uuidString, object: String, date: Date, place: String, latitude: Double? = nil, longitude: Double? = nil, equipment: String = "", equipmentIDs: [String] = [], seeing: Int? = nil, notes: String = "") {
44 public init(id: String = UUID().uuidString, object: String, date: Date, place: String, latitude: Double? = nil, longitude: Double? = nil, equipment: String = "", equipmentIDs: [String] = [], seeing: Int? = nil, notes: String = "", otherProperties: [OrgProperty] = []) {
2045 self.id = id
2146 self.object = object
2247 self.date = date
@@ -27,6 +52,7 @@ public struct Observation: Sendable, Identifiable, Equatable {
2752 self.equipmentIDs = equipmentIDs
2853 self.seeing = seeing
2954 self.notes = notes
55 self.otherProperties = otherProperties
3056 }
3157
3258 static let orgDate: Date.VerbatimFormatStyle = .init(format: "\(year: .defaultDigits)-\(month: .twoDigits)-\(day: .twoDigits) \(weekday: .abbreviated) \(hour: .twoDigits(clock: .twentyFourHour, hourCycle: .zeroBased)):\(minute: .twoDigits)", locale: Locale(identifier: "en_US_POSIX"), timeZone: .current, calendar: Calendar(identifier: .gregorian))
@@ -40,6 +66,7 @@ public struct Observation: Sendable, Identifiable, Equatable {
4066 if let seeing { props.append((":SEEING:", "\(seeing)/5")) }
4167 var lines = ["* \(object)", "[\(date.formatted(Self.orgDate))]", ":PROPERTIES:"]
4268 lines += props.map { "\($0.0) \($0.1)" }
69 lines += otherProperties.map(\.line)
4370 lines.append(":END:")
4471 let body = notes.trimmingCharacters(in: .whitespacesAndNewlines)
4572 if !body.isEmpty { lines += ["", body] }
@@ -53,12 +80,14 @@ public struct Observation: Sendable, Identifiable, Equatable {
5380 lines.count > 1, lines[1].hasPrefix("["), lines[1].hasSuffix("]"),
5481 let date = try? Date(String(lines[1].dropFirst().dropLast()), strategy: Self.orgDate.parseStrategy) else { return nil }
5582 var props: [String: String] = [:]
83 var other: [OrgProperty] = []
5684 var i = 2
5785 if i < lines.count, lines[i] == ":PROPERTIES:" {
5886 i += 1
5987 while i < lines.count, lines[i] != ":END:" {
60 let parts = lines[i].split(separator: " ", maxSplits: 1).map(String.init)
61 if parts.count == 2 { props[parts[0]] = parts[1] }
88 if let p = OrgProperty.parse(lines[i]) {
89 if !Self.knownKeys.contains(p.key) { other.append(p) } else if !p.value.isEmpty { props[p.key] = p.value }
90 }
6291 i += 1
6392 }
6493 i += 1
@@ -75,7 +104,8 @@ public struct Observation: Sendable, Identifiable, Equatable {
75104 equipment: props[":EQUIPMENT:"] ?? "",
76105 equipmentIDs: props[":EQUIPMENT_IDS:"]?.split(separator: " ").map(String.init) ?? [],
77106 seeing: props[":SEEING:"].flatMap { Int($0.prefix(1)) },
78 notes: notes
107 notes: notes,
108 otherProperties: other
79109 )
80110 }
81111}
Packages/MoonriseKit/Sources/SkyEngine/Events.swift +17 −3
@@ -136,14 +136,28 @@ extension SkyEngine {
136136 }
137137 }
138138
139 // Meteor showers, with how much moonlight there is at peak
139 // Meteor showers, with the Moon on the observer's night of the peak
140 func altitude(_ body: astro_body_t, _ d: Date) -> Double {
141 var t = time(d)
142 let eq = Astronomy_Equator(body, &t, observer.astro, EQUATOR_OF_DATE, ABERRATION)
143 return Astronomy_Horizon(&t, observer.astro, eq.ra, eq.dec, REFRACTION_NORMAL).altitude
144 }
140145 var utc = Calendar(identifier: .gregorian)
141146 utc.timeZone = TimeZone(identifier: "UTC")!
142147 for y in y0...(y0 + 1) {
143148 for m in MeteorShower.major {
144149 guard let d = utc.date(from: DateComponents(year: y, month: m.month, day: m.day, hour: 12)), d >= start, d <= end else { continue }
145 let lit = Astronomy_Illumination(BODY_MOON, time(d)).phase_fraction
146 out.append(SkyEvent(kind: .meteorShower, date: d, title: "\(m.name) peak", detail: String(format: "Up to about %d meteors an hour under dark skies. Moon %.0f%% lit.", m.zhr, lit * 100), visibleHere: nil))
150 // Local solar midnight after the evening of the peak date.
151 let midnight = d.addingTimeInterval((12 - observer.longitude / 15) * 3600)
152 let lit = Astronomy_Illumination(BODY_MOON, time(midnight)).phase_fraction
153 // Dark hours: Sun below -18°, or just midnight where it never gets that dark.
154 var dark = stride(from: -12.0, through: 12.0, by: 0.25).map { midnight.addingTimeInterval($0 * 3600) }.filter { altitude(BODY_SUN, $0) < -18 }
155 if dark.isEmpty { dark = [midnight] }
156 let up = dark.map { altitude(BODY_MOON, $0) > 0 }
157 let moon = !up.contains(true) ? "below the horizon during the dark hours"
158 : !up.contains(false) ? "up all through the dark hours"
159 : up[0] ? "sets during the dark hours" : "rises during the dark hours"
160 out.append(SkyEvent(kind: .meteorShower, date: d, title: "\(m.name) peak", detail: String(format: "Up to about %d meteors an hour under dark skies. Moon %.0f%% lit, %@.", m.zhr, lit * 100, moon), visibleHere: nil))
147161 }
148162 }
149163
Packages/MoonriseKit/Tests/ObservingLogTests/ObservingLogTests.swift +18
@@ -124,3 +124,21 @@ let when = Date(timeIntervalSince1970: 1_792_202_400) // 2026-10-17T02:00:00Z
124124 let none = EquipmentUsage(of: "camera", in: log, calendar: cal)
125125 #expect(none.observations == 0 && none.first == nil && none.averageSeeing == nil && none.usedWith.isEmpty)
126126}
127
128@Test func observationKeepsHandAddedProperties() throws {
129 let text = "* Saturn\n[2026-10-16 Fri 21:00]\n:PROPERTIES:\n:ID: abc\n:TRANSPARENCY: 4/5\n:PLACE: Chicago\n:CATEGORY: planet\n:BORTLE:\n:END:\n\nRings.\n"
130 var o = try #require(Observation(org: text))
131 o.notes = "Rings and Titan."
132 let out = o.org
133 #expect(out.contains(":PLACE: Chicago\n:TRANSPARENCY: 4/5\n:CATEGORY: planet\n:BORTLE:\n:END:\n"))
134 #expect(Observation(org: out) == o)
135}
136
137@Test func equipmentKeepsHandAddedProperties() throws {
138 let text = "* Dob\n:PROPERTIES:\n:ID: scope-1\n:TYPE: telescope\n:PURCHASED: 2019-05-02\n:APERTURE: 203 mm\n:FOCAL_RATIO: f/5.9\n:SERIAL: X123\n:END:\n"
139 var scope = try #require(Equipment(org: text))
140 scope.focalLength = 1200
141 let out = scope.org
142 #expect(out.contains(":FOCAL_RATIO: f/5.9\n:PURCHASED: 2019-05-02\n:SERIAL: X123\n:END:\n"))
143 #expect(Equipment(org: out) == scope)
144}
Packages/MoonriseKit/Tests/SkyEngineTests/EventsTests.swift +11
@@ -33,3 +33,14 @@ func utc(_ s: String) -> Date { try! Date(s, strategy: .iso8601) }
3333 let kinds = Set(events.map(\.kind))
3434 #expect(kinds.isSuperset(of: [.moonPhase, .lunarEclipse, .solarEclipse, .opposition, .elongation, .season, .meteorShower]))
3535}
36
37@Test func meteorShowerMoonIsForTheLocalNight() throws {
38 let events = SkyEngine.events(from: utc("2026-08-01T00:00:00Z"), days: 180, observer: chicago)
39 func detail(_ name: String) throws -> String { try #require(events.first { $0.title == "\(name) peak" }).detail }
40 // New moon on 12 August 2026.
41 #expect(try detail("Perseids").hasSuffix("Moon 0% lit, below the horizon during the dark hours."))
42 // 76% at 12:00 UTC on the 21st; 82% at Chicago midnight, setting about 03:00 CDT.
43 #expect(try detail("Orionids").hasSuffix("Moon 82% lit, sets during the dark hours."))
44 // 16% at 12:00 UTC on the 3rd; 11% at Chicago midnight, rising about 05:00 CST before dawn.
45 #expect(try detail("Quadrantids").hasSuffix("Moon 11% lit, rises during the dark hours."))
46}