M3: launches and countdown Live Activity !10
16 files changed, +584 −5
Layout: unified · split
App/Shared/LaunchActivity.swift added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | import ActivityKit | |
| 2 | import Foundation | |
| 3 | ||
| 4 | /// Live Activity for a followed launch. Shared by the app and the widget extension. | |
| 5 | struct LaunchActivityAttributes: ActivityAttributes { | |
| 6 | struct ContentState: Codable, Hashable { | |
| 7 | var net: Date | |
| 8 | /// Launch Library 2 status abbreviation. | |
| 9 | var status: String | |
| 10 | } | |
| 11 | ||
| 12 | var launchID: String | |
| 13 | var rocket: String | |
| 14 | var mission: String | |
| 15 | var location: String | |
| 16 | } | |
App/Sources/Info.plist added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | <?xml version="1.0" encoding="UTF-8"?> | |
| 2 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| 3 | <plist version="1.0"> | |
| 4 | <dict> | |
| 5 | <key>CFBundleDevelopmentRegion</key> | |
| 6 | <string>$(DEVELOPMENT_LANGUAGE)</string> | |
| 7 | <key>CFBundleExecutable</key> | |
| 8 | <string>$(EXECUTABLE_NAME)</string> | |
| 9 | <key>CFBundleIdentifier</key> | |
| 10 | <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> | |
| 11 | <key>CFBundleInfoDictionaryVersion</key> | |
| 12 | <string>6.0</string> | |
| 13 | <key>CFBundleName</key> | |
| 14 | <string>$(PRODUCT_NAME)</string> | |
| 15 | <key>CFBundlePackageType</key> | |
| 16 | <string>APPL</string> | |
| 17 | <key>CFBundleShortVersionString</key> | |
| 18 | <string>1.0</string> | |
| 19 | <key>CFBundleVersion</key> | |
| 20 | <string>1</string> | |
| 21 | <key>MoonriseAPIURL</key> | |
| 22 | <string>$(MOONRISE_API_URL)</string> | |
| 23 | <key>NSAppTransportSecurity</key> | |
| 24 | <dict> | |
| 25 | <key>NSAllowsLocalNetworking</key> | |
| 26 | <true/> | |
| 27 | </dict> | |
| 28 | </dict> | |
| 29 | </plist> | |
App/Sources/LaunchesScreen.swift added +207
| @@ -0,0 +1,207 @@ | ||
| 1 | import ActivityKit | |
| 2 | import Feeds | |
| 3 | import SwiftUI | |
| 4 | ||
| 5 | /// Backend base URL from Info.plist (MOONRISE_API_URL build setting); nil when unset. | |
| 6 | let apiBase: URL? = (Bundle.main.object(forInfoDictionaryKey: "MoonriseAPIURL") as? String) | |
| 7 | .flatMap { $0.isEmpty ? nil : URL(string: $0) } | |
| 8 | ||
| 9 | struct LaunchesScreen: View { | |
| 10 | @State private var list: LaunchList? | |
| 11 | @State private var error: String? | |
| 12 | @State private var selected: Launch? | |
| 13 | ||
| 14 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) | |
| 15 | private let accent = Color(red: 0.88, green: 0.24, blue: 0.19) | |
| 16 | private let background = Color(red: 0.043, green: 0.078, blue: 0.141) | |
| 17 | ||
| 18 | var body: some View { | |
| 19 | ScrollView { | |
| 20 | VStack(alignment: .leading, spacing: 0) { | |
| 21 | Text("Launches").font(.system(size: 28, weight: .medium)) | |
| 22 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 8) | |
| 23 | ||
| 24 | if let error { | |
| 25 | Text(error).font(.system(size: 13)).foregroundStyle(accent).padding(.vertical, 12) | |
| 26 | } | |
| 27 | if let list { | |
| 28 | ForEach(Array(list.launches.enumerated()), id: \.element.id) { i, launch in | |
| 29 | Button { selected = launch } label: { row(launch, index: i + 1) } | |
| 30 | .buttonStyle(.plain) | |
| 31 | } | |
| 32 | Text("Launch data: The Space Devs, Launch Library 2.") | |
| 33 | .font(.system(size: 10)).opacity(0.5).padding(.vertical, 16) | |
| 34 | } else if error == nil { | |
| 35 | ProgressView().tint(ink).padding(.top, 40).frame(maxWidth: .infinity) | |
| 36 | } | |
| 37 | } | |
| 38 | .padding(.horizontal, 20) | |
| 39 | } | |
| 40 | .foregroundStyle(ink) | |
| 41 | .background(background.ignoresSafeArea()) | |
| 42 | .refreshable { await load() } | |
| 43 | .task { await load() } | |
| 44 | .sheet(item: $selected) { LaunchDetail(launch: $0, ink: ink, accent: accent, background: background) } | |
| 45 | } | |
| 46 | ||
| 47 | private func load() async { | |
| 48 | guard let apiBase else { error = "No backend configured for this build."; return } | |
| 49 | do { | |
| 50 | list = try await LaunchFeed.fetch(base: apiBase) | |
| 51 | error = nil | |
| 52 | } catch { | |
| 53 | self.error = "Couldn't load launches. Pull to retry." | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | private func row(_ l: Launch, index: Int) -> some View { | |
| 58 | VStack(alignment: .leading, spacing: 6) { | |
| 59 | HStack(alignment: .firstTextBaseline) { | |
| 60 | Text(String(format: "%02d", index)).font(.system(size: 10).monospacedDigit()).opacity(0.5) | |
| 61 | Text(l.net.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute())) | |
| 62 | .font(.system(size: 11)).opacity(0.7) | |
| 63 | Spacer() | |
| 64 | StatusTag(status: l.status, ink: ink, accent: accent) | |
| 65 | } | |
| 66 | Text(l.rocket).font(.system(size: 22, weight: .medium)) | |
| 67 | if let m = l.mission { Text(m).font(.system(size: 14)).lineLimit(1) } | |
| 68 | HStack { | |
| 69 | Text("\(l.provider) · \(l.location)").font(.system(size: 11)).opacity(0.6).lineLimit(1) | |
| 70 | Spacer() | |
| 71 | Countdown(to: l.net).font(.system(size: 13, weight: .medium).monospacedDigit()) | |
| 72 | } | |
| 73 | } | |
| 74 | .padding(.vertical, 14) | |
| 75 | .overlay(alignment: .top) { Rectangle().fill(ink.opacity(0.25)).frame(height: 0.5) } | |
| 76 | .contentShape(Rectangle()) | |
| 77 | } | |
| 78 | } | |
| 79 | ||
| 80 | struct StatusTag: View { | |
| 81 | var status: String | |
| 82 | var ink: Color | |
| 83 | var accent: Color | |
| 84 | ||
| 85 | var body: some View { | |
| 86 | Text(status.uppercased()) | |
| 87 | .font(.system(size: 10, weight: .medium)) | |
| 88 | .tracking(0.6) | |
| 89 | .padding(.horizontal, 6).padding(.vertical, 2) | |
| 90 | .foregroundStyle(status == "Go" ? Color.black : ink) | |
| 91 | .background(status == "Go" ? ink : (status == "Hold" ? accent : ink.opacity(0.15)), in: .rect(cornerRadius: 2)) | |
| 92 | } | |
| 93 | } | |
| 94 | ||
| 95 | /// T− countdown, ticking each second; T+ once the time has passed. | |
| 96 | struct Countdown: View { | |
| 97 | var to: Date | |
| 98 | ||
| 99 | var body: some View { | |
| 100 | TimelineView(.periodic(from: .now, by: 1)) { ctx in | |
| 101 | Text(Self.format(to.timeIntervalSince(ctx.date))) | |
| 102 | } | |
| 103 | } | |
| 104 | ||
| 105 | static func format(_ seconds: TimeInterval) -> String { | |
| 106 | let s = Int(abs(seconds)) | |
| 107 | let sign = seconds >= 0 ? "T−" : "T+" | |
| 108 | let d = s / 86_400, h = (s % 86_400) / 3600, m = (s % 3600) / 60, sec = s % 60 | |
| 109 | return d > 0 ? String(format: "%@%dd %02d:%02d:%02d", sign, d, h, m, sec) : String(format: "%@%02d:%02d:%02d", sign, h, m, sec) | |
| 110 | } | |
| 111 | } | |
| 112 | ||
| 113 | struct LaunchDetail: View { | |
| 114 | var launch: Launch | |
| 115 | var ink: Color | |
| 116 | var accent: Color | |
| 117 | var background: Color | |
| 118 | ||
| 119 | var body: some View { | |
| 120 | ScrollView { | |
| 121 | VStack(alignment: .leading, spacing: 10) { | |
| 122 | HStack { | |
| 123 | StatusTag(status: launch.status, ink: ink, accent: accent) | |
| 124 | Text(launch.statusName).font(.system(size: 12)).opacity(0.7) | |
| 125 | } | |
| 126 | Text(launch.rocket).font(.system(size: 28, weight: .medium)) | |
| 127 | if let m = launch.mission { Text(m).font(.system(size: 17)) } | |
| 128 | Countdown(to: launch.net).font(.system(size: 34, weight: .medium).monospacedDigit()).padding(.vertical, 6) | |
| 129 | Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) { | |
| 130 | fact("Provider", launch.provider) | |
| 131 | fact("Launch time", launch.net.formatted(date: .abbreviated, time: .shortened)) | |
| 132 | if let a = launch.windowStart, let b = launch.windowEnd, a != b { | |
| 133 | fact("Window", "\(a.formatted(date: .omitted, time: .shortened))–\(b.formatted(date: .omitted, time: .shortened))") | |
| 134 | } | |
| 135 | if let t = launch.missionType { fact("Mission", t) } | |
| 136 | if let o = launch.orbit { fact("Orbit", o) } | |
| 137 | fact("Pad", launch.pad) | |
| 138 | fact("Location", launch.location) | |
| 139 | } | |
| 140 | .font(.system(size: 14)) | |
| 141 | followButton.padding(.top, 8) | |
| 142 | if let message { Text(message).font(.system(size: 12)).opacity(0.7) } | |
| 143 | if let d = launch.missionDescription { | |
| 144 | Text(d).font(.system(size: 14)).opacity(0.85).padding(.top, 8) | |
| 145 | } | |
| 146 | } | |
| 147 | .padding(20) | |
| 148 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 149 | } | |
| 150 | .foregroundStyle(ink) | |
| 151 | .presentationBackground(background) | |
| 152 | .presentationDetents([.medium, .large]) | |
| 153 | } | |
| 154 | ||
| 155 | @State private var message: String? | |
| 156 | @State private var following = false | |
| 157 | ||
| 158 | private var followButton: some View { | |
| 159 | Button { | |
| 160 | following ? unfollow() : follow() | |
| 161 | } label: { | |
| 162 | Label(following ? "Stop following" : "Follow on Lock Screen", systemImage: following ? "bell.slash" : "bell") | |
| 163 | .font(.system(size: 15, weight: .medium)) | |
| 164 | .frame(maxWidth: .infinity) | |
| 165 | .padding(.vertical, 10) | |
| 166 | .overlay(RoundedRectangle(cornerRadius: 6).stroke(ink.opacity(0.5), lineWidth: 0.5)) | |
| 167 | } | |
| 168 | .buttonStyle(.plain) | |
| 169 | .onAppear { | |
| 170 | following = Activity<LaunchActivityAttributes>.activities.contains { $0.attributes.launchID == launch.id } | |
| 171 | } | |
| 172 | } | |
| 173 | ||
| 174 | private func follow() { | |
| 175 | guard ActivityAuthorizationInfo().areActivitiesEnabled else { | |
| 176 | message = "Live Activities are turned off for Moonrise in Settings." | |
| 177 | return | |
| 178 | } | |
| 179 | let attributes = LaunchActivityAttributes(launchID: launch.id, rocket: launch.rocket, mission: launch.mission ?? launch.name, location: launch.location) | |
| 180 | let state = LaunchActivityAttributes.ContentState(net: launch.net, status: launch.status) | |
| 181 | do { | |
| 182 | _ = try Activity.request(attributes: attributes, content: .init(state: state, staleDate: launch.net.addingTimeInterval(3600))) | |
| 183 | following = true | |
| 184 | message = "Countdown added to the Lock Screen." | |
| 185 | } catch { | |
| 186 | message = "Couldn't start the countdown: \(error.localizedDescription)" | |
| 187 | } | |
| 188 | } | |
| 189 | ||
| 190 | private func unfollow() { | |
| 191 | let id = launch.id | |
| 192 | Task { | |
| 193 | for a in Activity<LaunchActivityAttributes>.activities where a.attributes.launchID == id { | |
| 194 | await a.end(nil, dismissalPolicy: .immediate) | |
| 195 | } | |
| 196 | following = false | |
| 197 | message = nil | |
| 198 | } | |
| 199 | } | |
| 200 | ||
| 201 | private func fact(_ label: String, _ value: String) -> some View { | |
| 202 | GridRow { | |
| 203 | Text(label).opacity(0.6) | |
| 204 | Text(value) | |
| 205 | } | |
| 206 | } | |
| 207 | } | |
App/Sources/MoonriseApp.swift +1
| @@ -13,6 +13,7 @@ struct MoonriseApp: App { | ||
| 13 | 13 | TabView { |
| 14 | 14 | Tab("Sky", systemImage: "sparkles") { SkyScreen() } |
| 15 | 15 | Tab("Sun", systemImage: "sun.max") { SunScreen() } |
| 16 | Tab("Launches", systemImage: "airplane.departure") { LaunchesScreen() } | |
| 16 | 17 | } |
| 17 | 18 | .preferredColorScheme(.dark) |
| 18 | 19 | } |
App/Widgets/LaunchLiveActivity.swift added +56
| @@ -0,0 +1,56 @@ | ||
| 1 | import ActivityKit | |
| 2 | import SwiftUI | |
| 3 | import WidgetKit | |
| 4 | ||
| 5 | struct LaunchLiveActivity: Widget { | |
| 6 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) | |
| 7 | private let accent = Color(red: 0.88, green: 0.24, blue: 0.19) | |
| 8 | ||
| 9 | var body: some WidgetConfiguration { | |
| 10 | ActivityConfiguration(for: LaunchActivityAttributes.self) { context in | |
| 11 | HStack(alignment: .center, spacing: 12) { | |
| 12 | VStack(alignment: .leading, spacing: 2) { | |
| 13 | Text(context.attributes.rocket).font(.system(size: 17, weight: .medium)) | |
| 14 | Text(context.attributes.mission).font(.system(size: 13)).lineLimit(1).opacity(0.8) | |
| 15 | Text(context.attributes.location).font(.system(size: 11)).lineLimit(1).opacity(0.6) | |
| 16 | } | |
| 17 | Spacer() | |
| 18 | VStack(alignment: .trailing, spacing: 2) { | |
| 19 | Text(context.state.status.uppercased()).font(.system(size: 10, weight: .medium)).tracking(0.6) | |
| 20 | .foregroundStyle(context.state.status == "Hold" ? accent : ink) | |
| 21 | HStack(spacing: 2) { | |
| 22 | Text(context.state.net > .now ? "T−" : "T+") | |
| 23 | countdown(context.state.net) | |
| 24 | } | |
| 25 | .font(.system(size: 22, weight: .medium).monospacedDigit()) | |
| 26 | } | |
| 27 | } | |
| 28 | .padding(16) | |
| 29 | .foregroundStyle(ink) | |
| 30 | .activityBackgroundTint(Color(red: 0.043, green: 0.078, blue: 0.141)) | |
| 31 | } dynamicIsland: { context in | |
| 32 | DynamicIsland { | |
| 33 | DynamicIslandExpandedRegion(.leading) { | |
| 34 | Text(context.attributes.rocket).font(.system(size: 14, weight: .medium)) | |
| 35 | } | |
| 36 | DynamicIslandExpandedRegion(.trailing) { | |
| 37 | countdown(context.state.net).font(.system(size: 14, weight: .medium).monospacedDigit()) | |
| 38 | } | |
| 39 | DynamicIslandExpandedRegion(.bottom) { | |
| 40 | Text(context.attributes.mission).font(.system(size: 12)).lineLimit(1).opacity(0.8) | |
| 41 | } | |
| 42 | } compactLeading: { | |
| 43 | Text("T−").font(.system(size: 12, weight: .medium)) | |
| 44 | } compactTrailing: { | |
| 45 | countdown(context.state.net).font(.system(size: 12).monospacedDigit()).frame(maxWidth: 64) | |
| 46 | } minimal: { | |
| 47 | Image(systemName: "arrow.up") | |
| 48 | } | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 52 | /// Counts down to T-0, then shows elapsed time. | |
| 53 | private func countdown(_ net: Date) -> Text { | |
| 54 | net > .now ? Text(timerInterval: Date.now...net, countsDown: true) : Text(net, style: .timer) | |
| 55 | } | |
| 56 | } | |
App/Widgets/MoonriseWidgets.swift +1
| @@ -9,6 +9,7 @@ struct MoonriseWidgets: WidgetBundle { | ||
| 9 | 9 | var body: some Widget { |
| 10 | 10 | MoonWidget() |
| 11 | 11 | PlanetsWidget() |
| 12 | LaunchLiveActivity() | |
| 12 | 13 | } |
| 13 | 14 | } |
| 14 | 15 | |
App/project.yml +14 −2
| @@ -10,7 +10,7 @@ targets: | ||
| 10 | 10 | Moonrise: |
| 11 | 11 | type: application |
| 12 | 12 | platform: iOS |
| 13 | sources: [Sources] | |
| 13 | sources: [Sources, Shared] | |
| 14 | 14 | dependencies: |
| 15 | 15 | - package: MoonriseKit |
| 16 | 16 | product: SkyEngine |
| @@ -23,7 +23,18 @@ targets: | ||
| 23 | 23 | path: Sources/Moonrise.entitlements |
| 24 | 24 | properties: |
| 25 | 25 | aps-environment: development |
| 26 | info: | |
| 27 | path: Sources/Info.plist | |
| 28 | properties: | |
| 29 | MoonriseAPIURL: $(MOONRISE_API_URL) | |
| 30 | NSAppTransportSecurity: | |
| 31 | NSAllowsLocalNetworking: true | |
| 26 | 32 | settings: |
| 33 | configs: | |
| 34 | Debug: | |
| 35 | MOONRISE_API_URL: http://localhost:8787 | |
| 36 | Release: | |
| 37 | MOONRISE_API_URL: "" | |
| 27 | 38 | base: |
| 28 | 39 | PRODUCT_BUNDLE_IDENTIFIER: sh.krz.moonrise |
| 29 | 40 | DEVELOPMENT_TEAM: ZCNAX3VL9D |
| @@ -34,6 +45,7 @@ targets: | ||
| 34 | 45 | CURRENT_PROJECT_VERSION: "1" |
| 35 | 46 | GENERATE_INFOPLIST_FILE: YES |
| 36 | 47 | INFOPLIST_KEY_CFBundleDisplayName: Moonrise |
| 48 | INFOPLIST_KEY_NSSupportsLiveActivities: YES | |
| 37 | 49 | INFOPLIST_KEY_UILaunchScreen_Generation: YES |
| 38 | 50 | INFOPLIST_KEY_UIStatusBarStyle: UIStatusBarStyleLightContent |
| 39 | 51 | INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait |
| @@ -41,7 +53,7 @@ targets: | ||
| 41 | 53 | MoonriseWidgets: |
| 42 | 54 | type: app-extension |
| 43 | 55 | platform: iOS |
| 44 | sources: [Widgets] | |
| 56 | sources: [Widgets, Shared] | |
| 45 | 57 | dependencies: |
| 46 | 58 | - package: MoonriseKit |
| 47 | 59 | product: SkyEngine |
PLAN.org +15
| @@ -346,6 +346,21 @@ Stacked MRs: !7 Sun tab, !8 backend, !9 aurora map. | ||
| 346 | 346 | - Not done: app registration with the backend (needs a deployed URL), |
| 347 | 347 | launch caching, Live Activities. |
| 348 | 348 | |
| 349 | ** M3 status (2026-10-17) | |
| 350 | MR !10. | |
| 351 | ||
| 352 | - Backend: Launch Library 2 upcoming list every 10 minutes (separate cron, | |
| 353 | 6 of 15 anonymous requests per hour) into D1; =GET /v1/launches=. | |
| 354 | Smoke-tested locally with live LL2 data. LL2 images are not used | |
| 355 | (licence often "Unknown"). | |
| 356 | - App: Launches tab (list with T− countdowns, detail sheet); Debug builds | |
| 357 | read the API from =http://localhost:8787= (=MOONRISE_API_URL=). | |
| 358 | - Live Activity: "Follow on Lock Screen" starts a local countdown | |
| 359 | activity; verified on the simulator Lock Screen. Updates by push | |
| 360 | (holds, scrubs, liftoff) not done. | |
| 361 | - Not done: rocket silhouettes to scale, push-to-start and push updates, | |
| 362 | "launch in one hour" notification. | |
| 363 | ||
| 349 | 364 | * roadmap |
| 350 | 365 | ** v1: now |
| 351 | 366 | Sky, System, Sun, Launches, Library core (above). |
Packages/MoonriseKit/Sources/Feeds/Launches.swift added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// An upcoming launch as served by the Moonrise backend (from Launch Library 2). | |
| 4 | public struct Launch: Codable, Sendable, Identifiable, Hashable { | |
| 5 | public var id: String | |
| 6 | public var name: String | |
| 7 | public var net: Date | |
| 8 | public var windowStart: Date? | |
| 9 | public var windowEnd: Date? | |
| 10 | /// Launch Library 2 status abbreviation: Go, TBC, TBD, Hold, Success, Failure, … | |
| 11 | public var status: String | |
| 12 | public var statusName: String | |
| 13 | public var provider: String | |
| 14 | public var rocket: String | |
| 15 | public var mission: String? | |
| 16 | public var missionType: String? | |
| 17 | public var missionDescription: String? | |
| 18 | public var orbit: String? | |
| 19 | public var pad: String | |
| 20 | public var location: String | |
| 21 | public var padLatitude: Double? | |
| 22 | public var padLongitude: Double? | |
| 23 | } | |
| 24 | ||
| 25 | public struct LaunchList: Codable, Sendable { | |
| 26 | public var fetchedAt: Date? | |
| 27 | public var launches: [Launch] | |
| 28 | } | |
| 29 | ||
| 30 | public enum LaunchFeed { | |
| 31 | static let decoder: JSONDecoder = { | |
| 32 | let d = JSONDecoder() | |
| 33 | d.dateDecodingStrategy = .custom { decoder in | |
| 34 | let s = try decoder.singleValueContainer().decode(String.self) | |
| 35 | if let date = try? Date(s, strategy: .iso8601) { return date } | |
| 36 | if let date = try? Date(s, strategy: Date.ISO8601FormatStyle(includingFractionalSeconds: true)) { return date } | |
| 37 | throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "bad date \(s)")) | |
| 38 | } | |
| 39 | return d | |
| 40 | }() | |
| 41 | ||
| 42 | public static func decode(_ data: Data) throws -> LaunchList { | |
| 43 | try decoder.decode(LaunchList.self, from: data) | |
| 44 | } | |
| 45 | ||
| 46 | public static func fetch(base: URL, session: URLSession = .shared) async throws -> LaunchList { | |
| 47 | try decode(await session.data(from: base.appending(path: "v1/launches")).0) | |
| 48 | } | |
| 49 | } | |
Packages/MoonriseKit/Tests/FeedsTests/LaunchTests.swift added +21
| @@ -0,0 +1,21 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import Feeds | |
| 4 | ||
| 5 | @Test func decodesBackendLaunches() throws { | |
| 6 | // Shape of GET /v1/launches from the local Worker, 2026-10-17. | |
| 7 | let data = Data(""" | |
| 8 | {"fetchedAt":"2026-10-17T23:54:22.559Z","launches":[{"id":"3b430f81","name":"Falcon 9 Block 5 | Bandwagon 5","net":"2026-10-19T10:30:00Z", | |
| 9 | "windowStart":"2026-10-19T10:30:00Z","windowEnd":"2026-10-19T11:30:00Z","status":"Go","statusName":"Go for Launch","provider":"SpaceX", | |
| 10 | "rocket":"Falcon 9 Block 5","mission":"Bandwagon 5","missionType":"Dedicated Rideshare","missionDescription":"Rideshare.","orbit":"LEO", | |
| 11 | "pad":"Space Launch Complex 40","location":"Cape Canaveral SFS, FL, USA","padLatitude":28.56194122,"padLongitude":-80.57735736}, | |
| 12 | {"id":"x","name":"Electron | HawkEye","net":"2026-10-21T05:00:00Z","windowStart":null,"windowEnd":null,"status":"TBC","statusName":"To Be Confirmed", | |
| 13 | "provider":"Rocket Lab","rocket":"Electron","mission":null,"missionType":null,"missionDescription":null,"orbit":null,"pad":"LC-1B","location":"Mahia","padLatitude":null,"padLongitude":null}]} | |
| 14 | """.utf8) | |
| 15 | let list = try LaunchFeed.decode(data) | |
| 16 | #expect(list.launches.count == 2) | |
| 17 | #expect(list.launches[0].rocket == "Falcon 9 Block 5") | |
| 18 | #expect(list.launches[0].net == (try Date("2026-10-19T10:30:00Z", strategy: .iso8601))) | |
| 19 | #expect(list.launches[1].mission == nil) | |
| 20 | #expect(list.fetchedAt != nil) | |
| 21 | } | |
THIRD_PARTY.org +1
| @@ -6,3 +6,4 @@ | ||
| 6 | 6 | | Yale Bright Star Catalogue, 5th rev. ed. (Hoffleit & Warren 1991) | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/bsc5.json= | See VizieR policy; licence statement pending | https://cdsarc.cds.unistra.fr/viz-bin/cat/V/50 | |
| 7 | 7 | | ConstellationLines v1.4, Copyright (c) 2005-2026 Marc van der Sluys | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/constellations.json= | CC BY 4.0 | https://github.com/MarcvdSluys/ConstellationLines, doi:10.5281/zenodo.10397192 | |
| 8 | 8 | | Natural Earth 1:110m coastline v5.1.2 | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/coastline.json= | Public domain | https://www.naturalearthdata.com | |
| 9 | | Launch Library 2 (The Space Devs) | fetched at runtime by =server/= | Free API; credit shown in the app | https://thespacedevs.com/llapi | | |
server/migrations/0002_launches.sql added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | -- Upcoming launches, normalized from Launch Library 2. | |
| 2 | CREATE TABLE launches ( | |
| 3 | id TEXT PRIMARY KEY, | |
| 4 | name TEXT NOT NULL, | |
| 5 | net TEXT NOT NULL, | |
| 6 | window_start TEXT, | |
| 7 | window_end TEXT, | |
| 8 | status TEXT NOT NULL, | |
| 9 | status_name TEXT NOT NULL, | |
| 10 | provider TEXT NOT NULL, | |
| 11 | rocket TEXT NOT NULL, | |
| 12 | mission TEXT, | |
| 13 | mission_type TEXT, | |
| 14 | mission_description TEXT, | |
| 15 | orbit TEXT, | |
| 16 | pad TEXT NOT NULL, | |
| 17 | location TEXT NOT NULL, | |
| 18 | pad_latitude REAL, | |
| 19 | pad_longitude REAL, | |
| 20 | last_updated TEXT NOT NULL | |
| 21 | ); | |
| 22 | ||
| 23 | CREATE INDEX launches_net ON launches (net); | |
server/src/index.ts +46 −2
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { send, type ApnsConfig } from "./apns"; |
| 2 | 2 | import { actionableKp, alertBands, eventKey, isNight } from "./aurora"; |
| 3 | import { fetchUpcoming, type Launch } from "./ll2"; | |
| 3 | 4 | import { fetchSpaceWeather, next24h, type SpaceWeather } from "./swpc"; |
| 4 | 5 | |
| 5 | 6 | interface Env { |
| @@ -18,6 +19,8 @@ interface DeviceRequest { | ||
| 18 | 19 | aurora?: boolean; |
| 19 | 20 | } |
| 20 | 21 | |
| 22 | const LAUNCH_CRON = "*/10 * * * *"; | |
| 23 | ||
| 21 | 24 | const json = (body: unknown, status = 200) => Response.json(body, { status }); |
| 22 | 25 | |
| 23 | 26 | function validDevice(d: Partial<DeviceRequest>): d is DeviceRequest { |
| @@ -49,6 +52,7 @@ export default { | ||
| 49 | 52 | return json({ ok: true, feeds: rows.results }); |
| 50 | 53 | } |
| 51 | 54 | if (request.method === "GET" && url.pathname === "/v1/today") return today(env, now); |
| 55 | if (request.method === "GET" && url.pathname === "/v1/launches") return launches(env, now); | |
| 52 | 56 | |
| 53 | 57 | if (request.method === "POST" && url.pathname === "/v1/devices") { |
| 54 | 58 | const d = (await request.json().catch(() => ({}))) as Partial<DeviceRequest>; |
| @@ -69,11 +73,51 @@ export default { | ||
| 69 | 73 | return json({ error: "not found" }, 404); |
| 70 | 74 | }, |
| 71 | 75 | |
| 72 | async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> { | |
| 73 | ctx.waitUntil(pollSpaceWeather(env, new Date())); | |
| 76 | async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> { | |
| 77 | const now = new Date(controller.scheduledTime); | |
| 78 | // Launches every 10 minutes: 6 of the 15 anonymous Launch Library 2 requests per hour. | |
| 79 | if (controller.cron === LAUNCH_CRON) ctx.waitUntil(pollLaunches(env, now)); | |
| 80 | else ctx.waitUntil(pollSpaceWeather(env, now)); | |
| 74 | 81 | }, |
| 75 | 82 | } satisfies ExportedHandler<Env>; |
| 76 | 83 | |
| 84 | async function launches(env: Env, now: Date): Promise<Response> { | |
| 85 | const since = new Date(now.getTime() - 6 * 3600_000).toISOString(); | |
| 86 | const rows = await env.DB.prepare("SELECT * FROM launches WHERE net >= ?1 ORDER BY net LIMIT 20").bind(since).all<Record<string, unknown>>(); | |
| 87 | const fetched = await env.DB.prepare("SELECT fetched_at FROM feed_snapshots WHERE feed = 'll2' AND ok = 1").first<{ fetched_at: string }>(); | |
| 88 | return json({ | |
| 89 | fetchedAt: fetched?.fetched_at ?? null, | |
| 90 | launches: rows.results.map((r) => ({ | |
| 91 | id: r.id, name: r.name, net: r.net, windowStart: r.window_start, windowEnd: r.window_end, | |
| 92 | status: r.status, statusName: r.status_name, provider: r.provider, rocket: r.rocket, | |
| 93 | mission: r.mission, missionType: r.mission_type, missionDescription: r.mission_description, orbit: r.orbit, | |
| 94 | pad: r.pad, location: r.location, padLatitude: r.pad_latitude, padLongitude: r.pad_longitude, | |
| 95 | })), | |
| 96 | }); | |
| 97 | } | |
| 98 | ||
| 99 | async function pollLaunches(env: Env, now: Date): Promise<void> { | |
| 100 | let list: Launch[]; | |
| 101 | try { | |
| 102 | list = await fetchUpcoming(); | |
| 103 | } catch (e) { | |
| 104 | console.error("ll2", String(e)); | |
| 105 | await env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok) VALUES ('ll2', ?1, 0) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1, ok = 0").bind(now.toISOString()).run(); | |
| 106 | return; | |
| 107 | } | |
| 108 | const upsert = env.DB.prepare( | |
| 109 | `INSERT INTO launches (id, name, net, window_start, window_end, status, status_name, provider, rocket, mission, mission_type, mission_description, orbit, pad, location, pad_latitude, pad_longitude, last_updated) | |
| 110 | VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18) | |
| 111 | ON CONFLICT (id) DO UPDATE SET name = ?2, net = ?3, window_start = ?4, window_end = ?5, status = ?6, status_name = ?7, provider = ?8, rocket = ?9, | |
| 112 | mission = ?10, mission_type = ?11, mission_description = ?12, orbit = ?13, pad = ?14, location = ?15, pad_latitude = ?16, pad_longitude = ?17, last_updated = ?18`, | |
| 113 | ); | |
| 114 | await env.DB.batch([ | |
| 115 | ...list.map((l) => upsert.bind(l.id, l.name, l.net, l.windowStart, l.windowEnd, l.status, l.statusName, l.provider, l.rocket, l.mission, l.missionType, l.missionDescription, l.orbit, l.pad, l.location, l.padLatitude, l.padLongitude, l.lastUpdated)), | |
| 116 | env.DB.prepare("DELETE FROM launches WHERE net < ?1").bind(new Date(now.getTime() - 7 * 86400_000).toISOString()), | |
| 117 | env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok) VALUES ('ll2', ?1, 1) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1, ok = 1").bind(now.toISOString()), | |
| 118 | ]); | |
| 119 | } | |
| 120 | ||
| 77 | 121 | async function pollSpaceWeather(env: Env, now: Date): Promise<void> { |
| 78 | 122 | let w: SpaceWeather; |
| 79 | 123 | try { |
server/src/ll2.ts added +71
| @@ -0,0 +1,71 @@ | ||
| 1 | // Launch Library 2 (The Space Devs). Anonymous limit: 15 requests per hour. | |
| 2 | // Images are not used: their licences are often "Unknown". | |
| 3 | ||
| 4 | export const UPCOMING_URL = "https://ll.thespacedevs.com/2.3.0/launches/upcoming/?limit=20&mode=normal"; | |
| 5 | ||
| 6 | export interface Launch { | |
| 7 | id: string; | |
| 8 | name: string; | |
| 9 | net: string; | |
| 10 | windowStart: string | null; | |
| 11 | windowEnd: string | null; | |
| 12 | status: string; | |
| 13 | statusName: string; | |
| 14 | provider: string; | |
| 15 | rocket: string; | |
| 16 | mission: string | null; | |
| 17 | missionType: string | null; | |
| 18 | missionDescription: string | null; | |
| 19 | orbit: string | null; | |
| 20 | pad: string; | |
| 21 | location: string; | |
| 22 | padLatitude: number | null; | |
| 23 | padLongitude: number | null; | |
| 24 | lastUpdated: string; | |
| 25 | } | |
| 26 | ||
| 27 | interface Raw { | |
| 28 | id: string; | |
| 29 | name: string; | |
| 30 | net: string; | |
| 31 | window_start?: string | null; | |
| 32 | window_end?: string | null; | |
| 33 | last_updated: string; | |
| 34 | status: { abbrev: string; name: string }; | |
| 35 | launch_service_provider: { name: string }; | |
| 36 | rocket: { configuration: { full_name?: string; name: string } }; | |
| 37 | mission?: { name?: string; type?: string; description?: string; orbit?: { abbrev?: string } | null } | null; | |
| 38 | pad: { name: string; latitude?: string | number | null; longitude?: string | number | null; location: { name: string } }; | |
| 39 | } | |
| 40 | ||
| 41 | const num = (v: string | number | null | undefined) => (v == null || v === "" ? null : Number(v)); | |
| 42 | ||
| 43 | export function normalize(r: Raw): Launch { | |
| 44 | return { | |
| 45 | id: r.id, | |
| 46 | name: r.name, | |
| 47 | net: r.net, | |
| 48 | windowStart: r.window_start ?? null, | |
| 49 | windowEnd: r.window_end ?? null, | |
| 50 | status: r.status.abbrev, | |
| 51 | statusName: r.status.name, | |
| 52 | provider: r.launch_service_provider.name, | |
| 53 | rocket: r.rocket.configuration.full_name ?? r.rocket.configuration.name, | |
| 54 | mission: r.mission?.name ?? null, | |
| 55 | missionType: r.mission?.type ?? null, | |
| 56 | missionDescription: r.mission?.description?.trim() ?? null, | |
| 57 | orbit: r.mission?.orbit?.abbrev ?? null, | |
| 58 | pad: r.pad.name, | |
| 59 | location: r.pad.location.name, | |
| 60 | padLatitude: num(r.pad.latitude), | |
| 61 | padLongitude: num(r.pad.longitude), | |
| 62 | lastUpdated: r.last_updated, | |
| 63 | }; | |
| 64 | } | |
| 65 | ||
| 66 | export async function fetchUpcoming(): Promise<Launch[]> { | |
| 67 | const res = await fetch(UPCOMING_URL, { headers: { "user-agent": "moonrise-server (sh.krz.moonrise)" } }); | |
| 68 | if (!res.ok) throw new Error(`LL2 ${res.status}`); | |
| 69 | const body = (await res.json()) as { results: Raw[] }; | |
| 70 | return body.results.map(normalize); | |
| 71 | } | |
server/test/ll2.test.ts added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | import assert from "node:assert/strict"; | |
| 2 | import { test } from "node:test"; | |
| 3 | import { normalize } from "../src/ll2.ts"; | |
| 4 | ||
| 5 | // Trimmed from /2.3.0/launches/upcoming/?mode=normal on 2026-10-17. | |
| 6 | const raw = { | |
| 7 | id: "3b430f81-0ff1-4d85-8a1a-cef0de326198", | |
| 8 | name: "Falcon 9 Block 5 | Bandwagon 5 (Dedicated Mid-Inclination Rideshare)", | |
| 9 | net: "2026-10-19T10:30:00Z", | |
| 10 | window_start: "2026-10-19T10:30:00Z", | |
| 11 | window_end: "2026-10-19T11:30:00Z", | |
| 12 | last_updated: "2026-10-17T20:00:00Z", | |
| 13 | status: { id: 1, name: "Go for Launch", abbrev: "Go" }, | |
| 14 | launch_service_provider: { name: "SpaceX", abbrev: "SpX" }, | |
| 15 | rocket: { configuration: { name: "Falcon 9", full_name: "Falcon 9 Block 5" } }, | |
| 16 | mission: { name: "Bandwagon 5", type: "Dedicated Rideshare", description: " Rideshare. ", orbit: { abbrev: "LEO" } }, | |
| 17 | pad: { name: "Space Launch Complex 40", latitude: "28.56194122", longitude: "-80.57735736", location: { name: "Cape Canaveral SFS, FL, USA" } }, | |
| 18 | }; | |
| 19 | ||
| 20 | test("normalizes a launch", () => { | |
| 21 | const l = normalize(raw); | |
| 22 | assert.equal(l.rocket, "Falcon 9 Block 5"); | |
| 23 | assert.equal(l.status, "Go"); | |
| 24 | assert.equal(l.orbit, "LEO"); | |
| 25 | assert.equal(l.missionDescription, "Rideshare."); | |
| 26 | assert.equal(l.padLatitude, 28.56194122); | |
| 27 | }); | |
| 28 | ||
| 29 | test("tolerates a missing mission", () => { | |
| 30 | const l = normalize({ ...raw, mission: null }); | |
| 31 | assert.equal(l.mission, null); | |
| 32 | assert.equal(l.orbit, null); | |
| 33 | }); | |
server/wrangler.jsonc +1 −1
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | "name": "moonrise-server", |
| 3 | 3 | "main": "src/index.ts", |
| 4 | 4 | "compatibility_date": "2026-10-01", |
| 5 | "triggers": { "crons": ["*/5 * * * *"] }, | |
| 5 | "triggers": { "crons": ["*/5 * * * *", "*/10 * * * *"] }, | |
| 6 | 6 | "d1_databases": [ |
| 7 | 7 | { |
| 8 | 8 | "binding": "DB", |