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 365129e599

365129e59955addbec6a89f274ff6b20c808541e

parent: 3b8c40e96a

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-19 21:42 UTC

Launch payload first pass and Starlink train labels

- Migration 0004 adds launches.launch_designator. LL2 previous launches (limit 10) are polled on the first 10-minute run of each hour; launches are kept 14 days after NET. /v1/launches adds "recent" (NET before now - 6 h) and per-launch "elements" and "elementsSource".
- Satellite cron: after the two GP groups, up to 4 SupGP files (starlink-gX-Y) for SpaceX Starlink launches from 3 days ago to 48 h ahead. Non-JSON 200 replies are no data; any non-200 stops further requests and the last good "supgp" snapshot is used. SupGP sets are tied to launches by file name and epoch (0-3 h after NET before launch), not OBJECT_ID.
- Per launch, GP objects matched by designator or SupGP sets, whichever is newer: a Starlink train plus rocket bodies and other objects, no debris, at most 8. Trains are named "Starlink train (Group 15-25)" from the mission.
- App: Recent section in Launches; launch detail shows the first visible pass and two later passes of the same object, computed on the device, with an opt-in local notification 15 minutes before.

Layout: unified · split

App/Sources/LaunchPassNotifications.swift added +76
@@ -0,0 +1,76 @@
1import Feeds
2import SkyEngine
3import SwiftUI
4import UserNotifications
5
6/// Opt-in local notifications 15 minutes before a launch's first visible pass. Passes are worked out on the
7/// device and replaced whenever the launch list is loaded.
8@MainActor
9enum LaunchPassNotifications {
10 static let followedKey = "launchPassFollowed"
11 static let lead: TimeInterval = 15 * 60
12
13 static var followed: Set<String> {
14 get { Set(UserDefaults.standard.stringArray(forKey: followedKey) ?? []) }
15 set { UserDefaults.standard.set(Array(newValue), forKey: followedKey) }
16 }
17
18 private static func id(_ launch: Launch) -> String { "launch-pass-\(launch.id)" }
19
20 nonisolated static func passes(of launch: Launch, observer: Observer) async -> [SatellitePass] {
21 let elements = launch.elements ?? []
22 return await Task.detached { SkyEngine.visiblePasses(of: elements, from: .now, observer: observer) }.value
23 }
24
25 static func follow(_ launch: Launch) async -> Bool {
26 let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])) ?? false
27 guard granted else { return false }
28 followed.insert(launch.id)
29 await schedule(launch, observer: libraryObserver())
30 return true
31 }
32
33 static func unfollow(_ launch: Launch) {
34 followed.remove(launch.id)
35 UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: [id(launch)])
36 }
37
38 /// Replaces the notifications for followed launches in `launches`; followed launches no longer listed are dropped.
39 static func reschedule(_ launches: [Launch]) async {
40 let listed = Set(launches.map(\.id))
41 let center = UNUserNotificationCenter.current()
42 let stale = followed.subtracting(listed)
43 center.removePendingNotificationRequests(withIdentifiers: stale.map { "launch-pass-\($0)" })
44 followed.formIntersection(listed)
45 let observer = libraryObserver()
46 for launch in launches where followed.contains(launch.id) {
47 await schedule(launch, observer: observer)
48 }
49 }
50
51 /// Loads the launch list, when anything is followed, and reschedules.
52 static func refresh() async {
53 guard !followed.isEmpty, let apiBase else { return }
54 var list: LaunchList? = FeedMemo.get("launches", maxAge: 300)
55 if list == nil { list = try? await LaunchFeed.fetch(base: apiBase) }
56 guard let list else { return }
57 await reschedule(list.launches + (list.recent ?? []))
58 }
59
60 private static func schedule(_ launch: Launch, observer: Observer) async {
61 let center = UNUserNotificationCenter.current()
62 center.removePendingNotificationRequests(withIdentifiers: [id(launch)])
63 guard let pass = await passes(of: launch, observer: observer).first(where: { $0.rise.addingTimeInterval(-lead) > .now }) else { return }
64 let time = pass.rise.formatted(date: .omitted, time: .shortened)
65 let content = UNMutableNotificationContent()
66 content.title = "\(launch.mission ?? launch.rocket): first visible pass at \(time)"
67 content.body = "\(pass.displayName) rises in the \(compass(pass.riseAzimuth)), highest \(Int(pass.maxAltitude.rounded()))°, sets in the \(compass(pass.setAzimuth)). Sunlit while your sky is dark."
68 content.sound = .default
69 let parts = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: pass.rise.addingTimeInterval(-lead))
70 try? await center.add(UNNotificationRequest(identifier: id(launch), content: content, trigger: UNCalendarNotificationTrigger(dateMatching: parts, repeats: false)))
71 }
72
73 static func compass(_ azimuth: Double) -> String {
74 ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Int(((azimuth + 22.5) / 45).rounded(.down)) % 8]
75 }
76}
App/Sources/LaunchesScreen.swift +83 −1
@@ -1,5 +1,6 @@
11import ActivityKit
22import Feeds
3import SkyEngine
34import SwiftUI
45
56/// Backend base URL from Info.plist (MOONRISE_API_URL build setting); nil when unset.
@@ -64,6 +65,13 @@ struct LaunchesScreen: View {
6465 Button { selected = launch } label: { row(launch, index: i + 1) }
6566 .buttonStyle(.plain)
6667 }
68 if let recent = list.recent, !recent.isEmpty {
69 Text("Recent").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6).padding(.top, 20).padding(.bottom, 4)
70 ForEach(Array(recent.enumerated()), id: \.element.id) { i, launch in
71 Button { selected = launch } label: { row(launch, index: i + 1) }
72 .buttonStyle(.plain)
73 }
74 }
6775 Text("Launch data: The Space Devs, Launch Library 2.")
6876 .font(.caption2).dimmed(0.5).padding(.vertical, 16)
6977 } else if error == nil {
@@ -77,7 +85,10 @@ struct LaunchesScreen: View {
7785 if !force, let cached: LaunchList = FeedMemo.get("launches", maxAge: 300) { list = cached; return }
7886 do {
7987 list = try await LaunchFeed.fetch(base: apiBase)
80 if let list { FeedMemo.set("launches", list) }
88 if let list {
89 FeedMemo.set("launches", list)
90 await LaunchPassNotifications.reschedule(list.launches + (list.recent ?? []))
91 }
8192 error = nil
8293 } catch {
8394 self.error = "Couldn't load launches. Pull to retry."
@@ -168,6 +179,7 @@ struct LaunchDetail: View {
168179 fact("Location", launch.location)
169180 }
170181 .font(.subheadline)
182 visibility.padding(.top, 8)
171183 followButton.padding(.top, 8)
172184 if let message { Text(message).font(.caption).dimmed(0.7) }
173185 if let d = launch.missionDescription {
@@ -184,6 +196,76 @@ struct LaunchDetail: View {
184196
185197 @State private var message: String?
186198 @State private var following = false
199 @State private var passes: [SatellitePass]?
200 @State private var notifying = false
201 @State private var notifyMessage: String?
202
203 /// First visible passes over the observer, worked out on the device.
204 @ViewBuilder
205 private var visibility: some View {
206 if let elements = launch.elements, !elements.isEmpty {
207 VStack(alignment: .leading, spacing: 6) {
208 Text("Visibility").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
209 if let passes {
210 if let first = passes.first {
211 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
212 fact("First visible pass", describe(first))
213 ForEach(passes.filter { $0.name == first.name }.dropFirst().prefix(2)) { fact("Later", describe($0)) }
214 }
215 .font(.subheadline)
216 Text("\(first.displayName). Sunlit while your sky is dark; it may still be too faint to see\(elements.contains { $0.OBJECT_NAME.hasPrefix("Starlink train") } ? ", though new trains are often visible" : "").")
217 .font(.caption).dimmed(0.7)
218 Text(source).font(.caption2).dimmed(0.5)
219 notifyButton
220 if let notifyMessage { Text(notifyMessage).font(.caption).dimmed(0.7) }
221 } else {
222 Text("No visible pass from here in the next three days.").font(.subheadline).dimmed(0.7)
223 }
224 } else {
225 ProgressView().tint(ink)
226 }
227 }
228 .task {
229 notifying = LaunchPassNotifications.followed.contains(launch.id)
230 passes = await LaunchPassNotifications.passes(of: launch, observer: libraryObserver())
231 }
232 } else if launch.net < .now {
233 Text("No orbit published for this launch yet.").font(.caption).dimmed(0.7)
234 }
235 }
236
237 private var source: String {
238 let credit = launch.elementsSource == "supgp" ? "SpaceX via CelesTrak" : "CelesTrak"
239 return launch.elementsSource == "supgp" && launch.net > .now ? "Predicted before launch · \(credit)" : "Orbit: \(credit)"
240 }
241
242 private func describe(_ p: SatellitePass) -> String {
243 p.rise.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute())
244 + " · \(LaunchPassNotifications.compass(p.riseAzimuth)) → \(LaunchPassNotifications.compass(p.setAzimuth)), highest \(Int(p.maxAltitude.rounded()))°"
245 }
246
247 private var notifyButton: some View {
248 Button {
249 if notifying {
250 LaunchPassNotifications.unfollow(launch)
251 notifying = false
252 notifyMessage = nil
253 } else {
254 Task {
255 notifying = await LaunchPassNotifications.follow(launch)
256 notifyMessage = notifying ? nil : "Notifications are off for Moonrise. Turn them on in the Settings app."
257 }
258 }
259 } label: {
260 Label(notifying ? "Don't notify me" : "Notify me 15 minutes before", systemImage: notifying ? "bell.slash" : "bell")
261 .font(.subheadline.weight(.medium))
262 .frame(maxWidth: .infinity)
263 .padding(.vertical, 10)
264 .overlay(RoundedRectangle(cornerRadius: 6).stroke(ink.opacity(0.5), lineWidth: 0.5))
265 }
266 .buttonStyle(.plain)
267 .padding(.top, 4)
268 }
187269
188270 private var followButton: some View {
189271 Button {
App/Sources/LibraryScreen.swift +1 −1
@@ -10,7 +10,7 @@ private let pageBackground = Color.black
1010private let tile = Color(white: 0.08)
1111
1212/// The observer from Settings (a chosen city) or the last known location, else Chicago.
13private func libraryObserver() -> Observer {
13func libraryObserver() -> Observer {
1414 let d = UserDefaults.standard
1515 if d.string(forKey: SettingsKey.locationMode) == "city", d.object(forKey: SettingsKey.cityLatitude) != nil {
1616 return Observer(latitude: d.double(forKey: SettingsKey.cityLatitude), longitude: d.double(forKey: SettingsKey.cityLongitude))
App/Sources/MoonriseApp.swift +2
@@ -12,6 +12,7 @@ struct MoonriseApp: App {
1212 @AppStorage(SettingsKey.onboarded) private var onboarded = false
1313 @AppStorage(SettingsKey.nightActive) private var nightActive = false
1414 @State private var router = Router.shared
15 @Environment(\.scenePhase) private var scenePhase
1516
1617 var body: some Scene {
1718 WindowGroup {
@@ -30,6 +31,7 @@ struct MoonriseApp: App {
3031 .fullScreenCover(isPresented: .constant(!onboarded)) {
3132 OnboardingScreen().preferredColorScheme(.dark)
3233 }
34 .onChange(of: scenePhase) { if scenePhase == .active { Task { await LaunchPassNotifications.refresh() } } }
3335 }
3436 }
3537}
Packages/MoonriseKit/Package.swift +1 −1
@@ -24,7 +24,7 @@ let package = Package(
2424 dependencies: ["SkyEngine"],
2525 resources: [.copy("Resources/bsc5.json"), .copy("Resources/constellations.json"), .copy("Resources/coastline.json"), .copy("Resources/spacecraft.json"), .copy("Resources/skycultures.json")]
2626 ),
27 .target(name: "Feeds"),
27 .target(name: "Feeds", dependencies: ["SkyEngine"]),
2828 .target(name: "ObservingLog"),
2929 .target(name: "Library", resources: [.copy("Resources/pages"), .copy("Resources/tours"), .copy("Resources/stories")]),
3030 .testTarget(name: "SkyEngineTests", dependencies: ["SkyEngine"]),
Packages/MoonriseKit/Sources/Feeds/Launches.swift +7
@@ -1,4 +1,5 @@
11import Foundation
2import SkyEngine
23
34/// An upcoming launch as served by the Moonrise backend (from Launch Library 2).
45public struct Launch: Codable, Sendable, Identifiable, Hashable {
@@ -20,11 +21,17 @@ public struct Launch: Codable, Sendable, Identifiable, Hashable {
2021 public var location: String
2122 public var padLatitude: Double?
2223 public var padLongitude: Double?
24 /// Element sets for this launch's objects (a Starlink train, rocket bodies, payloads), once known.
25 public var elements: [OrbitalElements]?
26 /// "supgp" (SpaceX data via CelesTrak, possibly predicted before launch) or "gp" (CelesTrak catalogue).
27 public var elementsSource: String?
2328}
2429
2530public struct LaunchList: Codable, Sendable {
2631 public var fetchedAt: Date?
2732 public var launches: [Launch]
33 /// Launched in the last 14 days, latest first.
34 public var recent: [Launch]?
2835}
2936
3037public enum LaunchFeed {
Packages/MoonriseKit/Sources/SkyEngine/Passes.swift +9
@@ -118,4 +118,13 @@ extension SkyEngine {
118118 }
119119 return out
120120 }
121
122 /// Visible passes of any of `elements`, earliest first, each set searched for `hours` from the later of `start`
123 /// and its epoch: a set published before launch is epoched at deployment and means nothing earlier.
124 public static func visiblePasses(of elements: [OrbitalElements], from start: Date, hours: Double = 72, observer: Observer) -> [SatellitePass] {
125 elements.flatMap { e in
126 passes(of: e, from: max(start, e.epochDate ?? start), hours: hours, observer: observer).filter(\.visible)
127 }
128 .sorted { $0.rise < $1.rise }
129 }
121130}
Packages/MoonriseKit/Tests/FeedsTests/LaunchTests.swift +19
@@ -18,4 +18,23 @@ import Testing
1818 #expect(list.launches[0].net == (try Date("2026-10-19T10:30:00Z", strategy: .iso8601)))
1919 #expect(list.launches[1].mission == nil)
2020 #expect(list.fetchedAt != nil)
21 #expect(list.recent == nil)
22 #expect(list.launches[0].elements == nil)
23}
24
25@Test func decodesRecentLaunchesWithElements() throws {
26 // Shape of GET /v1/launches with launch element sets, 2026-10-19.
27 let data = Data("""
28 {"fetchedAt":"2026-10-19T12:00:00.000Z","launches":[],"recent":[{"id":"97c6f5e1","name":"Falcon 9 Block 5 | Starlink Group 15-25",
29 "net":"2026-10-13T00:28:11Z","windowStart":null,"windowEnd":null,"status":"Success","statusName":"Launch Successful","provider":"SpaceX",
30 "rocket":"Falcon 9 Block 5","mission":"Starlink Group 15-25","missionType":"Communications","missionDescription":null,"orbit":"LEO",
31 "pad":"Space Launch Complex 4E","location":"Vandenberg SFB, CA, USA","padLatitude":34.632,"padLongitude":-120.611,
32 "elements":[{"OBJECT_NAME":"Starlink train (Group 15-25)","NORAD_CAT_ID":66001,"EPOCH":"2026-10-18T22:10:01.123456","MEAN_MOTION":15.91,
33 "ECCENTRICITY":0.0001,"INCLINATION":70.0,"RA_OF_ASC_NODE":120.5,"ARG_OF_PERICENTER":90.1,"MEAN_ANOMALY":270.2,"BSTAR":0.0012}],
34 "elementsSource":"gp"}]}
35 """.utf8)
36 let list = try LaunchFeed.decode(data)
37 let launch = try #require(list.recent?.first)
38 #expect(launch.elements?.first?.OBJECT_NAME == "Starlink train (Group 15-25)")
39 #expect(launch.elementsSource == "gp")
2140}
Packages/MoonriseKit/Tests/SkyEngineTests/LaunchPassTests.swift added +26
@@ -0,0 +1,26 @@
1import Foundation
2import Testing
3@testable import SkyEngine
4
5// CelesTrak GP for the ISS, 2026-10-18.
6private let iss = OrbitalElements(name: "ISS (ZARYA)", norad: 25544, epoch: "2026-10-18T03:28:15.076416",
7 meanMotion: 15.48918166, eccentricity: 0.000678, inclination: 51.6328,
8 raan: 51.6259, argPerigee: 277.7856, meanAnomaly: 82.2363, bstar: 0.00014456)
9
10@Test func visiblePassesStartAtEachEpoch() throws {
11 let epoch = try #require(iss.epochDate)
12 let passes = SkyEngine.visiblePasses(of: [iss], from: epoch.addingTimeInterval(-86_400), observer: chicago)
13 #expect(!passes.isEmpty)
14 #expect(passes.allSatisfy { $0.visible && $0.rise >= epoch && $0.rise <= epoch.addingTimeInterval(72 * 3600) })
15}
16
17@Test func visiblePassesMergeSetsInTimeOrder() throws {
18 var other = iss
19 other.OBJECT_NAME = "FALCON 9 R/B"
20 other.MEAN_ANOMALY += 40
21 let start = try #require(iss.epochDate)
22 let one = SkyEngine.visiblePasses(of: [iss], from: start, observer: chicago)
23 let both = SkyEngine.visiblePasses(of: [iss, other], from: start, observer: chicago)
24 #expect(both.count > one.count)
25 #expect(both.map(\.rise) == both.map(\.rise).sorted())
26}
server/migrations/0004_launch_designator.sql added +2
@@ -0,0 +1,2 @@
1-- International designator from Launch Library 2 (e.g. 2026-232), set after liftoff.
2ALTER TABLE launches ADD COLUMN launch_designator TEXT;
server/src/celestrak.ts +136 −22
@@ -22,42 +22,156 @@ export interface OMM {
2222
2323const KEYS: (keyof OMM)[] = ["OBJECT_NAME", "NORAD_CAT_ID", "EPOCH", "MEAN_MOTION", "ECCENTRICITY", "INCLINATION", "RA_OF_ASC_NODE", "ARG_OF_PERICENTER", "MEAN_ANOMALY", "BSTAR"];
2424
25export function pick(rows: Record<string, unknown>[]): OMM[] {
25export type Row = Record<string, unknown>;
26
27export function pick(rows: Row[]): OMM[] {
2628 return rows.map((r) => Object.fromEntries(KEYS.map((k) => [k, r[k]])) as unknown as OMM);
2729}
2830
29/**
30 * One element set per recent Starlink launch still low enough to be seen as a train,
31 * the satellite with the median mean motion, named "Starlink train (2026-232)".
32 */
33export function starlinkTrains(rows: Record<string, unknown>[]): OMM[] {
34 const groups = new Map<string, Record<string, unknown>[]>();
31/** Objects grouped by launch, keyed by international designator ("2026-232"). */
32export function byDesignator(rows: Row[]): Map<string, Row[]> {
33 const groups = new Map<string, Row[]>();
3534 for (const r of rows) {
36 if (!String(r.OBJECT_NAME).startsWith("STARLINK")) continue;
3735 const launch = String(r.OBJECT_ID).slice(0, 8);
3836 groups.set(launch, [...(groups.get(launch) ?? []), r]);
3937 }
38 return groups;
39}
40
41const isStarlink = (r: Row) => String(r.OBJECT_NAME).startsWith("STARLINK");
42
43/** The Starlink satellite with the median mean motion, named "Starlink train (<label>)". */
44function train(rows: Row[], label: string): OMM | null {
45 const sorted = rows.filter(isStarlink).sort((a, b) => Number(a.MEAN_MOTION) - Number(b.MEAN_MOTION));
46 if (!sorted.length) return null;
47 return { ...pick([sorted[Math.floor(sorted.length / 2)]])[0], OBJECT_NAME: `Starlink train (${label})` };
48}
49
50/** One element set per launch, keyed by label, whose Starlink satellites are still low enough to be seen as a train. */
51export function starlinkTrains(groups: Map<string, Row[]>): OMM[] {
4052 const out: OMM[] = [];
41 for (const [launch, sats] of groups) {
42 const sorted = [...sats].sort((a, b) => Number(a.MEAN_MOTION) - Number(b.MEAN_MOTION));
43 const median = sorted[Math.floor(sorted.length / 2)];
44 if (Number(median.MEAN_MOTION) <= TRAIN_MEAN_MOTION) continue;
45 out.push({ ...pick([median])[0], OBJECT_NAME: `Starlink train (${launch})` });
53 for (const [label, rows] of groups) {
54 const t = train(rows, label);
55 if (t && t.MEAN_MOTION > TRAIN_MEAN_MOTION) out.push(t);
4656 }
4757 return out;
4858}
4959
50async function group(name: string): Promise<Record<string, unknown>[]> {
51 const res = await fetch(`https://celestrak.org/NORAD/elements/gp.php?GROUP=${name}&FORMAT=JSON`, {
52 headers: { "user-agent": "moonrise-server (sh.krz.moonrise)" },
53 });
60// SupGP: pre-launch and post-deployment Starlink sets from SpaceX data. OBJECT_ID and
61// NORAD_CAT_ID are placeholders (a pre-launch OBJECT_ID can be a designator already given to
62// another launch), so SupGP sets are tied to launches by file name and epoch only.
63
64/** "Starlink Group 15-15" → "starlink-g15-15"; null for other missions. */
65export function supgpFile(mission: string | null): string | null {
66 const m = mission?.match(/^Starlink Group (\d+)-(\d+)$/);
67 return m ? `starlink-g${m[1]}-${m[2]}` : null;
68}
69
70/** An unknown FILE is answered with HTTP 200 and plain text; anything but complete element sets is no data. */
71export function parseSupGP(text: string): Row[] {
72 let rows: unknown;
73 try {
74 rows = JSON.parse(text);
75 } catch {
76 return [];
77 }
78 return Array.isArray(rows) ? rows.filter(complete) : [];
79}
80
81function complete(r: unknown): r is Row {
82 if (typeof r !== "object" || r === null) return false;
83 const o = r as Row;
84 return typeof o.OBJECT_NAME === "string" && typeof o.OBJECT_ID === "string" && typeof o.EPOCH === "string" && !Number.isNaN(epoch(o)) &&
85 KEYS.every((k) => k === "OBJECT_NAME" || k === "EPOCH" || (typeof o[k] === "number" && Number.isFinite(o[k])));
86}
87
88const epoch = (r: Row) => Date.parse(`${r.EPOCH}Z`);
89
90/**
91 * Starlink deployment is one to two hours after liftoff (Group 15-15: 1 h 57 min): before launch, sets must be epoched
92 * 0–3 h after NET, which rejects a file left over from an earlier time; after launch, any time after NET.
93 */
94export function fitsLaunch(rows: Row[], net: string, now: Date): Row[] {
95 const t = Date.parse(net);
96 return rows.filter((r) => epoch(r) >= t && (t < now.getTime() || epoch(r) <= t + 3 * 3600_000));
97}
98
99export interface LaunchRef {
100 id: string;
101 mission: string | null;
102 provider: string;
103 net: string;
104 designator: string | null;
105}
106
107/** SpaceX Starlink launches from 3 days ago to 48 h ahead, nearest first, at most 4 (one SupGP request each). */
108export function supgpLaunches(launches: LaunchRef[], now: Date): LaunchRef[] {
109 const from = now.getTime() - 3 * 86400_000, to = now.getTime() + 48 * 3600_000;
110 return launches
111 .filter((l) => l.provider === "SpaceX" && supgpFile(l.mission) && Date.parse(l.net) >= from && Date.parse(l.net) <= to)
112 .sort((a, b) => Math.abs(Date.parse(a.net) - now.getTime()) - Math.abs(Date.parse(b.net) - now.getTime()))
113 .slice(0, 4);
114}
115
116const newest = (rows: Row[]) => Math.max(...rows.map(epoch));
117
118/** A Starlink train, then rocket bodies and other objects (no debris), at most 8. */
119function launchElements(rows: Row[], label: string): OMM[] {
120 const others = rows
121 .filter((r) => !isStarlink(r) && !String(r.OBJECT_NAME).includes(" DEB"))
122 .sort((a, b) => Number(String(b.OBJECT_NAME).includes("R/B")) - Number(String(a.OBJECT_NAME).includes("R/B")) || String(a.OBJECT_ID).localeCompare(String(b.OBJECT_ID)));
123 const t = train(rows, label);
124 return [...(t ? [t] : []), ...pick(others)].slice(0, 8);
125}
126
127export interface LaunchElements {
128 /** "gp": CelesTrak GP matched by designator; "supgp": SpaceX data via CelesTrak SupGP. */
129 source: "gp" | "supgp";
130 elements: OMM[];
131}
132
133/**
134 * The satellite list (visual group plus trains, labelled with the mission name where known) and element sets
135 * per launch. For each launch the GP objects with its designator or its SupGP sets are used, whichever has the
136 * newer epoch. A SupGP train joins the satellite list only once its epoch (predicted deployment) has passed,
137 * since clients propagate that list from now.
138 */
139export function compose(visual: Row[], recent: Row[], launches: LaunchRef[], supgp: Record<string, Row[]>, now: Date): { satellites: OMM[]; launches: Record<string, LaunchElements> } {
140 const gp = byDesignator(recent);
141 const groups = new Map(gp);
142 const out: Record<string, LaunchElements> = {};
143 for (const l of launches) {
144 const g = l.designator ? gp.get(l.designator) : undefined;
145 const s = supgp[l.id]?.length ? supgp[l.id] : undefined;
146 const source = s && (!g || newest(s) > newest(g)) ? "supgp" : "gp";
147 const rows = source === "supgp" ? s : g;
148 if (!rows) continue;
149 const label = l.mission?.startsWith("Starlink ") ? l.mission.slice(9) : (l.mission ?? l.designator ?? l.id);
150 if (l.designator) groups.delete(l.designator);
151 if (source === "gp" || newest(rows) <= now.getTime()) groups.set(label, rows);
152 out[l.id] = { source, elements: launchElements(rows, label) };
153 }
154 return { satellites: [...pick(visual), ...starlinkTrains(groups)], launches: out };
155}
156
157const HEADERS = { "user-agent": "moonrise-server (sh.krz.moonrise)" };
158
159async function group(name: string): Promise<Row[]> {
160 const res = await fetch(`https://celestrak.org/NORAD/elements/gp.php?GROUP=${name}&FORMAT=JSON`, { headers: HEADERS });
54161 if (res.status !== 200) throw new Error(`CelesTrak ${res.status} for ${name}`);
55 return (await res.json()) as Record<string, unknown>[];
162 return (await res.json()) as Row[];
56163}
57164
58/** The visual group (about 150 bright objects) and Starlink trains; throws on the first non-200 so nothing more is requested. */
59export async function fetchElements(): Promise<OMM[]> {
60 const visual = pick(await group("visual"));
165/** The visual group (about 150 bright objects) and the last 30 days' launches; throws on the first non-200 so nothing more is requested. */
166export async function fetchGroups(): Promise<{ visual: Row[]; recent: Row[] }> {
167 const visual = await group("visual");
61168 const recent = await group("last-30-days");
62 return [...visual, ...starlinkTrains(recent)];
169 return { visual, recent };
170}
171
172/** One SupGP file; throws on a non-200 so the caller stops. */
173export async function fetchSupGP(file: string): Promise<Row[]> {
174 const res = await fetch(`https://celestrak.org/NORAD/elements/supplemental/sup-gp.php?FILE=${file}&FORMAT=JSON`, { headers: HEADERS });
175 if (res.status !== 200) throw new Error(`CelesTrak SupGP ${res.status} for ${file}`);
176 return parseSupGP(await res.text());
63177}
server/src/index.ts +65 −18
@@ -1,8 +1,8 @@
11import { send, type ApnsConfig } from "./apns";
22import { actionableKp, alertBands, eventKey, isNight } from "./aurora";
3import { fetchElements } from "./celestrak";
3import { compose, fetchGroups, fetchSupGP, fitsLaunch, supgpFile, supgpLaunches, type LaunchElements, type LaunchRef, type Row } from "./celestrak";
44import { activeEvent, fetchTrajectory, STEP_MINUTES } from "./live";
5import { fetchUpcoming, type Launch } from "./ll2";
5import { fetchLaunches, PREVIOUS_URL, UPCOMING_URL, type Launch } from "./ll2";
66import { digestDue, digestPayload, fetchLatest, localDate, pickHeadlines } from "./news";
77import { PRIVACY_HTML } from "./privacy";
88import { fetchSpaceWeather, next24h, type SpaceWeather } from "./swpc";
@@ -28,6 +28,8 @@ interface DeviceRequest {
2828
2929const LAUNCH_CRON = "*/10 * * * *";
3030const SATELLITE_CRON = "7 */2 * * *";
31/** Launches are kept this long after NET, so a launch page and its first pass outlive liftoff. */
32const RETENTION_MS = 14 * 86400_000;
3133
3234const json = (body: unknown, status = 200, maxAge?: number) =>
3335 Response.json(body, { status, headers: maxAge && status === 200 ? { "cache-control": `public, max-age=${maxAge}` } : {} });
@@ -97,7 +99,7 @@ export default {
9799
98100 async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
99101 const now = new Date(controller.scheduledTime);
100 // Launches every 10 minutes: 6 of the 15 anonymous Launch Library 2 requests per hour.
102 // Upcoming launches every 10 minutes and previous launches hourly: 7 of the 15 anonymous Launch Library 2 requests per hour.
101103 if (controller.cron === LAUNCH_CRON) ctx.waitUntil(Promise.all([pollLaunches(env, now), sendNewsDigests(env, now)]));
102104 else if (controller.cron === SATELLITE_CRON) ctx.waitUntil(pollSatellites(env, now));
103105 else ctx.waitUntil(pollSpaceWeather(env, now));
@@ -154,15 +156,22 @@ async function live(env: Env, url: URL, now: Date): Promise<Response> {
154156async function launches(env: Env, now: Date): Promise<Response> {
155157 const since = new Date(now.getTime() - 6 * 3600_000).toISOString();
156158 const rows = await env.DB.prepare("SELECT * FROM launches WHERE net >= ?1 ORDER BY net LIMIT 20").bind(since).all<Record<string, unknown>>();
159 const recent = await env.DB.prepare("SELECT * FROM launches WHERE net < ?1 AND net >= ?2 ORDER BY net DESC LIMIT 20")
160 .bind(since, new Date(now.getTime() - RETENTION_MS).toISOString()).all<Record<string, unknown>>();
157161 const fetched = await env.DB.prepare("SELECT fetched_at FROM feed_snapshots WHERE feed = 'll2' AND ok = 1").first<{ fetched_at: string }>();
162 const els = await env.DB.prepare("SELECT body FROM feed_snapshots WHERE feed = 'launch-elements' AND ok = 1").first<{ body: string }>();
163 const elements: Record<string, LaunchElements> = els ? JSON.parse(els.body) : {};
164 const shape = (r: Record<string, unknown>) => ({
165 id: r.id, name: r.name, net: r.net, windowStart: r.window_start, windowEnd: r.window_end,
166 status: r.status, statusName: r.status_name, provider: r.provider, rocket: r.rocket,
167 mission: r.mission, missionType: r.mission_type, missionDescription: r.mission_description, orbit: r.orbit,
168 pad: r.pad, location: r.location, padLatitude: r.pad_latitude, padLongitude: r.pad_longitude,
169 elements: elements[String(r.id)]?.elements ?? [], elementsSource: elements[String(r.id)]?.source ?? null,
170 });
158171 return json({
159172 fetchedAt: fetched?.fetched_at ?? null,
160 launches: rows.results.map((r) => ({
161 id: r.id, name: r.name, net: r.net, windowStart: r.window_start, windowEnd: r.window_end,
162 status: r.status, statusName: r.status_name, provider: r.provider, rocket: r.rocket,
163 mission: r.mission, missionType: r.mission_type, missionDescription: r.mission_description, orbit: r.orbit,
164 pad: r.pad, location: r.location, padLatitude: r.pad_latitude, padLongitude: r.pad_longitude,
165 })),
173 launches: rows.results.map(shape),
174 recent: recent.results.map(shape),
166175 }, 200, MAX_AGE.launches);
167176}
168177
@@ -170,34 +179,72 @@ async function launches(env: Env, now: Date): Promise<Response> {
170179async function pollLaunches(env: Env, now: Date): Promise<void> {
171180 let list: Launch[];
172181 try {
173 list = await fetchUpcoming(env.LL2_API_KEY);
182 list = await fetchLaunches(UPCOMING_URL, env.LL2_API_KEY);
174183 } catch (e) {
175184 console.error("ll2", String(e));
176185 await env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok) VALUES ('ll2:error', ?1, 0) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1").bind(now.toISOString()).run();
177186 return;
178187 }
188 // Previous launches on the first run of each hour: designators and outcomes arrive after a launch leaves the upcoming list.
189 if (now.getUTCMinutes() < 10) {
190 try {
191 list = [...list, ...(await fetchLaunches(PREVIOUS_URL, env.LL2_API_KEY))];
192 } catch (e) {
193 console.error("ll2 previous", String(e));
194 }
195 }
179196 const upsert = env.DB.prepare(
180 `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)
181 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)
197 `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, launch_designator)
198 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)
182199 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,
183 mission = ?10, mission_type = ?11, mission_description = ?12, orbit = ?13, pad = ?14, location = ?15, pad_latitude = ?16, pad_longitude = ?17, last_updated = ?18`,
200 mission = ?10, mission_type = ?11, mission_description = ?12, orbit = ?13, pad = ?14, location = ?15, pad_latitude = ?16, pad_longitude = ?17, last_updated = ?18,
201 launch_designator = COALESCE(?19, launch_designator)`,
184202 );
185203 await env.DB.batch([
186 ...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)),
187 env.DB.prepare("DELETE FROM launches WHERE net < ?1").bind(new Date(now.getTime() - 7 * 86400_000).toISOString()),
204 ...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, l.designator)),
205 env.DB.prepare("DELETE FROM launches WHERE net < ?1").bind(new Date(now.getTime() - RETENTION_MS).toISOString()),
188206 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()),
189207 ]);
190208}
191209
192210async function pollSatellites(env: Env, now: Date): Promise<void> {
211 let groups: { visual: Row[]; recent: Row[] };
193212 try {
194 const elements = await fetchElements();
195 await env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok, body) VALUES ('celestrak', ?1, 1, ?2) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1, ok = 1, body = ?2")
196 .bind(now.toISOString(), JSON.stringify(elements)).run();
213 groups = await fetchGroups();
197214 } catch (e) {
198215 console.error("celestrak", String(e));
199216 await env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok) VALUES ('celestrak:error', ?1, 0) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1").bind(now.toISOString()).run();
217 return;
218 }
219 const launches = await env.DB.prepare("SELECT id, mission, provider, net, launch_designator AS designator FROM launches WHERE net >= ?1")
220 .bind(new Date(now.getTime() - RETENTION_MS).toISOString()).all<LaunchRef>();
221 const supgp = await pollSupGP(env, launches.results, now);
222 const fitting = Object.fromEntries(launches.results.map((l) => [l.id, fitsLaunch(supgp[l.id] ?? [], l.net, now)]));
223 const out = compose(groups.visual, groups.recent, launches.results, fitting, now);
224 const save = env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok, body) VALUES (?1, ?2, 1, ?3) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?2, ok = 1, body = ?3");
225 await env.DB.batch([
226 save.bind("celestrak", now.toISOString(), JSON.stringify(out.satellites)),
227 save.bind("launch-elements", now.toISOString(), JSON.stringify(out.launches)),
228 ]);
229}
230
231/**
232 * SupGP sets by launch id, fetched after the GP groups and kept in their own snapshot: on any failure no more
233 * requests are made and the last good sets are used, so the GP data is never lost to a SupGP problem.
234 */
235async function pollSupGP(env: Env, launches: LaunchRef[], now: Date): Promise<Record<string, Row[]>> {
236 const sets: Record<string, Row[]> = {};
237 try {
238 for (const l of supgpLaunches(launches, now)) sets[l.id] = await fetchSupGP(supgpFile(l.mission)!);
239 } catch (e) {
240 console.error("supgp", String(e));
241 await env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok) VALUES ('supgp:error', ?1, 0) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1").bind(now.toISOString()).run();
242 const last = await env.DB.prepare("SELECT body FROM feed_snapshots WHERE feed = 'supgp' AND ok = 1").first<{ body: string }>();
243 return last ? JSON.parse(last.body) : {};
200244 }
245 await env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok, body) VALUES ('supgp', ?1, 1, ?2) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1, ok = 1, body = ?2")
246 .bind(now.toISOString(), JSON.stringify(sets)).run();
247 return sets;
201248}
202249
203250async function pollSpaceWeather(env: Env, now: Date): Promise<void> {
server/src/ll2.ts +8 −2
@@ -2,6 +2,8 @@
22// Images are not used: their licences are often "Unknown".
33
44export const UPCOMING_URL = "https://ll.thespacedevs.com/2.3.0/launches/upcoming/?limit=20&mode=normal";
5/** Recent launches, for the international designator, which is assigned only after liftoff. */
6export const PREVIOUS_URL = "https://ll.thespacedevs.com/2.3.0/launches/previous/?limit=10&mode=normal";
57
68export interface Launch {
79 id: string;
@@ -22,6 +24,8 @@ export interface Launch {
2224 padLatitude: number | null;
2325 padLongitude: number | null;
2426 lastUpdated: string;
27 /** International designator, e.g. "2026-232"; null before launch. */
28 designator: string | null;
2529}
2630
2731interface Raw {
@@ -31,6 +35,7 @@ interface Raw {
3135 window_start?: string | null;
3236 window_end?: string | null;
3337 last_updated: string;
38 launch_designator?: string | null;
3439 status: { abbrev: string; name: string };
3540 launch_service_provider: { name: string };
3641 rocket: { configuration: { full_name?: string; name: string } };
@@ -60,14 +65,15 @@ export function normalize(r: Raw): Launch {
6065 padLatitude: num(r.pad.latitude),
6166 padLongitude: num(r.pad.longitude),
6267 lastUpdated: r.last_updated,
68 designator: r.launch_designator ?? null,
6369 };
6470}
6571
6672/** `apiKey` lifts the anonymous per-IP limit, which Cloudflare's shared egress IPs often exhaust (HTTP 429). */
67export async function fetchUpcoming(apiKey?: string): Promise<Launch[]> {
73export async function fetchLaunches(url: string, apiKey?: string): Promise<Launch[]> {
6874 const headers: Record<string, string> = { "user-agent": "moonrise-server (sh.krz.moonrise)" };
6975 if (apiKey) headers.authorization = `Token ${apiKey}`;
70 const res = await fetch(UPCOMING_URL, { headers });
76 const res = await fetch(url, { headers });
7177 if (!res.ok) throw new Error(`LL2 ${res.status}`);
7278 const body = (await res.json()) as { results: Raw[] };
7379 return body.results.map(normalize);
server/test/celestrak.test.ts +109 −3
@@ -1,6 +1,6 @@
11import assert from "node:assert/strict";
22import { test } from "node:test";
3import { pick, starlinkTrains } from "../src/celestrak.ts";
3import { byDesignator, compose, fitsLaunch, parseSupGP, pick, starlinkTrains, supgpFile, supgpLaunches } from "../src/celestrak.ts";
44
55test("keeps only the SGP4 fields", () => {
66 // CelesTrak GP JSON for the ISS, 2026-10-18.
@@ -20,12 +20,118 @@ test("one Starlink train per low recent launch, median satellite", () => {
2020 OBJECT_NAME: `STARLINK-${n}`, OBJECT_ID: id, NORAD_CAT_ID: n, EPOCH: "2026-10-18T00:00:00", MEAN_MOTION: mm,
2121 ECCENTRICITY: 0.0001, INCLINATION: 53, RA_OF_ASC_NODE: 10, ARG_OF_PERICENTER: 90, MEAN_ANOMALY: 0, BSTAR: 0.001,
2222 });
23 const trains = starlinkTrains([
23 const trains = starlinkTrains(byDesignator([
2424 sat("2026-232A", 1, 15.9), sat("2026-232B", 2, 16.0), sat("2026-232C", 3, 15.95),
2525 sat("2026-180A", 4, 15.06), sat("2026-180B", 5, 15.07),
2626 { ...sat("2026-232D", 6, 16.1), OBJECT_NAME: "FALCON 9 R/B" },
27 ]);
27 ]));
2828 assert.equal(trains.length, 1);
2929 assert.equal(trains[0].OBJECT_NAME, "Starlink train (2026-232)");
3030 assert.equal(trains[0].NORAD_CAT_ID, 3);
3131});
32
33// sup-gp.php?FILE=starlink-g15-15&FORMAT=JSON on 2026-10-19 (trimmed). Its OBJECT_ID is a guess:
34// 2026-235 went to USSF-481 on 2026-10-16.
35const SUPGP = `[{"OBJECT_NAME":"STARLINK-G15-15 STACK","OBJECT_ID":"2026-235A","EPOCH":"2026-10-21T00:11:38.559984","MEAN_MOTION":16.05876681,"ECCENTRICITY":0.0009517,"INCLINATION":70.007,"RA_OF_ASC_NODE":92.2113,"ARG_OF_PERICENTER":261.8925,"MEAN_ANOMALY":112.7386,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"C","NORAD_CAT_ID":72000,"ELEMENT_SET_NO":0,"REV_AT_EPOCH":1,"BSTAR":0.00011238,"MEAN_MOTION_DOT":0.00082246,"MEAN_MOTION_DDOT":0,"RMS":"0.096","DATA_SOURCE":"SpaceX-SV"},
36{"OBJECT_NAME":"STARLINK-G15-15 SINGLE","OBJECT_ID":"2026-235B","EPOCH":"2026-10-21T00:11:38.559984","MEAN_MOTION":16.05877557,"ECCENTRICITY":0.0009499,"INCLINATION":70.0069,"RA_OF_ASC_NODE":92.2113,"ARG_OF_PERICENTER":263.1103,"MEAN_ANOMALY":111.5203,"EPHEMERIS_TYPE":0,"CLASSIFICATION_TYPE":"C","NORAD_CAT_ID":72001,"ELEMENT_SET_NO":0,"REV_AT_EPOCH":1,"BSTAR":0.0016439,"MEAN_MOTION_DOT":0.012324,"MEAN_MOTION_DDOT":0,"RMS":"0.110","DATA_SOURCE":"SpaceX-SV"}]`;
37
38test("maps Starlink mission names to SupGP files", () => {
39 assert.equal(supgpFile("Starlink Group 15-15"), "starlink-g15-15");
40 assert.equal(supgpFile("Starlink Group 6-100"), "starlink-g6-100");
41 assert.equal(supgpFile("Transporter 18 (Dedicated SSO Rideshare)"), null);
42 assert.equal(supgpFile(null), null);
43});
44
45test("SupGP misses and malformed rows are no data", () => {
46 assert.equal(parseSupGP(SUPGP).length, 2);
47 // An unknown FILE comes back as HTTP 200 with plain text.
48 assert.deepEqual(parseSupGP("Invalid query: \"starlink-g99-99\" not found."), []);
49 assert.deepEqual(parseSupGP("{}"), []);
50 assert.deepEqual(parseSupGP("[{"), []);
51 const rows = JSON.parse(SUPGP);
52 delete rows[1].BSTAR;
53 assert.equal(parseSupGP(JSON.stringify(rows)).length, 1);
54 assert.deepEqual(parseSupGP(JSON.stringify([{ ...rows[0], MEAN_MOTION: "16" }])), []);
55});
56
57test("SupGP sets must fit the launch time", () => {
58 const rows = parseSupGP(SUPGP);
59 const before = new Date("2026-10-19T12:00:00Z");
60 // LL2 NET for Group 15-15: deployment 1 h 57 min later.
61 assert.equal(fitsLaunch(rows, "2026-10-20T22:14:00Z", before).length, 2);
62 // Slipped a day: the file is for the old time.
63 assert.equal(fitsLaunch(rows, "2026-10-21T23:10:00Z", before).length, 0);
64 // Epoch four hours after a liftoff still to come: not this opportunity.
65 assert.equal(fitsLaunch(rows, "2026-10-20T20:00:00Z", before).length, 0);
66 // After liftoff, later post-deployment sets are fine.
67 assert.equal(fitsLaunch(rows, "2026-10-20T20:00:00Z", new Date("2026-10-21T06:00:00Z")).length, 2);
68});
69
70const launch = (id: string, mission: string | null, net: string, designator: string | null = null, provider = "SpaceX") => ({ id, mission, provider, net, designator });
71
72test("SupGP is fetched for at most four nearby Starlink launches, nearest first", () => {
73 const now = new Date("2026-10-19T12:00:00Z");
74 const picked = supgpLaunches([
75 launch("far", "Starlink Group 1-1", "2026-10-22T12:00:00Z"),
76 launch("old", "Starlink Group 1-2", "2026-10-15T12:00:00Z"),
77 launch("a", "Starlink Group 1-3", "2026-10-19T14:00:00Z"),
78 launch("b", "Starlink Group 1-4", "2026-10-18T12:00:00Z"),
79 launch("c", "Starlink Group 1-5", "2026-10-20T12:00:00Z"),
80 launch("d", "Starlink Group 1-6", "2026-10-17T00:00:00Z"),
81 launch("e", "Starlink Group 1-7", "2026-10-16T13:00:00Z"),
82 launch("ussf", "USSF-481", "2026-10-19T13:00:00Z"),
83 launch("other", "Starlink Group 1-8", "2026-10-19T13:00:00Z", null, "Rocket Lab"),
84 ], now);
85 assert.deepEqual(picked.map((l) => l.id), ["a", "b", "c", "d"]);
86});
87
88const gp = (id: string, name: string, n: number, epoch = "2026-10-18T00:00:00", mm = 15.9) => ({
89 OBJECT_NAME: name, OBJECT_ID: id, NORAD_CAT_ID: n, EPOCH: epoch, MEAN_MOTION: mm,
90 ECCENTRICITY: 0.0001, INCLINATION: 53, RA_OF_ASC_NODE: 10, ARG_OF_PERICENTER: 90, MEAN_ANOMALY: 0, BSTAR: 0.001,
91});
92
93test("labels trains with the mission and keeps SupGP apart from a designator it guessed", () => {
94 const recent = [
95 gp("2026-232A", "STARLINK-1", 1), gp("2026-232B", "STARLINK-2", 2), gp("2026-232C", "STARLINK-3", 3),
96 gp("2026-235A", "USA 600", 10), gp("2026-235B", "FALCON 9 R/B", 11),
97 ];
98 const launches = [
99 launch("g1525", "Starlink Group 15-25", "2026-10-13T00:28:11Z", "2026-232"),
100 launch("ussf", "USSF-481", "2026-10-16T00:01:05Z", "2026-235"),
101 launch("g1515", "Starlink Group 15-15", "2026-10-20T23:10:00Z"),
102 ];
103 const out = compose([gp("1998-067A", "ISS (ZARYA)", 25544)], recent, launches, { g1515: parseSupGP(SUPGP) }, new Date("2026-10-21T06:00:00Z"));
104 assert.deepEqual(out.satellites.map((s) => s.OBJECT_NAME), ["ISS (ZARYA)", "Starlink train (Group 15-25)", "Starlink train (Group 15-15)"]);
105 assert.deepEqual(out.launches.g1525.elements.map((e) => e.OBJECT_NAME), ["Starlink train (Group 15-25)"]);
106 assert.equal(out.launches.g1525.source, "gp");
107 assert.deepEqual(out.launches.ussf.elements.map((e) => e.OBJECT_NAME), ["FALCON 9 R/B", "USA 600"]);
108 assert.equal(out.launches.g1515.source, "supgp");
109 assert.equal(out.launches.g1515.elements[0].NORAD_CAT_ID, 72001);
110 // Before its predicted deployment a train is on the launch only: the satellite list is propagated from now.
111 const early = compose([], recent, launches, { g1515: parseSupGP(SUPGP) }, new Date("2026-10-19T12:00:00Z"));
112 assert.deepEqual(early.satellites.map((s) => s.OBJECT_NAME), ["Starlink train (Group 15-25)"]);
113 assert.equal(early.launches.g1515.source, "supgp");
114});
115
116test("the newer of GP and SupGP wins for a launch", () => {
117 const launches = [launch("g1525", "Starlink Group 15-25", "2026-10-13T00:28:11Z", "2026-232")];
118 const recent = [gp("2026-232A", "STARLINK-1", 1, "2026-10-18T00:00:00")];
119 const older = [gp("2026-999A", "STARLINK-G15-25 STACK", 72000, "2026-10-13T01:30:00")];
120 const newer = [gp("2026-999A", "STARLINK-G15-25 STACK", 72000, "2026-10-18T06:00:00")];
121 assert.equal(compose([], recent, launches, { g1525: older }, new Date("2026-10-19T12:00:00Z")).launches.g1525.source, "gp");
122 const out = compose([], recent, launches, { g1525: newer }, new Date("2026-10-19T12:00:00Z"));
123 assert.equal(out.launches.g1525.source, "supgp");
124 assert.equal(out.satellites.length, 1);
125});
126
127test("other launches list rocket bodies first, skip debris and stop at eight", () => {
128 const recent = [
129 ...Array.from({ length: 12 }, (_, i) => gp(`2026-227${String.fromCharCode(65 + i)}`, `SAT ${i}`, 100 + i)),
130 gp("2026-227M", "FALCON 9 R/B", 200), gp("2026-227N", "FALCON 9 DEB", 201),
131 ];
132 const out = compose([], recent, [launch("t18", "Transporter 18", "2026-10-01T18:32:00Z", "2026-227")], {}, new Date("2026-10-19T12:00:00Z"));
133 const names = out.launches.t18.elements.map((e) => e.OBJECT_NAME);
134 assert.equal(names.length, 8);
135 assert.equal(names[0], "FALCON 9 R/B");
136 assert.ok(!names.includes("FALCON 9 DEB"));
137});
server/test/ll2.test.ts +26 −1
@@ -1,6 +1,6 @@
11import assert from "node:assert/strict";
22import { test } from "node:test";
3import { normalize } from "../src/ll2.ts";
3import { normalize, PREVIOUS_URL } from "../src/ll2.ts";
44
55// Trimmed from /2.3.0/launches/upcoming/?mode=normal on 2026-10-17.
66const raw = {
@@ -31,3 +31,28 @@ test("tolerates a missing mission", () => {
3131 assert.equal(l.mission, null);
3232 assert.equal(l.orbit, null);
3333});
34
35// Trimmed from /2.3.0/launches/previous/?mode=normal on 2026-10-19.
36const launched = {
37 id: "97c6f5e1-299e-4652-b72d-9618ac047076",
38 name: "Falcon 9 Block 5 | Starlink Group 15-25",
39 net: "2026-10-13T00:28:11Z",
40 window_start: "2026-10-12T23:00:00Z",
41 window_end: "2026-10-13T03:00:00Z",
42 last_updated: "2026-10-13T10:10:50Z",
43 launch_designator: "2026-232",
44 status: { name: "Launch Successful", abbrev: "Success" },
45 launch_service_provider: { name: "SpaceX" },
46 rocket: { configuration: { name: "Falcon 9", full_name: "Falcon 9 Block 5" } },
47 mission: { name: "Starlink Group 15-25", type: "Communications", orbit: { abbrev: "LEO" } },
48 pad: { name: "Space Launch Complex 4E", latitude: 34.632, longitude: -120.611, location: { name: "Vandenberg SFB, CA, USA" } },
49};
50
51test("keeps the international designator once launched", () => {
52 assert.equal(normalize(launched).designator, "2026-232");
53 assert.equal(normalize(raw).designator, null);
54});
55
56test("previous launches are fetched ten at a time", () => {
57 assert.match(PREVIOUS_URL, /\/launches\/previous\/\?limit=10&mode=normal$/);
58});