v2: News & launches, news alerts !30

merged merged by cmc on 2026-10-18 21:12 UTC · krz/moonrise:v2-news into main

13 files changed, +341 −40

Layout: unified · split

App/Sources/AuroraAlerts.swift +26 −12
@@ -4,17 +4,20 @@ import SkyEngine
44import UIKit
55import UserNotifications
66
7/// Registers this device with the backend for aurora pushes. Sends only the push
8/// token, a 5° geomagnetic latitude band and the UTC offset.
7/// Registers this device with the backend for aurora and news pushes. Sends only the push
8/// token, a 5° geomagnetic latitude band, the UTC offset and which alerts are on.
99@MainActor
1010@Observable
1111final class AuroraAlerts {
1212 static let shared = AuroraAlerts()
1313
1414 private static let enabledKey = "auroraAlerts"
15 private static let newsKey = "newsAlerts"
1516 private static let tokenKey = "apnsToken"
1617
1718 private(set) var enabled = UserDefaults.standard.bool(forKey: AuroraAlerts.enabledKey)
19 /// The daily news digest at 09:00.
20 private(set) var news = UserDefaults.standard.bool(forKey: AuroraAlerts.newsKey)
1821 private(set) var status: String?
1922 private var observer: Observer?
2023
@@ -40,30 +43,41 @@ final class AuroraAlerts {
4043 }
4144
4245 func setEnabled(_ on: Bool) async {
43 if on {
44 let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])) ?? false
45 guard granted else {
46 status = "Notifications are off for Moonrise. Turn them on in the Settings app."
47 return
48 }
49 UIApplication.shared.registerForRemoteNotifications()
50 }
46 if on, !(await permitted()) { return }
5147 enabled = on
5248 UserDefaults.standard.set(on, forKey: Self.enabledKey)
5349 await sync()
5450 }
5551
52 func setNews(_ on: Bool) async {
53 if on, !(await permitted()) { return }
54 news = on
55 UserDefaults.standard.set(on, forKey: Self.newsKey)
56 await sync()
57 }
58
59 private func permitted() async -> Bool {
60 let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])) ?? false
61 if granted {
62 UIApplication.shared.registerForRemoteNotifications()
63 } else {
64 status = "Notifications are off for Moonrise. Turn them on in the Settings app."
65 }
66 return granted
67 }
68
5669 /// Registers or unregisters to match `enabled`.
5770 func sync() async {
5871 guard let apiBase, let token else { return }
59 if enabled {
72 if enabled || news {
6073 guard let observer else { return }
6174 let body: [String: Any] = [
6275 "token": token,
6376 "environment": environment,
6477 "band": Geomagnetic.band(observer),
6578 "utcOffsetMinutes": TimeZone.current.secondsFromGMT() / 60,
66 "aurora": true,
79 "aurora": enabled,
80 "news": news,
6781 ]
6882 var req = URLRequest(url: apiBase.appending(path: "v1/devices"))
6983 req.httpMethod = "POST"
App/Sources/LaunchesScreen.swift +37 −11
@@ -6,10 +6,21 @@ import SwiftUI
66let apiBase: URL? = (Bundle.main.object(forInfoDictionaryKey: "MoonriseAPIURL") as? String)
77 .flatMap { $0.isEmpty ? nil : URL(string: $0) }
88
9/// Tab and News/Launches switch that a notification tap can change.
10@MainActor
11@Observable
12final class Router {
13 static let shared = Router()
14 var tab = "sky"
15 var showNews = false
16}
17
918struct LaunchesScreen: View {
19 @State private var router = Router.shared
1020 @State private var list: LaunchList?
1121 @State private var error: String?
1222 @State private var selected: Launch?
23 @State private var newsReload = 0
1324
1425 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
1526 private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
@@ -18,9 +29,33 @@ struct LaunchesScreen: View {
1829 var body: some View {
1930 ScrollView {
2031 VStack(alignment: .leading, spacing: 0) {
21 Text("Launches").font(.title.weight(.medium))
22 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 8)
32 Text("News & launches").font(.title.weight(.medium))
33 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12)
34 Picker("Show", selection: $router.showNews) {
35 Text("Launches").tag(false)
36 Text("News").tag(true)
37 }
38 .pickerStyle(.segmented)
39 .padding(.bottom, 8)
40
41 if router.showNews {
42 NewsList(ink: ink, accent: accent, reload: newsReload)
43 } else {
44 launches
45 }
46 }
47 .padding(.horizontal, 20)
48 .readableWidth()
49 }
50 .foregroundStyle(ink)
51 .background(background.ignoresSafeArea())
52 .refreshable { if router.showNews { newsReload += 1 } else { await load() } }
53 .task { await load() }
54 .sheet(item: $selected) { LaunchDetail(launch: $0, ink: ink, accent: accent, background: background) }
55 }
2356
57 @ViewBuilder
58 private var launches: some View {
2459 if let error {
2560 Text(error).font(.footnote).foregroundStyle(accent).padding(.vertical, 12)
2661 }
@@ -34,15 +69,6 @@ struct LaunchesScreen: View {
3469 } else if error == nil {
3570 ProgressView().tint(ink).padding(.top, 40).frame(maxWidth: .infinity)
3671 }
37 }
38 .padding(.horizontal, 20)
39 .readableWidth()
40 }
41 .foregroundStyle(ink)
42 .background(background.ignoresSafeArea())
43 .refreshable { await load() }
44 .task { await load() }
45 .sheet(item: $selected) { LaunchDetail(launch: $0, ink: ink, accent: accent, background: background) }
4672 }
4773
4874 private func load() async {
App/Sources/MoonriseApp.swift +13 −4
@@ -11,20 +11,20 @@ struct MoonriseApp: App {
1111 @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
1212 @AppStorage(SettingsKey.onboarded) private var onboarded = false
1313 @AppStorage(SettingsKey.nightActive) private var nightActive = false
14 @State private var tab = "sky"
14 @State private var router = Router.shared
1515
1616 var body: some Scene {
1717 WindowGroup {
18 TabView(selection: $tab) {
18 TabView(selection: $router.tab) {
1919 Tab("Sky", systemImage: "sparkles", value: "sky") { SkyScreen() }
2020 Tab("System", systemImage: "circle.circle", value: "system") { SystemScreen() }
2121 Tab("Sun", systemImage: "sun.max", value: "sun") { SunScreen() }
22 Tab("Launches", systemImage: "airplane.departure", value: "launches") { LaunchesScreen() }
22 Tab("News & launches", systemImage: "airplane.departure", value: "launches") { LaunchesScreen() }
2323 Tab("Library", systemImage: "books.vertical", value: "library") { LibraryScreen() }
2424 }
2525 .tabViewStyle(.sidebarAdaptable)
2626 // Red tab highlight while the Sky tab is in night mode.
27 .tint(tab == "sky" && nightActive ? SkyStyle.nightRed : nil)
27 .tint(router.tab == "sky" && nightActive ? SkyStyle.nightRed : nil)
2828 .preferredColorScheme(.dark)
2929 .fullScreenCover(isPresented: .constant(!onboarded)) {
3030 OnboardingScreen().preferredColorScheme(.dark)
@@ -46,6 +46,15 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
4646 Task { @MainActor in AuroraAlerts.shared.receivedToken(token) }
4747 }
4848
49 /// A news digest opens News & launches on the News switch.
50 nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
51 guard response.notification.request.content.userInfo["tab"] as? String == "news" else { return }
52 await MainActor.run {
53 Router.shared.tab = "launches"
54 Router.shared.showNews = true
55 }
56 }
57
4958 /// Show alerts as banners while the app is open.
5059 nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
5160 [.banner, .sound]
App/Sources/NewsList.swift added +62
@@ -0,0 +1,62 @@
1import Feeds
2import SwiftUI
3
4/// Latest space news headlines; tapping one opens the article in Safari.
5struct NewsList: View {
6 var ink: Color
7 var accent: Color
8 var reload = 0
9 @State private var articles: [NewsArticle]?
10 @State private var failed = false
11 @Environment(\.openURL) private var openURL
12
13 var body: some View {
14 VStack(alignment: .leading, spacing: 0) {
15 if let articles {
16 ForEach(articles) { a in
17 Button { openURL(a.url) } label: { row(a) }
18 .buttonStyle(.plain)
19 .accessibilityHint("Opens the article in Safari")
20 }
21 Text("Headlines: Spaceflight News API, The Space Devs. Articles belong to their publishers.")
22 .font(.caption2).dimmed(0.5).padding(.vertical, 16)
23 } else if failed {
24 Text("Couldn't load news. Pull to retry.").font(.footnote).foregroundStyle(accent).padding(.vertical, 12)
25 } else {
26 ProgressView().tint(ink).padding(.top, 40).frame(maxWidth: .infinity)
27 }
28 }
29 .task(id: reload) { await load() }
30 }
31
32 func load() async {
33 do {
34 articles = try await NewsArticle.latest()
35 failed = false
36 } catch {
37 failed = articles == nil
38 }
39 }
40
41 private func row(_ a: NewsArticle) -> some View {
42 HStack(alignment: .top, spacing: 12) {
43 VStack(alignment: .leading, spacing: 6) {
44 Text("\(a.site) · \(a.published.formatted(.relative(presentation: .named)))").font(.caption2).dimmed(0.7)
45 Text(a.title).font(.headline)
46 Text(a.summary).font(.footnote).dimmed(0.8).lineLimit(3)
47 }
48 Spacer(minLength: 0)
49 if let image = a.image {
50 AsyncImage(url: image) { phase in
51 if case .success(let img) = phase { img.resizable().scaledToFill() } else { ink.opacity(0.06) }
52 }
53 .frame(width: 72, height: 72)
54 .clipped()
55 .accessibilityHidden(true)
56 }
57 }
58 .padding(.vertical, 14)
59 .overlay(alignment: .top) { Rectangle().fill(ink.opacity(0.25)).frame(height: 0.5) }
60 .contentShape(Rectangle())
61 }
62}
App/Sources/SettingsScreen.swift +6 −2
@@ -87,7 +87,11 @@ struct SettingsScreen: View {
8787 get: { alerts.enabled },
8888 set: { on in Task { await alerts.setEnabled(on) } }
8989 ))
90 if alerts.enabled {
90 Toggle("News alerts", isOn: Binding(
91 get: { alerts.news },
92 set: { on in Task { await alerts.setNews(on) } }
93 ))
94 if alerts.enabled || alerts.news {
9195 Button("Send a test alert") { Task { await alerts.sendTest() } }
9296 }
9397 if let status = alerts.status {
@@ -96,7 +100,7 @@ struct SettingsScreen: View {
96100 } header: {
97101 Text("Alerts")
98102 } footer: {
99 Text("Moonrise notifies you when the aurora may be visible from your area at night. To do this it sends our server this device's notification token, a 5° band of geomagnetic latitude and your time zone offset, not your location.")
103 Text("Aurora alerts tell you when the aurora may be visible from your area at night. News alerts send three of the day's space headlines at 9 a.m. For these, Moonrise sends our server this device's notification token, a 5° band of geomagnetic latitude, your time zone offset and which alerts are on, not your location.")
100104 }
101105 Section {
102106 Toggle("Daily summary", isOn: Binding(
Packages/MoonriseKit/Sources/Feeds/News.swift added +46
@@ -0,0 +1,46 @@
1import Foundation
2
3/// Space news from the Spaceflight News API (The Space Devs): headline, summary and a link to the article.
4public struct NewsArticle: Sendable, Identifiable, Hashable, Decodable {
5 public var id: Int
6 public var title: String
7 public var summary: String
8 public var url: URL
9 public var image: URL?
10 public var site: String
11 public var published: Date
12
13 public static let url = URL(string: "https://api.spaceflightnewsapi.net/v4/articles/?limit=40")!
14
15 public static func latest(session: URLSession = .shared) async throws -> [NewsArticle] {
16 try decode(await session.data(from: url).0)
17 }
18
19 /// Decodes a page, leaving out Astronomy Picture of the Day posts, whose summaries are page navigation.
20 static func decode(_ data: Data) throws -> [NewsArticle] {
21 let d = JSONDecoder()
22 d.dateDecodingStrategy = .iso8601
23 return try d.decode(Page.self, from: data).results.filter { !$0.title.hasPrefix("APOD:") }
24 }
25
26 private struct Page: Decodable { var results: [NewsArticle] }
27
28 private enum CodingKeys: String, CodingKey {
29 case id, title, summary, url
30 case image = "image_url", site = "news_site", published = "published_at"
31 }
32
33 public init(from decoder: Decoder) throws {
34 let c = try decoder.container(keyedBy: CodingKeys.self)
35 id = try c.decode(Int.self, forKey: .id)
36 title = try c.decode(String.self, forKey: .title)
37 summary = try c.decode(String.self, forKey: .summary).trimmingCharacters(in: .whitespacesAndNewlines)
38 url = try c.decode(URL.self, forKey: .url)
39 site = try c.decode(String.self, forKey: .site)
40 published = try c.decode(Date.self, forKey: .published)
41 // Some sites give http image URLs, which App Transport Security blocks.
42 image = (try? c.decodeIfPresent(String.self, forKey: .image)).flatMap { s in
43 URL(string: s.hasPrefix("http://") ? "https://" + s.dropFirst(7) : s)
44 }
45 }
46}
Packages/MoonriseKit/Tests/FeedsTests/Fixtures/snapi.json added +1
@@ -0,0 +1 @@
1{"count":36523,"next":"https://api.spaceflightnewsapi.net/v4/articles/?limit=5&offset=5","previous":null,"results":[{"id":40426,"title":"Live coverage: SpaceX to launch Starfall reentry capsule, 31 customer payloads on Bandwagon-5 rideshare mission","authors":[{"name":"Will Robinson-Smith","socials":null}],"url":"https://spaceflightnow.com/2026/10/18/live-coverage-spacex-to-launch-starfall-reentry-capsule-31-customer-payloads-on-bandwagon-5-rideshare-mission/","image_url":"http://spaceflightnow.com/wp-content/uploads/2026/10/20261017_Bandwagon-5_payloads.jpeg","news_site":"Spaceflight Now","summary":"This will be the second launch of SpaceX’s uncrewed reentry capsule, Starfall. Liftoff from pad 40 at Cape Canaveral Space Force Station is scheduled during a one-hour window that opens at 6:30 a.m. EDT (1030 UTC). ","published_at":"2026-10-18T16:32:02Z","updated_at":"2026-10-18T16:40:28.841793Z","featured":false,"launches":[],"events":[]},{"id":40425,"title":"SpaceX working through Starship Flight 15 and 16 milestones","authors":[{"name":"Chris Bergin and Arran Reid","socials":null}],"url":"https://www.nasaspaceflight.com/2026/10/spacex-flight-15-16-milestones/","image_url":"https://www.nasaspaceflight.com/wp-content/uploads/2026/10/NSF-2026-10-18-17-03-53-605.jpg","news_site":"NASASpaceflight","summary":"SpaceX is advancing toward its next two Starship missions as the company looks to close…\nThe post SpaceX working through Starship Flight 15 and 16 milestones appeared first on NASASpaceFlight.com.","published_at":"2026-10-18T16:31:04Z","updated_at":"2026-10-18T16:40:18.042903Z","featured":false,"launches":[],"events":[]},{"id":40424,"title":"APOD: 2026 October 18 – Sonified: Eagle Nebula Pillars","authors":[{"name":"NASA","socials":null}],"url":"https://science.nasa.gov/image-article/apod-2026-october-18-sonified-eagle-nebula-pillars/","image_url":"https://blogs.nasa.gov/spacestation/wp-content/uploads/sites/240/2023/01/nasa-logo-web-rgb.png","news_site":"NASA","summary":"APOD Science APOD APOD: 2026 October 18 –… Today’s APOD Archive Submissions Index Search Calendar RSS Education About Discuss   APOD Astronomy Picture of the Day Discover the cosmos! Each day a different image or photograph of our fascinating universe is featured, along with a brief explanation written by a professional astronomer. Sonified: Eagle Nebula […]","published_at":"2026-10-18T04:05:00Z","updated_at":"2026-10-18T04:50:14.949909Z","featured":false,"launches":[],"events":[]},{"id":40423,"title":"APOD: 2026 October 17 – 3D 67P","authors":[{"name":"NASA","socials":null}],"url":"https://science.nasa.gov/image-article/apod-2026-october-17-3d-67p/","image_url":"https://assets.science.nasa.gov/dynamicimage/assets/science/cds/apod/apod/2026/october/anaglyph-NAC_2015-07-25T06.28.48.742Z_lamy.jpg","news_site":"NASA","summary":"APOD Science APOD APOD: 2026 October 17… Today’s APOD Archive Submissions Index Search Calendar RSS Education About Discuss   APOD Astronomy Picture of the Day Discover the cosmos! Each day a different image or photograph of our fascinating universe is featured, along with a brief explanation written by a professional astronomer. 3D 67P Explanation: Put […]","published_at":"2026-10-17T04:05:00Z","updated_at":"2026-10-17T04:40:16.233688Z","featured":false,"launches":[],"events":[]},{"id":40422,"title":"NASA Names Stephen Cash Marshall Space Flight Center Director","authors":[{"name":"NASA","socials":null}],"url":"https://www.nasa.gov/news-release/nasa-names-stephen-cash-marshall-space-flight-center-director/","image_url":"https://www.nasa.gov/wp-content/uploads/2026/10/steve-cash-photo.jpg","news_site":"NASA","summary":"NASA Administrator Jared Isaacman has named Stephen Cash director of the agency’s Marshall Space Flight Center in Huntsville, Alabama, effective Monday, Oct. 19. Cash, a veteran NASA engineer and aerospace executive, returns to lead the center where he began his career more than four decades ago. “Marshall contributions to NASA’s missions are historic and it’s […]","published_at":"2026-10-16T20:14:39Z","updated_at":"2026-10-16T20:20:06.780652Z","featured":false,"launches":[],"events":[]}]}
\ No newline at end of file
Packages/MoonriseKit/Tests/FeedsTests/NewsTests.swift added +16
@@ -0,0 +1,16 @@
1import Foundation
2import Testing
3@testable import Feeds
4
5@Test func decodesNewsArticles() throws {
6 // snapi.json saved from api.spaceflightnewsapi.net/v4/articles/?limit=5 on 2026-10-18.
7 let url = try #require(Bundle.module.url(forResource: "Fixtures/snapi", withExtension: "json"))
8 let articles = try NewsArticle.decode(Data(contentsOf: url))
9 #expect(articles.count == 3)
10 #expect(!articles.contains { $0.title.hasPrefix("APOD:") })
11 #expect(articles[0].id == 40426)
12 #expect(articles[0].site == "Spaceflight Now")
13 #expect(articles[0].image?.scheme == "https")
14 #expect(articles[0].published == Date(timeIntervalSince1970: 1_792_341_122))
15 #expect(!articles[0].summary.hasSuffix(" "))
16}
server/migrations/0003_news.sql added +2
@@ -0,0 +1,2 @@
1-- Daily news digest opt-in.
2ALTER TABLE devices ADD COLUMN news INTEGER NOT NULL DEFAULT 0;
server/src/index.ts +37 −5
@@ -2,6 +2,7 @@ import { send, type ApnsConfig } from "./apns";
22import { actionableKp, alertBands, eventKey, isNight } from "./aurora";
33import { fetchElements } from "./celestrak";
44import { fetchUpcoming, type Launch } from "./ll2";
5import { digestDue, digestPayload, fetchLatest, localDate, pickHeadlines } from "./news";
56import { PRIVACY_HTML } from "./privacy";
67import { fetchSpaceWeather, next24h, type SpaceWeather } from "./swpc";
78
@@ -21,6 +22,7 @@ interface DeviceRequest {
2122 band: number;
2223 utcOffsetMinutes: number;
2324 aurora?: boolean;
25 news?: boolean;
2426}
2527
2628const LAUNCH_CRON = "*/10 * * * *";
@@ -69,9 +71,9 @@ export default {
6971 const d = (await request.json().catch(() => ({}))) as Partial<DeviceRequest>;
7072 if (!validDevice(d)) return json({ error: "invalid device" }, 400);
7173 await env.DB.prepare(
72 `INSERT INTO devices (token, environment, band, utc_offset_minutes, aurora, last_seen) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
73 ON CONFLICT (token) DO UPDATE SET environment = ?2, band = ?3, utc_offset_minutes = ?4, aurora = ?5, last_seen = ?6`,
74 ).bind(d.token.toLowerCase(), d.environment, d.band, d.utcOffsetMinutes, d.aurora === false ? 0 : 1, now.toISOString()).run();
74 `INSERT INTO devices (token, environment, band, utc_offset_minutes, aurora, news, last_seen) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
75 ON CONFLICT (token) DO UPDATE SET environment = ?2, band = ?3, utc_offset_minutes = ?4, aurora = ?5, news = ?6, last_seen = ?7`,
76 ).bind(d.token.toLowerCase(), d.environment, d.band, d.utcOffsetMinutes, d.aurora === false ? 0 : 1, d.news === true ? 1 : 0, now.toISOString()).run();
7577 return json({ ok: true });
7678 }
7779
@@ -90,7 +92,7 @@ export default {
9092 async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
9193 const now = new Date(controller.scheduledTime);
9294 // Launches every 10 minutes: 6 of the 15 anonymous Launch Library 2 requests per hour.
93 if (controller.cron === LAUNCH_CRON) ctx.waitUntil(pollLaunches(env, now));
95 if (controller.cron === LAUNCH_CRON) ctx.waitUntil(Promise.all([pollLaunches(env, now), sendNewsDigests(env, now)]));
9496 else if (controller.cron === SATELLITE_CRON) ctx.waitUntil(pollSatellites(env, now));
9597 else ctx.waitUntil(pollSpaceWeather(env, now));
9698 },
@@ -105,7 +107,7 @@ async function sendTest(env: Env, token: string, now: Date): Promise<Response> {
105107 .bind(window, now.toISOString()).run();
106108 if (!claimed.meta.changes) return json({ error: "try again in a few minutes" }, 429);
107109 const config: ApnsConfig = { keyId: env.APNS_KEY_ID, teamId: env.APNS_TEAM_ID, privateKey: env.APNS_KEY, topic: env.APNS_TOPIC };
108 const payload = { aps: { alert: { title: "Moonrise test alert", body: "Aurora alerts reach this device." }, sound: "default" } };
110 const payload = { aps: { alert: { title: "Moonrise test alert", body: "Alerts reach this device." }, sound: "default" } };
109111 const r = await send(config, token, payload, device.environment === "sandbox");
110112 if (r.status === 410) await env.DB.prepare("DELETE FROM devices WHERE token = ?1").bind(token).run();
111113 return json({ apnsStatus: r.status, reason: r.reason }, r.status === 200 ? 200 : 502);
@@ -199,3 +201,33 @@ async function sendAuroraAlerts(env: Env, w: SpaceWeather, now: Date): Promise<v
199201 }
200202 }
201203}
204
205/** The daily news digest, sent at 09:00 local time to devices that opted in, once per local day. */
206async function sendNewsDigests(env: Env, now: Date): Promise<void> {
207 if (!env.APNS_KEY) return;
208 const rows = await env.DB.prepare("SELECT token, environment, utc_offset_minutes FROM devices WHERE news = 1")
209 .all<{ token: string; environment: string; utc_offset_minutes: number }>();
210 const due = rows.results.filter((d) => digestDue(now, d.utc_offset_minutes));
211 if (!due.length) return;
212
213 let payload;
214 try {
215 const headlines = pickHeadlines(await fetchLatest(), now);
216 if (!headlines.length) return;
217 payload = digestPayload(headlines);
218 } catch (e) {
219 console.error("snapi", String(e));
220 return;
221 }
222
223 const config: ApnsConfig = { keyId: env.APNS_KEY_ID, teamId: env.APNS_TEAM_ID, privateKey: env.APNS_KEY, topic: env.APNS_TOPIC };
224 for (const d of due) {
225 const claimed = await env.DB.prepare("INSERT OR IGNORE INTO alerts_sent (kind, band, event, sent_at) VALUES ('news', 0, ?1, ?2)")
226 .bind(`${d.token}:${localDate(now, d.utc_offset_minutes)}`, now.toISOString()).run();
227 if (!claimed.meta.changes) continue;
228 const r = await send(config, d.token, payload, d.environment === "sandbox");
229 if (r.status === 410) await env.DB.prepare("DELETE FROM devices WHERE token = ?1").bind(d.token).run();
230 else if (r.status !== 200) console.error("apns", r.status, r.reason);
231 }
232 await env.DB.prepare("DELETE FROM alerts_sent WHERE kind = 'news' AND sent_at < ?1").bind(new Date(now.getTime() - 3 * 86400_000).toISOString()).run();
233}
server/src/news.ts added +53
@@ -0,0 +1,53 @@
1// Spaceflight News API (The Space Devs). Free, no key. Used only for the daily news digest push.
2
3export interface Article {
4 id: number;
5 title: string;
6 news_site: string;
7 published_at: string;
8}
9
10export const DIGEST_HOUR = 9;
11
12/** Whether the device's local time is in the first 10 minutes of the digest hour (the 10-minute cron fires once in it). */
13export function digestDue(now: Date, utcOffsetMinutes: number): boolean {
14 const minutes = (((now.getUTCHours() * 60 + now.getUTCMinutes() + utcOffsetMinutes) % 1440) + 1440) % 1440;
15 return minutes >= DIGEST_HOUR * 60 && minutes < DIGEST_HOUR * 60 + 10;
16}
17
18/** The device's local date, so each device gets one digest per local day. */
19export function localDate(now: Date, utcOffsetMinutes: number): string {
20 return new Date(now.getTime() + utcOffsetMinutes * 60_000).toISOString().slice(0, 10);
21}
22
23/** Up to three of the newest headlines from the last 24 hours, one per site, without APOD. */
24export function pickHeadlines(articles: Article[], now: Date): Article[] {
25 const since = now.getTime() - 86_400_000;
26 const sites = new Set<string>();
27 const out: Article[] = [];
28 for (const a of [...articles].sort((x, y) => y.published_at.localeCompare(x.published_at))) {
29 if (Date.parse(a.published_at) < since || a.title.startsWith("APOD:") || sites.has(a.news_site)) continue;
30 sites.add(a.news_site);
31 out.push(a);
32 if (out.length === 3) break;
33 }
34 return out;
35}
36
37export function digestPayload(headlines: Article[]) {
38 return {
39 aps: {
40 alert: { title: "Space news", body: headlines.map((a) => `${a.title} (${a.news_site})`).join("\n") },
41 sound: "default",
42 },
43 tab: "news",
44 };
45}
46
47export async function fetchLatest(): Promise<Article[]> {
48 const res = await fetch("https://api.spaceflightnewsapi.net/v4/articles/?limit=50", {
49 headers: { "user-agent": "moonrise-server (sh.krz.moonrise)" },
50 });
51 if (res.status !== 200) throw new Error(`SNAPI ${res.status}`);
52 return ((await res.json()) as { results: Article[] }).results;
53}
server/src/privacy.ts +9 −6
@@ -20,7 +20,7 @@ h2 { font-weight: 500; font-size: 1.1em; margin-top: 1.8em; border-top: 1px soli
2020<p>Last updated 18 October 2026.</p>
2121
2222<h2>Summary</h2>
23<p>Moonrise has no accounts, no advertising, no analytics and no tracking. If you turn on aurora alerts, it sends our server three things needed to deliver them, described below. Nothing else is collected.</p>
23<p>Moonrise has no accounts, no advertising, no analytics and no tracking. If you turn on aurora alerts or news alerts, it sends our server four things needed to deliver them, described below. Nothing else is collected.</p>
2424
2525<h2>Location</h2>
2626<p>If you allow it, Moonrise uses your location to work out where the Sun, Moon, planets and stars are in your sky. This happens on your device. Your location is not sent to us or to anyone else. You can choose a city instead in Settings.</p>
@@ -31,18 +31,21 @@ h2 { font-weight: 500; font-size: 1.1em; margin-top: 1.8em; border-top: 1px soli
3131<li>space weather and launch schedules from our server, moonrise-server.audit-labs.workers.dev, which runs on Cloudflare Workers;</li>
3232<li>space weather and aurora data directly from NOAA's Space Weather Prediction Center;</li>
3333<li>images of the Sun from NASA's Solar Dynamics Observatory;</li>
34<li>images in the Library from NASA, ESA, ESO, NOIRLab and the Library of Congress.</li>
34<li>images in the Library from NASA, ESA, ESO, NOIRLab and the Library of Congress;</li>
35<li>Deep Space Network status and Mars rover raw images from NASA/JPL;</li>
36<li>space news headlines from the Spaceflight News API, run by The Space Devs.</li>
3537</ul>
3638<p>These requests contain no identifiers. As with any internet request, the servers receive your IP address. Cloudflare may keep short-lived logs; see Cloudflare's privacy policy.</p>
3739
38<h2>Aurora alerts</h2>
39<p>Aurora alerts are off until you turn them on in Settings. When they are on, the app sends our server:</p>
40<h2>Aurora and news alerts</h2>
41<p>Aurora alerts and news alerts are off until you turn them on in Settings. When they are on, the app sends our server:</p>
4042<ul>
4143<li>this device's push notification token, which Apple issues so a server can send it notifications;</li>
4244<li>a 5° band of geomagnetic latitude worked out from your location on the device (for example "50° to 55° north"), not your location itself;</li>
43<li>your time zone offset from UTC, so alerts arrive at night.</li>
45<li>your time zone offset from UTC, so aurora alerts arrive at night and the news digest at 9 a.m.;</li>
46<li>which of the two alerts you turned on.</li>
4447</ul>
45<p>These are stored with the time the device last checked in, and used only to send aurora alerts. They are not linked to your identity or shared. Turning alerts off deletes them from the server. A token that Apple reports as no longer valid is deleted automatically.</p>
48<p>These are stored with the time the device last checked in, and used only to send those alerts. They are not linked to your identity or shared. Turning both alerts off deletes them from the server. A token that Apple reports as no longer valid is deleted automatically.</p>
4649
4750<h2>Live Activities</h2>
4851<p>A launch countdown you add to the Lock Screen runs on your device and sends nothing to our server.</p>
server/test/news.test.ts added +33
@@ -0,0 +1,33 @@
1import assert from "node:assert/strict";
2import { test } from "node:test";
3import { digestDue, digestPayload, localDate, pickHeadlines, type Article } from "../src/news.ts";
4
5test("digest is due in the first 10 minutes of 09:00 local", () => {
6 const at = (iso: string) => new Date(iso);
7 assert.equal(digestDue(at("2026-10-18T14:00:00Z"), -300), true); // 09:00 CDT
8 assert.equal(digestDue(at("2026-10-18T14:09:59Z"), -300), true);
9 assert.equal(digestDue(at("2026-10-18T14:10:00Z"), -300), false);
10 assert.equal(digestDue(at("2026-10-17T23:30:00Z"), 570), true); // 09:00 ACST, next day
11});
12
13test("local date follows the offset", () => {
14 assert.equal(localDate(new Date("2026-10-17T23:30:00Z"), 570), "2026-10-18");
15 assert.equal(localDate(new Date("2026-10-18T02:00:00Z"), -300), "2026-10-17");
16});
17
18test("picks three recent headlines from different sites, skipping APOD", () => {
19 const now = new Date("2026-10-18T16:00:00Z");
20 const a = (id: number, site: string, hoursAgo: number, title = `t${id}`): Article =>
21 ({ id, title, news_site: site, published_at: new Date(now.getTime() - hoursAgo * 3600_000).toISOString() });
22 const picked = pickHeadlines([
23 a(1, "NASA", 1, "APOD: 2026 October 18"),
24 a(2, "SpaceNews", 2),
25 a(3, "SpaceNews", 1.5),
26 a(4, "ESA", 5),
27 a(5, "NASA", 30),
28 a(6, "Spaceflight Now", 20),
29 a(7, "NASASpaceflight", 23),
30 ], now);
31 assert.deepEqual(picked.map((p) => p.id), [3, 4, 6]);
32 assert.match(digestPayload(picked).aps.alert.body, /^t3 \(SpaceNews\)\nt4 \(ESA\)\nt6 \(Spaceflight Now\)$/);
33});