Commit 63ddcc6d55
Unsigned
Layout: unified · split
App/Sources/EquipmentScreen.swift added +327
| @@ -0,0 +1,327 @@ | |||
| 1 | import Charts | ||
| 2 | import ObservingLog | ||
| 3 | import SwiftUI | ||
| 4 | |||
| 5 | /// The equipment inventory lives in Documents/Equipment.org, one heading per item. | ||
| 6 | let equipmentInventory = EquipmentInventory(file: URL.documentsDirectory.appending(path: "Equipment.org")) | ||
| 7 | |||
| 8 | extension 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 | |||
| 28 | extension [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 | |||
| 36 | struct 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 | |||
| 90 | struct 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 | |||
| 157 | struct 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. | ||
| 232 | private 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. | ||
| 248 | struct 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 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// One item of the user's gear. The inventory is one Org file with a heading per item. | ||
| 4 | public 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). | ||
| 131 | public 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 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// How one inventory item has been used across the log. | ||
| 4 | public 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 | } | ||