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 63ddcc6d55

63ddcc6d555721e7205d43f9eebe1f232357d249

parent: 04b2b1d914

Unsigned

cmc <hello@cleberg.net> · 2026-10-19 20:32 UTC

Equipment inventory linked to observations, with usage stats (!53)

Closes #1

- `Documents/Equipment.org` holds the inventory, one heading per item: `:TYPE:`, type-dependent specs (aperture, focal length, magnification, field of view, sensor), derived `:FOCAL_RATIO:`, notes, `:RETIRED: t`.
- Observations store `:EQUIPMENT_IDS:` (space-separated) next to the readable `:EQUIPMENT:` names. Entries with only free-text `:EQUIPMENT:` still parse and display, and the picker shows that text until items are picked.
- The editor's fixed picker is replaced by a multi-select over the inventory, plus "Naked eye". Retired items are hidden unless already on the entry. Items can be added from the picker and are selected on save.
- Equipment screen (binoculars button in the log) lists items by type with a Retired section. Each item shows observation count, first and last use, distinct and most-observed objects, observations by calendar month (chart) and season (southern seasons when latitude < 0), average seeing, and the items it was used with most.

Choices to check:
- Average seeing is over observations with seeing noted, not grouped by night.
- "By month" is month of year (Jan–Dec across all years), not year-month.
- Deleting an item leaves its ID on old entries; they show the stored names, and "Used with" lists it as "Deleted item".

Tests: `swift test` in `Packages/MoonriseKit` passes (88 tests, 5 new in ObservingLogTests). App built and checked on the iPhone 17 Pro simulator (iOS 27): inventory list, item stats, picker on a legacy entry (saved file has names and IDs), adding an item with a value typed just before Save.

Layout: unified · split

