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 2da0dd0b68

2da0dd0b68075843fa30fc995eb01728f77e6e19

parent: 3b8c40e96a

Verified · cmc

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

Satellite pass Live Activity; small, limited-width and StandBy layouts

- Pass activity started from the bell on a Today sheet pass row. Computed on
  the device: rise, highest and set times, rise/set directions, max altitude,
  timer text and a rise-to-set progress bar. Scheduled to start 10 minutes
  before rise (alert configuration as the reminder); stale a minute after set.
  Offered only for passes rising within 24 hours.
- Launch and event activities: .small family for Watch Smart Stack and
  CarPlay, content-sized compact timers instead of a fixed 64 pt frame,
  isDynamicIslandLimitedInWidth branches on iOS 27, black background in
  StandBy.
- SkyEngine: SatellitePass.activityStart(now:) and SkyEngine.compass(_:).

Layout: unified · split

App/Shared/PassActivity.swift added +17
@@ -0,0 +1,17 @@
1import ActivityKit
2import Foundation
3
4/// Live Activity for a satellite pass. Computed on the device and never updated: the view runs on timers
5/// and goes stale after set. Shared by the app and the widget extension.
6struct PassActivityAttributes: ActivityAttributes {
7 struct ContentState: Codable, Hashable {}
8
9 var name: String
10 var rise: Date
11 var peak: Date
12 var set: Date
13 var riseAzimuth: Double
14 var setAzimuth: Double
15 var maxAltitude: Double
16 var visible: Bool
17}
App/Sources/PassFollowButton.swift added +71
@@ -0,0 +1,71 @@
1import ActivityKit
2import SkyEngine
3import SwiftUI
4
5/// Bell on a pass row: puts the pass on the Lock Screen from 10 minutes before rise. Hidden for passes more
6/// than a day away.
7struct PassFollowButton: View {
8 var pass: SatellitePass
9 @State private var following = false
10 @State private var message: String?
11
12 var body: some View {
13 if pass.activityStart(now: .now) != nil {
14 Button {
15 following ? unfollow() : follow()
16 } label: {
17 Image(systemName: following ? "bell.fill" : "bell")
18 .font(.subheadline)
19 .frame(minWidth: 44, minHeight: 44)
20 .contentShape(Rectangle())
21 }
22 .buttonStyle(.plain)
23 .accessibilityLabel(following ? "Stop following on Lock Screen" : "Follow on Lock Screen")
24 .onAppear { following = Activity<PassActivityAttributes>.activities.contains { matches($0.attributes, pass) } }
25 .alert(message ?? "", isPresented: Binding { message != nil } set: { if !$0 { message = nil } }) {}
26 }
27 }
28
29 private func follow() {
30 guard ActivityAuthorizationInfo().areActivitiesEnabled else {
31 message = "Live Activities are turned off for Moonrise in Settings."
32 return
33 }
34 guard let start = pass.activityStart(now: .now) else { return }
35 let attributes = PassActivityAttributes(name: pass.displayName, rise: pass.rise, peak: pass.peak, set: pass.set,
36 riseAzimuth: pass.riseAzimuth, setAzimuth: pass.setAzimuth,
37 maxAltitude: pass.maxAltitude, visible: pass.visible)
38 // It cannot end itself without an update, so it goes stale a minute after set.
39 let content = ActivityContent(state: PassActivityAttributes.ContentState(), staleDate: pass.set.addingTimeInterval(60))
40 do {
41 if start > .now {
42 let rise = pass.rise.formatted(date: .omitted, time: .shortened)
43 let alert = AlertConfiguration(
44 title: "\(pass.displayName) pass in 10 minutes",
45 body: "Rises \(SkyEngine.compass(pass.riseAzimuth)) at \(rise), highest \(Int(pass.maxAltitude.rounded()))°, sets \(SkyEngine.compass(pass.setAzimuth)).",
46 sound: .default)
47 _ = try Activity.request(attributes: attributes, content: content, style: .standard, alertConfiguration: alert, start: start)
48 } else {
49 _ = try Activity.request(attributes: attributes, content: content)
50 }
51 following = true
52 } catch {
53 message = "Couldn't add the pass: \(error.localizedDescription)"
54 }
55 }
56
57 private func unfollow() {
58 let pass = pass
59 Task {
60 for a in Activity<PassActivityAttributes>.activities where matches(a.attributes, pass) {
61 await a.end(nil, dismissalPolicy: .immediate)
62 }
63 following = false
64 }
65 }
66}
67
68/// Rise times shift by a 20 s step each time passes are recomputed.
69private func matches(_ attributes: PassActivityAttributes, _ pass: SatellitePass) -> Bool {
70 attributes.name == pass.displayName && abs(attributes.rise.timeIntervalSince(pass.rise)) < 120
71}
App/Sources/TodaySheet.swift +3
@@ -99,6 +99,9 @@ struct TodaySheet: View {
9999 .font(.footnote.monospacedDigit()).dimmed(0.8)
100100 }
101101 .accessibilityElement(children: .combine)
102 .frame(maxWidth: .infinity, alignment: .leading)
103 .padding(.trailing, 44)
104 .overlay(alignment: .trailing) { PassFollowButton(pass: p) }
102105 }
103106 let others = alsoVisible
104107 if !others.isEmpty {
App/Widgets/ActivityLayout.swift added +36
@@ -0,0 +1,36 @@
1import SwiftUI
2import WidgetKit
3
4/// `full` in a normal Dynamic Island, `limited` where it is narrow: landscape, iPhone Duo, or several
5/// activities at once (iOS 27).
6struct IslandWidth<Full: View, Limited: View>: View {
7 @ViewBuilder var full: Full
8 @ViewBuilder var limited: Limited
9
10 var body: some View {
11 if #available(iOS 27, *) {
12 LimitedWidthReader(full: full, limited: limited)
13 } else {
14 full
15 }
16 }
17}
18
19@available(iOS 27, *)
20private struct LimitedWidthReader<Full: View, Limited: View>: View {
21 @Environment(\.isDynamicIslandLimitedInWidth) private var isLimited
22 var full: Full
23 var limited: Limited
24
25 var body: some View {
26 if isLimited { limited } else { full }
27 }
28}
29
30/// Counts down to `date`, or up from it once passed. Unlike `Text(timerInterval:)` it takes the width of its
31/// digits, not the widest the timer could get.
32func liveTimer(_ date: Date) -> Text {
33 date > .now
34 ? Text(.currentDate, format: .timer(countingDownIn: Date.now..<date))
35 : Text(.currentDate, format: .stopwatch(startingAt: date, maxPrecision: .seconds(1)))
36}
App/Widgets/EventLiveActivity.swift +57 −20
@@ -7,25 +7,8 @@ struct EventLiveActivity: Widget {
77
88 var body: some WidgetConfiguration {
99 ActivityConfiguration(for: EventActivityAttributes.self) { context in
10 VStack(alignment: .leading, spacing: 4) {
11 HStack {
12 Text(context.attributes.title).font(.system(size: 17, weight: .medium))
13 Spacer()
14 Text(context.attributes.launch, style: .timer).font(.system(size: 17, weight: .medium).monospacedDigit())
15 .multilineTextAlignment(.trailing)
16 }
17 Text(context.state.phase).font(.system(size: 14))
18 HStack {
19 Text("\(Int(context.state.distanceKm).formatted()) km from Earth").font(.system(size: 12)).opacity(0.8)
20 Spacer()
21 if let next = context.state.next, let at = context.state.nextAt {
22 Text("\(next) \(at, style: .relative)").font(.system(size: 12)).opacity(0.8).lineLimit(1)
23 }
24 }
25 }
26 .padding(16)
27 .foregroundStyle(ink)
28 .activityBackgroundTint(Color(red: 0.043, green: 0.078, blue: 0.141))
10 EventActivityView(context: context)
11 .foregroundStyle(ink)
2912 } dynamicIsland: { context in
3013 DynamicIsland {
3114 DynamicIslandExpandedRegion(.leading) {
@@ -40,10 +23,64 @@ struct EventLiveActivity: Widget {
4023 } compactLeading: {
4124 Image(systemName: "moon.stars")
4225 } compactTrailing: {
43 Text(context.attributes.launch, style: .timer).font(.system(size: 12).monospacedDigit()).frame(maxWidth: 64)
26 IslandWidth {
27 liveTimer(context.attributes.launch).font(.system(size: 12).monospacedDigit())
28 } limited: {
29 // Mission time in days once past the first day; the full timer does not fit.
30 if context.attributes.launch < .now.addingTimeInterval(-86_400) {
31 Text("\(Int(-context.attributes.launch.timeIntervalSinceNow / 86_400)) d").font(.system(size: 12).monospacedDigit())
32 } else {
33 liveTimer(context.attributes.launch).font(.system(size: 12).monospacedDigit())
34 }
35 }
4436 } minimal: {
4537 Image(systemName: "moon.stars")
4638 }
4739 }
40 .supplementalActivityFamilies([.small])
41 }
42}
43
44private struct EventActivityView: View {
45 @Environment(\.activityFamily) private var family
46 @Environment(\.isActivityFullscreen) private var fullscreen
47 var context: ActivityViewContext<EventActivityAttributes>
48
49 var body: some View {
50 Group {
51 if family == .small { small } else { medium }
52 }
53 // StandBy: black, to blend with the clock face.
54 .activityBackgroundTint(fullscreen ? .black : Color(red: 0.043, green: 0.078, blue: 0.141))
55 }
56
57 /// Apple Watch Smart Stack and CarPlay.
58 private var small: some View {
59 VStack(alignment: .leading, spacing: 2) {
60 Text(context.attributes.title).font(.system(size: 13, weight: .medium)).lineLimit(1)
61 liveTimer(context.attributes.launch).font(.system(size: 17, weight: .medium).monospacedDigit())
62 }
63 .frame(maxWidth: .infinity, alignment: .leading)
64 .padding(12)
65 }
66
67 private var medium: some View {
68 VStack(alignment: .leading, spacing: 4) {
69 HStack {
70 Text(context.attributes.title).font(.system(size: 17, weight: .medium))
71 Spacer()
72 Text(context.attributes.launch, style: .timer).font(.system(size: 17, weight: .medium).monospacedDigit())
73 .multilineTextAlignment(.trailing)
74 }
75 Text(context.state.phase).font(.system(size: 14))
76 HStack {
77 Text("\(Int(context.state.distanceKm).formatted()) km from Earth").font(.system(size: 12)).opacity(0.8)
78 Spacer()
79 if let next = context.state.next, let at = context.state.nextAt {
80 Text("\(next) \(at, style: .relative)").font(.system(size: 12)).opacity(0.8).lineLimit(1)
81 }
82 }
83 }
84 .padding(16)
4885 }
4986}
App/Widgets/LaunchLiveActivity.swift +72 −25
@@ -8,26 +8,8 @@ struct LaunchLiveActivity: Widget {
88
99 var body: some WidgetConfiguration {
1010 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))
11 LaunchActivityView(context: context, ink: ink, accent: accent)
12 .foregroundStyle(ink)
3113 } dynamicIsland: { context in
3214 DynamicIsland {
3315 DynamicIslandExpandedRegion(.leading) {
@@ -40,17 +22,82 @@ struct LaunchLiveActivity: Widget {
4022 Text(context.attributes.mission).font(.system(size: 12)).lineLimit(1).opacity(0.8)
4123 }
4224 } compactLeading: {
43 Text("T−").font(.system(size: 12, weight: .medium))
25 IslandWidth {
26 Text("T−").font(.system(size: 12, weight: .medium))
27 } limited: {
28 Image(systemName: "arrow.up").foregroundStyle(context.state.status == "Hold" ? accent : ink)
29 }
4430 } compactTrailing: {
45 countdown(context.state.net).font(.system(size: 12).monospacedDigit()).frame(maxWidth: 64)
31 IslandWidth {
32 liveTimer(context.state.net).font(.system(size: 12).monospacedDigit())
33 } limited: {
34 if context.state.status == "Hold" {
35 Text("HOLD").font(.system(size: 12, weight: .medium)).foregroundStyle(accent)
36 } else {
37 liveTimer(context.state.net).font(.system(size: 12).monospacedDigit())
38 }
39 }
4640 } minimal: {
4741 Image(systemName: "arrow.up")
4842 }
4943 }
44 .supplementalActivityFamilies([.small])
45 }
46}
47
48private struct LaunchActivityView: View {
49 @Environment(\.activityFamily) private var family
50 @Environment(\.isActivityFullscreen) private var fullscreen
51 var context: ActivityViewContext<LaunchActivityAttributes>
52 var ink: Color
53 var accent: Color
54
55 var body: some View {
56 Group {
57 if family == .small { small } else { medium }
58 }
59 // StandBy: black, to blend with the clock face.
60 .activityBackgroundTint(fullscreen ? .black : Color(red: 0.043, green: 0.078, blue: 0.141))
61 }
62
63 /// Apple Watch Smart Stack and CarPlay.
64 private var small: some View {
65 VStack(alignment: .leading, spacing: 2) {
66 Text(context.attributes.rocket).font(.system(size: 13, weight: .medium)).lineLimit(1)
67 HStack(spacing: 2) {
68 Text(context.state.net > .now ? "T−" : "T+")
69 liveTimer(context.state.net)
70 }
71 .font(.system(size: 17, weight: .medium).monospacedDigit())
72 .foregroundStyle(context.state.status == "Hold" ? accent : ink)
73 }
74 .frame(maxWidth: .infinity, alignment: .leading)
75 .padding(12)
5076 }
5177
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)
78 private var medium: some View {
79 HStack(alignment: .center, spacing: 12) {
80 VStack(alignment: .leading, spacing: 2) {
81 Text(context.attributes.rocket).font(.system(size: 17, weight: .medium))
82 Text(context.attributes.mission).font(.system(size: 13)).lineLimit(1).opacity(0.8)
83 Text(context.attributes.location).font(.system(size: 11)).lineLimit(1).opacity(0.6)
84 }
85 Spacer()
86 VStack(alignment: .trailing, spacing: 2) {
87 Text(context.state.status.uppercased()).font(.system(size: 10, weight: .medium)).tracking(0.6)
88 .foregroundStyle(context.state.status == "Hold" ? accent : ink)
89 HStack(spacing: 2) {
90 Text(context.state.net > .now ? "T−" : "T+")
91 countdown(context.state.net)
92 }
93 .font(.system(size: 22, weight: .medium).monospacedDigit())
94 }
95 }
96 .padding(16)
5597 }
5698}
99
100/// Counts down to T-0, then shows elapsed time.
101private func countdown(_ net: Date) -> Text {
102 net > .now ? Text(timerInterval: Date.now...net, countsDown: true) : Text(net, style: .timer)
103}
App/Widgets/MoonriseWidgets.swift +1
@@ -12,6 +12,7 @@ struct MoonriseWidgets: WidgetBundle {
1212 #if os(iOS)
1313 LaunchLiveActivity()
1414 EventLiveActivity()
15 PassLiveActivity()
1516 #endif
1617 }
1718}
App/Widgets/PassLiveActivity.swift added +128
@@ -0,0 +1,128 @@
1import ActivityKit
2import SkyEngine
3import SwiftUI
4import WidgetKit
5
6struct PassLiveActivity: Widget {
7 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
8
9 var body: some WidgetConfiguration {
10 ActivityConfiguration(for: PassActivityAttributes.self) { context in
11 PassActivityView(context: context, ink: ink)
12 .foregroundStyle(ink)
13 } dynamicIsland: { context in
14 DynamicIsland {
15 DynamicIslandExpandedRegion(.leading) {
16 Text(context.attributes.name).font(.system(size: 14, weight: .medium)).lineLimit(1)
17 }
18 DynamicIslandExpandedRegion(.trailing) {
19 liveTimer(context.attributes.rise).font(.system(size: 14, weight: .medium).monospacedDigit())
20 }
21 DynamicIslandExpandedRegion(.bottom) {
22 if context.isStale {
23 Text("Passed \(time(context.attributes.set))").font(.system(size: 12)).opacity(0.8)
24 } else {
25 VStack(alignment: .leading, spacing: 6) {
26 Text(summary(context.attributes)).font(.system(size: 12).monospacedDigit()).lineLimit(1).opacity(0.8)
27 passProgress(context.attributes, ink: ink)
28 }
29 }
30 }
31 } compactLeading: {
32 IslandWidth {
33 Text(context.attributes.name).font(.system(size: 12, weight: .medium)).lineLimit(1)
34 } limited: {
35 Image(systemName: "sparkle")
36 }
37 } compactTrailing: {
38 liveTimer(context.attributes.rise).font(.system(size: 12).monospacedDigit())
39 } minimal: {
40 Image(systemName: "sparkle")
41 }
42 }
43 .supplementalActivityFamilies([.small])
44 }
45}
46
47private struct PassActivityView: View {
48 @Environment(\.activityFamily) private var family
49 @Environment(\.isActivityFullscreen) private var fullscreen
50 var context: ActivityViewContext<PassActivityAttributes>
51 var ink: Color
52
53 private var pass: PassActivityAttributes { context.attributes }
54
55 var body: some View {
56 Group {
57 if family == .small { small } else { medium }
58 }
59 // StandBy: black, to blend with the clock face.
60 .activityBackgroundTint(fullscreen ? .black : Color(red: 0.043, green: 0.078, blue: 0.141))
61 }
62
63 /// Apple Watch Smart Stack and CarPlay.
64 private var small: some View {
65 VStack(alignment: .leading, spacing: 2) {
66 Text("\(pass.name) \(Int(pass.maxAltitude.rounded()))°").font(.system(size: 13, weight: .medium)).lineLimit(1)
67 if context.isStale {
68 Text("Passed \(time(pass.set))").font(.system(size: 13).monospacedDigit()).opacity(0.8)
69 } else {
70 liveTimer(pass.rise).font(.system(size: 17, weight: .medium).monospacedDigit())
71 }
72 }
73 .frame(maxWidth: .infinity, alignment: .leading)
74 .padding(12)
75 }
76
77 private var medium: some View {
78 VStack(alignment: .leading, spacing: 8) {
79 HStack(alignment: .firstTextBaseline) {
80 Text("\(pass.name) pass").font(.system(size: 10, weight: .medium)).textCase(.uppercase).tracking(0.6).opacity(0.8)
81 if pass.visible {
82 Text("Visible").font(.system(size: 10, weight: .medium)).textCase(.uppercase).tracking(0.6)
83 }
84 Spacer()
85 if !context.isStale {
86 liveTimer(pass.rise).font(.system(size: 17, weight: .medium).monospacedDigit())
87 }
88 }
89 Rectangle().fill(ink.opacity(0.4)).frame(height: 0.5)
90 if context.isStale {
91 Text("Passed \(time(pass.set))").font(.system(size: 17, weight: .medium).monospacedDigit())
92 } else {
93 HStack(alignment: .top) {
94 column("Rise", time(pass.rise), SkyEngine.compass(pass.riseAzimuth))
95 Spacer()
96 column("Highest", time(pass.peak), "\(Int(pass.maxAltitude.rounded()))°")
97 Spacer()
98 column("Set", time(pass.set), SkyEngine.compass(pass.setAzimuth))
99 }
100 passProgress(pass, ink: ink)
101 }
102 }
103 .padding(16)
104 }
105
106 private func column(_ label: String, _ value: String, _ detail: String) -> some View {
107 VStack(alignment: .leading, spacing: 1) {
108 Text(label).font(.system(size: 10, weight: .medium)).textCase(.uppercase).tracking(0.6).opacity(0.6)
109 Text("\(value) \(detail)").font(.system(size: 15, weight: .medium).monospacedDigit())
110 }
111 }
112}
113
114/// Empty before rise, full at set, with the time left in the pass beside it.
115private func passProgress(_ pass: PassActivityAttributes, ink: Color) -> some View {
116 HStack(spacing: 8) {
117 ProgressView(timerInterval: pass.rise...pass.set, countsDown: false) { EmptyView() } currentValueLabel: { EmptyView() }
118 .tint(ink)
119 Text(.currentDate, format: .timer(countingDownIn: pass.rise..<pass.set))
120 .font(.system(size: 12).monospacedDigit()).opacity(0.8)
121 }
122}
123
124private func summary(_ pass: PassActivityAttributes) -> String {
125 "Rises \(SkyEngine.compass(pass.riseAzimuth)) \(time(pass.rise)), highest \(Int(pass.maxAltitude.rounded()))°, sets \(SkyEngine.compass(pass.setAzimuth))"
126}
127
128private func time(_ date: Date) -> String { date.formatted(date: .omitted, time: .shortened) }
Packages/MoonriseKit/Sources/SkyEngine/Passes.swift +12
@@ -24,6 +24,13 @@ public struct SatellitePass: Sendable, Identifiable, Equatable {
2424 default: name.replacingOccurrences(of: " R/B", with: " rocket body")
2525 }
2626 }
27
28 /// When a Lock Screen activity for this pass starts: 10 minutes before rise, or `now` if that has passed.
29 /// Nil once the pass has set, or when rise is more than a day away and the elements will have drifted.
30 public func activityStart(now: Date) -> Date? {
31 guard set > now, rise.timeIntervalSince(now) <= 86_400 else { return nil }
32 return max(now, rise.addingTimeInterval(-600))
33 }
2734}
2835
2936extension OrbitalElements {
@@ -33,6 +40,11 @@ extension OrbitalElements {
3340}
3441
3542extension SkyEngine {
43 /// Eight-point compass direction for an azimuth in degrees.
44 public static func compass(_ azimuth: Double) -> String {
45 ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Int(((azimuth + 22.5) / 45).rounded(.down)) % 8]
46 }
47
3648 /// Greenwich mean sidereal time in radians (IAU 1982, as used with SGP4's TEME frame).
3749 static func gmst(_ date: Date) -> Double {
3850 let jd = date.timeIntervalSince1970 / 86_400 + 2_440_587.5
Packages/MoonriseKit/Tests/SkyEngineTests/PassActivityTests.swift added +33
@@ -0,0 +1,33 @@
1import Foundation
2import Testing
3@testable import SkyEngine
4
5private let now = Date(timeIntervalSince1970: 1_792_364_400)
6
7private func pass(risingIn seconds: TimeInterval) -> SatellitePass {
8 SatellitePass(name: "ISS (ZARYA)", rise: now.addingTimeInterval(seconds), set: now.addingTimeInterval(seconds + 360), peak: now.addingTimeInterval(seconds + 180),
9 maxAltitude: 62, riseAzimuth: 240, setAzimuth: 60, visible: true, featured: true)
10}
11
12@Test func activityStartsTenMinutesBeforeRise() {
13 #expect(pass(risingIn: 3600).activityStart(now: now) == now.addingTimeInterval(3000))
14 #expect(pass(risingIn: 24 * 3600).activityStart(now: now) == now.addingTimeInterval(24 * 3600 - 600))
15}
16
17@Test func activityStartsNowWithinTenMinutesOrDuringThePass() {
18 #expect(pass(risingIn: 300).activityStart(now: now) == now)
19 #expect(pass(risingIn: -120).activityStart(now: now) == now)
20}
21
22@Test func noActivityAfterSetOrBeyondADay() {
23 #expect(pass(risingIn: -360).activityStart(now: now) == nil)
24 #expect(pass(risingIn: 24 * 3600 + 1).activityStart(now: now) == nil)
25}
26
27@Test func compassPoints() {
28 #expect(SkyEngine.compass(0) == "N")
29 #expect(SkyEngine.compass(22.4) == "N")
30 #expect(SkyEngine.compass(22.5) == "NE")
31 #expect(SkyEngine.compass(240) == "SW")
32 #expect(SkyEngine.compass(359) == "N")
33}