diff --git a/Package.swift b/Package.swift index 30a4152..45eb7c6 100644 --- a/Package.swift +++ b/Package.swift @@ -5,10 +5,12 @@ let package = Package( name: "Stuff", platforms: [ .iOS(.v26), + .macOS(.v26), ], products: [ .library(name: "StuffCore", targets: ["StuffCore"]), .library(name: "WhereCore", targets: ["WhereCore"]), + .library(name: "WhereData", targets: ["WhereData"]), .library(name: "WhereUI", targets: ["WhereUI"]), .library(name: "WhereTesting", targets: ["WhereTesting"]), ], @@ -21,6 +23,13 @@ let package = Package( name: "WhereCore", path: "Where/WhereCore/Sources", ), + .target( + name: "WhereData", + dependencies: [ + .target(name: "WhereCore"), + ], + path: "Where/WhereData/Sources", + ), .target( name: "WhereUI", dependencies: [ diff --git a/Project.swift b/Project.swift index 5343c7d..5711a3b 100644 --- a/Project.swift +++ b/Project.swift @@ -3,7 +3,7 @@ import ProjectDescription let destinations: Destinations = [.iPhone, .iPad] let deployment: DeploymentTargets = .iOS("26.0") -/// Local Swift package (see root `Package.swift`) for StuffCore, WhereCore, WhereUI, and WhereTesting. +/// Local Swift package (see root `Package.swift`) for StuffCore, WhereCore, WhereData, WhereUI, and WhereTesting. private let stuffPackage = Package.local(path: .relativeToRoot(".")) func unitTests( @@ -44,10 +44,20 @@ let project = Project( infoPlist: .extendingDefault(with: [ "UILaunchScreen": .dictionary([:]), "UIApplicationSupportsIndirectInputEvents": .boolean(true), + "NSLocationAlwaysAndWhenInUseUsageDescription": .string("Where uses your location in the background to build day-by-day tax records."), + "NSLocationWhenInUseUsageDescription": .string("Where uses your location to classify the states you visit for tax reporting."), + "BGTaskSchedulerPermittedIdentifiers": .array([ + .string("com.stuff.where.app-refresh"), + ]), + "UIBackgroundModes": .array([ + .string("location"), + .string("processing"), + ]), ]), sources: ["Where/Where/Sources/**"], resources: ["Where/Where/Resources/**"], dependencies: [ + .package(product: "WhereData"), .package(product: "WhereUI"), ], ), @@ -98,6 +108,12 @@ let project = Project( productDependency: "WhereCore", sources: ["Where/WhereCore/Tests/**"], ), + unitTests( + name: "WhereDataTests", + bundleIdSuffix: "wheredata", + productDependency: "WhereData", + sources: ["Where/WhereData/Tests/**"], + ), unitTests( name: "WhereUITests", bundleIdSuffix: "whereui", @@ -105,4 +121,16 @@ let project = Project( sources: ["Where/WhereUI/Tests/**"], ), ], + schemes: [ + .scheme( + name: "WhereUITests", + shared: true, + buildAction: .buildAction(targets: ["WhereUITests"]), + testAction: .targets( + ["WhereUITests"], + configuration: .debug, + options: .options(coverage: true), + ), + ), + ], ) diff --git a/Where/AGENTS.md b/Where/AGENTS.md new file mode 100644 index 0000000..698b357 --- /dev/null +++ b/Where/AGENTS.md @@ -0,0 +1,35 @@ +# Where + +There **Where** app is an app designed to track how much time to spend in a given location, usually state by state within the US. + +Any time per day spent in a state counts entirely towards a day in that state. So it's possible to be in two states on a given day. + + + +## Build Information + +Repo-wide build, formatting, and agent-sync rules live in the root [`AGENTS.md`](../AGENTS.md). + +## Layout + +| Piece | Role | Path | +|-------|------|------| +| **Where** | iOS app (`com.stuff.where`) | [`Where/`](Where/) — [`Sources/`](Where/Sources/), [`Tests/`](Where/Tests/), [`Resources/`](Where/Resources/) | +| **WhereCore** | SPM library (domain / non-UI) | [`WhereCore/Sources/`](WhereCore/Sources/), [`Tests/`](WhereCore/Tests/) | +| **WhereUI** | SPM library (SwiftUI); depends on **WhereCore** | [`WhereUI/Sources/`](WhereUI/Sources/), [`Tests/`](WhereUI/Tests/) | +| **WhereTesting** | SPM test helpers for Where targets | [`WhereTesting/Sources/`](WhereTesting/Sources/) | + +The app target depends on the **WhereUI** package product only; **WhereUI** pulls in **WhereCore** via SPM. + +## Xcode / Tuist + +Targets are declared in the root [`Project.swift`](../Project.swift) and [`Package.swift`](../Package.swift): + +- **WhereTests** — tests the app; depends on the **Where** target and **WhereTesting** (no **StuffTestHost**). +- **WhereCoreTests** and **WhereUITests** — hosted unit tests (see `unitTests` in `Project.swift`): **StuffTestHost** + **WhereTesting** + the corresponding package product. + +Regenerate the Xcode project from the repo root with `./ide`. + +## Conventions + +Match the rest of the repo: **Swift Testing** only, bundle IDs under `com.stuff.*`, and shared cross-feature code in [`Shared/`](../Shared/) (**StuffCore**, etc.) rather than duplicating it under `Where/`. diff --git a/Where/Where/Sources/Tracking/AppRefreshCoordinator.swift b/Where/Where/Sources/Tracking/AppRefreshCoordinator.swift new file mode 100644 index 0000000..6aa58ed --- /dev/null +++ b/Where/Where/Sources/Tracking/AppRefreshCoordinator.swift @@ -0,0 +1,44 @@ +import BackgroundTasks +import Foundation +import WhereData + +@MainActor +final class AppRefreshCoordinator { + static let taskIdentifier = "com.stuff.where.app-refresh" + + private let trackingController: BackgroundTrackingController + + init(trackingController: BackgroundTrackingController) { + self.trackingController = trackingController + } + + func register() { + BGTaskScheduler.shared.register( + forTaskWithIdentifier: Self.taskIdentifier, + using: nil, + ) { [weak self] task in + guard let refreshTask = task as? BGAppRefreshTask else { + task.setTaskCompleted(success: false) + return + } + + self?.handle(refreshTask) + } + } + + func schedule() { + let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier) + request.earliestBeginDate = Date(timeIntervalSinceNow: 4 * 60 * 60) + try? BGTaskScheduler.shared.submit(request) + } + + private func handle(_ task: BGAppRefreshTask) { + schedule() + task.expirationHandler = {} + + Task { + await trackingController.refreshMonitoring() + task.setTaskCompleted(success: true) + } + } +} diff --git a/Where/Where/Sources/Tracking/AppleJurisdictionResolver.swift b/Where/Where/Sources/Tracking/AppleJurisdictionResolver.swift new file mode 100644 index 0000000..f55c1f7 --- /dev/null +++ b/Where/Where/Sources/Tracking/AppleJurisdictionResolver.swift @@ -0,0 +1,36 @@ +import CoreLocation +import Foundation +import WhereCore + +actor AppleJurisdictionResolver: JurisdictionResolving { + private let geocoder = CLGeocoder() + + func jurisdiction(for event: TrackingWakeEvent) async -> TaxJurisdiction { + let location = CLLocation( + latitude: event.latitude, + longitude: event.longitude, + ) + + do { + let placemarks = try await geocoder.reverseGeocodeLocation(location) + guard let placemark = placemarks.first else { + return .unknown + } + + guard placemark.isoCountryCode == "US" else { + return .unknown + } + + guard + let administrativeArea = placemark.administrativeArea, + let state = USState(rawValue: administrativeArea.uppercased()) + else { + return .unknown + } + + return .state(state) + } catch { + return .unknown + } + } +} diff --git a/Where/Where/Sources/Tracking/AppleLocationBridge.swift b/Where/Where/Sources/Tracking/AppleLocationBridge.swift new file mode 100644 index 0000000..abfed3b --- /dev/null +++ b/Where/Where/Sources/Tracking/AppleLocationBridge.swift @@ -0,0 +1,179 @@ +import CoreLocation +import Foundation +import WhereCore +import WhereData + +@MainActor +final class AppleLocationBridge: NSObject, CLLocationManagerDelegate, LocationAuthorizationProviding, LocationWakeSource { + private let locationManager: CLLocationManager + private weak var trackingController: BackgroundTrackingController? + private let regionRadius: CLLocationDistance = 150_000 + + init( + locationManager: CLLocationManager = CLLocationManager(), + trackingController: BackgroundTrackingController? = nil, + ) { + self.locationManager = locationManager + self.trackingController = trackingController + super.init() + self.locationManager.delegate = self + self.locationManager.desiredAccuracy = kCLLocationAccuracyKilometer + self.locationManager.allowsBackgroundLocationUpdates = true + self.locationManager.pausesLocationUpdatesAutomatically = true + } + + func attach(trackingController: BackgroundTrackingController) { + self.trackingController = trackingController + } + + func currentAuthorizationStatus() async -> TrackingAuthorizationStatus { + map(locationManager.authorizationStatus) + } + + func requestAlwaysAuthorization() async { + locationManager.requestAlwaysAuthorization() + } + + func startMonitoring(configuration: TrackingMonitoringConfiguration) async { + if configuration.wantsSignificantLocationChanges { + locationManager.startMonitoringSignificantLocationChanges() + } + + if configuration.wantsVisitMonitoring { + locationManager.startMonitoringVisits() + } + + refreshRegions(configuration: configuration) + } + + func refreshRegionMonitoring(configuration: TrackingMonitoringConfiguration) async { + refreshRegions(configuration: configuration) + } + + func stopMonitoring() async { + locationManager.stopMonitoringSignificantLocationChanges() + locationManager.stopMonitoringVisits() + + for region in locationManager.monitoredRegions { + locationManager.stopMonitoring(for: region) + } + } + + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + guard let trackingController else { return } + let status = map(manager.authorizationStatus) + Task { + await trackingController.handleAuthorizationStatusChange(status) + } + } + + func locationManager(_: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let trackingController, let location = locations.last else { return } + + let event = TrackingWakeEvent( + timestamp: location.timestamp, + reason: .significantLocationChange, + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude, + horizontalAccuracy: location.horizontalAccuracy, + ) + + Task { + await trackingController.handleWakeEvent(event) + } + } + + func locationManager(_: CLLocationManager, didVisit visit: CLVisit) { + guard let trackingController else { return } + + let event = TrackingWakeEvent( + timestamp: visit.arrivalDate == .distantPast ? Date() : visit.arrivalDate, + reason: .visit, + latitude: visit.coordinate.latitude, + longitude: visit.coordinate.longitude, + horizontalAccuracy: visit.horizontalAccuracy, + ) + + Task { + await trackingController.handleWakeEvent(event) + } + } + + func locationManager(_: CLLocationManager, didEnterRegion region: CLRegion) { + handleRegionWake(region: region) + } + + func locationManager(_: CLLocationManager, didExitRegion region: CLRegion) { + handleRegionWake(region: region) + } + + private func refreshRegions(configuration: TrackingMonitoringConfiguration) { + for region in locationManager.monitoredRegions { + locationManager.stopMonitoring(for: region) + } + + for jurisdiction in configuration.jurisdictionRegions.prefix(20) { + guard let region = circularRegion(for: jurisdiction) else { continue } + locationManager.startMonitoring(for: region) + } + } + + private func circularRegion(for jurisdiction: TaxJurisdiction) -> CLCircularRegion? { + let coordinate: CLLocationCoordinate2D + + switch jurisdiction { + case .state(.california): + coordinate = CLLocationCoordinate2D(latitude: 36.7783, longitude: -119.4179) + case .state(.newYork): + coordinate = CLLocationCoordinate2D(latitude: 42.9538, longitude: -75.5268) + case .state, .unknown: + return nil + } + + let region = CLCircularRegion( + center: coordinate, + radius: regionRadius, + identifier: jurisdiction.abbreviation, + ) + region.notifyOnEntry = true + region.notifyOnExit = true + return region + } + + private func handleRegionWake(region: CLRegion) { + guard + let trackingController, + let circularRegion = region as? CLCircularRegion + else { + return + } + + let event = TrackingWakeEvent( + timestamp: Date(), + reason: .regionBoundary, + latitude: circularRegion.center.latitude, + longitude: circularRegion.center.longitude, + ) + + Task { + await trackingController.handleWakeEvent(event) + } + } + + private func map(_ status: CLAuthorizationStatus) -> TrackingAuthorizationStatus { + switch status { + case .notDetermined: + .notDetermined + case .restricted: + .restricted + case .denied: + .denied + case .authorizedWhenInUse: + .authorizedWhenInUse + case .authorizedAlways: + .authorizedAlways + @unknown default: + .restricted + } + } +} diff --git a/Where/Where/Sources/Tracking/AppleNotificationScheduler.swift b/Where/Where/Sources/Tracking/AppleNotificationScheduler.swift new file mode 100644 index 0000000..d65e6d2 --- /dev/null +++ b/Where/Where/Sources/Tracking/AppleNotificationScheduler.swift @@ -0,0 +1,36 @@ +import Foundation +import UserNotifications +import WhereCore + +actor AppleNotificationScheduler: TrackingNotificationScheduling { + private let notificationCenter: UNUserNotificationCenter + + init(notificationCenter: UNUserNotificationCenter = .current()) { + self.notificationCenter = notificationCenter + } + + func schedule(_ request: TrackingNotificationRequest) async { + let content = UNMutableNotificationContent() + content.title = request.title + content.body = request.body + content.sound = .default + + let interval = max(request.deliverAt.timeIntervalSinceNow, 1) + let trigger = UNTimeIntervalNotificationTrigger( + timeInterval: interval, + repeats: false, + ) + let notificationRequest = UNNotificationRequest( + identifier: request.id, + content: content, + trigger: trigger, + ) + + try? await notificationCenter.add(notificationRequest) + } + + func cancel(ids: [String]) async { + guard !ids.isEmpty else { return } + notificationCenter.removePendingNotificationRequests(withIdentifiers: ids) + } +} diff --git a/Where/Where/Sources/WhereApp.swift b/Where/Where/Sources/WhereApp.swift index 9ccfbc0..a0d82ca 100644 --- a/Where/Where/Sources/WhereApp.swift +++ b/Where/Where/Sources/WhereApp.swift @@ -1,11 +1,71 @@ import SwiftUI +import UserNotifications +import WhereData import WhereUI @main struct WhereApp: App { + private let dataStore: WhereDataStore + private let trackingController: BackgroundTrackingController + private let appRefreshCoordinator: AppRefreshCoordinator + @State private var viewModel: RootViewModel + @State private var manualEntryViewModel: ManualEntryViewModel + + init() { + let dataStore = WhereDataStore.makeDefault() + let trackingStateController = TrackingStateController(store: dataStore.trackingStateStore) + let locationBridge = AppleLocationBridge() + let trackingController = BackgroundTrackingController( + locationRepository: dataStore.locationRepository, + authorizationProvider: locationBridge, + wakeSource: locationBridge, + jurisdictionResolver: AppleJurisdictionResolver(), + notificationScheduler: AppleNotificationScheduler(), + trackingStateController: trackingStateController, + ) + let appRefreshCoordinator = AppRefreshCoordinator(trackingController: trackingController) + locationBridge.attach(trackingController: trackingController) + + self.dataStore = dataStore + self.trackingController = trackingController + self.appRefreshCoordinator = appRefreshCoordinator + _viewModel = State( + initialValue: RootViewModel( + provider: dataStore.makeYearProgressController(), + ), + ) + _manualEntryViewModel = State( + initialValue: ManualEntryViewModel( + manager: dataStore.manualEntryController, + importer: dataStore.manualDataImportController, + exporter: dataStore.yearExportController, + yearDataProvider: dataStore.yearDataProvider, + resetter: dataStore.resetController, + ), + ) + + appRefreshCoordinator.register() + appRefreshCoordinator.schedule() + requestNotificationAuthorization() + } + var body: some Scene { WindowGroup { - RootView() + RootView( + viewModel: viewModel, + manualEntryViewModel: manualEntryViewModel, + ) + .task { + await trackingController.prepareForLaunch() + } + } + } + + private func requestNotificationAuthorization() { + Task { + _ = try? await UNUserNotificationCenter.current().requestAuthorization( + options: [.alert, .badge, .sound], + ) } } } diff --git a/Where/Where/Tests/WhereTests.swift b/Where/Where/Tests/WhereTests.swift index 70f3a88..e8410e8 100644 --- a/Where/Where/Tests/WhereTests.swift +++ b/Where/Where/Tests/WhereTests.swift @@ -1,6 +1,10 @@ import Testing +import WhereData @Test -func appModuleLoads() { - #expect(true) +func appDependenciesBuildYearSnapshot() async { + let controller = YearProgressController() + let years = await controller.availableYears() + + #expect(!years.isEmpty) } diff --git a/Where/WhereCore/Sources/Domain/DailyJurisdictionRecord.swift b/Where/WhereCore/Sources/Domain/DailyJurisdictionRecord.swift new file mode 100644 index 0000000..897569c --- /dev/null +++ b/Where/WhereCore/Sources/Domain/DailyJurisdictionRecord.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct DailyJurisdictionRecord: Equatable, Sendable { + public let date: Date + public let jurisdictions: [TaxJurisdiction] + + public init(date: Date, jurisdictions: [TaxJurisdiction]) { + self.date = date + self.jurisdictions = jurisdictions + } +} diff --git a/Where/WhereCore/Sources/Domain/DailyStateLedger.swift b/Where/WhereCore/Sources/Domain/DailyStateLedger.swift new file mode 100644 index 0000000..6f4eb97 --- /dev/null +++ b/Where/WhereCore/Sources/Domain/DailyStateLedger.swift @@ -0,0 +1,31 @@ +import Foundation + +public struct DailyStateLedger: Equatable, Sendable, Identifiable { + public let date: Date + public let trackedJurisdictions: [TaxJurisdiction] + public let manualEntries: [ManualLogEntry] + public let finalJurisdictions: [TaxJurisdiction] + public let note: String? + + public init( + date: Date, + trackedJurisdictions: [TaxJurisdiction], + manualEntries: [ManualLogEntry], + finalJurisdictions: [TaxJurisdiction], + note: String?, + ) { + self.date = date + self.trackedJurisdictions = trackedJurisdictions + self.manualEntries = manualEntries + self.finalJurisdictions = finalJurisdictions + self.note = note + } + + public var id: Date { + date + } + + public var needsReview: Bool { + finalJurisdictions.contains(.unknown) + } +} diff --git a/Where/WhereCore/Sources/Domain/EvidenceAttachment.swift b/Where/WhereCore/Sources/Domain/EvidenceAttachment.swift new file mode 100644 index 0000000..fa7b36a --- /dev/null +++ b/Where/WhereCore/Sources/Domain/EvidenceAttachment.swift @@ -0,0 +1,29 @@ +import Foundation + +public struct EvidenceAttachment: Codable, Equatable, Sendable, Identifiable { + public let id: UUID + public let manualEntryID: UUID + public let storageKey: String + public let originalFilename: String + public let contentType: String + public let byteCount: Int + public let createdAt: Date + + public init( + id: UUID = UUID(), + manualEntryID: UUID, + storageKey: String? = nil, + originalFilename: String, + contentType: String, + byteCount: Int, + createdAt: Date = Date(), + ) { + self.id = id + self.manualEntryID = manualEntryID + self.storageKey = storageKey ?? id.uuidString + self.originalFilename = originalFilename + self.contentType = contentType + self.byteCount = byteCount + self.createdAt = createdAt + } +} diff --git a/Where/WhereCore/Sources/Domain/LocationSample.swift b/Where/WhereCore/Sources/Domain/LocationSample.swift new file mode 100644 index 0000000..85396da --- /dev/null +++ b/Where/WhereCore/Sources/Domain/LocationSample.swift @@ -0,0 +1,17 @@ +import Foundation + +public struct LocationSample: Codable, Equatable, Sendable, Hashable, Identifiable { + public let id: UUID + public let timestamp: Date + public let jurisdiction: TaxJurisdiction + + public init( + id: UUID = UUID(), + timestamp: Date, + jurisdiction: TaxJurisdiction, + ) { + self.id = id + self.timestamp = timestamp + self.jurisdiction = jurisdiction + } +} diff --git a/Where/WhereCore/Sources/Domain/ManualEntryDraft.swift b/Where/WhereCore/Sources/Domain/ManualEntryDraft.swift new file mode 100644 index 0000000..f00b2dc --- /dev/null +++ b/Where/WhereCore/Sources/Domain/ManualEntryDraft.swift @@ -0,0 +1,38 @@ +import Foundation + +public struct ManualEntryDraft: Equatable, Sendable { + public let id: UUID? + public let timestamp: Date + public let jurisdiction: TaxJurisdiction + public let note: String + public let kind: ManualLogEntry.Kind + + public init( + id: UUID? = nil, + timestamp: Date, + jurisdiction: TaxJurisdiction, + note: String = "", + kind: ManualLogEntry.Kind, + ) { + self.id = id + self.timestamp = timestamp + self.jurisdiction = jurisdiction + self.note = note + self.kind = kind + } + + public init(entry: ManualLogEntry) { + self.init( + id: entry.id, + timestamp: entry.timestamp, + jurisdiction: entry.jurisdiction, + note: entry.note ?? "", + kind: entry.kind, + ) + } + + public var trimmedNote: String? { + let trimmed = note.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Where/WhereCore/Sources/Domain/ManualEntryRecord.swift b/Where/WhereCore/Sources/Domain/ManualEntryRecord.swift new file mode 100644 index 0000000..d52581e --- /dev/null +++ b/Where/WhereCore/Sources/Domain/ManualEntryRecord.swift @@ -0,0 +1,18 @@ +import Foundation + +public struct ManualEntryRecord: Equatable, Sendable, Identifiable { + public let entry: ManualLogEntry + public let attachments: [EvidenceAttachment] + + public init( + entry: ManualLogEntry, + attachments: [EvidenceAttachment], + ) { + self.entry = entry + self.attachments = attachments + } + + public var id: UUID { + entry.id + } +} diff --git a/Where/WhereCore/Sources/Domain/ManualImportBackfillRequest.swift b/Where/WhereCore/Sources/Domain/ManualImportBackfillRequest.swift new file mode 100644 index 0000000..4bcbab0 --- /dev/null +++ b/Where/WhereCore/Sources/Domain/ManualImportBackfillRequest.swift @@ -0,0 +1,26 @@ +import Foundation + +public struct ManualImportBackfillRequest: Equatable, Sendable { + public let startDate: Date + public let endDate: Date + public let jurisdiction: TaxJurisdiction + public let note: String + public let kind: ManualLogEntry.Kind + public let evidenceFiles: [URL] + + public init( + startDate: Date, + endDate: Date, + jurisdiction: TaxJurisdiction, + note: String = "", + kind: ManualLogEntry.Kind, + evidenceFiles: [URL] = [], + ) { + self.startDate = startDate + self.endDate = endDate + self.jurisdiction = jurisdiction + self.note = note + self.kind = kind + self.evidenceFiles = evidenceFiles + } +} diff --git a/Where/WhereCore/Sources/Domain/ManualImportEntryDraft.swift b/Where/WhereCore/Sources/Domain/ManualImportEntryDraft.swift new file mode 100644 index 0000000..f48572b --- /dev/null +++ b/Where/WhereCore/Sources/Domain/ManualImportEntryDraft.swift @@ -0,0 +1,46 @@ +import Foundation + +public struct ManualImportEntryDraft: Equatable, Sendable, Identifiable { + public let id: UUID + public let timestamp: Date + public let jurisdiction: TaxJurisdiction + public let note: String + public let kind: ManualLogEntry.Kind + public let evidenceFiles: [URL] + + public init( + id: UUID = UUID(), + timestamp: Date, + jurisdiction: TaxJurisdiction, + note: String = "", + kind: ManualLogEntry.Kind, + evidenceFiles: [URL] = [], + ) { + self.id = id + self.timestamp = timestamp + self.jurisdiction = jurisdiction + self.note = note + self.kind = kind + self.evidenceFiles = evidenceFiles + } + + public var manualEntryDraft: ManualEntryDraft { + ManualEntryDraft( + id: id, + timestamp: timestamp, + jurisdiction: jurisdiction, + note: note, + kind: kind, + ) + } + + public var manualLogEntry: ManualLogEntry { + ManualLogEntry( + id: id, + timestamp: timestamp, + jurisdiction: jurisdiction, + note: manualEntryDraft.trimmedNote, + kind: kind, + ) + } +} diff --git a/Where/WhereCore/Sources/Domain/ManualImportPackageManifest.swift b/Where/WhereCore/Sources/Domain/ManualImportPackageManifest.swift new file mode 100644 index 0000000..39455bc --- /dev/null +++ b/Where/WhereCore/Sources/Domain/ManualImportPackageManifest.swift @@ -0,0 +1,78 @@ +import Foundation + +public struct ManualImportPackageManifest: Codable, Equatable, Sendable { + public let entries: [Entry] + + public init(entries: [Entry]) { + self.entries = entries + } + + public struct Entry: Codable, Equatable, Sendable { + public let timestamp: Date + public let jurisdiction: Jurisdiction + public let note: String? + public let kind: ManualLogEntry.Kind + public let evidenceFilenames: [String] + + public init( + timestamp: Date, + jurisdiction: Jurisdiction, + note: String? = nil, + kind: ManualLogEntry.Kind, + evidenceFilenames: [String] = [], + ) { + self.timestamp = timestamp + self.jurisdiction = jurisdiction + self.note = note + self.kind = kind + self.evidenceFilenames = evidenceFilenames + } + } + + public enum Jurisdiction: Codable, Equatable, Sendable { + case state(String) + case unknown + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self).trimmingCharacters(in: .whitespacesAndNewlines) + + if value.caseInsensitiveCompare("unknown") == .orderedSame { + self = .unknown + } else { + self = .state(value.uppercased()) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .state(rawValue): + try container.encode(rawValue) + case .unknown: + try container.encode("UNKNOWN") + } + } + + public var displayValue: String { + switch self { + case let .state(rawValue): + rawValue + case .unknown: + "UNKNOWN" + } + } + + public func resolve() -> TaxJurisdiction? { + switch self { + case let .state(rawValue): + guard let state = USState(rawValue: rawValue.uppercased()) else { + return nil + } + return .state(state) + case .unknown: + return .unknown + } + } + } +} diff --git a/Where/WhereCore/Sources/Domain/ManualImportPreview.swift b/Where/WhereCore/Sources/Domain/ManualImportPreview.swift new file mode 100644 index 0000000..01c41fe --- /dev/null +++ b/Where/WhereCore/Sources/Domain/ManualImportPreview.swift @@ -0,0 +1,55 @@ +import Foundation + +public struct ManualImportPreview: Equatable, Sendable { + public let yearSpan: ClosedRange? + public let entryCount: Int + public let evidenceAttachmentCount: Int + public let sharedEvidenceAttachmentCount: Int + public let entries: [ManualImportEntryDraft] + public let issues: [Issue] + + public init( + yearSpan: ClosedRange?, + entryCount: Int, + evidenceAttachmentCount: Int, + sharedEvidenceAttachmentCount: Int, + entries: [ManualImportEntryDraft], + issues: [Issue] = [], + ) { + self.yearSpan = yearSpan + self.entryCount = entryCount + self.evidenceAttachmentCount = evidenceAttachmentCount + self.sharedEvidenceAttachmentCount = sharedEvidenceAttachmentCount + self.entries = entries + self.issues = issues + } + + public var isValid: Bool { + !entries.isEmpty && !issues.contains(where: { $0.severity == .error }) + } + + public var hasWarnings: Bool { + issues.contains(where: { $0.severity == .warning }) + } + + public struct Issue: Equatable, Sendable, Identifiable { + public enum Severity: String, Equatable, Sendable { + case error + case warning + } + + public let id: UUID + public let severity: Severity + public let message: String + + public init( + id: UUID = UUID(), + severity: Severity, + message: String, + ) { + self.id = id + self.severity = severity + self.message = message + } + } +} diff --git a/Where/WhereCore/Sources/Domain/ManualLogEntry.swift b/Where/WhereCore/Sources/Domain/ManualLogEntry.swift new file mode 100644 index 0000000..4316d0d --- /dev/null +++ b/Where/WhereCore/Sources/Domain/ManualLogEntry.swift @@ -0,0 +1,28 @@ +import Foundation + +public struct ManualLogEntry: Codable, Equatable, Sendable, Identifiable, Hashable { + public enum Kind: String, Codable, Equatable, Sendable { + case supplemental + case correction + } + + public let id: UUID + public let timestamp: Date + public let jurisdiction: TaxJurisdiction + public let note: String? + public let kind: Kind + + public init( + id: UUID = UUID(), + timestamp: Date, + jurisdiction: TaxJurisdiction, + note: String? = nil, + kind: Kind, + ) { + self.id = id + self.timestamp = timestamp + self.jurisdiction = jurisdiction + self.note = note + self.kind = kind + } +} diff --git a/Where/WhereCore/Sources/Domain/SyncCheckpoint.swift b/Where/WhereCore/Sources/Domain/SyncCheckpoint.swift new file mode 100644 index 0000000..5e33353 --- /dev/null +++ b/Where/WhereCore/Sources/Domain/SyncCheckpoint.swift @@ -0,0 +1,28 @@ +import Foundation + +public struct SyncCheckpoint: Codable, Equatable, Sendable { + public enum State: String, Codable, Sendable { + case idle + case pendingUpload + case pendingDownload + case syncing + case failed + } + + public let state: State + public let lastSuccessfulSyncAt: Date? + public let lastAttemptAt: Date? + public let failureReason: String? + + public init( + state: State, + lastSuccessfulSyncAt: Date? = nil, + lastAttemptAt: Date? = nil, + failureReason: String? = nil, + ) { + self.state = state + self.lastSuccessfulSyncAt = lastSuccessfulSyncAt + self.lastAttemptAt = lastAttemptAt + self.failureReason = failureReason + } +} diff --git a/Where/WhereCore/Sources/Domain/TaxJurisdiction.swift b/Where/WhereCore/Sources/Domain/TaxJurisdiction.swift new file mode 100644 index 0000000..614a49b --- /dev/null +++ b/Where/WhereCore/Sources/Domain/TaxJurisdiction.swift @@ -0,0 +1,38 @@ +public enum TaxJurisdiction: Codable, Sendable, Hashable { + case state(USState) + case unknown + + public static let california = Self.state(.california) + public static let newYork = Self.state(.newYork) + + public var displayName: String { + switch self { + case let .state(state): + state.displayName + case .unknown: + "Unknown" + } + } + + public var abbreviation: String { + switch self { + case let .state(state): + state.rawValue + case .unknown: + "UNK" + } + } + + public var countsTowardTaxDay: Bool { + self != .unknown + } + + public var usState: USState? { + switch self { + case let .state(state): + state + case .unknown: + nil + } + } +} diff --git a/Where/WhereCore/Sources/Domain/TrackingAuthorizationStatus.swift b/Where/WhereCore/Sources/Domain/TrackingAuthorizationStatus.swift new file mode 100644 index 0000000..c97018a --- /dev/null +++ b/Where/WhereCore/Sources/Domain/TrackingAuthorizationStatus.swift @@ -0,0 +1,11 @@ +public enum TrackingAuthorizationStatus: String, Codable, Sendable { + case notDetermined + case authorizedWhenInUse + case authorizedAlways + case denied + case restricted + + public var isBackgroundAuthorized: Bool { + self == .authorizedAlways + } +} diff --git a/Where/WhereCore/Sources/Domain/TrackingGap.swift b/Where/WhereCore/Sources/Domain/TrackingGap.swift new file mode 100644 index 0000000..95100ff --- /dev/null +++ b/Where/WhereCore/Sources/Domain/TrackingGap.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct TrackingGap: Equatable, Sendable { + public let date: Date + public let reason: String + + public init(date: Date, reason: String) { + self.date = date + self.reason = reason + } +} diff --git a/Where/WhereCore/Sources/Domain/TrackingMonitoringConfiguration.swift b/Where/WhereCore/Sources/Domain/TrackingMonitoringConfiguration.swift new file mode 100644 index 0000000..c055509 --- /dev/null +++ b/Where/WhereCore/Sources/Domain/TrackingMonitoringConfiguration.swift @@ -0,0 +1,20 @@ +import Foundation + +public struct TrackingMonitoringConfiguration: Equatable, Sendable { + public let jurisdictionRegions: [TaxJurisdiction] + public let wantsSignificantLocationChanges: Bool + public let wantsVisitMonitoring: Bool + public let wakeNotificationHour: Int + + public init( + jurisdictionRegions: [TaxJurisdiction] = [.california, .newYork], + wantsSignificantLocationChanges: Bool = true, + wantsVisitMonitoring: Bool = true, + wakeNotificationHour: Int = 20, + ) { + self.jurisdictionRegions = jurisdictionRegions + self.wantsSignificantLocationChanges = wantsSignificantLocationChanges + self.wantsVisitMonitoring = wantsVisitMonitoring + self.wakeNotificationHour = wakeNotificationHour + } +} diff --git a/Where/WhereCore/Sources/Domain/TrackingNotificationRequest.swift b/Where/WhereCore/Sources/Domain/TrackingNotificationRequest.swift new file mode 100644 index 0000000..c3bcf8a --- /dev/null +++ b/Where/WhereCore/Sources/Domain/TrackingNotificationRequest.swift @@ -0,0 +1,20 @@ +import Foundation + +public struct TrackingNotificationRequest: Equatable, Sendable { + public let id: String + public let title: String + public let body: String + public let deliverAt: Date + + public init( + id: String, + title: String, + body: String, + deliverAt: Date, + ) { + self.id = id + self.title = title + self.body = body + self.deliverAt = deliverAt + } +} diff --git a/Where/WhereCore/Sources/Domain/TrackingState.swift b/Where/WhereCore/Sources/Domain/TrackingState.swift new file mode 100644 index 0000000..c094449 --- /dev/null +++ b/Where/WhereCore/Sources/Domain/TrackingState.swift @@ -0,0 +1,53 @@ +import Foundation + +public struct TrackingState: Codable, Equatable, Sendable { + public let authorizationStatus: TrackingAuthorizationStatus + public let lastWakeEventAt: Date? + public let lastRecordedSampleAt: Date? + public let lastWakeReason: TrackingWakeReason? + public let pendingGapNotificationDates: [Date] + public let isMonitoringActive: Bool + + public init( + authorizationStatus: TrackingAuthorizationStatus, + lastWakeEventAt: Date? = nil, + lastRecordedSampleAt: Date? = nil, + lastWakeReason: TrackingWakeReason? = nil, + pendingGapNotificationDates: [Date] = [], + isMonitoringActive: Bool = false, + ) { + self.authorizationStatus = authorizationStatus + self.lastWakeEventAt = lastWakeEventAt + self.lastRecordedSampleAt = lastRecordedSampleAt + self.lastWakeReason = lastWakeReason + self.pendingGapNotificationDates = pendingGapNotificationDates + self.isMonitoringActive = isMonitoringActive + } + + public func runtimeStatus( + at referenceDate: Date, + staleInterval: TimeInterval = 36 * 60 * 60, + ) -> TrackingStatus { + guard authorizationStatus.isBackgroundAuthorized else { + return .needsAttention + } + + guard isMonitoringActive else { + return .needsAttention + } + + guard let lastRecordedSampleAt else { + return .needsAttention + } + + if referenceDate.timeIntervalSince(lastRecordedSampleAt) > staleInterval { + return .needsAttention + } + + if let lastWakeEventAt, lastWakeEventAt > lastRecordedSampleAt { + return .needsReview + } + + return .healthy + } +} diff --git a/Where/WhereCore/Sources/Domain/TrackingStatus.swift b/Where/WhereCore/Sources/Domain/TrackingStatus.swift new file mode 100644 index 0000000..256f04c --- /dev/null +++ b/Where/WhereCore/Sources/Domain/TrackingStatus.swift @@ -0,0 +1,16 @@ +public enum TrackingStatus: String, Codable, Sendable { + case healthy + case needsReview + case needsAttention + + public var title: String { + switch self { + case .healthy: + "Tracking is healthy" + case .needsReview: + "Review recent activity" + case .needsAttention: + "Open the app" + } + } +} diff --git a/Where/WhereCore/Sources/Domain/TrackingWakeEvent.swift b/Where/WhereCore/Sources/Domain/TrackingWakeEvent.swift new file mode 100644 index 0000000..015b8a8 --- /dev/null +++ b/Where/WhereCore/Sources/Domain/TrackingWakeEvent.swift @@ -0,0 +1,23 @@ +import Foundation + +public struct TrackingWakeEvent: Equatable, Sendable { + public let timestamp: Date + public let reason: TrackingWakeReason + public let latitude: Double + public let longitude: Double + public let horizontalAccuracy: Double? + + public init( + timestamp: Date, + reason: TrackingWakeReason, + latitude: Double, + longitude: Double, + horizontalAccuracy: Double? = nil, + ) { + self.timestamp = timestamp + self.reason = reason + self.latitude = latitude + self.longitude = longitude + self.horizontalAccuracy = horizontalAccuracy + } +} diff --git a/Where/WhereCore/Sources/Domain/TrackingWakeReason.swift b/Where/WhereCore/Sources/Domain/TrackingWakeReason.swift new file mode 100644 index 0000000..126f14e --- /dev/null +++ b/Where/WhereCore/Sources/Domain/TrackingWakeReason.swift @@ -0,0 +1,25 @@ +public enum TrackingWakeReason: String, Codable, Sendable { + case appLaunch + case significantLocationChange + case visit + case regionBoundary + case backgroundRefresh + case manualRefresh + + public var title: String { + switch self { + case .appLaunch: + "App Launch" + case .significantLocationChange: + "Significant Change" + case .visit: + "Visit" + case .regionBoundary: + "Region Boundary" + case .backgroundRefresh: + "Background Refresh" + case .manualRefresh: + "Manual Refresh" + } + } +} diff --git a/Where/WhereCore/Sources/Domain/USState.swift b/Where/WhereCore/Sources/Domain/USState.swift new file mode 100644 index 0000000..0b41215 --- /dev/null +++ b/Where/WhereCore/Sources/Domain/USState.swift @@ -0,0 +1,107 @@ +public enum USState: String, Codable, Sendable, CaseIterable, Hashable { + case alabama = "AL" + case alaska = "AK" + case arizona = "AZ" + case arkansas = "AR" + case california = "CA" + case colorado = "CO" + case connecticut = "CT" + case delaware = "DE" + case florida = "FL" + case georgia = "GA" + case hawaii = "HI" + case idaho = "ID" + case illinois = "IL" + case indiana = "IN" + case iowa = "IA" + case kansas = "KS" + case kentucky = "KY" + case louisiana = "LA" + case maine = "ME" + case maryland = "MD" + case massachusetts = "MA" + case michigan = "MI" + case minnesota = "MN" + case mississippi = "MS" + case missouri = "MO" + case montana = "MT" + case nebraska = "NE" + case nevada = "NV" + case newHampshire = "NH" + case newJersey = "NJ" + case newMexico = "NM" + case newYork = "NY" + case northCarolina = "NC" + case northDakota = "ND" + case ohio = "OH" + case oklahoma = "OK" + case oregon = "OR" + case pennsylvania = "PA" + case rhodeIsland = "RI" + case southCarolina = "SC" + case southDakota = "SD" + case tennessee = "TN" + case texas = "TX" + case utah = "UT" + case vermont = "VT" + case virginia = "VA" + case washington = "WA" + case westVirginia = "WV" + case wisconsin = "WI" + case wyoming = "WY" + + public var displayName: String { + switch self { + case .alabama: "Alabama" + case .alaska: "Alaska" + case .arizona: "Arizona" + case .arkansas: "Arkansas" + case .california: "California" + case .colorado: "Colorado" + case .connecticut: "Connecticut" + case .delaware: "Delaware" + case .florida: "Florida" + case .georgia: "Georgia" + case .hawaii: "Hawaii" + case .idaho: "Idaho" + case .illinois: "Illinois" + case .indiana: "Indiana" + case .iowa: "Iowa" + case .kansas: "Kansas" + case .kentucky: "Kentucky" + case .louisiana: "Louisiana" + case .maine: "Maine" + case .maryland: "Maryland" + case .massachusetts: "Massachusetts" + case .michigan: "Michigan" + case .minnesota: "Minnesota" + case .mississippi: "Mississippi" + case .missouri: "Missouri" + case .montana: "Montana" + case .nebraska: "Nebraska" + case .nevada: "Nevada" + case .newHampshire: "New Hampshire" + case .newJersey: "New Jersey" + case .newMexico: "New Mexico" + case .newYork: "New York" + case .northCarolina: "North Carolina" + case .northDakota: "North Dakota" + case .ohio: "Ohio" + case .oklahoma: "Oklahoma" + case .oregon: "Oregon" + case .pennsylvania: "Pennsylvania" + case .rhodeIsland: "Rhode Island" + case .southCarolina: "South Carolina" + case .southDakota: "South Dakota" + case .tennessee: "Tennessee" + case .texas: "Texas" + case .utah: "Utah" + case .vermont: "Vermont" + case .virginia: "Virginia" + case .washington: "Washington" + case .westVirginia: "West Virginia" + case .wisconsin: "Wisconsin" + case .wyoming: "Wyoming" + } + } +} diff --git a/Where/WhereCore/Sources/Domain/YearDataBundle.swift b/Where/WhereCore/Sources/Domain/YearDataBundle.swift new file mode 100644 index 0000000..65bb4b7 --- /dev/null +++ b/Where/WhereCore/Sources/Domain/YearDataBundle.swift @@ -0,0 +1,26 @@ +import Foundation + +public struct YearDataBundle: Equatable, Sendable { + public let year: Int + public let locationSamples: [LocationSample] + public let manualEntries: [ManualLogEntry] + public let evidenceAttachments: [EvidenceAttachment] + public let syncCheckpoint: SyncCheckpoint + public let trackingState: TrackingState? + + public init( + year: Int, + locationSamples: [LocationSample], + manualEntries: [ManualLogEntry], + evidenceAttachments: [EvidenceAttachment], + syncCheckpoint: SyncCheckpoint, + trackingState: TrackingState? = nil, + ) { + self.year = year + self.locationSamples = locationSamples + self.manualEntries = manualEntries + self.evidenceAttachments = evidenceAttachments + self.syncCheckpoint = syncCheckpoint + self.trackingState = trackingState + } +} diff --git a/Where/WhereCore/Sources/Domain/YearExportBundle.swift b/Where/WhereCore/Sources/Domain/YearExportBundle.swift new file mode 100644 index 0000000..5d5bcef --- /dev/null +++ b/Where/WhereCore/Sources/Domain/YearExportBundle.swift @@ -0,0 +1,28 @@ +import Foundation + +public struct YearExportBundle: Equatable, Sendable { + public let year: Int + public let generatedAt: Date + public let plaintext: String + public let pdfData: Data + + public init( + year: Int, + generatedAt: Date = Date(), + plaintext: String, + pdfData: Data, + ) { + self.year = year + self.generatedAt = generatedAt + self.plaintext = plaintext + self.pdfData = pdfData + } + + public var plaintextFilename: String { + "where-\(year)-report.txt" + } + + public var pdfFilename: String { + "where-\(year)-report.pdf" + } +} diff --git a/Where/WhereCore/Sources/Domain/YearProgressSnapshot.swift b/Where/WhereCore/Sources/Domain/YearProgressSnapshot.swift new file mode 100644 index 0000000..4bf3c48 --- /dev/null +++ b/Where/WhereCore/Sources/Domain/YearProgressSnapshot.swift @@ -0,0 +1,55 @@ +public struct YearProgressSnapshot: Equatable, Sendable { + public struct JurisdictionSummary: Identifiable, Equatable, Sendable { + public let jurisdiction: TaxJurisdiction + public let totalDays: Int + + public init(jurisdiction: TaxJurisdiction, totalDays: Int) { + self.jurisdiction = jurisdiction + self.totalDays = totalDays + } + + public var id: TaxJurisdiction { + jurisdiction + } + } + + public struct RecentDay: Identifiable, Equatable, Sendable { + public let dateLabel: String + public let jurisdictions: [TaxJurisdiction] + public let note: String? + + public init( + dateLabel: String, + jurisdictions: [TaxJurisdiction], + note: String? = nil, + ) { + self.dateLabel = dateLabel + self.jurisdictions = jurisdictions + self.note = note + } + + public var id: String { + dateLabel + } + } + + public let year: Int + public let primarySummaries: [JurisdictionSummary] + public let secondarySummaries: [JurisdictionSummary] + public let trackingStatus: TrackingStatus + public let recentDays: [RecentDay] + + public init( + year: Int, + primarySummaries: [JurisdictionSummary], + secondarySummaries: [JurisdictionSummary], + trackingStatus: TrackingStatus, + recentDays: [RecentDay], + ) { + self.year = year + self.primarySummaries = primarySummaries + self.secondarySummaries = secondarySummaries + self.trackingStatus = trackingStatus + self.recentDays = recentDays + } +} diff --git a/Where/WhereCore/Sources/Domain/YearSummary.swift b/Where/WhereCore/Sources/Domain/YearSummary.swift new file mode 100644 index 0000000..52f3299 --- /dev/null +++ b/Where/WhereCore/Sources/Domain/YearSummary.swift @@ -0,0 +1,18 @@ +public struct YearSummary: Equatable, Sendable { + public let year: Int + public let totalsByJurisdiction: [TaxJurisdiction: Int] + public let unknownDayCount: Int + public let totalTrackedDays: Int + + public init( + year: Int, + totalsByJurisdiction: [TaxJurisdiction: Int], + unknownDayCount: Int, + totalTrackedDays: Int, + ) { + self.year = year + self.totalsByJurisdiction = totalsByJurisdiction + self.unknownDayCount = unknownDayCount + self.totalTrackedDays = totalTrackedDays + } +} diff --git a/Where/WhereCore/Sources/Policies/TaxDayCalculator.swift b/Where/WhereCore/Sources/Policies/TaxDayCalculator.swift new file mode 100644 index 0000000..f88c126 --- /dev/null +++ b/Where/WhereCore/Sources/Policies/TaxDayCalculator.swift @@ -0,0 +1,50 @@ +import Foundation + +public struct TaxDayCalculator: Sendable { + private let calendar: Calendar + + public init(calendar: Calendar = .current) { + self.calendar = calendar + } + + public func makeDailyRecords(from samples: [LocationSample]) -> [DailyJurisdictionRecord] { + let grouped = Dictionary(grouping: samples) { sample in + calendar.startOfDay(for: sample.timestamp) + } + + return grouped + .map { date, entries in + let jurisdictions = orderedJurisdictions( + from: entries.map(\.jurisdiction), + ) + + return DailyJurisdictionRecord(date: date, jurisdictions: jurisdictions) + } + .sorted { $0.date < $1.date } + } + + public func countDaysByJurisdiction( + from records: [DailyJurisdictionRecord], + ) -> [TaxJurisdiction: Int] { + var totals: [TaxJurisdiction: Int] = [:] + + for record in records { + for jurisdiction in record.jurisdictions where jurisdiction.countsTowardTaxDay { + totals[jurisdiction, default: 0] += 1 + } + } + + return totals + } + + private func orderedJurisdictions(from jurisdictions: [TaxJurisdiction]) -> [TaxJurisdiction] { + var seen = Set() + var ordered: [TaxJurisdiction] = [] + + for jurisdiction in jurisdictions where seen.insert(jurisdiction).inserted { + ordered.append(jurisdiction) + } + + return ordered.sorted { $0.displayName < $1.displayName } + } +} diff --git a/Where/WhereCore/Sources/Policies/YearLedgerBuilder.swift b/Where/WhereCore/Sources/Policies/YearLedgerBuilder.swift new file mode 100644 index 0000000..dac5463 --- /dev/null +++ b/Where/WhereCore/Sources/Policies/YearLedgerBuilder.swift @@ -0,0 +1,117 @@ +import Foundation + +public struct YearLedgerBuilder: Sendable { + private let calendar: Calendar + private let taxDayCalculator: TaxDayCalculator + + public init(calendar: Calendar = .current) { + self.calendar = calendar + taxDayCalculator = TaxDayCalculator(calendar: calendar) + } + + public func makeLedgers( + year: Int, + samples: [LocationSample], + manualEntries: [ManualLogEntry], + ) -> [DailyStateLedger] { + let yearSamples = samples.filter { calendar.component(.year, from: $0.timestamp) == year } + let yearEntries = manualEntries.filter { calendar.component(.year, from: $0.timestamp) == year } + + let trackedRecords = taxDayCalculator.makeDailyRecords(from: yearSamples) + let recordsByDay = Dictionary(uniqueKeysWithValues: trackedRecords.map { ($0.date, $0) }) + let entriesByDay = Dictionary(grouping: yearEntries) { entry in + calendar.startOfDay(for: entry.timestamp) + } + + let allDays = Set(recordsByDay.keys).union(entriesByDay.keys).sorted() + + return allDays.map { day in + let trackedJurisdictions = recordsByDay[day]?.jurisdictions ?? [] + let manualForDay = (entriesByDay[day] ?? []).sorted { $0.timestamp < $1.timestamp } + let finalJurisdictions = finalJurisdictions( + trackedJurisdictions: trackedJurisdictions, + manualEntries: manualForDay, + ) + + return DailyStateLedger( + date: day, + trackedJurisdictions: trackedJurisdictions, + manualEntries: manualForDay, + finalJurisdictions: finalJurisdictions, + note: note(for: trackedJurisdictions, manualEntries: manualForDay, finalJurisdictions: finalJurisdictions), + ) + } + } + + public func makeYearSummary(year: Int, ledgers: [DailyStateLedger]) -> YearSummary { + var totals: [TaxJurisdiction: Int] = [:] + var unknownDays = 0 + + for ledger in ledgers { + if ledger.finalJurisdictions.contains(.unknown) { + unknownDays += 1 + } + + for jurisdiction in ledger.finalJurisdictions where jurisdiction.countsTowardTaxDay { + totals[jurisdiction, default: 0] += 1 + } + } + + return YearSummary( + year: year, + totalsByJurisdiction: totals, + unknownDayCount: unknownDays, + totalTrackedDays: ledgers.count, + ) + } + + public func finalJurisdictions( + trackedJurisdictions: [TaxJurisdiction], + manualEntries: [ManualLogEntry], + ) -> [TaxJurisdiction] { + let correctionJurisdictions = manualEntries + .filter { $0.kind == .correction } + .map(\.jurisdiction) + + if !correctionJurisdictions.isEmpty { + return orderedUnique(correctionJurisdictions) + } + + let supplementalJurisdictions = manualEntries + .filter { $0.kind == .supplemental } + .map(\.jurisdiction) + + return orderedUnique(trackedJurisdictions + supplementalJurisdictions) + } + + private func orderedUnique(_ jurisdictions: [TaxJurisdiction]) -> [TaxJurisdiction] { + var seen = Set() + var ordered: [TaxJurisdiction] = [] + + for jurisdiction in jurisdictions where seen.insert(jurisdiction).inserted { + ordered.append(jurisdiction) + } + + return ordered.sorted { $0.displayName < $1.displayName } + } + + private func note( + for trackedJurisdictions: [TaxJurisdiction], + manualEntries: [ManualLogEntry], + finalJurisdictions: [TaxJurisdiction], + ) -> String? { + if finalJurisdictions.contains(.unknown) { + return "Review location coverage" + } + + if manualEntries.contains(where: { $0.kind == .correction }) { + return "Manual correction applied" + } + + if finalJurisdictions.count > 1 || trackedJurisdictions.count > 1 { + return "Multiple jurisdictions logged" + } + + return nil + } +} diff --git a/Where/WhereCore/Sources/Protocols/EvidenceAttachmentRepository.swift b/Where/WhereCore/Sources/Protocols/EvidenceAttachmentRepository.swift new file mode 100644 index 0000000..5906136 --- /dev/null +++ b/Where/WhereCore/Sources/Protocols/EvidenceAttachmentRepository.swift @@ -0,0 +1,10 @@ +import Foundation + +public protocol EvidenceAttachmentRepository: Sendable { + func attachments(for manualEntryID: UUID) async -> [EvidenceAttachment] + func attachments(for manualEntryIDs: [UUID]) async -> [EvidenceAttachment] + func save(_ attachment: EvidenceAttachment) async + func save(_ attachments: [EvidenceAttachment]) async + func delete(id: UUID) async + func removeAll() async +} diff --git a/Where/WhereCore/Sources/Protocols/EvidenceFileStore.swift b/Where/WhereCore/Sources/Protocols/EvidenceFileStore.swift new file mode 100644 index 0000000..5545167 --- /dev/null +++ b/Where/WhereCore/Sources/Protocols/EvidenceFileStore.swift @@ -0,0 +1,7 @@ +import Foundation + +public protocol EvidenceFileStore: Sendable { + func save(_ data: Data, for attachment: EvidenceAttachment) async + func load(for attachment: EvidenceAttachment) async -> Data? + func delete(for attachment: EvidenceAttachment) async +} diff --git a/Where/WhereCore/Sources/Protocols/JurisdictionResolving.swift b/Where/WhereCore/Sources/Protocols/JurisdictionResolving.swift new file mode 100644 index 0000000..7715941 --- /dev/null +++ b/Where/WhereCore/Sources/Protocols/JurisdictionResolving.swift @@ -0,0 +1,3 @@ +public protocol JurisdictionResolving: Sendable { + func jurisdiction(for event: TrackingWakeEvent) async -> TaxJurisdiction +} diff --git a/Where/WhereCore/Sources/Protocols/LocationAuthorizationProviding.swift b/Where/WhereCore/Sources/Protocols/LocationAuthorizationProviding.swift new file mode 100644 index 0000000..ea7160c --- /dev/null +++ b/Where/WhereCore/Sources/Protocols/LocationAuthorizationProviding.swift @@ -0,0 +1,4 @@ +public protocol LocationAuthorizationProviding: Sendable { + func currentAuthorizationStatus() async -> TrackingAuthorizationStatus + func requestAlwaysAuthorization() async +} diff --git a/Where/WhereCore/Sources/Protocols/LocationSampleRepository.swift b/Where/WhereCore/Sources/Protocols/LocationSampleRepository.swift new file mode 100644 index 0000000..ae4f553 --- /dev/null +++ b/Where/WhereCore/Sources/Protocols/LocationSampleRepository.swift @@ -0,0 +1,8 @@ +import Foundation + +public protocol LocationSampleRepository: Sendable { + func availableYears() async -> [Int] + func samples(in year: Int) async -> [LocationSample] + func upsert(_ samples: [LocationSample]) async + func removeAll() async +} diff --git a/Where/WhereCore/Sources/Protocols/LocationWakeSource.swift b/Where/WhereCore/Sources/Protocols/LocationWakeSource.swift new file mode 100644 index 0000000..b266842 --- /dev/null +++ b/Where/WhereCore/Sources/Protocols/LocationWakeSource.swift @@ -0,0 +1,5 @@ +public protocol LocationWakeSource: Sendable { + func startMonitoring(configuration: TrackingMonitoringConfiguration) async + func refreshRegionMonitoring(configuration: TrackingMonitoringConfiguration) async + func stopMonitoring() async +} diff --git a/Where/WhereCore/Sources/Protocols/ManualDataImporting.swift b/Where/WhereCore/Sources/Protocols/ManualDataImporting.swift new file mode 100644 index 0000000..ed23391 --- /dev/null +++ b/Where/WhereCore/Sources/Protocols/ManualDataImporting.swift @@ -0,0 +1,8 @@ +import Foundation + +public protocol ManualDataImporting: Sendable { + func previewBackfill(_ request: ManualImportBackfillRequest) async -> ManualImportPreview + func previewPackage(at directoryURL: URL) async -> ManualImportPreview + func importEntries(_ entries: [ManualImportEntryDraft]) async -> [ManualEntryRecord] + func importPackage(at directoryURL: URL) async -> [ManualEntryRecord] +} diff --git a/Where/WhereCore/Sources/Protocols/ManualEntryManaging.swift b/Where/WhereCore/Sources/Protocols/ManualEntryManaging.swift new file mode 100644 index 0000000..5b770c8 --- /dev/null +++ b/Where/WhereCore/Sources/Protocols/ManualEntryManaging.swift @@ -0,0 +1,10 @@ +import Foundation + +public protocol ManualEntryManaging: Sendable { + func records(in year: Int) async -> [ManualEntryRecord] + func save(_ draft: ManualEntryDraft) async -> ManualEntryRecord + func deleteEntry(id: UUID) async + func importEvidence(manualEntryID: UUID, fileURL: URL) async -> EvidenceAttachment? + func evidenceFileURL(for attachment: EvidenceAttachment) async -> URL? + func deleteEvidence(_ attachment: EvidenceAttachment) async +} diff --git a/Where/WhereCore/Sources/Protocols/ManualLogEntryRepository.swift b/Where/WhereCore/Sources/Protocols/ManualLogEntryRepository.swift new file mode 100644 index 0000000..d69b7b7 --- /dev/null +++ b/Where/WhereCore/Sources/Protocols/ManualLogEntryRepository.swift @@ -0,0 +1,10 @@ +import Foundation + +public protocol ManualLogEntryRepository: Sendable { + func availableYears() async -> [Int] + func entries(in year: Int) async -> [ManualLogEntry] + func save(_ entry: ManualLogEntry) async + func save(_ entries: [ManualLogEntry]) async + func delete(id: UUID) async + func removeAll() async +} diff --git a/Where/WhereCore/Sources/Protocols/SyncCheckpointStore.swift b/Where/WhereCore/Sources/Protocols/SyncCheckpointStore.swift new file mode 100644 index 0000000..d1b07ed --- /dev/null +++ b/Where/WhereCore/Sources/Protocols/SyncCheckpointStore.swift @@ -0,0 +1,5 @@ +public protocol SyncCheckpointStore: Sendable { + func checkpoint() async -> SyncCheckpoint + func save(_ checkpoint: SyncCheckpoint) async + func reset() async +} diff --git a/Where/WhereCore/Sources/Protocols/TrackingNotificationScheduling.swift b/Where/WhereCore/Sources/Protocols/TrackingNotificationScheduling.swift new file mode 100644 index 0000000..2b0e031 --- /dev/null +++ b/Where/WhereCore/Sources/Protocols/TrackingNotificationScheduling.swift @@ -0,0 +1,4 @@ +public protocol TrackingNotificationScheduling: Sendable { + func schedule(_ request: TrackingNotificationRequest) async + func cancel(ids: [String]) async +} diff --git a/Where/WhereCore/Sources/Protocols/TrackingStateStore.swift b/Where/WhereCore/Sources/Protocols/TrackingStateStore.swift new file mode 100644 index 0000000..b31d913 --- /dev/null +++ b/Where/WhereCore/Sources/Protocols/TrackingStateStore.swift @@ -0,0 +1,5 @@ +public protocol TrackingStateStore: Sendable { + func load() async -> TrackingState + func save(_ state: TrackingState) async + func reset() async +} diff --git a/Where/WhereCore/Sources/Protocols/WhereDataResetting.swift b/Where/WhereCore/Sources/Protocols/WhereDataResetting.swift new file mode 100644 index 0000000..6712d84 --- /dev/null +++ b/Where/WhereCore/Sources/Protocols/WhereDataResetting.swift @@ -0,0 +1,3 @@ +public protocol WhereDataResetting: Sendable { + func resetAllData() async +} diff --git a/Where/WhereCore/Sources/Protocols/YearDataProviding.swift b/Where/WhereCore/Sources/Protocols/YearDataProviding.swift new file mode 100644 index 0000000..4b573cb --- /dev/null +++ b/Where/WhereCore/Sources/Protocols/YearDataProviding.swift @@ -0,0 +1,4 @@ +public protocol YearDataProviding: Sendable { + func availableYears() async -> [Int] + func bundle(for year: Int) async -> YearDataBundle +} diff --git a/Where/WhereCore/Sources/Protocols/YearExporting.swift b/Where/WhereCore/Sources/Protocols/YearExporting.swift new file mode 100644 index 0000000..17a43c9 --- /dev/null +++ b/Where/WhereCore/Sources/Protocols/YearExporting.swift @@ -0,0 +1,3 @@ +public protocol YearExporting: Sendable { + func exportBundle(for year: Int) async -> YearExportBundle +} diff --git a/Where/WhereCore/Sources/Protocols/YearProgressProviding.swift b/Where/WhereCore/Sources/Protocols/YearProgressProviding.swift new file mode 100644 index 0000000..090875d --- /dev/null +++ b/Where/WhereCore/Sources/Protocols/YearProgressProviding.swift @@ -0,0 +1,4 @@ +public protocol YearProgressProviding: Sendable { + func availableYears() async -> [Int] + func snapshot(for year: Int) async -> YearProgressSnapshot +} diff --git a/Where/WhereCore/Sources/WhereCore.swift b/Where/WhereCore/Sources/WhereCore.swift index 503912c..332072f 100644 --- a/Where/WhereCore/Sources/WhereCore.swift +++ b/Where/WhereCore/Sources/WhereCore.swift @@ -1,4 +1,2 @@ -public enum WhereCore { - /// Placeholder until location model code lands here. - public static let version = 1 -} +/// Namespace for shared constants as the Where domain expands. +public enum WhereCore {} diff --git a/Where/WhereCore/Tests/WhereCoreTests.swift b/Where/WhereCore/Tests/WhereCoreTests.swift index 3eaa4bb..5841bff 100644 --- a/Where/WhereCore/Tests/WhereCoreTests.swift +++ b/Where/WhereCore/Tests/WhereCoreTests.swift @@ -1,7 +1,132 @@ +import Foundation import Testing import WhereCore @Test -func versionIsDefined() { - #expect(WhereCore.version == 1) +func taxDayCalculatorCountsMultipleJurisdictionsPerDay() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + + let calculator = TaxDayCalculator(calendar: calendar) + let samples = [ + LocationSample( + timestamp: Date(timeIntervalSince1970: 1_735_689_600), + jurisdiction: .california, + ), + LocationSample( + timestamp: Date(timeIntervalSince1970: 1_735_725_600), + jurisdiction: .newYork, + ), + LocationSample( + timestamp: Date(timeIntervalSince1970: 1_735_776_000), + jurisdiction: .california, + ), + ] + + let records = calculator.makeDailyRecords(from: samples) + let counts = calculator.countDaysByJurisdiction(from: records) + + #expect(records.count == 2) + #expect(records[0].jurisdictions == [.california, .newYork]) + #expect(counts[.california] == 2) + #expect(counts[.newYork] == 1) +} + +@Test +func taxDayCalculatorKeepsUnknownDaysOutOfTaxTotals() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + + let calculator = TaxDayCalculator(calendar: calendar) + let samples = [ + LocationSample( + timestamp: Date(timeIntervalSince1970: 1_736_035_200), + jurisdiction: .unknown, + ), + ] + + let records = calculator.makeDailyRecords(from: samples) + let counts = calculator.countDaysByJurisdiction(from: records) + + #expect(records.count == 1) + #expect(records[0].jurisdictions == [.unknown]) + #expect(counts.isEmpty) +} + +@Test +func yearLedgerBuilderUsesCorrectionsToOverrideTrackedJurisdictions() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + + let builder = YearLedgerBuilder(calendar: calendar) + let year = 2026 + let samples = [ + LocationSample( + timestamp: Date(timeIntervalSince1970: 1_767_715_200), + jurisdiction: .unknown, + ), + LocationSample( + timestamp: Date(timeIntervalSince1970: 1_767_718_800), + jurisdiction: .california, + ), + ] + let manualEntries = [ + ManualLogEntry( + timestamp: Date(timeIntervalSince1970: 1_767_720_000), + jurisdiction: .newYork, + note: "Boarding pass attached", + kind: .correction, + ), + ] + + let ledgers = builder.makeLedgers( + year: year, + samples: samples, + manualEntries: manualEntries, + ) + let summary = builder.makeYearSummary(year: year, ledgers: ledgers) + + #expect(ledgers.count == 1) + #expect(ledgers[0].trackedJurisdictions == [.california, .unknown]) + #expect(ledgers[0].finalJurisdictions == [.newYork]) + #expect(ledgers[0].note == "Manual correction applied") + #expect(summary.totalsByJurisdiction[.newYork] == 1) + #expect(summary.totalsByJurisdiction[.california] == nil) + #expect(summary.unknownDayCount == 0) +} + +@Test +func yearLedgerBuilderUsesSupplementalEntriesAsAdditiveEvidence() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + + let builder = YearLedgerBuilder(calendar: calendar) + let year = 2026 + let samples = [ + LocationSample( + timestamp: Date(timeIntervalSince1970: 1_767_456_000), + jurisdiction: .california, + ), + ] + let manualEntries = [ + ManualLogEntry( + timestamp: Date(timeIntervalSince1970: 1_767_459_600), + jurisdiction: .newYork, + note: "Flight evidence added", + kind: .supplemental, + ), + ] + + let ledgers = builder.makeLedgers( + year: year, + samples: samples, + manualEntries: manualEntries, + ) + let summary = builder.makeYearSummary(year: year, ledgers: ledgers) + + #expect(ledgers.count == 1) + #expect(ledgers[0].finalJurisdictions == [.california, .newYork]) + #expect(ledgers[0].note == "Multiple jurisdictions logged") + #expect(summary.totalsByJurisdiction[.california] == 1) + #expect(summary.totalsByJurisdiction[.newYork] == 1) } diff --git a/Where/WhereData/Sources/BackgroundTrackingController.swift b/Where/WhereData/Sources/BackgroundTrackingController.swift new file mode 100644 index 0000000..a39a7c6 --- /dev/null +++ b/Where/WhereData/Sources/BackgroundTrackingController.swift @@ -0,0 +1,183 @@ +import Foundation +import WhereCore + +public actor BackgroundTrackingController { + private enum MonitoringAction { + case start + case refresh + } + + private let calendar: Calendar + private let locationRepository: any LocationSampleRepository + private let authorizationProvider: any LocationAuthorizationProviding + private let wakeSource: any LocationWakeSource + private let jurisdictionResolver: any JurisdictionResolving + private let notificationScheduler: any TrackingNotificationScheduling + private let trackingStateController: TrackingStateController + private let configuration: TrackingMonitoringConfiguration + private let now: @Sendable () -> Date + + public init( + calendar: Calendar = .current, + locationRepository: any LocationSampleRepository, + authorizationProvider: any LocationAuthorizationProviding, + wakeSource: any LocationWakeSource, + jurisdictionResolver: any JurisdictionResolving, + notificationScheduler: any TrackingNotificationScheduling, + trackingStateController: TrackingStateController, + configuration: TrackingMonitoringConfiguration = .init(), + now: @escaping @Sendable () -> Date = Date.init, + ) { + self.calendar = calendar + self.locationRepository = locationRepository + self.authorizationProvider = authorizationProvider + self.wakeSource = wakeSource + self.jurisdictionResolver = jurisdictionResolver + self.notificationScheduler = notificationScheduler + self.trackingStateController = trackingStateController + self.configuration = configuration + self.now = now + } + + public func prepareForLaunch() async { + var authorizationStatus = await authorizationProvider.currentAuthorizationStatus() + if authorizationStatus == .notDetermined { + await authorizationProvider.requestAlwaysAuthorization() + authorizationStatus = await authorizationProvider.currentAuthorizationStatus() + } + + await applyAuthorizationStatus(authorizationStatus, action: .start) + } + + public func requestAlwaysAuthorization() async { + await authorizationProvider.requestAlwaysAuthorization() + let authorizationStatus = await authorizationProvider.currentAuthorizationStatus() + await applyAuthorizationStatus(authorizationStatus, action: .refresh) + } + + public func refreshMonitoring() async { + let authorizationStatus = await authorizationProvider.currentAuthorizationStatus() + await applyAuthorizationStatus(authorizationStatus, action: .refresh) + } + + public func handleAuthorizationStatusChange(_ status: TrackingAuthorizationStatus) async { + await applyAuthorizationStatus(status, action: .refresh) + } + + public func handleWakeEvent(_ event: TrackingWakeEvent) async { + await trackingStateController.recordWakeEvent(event) + + let jurisdiction = await jurisdictionResolver.jurisdiction(for: event) + let sample = LocationSample( + timestamp: event.timestamp, + jurisdiction: jurisdiction, + ) + await locationRepository.upsert([sample]) + await trackingStateController.recordSample(at: event.timestamp) + await refreshGapNotifications() + } + + public func trackingState() async -> TrackingState { + await trackingStateController.state() + } + + private func applyAuthorizationStatus( + _ status: TrackingAuthorizationStatus, + action: MonitoringAction, + ) async { + await trackingStateController.updateAuthorization(status) + + guard status.isBackgroundAuthorized else { + await wakeSource.stopMonitoring() + await trackingStateController.markMonitoringActive(false) + await clearScheduledGapNotifications() + return + } + + switch action { + case .start: + await wakeSource.startMonitoring(configuration: configuration) + case .refresh: + await wakeSource.refreshRegionMonitoring(configuration: configuration) + } + + await trackingStateController.markMonitoringActive(true) + await refreshGapNotifications() + } + + private func refreshGapNotifications() async { + let state = await trackingStateController.state() + let dueDates = gapNotificationDates(for: state) + let requests = dueDates.map(notificationRequest(for:)) + let existingIDs = state.pendingGapNotificationDates.map(notificationID(for:)) + + await notificationScheduler.cancel(ids: existingIDs) + await trackingStateController.clearGapNotifications() + for dueDate in dueDates { + await trackingStateController.scheduleGapNotification(for: dueDate) + } + + for request in requests { + await notificationScheduler.schedule(request) + } + } + + private func clearScheduledGapNotifications() async { + let state = await trackingStateController.state() + let existingIDs = state.pendingGapNotificationDates.map(notificationID(for:)) + await notificationScheduler.cancel(ids: existingIDs) + await trackingStateController.clearGapNotifications() + } + + private func gapNotificationDates(for state: TrackingState) -> [Date] { + guard state.authorizationStatus.isBackgroundAuthorized else { + return [] + } + + let referenceDate = state.lastRecordedSampleAt ?? state.lastWakeEventAt + let startOfToday = calendar.startOfDay(for: now()) + + guard let referenceDate else { + return [notificationDate(for: startOfToday)] + } + + let startOfReferenceDay = calendar.startOfDay(for: referenceDate) + guard startOfReferenceDay < startOfToday else { + return [] + } + + var dates: [Date] = [] + var cursor = startOfReferenceDay + while cursor < startOfToday { + if let next = calendar.date(byAdding: .day, value: 1, to: cursor) { + dates.append(notificationDate(for: next)) + cursor = next + } else { + break + } + } + return dates + } + + private func notificationDate(for day: Date) -> Date { + calendar.date( + bySettingHour: configuration.wakeNotificationHour, + minute: 0, + second: 0, + of: day, + ) ?? day + } + + private func notificationRequest(for date: Date) -> TrackingNotificationRequest { + TrackingNotificationRequest( + id: notificationID(for: date), + title: "Tracking needs attention", + body: "Open Where to review location coverage for missing days.", + deliverAt: date, + ) + } + + private func notificationID(for date: Date) -> String { + "tracking-gap-\(Int(date.timeIntervalSince1970))" + } +} diff --git a/Where/WhereData/Sources/EvidenceController.swift b/Where/WhereData/Sources/EvidenceController.swift new file mode 100644 index 0000000..dd9a416 --- /dev/null +++ b/Where/WhereData/Sources/EvidenceController.swift @@ -0,0 +1,85 @@ +import Foundation +import WhereCore + +public actor EvidenceController { + private let attachmentRepository: any EvidenceAttachmentRepository + private let fileStore: any EvidenceFileStore + + public init( + attachmentRepository: any EvidenceAttachmentRepository, + fileStore: any EvidenceFileStore, + ) { + self.attachmentRepository = attachmentRepository + self.fileStore = fileStore + } + + public func attachments(for manualEntryID: UUID) async -> [EvidenceAttachment] { + await attachmentRepository.attachments(for: manualEntryID) + } + + public func importEvidence( + manualEntryID: UUID, + originalFilename: String, + contentType: String, + data: Data, + createdAt: Date = Date(), + ) async -> EvidenceAttachment { + let attachment = EvidenceAttachment( + manualEntryID: manualEntryID, + originalFilename: originalFilename, + contentType: contentType, + byteCount: data.count, + createdAt: createdAt, + ) + + await attachmentRepository.save(attachment) + await fileStore.save(data, for: attachment) + return attachment + } + + public enum BatchImportError: Error, Sendable { + case couldNotPersistEvidence + } + + public func importEvidence( + _ requests: [(manualEntryID: UUID, originalFilename: String, contentType: String, data: Data, createdAt: Date)], + ) async throws -> [EvidenceAttachment] { + let attachments = requests.map { request in + EvidenceAttachment( + manualEntryID: request.manualEntryID, + originalFilename: request.originalFilename, + contentType: request.contentType, + byteCount: request.data.count, + createdAt: request.createdAt, + ) + } + + guard !attachments.isEmpty else { + return [] + } + + await attachmentRepository.save(attachments) + + for (attachment, request) in zip(attachments, requests) { + await fileStore.save(request.data, for: attachment) + guard await fileStore.load(for: attachment) != nil else { + for persistedAttachment in attachments { + await attachmentRepository.delete(id: persistedAttachment.id) + await fileStore.delete(for: persistedAttachment) + } + throw BatchImportError.couldNotPersistEvidence + } + } + + return attachments + } + + public func loadData(for attachment: EvidenceAttachment) async -> Data? { + await fileStore.load(for: attachment) + } + + public func delete(_ attachment: EvidenceAttachment) async { + await attachmentRepository.delete(id: attachment.id) + await fileStore.delete(for: attachment) + } +} diff --git a/Where/WhereData/Sources/FileEvidenceAttachmentRepository.swift b/Where/WhereData/Sources/FileEvidenceAttachmentRepository.swift new file mode 100644 index 0000000..3e2e0a6 --- /dev/null +++ b/Where/WhereData/Sources/FileEvidenceAttachmentRepository.swift @@ -0,0 +1,66 @@ +import Foundation +import WhereCore + +public actor FileEvidenceAttachmentRepository: EvidenceAttachmentRepository { + private let store: JSONFileStore<[EvidenceAttachment]> + private let seedRecords: [EvidenceAttachment] + + public init( + fileURL: URL, + seedRecords: [EvidenceAttachment] = [], + ) { + store = JSONFileStore(fileURL: fileURL) + self.seedRecords = seedRecords + } + + public func attachments(for manualEntryID: UUID) async -> [EvidenceAttachment] { + records() + .filter { $0.manualEntryID == manualEntryID } + .sorted { $0.createdAt < $1.createdAt } + } + + public func attachments(for manualEntryIDs: [UUID]) async -> [EvidenceAttachment] { + let ids = Set(manualEntryIDs) + return records() + .filter { ids.contains($0.manualEntryID) } + .sorted { lhs, rhs in + if lhs.createdAt == rhs.createdAt { + return lhs.id.uuidString < rhs.id.uuidString + } + + return lhs.createdAt < rhs.createdAt + } + } + + public func save(_ attachment: EvidenceAttachment) async { + await save([attachment]) + } + + public func save(_ attachments: [EvidenceAttachment]) async { + var merged = Dictionary(uniqueKeysWithValues: records().map { ($0.id, $0) }) + for attachment in attachments { + merged[attachment.id] = attachment + } + + let ordered = merged.values.sorted { lhs, rhs in + if lhs.createdAt == rhs.createdAt { + return lhs.id.uuidString < rhs.id.uuidString + } + + return lhs.createdAt < rhs.createdAt + } + store.save(ordered) + } + + public func delete(id: UUID) async { + store.save(records().filter { $0.id != id }) + } + + public func removeAll() async { + store.save([]) + } + + private func records() -> [EvidenceAttachment] { + store.load(defaultValue: seedRecords) + } +} diff --git a/Where/WhereData/Sources/FileEvidenceBlobStore.swift b/Where/WhereData/Sources/FileEvidenceBlobStore.swift new file mode 100644 index 0000000..970efe1 --- /dev/null +++ b/Where/WhereData/Sources/FileEvidenceBlobStore.swift @@ -0,0 +1,42 @@ +import Foundation +import WhereCore + +public actor FileEvidenceBlobStore: EvidenceFileStore { + private let baseDirectoryURL: URL + private let fileManager: FileManager + + public init( + baseDirectoryURL: URL, + fileManager: FileManager = .default, + ) { + self.baseDirectoryURL = baseDirectoryURL + self.fileManager = fileManager + } + + public func save(_ data: Data, for attachment: EvidenceAttachment) async { + let destination = fileURL(for: attachment) + + do { + try fileManager.createDirectory( + at: baseDirectoryURL, + withIntermediateDirectories: true, + attributes: nil, + ) + try data.write(to: destination, options: .atomic) + } catch { + assertionFailure("Failed to save evidence blob at \(destination.path): \(error)") + } + } + + public func load(for attachment: EvidenceAttachment) async -> Data? { + try? Data(contentsOf: fileURL(for: attachment)) + } + + public func delete(for attachment: EvidenceAttachment) async { + try? fileManager.removeItem(at: fileURL(for: attachment)) + } + + private func fileURL(for attachment: EvidenceAttachment) -> URL { + baseDirectoryURL.appending(path: attachment.storageKey) + } +} diff --git a/Where/WhereData/Sources/FileLocationSampleRepository.swift b/Where/WhereData/Sources/FileLocationSampleRepository.swift new file mode 100644 index 0000000..2b6ac35 --- /dev/null +++ b/Where/WhereData/Sources/FileLocationSampleRepository.swift @@ -0,0 +1,53 @@ +import Foundation +import WhereCore + +public actor FileLocationSampleRepository: LocationSampleRepository { + private let calendar: Calendar + private let store: JSONFileStore<[LocationSample]> + private let seedRecords: [LocationSample] + + public init( + calendar: Calendar = .current, + fileURL: URL, + seedRecords: [LocationSample] = [], + ) { + self.calendar = calendar + store = JSONFileStore(fileURL: fileURL) + self.seedRecords = seedRecords + } + + public func availableYears() async -> [Int] { + let years = Set(records().map { calendar.component(.year, from: $0.timestamp) }) + return years.sorted() + } + + public func samples(in year: Int) async -> [LocationSample] { + records() + .filter { calendar.component(.year, from: $0.timestamp) == year } + .sorted { $0.timestamp < $1.timestamp } + } + + public func upsert(_ samples: [LocationSample]) async { + var merged = Dictionary(uniqueKeysWithValues: records().map { ($0.id, $0) }) + for sample in samples { + merged[sample.id] = sample + } + + let ordered = merged.values.sorted { lhs, rhs in + if lhs.timestamp == rhs.timestamp { + return lhs.id.uuidString < rhs.id.uuidString + } + + return lhs.timestamp < rhs.timestamp + } + store.save(ordered) + } + + public func removeAll() async { + store.save([]) + } + + private func records() -> [LocationSample] { + store.load(defaultValue: seedRecords) + } +} diff --git a/Where/WhereData/Sources/FileManualLogEntryRepository.swift b/Where/WhereData/Sources/FileManualLogEntryRepository.swift new file mode 100644 index 0000000..e2a9195 --- /dev/null +++ b/Where/WhereData/Sources/FileManualLogEntryRepository.swift @@ -0,0 +1,61 @@ +import Foundation +import WhereCore + +public actor FileManualLogEntryRepository: ManualLogEntryRepository { + private let calendar: Calendar + private let store: JSONFileStore<[ManualLogEntry]> + private let seedRecords: [ManualLogEntry] + + public init( + calendar: Calendar = .current, + fileURL: URL, + seedRecords: [ManualLogEntry] = [], + ) { + self.calendar = calendar + store = JSONFileStore(fileURL: fileURL) + self.seedRecords = seedRecords + } + + public func availableYears() async -> [Int] { + let years = Set(records().map { calendar.component(.year, from: $0.timestamp) }) + return years.sorted() + } + + public func entries(in year: Int) async -> [ManualLogEntry] { + records() + .filter { calendar.component(.year, from: $0.timestamp) == year } + .sorted { $0.timestamp < $1.timestamp } + } + + public func save(_ entry: ManualLogEntry) async { + await save([entry]) + } + + public func save(_ entries: [ManualLogEntry]) async { + var merged = Dictionary(uniqueKeysWithValues: records().map { ($0.id, $0) }) + for entry in entries { + merged[entry.id] = entry + } + + let ordered = merged.values.sorted { lhs, rhs in + if lhs.timestamp == rhs.timestamp { + return lhs.id.uuidString < rhs.id.uuidString + } + + return lhs.timestamp < rhs.timestamp + } + store.save(ordered) + } + + public func delete(id: UUID) async { + store.save(records().filter { $0.id != id }) + } + + public func removeAll() async { + store.save([]) + } + + private func records() -> [ManualLogEntry] { + store.load(defaultValue: seedRecords) + } +} diff --git a/Where/WhereData/Sources/FileSyncCheckpointStore.swift b/Where/WhereData/Sources/FileSyncCheckpointStore.swift new file mode 100644 index 0000000..6614b28 --- /dev/null +++ b/Where/WhereData/Sources/FileSyncCheckpointStore.swift @@ -0,0 +1,22 @@ +import Foundation +import WhereCore + +public actor FileSyncCheckpointStore: SyncCheckpointStore { + private let store: JSONFileStore + + public init(fileURL: URL) { + store = JSONFileStore(fileURL: fileURL) + } + + public func checkpoint() async -> SyncCheckpoint { + store.load(defaultValue: .init(state: .idle)) + } + + public func save(_ checkpoint: SyncCheckpoint) async { + store.save(checkpoint) + } + + public func reset() async { + store.save(.init(state: .idle)) + } +} diff --git a/Where/WhereData/Sources/FileTrackingStateStore.swift b/Where/WhereData/Sources/FileTrackingStateStore.swift new file mode 100644 index 0000000..fc5eee3 --- /dev/null +++ b/Where/WhereData/Sources/FileTrackingStateStore.swift @@ -0,0 +1,30 @@ +import Foundation +import WhereCore + +public actor FileTrackingStateStore: TrackingStateStore { + private let store: JSONFileStore + + public init(fileURL: URL) { + store = JSONFileStore(fileURL: fileURL) + } + + public func load() async -> TrackingState { + store.load( + defaultValue: TrackingState( + authorizationStatus: .notDetermined, + ), + ) + } + + public func save(_ state: TrackingState) async { + store.save(state) + } + + public func reset() async { + store.save( + TrackingState( + authorizationStatus: .notDetermined, + ), + ) + } +} diff --git a/Where/WhereData/Sources/JSONFileStore.swift b/Where/WhereData/Sources/JSONFileStore.swift new file mode 100644 index 0000000..fb4b328 --- /dev/null +++ b/Where/WhereData/Sources/JSONFileStore.swift @@ -0,0 +1,40 @@ +import Foundation + +struct JSONFileStore { + private let fileURL: URL + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + init(fileURL: URL) { + self.fileURL = fileURL + encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + + decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + } + + func load(defaultValue: Record) -> Record { + guard let data = try? Data(contentsOf: fileURL) else { + return defaultValue + } + + return (try? decoder.decode(Record.self, from: data)) ?? defaultValue + } + + func save(_ record: Record) { + do { + let directory = fileURL.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: nil, + ) + let data = try encoder.encode(record) + try data.write(to: fileURL, options: .atomic) + } catch { + assertionFailure("Failed to save JSON store at \(fileURL.path): \(error)") + } + } +} diff --git a/Where/WhereData/Sources/ManualDataImportController.swift b/Where/WhereData/Sources/ManualDataImportController.swift new file mode 100644 index 0000000..7eb2459 --- /dev/null +++ b/Where/WhereData/Sources/ManualDataImportController.swift @@ -0,0 +1,289 @@ +import Foundation +import UniformTypeIdentifiers +import WhereCore + +public actor ManualDataImportController: ManualDataImporting { + private let calendar: Calendar + private let manualEntryRepository: any ManualLogEntryRepository + private let manualEntryController: ManualEntryController + private let evidenceController: EvidenceController + private let manifestDecoder: JSONDecoder + + public init( + calendar: Calendar = .current, + manualEntryRepository: any ManualLogEntryRepository, + manualEntryController: ManualEntryController, + evidenceController: EvidenceController, + ) { + self.calendar = calendar + self.manualEntryRepository = manualEntryRepository + self.manualEntryController = manualEntryController + self.evidenceController = evidenceController + + manifestDecoder = JSONDecoder() + manifestDecoder.dateDecodingStrategy = .iso8601 + } + + public func previewBackfill(_ request: ManualImportBackfillRequest) async -> ManualImportPreview { + let normalizedStart = calendar.startOfDay(for: request.startDate) + let normalizedEnd = calendar.startOfDay(for: request.endDate) + + guard normalizedStart <= normalizedEnd else { + return ManualImportPreview( + yearSpan: nil, + entryCount: 0, + evidenceAttachmentCount: 0, + sharedEvidenceAttachmentCount: 0, + entries: [], + issues: [ + .init( + severity: .error, + message: "The end date must be on or after the start date.", + ), + ], + ) + } + + var entries: [ManualImportEntryDraft] = [] + var currentDate = normalizedStart + + while currentDate <= normalizedEnd { + let timestamp = midday(for: currentDate) + entries.append( + ManualImportEntryDraft( + timestamp: timestamp, + jurisdiction: request.jurisdiction, + note: request.note, + kind: request.kind, + evidenceFiles: request.evidenceFiles, + ), + ) + + guard let nextDay = calendar.date(byAdding: .day, value: 1, to: currentDate) else { + break + } + currentDate = nextDay + } + + return ManualImportPreview( + yearSpan: yearSpan(for: entries.map(\.timestamp)), + entryCount: entries.count, + evidenceAttachmentCount: entries.reduce(0) { $0 + $1.evidenceFiles.count }, + sharedEvidenceAttachmentCount: request.evidenceFiles.count, + entries: entries, + ) + } + + public func previewPackage(at directoryURL: URL) async -> ManualImportPreview { + let didAccess = directoryURL.startAccessingSecurityScopedResource() + defer { + if didAccess { + directoryURL.stopAccessingSecurityScopedResource() + } + } + + return previewPackageUnlocked(at: directoryURL) + } + + public func importEntries(_ entries: [ManualImportEntryDraft]) async -> [ManualEntryRecord] { + guard !entries.isEmpty else { + return [] + } + + let manualEntries = entries.map(\.manualLogEntry) + await manualEntryRepository.save(manualEntries) + + do { + let evidenceRequests = try makeEvidenceRequests(for: entries) + _ = try await evidenceController.importEvidence(evidenceRequests) + } catch { + for manualEntry in manualEntries { + await manualEntryRepository.delete(id: manualEntry.id) + } + return [] + } + + let importedIDs = Set(manualEntries.map(\.id)) + let years = Set(manualEntries.map { calendar.component(.year, from: $0.timestamp) }) + + var records: [ManualEntryRecord] = [] + for year in years.sorted() { + let yearRecords = await manualEntryController.records(in: year) + records.append(contentsOf: yearRecords.filter { importedIDs.contains($0.id) }) + } + + return records.sorted { lhs, rhs in + if lhs.entry.timestamp == rhs.entry.timestamp { + return lhs.id.uuidString < rhs.id.uuidString + } + + return lhs.entry.timestamp < rhs.entry.timestamp + } + } + + public func importPackage(at directoryURL: URL) async -> [ManualEntryRecord] { + let didAccess = directoryURL.startAccessingSecurityScopedResource() + defer { + if didAccess { + directoryURL.stopAccessingSecurityScopedResource() + } + } + + let preview = previewPackageUnlocked(at: directoryURL) + guard preview.isValid else { + return [] + } + + return await importEntries(preview.entries) + } + + private func previewPackageUnlocked(at directoryURL: URL) -> ManualImportPreview { + let manifestURL = directoryURL.appending(path: "manifest.json") + guard let data = try? Data(contentsOf: manifestURL) else { + return ManualImportPreview( + yearSpan: nil, + entryCount: 0, + evidenceAttachmentCount: 0, + sharedEvidenceAttachmentCount: 0, + entries: [], + issues: [ + .init( + severity: .error, + message: "The selected package is missing `manifest.json`.", + ), + ], + ) + } + + let manifest: ManualImportPackageManifest + do { + manifest = try manifestDecoder.decode(ManualImportPackageManifest.self, from: data) + } catch { + return ManualImportPreview( + yearSpan: nil, + entryCount: 0, + evidenceAttachmentCount: 0, + sharedEvidenceAttachmentCount: 0, + entries: [], + issues: [ + .init( + severity: .error, + message: "The selected manifest could not be decoded.", + ), + ], + ) + } + + let evidenceDirectory = directoryURL.appending(path: "evidence") + var entries: [ManualImportEntryDraft] = [] + var issues: [ManualImportPreview.Issue] = [] + var evidenceAttachmentCount = 0 + + for manifestEntry in manifest.entries { + guard let jurisdiction = manifestEntry.jurisdiction.resolve() else { + issues.append( + .init( + severity: .error, + message: "Unsupported jurisdiction code `\(manifestEntry.jurisdiction.displayValue)`.", + ), + ) + continue + } + + var evidenceFiles: [URL] = [] + for filename in manifestEntry.evidenceFilenames { + let candidateURL = evidenceDirectory.appending(path: filename) + if FileManager.default.fileExists(atPath: candidateURL.path) { + evidenceFiles.append(candidateURL) + } else { + issues.append( + .init( + severity: .error, + message: "Missing evidence file `\(filename)` referenced by the manifest.", + ), + ) + } + } + + evidenceAttachmentCount += manifestEntry.evidenceFilenames.count + entries.append( + ManualImportEntryDraft( + timestamp: manifestEntry.timestamp, + jurisdiction: jurisdiction, + note: manifestEntry.note ?? "", + kind: manifestEntry.kind, + evidenceFiles: evidenceFiles, + ), + ) + } + + let years = Set(entries.map { calendar.component(.year, from: $0.timestamp) }) + if years.count > 1, let lowerBound = years.min(), let upperBound = years.max() { + issues.append( + .init( + severity: .warning, + message: "This package spans multiple years (\(lowerBound)-\(upperBound)).", + ), + ) + } + + return ManualImportPreview( + yearSpan: yearSpan(for: entries.map(\.timestamp)), + entryCount: entries.count, + evidenceAttachmentCount: evidenceAttachmentCount, + sharedEvidenceAttachmentCount: countSharedEvidenceFiles(in: entries), + entries: entries, + issues: issues, + ) + } + + private func makeEvidenceRequests( + for entries: [ManualImportEntryDraft], + ) throws -> [(manualEntryID: UUID, originalFilename: String, contentType: String, data: Data, createdAt: Date)] { + try entries.flatMap { entry in + try entry.evidenceFiles.map { fileURL in + let data = try Data(contentsOf: fileURL) + let resourceValues = try? fileURL.resourceValues(forKeys: [.contentTypeKey, .nameKey]) + let contentType = resourceValues?.contentType?.preferredMIMEType + ?? UTType(filenameExtension: fileURL.pathExtension)?.preferredMIMEType + ?? "application/octet-stream" + let originalFilename = resourceValues?.name ?? fileURL.lastPathComponent + + return ( + manualEntryID: entry.id, + originalFilename: originalFilename, + contentType: contentType, + data: data, + createdAt: entry.timestamp, + ) + } + } + } + + private func midday(for date: Date) -> Date { + let components = calendar.dateComponents([.year, .month, .day], from: date) + return calendar.date( + from: DateComponents( + calendar: calendar, + year: components.year, + month: components.month, + day: components.day, + hour: 12, + ), + ) ?? date + } + + private func yearSpan(for dates: [Date]) -> ClosedRange? { + let years = dates.map { calendar.component(.year, from: $0) } + guard let minYear = years.min(), let maxYear = years.max() else { + return nil + } + + return minYear ... maxYear + } + + private func countSharedEvidenceFiles(in entries: [ManualImportEntryDraft]) -> Int { + let counts = Dictionary(grouping: entries.flatMap(\.evidenceFiles), by: \.standardizedFileURL) + return counts.values.count(where: { $0.count > 1 }) + } +} diff --git a/Where/WhereData/Sources/ManualEntryController.swift b/Where/WhereData/Sources/ManualEntryController.swift new file mode 100644 index 0000000..4f43978 --- /dev/null +++ b/Where/WhereData/Sources/ManualEntryController.swift @@ -0,0 +1,102 @@ +import Foundation +import UniformTypeIdentifiers +import WhereCore + +public actor ManualEntryController: ManualEntryManaging { + private let repository: any ManualLogEntryRepository + private let evidenceController: EvidenceController + + public init( + repository: any ManualLogEntryRepository, + evidenceController: EvidenceController, + ) { + self.repository = repository + self.evidenceController = evidenceController + } + + public func records(in year: Int) async -> [ManualEntryRecord] { + let entries = await repository.entries(in: year) + var records: [ManualEntryRecord] = [] + records.reserveCapacity(entries.count) + + for entry in entries.sorted(by: { $0.timestamp > $1.timestamp }) { + let attachments = await evidenceController.attachments(for: entry.id) + records.append( + ManualEntryRecord( + entry: entry, + attachments: attachments, + ), + ) + } + + return records + } + + public func save(_ draft: ManualEntryDraft) async -> ManualEntryRecord { + let entry = ManualLogEntry( + id: draft.id ?? UUID(), + timestamp: draft.timestamp, + jurisdiction: draft.jurisdiction, + note: draft.trimmedNote, + kind: draft.kind, + ) + + await repository.save(entry) + let attachments = await evidenceController.attachments(for: entry.id) + return ManualEntryRecord(entry: entry, attachments: attachments) + } + + public func deleteEntry(id: UUID) async { + let attachments = await evidenceController.attachments(for: id) + for attachment in attachments { + await evidenceController.delete(attachment) + } + + await repository.delete(id: id) + } + + public func importEvidence(manualEntryID: UUID, fileURL: URL) async -> EvidenceAttachment? { + guard let data = try? Data(contentsOf: fileURL) else { + return nil + } + + let resourceValues = try? fileURL.resourceValues(forKeys: [.contentTypeKey, .nameKey]) + let contentType = resourceValues?.contentType?.preferredMIMEType + ?? UTType(filenameExtension: fileURL.pathExtension)?.preferredMIMEType + ?? "application/octet-stream" + let originalFilename = resourceValues?.name ?? fileURL.lastPathComponent + + return await evidenceController.importEvidence( + manualEntryID: manualEntryID, + originalFilename: originalFilename, + contentType: contentType, + data: data, + ) + } + + public func evidenceFileURL(for attachment: EvidenceAttachment) async -> URL? { + guard let data = await evidenceController.loadData(for: attachment) else { + return nil + } + + let sanitizedFilename = attachment.originalFilename.isEmpty ? attachment.id.uuidString : attachment.originalFilename + let previewDirectory = FileManager.default.temporaryDirectory.appending(path: "WherePreview") + + do { + try FileManager.default.createDirectory( + at: previewDirectory, + withIntermediateDirectories: true, + attributes: nil, + ) + let fileURL = previewDirectory.appending(path: "\(attachment.id.uuidString)-\(sanitizedFilename)") + try data.write(to: fileURL, options: .atomic) + return fileURL + } catch { + return nil + } + } + + public func deleteEvidence(_ attachment: EvidenceAttachment) async { + await evidenceController.delete(attachment) + } +} diff --git a/Where/WhereData/Sources/RepositoryYearDataProvider.swift b/Where/WhereData/Sources/RepositoryYearDataProvider.swift new file mode 100644 index 0000000..9bc8bc5 --- /dev/null +++ b/Where/WhereData/Sources/RepositoryYearDataProvider.swift @@ -0,0 +1,47 @@ +import Foundation +import WhereCore + +public actor RepositoryYearDataProvider: YearDataProviding { + private let locationRepository: any LocationSampleRepository + private let manualEntryRepository: any ManualLogEntryRepository + private let evidenceRepository: any EvidenceAttachmentRepository + private let syncCheckpointStore: any SyncCheckpointStore + private let trackingStateStore: (any TrackingStateStore)? + + public init( + locationRepository: any LocationSampleRepository, + manualEntryRepository: any ManualLogEntryRepository, + evidenceRepository: any EvidenceAttachmentRepository, + syncCheckpointStore: any SyncCheckpointStore, + trackingStateStore: (any TrackingStateStore)? = nil, + ) { + self.locationRepository = locationRepository + self.manualEntryRepository = manualEntryRepository + self.evidenceRepository = evidenceRepository + self.syncCheckpointStore = syncCheckpointStore + self.trackingStateStore = trackingStateStore + } + + public func availableYears() async -> [Int] { + let locationYears = await locationRepository.availableYears() + let manualYears = await manualEntryRepository.availableYears() + return Array(Set(locationYears).union(manualYears)).sorted() + } + + public func bundle(for year: Int) async -> YearDataBundle { + let locationSamples = await locationRepository.samples(in: year) + let manualEntries = await manualEntryRepository.entries(in: year) + let evidenceAttachments = await evidenceRepository.attachments(for: manualEntries.map(\.id)) + let syncCheckpoint = await syncCheckpointStore.checkpoint() + let trackingState = await trackingStateStore?.load() + + return YearDataBundle( + year: year, + locationSamples: locationSamples, + manualEntries: manualEntries, + evidenceAttachments: evidenceAttachments, + syncCheckpoint: syncCheckpoint, + trackingState: trackingState, + ) + } +} diff --git a/Where/WhereData/Sources/ResetController.swift b/Where/WhereData/Sources/ResetController.swift new file mode 100644 index 0000000..f1f26b6 --- /dev/null +++ b/Where/WhereData/Sources/ResetController.swift @@ -0,0 +1,41 @@ +import Foundation +import WhereCore + +public actor ResetController: WhereDataResetting { + private let locationRepository: any LocationSampleRepository + private let manualEntryRepository: any ManualLogEntryRepository + private let evidenceAttachmentRepository: any EvidenceAttachmentRepository + private let syncCheckpointStore: any SyncCheckpointStore + private let trackingStateStore: any TrackingStateStore + private let fileManager: FileManager + private let baseDirectoryURL: URL + + public init( + locationRepository: any LocationSampleRepository, + manualEntryRepository: any ManualLogEntryRepository, + evidenceAttachmentRepository: any EvidenceAttachmentRepository, + syncCheckpointStore: any SyncCheckpointStore, + trackingStateStore: any TrackingStateStore, + baseDirectoryURL: URL, + fileManager: FileManager = .default, + ) { + self.locationRepository = locationRepository + self.manualEntryRepository = manualEntryRepository + self.evidenceAttachmentRepository = evidenceAttachmentRepository + self.syncCheckpointStore = syncCheckpointStore + self.trackingStateStore = trackingStateStore + self.baseDirectoryURL = baseDirectoryURL + self.fileManager = fileManager + } + + public func resetAllData() async { + await locationRepository.removeAll() + await manualEntryRepository.removeAll() + await evidenceAttachmentRepository.removeAll() + await syncCheckpointStore.reset() + await trackingStateStore.reset() + + let evidenceDirectory = baseDirectoryURL.appending(path: "evidence") + try? fileManager.removeItem(at: evidenceDirectory) + } +} diff --git a/Where/WhereData/Sources/SampleYearDataProvider.swift b/Where/WhereData/Sources/SampleYearDataProvider.swift new file mode 100644 index 0000000..19b3b9d --- /dev/null +++ b/Where/WhereData/Sources/SampleYearDataProvider.swift @@ -0,0 +1,182 @@ +import Foundation +import WhereCore + +public actor SampleYearDataProvider: YearDataProviding { + private let calendar: Calendar + private let sampleData: [LocationSample] + private let manualEntries: [ManualLogEntry] + private let evidenceAttachments: [EvidenceAttachment] + private let syncCheckpoint: SyncCheckpoint + private let trackingState: TrackingState + + public init( + calendar: Calendar = .current, + sampleData: [LocationSample]? = nil, + manualEntries: [ManualLogEntry]? = nil, + evidenceAttachments: [EvidenceAttachment]? = nil, + syncCheckpoint: SyncCheckpoint = .init(state: .idle), + trackingState: TrackingState? = nil, + ) { + let resolvedSampleData = sampleData ?? SampleDataFactory.makeSamples(calendar: calendar) + let resolvedManualEntries = manualEntries ?? SampleDataFactory.makeManualEntries(calendar: calendar) + let resolvedEvidenceAttachments = evidenceAttachments + ?? SampleDataFactory.makeEvidenceAttachments( + manualEntries: resolvedManualEntries, + calendar: calendar, + ) + let currentDate = Date() + let resolvedTrackingState = trackingState ?? TrackingState( + authorizationStatus: .authorizedAlways, + lastWakeEventAt: currentDate, + lastRecordedSampleAt: currentDate, + lastWakeReason: .appLaunch, + isMonitoringActive: true, + ) + + self.calendar = calendar + self.sampleData = resolvedSampleData + self.manualEntries = resolvedManualEntries + self.evidenceAttachments = resolvedEvidenceAttachments + self.syncCheckpoint = syncCheckpoint + self.trackingState = resolvedTrackingState + } + + public func availableYears() async -> [Int] { + let sampleYears = sampleData.map { calendar.component(.year, from: $0.timestamp) } + let manualYears = manualEntries.map { calendar.component(.year, from: $0.timestamp) } + return Array(Set(sampleYears).union(manualYears)).sorted() + } + + public func bundle(for year: Int) async -> YearDataBundle { + let yearManualEntries = manualEntries + .filter { calendar.component(.year, from: $0.timestamp) == year } + .sorted { $0.timestamp < $1.timestamp } + let manualEntryIDs = Set(yearManualEntries.map(\.id)) + + return YearDataBundle( + year: year, + locationSamples: sampleData + .filter { calendar.component(.year, from: $0.timestamp) == year } + .sorted { $0.timestamp < $1.timestamp }, + manualEntries: yearManualEntries, + evidenceAttachments: evidenceAttachments + .filter { manualEntryIDs.contains($0.manualEntryID) } + .sorted { $0.createdAt < $1.createdAt }, + syncCheckpoint: syncCheckpoint, + trackingState: trackingState, + ) + } +} + +enum SampleDataFactory { + static func makeSamples(calendar: Calendar) -> [LocationSample] { + let year = calendar.component(.year, from: Date()) + + return [ + makeSample(year: year, month: 1, day: 4, hour: 9, jurisdiction: .california, calendar: calendar), + makeSample(year: year, month: 1, day: 4, hour: 20, jurisdiction: .newYork, calendar: calendar), + makeSample(year: year, month: 2, day: 10, hour: 8, jurisdiction: .california, calendar: calendar), + makeSample(year: year, month: 2, day: 11, hour: 8, jurisdiction: .california, calendar: calendar), + makeSample(year: year, month: 2, day: 12, hour: 8, jurisdiction: .unknown, calendar: calendar), + makeSample(year: year, month: 2, day: 13, hour: 8, jurisdiction: .newYork, calendar: calendar), + ] + } + + static func makeManualEntries(calendar: Calendar) -> [ManualLogEntry] { + let year = calendar.component(.year, from: Date()) + + return [ + ManualLogEntry( + timestamp: makeDate(year: year, month: 2, day: 12, hour: 12, calendar: calendar), + jurisdiction: .newYork, + note: "Attached ticket for overnight travel", + kind: .correction, + ), + ManualLogEntry( + timestamp: makeDate(year: year, month: 1, day: 4, hour: 21, calendar: calendar), + jurisdiction: .california, + note: "Added airport evidence", + kind: .supplemental, + ), + ] + } + + static func makeEvidenceAttachments( + manualEntries: [ManualLogEntry], + calendar: Calendar, + ) -> [EvidenceAttachment] { + guard + manualEntries.count >= 2 + else { + return [] + } + + return [ + EvidenceAttachment( + manualEntryID: manualEntries[0].id, + originalFilename: "overnight-train-ticket.pdf", + contentType: "application/pdf", + byteCount: 48000, + createdAt: makeDate( + year: calendar.component(.year, from: manualEntries[0].timestamp), + month: 2, + day: 12, + hour: 12, + calendar: calendar, + ), + ), + EvidenceAttachment( + manualEntryID: manualEntries[1].id, + originalFilename: "airport-receipt.jpg", + contentType: "image/jpeg", + byteCount: 8300, + createdAt: makeDate( + year: calendar.component(.year, from: manualEntries[1].timestamp), + month: 1, + day: 4, + hour: 21, + calendar: calendar, + ), + ), + ] + } + + private static func makeSample( + year: Int, + month: Int, + day: Int, + hour: Int, + jurisdiction: TaxJurisdiction, + calendar: Calendar, + ) -> LocationSample { + let components = DateComponents( + calendar: calendar, + year: year, + month: month, + day: day, + hour: hour, + ) + + return LocationSample( + timestamp: components.date ?? Date(), + jurisdiction: jurisdiction, + ) + } + + private static func makeDate( + year: Int, + month: Int, + day: Int, + hour: Int, + calendar: Calendar, + ) -> Date { + DateComponents( + calendar: calendar, + year: year, + month: month, + day: day, + hour: hour, + ) + .date ?? Date() + } +} diff --git a/Where/WhereData/Sources/SyncController.swift b/Where/WhereData/Sources/SyncController.swift new file mode 100644 index 0000000..b8d0ab1 --- /dev/null +++ b/Where/WhereData/Sources/SyncController.swift @@ -0,0 +1,61 @@ +import Foundation +import WhereCore + +public actor SyncController { + private let store: any SyncCheckpointStore + + public init(store: any SyncCheckpointStore) { + self.store = store + } + + public func checkpoint() async -> SyncCheckpoint { + await store.checkpoint() + } + + public func markSyncStarted(at date: Date = Date()) async { + let current = await store.checkpoint() + await store.save( + SyncCheckpoint( + state: .syncing, + lastSuccessfulSyncAt: current.lastSuccessfulSyncAt, + lastAttemptAt: date, + failureReason: nil, + ), + ) + } + + public func markSyncSucceeded(at date: Date = Date()) async { + await store.save( + SyncCheckpoint( + state: .idle, + lastSuccessfulSyncAt: date, + lastAttemptAt: date, + failureReason: nil, + ), + ) + } + + public func markSyncFailed(reason: String, at date: Date = Date()) async { + let current = await store.checkpoint() + await store.save( + SyncCheckpoint( + state: .failed, + lastSuccessfulSyncAt: current.lastSuccessfulSyncAt, + lastAttemptAt: date, + failureReason: reason, + ), + ) + } + + public func markPendingUpload() async { + let current = await store.checkpoint() + await store.save( + SyncCheckpoint( + state: .pendingUpload, + lastSuccessfulSyncAt: current.lastSuccessfulSyncAt, + lastAttemptAt: current.lastAttemptAt, + failureReason: nil, + ), + ) + } +} diff --git a/Where/WhereData/Sources/TrackingStateController.swift b/Where/WhereData/Sources/TrackingStateController.swift new file mode 100644 index 0000000..49d7624 --- /dev/null +++ b/Where/WhereData/Sources/TrackingStateController.swift @@ -0,0 +1,99 @@ +import Foundation +import WhereCore + +public actor TrackingStateController { + private let store: any TrackingStateStore + + public init(store: any TrackingStateStore) { + self.store = store + } + + public func state() async -> TrackingState { + await store.load() + } + + public func updateAuthorization(_ status: TrackingAuthorizationStatus) async { + let current = await store.load() + await store.save( + TrackingState( + authorizationStatus: status, + lastWakeEventAt: current.lastWakeEventAt, + lastRecordedSampleAt: current.lastRecordedSampleAt, + lastWakeReason: current.lastWakeReason, + pendingGapNotificationDates: current.pendingGapNotificationDates, + isMonitoringActive: current.isMonitoringActive, + ), + ) + } + + public func markMonitoringActive(_ isActive: Bool) async { + let current = await store.load() + await store.save( + TrackingState( + authorizationStatus: current.authorizationStatus, + lastWakeEventAt: current.lastWakeEventAt, + lastRecordedSampleAt: current.lastRecordedSampleAt, + lastWakeReason: current.lastWakeReason, + pendingGapNotificationDates: current.pendingGapNotificationDates, + isMonitoringActive: isActive, + ), + ) + } + + public func recordWakeEvent(_ event: TrackingWakeEvent) async { + let current = await store.load() + await store.save( + TrackingState( + authorizationStatus: current.authorizationStatus, + lastWakeEventAt: event.timestamp, + lastRecordedSampleAt: current.lastRecordedSampleAt, + lastWakeReason: event.reason, + pendingGapNotificationDates: current.pendingGapNotificationDates, + isMonitoringActive: current.isMonitoringActive, + ), + ) + } + + public func recordSample(at timestamp: Date) async { + let current = await store.load() + await store.save( + TrackingState( + authorizationStatus: current.authorizationStatus, + lastWakeEventAt: current.lastWakeEventAt, + lastRecordedSampleAt: timestamp, + lastWakeReason: current.lastWakeReason, + pendingGapNotificationDates: current.pendingGapNotificationDates, + isMonitoringActive: current.isMonitoringActive, + ), + ) + } + + public func scheduleGapNotification(for date: Date) async { + let current = await store.load() + let pending = Array(Set(current.pendingGapNotificationDates + [date])).sorted() + await store.save( + TrackingState( + authorizationStatus: current.authorizationStatus, + lastWakeEventAt: current.lastWakeEventAt, + lastRecordedSampleAt: current.lastRecordedSampleAt, + lastWakeReason: current.lastWakeReason, + pendingGapNotificationDates: pending, + isMonitoringActive: current.isMonitoringActive, + ), + ) + } + + public func clearGapNotifications() async { + let current = await store.load() + await store.save( + TrackingState( + authorizationStatus: current.authorizationStatus, + lastWakeEventAt: current.lastWakeEventAt, + lastRecordedSampleAt: current.lastRecordedSampleAt, + lastWakeReason: current.lastWakeReason, + pendingGapNotificationDates: [], + isMonitoringActive: current.isMonitoringActive, + ), + ) + } +} diff --git a/Where/WhereData/Sources/WhereDataStore.swift b/Where/WhereData/Sources/WhereDataStore.swift new file mode 100644 index 0000000..3031482 --- /dev/null +++ b/Where/WhereData/Sources/WhereDataStore.swift @@ -0,0 +1,123 @@ +import Foundation +import WhereCore + +public struct WhereDataStore { + public let yearDataProvider: RepositoryYearDataProvider + public let evidenceController: EvidenceController + public let manualEntryController: ManualEntryController + public let manualDataImportController: ManualDataImportController + public let resetController: ResetController + public let syncController: SyncController + public let yearExportController: YearExportController + public let locationRepository: any LocationSampleRepository + public let trackingStateStore: any TrackingStateStore + + public init( + yearDataProvider: RepositoryYearDataProvider, + evidenceController: EvidenceController, + manualEntryController: ManualEntryController, + manualDataImportController: ManualDataImportController, + resetController: ResetController, + syncController: SyncController, + yearExportController: YearExportController, + locationRepository: any LocationSampleRepository, + trackingStateStore: any TrackingStateStore, + ) { + self.yearDataProvider = yearDataProvider + self.evidenceController = evidenceController + self.manualEntryController = manualEntryController + self.manualDataImportController = manualDataImportController + self.resetController = resetController + self.syncController = syncController + self.yearExportController = yearExportController + self.locationRepository = locationRepository + self.trackingStateStore = trackingStateStore + } + + public func makeYearProgressController( + calendar: Calendar = .current, + ) -> YearProgressController { + YearProgressController( + calendar: calendar, + yearDataProvider: yearDataProvider, + ) + } + + public static func makeDefault( + calendar: Calendar = .current, + fileManager: FileManager = .default, + ) -> Self { + let baseURL = defaultBaseURL(fileManager: fileManager) + let locationRepository = FileLocationSampleRepository( + calendar: calendar, + fileURL: baseURL.appending(path: "location-samples.json"), + ) + let manualEntryRepository = FileManualLogEntryRepository( + calendar: calendar, + fileURL: baseURL.appending(path: "manual-entries.json"), + ) + let evidenceRepository = FileEvidenceAttachmentRepository( + fileURL: baseURL.appending(path: "evidence-index.json"), + ) + let syncCheckpointStore = FileSyncCheckpointStore( + fileURL: baseURL.appending(path: "sync-checkpoint.json"), + ) + let trackingStateStore = FileTrackingStateStore( + fileURL: baseURL.appending(path: "tracking-state.json"), + ) + let blobStore = FileEvidenceBlobStore( + baseDirectoryURL: baseURL.appending(path: "evidence"), + ) + let yearDataProvider = RepositoryYearDataProvider( + locationRepository: locationRepository, + manualEntryRepository: manualEntryRepository, + evidenceRepository: evidenceRepository, + syncCheckpointStore: syncCheckpointStore, + trackingStateStore: trackingStateStore, + ) + let evidenceController = EvidenceController( + attachmentRepository: evidenceRepository, + fileStore: blobStore, + ) + let manualEntryController = ManualEntryController( + repository: manualEntryRepository, + evidenceController: evidenceController, + ) + let manualDataImportController = ManualDataImportController( + calendar: calendar, + manualEntryRepository: manualEntryRepository, + manualEntryController: manualEntryController, + evidenceController: evidenceController, + ) + let resetController = ResetController( + locationRepository: locationRepository, + manualEntryRepository: manualEntryRepository, + evidenceAttachmentRepository: evidenceRepository, + syncCheckpointStore: syncCheckpointStore, + trackingStateStore: trackingStateStore, + baseDirectoryURL: baseURL, + ) + let yearExportController = YearExportController( + calendar: calendar, + yearDataProvider: yearDataProvider, + ) + + return Self( + yearDataProvider: yearDataProvider, + evidenceController: evidenceController, + manualEntryController: manualEntryController, + manualDataImportController: manualDataImportController, + resetController: resetController, + syncController: SyncController(store: syncCheckpointStore), + yearExportController: yearExportController, + locationRepository: locationRepository, + trackingStateStore: trackingStateStore, + ) + } + + static func defaultBaseURL(fileManager: FileManager) -> URL { + let applicationSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.temporaryDirectory + return applicationSupport.appending(path: "Where") + } +} diff --git a/Where/WhereData/Sources/YearExportController.swift b/Where/WhereData/Sources/YearExportController.swift new file mode 100644 index 0000000..6f280ee --- /dev/null +++ b/Where/WhereData/Sources/YearExportController.swift @@ -0,0 +1,508 @@ +import Foundation +import WhereCore + +public actor YearExportController: YearExporting { + private let calendar: Calendar + private let yearDataProvider: any YearDataProviding + private let ledgerBuilder: YearLedgerBuilder + private let generatedAt: @Sendable () -> Date + private let timestampFormatter: DateFormatter + private let dayFormatter: DateFormatter + + public init( + calendar: Calendar = .current, + yearDataProvider: any YearDataProviding, + generatedAt: @escaping @Sendable () -> Date = Date.init, + ) { + self.calendar = calendar + self.yearDataProvider = yearDataProvider + ledgerBuilder = YearLedgerBuilder(calendar: calendar) + self.generatedAt = generatedAt + + timestampFormatter = DateFormatter() + timestampFormatter.calendar = calendar + timestampFormatter.locale = Locale(identifier: "en_US_POSIX") + timestampFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss ZZZZ" + + dayFormatter = DateFormatter() + dayFormatter.calendar = calendar + dayFormatter.locale = Locale(identifier: "en_US_POSIX") + dayFormatter.dateFormat = "yyyy-MM-dd" + } + + public func exportBundle(for year: Int) async -> YearExportBundle { + let bundle = await yearDataProvider.bundle(for: year) + let ledgers = ledgerBuilder.makeLedgers( + year: year, + samples: bundle.locationSamples, + manualEntries: bundle.manualEntries, + ) + let summary = ledgerBuilder.makeYearSummary(year: year, ledgers: ledgers) + let manualRecords = manualRecords( + from: bundle.manualEntries, + evidenceAttachments: bundle.evidenceAttachments, + ) + let generatedAt = generatedAt() + let plaintext = plaintextExport( + year: year, + summary: summary, + ledgers: ledgers, + manualRecords: manualRecords, + generatedAt: generatedAt, + trackingState: bundle.trackingState, + ) + + return YearExportBundle( + year: year, + generatedAt: generatedAt, + plaintext: plaintext, + pdfData: pdfData( + year: year, + summary: summary, + ledgers: ledgers, + manualRecords: manualRecords, + generatedAt: generatedAt, + trackingState: bundle.trackingState, + ), + ) + } + + private func manualRecords( + from entries: [ManualLogEntry], + evidenceAttachments: [EvidenceAttachment], + ) -> [ManualEntryRecord] { + let attachmentsByEntryID = Dictionary(grouping: evidenceAttachments, by: \.manualEntryID) + + return entries + .sorted { $0.timestamp < $1.timestamp } + .map { entry in + ManualEntryRecord( + entry: entry, + attachments: attachmentsByEntryID[entry.id, default: []] + .sorted { $0.createdAt < $1.createdAt }, + ) + } + } + + private func plaintextExport( + year: Int, + summary: YearSummary, + ledgers: [DailyStateLedger], + manualRecords: [ManualEntryRecord], + generatedAt: Date, + trackingState: TrackingState?, + ) -> String { + var lines = [ + "Where Tax Report", + "Year: \(year)", + "Generated: \(timestampFormatter.string(from: generatedAt))", + ] + + if let trackingState { + lines.append("Tracking status: \(trackingState.runtimeStatus(at: generatedAt).title)") + } + + lines += [ + "", + "Totals", + ] + + let orderedTotals = summary.totalsByJurisdiction + .sorted { lhs, rhs in + if lhs.value == rhs.value { + return lhs.key.displayName < rhs.key.displayName + } + + return lhs.value > rhs.value + } + + for (jurisdiction, totalDays) in orderedTotals { + lines.append("- \(jurisdiction.displayName): \(totalDays) day(s)") + } + + lines += [ + "- Unknown: \(summary.unknownDayCount) day(s)", + "- Total tracked days: \(summary.totalTrackedDays)", + "", + "Daily Ledger", + ] + + if ledgers.isEmpty { + lines.append("- No daily ledger entries") + } else { + for ledger in ledgers { + let jurisdictions = ledger.finalJurisdictions.map(\.displayName).joined(separator: ", ") + let noteSuffix = ledger.note.map { " | \($0)" } ?? "" + lines.append("- \(dayFormatter.string(from: ledger.date)): \(jurisdictions)\(noteSuffix)") + } + } + + lines += [ + "", + "Manual Entries", + ] + + if manualRecords.isEmpty { + lines.append("- No manual entries") + } else { + for record in manualRecords { + let entry = record.entry + let noteSuffix = entry.note.map { " | \($0)" } ?? "" + lines.append( + "- \(timestampFormatter.string(from: entry.timestamp)) | \(entry.kind.rawValue.capitalized) | \(entry.jurisdiction.displayName)\(noteSuffix)", + ) + + if record.attachments.isEmpty { + lines.append(" Evidence: none") + } else { + for attachment in record.attachments { + lines.append( + " Evidence: \(attachment.originalFilename) (\(attachment.contentType), \(attachment.byteCount) bytes)", + ) + } + } + } + } + + return lines.joined(separator: "\n") + } + + private func pdfData( + year: Int, + summary: YearSummary, + ledgers: [DailyStateLedger], + manualRecords: [ManualEntryRecord], + generatedAt: Date, + trackingState: TrackingState?, + ) -> Data { + var pageStreams: [String] = [] + var pageText = "" + var cursorY: Double = 742 + + func startPage() { + pageText = "" + cursorY = 742 + } + + func commitPage() { + let trimmed = pageText.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + pageStreams.append(trimmed) + } + } + + func ensureSpace(_ requiredHeight: Double) { + if cursorY - requiredHeight < 50 { + commitPage() + startPage() + } + } + + func appendText(_ text: String, x: Double, font: String, size: Double) { + pageText += "BT\n/\(font) \(size) Tf\n1 0 0 1 \(pdfNumber(x)) \(pdfNumber(cursorY)) Tm\n(\(escapedPDFText(text))) Tj\nET\n" + } + + func appendWrappedText( + _ text: String, + x: Double, + font: String, + size: Double, + lineHeight: Double, + width: Int, + ) { + let lines = wrappedLines(for: text, maxCharacters: width) + ensureSpace(Double(lines.count) * lineHeight) + + for line in lines { + appendText(line, x: x, font: font, size: size) + cursorY -= lineHeight + } + } + + func appendSpacer(_ amount: Double) { + cursorY -= amount + } + + startPage() + + appendText("Where Tax Report", x: 50, font: "F2", size: 22) + cursorY -= 28 + appendText("Year \(year)", x: 50, font: "F2", size: 16) + cursorY -= 22 + appendText("Generated \(timestampFormatter.string(from: generatedAt))", x: 50, font: "F1", size: 11) + cursorY -= 18 + + if let trackingState { + appendText("Tracking status: \(trackingState.runtimeStatus(at: generatedAt).title)", x: 50, font: "F1", size: 11) + cursorY -= 22 + } else { + cursorY -= 4 + } + + ensureSpace(24) + appendText("Totals", x: 50, font: "F2", size: 14) + cursorY -= 20 + + let orderedTotals = summary.totalsByJurisdiction + .sorted { lhs, rhs in + if lhs.value == rhs.value { + return lhs.key.displayName < rhs.key.displayName + } + + return lhs.value > rhs.value + } + + for (jurisdiction, totalDays) in orderedTotals { + appendWrappedText( + "\(jurisdiction.displayName): \(totalDays) day(s)", + x: 64, + font: "F1", + size: 11, + lineHeight: 14, + width: 62, + ) + } + + appendWrappedText( + "Unknown: \(summary.unknownDayCount) day(s)", + x: 64, + font: "F1", + size: 11, + lineHeight: 14, + width: 62, + ) + appendWrappedText( + "Total tracked days: \(summary.totalTrackedDays)", + x: 64, + font: "F1", + size: 11, + lineHeight: 14, + width: 62, + ) + appendSpacer(10) + + ensureSpace(24) + appendText("Daily Ledger", x: 50, font: "F2", size: 14) + cursorY -= 20 + + if ledgers.isEmpty { + appendWrappedText( + "No daily ledger entries", + x: 64, + font: "F1", + size: 11, + lineHeight: 14, + width: 62, + ) + } else { + for ledger in ledgers { + let jurisdictions = ledger.finalJurisdictions.map(\.displayName).joined(separator: ", ") + appendWrappedText( + "\(dayFormatter.string(from: ledger.date)) \(jurisdictions)", + x: 64, + font: "F1", + size: 11, + lineHeight: 14, + width: 62, + ) + + if let note = ledger.note { + appendWrappedText( + note, + x: 80, + font: "F1", + size: 10, + lineHeight: 13, + width: 56, + ) + } + + appendSpacer(4) + } + } + + ensureSpace(24) + appendText("Manual Entries", x: 50, font: "F2", size: 14) + cursorY -= 20 + + if manualRecords.isEmpty { + appendWrappedText( + "No manual entries", + x: 64, + font: "F1", + size: 11, + lineHeight: 14, + width: 62, + ) + } else { + for record in manualRecords { + let entry = record.entry + appendWrappedText( + "\(timestampFormatter.string(from: entry.timestamp))", + x: 64, + font: "F2", + size: 11, + lineHeight: 14, + width: 62, + ) + appendWrappedText( + "\(entry.kind.rawValue.capitalized) - \(entry.jurisdiction.displayName)", + x: 80, + font: "F1", + size: 11, + lineHeight: 14, + width: 56, + ) + + if let note = entry.note { + appendWrappedText( + note, + x: 80, + font: "F1", + size: 10, + lineHeight: 13, + width: 56, + ) + } + + if record.attachments.isEmpty { + appendWrappedText( + "Evidence: none", + x: 80, + font: "F1", + size: 10, + lineHeight: 13, + width: 56, + ) + } else { + for attachment in record.attachments { + appendWrappedText( + "Evidence: \(attachment.originalFilename) (\(attachment.contentType), \(attachment.byteCount) bytes)", + x: 80, + font: "F1", + size: 10, + lineHeight: 13, + width: 56, + ) + } + } + + appendSpacer(8) + } + } + + commitPage() + + if pageStreams.isEmpty { + pageStreams = ["BT\n/F2 18 Tf\n1 0 0 1 50 742 Tm\n(Where Tax Report) Tj\nET"] + } + + return makePDFDocument(pageStreams: pageStreams) + } + + private func makePDFDocument(pageStreams: [String]) -> Data { + var objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>", + ] + var pageObjectNumbers: [Int] = [] + + for stream in pageStreams { + let pageObjectNumber = objects.count + 1 + let contentObjectNumber = pageObjectNumber + 1 + pageObjectNumbers.append(pageObjectNumber) + + objects.append( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents \(contentObjectNumber) 0 R >>", + ) + objects.append( + """ + << /Length \(stream.utf8.count) >> + stream + \(stream) + endstream + """, + ) + } + + objects[1] = "<< /Type /Pages /Kids [\(pageObjectNumbers.map { "\($0) 0 R" }.joined(separator: " "))] /Count \(pageObjectNumbers.count) >>" + + var pdf = "%PDF-1.4\n" + var offsets = [0] + + for (index, object) in objects.enumerated() { + offsets.append(pdf.utf8.count) + pdf += "\(index + 1) 0 obj\n\(object)\nendobj\n" + } + + let xrefOffset = pdf.utf8.count + pdf += "xref\n0 \(objects.count + 1)\n" + pdf += "0000000000 65535 f \n" + + for offset in offsets.dropFirst() { + pdf += String(format: "%010d 00000 n \n", offset) + } + + pdf += "trailer\n<< /Size \(objects.count + 1) /Root 1 0 R >>\n" + pdf += "startxref\n\(xrefOffset)\n%%EOF" + return Data(pdf.utf8) + } + + private func wrappedLines(for text: String, maxCharacters: Int) -> [String] { + guard !text.isEmpty else { + return [""] + } + + var lines: [String] = [] + + for paragraph in text.split(separator: "\n", omittingEmptySubsequences: false) { + let words = paragraph.split(separator: " ", omittingEmptySubsequences: true) + + if words.isEmpty { + lines.append("") + continue + } + + var currentLine = "" + + for word in words { + let candidate = currentLine.isEmpty ? String(word) : "\(currentLine) \(word)" + + if candidate.count <= maxCharacters { + currentLine = candidate + } else { + if !currentLine.isEmpty { + lines.append(currentLine) + } + + currentLine = String(word) + + while currentLine.count > maxCharacters { + let splitIndex = currentLine.index(currentLine.startIndex, offsetBy: maxCharacters) + lines.append(String(currentLine[.. String { + String(format: "%.2f", value) + } + + private func escapedPDFText(_ text: String) -> String { + text + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "(", with: "\\(") + .replacingOccurrences(of: ")", with: "\\)") + } +} diff --git a/Where/WhereData/Sources/YearProgressController.swift b/Where/WhereData/Sources/YearProgressController.swift new file mode 100644 index 0000000..48bb8bb --- /dev/null +++ b/Where/WhereData/Sources/YearProgressController.swift @@ -0,0 +1,104 @@ +import Foundation +import WhereCore + +public actor YearProgressController: YearProgressProviding { + private let calendar: Calendar + private let ledgerBuilder: YearLedgerBuilder + private let yearDataProvider: any YearDataProviding + private let dayFormatter: DateFormatter + + public init( + calendar: Calendar = .current, + yearDataProvider: (any YearDataProviding)? = nil, + ) { + self.calendar = calendar + ledgerBuilder = YearLedgerBuilder(calendar: calendar) + self.yearDataProvider = yearDataProvider ?? SampleYearDataProvider(calendar: calendar) + dayFormatter = DateFormatter() + dayFormatter.calendar = calendar + dayFormatter.locale = Locale(identifier: "en_US_POSIX") + dayFormatter.dateFormat = "MMM d" + } + + public func availableYears() async -> [Int] { + await yearDataProvider.availableYears() + } + + public func snapshot(for year: Int) async -> YearProgressSnapshot { + let bundle = await yearDataProvider.bundle(for: year) + let ledgers = ledgerBuilder.makeLedgers( + year: year, + samples: bundle.locationSamples, + manualEntries: bundle.manualEntries, + ) + let yearSummary = ledgerBuilder.makeYearSummary(year: year, ledgers: ledgers) + + let primary = [TaxJurisdiction.california, .newYork].map { jurisdiction in + YearProgressSnapshot.JurisdictionSummary( + jurisdiction: jurisdiction, + totalDays: yearSummary.totalsByJurisdiction[jurisdiction, default: 0], + ) + } + + let secondary = [TaxJurisdiction.unknown].map { jurisdiction in + YearProgressSnapshot.JurisdictionSummary( + jurisdiction: jurisdiction, + totalDays: jurisdiction == .unknown ? yearSummary.unknownDayCount : 0, + ) + } + + let recentDays = ledgers + .suffix(5) + .reversed() + .map { ledger in + YearProgressSnapshot.RecentDay( + dateLabel: dayFormatter.string(from: ledger.date), + jurisdictions: ledger.finalJurisdictions, + note: ledger.note, + ) + } + + return YearProgressSnapshot( + year: year, + primarySummaries: primary, + secondarySummaries: secondary, + trackingStatus: trackingStatus( + for: ledgers, + syncCheckpoint: bundle.syncCheckpoint, + trackingState: bundle.trackingState, + ), + recentDays: recentDays, + ) + } + + private func trackingStatus( + for ledgers: [DailyStateLedger], + syncCheckpoint: SyncCheckpoint, + trackingState: TrackingState?, + ) -> TrackingStatus { + if let trackingState { + let runtimeStatus = trackingState.runtimeStatus(at: Date()) + if runtimeStatus == .needsAttention { + return .needsAttention + } + } + + guard !ledgers.isEmpty else { + return .needsAttention + } + + if syncCheckpoint.state == .failed { + return .needsAttention + } + + if let trackingState, trackingState.runtimeStatus(at: Date()) == .needsReview { + return .needsReview + } + + if ledgers.contains(where: \.needsReview) { + return .needsReview + } + + return .healthy + } +} diff --git a/Where/WhereData/Tests/WhereDataTests.swift b/Where/WhereData/Tests/WhereDataTests.swift new file mode 100644 index 0000000..3340365 --- /dev/null +++ b/Where/WhereData/Tests/WhereDataTests.swift @@ -0,0 +1,901 @@ +import Foundation +import Testing +import WhereCore +import WhereData + +@Test +func yearProgressControllerBuildsSnapshot() async throws { + let controller = YearProgressController() + let years = await controller.availableYears() + let year = try #require(years.last) + + let snapshot = await controller.snapshot(for: year) + + #expect(snapshot.year == year) + #expect(snapshot.primarySummaries.count == 2) + #expect(snapshot.secondarySummaries.contains { $0.jurisdiction == .unknown }) + #expect(snapshot.primarySummaries.first { $0.jurisdiction == .newYork }?.totalDays == 3) + #expect(snapshot.secondarySummaries.first { $0.jurisdiction == .unknown }?.totalDays == 0) + #expect(snapshot.trackingStatus == .healthy) + #expect(!snapshot.recentDays.isEmpty) +} + +@Test +func fileRepositoriesPersistAndFilterByYear() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString) + let locationRepository = FileLocationSampleRepository( + calendar: calendarUTC(), + fileURL: directory.appending(path: "location-samples.json"), + ) + let manualRepository = FileManualLogEntryRepository( + calendar: calendarUTC(), + fileURL: directory.appending(path: "manual-entries.json"), + ) + + let californiaSample = try LocationSample( + timestamp: makeDate(year: 2026, month: 4, day: 5, hour: 9), + jurisdiction: .california, + ) + let newYorkSample = try LocationSample( + timestamp: makeDate(year: 2025, month: 12, day: 31, hour: 21), + jurisdiction: .newYork, + ) + let entry = try ManualLogEntry( + timestamp: makeDate(year: 2026, month: 4, day: 5, hour: 10), + jurisdiction: .newYork, + note: "Train ticket attached", + kind: .supplemental, + ) + + await locationRepository.upsert([californiaSample, newYorkSample]) + await manualRepository.save(entry) + + let years = await locationRepository.availableYears() + let samples2026 = await locationRepository.samples(in: 2026) + let manualYears = await manualRepository.availableYears() + let entries2026 = await manualRepository.entries(in: 2026) + + #expect(years == [2025, 2026]) + #expect(samples2026 == [californiaSample]) + #expect(manualYears == [2026]) + #expect(entries2026 == [entry]) +} + +@Test +func evidenceControllerStoresMetadataAndBlobData() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString) + let attachmentRepository = FileEvidenceAttachmentRepository( + fileURL: directory.appending(path: "evidence-index.json"), + ) + let blobStore = FileEvidenceBlobStore( + baseDirectoryURL: directory.appending(path: "evidence"), + ) + let controller = EvidenceController( + attachmentRepository: attachmentRepository, + fileStore: blobStore, + ) + let manualEntryID = UUID() + let data = Data("ticket-pdf".utf8) + + let attachment = try await controller.importEvidence( + manualEntryID: manualEntryID, + originalFilename: "ticket.pdf", + contentType: "application/pdf", + data: data, + createdAt: makeDate(year: 2026, month: 4, day: 5, hour: 12), + ) + let attachments = await controller.attachments(for: manualEntryID) + let loadedData = await controller.loadData(for: attachment) + + #expect(attachments == [attachment]) + #expect(attachment.storageKey == attachment.id.uuidString) + #expect(loadedData == data) + + await controller.delete(attachment) + + let remainingAttachments = await controller.attachments(for: manualEntryID) + let deletedData = await controller.loadData(for: attachment) + + #expect(remainingAttachments.isEmpty) + #expect(deletedData == nil) +} + +@Test +func yearProgressControllerMarksFailedSyncAsNeedsAttention() async throws { + let year = 2026 + let sample = try LocationSample( + timestamp: makeDate(year: year, month: 4, day: 5, hour: 9), + jurisdiction: .california, + ) + let provider = try SampleYearDataProvider( + calendar: calendarUTC(), + sampleData: [sample], + manualEntries: [], + evidenceAttachments: [], + syncCheckpoint: .init( + state: .failed, + lastSuccessfulSyncAt: nil, + lastAttemptAt: makeDate(year: year, month: 4, day: 5, hour: 10), + failureReason: "CloudKit unavailable", + ), + ) + let controller = YearProgressController( + calendar: calendarUTC(), + yearDataProvider: provider, + ) + + let snapshot = await controller.snapshot(for: year) + + #expect(snapshot.trackingStatus == .needsAttention) +} + +@Test +func manualEntryControllerSavesEntriesAndImportsEvidence() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString) + let repository = FileManualLogEntryRepository( + calendar: calendarUTC(), + fileURL: directory.appending(path: "manual-entries.json"), + ) + let evidenceRepository = FileEvidenceAttachmentRepository( + fileURL: directory.appending(path: "evidence-index.json"), + ) + let evidenceController = EvidenceController( + attachmentRepository: evidenceRepository, + fileStore: FileEvidenceBlobStore( + baseDirectoryURL: directory.appending(path: "evidence"), + ), + ) + let controller = ManualEntryController( + repository: repository, + evidenceController: evidenceController, + ) + let draft = try ManualEntryDraft( + timestamp: makeDate(year: 2026, month: 4, day: 5, hour: 10), + jurisdiction: .newYork, + note: " Boarding pass attached ", + kind: .correction, + ) + let evidenceURL = directory.appending(path: "ticket.txt") + + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: nil, + ) + try Data("ticket".utf8).write(to: evidenceURL) + + let saved = await controller.save(draft) + let attachment = await controller.importEvidence(manualEntryID: saved.id, fileURL: evidenceURL) + let records = await controller.records(in: 2026) + + #expect(saved.entry.note == "Boarding pass attached") + #expect(attachment?.originalFilename == "ticket.txt") + #expect(records.count == 1) + #expect(records.first?.attachments.count == 1) +} + +@Test +func manualEntryControllerDeletesEntriesAndAttachedEvidence() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString) + let repository = FileManualLogEntryRepository( + calendar: calendarUTC(), + fileURL: directory.appending(path: "manual-entries.json"), + ) + let evidenceRepository = FileEvidenceAttachmentRepository( + fileURL: directory.appending(path: "evidence-index.json"), + ) + let evidenceController = EvidenceController( + attachmentRepository: evidenceRepository, + fileStore: FileEvidenceBlobStore( + baseDirectoryURL: directory.appending(path: "evidence"), + ), + ) + let controller = ManualEntryController( + repository: repository, + evidenceController: evidenceController, + ) + let draft = try ManualEntryDraft( + timestamp: makeDate(year: 2026, month: 4, day: 5, hour: 10), + jurisdiction: .california, + note: "Receipt attached", + kind: .supplemental, + ) + let evidenceURL = directory.appending(path: "receipt.txt") + + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: nil, + ) + try Data("receipt".utf8).write(to: evidenceURL) + + let saved = await controller.save(draft) + _ = await controller.importEvidence(manualEntryID: saved.id, fileURL: evidenceURL) + + await controller.deleteEntry(id: saved.id) + + let records = await controller.records(in: 2026) + let attachments = await evidenceController.attachments(for: saved.id) + + #expect(records.isEmpty) + #expect(attachments.isEmpty) +} + +@Test +func manualEntryControllerCreatesPreviewFileURLForEvidence() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString) + let repository = FileManualLogEntryRepository( + calendar: calendarUTC(), + fileURL: directory.appending(path: "manual-entries.json"), + ) + let evidenceRepository = FileEvidenceAttachmentRepository( + fileURL: directory.appending(path: "evidence-index.json"), + ) + let evidenceController = EvidenceController( + attachmentRepository: evidenceRepository, + fileStore: FileEvidenceBlobStore( + baseDirectoryURL: directory.appending(path: "evidence"), + ), + ) + let controller = ManualEntryController( + repository: repository, + evidenceController: evidenceController, + ) + let draft = try ManualEntryDraft( + timestamp: makeDate(year: 2026, month: 4, day: 5, hour: 10), + jurisdiction: .california, + note: "Passport stamp", + kind: .supplemental, + ) + let evidenceURL = directory.appending(path: "stamp.txt") + + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: nil, + ) + try Data("stamp".utf8).write(to: evidenceURL) + + let saved = await controller.save(draft) + let attachment = try #require( + await controller.importEvidence(manualEntryID: saved.id, fileURL: evidenceURL), + ) + let previewURL = await controller.evidenceFileURL(for: attachment) + + #expect(previewURL != nil) + #expect(try Data(contentsOf: #require(previewURL)) == Data("stamp".utf8)) +} + +@Test +func fileManualLogEntryRepositoryBatchSaveMergesImportedEntries() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString) + let repository = FileManualLogEntryRepository( + calendar: calendarUTC(), + fileURL: directory.appending(path: "manual-entries.json"), + ) + let original = try ManualLogEntry( + id: UUID(), + timestamp: makeDate(year: 2026, month: 4, day: 5, hour: 9), + jurisdiction: .california, + note: "Original", + kind: .supplemental, + ) + let later = try ManualLogEntry( + timestamp: makeDate(year: 2026, month: 4, day: 5, hour: 11), + jurisdiction: .newYork, + note: "Later", + kind: .supplemental, + ) + let updated = ManualLogEntry( + id: original.id, + timestamp: original.timestamp, + jurisdiction: .newYork, + note: "Updated", + kind: .correction, + ) + + await repository.save([original, later]) + await repository.save([updated]) + + let entries = await repository.entries(in: 2026) + + #expect(entries == [updated, later]) +} + +@Test +func manualDataImportControllerPreviewsBackfillAcrossYearBoundary() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: nil, + ) + let evidenceURL = directory.appending(path: "ticket.txt") + try Data("ticket".utf8).write(to: evidenceURL) + + let controller = makeManualDataImportController(directory: directory) + let preview = try await controller.previewBackfill( + ManualImportBackfillRequest( + startDate: makeDate(year: 2025, month: 12, day: 31, hour: 9), + endDate: makeDate(year: 2026, month: 1, day: 2, hour: 9), + jurisdiction: .california, + note: "Year boundary import", + kind: .supplemental, + evidenceFiles: [evidenceURL], + ), + ) + + #expect(preview.isValid) + #expect(preview.entryCount == 3) + #expect(preview.evidenceAttachmentCount == 3) + #expect(preview.sharedEvidenceAttachmentCount == 1) + #expect(preview.yearSpan == 2025 ... 2026) +} + +@Test +func manualDataImportControllerImportsPackageManifestWithEvidence() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString) + try FileManager.default.createDirectory( + at: directory.appending(path: "evidence"), + withIntermediateDirectories: true, + attributes: nil, + ) + let manifest = try ManualImportPackageManifest( + entries: [ + .init( + timestamp: makeDate(year: 2026, month: 4, day: 5, hour: 10), + jurisdiction: .state("CA"), + note: "Imported from manifest", + kind: .supplemental, + evidenceFilenames: ["ticket.txt"], + ), + ], + ) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + try encoder.encode(manifest).write(to: directory.appending(path: "manifest.json")) + try Data("ticket-data".utf8).write(to: directory.appending(path: "evidence/ticket.txt")) + + let evidenceRepository = FileEvidenceAttachmentRepository( + fileURL: directory.appending(path: "evidence-index.json"), + ) + let evidenceController = EvidenceController( + attachmentRepository: evidenceRepository, + fileStore: FileEvidenceBlobStore(baseDirectoryURL: directory.appending(path: "evidence-store")), + ) + let manualRepository = FileManualLogEntryRepository( + calendar: calendarUTC(), + fileURL: directory.appending(path: "manual-entries.json"), + ) + let controller = ManualDataImportController( + calendar: calendarUTC(), + manualEntryRepository: manualRepository, + manualEntryController: ManualEntryController( + repository: manualRepository, + evidenceController: evidenceController, + ), + evidenceController: evidenceController, + ) + + let preview = await controller.previewPackage(at: directory) + let records = await controller.importPackage(at: directory) + + #expect(preview.isValid) + #expect(preview.entryCount == 1) + #expect(records.count == 1) + #expect(records.first?.entry.jurisdiction == .california) + #expect(records.first?.entry.note == "Imported from manifest") + #expect(records.first?.attachments.count == 1) + #expect(records.first?.attachments.first?.originalFilename == "ticket.txt") +} + +@Test +func manualDataImportControllerFlagsMissingEvidenceInPackagePreview() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: nil, + ) + let manifest = try ManualImportPackageManifest( + entries: [ + .init( + timestamp: makeDate(year: 2026, month: 4, day: 5, hour: 10), + jurisdiction: .state("CA"), + note: "Missing file", + kind: .correction, + evidenceFilenames: ["missing-ticket.txt"], + ), + ], + ) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + try encoder.encode(manifest).write(to: directory.appending(path: "manifest.json")) + + let controller = makeManualDataImportController(directory: directory) + let preview = await controller.previewPackage(at: directory) + + #expect(!preview.isValid) + #expect(preview.issues.contains { $0.message.contains("Missing evidence file") }) +} + +@Test +func manualDataImportControllerRollsBackEntriesWhenEvidencePersistenceFails() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: nil, + ) + let evidenceURL = directory.appending(path: "ticket.txt") + try Data("ticket".utf8).write(to: evidenceURL) + + let manualRepository = FileManualLogEntryRepository( + calendar: calendarUTC(), + fileURL: directory.appending(path: "manual-entries.json"), + ) + let evidenceRepository = FileEvidenceAttachmentRepository( + fileURL: directory.appending(path: "evidence-index.json"), + ) + let evidenceController = EvidenceController( + attachmentRepository: evidenceRepository, + fileStore: FailingEvidenceFileStore(), + ) + let controller = ManualDataImportController( + calendar: calendarUTC(), + manualEntryRepository: manualRepository, + manualEntryController: ManualEntryController( + repository: manualRepository, + evidenceController: evidenceController, + ), + evidenceController: evidenceController, + ) + let entry = try ManualImportEntryDraft( + timestamp: makeDate(year: 2026, month: 4, day: 5, hour: 10), + jurisdiction: .newYork, + note: "Should roll back", + kind: .supplemental, + evidenceFiles: [evidenceURL], + ) + + let imported = await controller.importEntries([entry]) + + #expect(imported.isEmpty) + #expect(await manualRepository.entries(in: 2026).isEmpty) + #expect(await evidenceRepository.attachments(for: entry.id).isEmpty) +} + +@Test +func yearExportControllerBuildsPlainTextAndPDFBundle() async throws { + let year = 2026 + let generatedAt = try makeDate(year: year, month: 4, day: 6, hour: 18) + let sample = try LocationSample( + timestamp: makeDate(year: year, month: 4, day: 5, hour: 9), + jurisdiction: .california, + ) + let manualEntry = try ManualLogEntry( + timestamp: makeDate(year: year, month: 4, day: 5, hour: 12), + jurisdiction: .newYork, + note: "Attached ticket", + kind: .supplemental, + ) + let attachment = try EvidenceAttachment( + manualEntryID: manualEntry.id, + originalFilename: "train-ticket.pdf", + contentType: "application/pdf", + byteCount: 42000, + createdAt: makeDate(year: year, month: 4, day: 5, hour: 12), + ) + let provider = SampleYearDataProvider( + calendar: calendarUTC(), + sampleData: [sample], + manualEntries: [manualEntry], + evidenceAttachments: [attachment], + trackingState: TrackingState( + authorizationStatus: .authorizedAlways, + lastWakeEventAt: generatedAt, + lastRecordedSampleAt: generatedAt, + lastWakeReason: .manualRefresh, + isMonitoringActive: true, + ), + ) + let controller = YearExportController( + calendar: calendarUTC(), + yearDataProvider: provider, + generatedAt: { generatedAt }, + ) + + let bundle = await controller.exportBundle(for: year) + + #expect(bundle.plaintext.contains("Where Tax Report")) + #expect(bundle.plaintext.contains("Manual Entries")) + #expect(bundle.plaintext.contains("train-ticket.pdf")) + #expect(bundle.pdfData.contains(Data("Where Tax Report".utf8))) + #expect(bundle.pdfData.contains(Data("Daily Ledger".utf8))) + #expect(bundle.pdfData.contains(Data("Manual Entries".utf8))) + #expect(bundle.plaintextFilename == "where-2026-report.txt") + #expect(bundle.pdfFilename == "where-2026-report.pdf") + #expect(String(decoding: bundle.pdfData.prefix(8), as: UTF8.self).hasPrefix("%PDF-1.4")) +} + +@Test +func resetControllerClearsPersistedWhereData() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString) + let locationRepository = FileLocationSampleRepository( + calendar: calendarUTC(), + fileURL: directory.appending(path: "location-samples.json"), + ) + let manualRepository = FileManualLogEntryRepository( + calendar: calendarUTC(), + fileURL: directory.appending(path: "manual-entries.json"), + ) + let evidenceRepository = FileEvidenceAttachmentRepository( + fileURL: directory.appending(path: "evidence-index.json"), + ) + let syncCheckpointStore = FileSyncCheckpointStore( + fileURL: directory.appending(path: "sync-checkpoint.json"), + ) + let trackingStateStore = FileTrackingStateStore( + fileURL: directory.appending(path: "tracking-state.json"), + ) + let evidenceController = EvidenceController( + attachmentRepository: evidenceRepository, + fileStore: FileEvidenceBlobStore( + baseDirectoryURL: directory.appending(path: "evidence"), + ), + ) + let resetController = ResetController( + locationRepository: locationRepository, + manualEntryRepository: manualRepository, + evidenceAttachmentRepository: evidenceRepository, + syncCheckpointStore: syncCheckpointStore, + trackingStateStore: trackingStateStore, + baseDirectoryURL: directory, + ) + + let sample = try LocationSample( + timestamp: makeDate(year: 2026, month: 4, day: 5, hour: 9), + jurisdiction: .california, + ) + let entry = try ManualLogEntry( + timestamp: makeDate(year: 2026, month: 4, day: 5, hour: 10), + jurisdiction: .newYork, + note: "Reset me", + kind: .correction, + ) + await locationRepository.upsert([sample]) + await manualRepository.save(entry) + _ = await evidenceController.importEvidence( + manualEntryID: entry.id, + originalFilename: "ticket.txt", + contentType: "text/plain", + data: Data("ticket".utf8), + ) + await syncCheckpointStore.save( + SyncCheckpoint( + state: .failed, + failureReason: "Network", + ), + ) + try await trackingStateStore.save( + TrackingState( + authorizationStatus: .authorizedAlways, + lastRecordedSampleAt: makeDate(year: 2026, month: 4, day: 5, hour: 9), + isMonitoringActive: true, + ), + ) + + await resetController.resetAllData() + + #expect(await locationRepository.samples(in: 2026).isEmpty) + #expect(await manualRepository.entries(in: 2026).isEmpty) + #expect(await evidenceRepository.attachments(for: entry.id).isEmpty) + #expect(await syncCheckpointStore.checkpoint() == .init(state: .idle)) + #expect(await trackingStateStore.load() == TrackingState(authorizationStatus: .notDetermined)) +} + +@Test +func backgroundTrackingControllerStartsMonitoringAfterAuthorization() async { + let locationRepository = InMemoryLocationSampleRepository() + let authorizationProvider = StubLocationAuthorizationProvider(status: .authorizedAlways) + let wakeSource = StubLocationWakeSource() + let notificationScheduler = StubTrackingNotificationScheduler() + let trackingStateStore = InMemoryTrackingStateStore( + state: TrackingState(authorizationStatus: .notDetermined), + ) + let controller = BackgroundTrackingController( + calendar: calendarUTC(), + locationRepository: locationRepository, + authorizationProvider: authorizationProvider, + wakeSource: wakeSource, + jurisdictionResolver: StubJurisdictionResolver(jurisdiction: .california), + notificationScheduler: notificationScheduler, + trackingStateController: TrackingStateController(store: trackingStateStore), + now: { try! makeDate(year: 2026, month: 4, day: 6, hour: 9) }, + ) + + await controller.prepareForLaunch() + let state = await controller.trackingState() + + #expect(await wakeSource.startedMonitoringCount == 1) + #expect(await wakeSource.stoppedMonitoringCount == 0) + #expect(state.authorizationStatus == .authorizedAlways) + #expect(state.isMonitoringActive) +} + +@Test +func backgroundTrackingControllerStoresWakeEventsAsLocationSamples() async throws { + let locationRepository = InMemoryLocationSampleRepository() + let controller = BackgroundTrackingController( + calendar: calendarUTC(), + locationRepository: locationRepository, + authorizationProvider: StubLocationAuthorizationProvider(status: .authorizedAlways), + wakeSource: StubLocationWakeSource(), + jurisdictionResolver: StubJurisdictionResolver(jurisdiction: .newYork), + notificationScheduler: StubTrackingNotificationScheduler(), + trackingStateController: TrackingStateController( + store: InMemoryTrackingStateStore( + state: TrackingState( + authorizationStatus: .authorizedAlways, + isMonitoringActive: true, + ), + ), + ), + now: { try! makeDate(year: 2026, month: 4, day: 6, hour: 9) }, + ) + let wakeDate = try makeDate(year: 2026, month: 4, day: 5, hour: 11) + + await controller.handleWakeEvent( + TrackingWakeEvent( + timestamp: wakeDate, + reason: .visit, + latitude: 40.7128, + longitude: -74.0060, + ), + ) + let samples = await locationRepository.samples(in: 2026) + let state = await controller.trackingState() + + #expect(samples.count == 1) + #expect(samples.first?.jurisdiction == .newYork) + #expect(state.lastWakeEventAt == wakeDate) + #expect(state.lastRecordedSampleAt == wakeDate) + #expect(state.lastWakeReason == .visit) +} + +@Test +func backgroundTrackingControllerSchedulesGapNotificationsForStaleTracking() async throws { + let notificationScheduler = StubTrackingNotificationScheduler() + let staleDate = try makeDate(year: 2026, month: 4, day: 3, hour: 9) + let nowDate = try makeDate(year: 2026, month: 4, day: 6, hour: 9) + let controller = BackgroundTrackingController( + calendar: calendarUTC(), + locationRepository: InMemoryLocationSampleRepository(), + authorizationProvider: StubLocationAuthorizationProvider(status: .authorizedAlways), + wakeSource: StubLocationWakeSource(), + jurisdictionResolver: StubJurisdictionResolver(jurisdiction: .california), + notificationScheduler: notificationScheduler, + trackingStateController: TrackingStateController( + store: InMemoryTrackingStateStore( + state: TrackingState( + authorizationStatus: .authorizedAlways, + lastRecordedSampleAt: staleDate, + isMonitoringActive: true, + ), + ), + ), + now: { nowDate }, + ) + + await controller.refreshMonitoring() + let state = await controller.trackingState() + let scheduledRequests = await notificationScheduler.scheduledRequests + + #expect(await notificationScheduler.cancelledIDs.isEmpty) + #expect(scheduledRequests.count == 3) + #expect(state.pendingGapNotificationDates.count == 3) +} + +@Test +func backgroundTrackingControllerStopsMonitoringWhenAuthorizationIsLost() async throws { + let wakeSource = StubLocationWakeSource() + let notificationScheduler = StubTrackingNotificationScheduler() + let controller = try BackgroundTrackingController( + calendar: calendarUTC(), + locationRepository: InMemoryLocationSampleRepository(), + authorizationProvider: StubLocationAuthorizationProvider(status: .denied), + wakeSource: wakeSource, + jurisdictionResolver: StubJurisdictionResolver(jurisdiction: .unknown), + notificationScheduler: notificationScheduler, + trackingStateController: TrackingStateController( + store: InMemoryTrackingStateStore( + state: TrackingState( + authorizationStatus: .authorizedAlways, + pendingGapNotificationDates: [makeDate(year: 2026, month: 4, day: 6, hour: 20)], + isMonitoringActive: true, + ), + ), + ), + now: { try! makeDate(year: 2026, month: 4, day: 6, hour: 9) }, + ) + + await controller.handleAuthorizationStatusChange(.denied) + let state = await controller.trackingState() + + #expect(await wakeSource.stoppedMonitoringCount == 1) + #expect(await notificationScheduler.cancelledIDs.count == 1) + #expect(state.authorizationStatus == .denied) + #expect(!state.isMonitoringActive) + #expect(state.pendingGapNotificationDates.isEmpty) +} + +private func calendarUTC() -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + return calendar +} + +private func makeDate( + year: Int, + month: Int, + day: Int, + hour: Int, +) throws -> Date { + let components = DateComponents( + calendar: calendarUTC(), + year: year, + month: month, + day: day, + hour: hour, + ) + + return try #require(components.date) +} + +private func makeManualDataImportController(directory: URL) -> ManualDataImportController { + let manualRepository = FileManualLogEntryRepository( + calendar: calendarUTC(), + fileURL: directory.appending(path: "manual-entries.json"), + ) + let evidenceController = EvidenceController( + attachmentRepository: FileEvidenceAttachmentRepository( + fileURL: directory.appending(path: "evidence-index.json"), + ), + fileStore: FileEvidenceBlobStore( + baseDirectoryURL: directory.appending(path: "evidence-store"), + ), + ) + + return ManualDataImportController( + calendar: calendarUTC(), + manualEntryRepository: manualRepository, + manualEntryController: ManualEntryController( + repository: manualRepository, + evidenceController: evidenceController, + ), + evidenceController: evidenceController, + ) +} + +private actor InMemoryLocationSampleRepository: LocationSampleRepository { + private var samples: [LocationSample] = [] + + func availableYears() async -> [Int] { + let years = Set(samples.map { calendarUTC().component(.year, from: $0.timestamp) }) + return years.sorted() + } + + func samples(in year: Int) async -> [LocationSample] { + samples + .filter { calendarUTC().component(.year, from: $0.timestamp) == year } + .sorted { $0.timestamp < $1.timestamp } + } + + func upsert(_ samples: [LocationSample]) async { + var merged = Dictionary(uniqueKeysWithValues: self.samples.map { ($0.id, $0) }) + for sample in samples { + merged[sample.id] = sample + } + self.samples = merged.values.sorted { $0.timestamp < $1.timestamp } + } + + func removeAll() async { + samples = [] + } +} + +private actor InMemoryTrackingStateStore: TrackingStateStore { + private var state: TrackingState + + init(state: TrackingState) { + self.state = state + } + + func load() async -> TrackingState { + state + } + + func save(_ state: TrackingState) async { + self.state = state + } + + func reset() async { + state = TrackingState(authorizationStatus: .notDetermined) + } +} + +private actor FailingEvidenceFileStore: EvidenceFileStore { + func save(_: Data, for _: EvidenceAttachment) async {} + + func load(for _: EvidenceAttachment) async -> Data? { + nil + } + + func delete(for _: EvidenceAttachment) async {} +} + +private actor StubLocationWakeSource: LocationWakeSource { + private(set) var startedMonitoringCount = 0 + private(set) var refreshedMonitoringCount = 0 + private(set) var stoppedMonitoringCount = 0 + + func startMonitoring(configuration _: TrackingMonitoringConfiguration) async { + startedMonitoringCount += 1 + } + + func refreshRegionMonitoring(configuration _: TrackingMonitoringConfiguration) async { + refreshedMonitoringCount += 1 + } + + func stopMonitoring() async { + stoppedMonitoringCount += 1 + } +} + +private actor StubLocationAuthorizationProvider: LocationAuthorizationProviding { + private var status: TrackingAuthorizationStatus + + init(status: TrackingAuthorizationStatus) { + self.status = status + } + + func currentAuthorizationStatus() async -> TrackingAuthorizationStatus { + status + } + + func requestAlwaysAuthorization() async { + status = .authorizedAlways + } +} + +private struct StubJurisdictionResolver: JurisdictionResolving { + let jurisdiction: TaxJurisdiction + + func jurisdiction(for _: TrackingWakeEvent) async -> TaxJurisdiction { + jurisdiction + } +} + +private actor StubTrackingNotificationScheduler: TrackingNotificationScheduling { + private(set) var scheduledRequests: [TrackingNotificationRequest] = [] + private(set) var cancelledIDs: [String] = [] + + func schedule(_ request: TrackingNotificationRequest) async { + scheduledRequests.append(request) + } + + func cancel(ids: [String]) async { + cancelledIDs.append(contentsOf: ids) + } +} diff --git a/Where/WhereTesting/Sources/WhereTesting.swift b/Where/WhereTesting/Sources/WhereTesting.swift index 0c3c0da..7da9174 100644 --- a/Where/WhereTesting/Sources/WhereTesting.swift +++ b/Where/WhereTesting/Sources/WhereTesting.swift @@ -1,136 +1,140 @@ -import UIKit +import Foundation -public struct ShowError: Error, CustomStringConvertible { - public var description: String +#if canImport(UIKit) + import UIKit - public init(_ description: String) { - self.description = description - } -} - -/// Shows a view controller in the test host application's window for the -/// duration of `perform`. The view controller is added as a child of -/// the host's root view controller and its view is placed in the window, -/// triggering the full UIKit appearance lifecycle (`viewIsAppearing`, etc.). -/// -/// After `perform` returns (or throws), the view controller is removed -/// from the hierarchy automatically. -@MainActor -public func show( - _ viewController: ViewController, - loadAndPlaceView: Bool = true, - perform test: (ViewController) throws -> Void, -) throws { - let window = UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .flatMap(\.windows) - .first { $0.isKeyWindow } - ?? UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .flatMap(\.windows) - .first - - guard let rootVC = window?.rootViewController else { - throw ShowError("No root view controller in test host.") + public struct ShowError: Error, CustomStringConvertible { + public var description: String + + public init(_ description: String) { + self.description = description + } } - rootVC.view.window?.layer.speed = 100 - rootVC.addChild(viewController) - viewController.didMove(toParent: rootVC) + /// Shows a view controller in the test host application's window for the + /// duration of `perform`. The view controller is added as a child of + /// the host's root view controller and its view is placed in the window, + /// triggering the full UIKit appearance lifecycle (`viewIsAppearing`, etc.). + /// + /// After `perform` returns (or throws), the view controller is removed + /// from the hierarchy automatically. + @MainActor + public func show( + _ viewController: ViewController, + loadAndPlaceView: Bool = true, + perform test: (ViewController) throws -> Void, + ) throws { + let window = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first { $0.isKeyWindow } + ?? UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first + + guard let rootVC = window?.rootViewController else { + throw ShowError("No root view controller in test host.") + } - if loadAndPlaceView { - viewController.view.frame = rootVC.view.bounds - rootVC.view.addSubview(viewController.view) - viewController.view.layoutIfNeeded() - } + rootVC.view.window?.layer.speed = 100 + rootVC.addChild(viewController) + viewController.didMove(toParent: rootVC) - defer { if loadAndPlaceView { - viewController.view.removeFromSuperview() + viewController.view.frame = rootVC.view.bounds + rootVC.view.addSubview(viewController.view) + viewController.view.layoutIfNeeded() + } + + defer { + if loadAndPlaceView { + viewController.view.removeFromSuperview() + } + viewController.willMove(toParent: nil) + viewController.removeFromParent() + rootVC.view.window?.layer.speed = 1 } - viewController.willMove(toParent: nil) - viewController.removeFromParent() - rootVC.view.window?.layer.speed = 1 - } - try autoreleasepool { - try test(viewController) + try autoreleasepool { + try test(viewController) + } } -} -// MARK: - Run Loop Helpers + // MARK: - Run Loop Helpers + + @MainActor + public func waitFor(timeout: TimeInterval = 10.0, predicate: () -> Bool) throws { + let runloop = RunLoop.main + let deadline = Date(timeIntervalSinceNow: timeout) -@MainActor -public func waitFor(timeout: TimeInterval = 10.0, predicate: () -> Bool) throws { - let runloop = RunLoop.main - let deadline = Date(timeIntervalSinceNow: timeout) + while Date() < deadline { + if predicate() { + return + } - while Date() < deadline { - if predicate() { - return + runloop.run(mode: .default, before: Date(timeIntervalSinceNow: 0.001)) } - runloop.run(mode: .default, before: Date(timeIntervalSinceNow: 0.001)) + throw WaitError("waitFor timed out waiting for a check to pass.") } - throw WaitError("waitFor timed out waiting for a check to pass.") -} + @MainActor + public func waitFor(timeout: TimeInterval = 10.0, block: (() -> Void) -> Void) throws { + var isDone = false -@MainActor -public func waitFor(timeout: TimeInterval = 10.0, block: (() -> Void) -> Void) throws { - var isDone = false + try waitFor(timeout: timeout, predicate: { + block { isDone = true } + return isDone + }) + } - try waitFor(timeout: timeout, predicate: { - block { isDone = true } - return isDone - }) -} + @MainActor + public func waitForOneRunloop() { + let runloop = RunLoop.main + runloop.run(mode: .default, before: Date(timeIntervalSinceNow: 0.001)) + } -@MainActor -public func waitForOneRunloop() { - let runloop = RunLoop.main - runloop.run(mode: .default, before: Date(timeIntervalSinceNow: 0.001)) -} + @MainActor + public func determineAverage(for seconds: TimeInterval, using block: () -> Void) { + let start = Date() -@MainActor -public func determineAverage(for seconds: TimeInterval, using block: () -> Void) { - let start = Date() + var iterations = 0 + var lastUpdateDate = Date() - var iterations = 0 - var lastUpdateDate = Date() + repeat { + block() - repeat { - block() + iterations += 1 - iterations += 1 + if Date().timeIntervalSince(lastUpdateDate) >= 1 { + lastUpdateDate = Date() + print("Continuing Test: \(iterations) Iterations...") + } - if Date().timeIntervalSince(lastUpdateDate) >= 1 { - lastUpdateDate = Date() - print("Continuing Test: \(iterations) Iterations...") - } + } while Date() < start + seconds - } while Date() < start + seconds + let end = Date() - let end = Date() + let duration = end.timeIntervalSince(start) + let average = duration / TimeInterval(iterations) - let duration = end.timeIntervalSince(start) - let average = duration / TimeInterval(iterations) - - print("Iterations: \(iterations), Average Time: \(average)") -} + print("Iterations: \(iterations), Average Time: \(average)") + } -public struct WaitError: Error, CustomStringConvertible { - public var description: String + public struct WaitError: Error, CustomStringConvertible { + public var description: String - public init(_ description: String) { - self.description = description + public init(_ description: String) { + self.description = description + } } -} -// MARK: - UIView Helpers + // MARK: - UIView Helpers -extension UIView { - public var recursiveDescription: String { - value(forKey: "recursiveDescription") as! String + extension UIView { + public var recursiveDescription: String { + value(forKey: "recursiveDescription") as! String + } } -} +#endif diff --git a/Where/WhereUI/Sources/Dashboard/DashboardView.swift b/Where/WhereUI/Sources/Dashboard/DashboardView.swift new file mode 100644 index 0000000..f2aa2f1 --- /dev/null +++ b/Where/WhereUI/Sources/Dashboard/DashboardView.swift @@ -0,0 +1,84 @@ +import SwiftUI +import WhereCore + +struct DashboardView: View { + let viewModel: RootViewModel + + var body: some View { + NavigationStack { + Group { + if let snapshot = viewModel.snapshot { + List { + Section("Year to Date") { + ForEach(snapshot.primarySummaries) { summary in + SummaryRow(summary: summary) + } + } + + if !snapshot.secondarySummaries.isEmpty { + Section("Needs Review") { + ForEach(snapshot.secondarySummaries) { summary in + SummaryRow(summary: summary) + } + } + } + + Section("Tracking") { + LabeledContent("Status", value: snapshot.trackingStatus.title) + } + + Section("Recent Days") { + ForEach(snapshot.recentDays) { day in + VStack(alignment: .leading, spacing: 6) { + Text(day.dateLabel) + .font(.headline) + + Text(day.jurisdictions.map(\.displayName).joined(separator: ", ")) + .foregroundStyle(.secondary) + + if let note = day.note { + Text(note) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + } + } + .accessibilityIdentifier("where_dashboard") + } else if viewModel.isLoading { + ProgressView("Loading tracking data...") + .accessibilityIdentifier("where_loading") + } else { + ContentUnavailableView( + "No Tracking Data", + systemImage: "location.slash", + description: Text("Open the app regularly while background tracking is being set up."), + ) + } + } + .navigationTitle("Where") + } + } +} + +private struct SummaryRow: View { + let summary: YearProgressSnapshot.JurisdictionSummary + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(summary.jurisdiction.displayName) + Text(summary.jurisdiction.abbreviation) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(summary.totalDays, format: .number) + .font(.title3.monospacedDigit()) + } + } +} diff --git a/Where/WhereUI/Sources/History/HistoryView.swift b/Where/WhereUI/Sources/History/HistoryView.swift new file mode 100644 index 0000000..0e16760 --- /dev/null +++ b/Where/WhereUI/Sources/History/HistoryView.swift @@ -0,0 +1,45 @@ +import SwiftUI + +struct HistoryView: View { + let viewModel: RootViewModel + + var body: some View { + NavigationStack { + List { + Section("Available Years") { + ForEach(viewModel.availableYears, id: \.self) { year in + Button { + Task { + await viewModel.selectYear(year) + } + } label: { + HStack { + Text(String(year)) + Spacer() + if year == viewModel.selectedYear { + Image(systemName: "checkmark") + .foregroundStyle(.tint) + } + } + } + } + } + + if let snapshot = viewModel.snapshot { + Section("Recent History") { + ForEach(snapshot.recentDays) { day in + VStack(alignment: .leading, spacing: 4) { + Text(day.dateLabel) + Text(day.jurisdictions.map(\.displayName).joined(separator: ", ")) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + } + } + .navigationTitle("History") + .accessibilityIdentifier("where_history") + } + } +} diff --git a/Where/WhereUI/Sources/ManualEntry/ActivityView.swift b/Where/WhereUI/Sources/ManualEntry/ActivityView.swift new file mode 100644 index 0000000..78f08ac --- /dev/null +++ b/Where/WhereUI/Sources/ManualEntry/ActivityView.swift @@ -0,0 +1,15 @@ +import SwiftUI + +#if canImport(UIKit) + import UIKit + + struct ActivityView: UIViewControllerRepresentable { + let activityItems: [Any] + + func makeUIViewController(context _: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + } + + func updateUIViewController(_: UIActivityViewController, context _: Context) {} + } +#endif diff --git a/Where/WhereUI/Sources/ManualEntry/EvidenceAttachmentPreviewView.swift b/Where/WhereUI/Sources/ManualEntry/EvidenceAttachmentPreviewView.swift new file mode 100644 index 0000000..0122657 --- /dev/null +++ b/Where/WhereUI/Sources/ManualEntry/EvidenceAttachmentPreviewView.swift @@ -0,0 +1,113 @@ +import SwiftUI +import WhereCore + +#if canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif + +struct EvidenceAttachmentPreviewView: View { + let attachment: EvidenceAttachment + let stagedURL: URL? + + var body: some View { + HStack(spacing: 12) { + thumbnail + + VStack(alignment: .leading, spacing: 2) { + Text(attachment.originalFilename) + .lineLimit(1) + Text(metadataText) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + } + + @ViewBuilder + private var thumbnail: some View { + if let previewImage { + previewImage + .resizable() + .scaledToFill() + .frame(width: 56, height: 56) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } else { + RoundedRectangle(cornerRadius: 10) + .fill(.quaternary) + .frame(width: 56, height: 56) + .overlay { + VStack(spacing: 4) { + Image(systemName: iconName) + .font(.title3) + Text(fileTypeLabel) + .font(.caption2.weight(.semibold)) + } + .foregroundStyle(.secondary) + } + } + } + + private var previewImage: Image? { + guard attachment.contentType.hasPrefix("image/"), let stagedURL else { + return nil + } + + #if canImport(UIKit) + guard let image = UIImage(contentsOfFile: stagedURL.path) else { + return nil + } + return Image(uiImage: image) + #elseif canImport(AppKit) + guard let image = NSImage(contentsOf: stagedURL) else { + return nil + } + return Image(nsImage: image) + #else + return nil + #endif + } + + private var metadataText: String { + "\(attachment.contentType) • \(attachment.byteCount) bytes" + } + + private var fileTypeLabel: String { + if attachment.contentType == "application/pdf" { + return "PDF" + } + + if attachment.contentType.hasPrefix("text/") { + return "TXT" + } + + if attachment.contentType.hasPrefix("image/") { + return "IMG" + } + + let fileExtension = URL(fileURLWithPath: attachment.originalFilename).pathExtension + if !fileExtension.isEmpty { + return fileExtension.uppercased() + } + + return "FILE" + } + + private var iconName: String { + if attachment.contentType == "application/pdf" { + return "doc.richtext" + } + + if attachment.contentType.hasPrefix("text/") { + return "doc.plaintext" + } + + if attachment.contentType.hasPrefix("image/") { + return "photo" + } + + return "doc" + } +} diff --git a/Where/WhereUI/Sources/ManualEntry/ManualBackfillView.swift b/Where/WhereUI/Sources/ManualEntry/ManualBackfillView.swift new file mode 100644 index 0000000..de441ed --- /dev/null +++ b/Where/WhereUI/Sources/ManualEntry/ManualBackfillView.swift @@ -0,0 +1,153 @@ +import SwiftUI +import WhereCore + +struct ManualBackfillView: View { + @Environment(\.dismiss) private var dismiss + + private let onPreview: (ManualImportBackfillRequest) -> Void + + @State private var startDate: Date + @State private var endDate: Date + @State private var jurisdiction: TaxJurisdiction + @State private var note: String + @State private var kind: ManualLogEntry.Kind + @State private var evidenceFiles: [URL] + @State private var isPickingEvidence = false + + init( + initialDate: Date, + onPreview: @escaping (ManualImportBackfillRequest) -> Void, + ) { + self.onPreview = onPreview + _startDate = State(initialValue: initialDate) + _endDate = State(initialValue: initialDate) + _jurisdiction = State(initialValue: .california) + _note = State(initialValue: "") + _kind = State(initialValue: .supplemental) + _evidenceFiles = State(initialValue: []) + } + + var body: some View { + NavigationStack { + Form { + Section("Dates") { + DatePicker("Start", selection: $startDate, displayedComponents: .date) + DatePicker("End", selection: $endDate, displayedComponents: .date) + } + + Section("Details") { + Picker("Kind", selection: $kind) { + Text("Supplemental").tag(ManualLogEntry.Kind.supplemental) + Text("Correction").tag(ManualLogEntry.Kind.correction) + } + .pickerStyle(.segmented) + + Picker("Jurisdiction", selection: $jurisdiction) { + ForEach(jurisdictionOptions, id: \.self) { option in + Text(option.displayName) + .tag(option) + } + } + + TextField("Note", text: $note, axis: .vertical) + .lineLimit(3 ... 6) + } + + Section("Shared Evidence") { + if evidenceFiles.isEmpty { + Text("Optional files that should be attached to every created day.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + ForEach(evidenceFiles, id: \.self) { fileURL in + Text(fileURL.lastPathComponent) + .font(.footnote) + } + .onDelete { offsets in + evidenceFiles.remove(atOffsets: offsets) + } + } + + Button("Add Evidence Files") { + isPickingEvidence = true + } + } + } + .navigationTitle("Backfill Days") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("Preview Import") { + onPreview( + ManualImportBackfillRequest( + startDate: startDate, + endDate: endDate, + jurisdiction: jurisdiction, + note: note, + kind: kind, + evidenceFiles: evidenceFiles, + ), + ) + dismiss() + } + } + } + } + .fileImporter( + isPresented: $isPickingEvidence, + allowedContentTypes: [.content, .data, .image, .pdf], + allowsMultipleSelection: true, + ) { result in + guard case let .success(urls) = result else { + return + } + + evidenceFiles = urls.compactMap(stageEvidenceFile).sorted { + $0.lastPathComponent.localizedCaseInsensitiveCompare($1.lastPathComponent) == .orderedAscending + } + } + } + + private var jurisdictionOptions: [TaxJurisdiction] { + let preferredStates: [TaxJurisdiction] = [.california, .newYork, .unknown] + let remainingStates = USState.allCases + .map(TaxJurisdiction.state) + .filter { !preferredStates.contains($0) } + .sorted { $0.displayName < $1.displayName } + + return preferredStates + remainingStates + } + + private func stageEvidenceFile(_ fileURL: URL) -> URL? { + let didAccess = fileURL.startAccessingSecurityScopedResource() + defer { + if didAccess { + fileURL.stopAccessingSecurityScopedResource() + } + } + + guard let data = try? Data(contentsOf: fileURL) else { + return nil + } + + let directory = FileManager.default.temporaryDirectory.appending(path: "WhereBackfillEvidence") + let stagedURL = directory.appending(path: "\(UUID().uuidString)-\(fileURL.lastPathComponent)") + + do { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: nil, + ) + try data.write(to: stagedURL, options: .atomic) + return stagedURL + } catch { + return nil + } + } +} diff --git a/Where/WhereUI/Sources/ManualEntry/ManualEntryDayRecord.swift b/Where/WhereUI/Sources/ManualEntry/ManualEntryDayRecord.swift new file mode 100644 index 0000000..805d2a3 --- /dev/null +++ b/Where/WhereUI/Sources/ManualEntry/ManualEntryDayRecord.swift @@ -0,0 +1,19 @@ +import Foundation +import WhereCore + +public struct ManualEntryDayRecord: Equatable, Sendable, Identifiable { + public let record: ManualEntryRecord + public let changesDayOutcome: Bool + + public init( + record: ManualEntryRecord, + changesDayOutcome: Bool, + ) { + self.record = record + self.changesDayOutcome = changesDayOutcome + } + + public var id: UUID { + record.id + } +} diff --git a/Where/WhereUI/Sources/ManualEntry/ManualEntryDaySection.swift b/Where/WhereUI/Sources/ManualEntry/ManualEntryDaySection.swift new file mode 100644 index 0000000..1e68d24 --- /dev/null +++ b/Where/WhereUI/Sources/ManualEntry/ManualEntryDaySection.swift @@ -0,0 +1,31 @@ +import Foundation +import WhereCore + +public struct ManualEntryDaySection: Equatable, Sendable, Identifiable { + public let date: Date + public let trackedJurisdictions: [TaxJurisdiction] + public let finalJurisdictions: [TaxJurisdiction] + public let note: String? + public let changesTrackedOutcome: Bool + public let entries: [ManualEntryDayRecord] + + public init( + date: Date, + trackedJurisdictions: [TaxJurisdiction], + finalJurisdictions: [TaxJurisdiction], + note: String?, + changesTrackedOutcome: Bool, + entries: [ManualEntryDayRecord], + ) { + self.date = date + self.trackedJurisdictions = trackedJurisdictions + self.finalJurisdictions = finalJurisdictions + self.note = note + self.changesTrackedOutcome = changesTrackedOutcome + self.entries = entries + } + + public var id: Date { + date + } +} diff --git a/Where/WhereUI/Sources/ManualEntry/ManualEntryEditorView.swift b/Where/WhereUI/Sources/ManualEntry/ManualEntryEditorView.swift new file mode 100644 index 0000000..45c49e6 --- /dev/null +++ b/Where/WhereUI/Sources/ManualEntry/ManualEntryEditorView.swift @@ -0,0 +1,89 @@ +import SwiftUI +import WhereCore + +struct ManualEntryEditorView: View { + @Environment(\.dismiss) private var dismiss + + private let initialDraft: ManualEntryDraft + private let onSave: (ManualEntryDraft) -> Void + + @State private var timestamp: Date + @State private var jurisdiction: TaxJurisdiction + @State private var note: String + @State private var kind: ManualLogEntry.Kind + + init( + draft: ManualEntryDraft, + onSave: @escaping (ManualEntryDraft) -> Void, + ) { + initialDraft = draft + self.onSave = onSave + _timestamp = State(initialValue: draft.timestamp) + _jurisdiction = State(initialValue: draft.jurisdiction) + _note = State(initialValue: draft.note) + _kind = State(initialValue: draft.kind) + } + + var body: some View { + NavigationStack { + Form { + Section("Entry") { + DatePicker( + "Date", + selection: $timestamp, + displayedComponents: [.date, .hourAndMinute], + ) + + Picker("Kind", selection: $kind) { + Text("Supplemental").tag(ManualLogEntry.Kind.supplemental) + Text("Correction").tag(ManualLogEntry.Kind.correction) + } + .pickerStyle(.segmented) + + Picker("Jurisdiction", selection: $jurisdiction) { + ForEach(jurisdictionOptions, id: \.self) { option in + Text(option.displayName) + .tag(option) + } + } + + TextField("Note", text: $note, axis: .vertical) + .lineLimit(3 ... 6) + } + } + .navigationTitle(initialDraft.id == nil ? "New Entry" : "Edit Entry") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + onSave( + ManualEntryDraft( + id: initialDraft.id, + timestamp: timestamp, + jurisdiction: jurisdiction, + note: note, + kind: kind, + ), + ) + dismiss() + } + } + } + } + } + + private var jurisdictionOptions: [TaxJurisdiction] { + let preferredStates: [TaxJurisdiction] = [.california, .newYork, .unknown] + let remainingStates = USState.allCases + .map(TaxJurisdiction.state) + .filter { !preferredStates.contains($0) } + .sorted { $0.displayName < $1.displayName } + + return preferredStates + remainingStates + } +} diff --git a/Where/WhereUI/Sources/ManualEntry/ManualEntryView.swift b/Where/WhereUI/Sources/ManualEntry/ManualEntryView.swift new file mode 100644 index 0000000..c34f7a9 --- /dev/null +++ b/Where/WhereUI/Sources/ManualEntry/ManualEntryView.swift @@ -0,0 +1,561 @@ +import QuickLook +import SwiftUI +import UniformTypeIdentifiers +import WhereCore + +struct ManualEntryView: View { + let rootViewModel: RootViewModel + @State private var viewModel: ManualEntryViewModel + @State private var draft = ManualEntryDraft( + timestamp: Date(), + jurisdiction: .california, + kind: .supplemental, + ) + @State private var isPresentingEditor = false + @State private var importingEvidenceEntryID: UUID? + @State private var isPresentingBackfill = false + @State private var isImportingPackage = false + @State private var isExportingPlainText = false + @State private var isExportingPDF = false + + init( + rootViewModel: RootViewModel, + viewModel: ManualEntryViewModel, + ) { + self.rootViewModel = rootViewModel + _viewModel = State(initialValue: viewModel) + } + + var body: some View { + @Bindable var viewModel = viewModel + + NavigationStack { + List { + Section("Selected Year") { + Text(String(rootViewModel.selectedYear)) + } + + Section("Data") { + Text("Locations and manual entries are stored on device. If you saw unfamiliar content before, it may have come from earlier seeded sample data or persisted local data from a previous run.") + .font(.footnote) + .foregroundStyle(.secondary) + + Button("Reset Stored Data", role: .destructive) { + viewModel.requestResetConfirmation() + } + } + + Section("Import") { + Text("Backfill prior travel with day-level entries, or import a package with a manifest and evidence files.") + .font(.footnote) + .foregroundStyle(.secondary) + + Button("Backfill Days") { + isPresentingBackfill = true + } + + Button("Import Package") { + isImportingPackage = true + } + } + + Section("Export") { + ReportActionRow( + title: "Plain Text", + statusText: viewModel.reportStatusText( + for: rootViewModel.selectedYear, + format: .plainText, + ), + onExport: { + Task { + await viewModel.preparePlainTextExport(for: rootViewModel.selectedYear) + if viewModel.plainTextExportDocument != nil { + isExportingPlainText = true + } + } + }, + onShare: { + Task { + await viewModel.preparePlainTextShare(for: rootViewModel.selectedYear) + } + }, + ) + + ReportActionRow( + title: "PDF", + statusText: viewModel.reportStatusText( + for: rootViewModel.selectedYear, + format: .pdf, + ), + onExport: { + Task { + await viewModel.preparePDFExport(for: rootViewModel.selectedYear) + if viewModel.pdfExportDocument != nil { + isExportingPDF = true + } + } + }, + onShare: { + Task { + await viewModel.preparePDFShare(for: rootViewModel.selectedYear) + } + }, + ) + } + + if viewModel.isLoading, viewModel.daySections.isEmpty { + Section("Manual Entries") { + ProgressView("Loading manual entries...") + } + } else if viewModel.daySections.isEmpty { + Section("Manual Entries") { + ContentUnavailableView( + "No Manual Entries", + systemImage: "square.and.pencil", + description: Text("Add a correction or supplemental note for \(rootViewModel.selectedYear)."), + ) + } + } else { + ForEach(viewModel.daySections) { daySection in + Section { + ForEach(daySection.entries) { dayEntry in + entryRow(for: dayEntry) + } + } header: { + dayHeader(for: daySection) + } + } + } + } + .navigationTitle("Manual") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + draft = ManualEntryDraft( + timestamp: Date(), + jurisdiction: .california, + kind: .supplemental, + ) + isPresentingEditor = true + } label: { + Label("Add Entry", systemImage: "plus") + } + } + } + .accessibilityIdentifier("where_manual") + } + .task(id: rootViewModel.selectedYear) { + await viewModel.load(for: rootViewModel.selectedYear) + } + .sheet(isPresented: $isPresentingEditor) { + ManualEntryEditorView(draft: draft) { draft in + Task { + await viewModel.save( + draft, + year: rootViewModel.selectedYear, + ) + await rootViewModel.selectYear(rootViewModel.selectedYear) + } + } + } + .sheet(isPresented: $isPresentingBackfill) { + ManualBackfillView(initialDate: Date()) { request in + Task { + await viewModel.previewBackfill(request) + } + } + } + .fileImporter( + isPresented: Binding( + get: { importingEvidenceEntryID != nil }, + set: { isPresented in + if !isPresented { + importingEvidenceEntryID = nil + } + }, + ), + allowedContentTypes: [.content, .data, .image, .pdf], + allowsMultipleSelection: false, + ) { result in + guard + case let .success(urls) = result, + let fileURL = urls.first, + let entryID = importingEvidenceEntryID + else { + importingEvidenceEntryID = nil + return + } + + Task { + let didAccess = fileURL.startAccessingSecurityScopedResource() + defer { + if didAccess { + fileURL.stopAccessingSecurityScopedResource() + } + } + + await viewModel.importEvidence( + manualEntryID: entryID, + fileURL: fileURL, + year: rootViewModel.selectedYear, + ) + } + + importingEvidenceEntryID = nil + } + .fileImporter( + isPresented: $isImportingPackage, + allowedContentTypes: allowedPackageTypes, + allowsMultipleSelection: false, + ) { result in + guard + case let .success(urls) = result, + let directoryURL = urls.first + else { + return + } + + Task { + await viewModel.previewPackage(at: directoryURL) + } + } + .fileExporter( + isPresented: $isExportingPlainText, + document: viewModel.plainTextExportDocument, + contentType: .plainText, + defaultFilename: viewModel.plainTextFilename, + ) { _ in + viewModel.clearPlainTextExport() + } + .fileExporter( + isPresented: $isExportingPDF, + document: viewModel.pdfExportDocument, + contentType: .pdf, + defaultFilename: viewModel.pdfFilename, + ) { _ in + viewModel.clearPDFExport() + } + .alert( + "Manual Entry Error", + isPresented: Binding( + get: { viewModel.errorMessage != nil }, + set: { isPresented in + if !isPresented { + viewModel.clearError() + } + }, + ), + ) { + Button("OK", role: .cancel) { + viewModel.clearError() + } + } message: { + Text(viewModel.errorMessage ?? "") + } + .confirmationDialog( + "Reset Stored Data?", + isPresented: Binding( + get: { viewModel.isShowingResetConfirmation }, + set: { isPresented in + if !isPresented { + viewModel.dismissResetConfirmation() + } + }, + ), + titleVisibility: .visible, + ) { + Button("Reset All Data", role: .destructive) { + Task { + await viewModel.resetAllData(for: rootViewModel.selectedYear) + await rootViewModel.reloadAfterDataReset() + } + } + + Button("Cancel", role: .cancel) { + viewModel.dismissResetConfirmation() + } + } message: { + Text("This removes all stored locations, manual entries, evidence metadata, and export activity from this device.") + } + .sheet( + isPresented: Binding( + get: { viewModel.isShowingImportPreview }, + set: { isPresented in + if !isPresented { + viewModel.dismissImportPreview() + } + }, + ), + ) { + NavigationStack { + List { + if let preview = viewModel.activeImportPreview { + Section("Summary") { + LabeledContent("Source", value: viewModel.importSourceDescription ?? "Import") + LabeledContent("Entries", value: String(preview.entryCount)) + LabeledContent("Evidence Attachments", value: String(preview.evidenceAttachmentCount)) + + if let yearSpan = preview.yearSpan { + LabeledContent("Years", value: yearSpan.lowerBound == yearSpan.upperBound ? String(yearSpan.lowerBound) : "\(yearSpan.lowerBound)-\(yearSpan.upperBound)") + } + + if preview.sharedEvidenceAttachmentCount > 0 { + LabeledContent("Shared Files", value: String(preview.sharedEvidenceAttachmentCount)) + } + } + + if !preview.issues.isEmpty { + Section("Validation") { + ForEach(preview.issues) { issue in + VStack(alignment: .leading, spacing: 4) { + Text(issue.severity.rawValue.capitalized) + .font(.caption.weight(.semibold)) + .foregroundStyle(issue.severity == .error ? .red : .orange) + Text(issue.message) + .font(.footnote) + } + .padding(.vertical, 2) + } + } + } + + if !preview.entries.isEmpty { + Section("Preview") { + ForEach(preview.entries.prefix(20)) { entry in + VStack(alignment: .leading, spacing: 4) { + Text(entry.timestamp, format: .dateTime.month().day().year().hour().minute()) + .font(.headline) + Text(entry.jurisdiction.displayName) + .foregroundStyle(.secondary) + + if !entry.note.isEmpty { + Text(entry.note) + .font(.footnote) + } + + if !entry.evidenceFiles.isEmpty { + Text("\(entry.evidenceFiles.count) evidence file(s)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 2) + } + + if preview.entries.count > 20 { + Text("Showing first 20 of \(preview.entries.count) entries.") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + } + } + .navigationTitle("Import Preview") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + viewModel.dismissImportPreview() + } + } + + ToolbarItem(placement: .confirmationAction) { + Button("Import") { + Task { + await viewModel.confirmImport(for: rootViewModel.selectedYear) + await rootViewModel.selectYear(rootViewModel.selectedYear) + } + } + .disabled(!(viewModel.activeImportPreview?.isValid ?? false)) + } + } + } + } + .quickLookPreview( + Binding( + get: { viewModel.previewURL }, + set: { url in + if url == nil { + viewModel.clearPreview() + } + }, + ), + ) + #if canImport(UIKit) + .sheet( + isPresented: Binding( + get: { viewModel.shareURL != nil }, + set: { isPresented in + if !isPresented { + viewModel.clearShare() + } + }, + ), + ) { + if let shareURL = viewModel.shareURL { + ActivityView(activityItems: [shareURL]) + } + } + #endif + } + + private func dayHeader(for section: ManualEntryDaySection) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(section.date, format: .dateTime.weekday(.abbreviated).month().day().year()) + .font(.headline) + + if section.changesTrackedOutcome { + Text("Outcome Changed") + .font(.caption.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.tint.opacity(0.15), in: Capsule()) + } + } + + Text("Tracked: \(jurisdictionSummary(section.trackedJurisdictions, emptyLabel: "No tracked data"))") + .font(.caption) + .foregroundStyle(.secondary) + + Text("Final: \(jurisdictionSummary(section.finalJurisdictions, emptyLabel: "No final jurisdictions"))") + .font(.caption) + .foregroundStyle(section.changesTrackedOutcome ? Color.accentColor : .secondary) + + if let note = section.note { + Text(note) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .textCase(nil) + } + + private func entryRow(for dayEntry: ManualEntryDayRecord) -> some View { + let record = dayEntry.record + + return VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text(record.entry.timestamp, format: .dateTime.hour().minute()) + .font(.headline) + Text(record.entry.jurisdiction.displayName) + .foregroundStyle(.secondary) + } + + Spacer() + + HStack(spacing: 6) { + Text(record.entry.kind.rawValue.capitalized) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.thinMaterial, in: Capsule()) + + if dayEntry.changesDayOutcome { + Text("Changes Day") + .font(.caption.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.tint.opacity(0.15), in: Capsule()) + } + } + } + + if let note = record.entry.note { + Text(note) + } + + if record.attachments.isEmpty { + Text("No evidence attached") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + DisclosureGroup("Evidence (\(record.attachments.count))") { + ForEach(record.attachments) { attachment in + HStack(alignment: .top) { + EvidenceAttachmentPreviewView( + attachment: attachment, + stagedURL: viewModel.inlineEvidenceURL(for: attachment), + ) + .task(id: attachment.id) { + await viewModel.loadInlineEvidenceIfNeeded(for: attachment) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 8) { + Button("Preview") { + Task { + await viewModel.prepareEvidencePreview(for: attachment) + } + } + + Button("Share") { + Task { + await viewModel.prepareEvidenceShare(for: attachment) + } + } + + Button("Delete", role: .destructive) { + Task { + await viewModel.deleteEvidence( + attachment, + year: rootViewModel.selectedYear, + ) + } + } + } + .font(.caption) + } + .padding(.vertical, 2) + } + } + } + + HStack { + Button("Edit") { + draft = ManualEntryDraft(entry: record.entry) + isPresentingEditor = true + } + + Button("Add Evidence") { + importingEvidenceEntryID = record.id + } + + Spacer() + + Button("Delete", role: .destructive) { + Task { + await viewModel.deleteEntry( + id: record.id, + year: rootViewModel.selectedYear, + ) + await rootViewModel.selectYear(rootViewModel.selectedYear) + } + } + } + .font(.subheadline) + } + .padding(.vertical, 6) + } + + private func jurisdictionSummary( + _ jurisdictions: [TaxJurisdiction], + emptyLabel: String, + ) -> String { + guard !jurisdictions.isEmpty else { + return emptyLabel + } + + return jurisdictions.map(\.displayName).joined(separator: ", ") + } + + private var allowedPackageTypes: [UTType] { + #if os(macOS) + [.directory] + #else + [.folder] + #endif + } +} diff --git a/Where/WhereUI/Sources/ManualEntry/ManualEntryViewModel.swift b/Where/WhereUI/Sources/ManualEntry/ManualEntryViewModel.swift new file mode 100644 index 0000000..7de6aa7 --- /dev/null +++ b/Where/WhereUI/Sources/ManualEntry/ManualEntryViewModel.swift @@ -0,0 +1,415 @@ +import Foundation +import Observation +import WhereCore + +@MainActor +@Observable +public final class ManualEntryViewModel { + public private(set) var records: [ManualEntryRecord] = [] + public private(set) var daySections: [ManualEntryDaySection] = [] + public private(set) var isLoading = false + public private(set) var errorMessage: String? + public private(set) var activeImportPreview: ManualImportPreview? + public private(set) var importSourceDescription: String? + public private(set) var isShowingImportPreview = false + public private(set) var plainTextExportDocument: PlainTextExportDocument? + public private(set) var plainTextFilename = "where-export" + public private(set) var pdfExportDocument: PDFExportDocument? + public private(set) var pdfFilename = "where-export" + public private(set) var previewURL: URL? + public private(set) var shareURL: URL? + public private(set) var isShowingResetConfirmation = false + + private let manager: any ManualEntryManaging + private let importer: any ManualDataImporting + private let exporter: any YearExporting + private let yearDataProvider: any YearDataProviding + private let resetter: any WhereDataResetting + private let ledgerBuilder: YearLedgerBuilder + private let reportActivityFormatter: DateFormatter + private var stagedEvidenceURLs: [UUID: URL] = [:] + private var reportActivities: [Int: [ManualReportFormat: ReportActivityState]] = [:] + private var pendingPackageDirectoryURL: URL? + + public init( + manager: any ManualEntryManaging, + importer: any ManualDataImporting, + exporter: any YearExporting, + yearDataProvider: any YearDataProviding, + resetter: any WhereDataResetting, + calendar: Calendar = .current, + ) { + self.manager = manager + self.importer = importer + self.exporter = exporter + self.yearDataProvider = yearDataProvider + self.resetter = resetter + ledgerBuilder = YearLedgerBuilder(calendar: calendar) + + reportActivityFormatter = DateFormatter() + reportActivityFormatter.calendar = calendar + reportActivityFormatter.locale = Locale(identifier: "en_US_POSIX") + reportActivityFormatter.dateStyle = .medium + reportActivityFormatter.timeStyle = .short + } + + public func load(for year: Int) async { + isLoading = true + await refreshRecords(for: year) + isLoading = false + } + + public func save(_ draft: ManualEntryDraft, year: Int) async { + isLoading = true + _ = await manager.save(draft) + await refreshRecords(for: year) + isLoading = false + } + + public func deleteEntry(id: UUID, year: Int) async { + isLoading = true + await manager.deleteEntry(id: id) + await refreshRecords(for: year) + isLoading = false + } + + public func importEvidence( + manualEntryID: UUID, + fileURL: URL, + year: Int, + ) async { + isLoading = true + + if await manager.importEvidence(manualEntryID: manualEntryID, fileURL: fileURL) == nil { + errorMessage = "Could not import the selected evidence file." + } + + await refreshRecords(for: year) + isLoading = false + } + + public func deleteEvidence( + _ attachment: EvidenceAttachment, + year: Int, + ) async { + isLoading = true + await manager.deleteEvidence(attachment) + stagedEvidenceURLs[attachment.id] = nil + await refreshRecords(for: year) + isLoading = false + } + + public func prepareEvidencePreview(for attachment: EvidenceAttachment) async { + isLoading = true + defer { isLoading = false } + + guard let url = await stagedEvidenceURL(for: attachment) else { + errorMessage = "Could not open the selected evidence file." + return + } + + previewURL = url + } + + public func prepareEvidenceShare(for attachment: EvidenceAttachment) async { + isLoading = true + defer { isLoading = false } + + guard let url = await stagedEvidenceURL(for: attachment) else { + errorMessage = "Could not prepare the selected evidence file for sharing." + return + } + + shareURL = url + } + + public func loadInlineEvidenceIfNeeded(for attachment: EvidenceAttachment) async { + guard attachment.contentType.hasPrefix("image/") else { + return + } + + _ = await stagedEvidenceURL(for: attachment) + } + + public func inlineEvidenceURL(for attachment: EvidenceAttachment) -> URL? { + stagedEvidenceURLs[attachment.id] + } + + public func reportActivity(for year: Int, format: ManualReportFormat) -> ReportActivityState? { + reportActivities[year]?[format] + } + + public func reportStatusText(for year: Int, format: ManualReportFormat) -> String? { + guard let activity = reportActivity(for: year, format: format) else { + return nil + } + + return "Last \(activity.triggerDescription) \(reportActivityFormatter.string(from: activity.generatedAt))" + } + + public func preparePlainTextExport(for year: Int) async { + let bundle = await exporter.exportBundle(for: year) + plainTextExportDocument = PlainTextExportDocument(text: bundle.plaintext) + plainTextFilename = bundle.plaintextFilename.replacingOccurrences(of: ".txt", with: "") + updateReportActivity( + for: year, + format: .plainText, + generatedAt: bundle.generatedAt, + triggerDescription: "prepared for save on", + ) + } + + public func preparePDFExport(for year: Int) async { + let bundle = await exporter.exportBundle(for: year) + pdfExportDocument = PDFExportDocument(data: bundle.pdfData) + pdfFilename = bundle.pdfFilename.replacingOccurrences(of: ".pdf", with: "") + updateReportActivity( + for: year, + format: .pdf, + generatedAt: bundle.generatedAt, + triggerDescription: "prepared for save on", + ) + } + + public func preparePlainTextShare(for year: Int) async { + isLoading = true + defer { isLoading = false } + + let bundle = await exporter.exportBundle(for: year) + if stageShareFile( + filename: bundle.plaintextFilename, + data: Data(bundle.plaintext.utf8), + failureMessage: "Could not prepare the text report for sharing.", + ) { + updateReportActivity( + for: year, + format: .plainText, + generatedAt: bundle.generatedAt, + triggerDescription: "prepared for sharing on", + ) + } + } + + public func preparePDFShare(for year: Int) async { + isLoading = true + defer { isLoading = false } + + let bundle = await exporter.exportBundle(for: year) + if stageShareFile( + filename: bundle.pdfFilename, + data: bundle.pdfData, + failureMessage: "Could not prepare the PDF report for sharing.", + ) { + updateReportActivity( + for: year, + format: .pdf, + generatedAt: bundle.generatedAt, + triggerDescription: "prepared for sharing on", + ) + } + } + + public func clearPlainTextExport() { + plainTextExportDocument = nil + } + + public func clearPDFExport() { + pdfExportDocument = nil + } + + public func clearPreview() { + previewURL = nil + } + + public func clearShare() { + shareURL = nil + } + + public func clearError() { + errorMessage = nil + } + + public func previewBackfill(_ request: ManualImportBackfillRequest) async { + isLoading = true + let preview = await importer.previewBackfill(request) + activeImportPreview = preview + importSourceDescription = "Backfill" + pendingPackageDirectoryURL = nil + isShowingImportPreview = true + isLoading = false + } + + public func previewPackage(at directoryURL: URL) async { + isLoading = true + let preview = await importer.previewPackage(at: directoryURL) + activeImportPreview = preview + importSourceDescription = directoryURL.lastPathComponent + pendingPackageDirectoryURL = directoryURL + isShowingImportPreview = true + isLoading = false + } + + public func dismissImportPreview() { + isShowingImportPreview = false + activeImportPreview = nil + importSourceDescription = nil + pendingPackageDirectoryURL = nil + } + + public func confirmImport(for year: Int) async { + guard let preview = activeImportPreview, preview.isValid else { + errorMessage = "The selected import data needs to be fixed before importing." + return + } + + isLoading = true + let importedRecords: [ManualEntryRecord] = if let pendingPackageDirectoryURL { + await importer.importPackage(at: pendingPackageDirectoryURL) + } else { + await importer.importEntries(preview.entries) + } + if importedRecords.isEmpty { + errorMessage = "The import could not be completed." + } else { + dismissImportPreview() + } + await refreshRecords(for: year) + isLoading = false + } + + public func requestResetConfirmation() { + isShowingResetConfirmation = true + } + + public func dismissResetConfirmation() { + isShowingResetConfirmation = false + } + + public func resetAllData(for year: Int) async { + isLoading = true + defer { + isLoading = false + isShowingResetConfirmation = false + } + + await resetter.resetAllData() + previewURL = nil + shareURL = nil + plainTextExportDocument = nil + pdfExportDocument = nil + reportActivities.removeAll() + stagedEvidenceURLs.removeAll() + await refreshRecords(for: year) + } + + private func refreshRecords(for year: Int) async { + async let recordsTask = manager.records(in: year) + async let bundleTask = yearDataProvider.bundle(for: year) + let records = await recordsTask + let bundle = await bundleTask + self.records = records + daySections = makeDaySections(records: records, bundle: bundle) + + let validAttachmentIDs = Set(records.flatMap(\.attachments).map(\.id)) + stagedEvidenceURLs = stagedEvidenceURLs.filter { validAttachmentIDs.contains($0.key) } + } + + private func stagedEvidenceURL(for attachment: EvidenceAttachment) async -> URL? { + if let cachedURL = stagedEvidenceURLs[attachment.id] { + return cachedURL + } + + guard let url = await manager.evidenceFileURL(for: attachment) else { + return nil + } + + stagedEvidenceURLs[attachment.id] = url + return url + } + + private func stageShareFile( + filename: String, + data: Data, + failureMessage: String, + ) -> Bool { + let directory = FileManager.default.temporaryDirectory.appending(path: "WhereShares") + + do { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: nil, + ) + let fileURL = directory.appending(path: filename) + try data.write(to: fileURL, options: .atomic) + shareURL = fileURL + return true + } catch { + errorMessage = failureMessage + return false + } + } + + private func makeDaySections( + records: [ManualEntryRecord], + bundle: YearDataBundle, + ) -> [ManualEntryDaySection] { + let recordsByID = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) }) + let ledgers = ledgerBuilder.makeLedgers( + year: bundle.year, + samples: bundle.locationSamples, + manualEntries: bundle.manualEntries, + ) + + return ledgers + .filter { !$0.manualEntries.isEmpty } + .sorted { $0.date > $1.date } + .map { ledger in + ManualEntryDaySection( + date: ledger.date, + trackedJurisdictions: ledger.trackedJurisdictions, + finalJurisdictions: ledger.finalJurisdictions, + note: ledger.note, + changesTrackedOutcome: Set(ledger.trackedJurisdictions) != Set(ledger.finalJurisdictions), + entries: ledger.manualEntries + .sorted { $0.timestamp > $1.timestamp } + .map { entry in + ManualEntryDayRecord( + record: recordsByID[entry.id] ?? ManualEntryRecord(entry: entry, attachments: []), + changesDayOutcome: changesDayOutcome(for: entry, in: ledger), + ) + }, + ) + } + } + + private func changesDayOutcome( + for entry: ManualLogEntry, + in ledger: DailyStateLedger, + ) -> Bool { + let withEntry = ledgerBuilder.finalJurisdictions( + trackedJurisdictions: ledger.trackedJurisdictions, + manualEntries: ledger.manualEntries, + ) + let withoutEntry = ledgerBuilder.finalJurisdictions( + trackedJurisdictions: ledger.trackedJurisdictions, + manualEntries: ledger.manualEntries.filter { $0.id != entry.id }, + ) + + return withEntry != withoutEntry + } + + private func updateReportActivity( + for year: Int, + format: ManualReportFormat, + generatedAt: Date, + triggerDescription: String, + ) { + var yearActivities = reportActivities[year, default: [:]] + yearActivities[format] = ReportActivityState( + generatedAt: generatedAt, + triggerDescription: triggerDescription, + ) + reportActivities[year] = yearActivities + } +} diff --git a/Where/WhereUI/Sources/ManualEntry/ManualReportFormat.swift b/Where/WhereUI/Sources/ManualEntry/ManualReportFormat.swift new file mode 100644 index 0000000..943e3bd --- /dev/null +++ b/Where/WhereUI/Sources/ManualEntry/ManualReportFormat.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum ManualReportFormat: String, Equatable, Sendable { + case plainText + case pdf +} diff --git a/Where/WhereUI/Sources/ManualEntry/PDFExportDocument.swift b/Where/WhereUI/Sources/ManualEntry/PDFExportDocument.swift new file mode 100644 index 0000000..99f35c6 --- /dev/null +++ b/Where/WhereUI/Sources/ManualEntry/PDFExportDocument.swift @@ -0,0 +1,23 @@ +import Foundation +import SwiftUI +import UniformTypeIdentifiers + +public struct PDFExportDocument: FileDocument { + public static var readableContentTypes: [UTType] { + [.pdf] + } + + public let data: Data + + public init(data: Data) { + self.data = data + } + + public init(configuration: ReadConfiguration) throws { + data = configuration.file.regularFileContents ?? Data() + } + + public func fileWrapper(configuration _: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: data) + } +} diff --git a/Where/WhereUI/Sources/ManualEntry/PlainTextExportDocument.swift b/Where/WhereUI/Sources/ManualEntry/PlainTextExportDocument.swift new file mode 100644 index 0000000..c76f860 --- /dev/null +++ b/Where/WhereUI/Sources/ManualEntry/PlainTextExportDocument.swift @@ -0,0 +1,23 @@ +import Foundation +import SwiftUI +import UniformTypeIdentifiers + +public struct PlainTextExportDocument: FileDocument { + public static var readableContentTypes: [UTType] { + [.plainText] + } + + public let text: String + + public init(text: String) { + self.text = text + } + + public init(configuration: ReadConfiguration) throws { + text = String(decoding: configuration.file.regularFileContents ?? Data(), as: UTF8.self) + } + + public func fileWrapper(configuration _: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: Data(text.utf8)) + } +} diff --git a/Where/WhereUI/Sources/ManualEntry/ReportActionRow.swift b/Where/WhereUI/Sources/ManualEntry/ReportActionRow.swift new file mode 100644 index 0000000..74b94e5 --- /dev/null +++ b/Where/WhereUI/Sources/ManualEntry/ReportActionRow.swift @@ -0,0 +1,26 @@ +import SwiftUI + +struct ReportActionRow: View { + let title: String + let statusText: String? + let onExport: () -> Void + let onShare: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + LabeledContent(title) { + HStack(spacing: 12) { + Button("Save", action: onExport) + Button("Share", action: onShare) + } + .buttonStyle(.borderless) + } + + if let statusText { + Text(statusText) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } +} diff --git a/Where/WhereUI/Sources/ManualEntry/ReportActivityState.swift b/Where/WhereUI/Sources/ManualEntry/ReportActivityState.swift new file mode 100644 index 0000000..6b6b2d2 --- /dev/null +++ b/Where/WhereUI/Sources/ManualEntry/ReportActivityState.swift @@ -0,0 +1,14 @@ +import Foundation + +public struct ReportActivityState: Equatable, Sendable { + public let generatedAt: Date + public let triggerDescription: String + + public init( + generatedAt: Date, + triggerDescription: String, + ) { + self.generatedAt = generatedAt + self.triggerDescription = triggerDescription + } +} diff --git a/Where/WhereUI/Sources/Root/RootView.swift b/Where/WhereUI/Sources/Root/RootView.swift new file mode 100644 index 0000000..1ceeb79 --- /dev/null +++ b/Where/WhereUI/Sources/Root/RootView.swift @@ -0,0 +1,41 @@ +import SwiftUI + +public struct RootView: View { + @State private var viewModel: RootViewModel + @State private var manualEntryViewModel: ManualEntryViewModel + + public init( + viewModel: RootViewModel, + manualEntryViewModel: ManualEntryViewModel, + ) { + _viewModel = State(initialValue: viewModel) + _manualEntryViewModel = State(initialValue: manualEntryViewModel) + } + + public var body: some View { + TabView { + DashboardView(viewModel: viewModel) + .tabItem { + Label("Dashboard", systemImage: "chart.bar.doc.horizontal") + } + + HistoryView(viewModel: viewModel) + .tabItem { + Label("History", systemImage: "clock.arrow.trianglehead.counterclockwise.rotate.90") + } + + ManualEntryView( + rootViewModel: viewModel, + viewModel: manualEntryViewModel, + ) + .tabItem { + Label("Manual", systemImage: "square.and.pencil") + } + } + .task { + if viewModel.snapshot == nil { + await viewModel.load() + } + } + } +} diff --git a/Where/WhereUI/Sources/Root/RootViewModel.swift b/Where/WhereUI/Sources/Root/RootViewModel.swift new file mode 100644 index 0000000..9f29dd5 --- /dev/null +++ b/Where/WhereUI/Sources/Root/RootViewModel.swift @@ -0,0 +1,45 @@ +import Foundation +import Observation +import WhereCore + +@MainActor +@Observable +public final class RootViewModel { + public private(set) var selectedYear: Int + public private(set) var availableYears: [Int] = [] + public private(set) var snapshot: YearProgressSnapshot? + public private(set) var isLoading = false + + private let provider: any YearProgressProviding + + public init( + provider: any YearProgressProviding, + selectedYear: Int? = nil, + ) { + self.provider = provider + self.selectedYear = selectedYear ?? Calendar.current.component(.year, from: Date()) + } + + public func load() async { + isLoading = true + let years = await provider.availableYears() + let fallbackYear = Calendar.current.component(.year, from: Date()) + availableYears = years.isEmpty ? [fallbackYear] : years + + if !availableYears.contains(selectedYear) { + selectedYear = availableYears.last ?? fallbackYear + } + + snapshot = await provider.snapshot(for: selectedYear) + isLoading = false + } + + public func selectYear(_ year: Int) async { + selectedYear = year + snapshot = await provider.snapshot(for: year) + } + + public func reloadAfterDataReset() async { + await load() + } +} diff --git a/Where/WhereUI/Sources/RootView.swift b/Where/WhereUI/Sources/RootView.swift deleted file mode 100644 index 626ffa1..0000000 --- a/Where/WhereUI/Sources/RootView.swift +++ /dev/null @@ -1,11 +0,0 @@ -import SwiftUI -import WhereCore - -public struct RootView: View { - public init() {} - - public var body: some View { - Text("Where") - .accessibilityIdentifier("where_root_title") - } -} diff --git a/Where/WhereUI/Tests/WhereUITests.swift b/Where/WhereUI/Tests/WhereUITests.swift index c390c19..1c9b601 100644 --- a/Where/WhereUI/Tests/WhereUITests.swift +++ b/Where/WhereUI/Tests/WhereUITests.swift @@ -1,13 +1,643 @@ +import Foundation import SwiftUI import Testing +import WhereCore import WhereTesting import WhereUI @Test @MainActor func rootViewBuilds() throws { - let vc = UIHostingController(rootView: RootView()) + let vc = UIHostingController( + rootView: RootView( + viewModel: RootViewModel(provider: StubYearProgressProvider()), + manualEntryViewModel: ManualEntryViewModel( + manager: StubManualEntryManager(), + importer: StubManualImporter(), + exporter: StubYearExporter(), + yearDataProvider: StubYearDataProvider(), + resetter: StubDataResetter(), + ), + ), + ) + try show(vc) { hosted in #expect(hosted.view != nil) } } + +@Test +@MainActor +func manualEntryViewModelStagesReportShareFiles() async throws { + let generatedAt = try makeDate(year: 2026, month: 4, day: 6, hour: 18) + let viewModel = ManualEntryViewModel( + manager: StubManualEntryManager(), + importer: StubManualImporter(), + exporter: StubYearExporter(generatedAt: generatedAt), + yearDataProvider: StubYearDataProvider(), + resetter: StubDataResetter(), + calendar: calendarUTC(), + ) + + await viewModel.preparePlainTextShare(for: 2026) + let textURL = try #require(viewModel.shareURL) + let textActivity = try #require(viewModel.reportActivity(for: 2026, format: .plainText)) + + #expect(textURL.lastPathComponent == "where-2026-report.txt") + #expect(try String(contentsOf: textURL, encoding: .utf8) == "Where Tax Report") + #expect(textActivity.generatedAt == generatedAt) + #expect(textActivity.triggerDescription == "prepared for sharing on") + + viewModel.clearShare() + + await viewModel.preparePDFShare(for: 2026) + let pdfURL = try #require(viewModel.shareURL) + let pdfPrefix = try String(decoding: Data(contentsOf: pdfURL).prefix(8), as: UTF8.self) + let pdfActivity = try #require(viewModel.reportActivity(for: 2026, format: .pdf)) + + #expect(pdfURL.lastPathComponent == "where-2026-report.pdf") + #expect(pdfPrefix.hasPrefix("%PDF-1.4")) + #expect(pdfActivity.generatedAt == generatedAt) + #expect(pdfActivity.triggerDescription == "prepared for sharing on") +} + +@Test +@MainActor +func manualEntryViewModelCachesInlineEvidenceURLForPreview() async throws { + let tempURL = FileManager.default.temporaryDirectory.appending(path: "\(UUID().uuidString).png") + try Data("preview".utf8).write(to: tempURL) + + let manager = PreviewManualEntryManager(evidenceURL: tempURL) + let viewModel = ManualEntryViewModel( + manager: manager, + importer: StubManualImporter(), + exporter: StubYearExporter(), + yearDataProvider: StubYearDataProvider(), + resetter: StubDataResetter(), + ) + let attachment = EvidenceAttachment( + manualEntryID: UUID(), + originalFilename: "boarding-pass.png", + contentType: "image/png", + byteCount: 7, + ) + + await viewModel.loadInlineEvidenceIfNeeded(for: attachment) + await viewModel.prepareEvidencePreview(for: attachment) + + #expect(viewModel.inlineEvidenceURL(for: attachment) == tempURL) + #expect(viewModel.previewURL == tempURL) + #expect(await manager.evidenceURLRequestCount == 1) +} + +@Test +@MainActor +func manualEntryViewModelBuildsGroupedDaySectionsAndHighlightsOutcomeChanges() async throws { + let generatedAt = try makeDate(year: 2026, month: 4, day: 6, hour: 18) + let trackedSample = try LocationSample( + timestamp: makeDate(year: 2026, month: 4, day: 5, hour: 8), + jurisdiction: .california, + ) + let unchangedEntry = try ManualLogEntry( + timestamp: makeDate(year: 2026, month: 4, day: 5, hour: 9), + jurisdiction: .california, + note: "Existing tracked jurisdiction", + kind: .supplemental, + ) + let changedEntry = try ManualLogEntry( + timestamp: makeDate(year: 2026, month: 4, day: 5, hour: 12), + jurisdiction: .newYork, + note: "Flight evidence adds New York", + kind: .supplemental, + ) + let bundle = YearDataBundle( + year: 2026, + locationSamples: [trackedSample], + manualEntries: [unchangedEntry, changedEntry], + evidenceAttachments: [], + syncCheckpoint: .init(state: .idle), + ) + let viewModel = ManualEntryViewModel( + manager: StubManualEntryManager( + records: [ + ManualEntryRecord(entry: changedEntry, attachments: []), + ManualEntryRecord(entry: unchangedEntry, attachments: []), + ], + ), + importer: StubManualImporter(), + exporter: StubYearExporter(generatedAt: generatedAt), + yearDataProvider: StubYearDataProvider(bundle: bundle), + resetter: StubDataResetter(), + calendar: calendarUTC(), + ) + + await viewModel.load(for: 2026) + await viewModel.preparePDFExport(for: 2026) + + let section = try #require(viewModel.daySections.first) + let exportActivity = try #require(viewModel.reportActivity(for: 2026, format: .pdf)) + + #expect(viewModel.daySections.count == 1) + #expect(section.changesTrackedOutcome) + #expect(section.trackedJurisdictions == [.california]) + #expect(section.finalJurisdictions == [.california, .newYork]) + #expect(section.entries.count == 2) + #expect(section.entries.first { $0.record.id == changedEntry.id }?.changesDayOutcome == true) + #expect(section.entries.first { $0.record.id == unchangedEntry.id }?.changesDayOutcome == false) + #expect(exportActivity.generatedAt == generatedAt) + #expect(exportActivity.triggerDescription == "prepared for save on") +} + +@Test +@MainActor +func manualEntryViewModelResetsStoredDataAndClearsUIState() async throws { + let trackedSample = try LocationSample( + timestamp: makeDate(year: 2026, month: 4, day: 5, hour: 8), + jurisdiction: .california, + ) + let entry = try ManualLogEntry( + timestamp: makeDate(year: 2026, month: 4, day: 5, hour: 12), + jurisdiction: .newYork, + note: "Flight correction", + kind: .correction, + ) + let record = ManualEntryRecord(entry: entry, attachments: []) + let manager = MutableManualEntryManager(records: [record]) + let provider = MutableYearDataProvider( + bundle: YearDataBundle( + year: 2026, + locationSamples: [trackedSample], + manualEntries: [entry], + evidenceAttachments: [], + syncCheckpoint: .init(state: .idle), + ), + ) + let resetter = StubDataResetter( + manager: manager, + provider: provider, + ) + let viewModel = ManualEntryViewModel( + manager: manager, + importer: StubManualImporter(), + exporter: StubYearExporter(), + yearDataProvider: provider, + resetter: resetter, + calendar: calendarUTC(), + ) + + await viewModel.load(for: 2026) + viewModel.requestResetConfirmation() + await viewModel.resetAllData(for: 2026) + + #expect(viewModel.records.isEmpty) + #expect(viewModel.daySections.isEmpty) + #expect(!viewModel.isShowingResetConfirmation) + #expect(await resetter.resetCount == 1) +} + +@Test +@MainActor +func manualEntryViewModelPreviewsBackfillImports() async throws { + let evidenceURL = FileManager.default.temporaryDirectory.appending(path: "\(UUID().uuidString).txt") + try Data("ticket".utf8).write(to: evidenceURL) + let importer = StubManualImporter() + let viewModel = ManualEntryViewModel( + manager: StubManualEntryManager(), + importer: importer, + exporter: StubYearExporter(), + yearDataProvider: StubYearDataProvider(), + resetter: StubDataResetter(), + calendar: calendarUTC(), + ) + + try await viewModel.previewBackfill( + ManualImportBackfillRequest( + startDate: makeDate(year: 2026, month: 4, day: 5, hour: 8), + endDate: makeDate(year: 2026, month: 4, day: 6, hour: 8), + jurisdiction: .california, + note: "Backfill request", + kind: .supplemental, + evidenceFiles: [evidenceURL], + ), + ) + + let preview = try #require(viewModel.activeImportPreview) + #expect(viewModel.isShowingImportPreview) + #expect(viewModel.importSourceDescription == "Backfill") + #expect(preview.entryCount == 2) + #expect(preview.evidenceAttachmentCount == 2) +} + +@Test +@MainActor +func manualEntryViewModelPreviewsPackageImportsAndConfirmsImport() async throws { + let importedEntry = try ManualLogEntry( + timestamp: makeDate(year: 2026, month: 4, day: 5, hour: 10), + jurisdiction: .newYork, + note: "Imported package row", + kind: .supplemental, + ) + let importedRecord = ManualEntryRecord( + entry: importedEntry, + attachments: [ + EvidenceAttachment( + manualEntryID: importedEntry.id, + originalFilename: "ticket.txt", + contentType: "text/plain", + byteCount: 6, + ), + ], + ) + let manager = MutableManualEntryManager(records: []) + let provider = MutableYearDataProvider( + bundle: YearDataBundle( + year: 2026, + locationSamples: [], + manualEntries: [], + evidenceAttachments: [], + syncCheckpoint: .init(state: .idle), + ), + ) + let importer = StubManualImporter(importPackageResult: [importedRecord]) + let viewModel = ManualEntryViewModel( + manager: manager, + importer: importer, + exporter: StubYearExporter(), + yearDataProvider: provider, + resetter: StubDataResetter(), + calendar: calendarUTC(), + ) + let packageURL = FileManager.default.temporaryDirectory.appending(path: "where-import-package") + + await viewModel.previewPackage(at: packageURL) + let preview = try #require(viewModel.activeImportPreview) + #expect(viewModel.importSourceDescription == "where-import-package") + #expect(preview.entryCount == 1) + #expect(preview.hasWarnings) + + await manager.replaceRecords([importedRecord]) + await provider.replaceBundle( + YearDataBundle( + year: 2026, + locationSamples: [], + manualEntries: [importedEntry], + evidenceAttachments: importedRecord.attachments, + syncCheckpoint: .init(state: .idle), + ), + ) + + await viewModel.confirmImport(for: 2026) + + #expect(!viewModel.isShowingImportPreview) + #expect(viewModel.records.count == 1) + #expect(viewModel.daySections.count == 1) + #expect(await importer.importedPackageURLs == [packageURL]) +} + +private struct StubYearProgressProvider: YearProgressProviding { + func availableYears() async -> [Int] { + [2026] + } + + func snapshot(for year: Int) async -> YearProgressSnapshot { + YearProgressSnapshot( + year: year, + primarySummaries: [ + .init(jurisdiction: .california, totalDays: 10), + .init(jurisdiction: .newYork, totalDays: 8), + ], + secondarySummaries: [ + .init(jurisdiction: .unknown, totalDays: 1), + ], + trackingStatus: .needsReview, + recentDays: [ + .init( + dateLabel: "Apr 6", + jurisdictions: [.california, .newYork], + note: "Multiple jurisdictions logged", + ), + ], + ) + } +} + +private struct StubManualEntryManager: ManualEntryManaging { + var records: [ManualEntryRecord] = [] + + func records(in _: Int) async -> [ManualEntryRecord] { + records + } + + func save(_ draft: ManualEntryDraft) async -> ManualEntryRecord { + ManualEntryRecord( + entry: ManualLogEntry( + id: draft.id ?? UUID(), + timestamp: draft.timestamp, + jurisdiction: draft.jurisdiction, + note: draft.trimmedNote, + kind: draft.kind, + ), + attachments: [], + ) + } + + func deleteEntry(id _: UUID) async {} + + func importEvidence(manualEntryID _: UUID, fileURL _: URL) async -> EvidenceAttachment? { + nil + } + + func evidenceFileURL(for _: EvidenceAttachment) async -> URL? { + nil + } + + func deleteEvidence(_: EvidenceAttachment) async {} +} + +private actor StubManualImporter: ManualDataImporting { + var backfillPreview = ManualImportPreview( + yearSpan: 2026 ... 2026, + entryCount: 2, + evidenceAttachmentCount: 2, + sharedEvidenceAttachmentCount: 1, + entries: [ + ManualImportEntryDraft( + timestamp: Date(), + jurisdiction: .california, + note: "Backfill preview", + kind: .supplemental, + evidenceFiles: [], + ), + ManualImportEntryDraft( + timestamp: Date().addingTimeInterval(86400), + jurisdiction: .california, + note: "Backfill preview", + kind: .supplemental, + evidenceFiles: [], + ), + ], + ) + var packagePreview = ManualImportPreview( + yearSpan: 2025 ... 2026, + entryCount: 1, + evidenceAttachmentCount: 1, + sharedEvidenceAttachmentCount: 0, + entries: [ + ManualImportEntryDraft( + timestamp: Date(), + jurisdiction: .newYork, + note: "Package preview", + kind: .supplemental, + evidenceFiles: [], + ), + ], + issues: [ + .init(severity: .warning, message: "This package spans multiple years (2025-2026)."), + ], + ) + var importEntriesResult: [ManualEntryRecord] = [] + var importPackageResult: [ManualEntryRecord] = [] + private(set) var importedPackageURLs: [URL] = [] + + init( + importEntriesResult: [ManualEntryRecord] = [], + importPackageResult: [ManualEntryRecord] = [], + ) { + self.importEntriesResult = importEntriesResult + self.importPackageResult = importPackageResult + } + + func previewBackfill(_ request: ManualImportBackfillRequest) async -> ManualImportPreview { + ManualImportPreview( + yearSpan: 2026 ... 2026, + entryCount: 2, + evidenceAttachmentCount: request.evidenceFiles.count * 2, + sharedEvidenceAttachmentCount: request.evidenceFiles.count, + entries: backfillPreview.entries, + ) + } + + func previewPackage(at _: URL) async -> ManualImportPreview { + packagePreview + } + + func importEntries(_: [ManualImportEntryDraft]) async -> [ManualEntryRecord] { + importEntriesResult + } + + func importPackage(at directoryURL: URL) async -> [ManualEntryRecord] { + importedPackageURLs.append(directoryURL) + return importPackageResult + } +} + +private struct StubYearExporter: YearExporting { + let generatedAt: Date + + init(generatedAt: Date = Date()) { + self.generatedAt = generatedAt + } + + func exportBundle(for year: Int) async -> YearExportBundle { + YearExportBundle( + year: year, + generatedAt: generatedAt, + plaintext: "Where Tax Report", + pdfData: Data("%PDF-1.4".utf8), + ) + } +} + +private struct StubYearDataProvider: YearDataProviding { + var bundle: YearDataBundle = .init( + year: 2026, + locationSamples: [], + manualEntries: [], + evidenceAttachments: [], + syncCheckpoint: .init(state: .idle), + ) + var fallbackBundle: YearDataBundle? + + func availableYears() async -> [Int] { + [bundle.year] + } + + func bundle(for year: Int) async -> YearDataBundle { + if year == bundle.year { + return bundle + } + + return fallbackBundle ?? YearDataBundle( + year: year, + locationSamples: [], + manualEntries: [], + evidenceAttachments: [], + syncCheckpoint: .init(state: .idle), + ) + } +} + +private actor StubDataResetter: WhereDataResetting { + private let manager: MutableManualEntryManager? + private let provider: MutableYearDataProvider? + private(set) var resetCount = 0 + + init( + manager: MutableManualEntryManager? = nil, + provider: MutableYearDataProvider? = nil, + ) { + self.manager = manager + self.provider = provider + } + + func resetAllData() async { + resetCount += 1 + await manager?.replaceRecords([]) + await provider?.replaceBundle( + YearDataBundle( + year: 2026, + locationSamples: [], + manualEntries: [], + evidenceAttachments: [], + syncCheckpoint: .init(state: .idle), + ), + ) + } +} + +private actor MutableManualEntryManager: ManualEntryManaging { + private var storedRecords: [ManualEntryRecord] + + init(records: [ManualEntryRecord]) { + storedRecords = records + } + + func records(in _: Int) async -> [ManualEntryRecord] { + storedRecords + } + + func save(_ draft: ManualEntryDraft) async -> ManualEntryRecord { + let record = ManualEntryRecord( + entry: ManualLogEntry( + id: draft.id ?? UUID(), + timestamp: draft.timestamp, + jurisdiction: draft.jurisdiction, + note: draft.trimmedNote, + kind: draft.kind, + ), + attachments: [], + ) + storedRecords = [record] + return record + } + + func deleteEntry(id: UUID) async { + storedRecords.removeAll { $0.id == id } + } + + func importEvidence(manualEntryID _: UUID, fileURL _: URL) async -> EvidenceAttachment? { + nil + } + + func evidenceFileURL(for _: EvidenceAttachment) async -> URL? { + nil + } + + func deleteEvidence(_: EvidenceAttachment) async {} + + func replaceRecords(_ records: [ManualEntryRecord]) async { + storedRecords = records + } +} + +private actor MutableYearDataProvider: YearDataProviding { + private var storedBundle: YearDataBundle + + init(bundle: YearDataBundle) { + storedBundle = bundle + } + + func availableYears() async -> [Int] { + [storedBundle.year] + } + + func bundle(for year: Int) async -> YearDataBundle { + if year == storedBundle.year { + return storedBundle + } + + return YearDataBundle( + year: year, + locationSamples: [], + manualEntries: [], + evidenceAttachments: [], + syncCheckpoint: .init(state: .idle), + ) + } + + func replaceBundle(_ bundle: YearDataBundle) async { + storedBundle = bundle + } +} + +private actor PreviewManualEntryManager: ManualEntryManaging { + private let evidenceURL: URL + private(set) var evidenceURLRequestCount = 0 + + init(evidenceURL: URL) { + self.evidenceURL = evidenceURL + } + + func records(in _: Int) async -> [ManualEntryRecord] { + [] + } + + func save(_ draft: ManualEntryDraft) async -> ManualEntryRecord { + ManualEntryRecord( + entry: ManualLogEntry( + id: draft.id ?? UUID(), + timestamp: draft.timestamp, + jurisdiction: draft.jurisdiction, + note: draft.trimmedNote, + kind: draft.kind, + ), + attachments: [], + ) + } + + func deleteEntry(id _: UUID) async {} + + func importEvidence(manualEntryID _: UUID, fileURL _: URL) async -> EvidenceAttachment? { + nil + } + + func evidenceFileURL(for _: EvidenceAttachment) async -> URL? { + evidenceURLRequestCount += 1 + return evidenceURL + } + + func deleteEvidence(_: EvidenceAttachment) async {} +} + +private func calendarUTC() -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + return calendar +} + +private func makeDate( + year: Int, + month: Int, + day: Int, + hour: Int, +) throws -> Date { + let components = DateComponents( + calendar: calendarUTC(), + year: year, + month: month, + day: day, + hour: hour, + ) + + return try #require(components.date) +}