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 d0a7fec18d

d0a7fec18d4a25d97b68ed1576c8319087c7d5ea

parent: beee32bb9b

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-17 23:58 UTC

Add launches: backend cache, Launches tab and countdown Live Activity

- server: launches table, Launch Library 2 poll on its own 10-minute
  cron, GET /v1/launches (next 20, from 6 hours ago).
- Feeds: Launch model and backend client.
- App: Launches tab with T- countdowns and a detail sheet; Debug builds
  use http://localhost:8787 (MOONRISE_API_URL, ATS local networking).
- Live Activity (Lock Screen and Dynamic Island) started from "Follow on
  Lock Screen"; attributes shared by app and widget extension.
- PLAN.org M3 status, THIRD_PARTY.org.

Layout: unified · split

App/Shared/LaunchActivity.swift added +16
@@ -0,0 +1,16 @@
1import ActivityKit
2import Foundation
3
4/// Live Activity for a followed launch. Shared by the app and the widget extension.
5struct 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 @@
1import ActivityKit
2import Feeds
3import SwiftUI
4
5/// Backend base URL from Info.plist (MOONRISE_API_URL build setting); nil when unset.
6let apiBase: URL? = (Bundle.main.object(forInfoDictionaryKey: "MoonriseAPIURL") as? String)
7 .flatMap { $0.isEmpty ? nil : URL(string: $0) }
8
9struct 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
80struct 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.
96struct 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
113struct 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 TabView { 13 TabView {
14 Tab("Sky", systemImage: "sparkles") { SkyScreen() } 14 Tab("Sky", systemImage: "sparkles") { SkyScreen() }
15 Tab("Sun", systemImage: "sun.max") { SunScreen() } 15 Tab("Sun", systemImage: "sun.max") { SunScreen() }
16 Tab("Launches", systemImage: "airplane.departure") { LaunchesScreen() }
16 } 17 }
17 .preferredColorScheme(.dark) 18 .preferredColorScheme(.dark)
18 } 19 }
App/Widgets/LaunchLiveActivity.swift added +56
@@ -0,0 +1,56 @@
1import ActivityKit
2import SwiftUI
3import WidgetKit
4
5struct 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 var body: some Widget { 9 var body: some Widget {
10 MoonWidget() 10 MoonWidget()
11 PlanetsWidget() 11 PlanetsWidget()
12 LaunchLiveActivity()
12 } 13 }
13} 14}
14 15
App/project.yml +14 −2
@@ -10,7 +10,7 @@ targets:
10 Moonrise: 10 Moonrise:
11 type: application 11 type: application
12 platform: iOS 12 platform: iOS
13 sources: [Sources] 13 sources: [Sources, Shared]
14 dependencies: 14 dependencies:
15 - package: MoonriseKit 15 - package: MoonriseKit
16 product: SkyEngine 16 product: SkyEngine
@@ -23,7 +23,18 @@ targets:
23 path: Sources/Moonrise.entitlements 23 path: Sources/Moonrise.entitlements
24 properties: 24 properties:
25 aps-environment: development 25 aps-environment: development
26 info:
27 path: Sources/Info.plist
28 properties:
29 MoonriseAPIURL: $(MOONRISE_API_URL)
30 NSAppTransportSecurity:
31 NSAllowsLocalNetworking: true
26 settings: 32 settings:
33 configs:
34 Debug:
35 MOONRISE_API_URL: http://localhost:8787
36 Release:
37 MOONRISE_API_URL: ""
27 base: 38 base:
28 PRODUCT_BUNDLE_IDENTIFIER: sh.krz.moonrise 39 PRODUCT_BUNDLE_IDENTIFIER: sh.krz.moonrise
29 DEVELOPMENT_TEAM: ZCNAX3VL9D 40 DEVELOPMENT_TEAM: ZCNAX3VL9D
@@ -34,6 +45,7 @@ targets:
34 CURRENT_PROJECT_VERSION: "1" 45 CURRENT_PROJECT_VERSION: "1"
35 GENERATE_INFOPLIST_FILE: YES 46 GENERATE_INFOPLIST_FILE: YES
36 INFOPLIST_KEY_CFBundleDisplayName: Moonrise 47 INFOPLIST_KEY_CFBundleDisplayName: Moonrise
48 INFOPLIST_KEY_NSSupportsLiveActivities: YES
37 INFOPLIST_KEY_UILaunchScreen_Generation: YES 49 INFOPLIST_KEY_UILaunchScreen_Generation: YES
38 INFOPLIST_KEY_UIStatusBarStyle: UIStatusBarStyleLightContent 50 INFOPLIST_KEY_UIStatusBarStyle: UIStatusBarStyleLightContent
39 INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait 51 INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait
@@ -41,7 +53,7 @@ targets:
41 MoonriseWidgets: 53 MoonriseWidgets:
42 type: app-extension 54 type: app-extension
43 platform: iOS 55 platform: iOS
44 sources: [Widgets] 56 sources: [Widgets, Shared]
45 dependencies: 57 dependencies:
46 - package: MoonriseKit 58 - package: MoonriseKit
47 product: SkyEngine 59 product: SkyEngine
PLAN.org +15
@@ -346,6 +346,21 @@ Stacked MRs: !7 Sun tab, !8 backend, !9 aurora map.
346- Not done: app registration with the backend (needs a deployed URL), 346- Not done: app registration with the backend (needs a deployed URL),
347 launch caching, Live Activities. 347 launch caching, Live Activities.
348 348
349** M3 status (2026-10-17)
350MR !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* roadmap 364* roadmap
350** v1: now 365** v1: now
351Sky, System, Sun, Launches, Library core (above). 366Sky, System, Sun, Launches, Library core (above).
Packages/MoonriseKit/Sources/Feeds/Launches.swift added +49
@@ -0,0 +1,49 @@
1import Foundation
2
3/// An upcoming launch as served by the Moonrise backend (from Launch Library 2).
4public 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
25public struct LaunchList: Codable, Sendable {
26 public var fetchedAt: Date?
27 public var launches: [Launch]
28}
29
30public 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 @@
1import Foundation
2import 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| 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 | 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| 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 | 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| Natural Earth 1:110m coastline v5.1.2 | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/coastline.json= | Public domain | https://www.naturalearthdata.com | 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.
2CREATE 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
23CREATE INDEX launches_net ON launches (net);
server/src/index.ts +46 −2
@@ -1,5 +1,6 @@
1import { send, type ApnsConfig } from "./apns"; 1import { send, type ApnsConfig } from "./apns";
2import { actionableKp, alertBands, eventKey, isNight } from "./aurora"; 2import { actionableKp, alertBands, eventKey, isNight } from "./aurora";
3import { fetchUpcoming, type Launch } from "./ll2";
3import { fetchSpaceWeather, next24h, type SpaceWeather } from "./swpc"; 4import { fetchSpaceWeather, next24h, type SpaceWeather } from "./swpc";
4 5
5interface Env { 6interface Env {
@@ -18,6 +19,8 @@ interface DeviceRequest {
18 aurora?: boolean; 19 aurora?: boolean;
19} 20}
20 21
22const LAUNCH_CRON = "*/10 * * * *";
23
21const json = (body: unknown, status = 200) => Response.json(body, { status }); 24const json = (body: unknown, status = 200) => Response.json(body, { status });
22 25
23function validDevice(d: Partial<DeviceRequest>): d is DeviceRequest { 26function validDevice(d: Partial<DeviceRequest>): d is DeviceRequest {
@@ -49,6 +52,7 @@ export default {
49 return json({ ok: true, feeds: rows.results }); 52 return json({ ok: true, feeds: rows.results });
50 } 53 }
51 if (request.method === "GET" && url.pathname === "/v1/today") return today(env, now); 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 if (request.method === "POST" && url.pathname === "/v1/devices") { 57 if (request.method === "POST" && url.pathname === "/v1/devices") {
54 const d = (await request.json().catch(() => ({}))) as Partial<DeviceRequest>; 58 const d = (await request.json().catch(() => ({}))) as Partial<DeviceRequest>;
@@ -69,11 +73,51 @@ export default {
69 return json({ error: "not found" }, 404); 73 return json({ error: "not found" }, 404);
70 }, 74 },
71 75
72 async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> { 76 async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
73 ctx.waitUntil(pollSpaceWeather(env, new Date())); 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} satisfies ExportedHandler<Env>; 82} satisfies ExportedHandler<Env>;
76 83
84async 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
99async 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
77async function pollSpaceWeather(env: Env, now: Date): Promise<void> { 121async function pollSpaceWeather(env: Env, now: Date): Promise<void> {
78 let w: SpaceWeather; 122 let w: SpaceWeather;
79 try { 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
4export const UPCOMING_URL = "https://ll.thespacedevs.com/2.3.0/launches/upcoming/?limit=20&mode=normal";
5
6export 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
27interface 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
41const num = (v: string | number | null | undefined) => (v == null || v === "" ? null : Number(v));
42
43export 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
66export 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 @@
1import assert from "node:assert/strict";
2import { test } from "node:test";
3import { normalize } from "../src/ll2.ts";
4
5// Trimmed from /2.3.0/launches/upcoming/?mode=normal on 2026-10-17.
6const 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
20test("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
29test("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 "name": "moonrise-server", 2 "name": "moonrise-server",
3 "main": "src/index.ts", 3 "main": "src/index.ts",
4 "compatibility_date": "2026-10-01", 4 "compatibility_date": "2026-10-01",
5 "triggers": { "crons": ["*/5 * * * *"] }, 5 "triggers": { "crons": ["*/5 * * * *", "*/10 * * * *"] },
6 "d1_databases": [ 6 "d1_databases": [
7 { 7 {
8 "binding": "DB", 8 "binding": "DB",