App/Sources/EquipmentScreen.swift added +327
@@ -0,0 +1,327 @@
1import Charts
2import ObservingLog
3import SwiftUI
4
5/// The equipment inventory lives in Documents/Equipment.org, one heading per item.
6let equipmentInventory = EquipmentInventory(file: URL.documentsDirectory.appending(path: "Equipment.org"))
7
8extension Equipment {
9 /// One line of the specs that apply to this kind, e.g. "203 mm · 1200 mm · f/5.9".
10 var specLine: String {
11 var parts: [String] = []
12 for spec in kind.specs {
13 switch spec {
14 case .aperture: if let aperture { parts.append("\(Self.format(aperture)) mm aperture") }
15 case .focalLength: if let focalLength { parts.append("\(Self.format(focalLength)) mm") }
16 case .magnification: if let magnification { parts.append("\(Self.format(magnification))×") }
17 case .fieldOfView: if let fieldOfView { parts.append("\(Self.format(fieldOfView))°") }
18 case .sensor: if !sensor.isEmpty { parts.append(sensor) }
19 }
20 }
21 if kind == .telescope, let focalRatio { parts.append("f/\(Self.format(focalRatio))") }
22 return parts.joined(separator: " · ")
23 }
24
25 static func format(_ x: Double) -> String { x.formatted(.number.precision(.fractionLength(0...1))) }
26}
27
28extension [Equipment] {
29 /// Grouped by kind in `Equipment.Kind` order, then by name.
30 var byKind: [Equipment] {
31 let order = Dictionary(uniqueKeysWithValues: Equipment.Kind.allCases.enumerated().map { ($1, $0) })
32 return sorted { order[$0.kind]! != order[$1.kind]! ? order[$0.kind]! < order[$1.kind]! : $0.name.localizedStandardCompare($1.name) == .orderedAscending }
33 }
34}
35
36struct EquipmentScreen: View {
37 @State private var items: [Equipment] = []
38 @State private var editing: Equipment?
39
40 var body: some View {
41 List {
42 if items.isEmpty {
43 Text("No equipment yet. Add your telescopes, binoculars, eyepieces and other gear, then pick them when you log an observation.")
44 .font(.footnote).foregroundStyle(.secondary)
45 }
46 ForEach(Equipment.Kind.allCases) { kind in
47 let group = items.filter { $0.kind == kind && !$0.retired }
48 if !group.isEmpty {
49 Section(kind.title) { rows(group) }
50 }
51 }
52 let retired = items.filter(\.retired)
53 if !retired.isEmpty {
54 Section("Retired") { rows(retired) }
55 }
56 }
57 .navigationTitle("Equipment")
58 .navigationBarTitleDisplayMode(.inline)
59 .toolbar {
60 ToolbarItem(placement: .primaryAction) {
61 Button { editing = Equipment(kind: .telescope, name: "") } label: { Image(systemName: "plus") }
62 .accessibilityLabel("New equipment")
63 }
64 }
65 .sheet(item: $editing) { item in
66 EquipmentEditor(item: item) { reload() }
67 }
68 .onAppear(perform: reload)
69 }
70
71 private func rows(_ group: [Equipment]) -> some View {
72 ForEach(group) { item in
73 NavigationLink { EquipmentDetail(item: item, onChange: reload) } label: {
74 VStack(alignment: .leading, spacing: 3) {
75 Text(item.name)
76 let line = item.retired ? [item.kind.title, item.specLine].filter { !$0.isEmpty }.joined(separator: " · ") : item.specLine
77 if !line.isEmpty { Text(line).font(.footnote).foregroundStyle(.secondary) }
78 }
79 }
80 }
81 .onDelete { idx in
82 for i in idx { try? equipmentInventory.delete(id: group[i].id) }
83 reload()
84 }
85 }
86
87 private func reload() { items = equipmentInventory.all().byKind }
88}
89
90struct EquipmentDetail: View {
91 @State var item: Equipment
92 var onChange: () -> Void
93 @State private var usage: EquipmentUsage?
94 @State private var names: [String: String] = [:]
95 @State private var editing: Equipment?
96
97 var body: some View {
98 List {
99 Section {
100 LabeledContent("Type", value: item.kind.title)
101 if !item.specLine.isEmpty { LabeledContent("Specs", value: item.specLine) }
102 if item.retired { LabeledContent("Status", value: "Retired") }
103 if !item.notes.isEmpty { Text(item.notes).font(.footnote) }
104 }
105 if let usage { usageSections(usage) }
106 }
107 .navigationTitle(item.name)
108 .navigationBarTitleDisplayMode(.inline)
109 .toolbar {
110 ToolbarItem(placement: .primaryAction) { Button("Edit") { editing = item } }
111 }
112 .sheet(item: $editing) { draft in
113 EquipmentEditor(item: draft) {
114 if let saved = equipmentInventory.all().first(where: { $0.id == draft.id }) { item = saved }
115 onChange()
116 }
117 }
118 .onAppear(perform: reload)
119 }
120
121 @ViewBuilder private func usageSections(_ u: EquipmentUsage) -> some View {
122 Section("Usage") {
123 LabeledContent("Observations", value: "\(u.observations)")
124 if let first = u.first { LabeledContent("First used", value: first.formatted(date: .abbreviated, time: .omitted)) }
125 if let last = u.last { LabeledContent("Last used", value: last.formatted(date: .abbreviated, time: .omitted)) }
126 if u.observations > 0 { LabeledContent("Objects", value: "\(u.distinctObjects)") }
127 if let seeing = u.averageSeeing { LabeledContent("Average seeing", value: "\(seeing.formatted(.number.precision(.fractionLength(1)))) of 5") }
128 }
129 if u.observations > 0 {
130 Section("Most observed") {
131 ForEach(u.topObjects.prefix(5), id: \.key) { LabeledContent($0.key, value: "\($0.count)") }
132 }
133 Section("By month") {
134 Chart(Array(u.byMonth.enumerated()), id: \.offset) { m, count in
135 BarMark(x: .value("Month", Calendar.current.shortMonthSymbols[m]), y: .value("Observations", count))
136 }
137 .frame(height: 140)
138 .accessibilityLabel("Observations by month")
139 ForEach(EquipmentUsage.Season.allCases, id: \.self) { s in
140 LabeledContent(s.title, value: "\(u.bySeason[s] ?? 0)")
141 }
142 }
143 if !u.usedWith.isEmpty {
144 Section("Used with") {
145 ForEach(u.usedWith.prefix(5), id: \.key) { LabeledContent(names[$0.key] ?? "Deleted item", value: "\($0.count)") }
146 }
147 }
148 }
149 }
150
151 private func reload() {
152 names = Dictionary(equipmentInventory.all().map { ($0.id, $0.name) }, uniquingKeysWith: { a, _ in a })
153 usage = EquipmentUsage(of: item.id, in: observingLog.all())
154 }
155}
156
157struct EquipmentEditor: View {
158 @State var item: Equipment
159 var onSave: () -> Void
160 @Environment(\.dismiss) private var dismiss
161 @State private var error: String?
162
163 var body: some View {
164 NavigationStack {
165 Form {
166 Section {
167 Picker("Type", selection: $item.kind) {
168 ForEach(Equipment.Kind.allCases) { Text($0.title).tag($0) }
169 }
170 TextField("Name", text: $item.name)
171 }
172 if !item.kind.specs.isEmpty {
173 Section("Specs") {
174 ForEach(item.kind.specs, id: \.self) { field($0) }
175 if item.kind == .telescope, let ratio = item.focalRatio {
176 LabeledContent("Focal ratio", value: "f/\(Equipment.format(ratio))")
177 }
178 }
179 }
180 Section("Notes") {
181 TextEditor(text: $item.notes).frame(minHeight: 100)
182 }
183 Section {
184 Toggle("Retired", isOn: $item.retired)
185 } footer: {
186 Text("Retired items leave the picker but stay on the observations that used them.")
187 }
188 if let error { Text(error).foregroundStyle(.red).font(.footnote) }
189 }
190 .navigationTitle(item.name.isEmpty ? "New equipment" : item.name)
191 .navigationBarTitleDisplayMode(.inline)
192 .toolbar {
193 ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
194 ToolbarItem(placement: .confirmationAction) {
195 Button("Save") {
196 do {
197 item.name = item.name.trimmingCharacters(in: .whitespaces)
198 try equipmentInventory.save(item)
199 onSave()
200 dismiss()
201 } catch {
202 self.error = "Couldn't save: \(error.localizedDescription)"
203 }
204 }
205 .disabled(item.name.trimmingCharacters(in: .whitespaces).isEmpty)
206 }
207 }
208 }
209 }
210
211 @ViewBuilder private func field(_ spec: Equipment.Spec) -> some View {
212 switch spec {
213 case .aperture: number("Aperture", unit: "mm", $item.aperture)
214 case .focalLength: number("Focal length", unit: "mm", $item.focalLength)
215 case .magnification: number(item.kind == .barlow ? "Factor" : "Magnification", unit: "×", $item.magnification)
216 case .fieldOfView: number(item.kind == .eyepiece ? "Apparent field" : "Field of view", unit: "°", $item.fieldOfView)
217 case .sensor: TextField("Sensor", text: $item.sensor)
218 }
219 }
220
221 private func number(_ title: String, unit: String, _ value: Binding<Double?>) -> some View {
222 LabeledContent(title) {
223 HStack(spacing: 4) {
224 NumberField(title: title, value: value)
225 Text(unit).foregroundStyle(.secondary)
226 }
227 }
228 }
229}
230
231/// Decimal entry that updates its binding on every keystroke, so Save never misses an uncommitted value.
232private struct NumberField: View {
233 var title: String
234 @Binding var value: Double?
235 @State private var text = ""
236
237 var body: some View {
238 TextField("", text: $text)
239 .keyboardType(.decimalPad)
240 .multilineTextAlignment(.trailing)
241 .accessibilityLabel(title)
242 .onAppear { text = value.map { $0.formatted(.number.grouping(.never)) } ?? "" }
243 .onChange(of: text) { value = try? Double(text, format: .number) }
244 }
245}
246
247/// Multi-select over the inventory for one observation.
248struct EquipmentPicker: View {
249 @Binding var observation: Observation
250 @State private var items: [Equipment] = []
251 @State private var adding: Equipment?
252
253 static let nakedEye = "Naked eye"
254
255 /// Free text from an entry logged before the inventory existed.
256 private var legacyText: String? {
257 observation.equipmentIDs.isEmpty && !observation.equipment.isEmpty && observation.equipment != Self.nakedEye ? observation.equipment : nil
258 }
259
260 var body: some View {
261 List {
262 if let legacyText {
263 Section {
264 Text(legacyText)
265 } header: {
266 Text("Recorded")
267 } footer: {
268 Text("Picking items below replaces this.")
269 }
270 }
271 Section {
272 row(Self.nakedEye, selected: observation.equipmentIDs.isEmpty && observation.equipment == Self.nakedEye) {
273 observation.equipmentIDs = []
274 observation.equipment = observation.equipment == Self.nakedEye ? "" : Self.nakedEye
275 }
276 }
277 ForEach(Equipment.Kind.allCases) { kind in
278 let group = items.filter { $0.kind == kind && (!$0.retired || observation.equipmentIDs.contains($0.id)) }
279 if !group.isEmpty {
280 Section(kind.title) {
281 ForEach(group) { item in
282 row(item.name, detail: item.specLine, selected: observation.equipmentIDs.contains(item.id)) { toggle(item) }
283 }
284 }
285 }
286 }
287 Section {
288 Button { adding = Equipment(kind: .telescope, name: "") } label: { Label("Add equipment", systemImage: "plus") }
289 }
290 }
291 .navigationTitle("Equipment")
292 .navigationBarTitleDisplayMode(.inline)
293 .sheet(item: $adding) { item in
294 EquipmentEditor(item: item) {
295 reload()
296 if let saved = items.first(where: { $0.id == item.id }) { toggle(saved) }
297 }
298 }
299 .onAppear(perform: reload)
300 }
301
302 private func row(_ title: String, detail: String = "", selected: Bool, action: @escaping () -> Void) -> some View {
303 Button(action: action) {
304 HStack {
305 VStack(alignment: .leading, spacing: 3) {
306 Text(title)
307 if !detail.isEmpty { Text(detail).font(.footnote).foregroundStyle(.secondary) }
308 }
309 Spacer()
310 if selected { Image(systemName: "checkmark").foregroundStyle(.tint) }
311 }
312 .contentShape(.rect)
313 }
314 .buttonStyle(.plain)
315 .accessibilityAddTraits(selected ? .isSelected : [])
316 }
317
318 private func toggle(_ item: Equipment) {
319 var ids = Set(observation.equipmentIDs)
320 if ids.contains(item.id) { ids.remove(item.id) } else { ids.insert(item.id) }
321 let chosen = items.filter { ids.contains($0.id) }
322 observation.equipmentIDs = chosen.map(\.id)
323 observation.equipment = chosen.map(\.name).joined(separator: ", ")
324 }
325
326 private func reload() { items = equipmentInventory.all().byKind }
327}
App/Sources/LogScreen.swift +16 −7
@@ -36,6 +36,10 @@ struct LogScreen: View {
36 .navigationBarTitleDisplayMode(.inline) 36 .navigationBarTitleDisplayMode(.inline)
37 .toolbar { 37 .toolbar {
38 ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } } 38 ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } }
39 ToolbarItem(placement: .primaryAction) {
40 NavigationLink { EquipmentScreen() } label: { Image(systemName: "binoculars") }
41 .accessibilityLabel("Equipment")
42 }
39 ToolbarItem(placement: .primaryAction) { 43 ToolbarItem(placement: .primaryAction) {
40 Button { editing = newEntry() } label: { Image(systemName: "plus") } 44 Button { editing = newEntry() } label: { Image(systemName: "plus") }
41 .accessibilityLabel("New observation") 45 .accessibilityLabel("New observation")
@@ -60,8 +64,14 @@ struct ObservationEditor: View {
60 var onSave: () -> Void 64 var onSave: () -> Void
61 @Environment(\.dismiss) private var dismiss 65 @Environment(\.dismiss) private var dismiss
62 @State private var error: String? 66 @State private var error: String?
67 @State private var names: [String: String] = [:]
63 68
64 private static let equipmentChoices = ["Naked eye", "Binoculars", "Telescope", "Camera"] 69 /// Current inventory names for linked items, else the text stored with the entry.
70 private var equipmentSummary: String {
71 let current = observation.equipmentIDs.compactMap { names[$0] }
72 if !current.isEmpty, current.count == observation.equipmentIDs.count { return current.joined(separator: ", ") }
73 return observation.equipment.isEmpty ? "Not noted" : observation.equipment
74 }
65 75
66 var body: some View { 76 var body: some View {
67 NavigationStack { 77 NavigationStack {
@@ -72,12 +82,8 @@ struct ObservationEditor: View {
72 TextField("Place", text: $observation.place) 82 TextField("Place", text: $observation.place)
73 } 83 }
74 Section("Conditions") { 84 Section("Conditions") {
75 Picker("Equipment", selection: $observation.equipment) { 85 NavigationLink { EquipmentPicker(observation: $observation) } label: {
76 Text("Not noted").tag("") 86 LabeledContent("Equipment", value: equipmentSummary)
77 ForEach(Self.equipmentChoices, id: \.self) { Text($0).tag($0) }
78 if !observation.equipment.isEmpty, !Self.equipmentChoices.contains(observation.equipment) {
79 Text(observation.equipment).tag(observation.equipment)
80 }
81 } 87 }
82 Picker("Seeing", selection: $observation.seeing) { 88 Picker("Seeing", selection: $observation.seeing) {
83 Text("Not noted").tag(Int?.none) 89 Text("Not noted").tag(Int?.none)
@@ -106,6 +112,9 @@ struct ObservationEditor: View {
106 .disabled(observation.object.trimmingCharacters(in: .whitespaces).isEmpty) 112 .disabled(observation.object.trimmingCharacters(in: .whitespaces).isEmpty)
107 } 113 }
108 } 114 }
115 .onAppear {
116 names = Dictionary(equipmentInventory.all().map { ($0.id, $0.name) }, uniquingKeysWith: { a, _ in a })
117 }
109 } 118 }
110 } 119 }
111} 120}
Packages/MoonriseKit/Sources/ObservingLog/Equipment.swift added +161
@@ -0,0 +1,161 @@
1import Foundation
2
3/// One item of the user's gear. The inventory is one Org file with a heading per item.
4public struct Equipment: Sendable, Identifiable, Equatable, Hashable {
5 public enum Kind: String, Sendable, CaseIterable, Identifiable {
6 case telescope, binoculars, mount, eyepiece, barlow, camera, filter, other
7 public var id: String { rawValue }
8
9 public var title: String {
10 switch self {
11 case .telescope: "Telescope"
12 case .binoculars: "Binoculars"
13 case .mount: "Mount"
14 case .eyepiece: "Eyepiece"
15 case .barlow: "Barlow"
16 case .camera: "Camera"
17 case .filter: "Filter"
18 case .other: "Other"
19 }
20 }
21
22 /// The spec fields that apply to this kind, in display order.
23 public var specs: [Spec] {
24 switch self {
25 case .telescope: [.aperture, .focalLength]
26 case .binoculars: [.magnification, .aperture, .fieldOfView]
27 case .eyepiece: [.focalLength, .fieldOfView]
28 case .barlow: [.magnification]
29 case .camera: [.sensor]
30 case .mount, .filter, .other: []
31 }
32 }
33 }
34
35 public enum Spec: Sendable {
36 case aperture, focalLength, magnification, fieldOfView, sensor
37 }
38
39 public var id: String
40 public var kind: Kind
41 public var name: String
42 /// Millimetres.
43 public var aperture: Double?
44 /// Millimetres.
45 public var focalLength: Double?
46 /// Binocular power, or the Barlow factor.
47 public var magnification: Double?
48 /// Degrees: true field for binoculars, apparent field for eyepieces.
49 public var fieldOfView: Double?
50 public var sensor: String
51 public var notes: String
52 /// Retired items stay in the inventory for old entries but leave the picker.
53 public var retired: Bool
54
55 public init(id: String = UUID().uuidString, kind: Kind, name: String, aperture: Double? = nil, focalLength: Double? = nil, magnification: Double? = nil, fieldOfView: Double? = nil, sensor: String = "", notes: String = "", retired: Bool = false) {
56 self.id = id
57 self.kind = kind
58 self.name = name
59 self.aperture = aperture
60 self.focalLength = focalLength
61 self.magnification = magnification
62 self.fieldOfView = fieldOfView
63 self.sensor = sensor
64 self.notes = notes
65 self.retired = retired
66 }
67
68 /// Focal length over aperture, when both are known.
69 public var focalRatio: Double? {
70 guard let aperture, let focalLength, aperture > 0 else { return nil }
71 return focalLength / aperture
72 }
73
74 static func number(_ x: Double) -> String {
75 x.formatted(.number.precision(.fractionLength(0...1)).grouping(.never).locale(Locale(identifier: "en_US_POSIX")))
76 }
77
78 /// Org text for this item: a heading, a property drawer and the notes.
79 public var org: String {
80 var props = [(":ID:", id), (":TYPE:", kind.rawValue)]
81 if let aperture { props.append((":APERTURE:", "\(Self.number(aperture)) mm")) }
82 if let focalLength { props.append((":FOCAL_LENGTH:", "\(Self.number(focalLength)) mm")) }
83 if let focalRatio { props.append((":FOCAL_RATIO:", "f/\(Self.number(focalRatio))")) }
84 if let magnification { props.append((":MAGNIFICATION:", "\(Self.number(magnification))x")) }
85 if let fieldOfView { props.append((":FIELD_OF_VIEW:", "\(Self.number(fieldOfView))°")) }
86 if !sensor.isEmpty { props.append((":SENSOR:", sensor)) }
87 if retired { props.append((":RETIRED:", "t")) }
88 var lines = ["* \(name)", ":PROPERTIES:"]
89 lines += props.map { "\($0.0) \($0.1)" }
90 lines.append(":END:")
91 let body = notes.trimmingCharacters(in: .whitespacesAndNewlines)
92 if !body.isEmpty { lines += ["", body] }
93 return lines.joined(separator: "\n") + "\n"
94 }
95
96 /// Parses one heading in the format written by `org`. Returns nil without a heading or an ID.
97 public init?(org text: String) {
98 let lines = text.components(separatedBy: "\n")
99 guard let head = lines.first, head.hasPrefix("* ") else { return nil }
100 var props: [String: String] = [:]
101 var i = 1
102 if i < lines.count, lines[i] == ":PROPERTIES:" {
103 i += 1
104 while i < lines.count, lines[i] != ":END:" {
105 let parts = lines[i].split(separator: " ", maxSplits: 1).map(String.init)
106 if parts.count == 2 { props[parts[0]] = parts[1] }
107 i += 1
108 }
109 i += 1
110 }
111 guard let id = props[":ID:"] else { return nil }
112 func leadingNumber(_ key: String) -> Double? {
113 props[key].flatMap { Double($0.prefix { $0.isNumber || $0 == "." }) }
114 }
115 self.init(
116 id: id,
117 kind: props[":TYPE:"].flatMap(Kind.init(rawValue:)) ?? .other,
118 name: String(head.dropFirst(2)),
119 aperture: leadingNumber(":APERTURE:"),
120 focalLength: leadingNumber(":FOCAL_LENGTH:"),
121 magnification: leadingNumber(":MAGNIFICATION:"),
122 fieldOfView: leadingNumber(":FIELD_OF_VIEW:"),
123 sensor: props[":SENSOR:"] ?? "",
124 notes: lines.dropFirst(i).joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines),
125 retired: props[":RETIRED:"] == "t"
126 )
127 }
128}
129
130/// The inventory as a single Org file (the app's Documents/Equipment.org).
131public struct EquipmentInventory: Sendable {
132 public var file: URL
133
134 public init(file: URL) { self.file = file }
135
136 public func all() -> [Equipment] {
137 guard let text = try? String(contentsOf: file, encoding: .utf8) else { return [] }
138 var chunks: [[String]] = []
139 for line in text.components(separatedBy: "\n") {
140 if line.hasPrefix("* ") { chunks.append([line]) } else if !chunks.isEmpty { chunks[chunks.count - 1].append(line) }
141 }
142 return chunks.compactMap { Equipment(org: $0.joined(separator: "\n")) }
143 }
144
145 /// Writes the item, replacing any earlier item with the same ID.
146 public func save(_ item: Equipment) throws {
147 var items = all()
148 if let i = items.firstIndex(where: { $0.id == item.id }) { items[i] = item } else { items.append(item) }
149 try write(items)
150 }
151
152 public func delete(id: String) throws {
153 try write(all().filter { $0.id != id })
154 }
155
156 private func write(_ items: [Equipment]) throws {
157 try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
158 let text = (["#+title: Equipment\n"] + items.map(\.org)).joined(separator: "\n")
159 try text.write(to: file, atomically: true, encoding: .utf8)
160 }
161}
Packages/MoonriseKit/Sources/ObservingLog/EquipmentUsage.swift added +82
@@ -0,0 +1,82 @@
1import Foundation
2
3/// How one inventory item has been used across the log.
4public struct EquipmentUsage: Sendable, Equatable {
5 public struct Tally: Sendable, Equatable {
6 public var key: String
7 public var count: Int
8 }
9
10 public enum Season: String, Sendable, CaseIterable {
11 case winter, spring, summer, autumn
12 public var title: String { rawValue.capitalized }
13 }
14
15 public var observations: Int
16 public var first: Date?
17 public var last: Date?
18 public var distinctObjects: Int
19 /// Object names, most observed first.
20 public var topObjects: [Tally]
21 /// Observations in each calendar month, January first.
22 public var byMonth: [Int]
23 /// Meteorological seasons for the observer's hemisphere (southern when latitude is negative).
24 public var bySeason: [Season: Int]
25 /// Mean of the observations with seeing noted.
26 public var averageSeeing: Double?
27 /// IDs of other items logged alongside this one, most frequent first.
28 public var usedWith: [Tally]
29
30 /// Usage of the item `id` in `log`. `calendar` decides month and season boundaries.
31 public init(of id: String, in log: [Observation], calendar: Calendar = .current) {
32 let mine = log.filter { $0.equipmentIDs.contains(id) }
33 observations = mine.count
34 first = mine.map(\.date).min()
35 last = mine.map(\.date).max()
36
37 var objects: [String: (name: String, count: Int)] = [:]
38 for o in mine {
39 let name = o.object.trimmingCharacters(in: .whitespaces)
40 objects[name.lowercased(), default: (name, 0)].count += 1
41 }
42 distinctObjects = objects.count
43 topObjects = objects.values.map { Tally(key: $0.name, count: $0.count) }.sorted(by: Self.order)
44
45 var months = Array(repeating: 0, count: 12)
46 var seasons: [Season: Int] = [:]
47 for o in mine {
48 let m = calendar.component(.month, from: o.date)
49 months[m - 1] += 1
50 seasons[Self.season(month: m, southern: (o.latitude ?? 0) < 0), default: 0] += 1
51 }
52 byMonth = months
53 bySeason = seasons
54
55 let seeing = mine.compactMap(\.seeing)
56 averageSeeing = seeing.isEmpty ? nil : Double(seeing.reduce(0, +)) / Double(seeing.count)
57
58 var others: [String: Int] = [:]
59 for o in mine { for other in Set(o.equipmentIDs) where other != id { others[other, default: 0] += 1 } }
60 usedWith = others.map { Tally(key: $0.key, count: $0.value) }.sorted(by: Self.order)
61 }
62
63 static func order(_ a: Tally, _ b: Tally) -> Bool {
64 a.count != b.count ? a.count > b.count : a.key < b.key
65 }
66
67 static func season(month: Int, southern: Bool) -> Season {
68 let north: Season = switch month {
69 case 12, 1, 2: .winter
70 case 3...5: .spring
71 case 6...8: .summer
72 default: .autumn
73 }
74 guard southern else { return north }
75 return switch north {
76 case .winter: .summer
77 case .spring: .autumn
78 case .summer: .winter
79 case .autumn: .spring
80 }
81 }
82}
Packages/MoonriseKit/Sources/ObservingLog/Observation.swift +7 −2
@@ -8,13 +8,15 @@ public struct Observation: Sendable, Identifiable, Equatable {
8 public var place: String 8 public var place: String
9 public var latitude: Double? 9 public var latitude: Double?
10 public var longitude: Double? 10 public var longitude: Double?
11 /// e.g. "Naked eye", "10x50 binoculars", "8-inch Dobsonian". 11 /// Readable names, e.g. "Naked eye" or "8-inch Dobsonian, 25 mm Plössl". Older entries have only this.
12 public var equipment: String 12 public var equipment: String
13 /// Inventory item IDs, in the order of `equipment`.
14 public var equipmentIDs: [String]
13 /// 1 (poor) to 5 (excellent); nil when not noted. 15 /// 1 (poor) to 5 (excellent); nil when not noted.
14 public var seeing: Int? 16 public var seeing: Int?
15 public var notes: String 17 public var notes: String
16 18
17 public init(id: String = UUID().uuidString, object: String, date: Date, place: String, latitude: Double? = nil, longitude: Double? = nil, equipment: String = "", seeing: Int? = nil, notes: String = "") { 19 public init(id: String = UUID().uuidString, object: String, date: Date, place: String, latitude: Double? = nil, longitude: Double? = nil, equipment: String = "", equipmentIDs: [String] = [], seeing: Int? = nil, notes: String = "") {
18 self.id = id 20 self.id = id
19 self.object = object 21 self.object = object
20 self.date = date 22 self.date = date
@@ -22,6 +24,7 @@ public struct Observation: Sendable, Identifiable, Equatable {
22 self.latitude = latitude 24 self.latitude = latitude
23 self.longitude = longitude 25 self.longitude = longitude
24 self.equipment = equipment 26 self.equipment = equipment
27 self.equipmentIDs = equipmentIDs
25 self.seeing = seeing 28 self.seeing = seeing
26 self.notes = notes 29 self.notes = notes
27 } 30 }
@@ -33,6 +36,7 @@ public struct Observation: Sendable, Identifiable, Equatable {
33 var props = [(":ID:", id), (":PLACE:", place)] 36 var props = [(":ID:", id), (":PLACE:", place)]
34 if let latitude, let longitude { props.append((":COORDINATES:", String(format: "%.4f, %.4f", latitude, longitude))) } 37 if let latitude, let longitude { props.append((":COORDINATES:", String(format: "%.4f, %.4f", latitude, longitude))) }
35 if !equipment.isEmpty { props.append((":EQUIPMENT:", equipment)) } 38 if !equipment.isEmpty { props.append((":EQUIPMENT:", equipment)) }
39 if !equipmentIDs.isEmpty { props.append((":EQUIPMENT_IDS:", equipmentIDs.joined(separator: " "))) }
36 if let seeing { props.append((":SEEING:", "\(seeing)/5")) } 40 if let seeing { props.append((":SEEING:", "\(seeing)/5")) }
37 var lines = ["* \(object)", "[\(date.formatted(Self.orgDate))]", ":PROPERTIES:"] 41 var lines = ["* \(object)", "[\(date.formatted(Self.orgDate))]", ":PROPERTIES:"]
38 lines += props.map { "\($0.0) \($0.1)" } 42 lines += props.map { "\($0.0) \($0.1)" }
@@ -69,6 +73,7 @@ public struct Observation: Sendable, Identifiable, Equatable {
69 latitude: coords?.count == 2 ? coords![0] : nil, 73 latitude: coords?.count == 2 ? coords![0] : nil,
70 longitude: coords?.count == 2 ? coords![1] : nil, 74 longitude: coords?.count == 2 ? coords![1] : nil,
71 equipment: props[":EQUIPMENT:"] ?? "", 75 equipment: props[":EQUIPMENT:"] ?? "",
76 equipmentIDs: props[":EQUIPMENT_IDS:"]?.split(separator: " ").map(String.init) ?? [],
72 seeing: props[":SEEING:"].flatMap { Int($0.prefix(1)) }, 77 seeing: props[":SEEING:"].flatMap { Int($0.prefix(1)) },
73 notes: notes 78 notes: notes
74 ) 79 )
Packages/MoonriseKit/Tests/ObservingLogTests/ObservingLogTests.swift +82
@@ -42,3 +42,85 @@ let when = Date(timeIntervalSince1970: 1_792_202_400) // 2026-10-17T02:00:00Z
42 #expect(log.all().map(\.object) == ["Moon"]) 42 #expect(log.all().map(\.object) == ["Moon"])
43 #expect(log.fileURL(for: a).lastPathComponent.hasSuffix("-moon.org")) 43 #expect(log.fileURL(for: a).lastPathComponent.hasSuffix("-moon.org"))
44} 44}
45
46@Test func equipmentIDsRoundTrip() throws {
47 let o = Observation(object: "M13", date: when, place: "Backyard", equipment: "8-inch Dobsonian, 25 mm Plössl", equipmentIDs: ["scope-1", "ep-2"])
48 #expect(o.org.contains(":EQUIPMENT: 8-inch Dobsonian, 25 mm Plössl\n:EQUIPMENT_IDS: scope-1 ep-2\n"))
49 let back = try #require(Observation(org: o.org))
50 #expect(back.equipment == o.equipment)
51 #expect(back.equipmentIDs == ["scope-1", "ep-2"])
52}
53
54@Test func freeTextEquipmentStillParses() throws {
55 let text = "* Saturn\n[2026-10-16 Fri 21:00]\n:PROPERTIES:\n:ID: abc\n:EQUIPMENT: 10x50 binoculars\n:END:\n"
56 let o = try #require(Observation(org: text))
57 #expect(o.equipment == "10x50 binoculars")
58 #expect(o.equipmentIDs.isEmpty)
59}
60
61@Test func equipmentOrgRoundTrip() throws {
62 let scope = Equipment(kind: .telescope, name: "8-inch Dobsonian", aperture: 203, focalLength: 1200, notes: "Collimated in May.")
63 let text = scope.org
64 #expect(text.hasPrefix("* 8-inch Dobsonian\n:PROPERTIES:\n"))
65 #expect(text.contains(":TYPE: telescope\n"))
66 #expect(text.contains(":APERTURE: 203 mm\n"))
67 #expect(text.contains(":FOCAL_LENGTH: 1200 mm\n"))
68 #expect(text.contains(":FOCAL_RATIO: f/5.9\n"))
69 #expect(!text.contains(":RETIRED:"))
70 let back = try #require(Equipment(org: text))
71 #expect(back == scope)
72
73 let binos = Equipment(kind: .binoculars, name: "10x50", aperture: 50, magnification: 10, fieldOfView: 6.5, retired: true)
74 #expect(binos.org.contains(":MAGNIFICATION: 10x\n"))
75 #expect(binos.org.contains(":FIELD_OF_VIEW: 6.5°\n"))
76 #expect(binos.org.contains(":RETIRED: t\n"))
77 #expect(Equipment(org: binos.org) == binos)
78
79 let cam = Equipment(kind: .camera, name: "ZWO", sensor: "IMX585")
80 #expect(Equipment(org: cam.org) == cam)
81}
82
83@Test func inventorySaveListDelete() throws {
84 let file = FileManager.default.temporaryDirectory.appending(path: "eq-\(UUID().uuidString)/Equipment.org")
85 defer { try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) }
86 let inv = EquipmentInventory(file: file)
87 #expect(inv.all().isEmpty)
88 var scope = Equipment(kind: .telescope, name: "Dob", aperture: 203, notes: "Line one.\n\nLine two.")
89 let ep = Equipment(kind: .eyepiece, name: "25 mm Plössl", focalLength: 25, fieldOfView: 52)
90 try inv.save(scope)
91 try inv.save(ep)
92 #expect(inv.all() == [scope, ep])
93 scope.retired = true
94 try inv.save(scope)
95 #expect(inv.all() == [scope, ep])
96 #expect(try String(contentsOf: file, encoding: .utf8).hasPrefix("#+title: Equipment\n"))
97 try inv.delete(id: scope.id)
98 #expect(inv.all() == [ep])
99}
100
101@Test func equipmentUsage() throws {
102 var cal = Calendar(identifier: .gregorian)
103 cal.timeZone = TimeZone(identifier: "UTC")!
104 func day(_ y: Int, _ m: Int, _ d: Int) -> Date { cal.date(from: DateComponents(year: y, month: m, day: d, hour: 3))! }
105 let log = [
106 Observation(object: "Saturn", date: day(2026, 1, 10), place: "", equipmentIDs: ["scope", "ep25"], seeing: 4),
107 Observation(object: "saturn ", date: day(2026, 7, 10), place: "", equipmentIDs: ["scope", "ep25"], seeing: 2),
108 Observation(object: "M31", date: day(2026, 10, 10), place: "", equipmentIDs: ["scope", "ep10"]),
109 Observation(object: "Moon", date: day(2026, 10, 11), place: "", latitude: -33.9, longitude: 18.4, equipmentIDs: ["scope"], seeing: 3),
110 Observation(object: "Jupiter", date: day(2026, 3, 1), place: "", equipmentIDs: ["binos"]),
111 Observation(object: "Mars", date: day(2026, 3, 2), place: "", equipment: "Telescope"),
112 ]
113 let u = EquipmentUsage(of: "scope", in: log, calendar: cal)
114 #expect(u.observations == 4)
115 #expect(u.first == day(2026, 1, 10))
116 #expect(u.last == day(2026, 10, 11))
117 #expect(u.distinctObjects == 3)
118 #expect(u.topObjects.first == .init(key: "Saturn", count: 2))
119 #expect(u.byMonth == [1, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0])
120 #expect(u.bySeason == [.winter: 1, .summer: 1, .autumn: 1, .spring: 1])
121 #expect(u.averageSeeing == 3)
122 #expect(u.usedWith == [.init(key: "ep25", count: 2), .init(key: "ep10", count: 1)])
123
124 let none = EquipmentUsage(of: "camera", in: log, calendar: cal)
125 #expect(none.observations == 0 && none.first == nil && none.averageSeeing == nil && none.usedWith.isEmpty)
126}