diff --git a/Packages/SparkKit/Sources/SparkKit/API/APIClient.swift b/Packages/SparkKit/Sources/SparkKit/API/APIClient.swift index a3b8e03..d17b5dc 100644 --- a/Packages/SparkKit/Sources/SparkKit/API/APIClient.swift +++ b/Packages/SparkKit/Sources/SparkKit/API/APIClient.swift @@ -136,13 +136,31 @@ public actor APIClient { // MARK: - Public entrypoints public func request(_ endpoint: Endpoint) async throws -> Response { + try await perform(endpoint, absoluteBase: false, allowRefresh: true).decoded + } + + public func requestWithRawResponse(_ endpoint: Endpoint) async throws -> RawAPIResponse { try await perform(endpoint, absoluteBase: false, allowRefresh: true) } /// Hit an endpoint whose path is rooted at `/api` (not `/api/v1/mobile`). /// Used for the OAuth token endpoints. public func requestSiteRoot(_ endpoint: Endpoint) async throws -> Response { - try await perform(endpoint, absoluteBase: true, allowRefresh: false) + try await perform(endpoint, absoluteBase: true, allowRefresh: false).decoded + } + + public func accessTokenRefreshingIfNeeded( + leeway: TimeInterval = 30, + forceRefresh: Bool = false + ) async throws -> String? { + guard let tokens = await tokenStore.tokens() else { return nil } + guard forceRefresh || tokens.expiresAt <= Date().addingTimeInterval(leeway) else { + return tokens.accessToken + } + guard await tokenStore.hasRefreshToken() else { + return tokens.accessToken + } + return try await refreshTokens().accessToken } // MARK: - Core @@ -153,7 +171,7 @@ public actor APIClient { allowRefresh: Bool, attempt: Int = 1, isRefreshRequest: Bool = false - ) async throws -> Response { + ) async throws -> RawAPIResponse { let url = try buildURL(endpoint: endpoint, absoluteBase: absoluteBase) var request = URLRequest(url: url) request.httpMethod = endpoint.method.rawValue @@ -162,7 +180,9 @@ public actor APIClient { request.httpBody = body request.setValue(endpoint.contentType ?? "application/json", forHTTPHeaderField: "Content-Type") } - let accessToken = endpoint.requiresAuth ? await tokenStore.accessToken() : nil + let accessToken = endpoint.requiresAuth + ? try await accessTokenRefreshingIfNeeded() + : nil if let accessToken { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } @@ -294,7 +314,7 @@ public actor APIClient { metrics: metricsCollector.snapshot, outcome: .success ) - return empty + return RawAPIResponse(decoded: empty, data: data) } do { @@ -314,7 +334,7 @@ public actor APIClient { outcome: .success, decodeDurationMillis: Date().timeIntervalSince(decodeStartedAt) * 1_000 ) - return decoded + return RawAPIResponse(decoded: decoded, data: data) } catch { let bodyString = String(data: data, encoding: .utf8) ?? "" logger.error("Decoding failed for \(endpoint.path, privacy: .public): \(error.localizedDescription, privacy: .public) — body: \(bodyString, privacy: .public)") @@ -341,7 +361,7 @@ public actor APIClient { absoluteBase: Bool, retryAttempt: Int, tokenUsedForRequest: String? - ) async throws -> Response { + ) async throws -> RawAPIResponse { if let tokenUsedForRequest, let currentAccessToken = await tokenStore.accessToken(), currentAccessToken != tokenUsedForRequest { @@ -364,7 +384,7 @@ public actor APIClient { absoluteBase: true, allowRefresh: false, isRefreshRequest: true - ) + ).decoded let authTokens = AuthTokens( accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, @@ -434,7 +454,7 @@ public actor APIClient { private func buildURL(endpoint: Endpoint, absoluteBase: Bool) throws -> URL { let base: URL if absoluteBase { - base = oauthSiteRootURL() + base = oauthAPIRootURL() } else { base = environment.baseURL } @@ -449,14 +469,20 @@ public actor APIClient { return url } - private func oauthSiteRootURL() -> URL { + /// The OAuth token/refresh endpoints (`/oauth/token`, `/oauth/refresh`) are + /// registered in Laravel's `routes/api.php`, so they are served from the + /// API host under the `/api` prefix — *not* as root-level siblings of the + /// `/oauth/authorize` web route. Derive the base from `environment.baseURL` + /// (definitionally the API host, e.g. `https://host/api/v1/mobile`) and + /// anchor it at `/api` so endpoint paths resolve to `/api/oauth/token`. + private func oauthAPIRootURL() -> URL { guard var components = URLComponents( - url: environment.oauthAuthorizeURL, + url: environment.baseURL, resolvingAgainstBaseURL: false ) else { return environment.baseURL } - components.path = "/" + components.path = "/api" components.query = nil components.fragment = nil return components.url ?? environment.baseURL @@ -519,3 +545,12 @@ private extension HTTPURLResponse { public struct EmptyResponse: Codable, Sendable { public init() {} } + +public struct RawAPIResponse: Sendable { + public let decoded: Response + public let data: Data + + public var utf8Body: String { + String(data: data, encoding: .utf8) ?? "" + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/HealthEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/HealthEndpoint.swift index 51a615d..c266842 100644 --- a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/HealthEndpoint.swift +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/HealthEndpoint.swift @@ -1,6 +1,32 @@ import Foundation public enum HealthEndpoint { + public enum DashboardRange: String, Sendable, CaseIterable { + case sevenDays = "7d" + case thirtyDays = "30d" + case ninetyDays = "90d" + + public var label: String { + switch self { + case .sevenDays: "7D" + case .thirtyDays: "30D" + case .ninetyDays: "90D" + } + } + } + + /// GET /health/dashboard?date=YYYY-MM-DD&range=7d + public static func dashboard(date: String = "today", range: DashboardRange = .sevenDays) -> Endpoint { + Endpoint( + method: .get, + path: "/health/dashboard", + query: [ + URLQueryItem(name: "date", value: date), + URLQueryItem(name: "range", value: range.rawValue), + ] + ) + } + /// POST /health/samples public static func submit(samples: [HealthSample]) -> Endpoint { let body = try? JSONEncoder().encode(HealthSampleBatch(samples: samples)) diff --git a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/SearchEndpoint.swift b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/SearchEndpoint.swift index e14f03c..26e4b7e 100644 --- a/Packages/SparkKit/Sources/SparkKit/API/Endpoints/SearchEndpoint.swift +++ b/Packages/SparkKit/Sources/SparkKit/API/Endpoints/SearchEndpoint.swift @@ -9,6 +9,13 @@ public enum SearchEndpoint { case integrations case semantic + public var queryValue: String { + switch self { + case .tags: "tag" + default: rawValue + } + } + /// Single-character prefix used in the web Spotlight (`>` etc.). The /// search bar swallows the prefix and switches `Mode`. public var symbol: String? { @@ -41,7 +48,7 @@ public enum SearchEndpoint { path: "/search", query: [ URLQueryItem(name: "q", value: text), - URLQueryItem(name: "mode", value: mode.rawValue), + URLQueryItem(name: "mode", value: mode.queryValue), ] ) } diff --git a/Packages/SparkKit/Sources/SparkKit/Deeplinks/DeepLink.swift b/Packages/SparkKit/Sources/SparkKit/Deeplinks/DeepLink.swift index 3c8d408..936d89c 100644 --- a/Packages/SparkKit/Sources/SparkKit/Deeplinks/DeepLink.swift +++ b/Packages/SparkKit/Sources/SparkKit/Deeplinks/DeepLink.swift @@ -16,6 +16,7 @@ public enum DeepLink: Sendable, Equatable { case metric(identifier: String) case place(id: String) case integration(service: String) + case tag(name: String) /// Parse an incoming URL. Returns nil when the URL doesn't match any /// route — caller can fall through to default handling (e.g. opening @@ -60,6 +61,9 @@ public enum DeepLink: Sendable, Equatable { // /integrations/{service}/details guard parts.count >= 3, parts[2] == "details" else { return nil } return .integration(service: parts[1]) + case "tags", "tag": + guard parts.count >= 2 else { return nil } + return .tag(name: parts[1]) default: return nil } diff --git a/Packages/SparkKit/Sources/SparkKit/Models/CheckIn.swift b/Packages/SparkKit/Sources/SparkKit/Models/CheckIn.swift index d5de22d..90f7d8d 100644 --- a/Packages/SparkKit/Sources/SparkKit/Models/CheckIn.swift +++ b/Packages/SparkKit/Sources/SparkKit/Models/CheckIn.swift @@ -14,6 +14,7 @@ public struct CheckInRequest: Encodable, Sendable { public let physical: Int public let mental: Int public let date: String + public let occurredAt: Date? public let latitude: Double? public let longitude: Double? public let address: String? @@ -24,6 +25,7 @@ public struct CheckInRequest: Encodable, Sendable { physical: Int, mental: Int, date: String, + occurredAt: Date? = nil, latitude: Double? = nil, longitude: Double? = nil, address: String? = nil, @@ -33,6 +35,7 @@ public struct CheckInRequest: Encodable, Sendable { self.physical = physical self.mental = mental self.date = date + self.occurredAt = occurredAt self.latitude = latitude self.longitude = longitude self.address = address @@ -41,6 +44,7 @@ public struct CheckInRequest: Encodable, Sendable { enum CodingKeys: String, CodingKey { case period, physical, mental, date, latitude, longitude, address, notes + case occurredAt = "occurred_at" } } diff --git a/Packages/SparkKit/Sources/SparkKit/Models/HealthDashboard.swift b/Packages/SparkKit/Sources/SparkKit/Models/HealthDashboard.swift new file mode 100644 index 0000000..9e77684 --- /dev/null +++ b/Packages/SparkKit/Sources/SparkKit/Models/HealthDashboard.swift @@ -0,0 +1,250 @@ +import Foundation + +/// Fitness-first dashboard payload returned by `/api/v1/mobile/health/dashboard`. +public struct HealthDashboard: Codable, Sendable, Hashable { + public let date: String + public let timezone: String + public let range: String + public let generatedAt: Date + public let syncStatus: [String: SyncStatus] + public let hero: Hero? + public let fitness: Fitness + public let bodyMetrics: [BodyMetric] + public let trends: [Trend] + public let insights: [Insight] + + enum CodingKeys: String, CodingKey { + case date, timezone, range, hero, fitness, trends, insights + case generatedAt = "generated_at" + case syncStatus = "sync_status" + case bodyMetrics = "body_metrics" + } + + public init( + date: String, + timezone: String, + range: String, + generatedAt: Date, + syncStatus: [String: SyncStatus] = [:], + hero: Hero? = nil, + fitness: Fitness, + bodyMetrics: [BodyMetric] = [], + trends: [Trend] = [], + insights: [Insight] = [] + ) { + self.date = date + self.timezone = timezone + self.range = range + self.generatedAt = generatedAt + self.syncStatus = syncStatus + self.hero = hero + self.fitness = fitness + self.bodyMetrics = bodyMetrics + self.trends = trends + self.insights = insights + } + + public struct SyncStatus: Codable, Sendable, Hashable { + public let eventCount: Int + public let lastEventTime: Date? + public let coverage: String? + + enum CodingKeys: String, CodingKey { + case coverage + case eventCount = "event_count" + case lastEventTime = "last_event_time" + } + } + + public struct Hero: Codable, Sendable, Hashable { + public let score: Int? + public let kind: String + public let status: String + public let title: String + public let subtitle: String + public let primaryEventId: String? + public let factors: [Factor] + + enum CodingKeys: String, CodingKey { + case score, kind, status, title, subtitle, factors + case primaryEventId = "primary_event_id" + } + + public struct Factor: Codable, Sendable, Hashable, Identifiable { + public var id: String { label } + public let label: String + public let value: Double? + public let unit: String? + public let status: String + } + } + + public struct Fitness: Codable, Sendable, Hashable { + public let today: Today + public let workouts: [Workout] + } + + public struct Today: Codable, Sendable, Hashable { + public let steps: Quantity? + public let distance: Quantity? + public let activeEnergy: Quantity? + public let exercise: Quantity? + public let stand: Quantity? + public let workoutCount: Int + public let workoutDurationSeconds: Double + public let workoutEnergyKcal: Double + public let strengthVolume: Quantity? + + enum CodingKeys: String, CodingKey { + case steps, distance, exercise, stand + case activeEnergy = "active_energy" + case workoutCount = "workout_count" + case workoutDurationSeconds = "workout_duration_seconds" + case workoutEnergyKcal = "workout_energy_kcal" + case strengthVolume = "strength_volume" + } + } + + public struct Workout: Codable, Sendable, Hashable, Identifiable { + public var id: String { eventId } + public let eventId: String + public let source: String + public let kind: String + public let type: String? + public let title: String + public let start: Date + public let end: Date? + public let durationSeconds: Double + public let energyKcal: Double? + public let distance: Quantity? + public let intensity: Quantity? + public let routeAvailable: Bool? + public let volume: Quantity? + public let exercises: [Exercise]? + + enum CodingKeys: String, CodingKey { + case source, kind, type, title, start, end, distance, intensity, volume, exercises + case eventId = "event_id" + case durationSeconds = "duration_seconds" + case energyKcal = "energy_kcal" + case routeAvailable = "route_available" + } + + public struct Exercise: Codable, Sendable, Hashable, Identifiable { + public var id: String { name } + public let name: String + public let sets: Int + public let volume: Quantity? + } + } + + public struct BodyMetric: Codable, Sendable, Hashable, Identifiable { + public let id: String + public let eventId: String + public let label: String + public let value: Double? + public let unit: String? + public let vsBaselinePct: Double? + public let isAnomaly: Bool + public let status: String + + enum CodingKeys: String, CodingKey { + case id, label, value, unit, status + case eventId = "event_id" + case vsBaselinePct = "vs_baseline_pct" + case isAnomaly = "is_anomaly" + } + } + + public struct Trend: Codable, Sendable, Hashable, Identifiable { + public var id: String { metric } + public let metric: String + public let label: String? + public let service: String + public let action: String + public let unit: String? + public let range: Range + public let dailyValues: [DailyValue] + public let summary: Summary? + public let baseline: Baseline? + + enum CodingKeys: String, CodingKey { + case metric, label, service, action, unit, range, summary, baseline + case dailyValues = "daily_values" + } + + public struct Range: Codable, Sendable, Hashable { + public let from: String + public let to: String + } + + public struct DailyValue: Codable, Sendable, Hashable, Identifiable { + public var id: String { date } + public let date: String + public let value: Double? + public let vsBaselinePct: Double? + public let isAnomaly: Bool? + + enum CodingKeys: String, CodingKey { + case date, value + case vsBaselinePct = "vs_baseline_pct" + case isAnomaly = "is_anomaly" + } + } + + public struct Summary: Codable, Sendable, Hashable { + public let min: Double? + public let max: Double? + public let mean: Double? + public let dataPoints: Int? + public let trendDirection: String? + + enum CodingKeys: String, CodingKey { + case min, max, mean + case dataPoints = "data_points" + case trendDirection = "trend_direction" + } + } + + public struct Baseline: Codable, Sendable, Hashable { + public let mean: Double? + public let stddev: Double? + public let normalLower: Double? + public let normalUpper: Double? + public let sampleDays: Int? + + enum CodingKeys: String, CodingKey { + case mean, stddev + case normalLower = "normal_lower" + case normalUpper = "normal_upper" + case sampleDays = "sample_days" + } + } + } + + public struct Insight: Codable, Sendable, Hashable, Identifiable { + public var id: String { blockId } + public let blockId: String + public let eventId: String + public let title: String + public let content: String? + public let time: Date + + enum CodingKeys: String, CodingKey { + case title, content, time + case blockId = "block_id" + case eventId = "event_id" + } + } + + public struct Quantity: Codable, Sendable, Hashable { + public let value: Double? + public let unit: String? + public let vsBaselinePct: Double? + + enum CodingKeys: String, CodingKey { + case value, unit + case vsBaselinePct = "vs_baseline_pct" + } + } +} diff --git a/Packages/SparkKit/Sources/SparkKit/Models/SearchResult.swift b/Packages/SparkKit/Sources/SparkKit/Models/SearchResult.swift index da5bacf..356ef40 100644 --- a/Packages/SparkKit/Sources/SparkKit/Models/SearchResult.swift +++ b/Packages/SparkKit/Sources/SparkKit/Models/SearchResult.swift @@ -9,6 +9,7 @@ public enum SearchResult: Codable, Sendable, Hashable, Identifiable { case metric(MetricHit) case integration(IntegrationHit) case place(PlaceHit) + case tag(TagHit) case intent(IntentHit) public var id: String { @@ -19,6 +20,7 @@ public enum SearchResult: Codable, Sendable, Hashable, Identifiable { case .metric(let h): "metric:\(h.identifier)" case .integration(let h): "integration:\(h.id)" case .place(let h): "place:\(h.id)" + case .tag(let h): "tag:\(h.type ?? ""):\(h.name)" case .intent(let h): "intent:\(h.id)" } } @@ -31,6 +33,7 @@ public enum SearchResult: Codable, Sendable, Hashable, Identifiable { case .metric(let h): h.title case .integration(let h): h.title case .place(let h): h.title + case .tag(let h): h.title case .intent(let h): h.title } } @@ -43,6 +46,7 @@ public enum SearchResult: Codable, Sendable, Hashable, Identifiable { case .metric(let h): h.subtitle case .integration(let h): h.subtitle case .place(let h): h.subtitle + case .tag(let h): h.subtitle case .intent(let h): h.subtitle } } @@ -55,6 +59,7 @@ public enum SearchResult: Codable, Sendable, Hashable, Identifiable { case .metric: "Metrics" case .integration: "Integrations" case .place: "Places" + case .tag: "Tags" case .intent: "Actions" } } @@ -74,6 +79,7 @@ public enum SearchResult: Codable, Sendable, Hashable, Identifiable { case "metric": self = .metric(try single.decode(MetricHit.self)) case "integration": self = .integration(try single.decode(IntegrationHit.self)) case "place": self = .place(try single.decode(PlaceHit.self)) + case "tag": self = .tag(try single.decode(TagHit.self)) case "intent": self = .intent(try single.decode(IntentHit.self)) default: throw DecodingError.dataCorruptedError( @@ -93,6 +99,7 @@ public enum SearchResult: Codable, Sendable, Hashable, Identifiable { case .metric(let h): try single.encode(h) case .integration(let h): try single.encode(h) case .place(let h): try single.encode(h) + case .tag(let h): try single.encode(h) case .intent(let h): try single.encode(h) } } @@ -145,6 +152,43 @@ public enum SearchResult: Codable, Sendable, Hashable, Identifiable { public let subtitle: String? } + public struct TagHit: Codable, Sendable, Hashable { + public let name: String + public let type: String? + public let title: String + public let subtitle: String? + + enum CodingKeys: String, CodingKey { + case id, name, type, title, subtitle, count, resultsCount = "results_count" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decodeIfPresent(String.self, forKey: .name) + ?? container.decodeIfPresent(String.self, forKey: .id) + ?? container.decode(String.self, forKey: .title) + type = try container.decodeIfPresent(String.self, forKey: .type) + title = try container.decodeIfPresent(String.self, forKey: .title) ?? name + + if let subtitle = try container.decodeIfPresent(String.self, forKey: .subtitle) { + self.subtitle = subtitle + } else if let count = try container.decodeIfPresent(Int.self, forKey: .count) + ?? container.decodeIfPresent(Int.self, forKey: .resultsCount) { + self.subtitle = "\(count) item\(count == 1 ? "" : "s")" + } else { + self.subtitle = type + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encodeIfPresent(type, forKey: .type) + try container.encode(title, forKey: .title) + try container.encodeIfPresent(subtitle, forKey: .subtitle) + } + } + public struct IntentHit: Codable, Sendable, Hashable { public let id: String public let title: String @@ -161,7 +205,7 @@ public struct SearchResponse: Codable, Sendable, Hashable { enum CodingKeys: String, CodingKey { // Grouped backend format - case events, objects, integrations, metrics + case events, objects, integrations, metrics, tags // Legacy wrapped formats case results, data, items, hits } @@ -181,7 +225,8 @@ public struct SearchResponse: Codable, Sendable, Hashable { // 2. Grouped backend format: { events: [...], objects: [...], ... } if container.contains(.events) || container.contains(.objects) - || container.contains(.integrations) || container.contains(.metrics) { + || container.contains(.integrations) || container.contains(.metrics) + || container.contains(.tags) { var all: [SearchResult] = [] for e in (try container.decodeIfPresent([BackendEvent].self, forKey: .events)) ?? [] { @@ -216,6 +261,9 @@ public struct SearchResponse: Codable, Sendable, Hashable { domain: m.domain ))) } + for tag in (try container.decodeIfPresent([SearchResult.TagHit].self, forKey: .tags)) ?? [] { + all.append(.tag(tag)) + } results = all return diff --git a/Packages/SparkKit/Tests/SparkKitTests/APIClientTests.swift b/Packages/SparkKit/Tests/SparkKitTests/APIClientTests.swift index 1921972..dbc941a 100644 --- a/Packages/SparkKit/Tests/SparkKitTests/APIClientTests.swift +++ b/Packages/SparkKit/Tests/SparkKitTests/APIClientTests.swift @@ -113,6 +113,42 @@ struct APIClientTests { #expect(retryRequest?.value(forHTTPHeaderField: "Authorization") == "Bearer new") } + @Test("expired access token refreshes before protected request") + func expiredTokenRefreshesBeforeProtectedRequest() async throws { + let (client, tokenStore) = makeClient() + await tokenStore.store(access: "old", refresh: "r-1", expiresIn: -1) + + await StubURLProtocol.set { request in + if request.url?.path.hasSuffix("/oauth/refresh") == true { + let json = """ + {"token_type":"Bearer","access_token":"new","refresh_token":"r-2","expires_in":3600} + """.data(using: .utf8)! + return (json, 200, [:]) + } + + guard request.value(forHTTPHeaderField: "Authorization") == "Bearer new" else { + return (Data(), 401, [:]) + } + + let payload = """ + {"date":"2026-04-19","timezone":"UTC","sync_status":{"in_flight":false,"last_synced_at":null,"anomaly_count":0},"sections":{},"anomalies":[]} + """.data(using: .utf8)! + return (payload, 200, [:]) + } + + let summary = try await client.request(BriefingEndpoint.today()) + #expect(summary.date == "2026-04-19") + #expect(await tokenStore.accessToken() == "new") + + let captured = await StubURLProtocol.recorded() + #expect(captured.compactMap { $0.url?.path } == [ + "/api/oauth/refresh", + "/api/v1/mobile/briefing/today", + ]) + let briefingRequest = try #require(captured.last) + #expect(briefingRequest.value(forHTTPHeaderField: "Authorization") == "Bearer new") + } + @Test("concurrent 401s share one refresh request") func concurrentUnauthorizedRequestsShareRefresh() async throws { let (client, tokenStore) = makeClient() @@ -309,8 +345,8 @@ struct APIClientTests { } } - @Test("site-root requests do not include a double slash") - func siteRootPathIsNormalized() async throws { + @Test("OAuth token endpoint resolves under the /api prefix on the API host") + func oauthTokenResolvesUnderAPIPrefix() async throws { let (client, _) = makeClient() await StubURLProtocol.set { _ in let payload = """ @@ -323,14 +359,15 @@ struct APIClientTests { let captured = await StubURLProtocol.recorded() let request = try #require(captured.first) - #expect(request.url?.path == "/oauth/token") + #expect(request.url?.host == "test.spark.cronx.co") + #expect(request.url?.path == "/api/oauth/token") } - @Test("site-root requests use oauth host when base URL has a trailing slash") - func siteRootUsesOAuthHost() async throws { + @Test("OAuth base ignores a trailing slash on the mobile API base URL") + func oauthBaseHandlesTrailingSlash() async throws { let environment = APIEnvironment( baseURL: URL(string: "https://api.spark.cronx.co/api/v1/mobile/")!, - oauthAuthorizeURL: URL(string: "https://auth.spark.cronx.co/oauth/authorize")!, + oauthAuthorizeURL: URL(string: "https://api.spark.cronx.co/oauth/authorize")!, name: "test" ) let (client, _) = makeClient(environment: environment) @@ -346,8 +383,31 @@ struct APIClientTests { let captured = await StubURLProtocol.recorded() let request = try #require(captured.first) - #expect(request.url?.host == "auth.spark.cronx.co") - #expect(request.url?.path == "/oauth/token") + #expect(request.url?.host == "api.spark.cronx.co") + #expect(request.url?.path == "/api/oauth/token") + } + + @Test("OAuth base honours a LAN override (http host with a port)") + func oauthBaseHandlesLANOverride() async throws { + let environment = APIEnvironment( + baseURL: URL(string: "http://192.168.1.42:8000/api/v1/mobile")!, + oauthAuthorizeURL: URL(string: "http://192.168.1.42:8000/oauth/authorize")!, + name: "lan" + ) + let (client, _) = makeClient(environment: environment) + + await StubURLProtocol.set { _ in + let payload = """ + {"token_type":"Bearer","access_token":"new","refresh_token":"r-2","expires_in":3600} + """.data(using: .utf8)! + return (payload, 200, [:]) + } + + _ = try await client.requestSiteRoot(AuthEndpoint.exchange(code: "abc", verifier: "verifier")) + + let captured = await StubURLProtocol.recorded() + let request = try #require(captured.first) + #expect(request.url?.absoluteString == "http://192.168.1.42:8000/api/oauth/token") } @Test("telemetry captures request and response metadata with redacted credentials") diff --git a/Packages/SparkKit/Tests/SparkKitTests/CheckInsEndpointTests.swift b/Packages/SparkKit/Tests/SparkKitTests/CheckInsEndpointTests.swift new file mode 100644 index 0000000..1adc959 --- /dev/null +++ b/Packages/SparkKit/Tests/SparkKitTests/CheckInsEndpointTests.swift @@ -0,0 +1,34 @@ +import Foundation +import Testing +@testable import SparkKit + +@Suite("Check-in endpoints") +struct CheckInsEndpointTests { + @Test("submit endpoint encodes retrospective timestamp") + func submitEndpointEncodesOccurredAt() throws { + let occurredAt = try #require(ISO8601DateFormatter().date(from: "2026-05-04T08:00:00Z")) + let endpoint = CheckInsEndpoint.submit(CheckInRequest( + period: .morning, + physical: 4, + mental: 5, + date: "2026-05-04", + occurredAt: occurredAt, + latitude: 51.5, + longitude: -0.1, + address: "London", + notes: "Good start" + )) + + let body = try #require(endpoint.body) + let object = try JSONSerialization.jsonObject(with: body) as? [String: Any] + + #expect(endpoint.method == .post) + #expect(endpoint.path == "/check-ins") + #expect(endpoint.contentType == "application/json") + #expect(object?["period"] as? String == "morning") + #expect(object?["date"] as? String == "2026-05-04") + #expect(object?["occurred_at"] as? String == "2026-05-04T08:00:00Z") + #expect(object?["physical"] as? Int == 4) + #expect(object?["mental"] as? Int == 5) + } +} diff --git a/Packages/SparkKit/Tests/SparkKitTests/DeepLinkTests.swift b/Packages/SparkKit/Tests/SparkKitTests/DeepLinkTests.swift index e06a026..4fe140f 100644 --- a/Packages/SparkKit/Tests/SparkKitTests/DeepLinkTests.swift +++ b/Packages/SparkKit/Tests/SparkKitTests/DeepLinkTests.swift @@ -109,4 +109,16 @@ struct DeepLinkTests { let url = try #require(URL(string: "https://spark.cronx.co/integrations/monzo")) #expect(DeepLink.parse(url) == nil) } + + @Test("parses /tags/:name") + func tagPlural() throws { + let url = try #require(URL(string: "https://spark.cronx.co/tags/Alice")) + #expect(DeepLink.parse(url) == .tag(name: "Alice")) + } + + @Test("parses /tag/:name (singular)") + func tagSingular() throws { + let url = try #require(URL(string: "https://spark.cronx.co/tag/coffee")) + #expect(DeepLink.parse(url) == .tag(name: "coffee")) + } } diff --git a/Packages/SparkKit/Tests/SparkKitTests/HealthDashboardEndpointTests.swift b/Packages/SparkKit/Tests/SparkKitTests/HealthDashboardEndpointTests.swift new file mode 100644 index 0000000..a5a5a2c --- /dev/null +++ b/Packages/SparkKit/Tests/SparkKitTests/HealthDashboardEndpointTests.swift @@ -0,0 +1,172 @@ +import Foundation +import Testing +@testable import SparkKit + +@Suite("Health dashboard endpoint") +struct HealthDashboardEndpointTests { + @Test("dashboard endpoint carries date and range") + func dashboardEndpoint() throws { + let endpoint = HealthEndpoint.dashboard(date: "2026-05-18", range: .thirtyDays) + + #expect(endpoint.method == .get) + #expect(endpoint.path == "/health/dashboard") + #expect(try #require(endpoint.query.first { $0.name == "date" }).value == "2026-05-18") + #expect(try #require(endpoint.query.first { $0.name == "range" }).value == "30d") + } + + @Test("dashboard decodes representative payload") + func dashboardDecodesPayload() throws { + let json = """ + { + "date": "2026-05-18", + "timezone": "Europe/London", + "range": "7d", + "generated_at": "2026-05-18T19:30:00+00:00", + "sync_status": { + "apple_health": { + "event_count": 28, + "last_event_time": "2026-05-18T16:39:00+00:00", + "coverage": "partial" + } + }, + "hero": { + "score": 58, + "kind": "readiness", + "status": "low", + "title": "Take a lighter day", + "subtitle": "Readiness is 26% below baseline.", + "primary_event_id": "evt_readiness", + "factors": [ + {"label": "Resting Heart Rate", "value": 13, "unit": "percent", "status": "low"} + ] + }, + "fitness": { + "today": { + "steps": {"value": 7411, "unit": "steps", "vs_baseline_pct": -14.4}, + "distance": {"value": 6.119, "unit": "km", "vs_baseline_pct": 6.8}, + "active_energy": {"value": 606.878, "unit": "kcal", "vs_baseline_pct": 1.2}, + "exercise": {"value": 68, "unit": "min", "vs_baseline_pct": -2.2}, + "stand": {"value": 8, "unit": "hours", "vs_baseline_pct": -8.5}, + "workout_count": 2, + "workout_duration_seconds": 3218, + "workout_energy_kcal": 365, + "strength_volume": {"value": 5330, "unit": "kg"} + }, + "workouts": [ + { + "event_id": "evt_run", + "source": "apple_health", + "kind": "cardio", + "type": "Run", + "title": "Run", + "start": "2026-05-18T10:22:54+00:00", + "end": "2026-05-18T10:37:01+00:00", + "duration_seconds": 846.921, + "energy_kcal": 135.695, + "distance": {"value": 1.976, "unit": "km"}, + "intensity": {"value": 9.498, "unit": "kcal/hr·kg"}, + "route_available": true + }, + { + "event_id": "evt_hevy", + "source": "hevy", + "kind": "strength", + "title": "Legs", + "start": "2026-05-18T09:37:49+00:00", + "duration_seconds": 0, + "volume": {"value": 5330, "unit": "kg"}, + "exercises": [ + {"name": "Leg Press (Machine)", "sets": 4, "volume": {"value": 4200, "unit": "kg"}} + ] + } + ] + }, + "body_metrics": [ + { + "id": "apple_health.had_heart_rate_variability.ms", + "event_id": "evt_hrv", + "label": "HRV", + "value": 44.503, + "unit": "ms", + "vs_baseline_pct": -16, + "is_anomaly": false, + "status": "normal" + } + ], + "trends": [ + { + "metric": "apple_health.had_step_count.steps", + "label": "Steps", + "service": "apple_health", + "action": "had_step_count", + "unit": "steps", + "range": {"from": "2026-05-12", "to": "2026-05-18"}, + "daily_values": [{"date": "2026-05-18", "value": 7411, "vs_baseline_pct": -14.4, "is_anomaly": false}], + "summary": {"min": 7411, "max": 7411, "mean": 7411, "data_points": 1, "trend_direction": "stable"}, + "baseline": {"mean": 8658, "stddev": 1200, "normal_lower": 6258, "normal_upper": 11058, "sample_days": 60} + } + ], + "insights": [ + { + "block_id": "block_1", + "event_id": "evt_flint", + "title": "Recovery note", + "content": "Prioritise recovery today.", + "time": "2026-05-18T12:01:00+00:00" + } + ] + } + """ + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let dashboard = try decoder.decode(HealthDashboard.self, from: Data(json.utf8)) + + #expect(dashboard.date == "2026-05-18") + #expect(dashboard.syncStatus["apple_health"]?.coverage == "partial") + #expect(dashboard.hero?.factors.first?.label == "Resting Heart Rate") + #expect(dashboard.fitness.today.steps?.value == 7411) + #expect(dashboard.fitness.workouts[0].routeAvailable == true) + #expect(dashboard.fitness.workouts[1].exercises?.first?.sets == 4) + #expect(dashboard.bodyMetrics.first?.label == "HRV") + #expect(dashboard.trends.first?.dailyValues.first?.value == 7411) + #expect(dashboard.insights.first?.content == "Prioritise recovery today.") + } + + @Test("dashboard decodes empty arrays and null hero") + func dashboardDecodesEmptyPayload() throws { + let json = """ + { + "date": "2026-05-18", + "timezone": "Europe/London", + "range": "7d", + "generated_at": "2026-05-18T19:30:00+00:00", + "sync_status": {}, + "hero": null, + "fitness": { + "today": { + "workout_count": 0, + "workout_duration_seconds": 0, + "workout_energy_kcal": 0 + }, + "workouts": [] + }, + "body_metrics": [], + "trends": [], + "insights": [] + } + """ + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let dashboard = try decoder.decode(HealthDashboard.self, from: Data(json.utf8)) + + #expect(dashboard.hero == nil) + #expect(dashboard.fitness.workouts.isEmpty) + #expect(dashboard.bodyMetrics.isEmpty) + #expect(dashboard.trends.isEmpty) + #expect(dashboard.insights.isEmpty) + } +} diff --git a/Packages/SparkKit/Tests/SparkKitTests/SearchResponseDecodingTests.swift b/Packages/SparkKit/Tests/SparkKitTests/SearchResponseDecodingTests.swift index 7e1bf0c..35ab598 100644 --- a/Packages/SparkKit/Tests/SparkKitTests/SearchResponseDecodingTests.swift +++ b/Packages/SparkKit/Tests/SparkKitTests/SearchResponseDecodingTests.swift @@ -4,6 +4,16 @@ import Testing @Suite("Search response decoding") struct SearchResponseDecodingTests { + @Test("tag search mode uses backend singular query value") + func tagSearchModeQueryValue() throws { + let endpoint = SearchEndpoint.query(text: "Hannah Waddingham", mode: .tags) + + let mode = try #require(endpoint.query.first { $0.name == "mode" }) + #expect(mode.value == "tag") + #expect(SearchEndpoint.Mode.tags.label == "Tags") + #expect(SearchEndpoint.Mode.tags.symbol == "#") + } + @Test("decodes top-level array payload") func decodesArrayPayload() throws { let json = """ @@ -118,4 +128,41 @@ struct SearchResponseDecodingTests { Issue.record("Expected a metric hit at index 3.") } } + + @Test("decodes tag hits from flat and grouped search payloads") + func decodesTagHits() throws { + let flatJSON = """ + [ + { "kind": "tag", "name": "Alice", "type": "spark_person", "title": "Alice", "count": 3 } + ] + """ + + let flat = try JSONDecoder().decode(SearchResponse.self, from: Data(flatJSON.utf8)) + if case .tag(let hit) = try #require(flat.results.first) { + #expect(hit.name == "Alice") + #expect(hit.type == "spark_person") + #expect(hit.subtitle == "3 items") + } else { + Issue.record("Expected a tag hit.") + } + + let groupedJSON = """ + { + "mode": "tags", + "query": "coffee", + "tags": [ + { "name": "coffee", "type": "merchant_category", "results_count": 1 } + ] + } + """ + + let grouped = try JSONDecoder().decode(SearchResponse.self, from: Data(groupedJSON.utf8)) + if case .tag(let hit) = try #require(grouped.results.first) { + #expect(hit.name == "coffee") + #expect(hit.title == "coffee") + #expect(hit.subtitle == "1 item") + } else { + Issue.record("Expected a grouped tag hit.") + } + } } diff --git a/Packages/SparkSync/Sources/SparkSync/ReverbClient.swift b/Packages/SparkSync/Sources/SparkSync/ReverbClient.swift index ed99072..00bcff4 100644 --- a/Packages/SparkSync/Sources/SparkSync/ReverbClient.swift +++ b/Packages/SparkSync/Sources/SparkSync/ReverbClient.swift @@ -32,7 +32,7 @@ public actor ReverbClient { // MARK: - Private state private let environment: APIEnvironment - private let tokenStore: KeychainTokenStore + private let apiClient: APIClient private let session: URLSession private let logger = Logger(subsystem: "co.cronx.sparkapp", category: "ReverbClient") @@ -55,11 +55,16 @@ public actor ReverbClient { public init( environment: APIEnvironment = .current(), tokenStore: KeychainTokenStore, - session: URLSession = .shared + session: URLSession = .shared, + apiClient: APIClient? = nil ) { self.environment = environment - self.tokenStore = tokenStore self.session = session + self.apiClient = apiClient ?? APIClient( + environment: environment, + session: session, + tokenStore: tokenStore + ) } // MARK: - Public API @@ -230,46 +235,65 @@ public actor ReverbClient { } private func fetchChannelAuth(channel: String, socketId: String) async -> String? { - guard let token = await tokenStore.accessToken() else { return nil } - var components = URLComponents(url: environment.baseURL, resolvingAgainstBaseURL: false)! components.path = "/broadcasting/auth" components.queryItems = nil let authURL = components.url! - var request = URLRequest(url: authURL) - request.httpMethod = "POST" - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") - - request.httpBody = "channel_name=\(channel)&socket_id=\(socketId)".data(using: .utf8) - - let startedAt = Date() - do { - let (data, response) = try await URLSession.shared.data(for: request) - let http = response as? HTTPURLResponse - await captureAuthTelemetry( - request: request, - response: http, - data: data, - startedAt: startedAt, - outcome: http?.statusCode == 200 ? .success : .httpError - ) - guard http?.statusCode == 200 else { return nil } - let authResponse = try JSONDecoder().decode(AuthResponse.self, from: data) - return authResponse.auth - } catch { - logger.error("Reverb auth request failed: \(error, privacy: .public)") - await captureAuthTelemetry( - request: request, - response: nil, - data: nil, - startedAt: startedAt, - outcome: .transportError, - errorDescription: String(describing: error) - ) - return nil + + for attempt in 1...2 { + let forceRefresh = attempt > 1 + guard let token = try? await apiClient.accessTokenRefreshingIfNeeded(forceRefresh: forceRefresh) else { + return nil + } + + var request = URLRequest(url: authURL) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = formURLEncodedBody([ + "channel_name": channel, + "socket_id": socketId, + ]) + + let startedAt = Date() + do { + let (data, response) = try await session.data(for: request) + let http = response as? HTTPURLResponse + await captureAuthTelemetry( + request: request, + response: http, + data: data, + startedAt: startedAt, + outcome: http?.statusCode == 200 ? .success : .httpError + ) + if http?.statusCode == 401, attempt == 1 { + continue + } + guard http?.statusCode == 200 else { return nil } + let authResponse = try JSONDecoder().decode(AuthResponse.self, from: data) + return authResponse.auth + } catch { + logger.error("Reverb auth request failed: \(error, privacy: .public)") + await captureAuthTelemetry( + request: request, + response: nil, + data: nil, + startedAt: startedAt, + outcome: .transportError, + errorDescription: String(describing: error) + ) + return nil + } } + + return nil + } + + private func formURLEncodedBody(_ values: [String: String]) -> Data? { + var components = URLComponents() + components.queryItems = values.map { URLQueryItem(name: $0.key, value: $0.value) } + return components.percentEncodedQuery?.data(using: .utf8) } private func captureAuthTelemetry( diff --git a/Packages/SparkUI/Sources/SparkUI/Components/SparkRichContentText.swift b/Packages/SparkUI/Sources/SparkUI/Components/SparkRichContentText.swift index 534bf66..798972d 100644 --- a/Packages/SparkUI/Sources/SparkUI/Components/SparkRichContentText.swift +++ b/Packages/SparkUI/Sources/SparkUI/Components/SparkRichContentText.swift @@ -48,7 +48,7 @@ public struct SparkRichContentText: View { for run in attributed.runs where run.link != nil { guard let url = run.link, isRecognised(url) else { continue } attributed[run.range].foregroundColor = linkTint - attributed[run.range].font = SparkTypography.bodyStrong + attributed[run.range].font = font.weight(.semibold) attributed[run.range].underlineStyle = nil } diff --git a/Packages/SparkUI/Sources/SparkUI/Components/TagChip.swift b/Packages/SparkUI/Sources/SparkUI/Components/TagChip.swift index 594ce2d..bfda58c 100644 --- a/Packages/SparkUI/Sources/SparkUI/Components/TagChip.swift +++ b/Packages/SparkUI/Sources/SparkUI/Components/TagChip.swift @@ -1,23 +1,173 @@ +import SparkKit import SwiftUI -/// Small `#tag` style chip used in detail views and the tag editor. Ghost -/// variant carries dashed outline for "add" affordances. +// MARK: - EventTag display helpers + +public struct SparkTagPresentation { + public enum Kind: Sendable, Equatable { + case person + case place + case topic + case unknownTyped + case untyped + } + + public let kind: Kind + public let label: String? + public let tint: Color + + public static func resolve(name: String, type: String?) -> SparkTagPresentation { + guard let rawType = type?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawType.isEmpty + else { + return SparkTagPresentation(kind: .untyped, label: nil, tint: Color.primary.opacity(0.55)) + } + + let normalized = rawType.lowercased() + let tokens = Set(Self.tokens(from: normalized)) + + if Self.matches(tokens: tokens, normalized: normalized, terms: personTerms) { + return SparkTagPresentation(kind: .person, label: "Person", tint: .sparkTagPerson) + } + if Self.matches(tokens: tokens, normalized: normalized, terms: topicTerms) { + return SparkTagPresentation(kind: .topic, label: Self.humanized(rawType), tint: .sparkTagTopic) + } + if Self.matches(tokens: tokens, normalized: normalized, terms: placeTerms) { + return SparkTagPresentation(kind: .place, label: "Place", tint: .sparkTagPlace) + } + + return SparkTagPresentation( + kind: .unknownTyped, + label: Self.humanized(rawType), + tint: Self.stableTint(seed: "\(normalized):\(name.lowercased())") + ) + } + + private static let personTerms = [ + "person", "people", "user", "contact", "human", "profile", "friend", "colleague", + ] + + private static let placeTerms = [ + "place", "location", "venue", "merchant", "store", "restaurant", "address", "geo", + ] + + private static let topicTerms = [ + "topic", "category", "domain", "interest", "theme", "label", + ] + + private static func matches(tokens: Set, normalized: String, terms: [String]) -> Bool { + terms.contains { term in + tokens.contains(term) || normalized.contains(term) + } + } + + private static func tokens(from value: String) -> [String] { + let separated = value.unicodeScalars.map { scalar in + CharacterSet.alphanumerics.contains(scalar) ? Character(scalar) : " " + } + return String(separated).split(separator: " ").map(String.init) + } + + private static func humanized(_ value: String) -> String { + let words = tokens(from: value) + guard !words.isEmpty else { return value.capitalized } + return words + .map { $0.prefix(1).uppercased() + $0.dropFirst() } + .joined(separator: " ") + } + + private static func stableTint(seed: String) -> Color { + let palette: [Color] = [.sparkAccent, .sparkOcean, .sparkWarning, .sparkSuccess, .sparkTagPerson, .sparkTagTopic] + let checksum = seed.unicodeScalars.reduce(0) { partial, scalar in + (partial &* 31 &+ Int(scalar.value)) & 0x7fffffff + } + return palette[checksum % palette.count] + } +} + +public extension EventTag { + var tagPresentation: SparkTagPresentation { + SparkTagPresentation.resolve(name: name, type: type) + } + + var tagTint: Color { + tagPresentation.tint + } + + var tagTypeLabel: String? { + tagPresentation.label + } +} + +// MARK: - TagChip + +/// Tag chip supporting both typed `EventTag` (colour-coded, no `#`) and a +/// legacy plain-string variant (keeps `#` prefix and ghost affordance) for +/// non-tag surfaces like `ApiTokensView`. public struct TagChip: View { - public let text: String - public let isGhost: Bool + private enum Content { + case typed(EventTag, onTap: (() -> Void)?) + case plain(String, isGhost: Bool) + } + private let content: Content + + /// Colour-coded chip for a typed `EventTag`. Omit `onTap` for display-only. + public init(_ tag: EventTag, onTap: (() -> Void)? = nil) { + self.content = .typed(tag, onTap: onTap) + } + + /// Legacy plain-string chip. Preserves `#` prefix and ghost variant. public init(_ text: String, isGhost: Bool = false) { - self.text = text - self.isGhost = isGhost + self.content = .plain(text, isGhost: isGhost) } public var body: some View { + switch content { + case .typed(let tag, let onTap): + typedBody(tag: tag, onTap: onTap) + case .plain(let text, let isGhost): + legacyBody(text: text, isGhost: isGhost) + } + } + + @ViewBuilder + private func typedBody(tag: EventTag, onTap: (() -> Void)?) -> some View { + let tint = tag.tagTint + let chip = Text(tag.name) + .font(SparkTypography.captionStrong) + .foregroundStyle(tint) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: 160, alignment: .leading) + .padding(.horizontal, SparkSpacing.md - 2) + .padding(.vertical, SparkSpacing.xs + 1) + .sparkGlass(.capsule, tint: tint.opacity(0.15)) + .accessibilityLabel(accessibilityLabel(for: tag)) + .accessibilityAddTraits(onTap != nil ? .isButton : []) + + if let onTap { + Button(action: onTap) { chip } + .buttonStyle(.plain) + } else { + chip + } + } + + private func accessibilityLabel(for tag: EventTag) -> String { + if let label = tag.tagTypeLabel { + return "Tag \(tag.name), \(label)" + } + return "Tag \(tag.name)" + } + + private func legacyBody(text: String, isGhost: Bool) -> some View { Text(isGhost ? text : "#\(text)") .font(SparkTypography.monoSmall) .foregroundStyle(.primary) .padding(.horizontal, SparkSpacing.md - 2) .padding(.vertical, SparkSpacing.xs + 1) - .background(background) + .background(isGhost ? Color.clear : Color.primary.opacity(0.06)) .clipShape(.capsule) .overlay { if isGhost { @@ -28,30 +178,42 @@ public struct TagChip: View { } .accessibilityLabel(isGhost ? "Add tag" : "Tag \(text)") } - - @ViewBuilder - private var background: some View { - if isGhost { - Color.clear - } else { - Color.primary.opacity(0.06) - } - } } +// MARK: - TagChipRow + /// A flowing chip cluster that wraps tags onto multiple lines. +/// +/// Two overloads: +/// - `[EventTag]` — colour-coded chips with optional tap and overflow truncation. +/// - `[String]` — legacy plain chips for non-tag surfaces (unchanged behaviour). public struct TagChipRow: View { - public let tags: [String] - public let allowAdd: Bool - public let onAdd: (() -> Void)? + private enum Mode { + case strings([String], allowAdd: Bool, onAdd: (() -> Void)?) + case tags([EventTag], maxVisible: Int, onTap: ((EventTag) -> Void)?) + } + + private let mode: Mode + @State private var expanded = false public init(_ tags: [String], allowAdd: Bool = false, onAdd: (() -> Void)? = nil) { - self.tags = tags - self.allowAdd = allowAdd - self.onAdd = onAdd + self.mode = .strings(tags, allowAdd: allowAdd, onAdd: onAdd) + } + + public init(_ tags: [EventTag], maxVisible: Int = 6, onTap: ((EventTag) -> Void)? = nil) { + self.mode = .tags(tags, maxVisible: maxVisible, onTap: onTap) } public var body: some View { + switch mode { + case .strings(let tags, let allowAdd, let onAdd): + stringBody(tags: tags, allowAdd: allowAdd, onAdd: onAdd) + case .tags(let tags, let maxVisible, let onTap): + tagBody(tags: tags, maxVisible: maxVisible, onTap: onTap) + } + } + + private func stringBody(tags: [String], allowAdd: Bool, onAdd: (() -> Void)?) -> some View { FlowLayout(spacing: SparkSpacing.xs + 2) { ForEach(tags, id: \.self) { TagChip($0) } if allowAdd { @@ -63,10 +225,37 @@ public struct TagChipRow: View { } } } + + private func tagBody(tags: [EventTag], maxVisible: Int, onTap: ((EventTag) -> Void)?) -> some View { + let visible = expanded ? tags : Array(tags.prefix(maxVisible)) + let overflow = tags.count - maxVisible + + return FlowLayout(spacing: SparkSpacing.xs + 2) { + ForEach(visible) { tag in + TagChip(tag, onTap: onTap.map { handler in { handler(tag) } }) + } + if !expanded && overflow > 0 { + Button { + expanded = true + } label: { + Text("+\(overflow) more") + .font(SparkTypography.captionStrong) + .foregroundStyle(.secondary) + .padding(.horizontal, SparkSpacing.md - 2) + .padding(.vertical, SparkSpacing.xs + 1) + .sparkGlass(.capsule, tint: Color.primary.opacity(0.06)) + } + .buttonStyle(.plain) + .accessibilityLabel("Show \(overflow) more tags") + } + } + } } +// MARK: - FlowLayout + /// Minimal flow layout for chip rows. Wraps to next line when the current -/// line fills. Avoids dragging in a heavier external layout helper. +/// line fills. public struct FlowLayout: Layout { public let spacing: CGFloat diff --git a/Packages/SparkUI/Sources/SparkUI/Theme/Color+Spark.swift b/Packages/SparkUI/Sources/SparkUI/Theme/Color+Spark.swift index 166d69b..fca5a37 100644 --- a/Packages/SparkUI/Sources/SparkUI/Theme/Color+Spark.swift +++ b/Packages/SparkUI/Sources/SparkUI/Theme/Color+Spark.swift @@ -87,6 +87,17 @@ public extension Color { static let domainAnomaly = Color.sparkWarning } +// MARK: - Tag type tints +// +// Colour-codes EventTag.type in chip display. Reuses existing semantic tokens +// where they fit the semantic (place → success green). + +public extension Color { + static let sparkTagPerson: Color = .purple + static let sparkTagPlace: Color = .sparkSuccess + static let sparkTagTopic: Color = .orange +} + // MARK: - Surfaces (light/dark adaptive) public extension Color { diff --git a/Packages/SparkUI/Tests/SparkUITests/SparkUITests.swift b/Packages/SparkUI/Tests/SparkUITests/SparkUITests.swift index bac3fa2..e646a29 100644 --- a/Packages/SparkUI/Tests/SparkUITests/SparkUITests.swift +++ b/Packages/SparkUI/Tests/SparkUITests/SparkUITests.swift @@ -1,5 +1,6 @@ import Testing import SwiftUI +import SparkKit @testable import SparkUI @Suite("Spark app background phase") @@ -85,3 +86,31 @@ struct SparkLongFormContentParsingTests { #expect(blocks == [.paragraph("This has **emphasis** but remains one paragraph.")]) } } + +@Suite("Tag presentation") +struct SparkTagPresentationTests { + @Test("wildcard type matching classifies people") + func wildcardPersonTypes() { + #expect(EventTag(name: "Alice", type: "spark_person").tagPresentation.kind == .person) + #expect(EventTag(name: "u/example", type: "reddit_user").tagPresentation.kind == .person) + #expect(EventTag(name: "Will", type: "email_contact").tagPresentation.kind == .person) + } + + @Test("wildcard type matching classifies places and topics") + func wildcardPlaceAndTopicTypes() { + #expect(EventTag(name: "Prufrock", type: "merchant_category").tagPresentation.kind == .topic) + #expect(EventTag(name: "London", type: "geo_place").tagPresentation.kind == .place) + #expect(EventTag(name: "Swift", type: "spark_topic").tagPresentation.kind == .topic) + } + + @Test("unknown typed tags stay typed and legacy strings stay neutral") + func unknownAndUntypedTags() { + let unknown = EventTag(name: "Inbox", type: "custom_bucket").tagPresentation + #expect(unknown.kind == .unknownTyped) + #expect(unknown.label == "Custom Bucket") + + let untyped = EventTag(name: "news").tagPresentation + #expect(untyped.kind == .untyped) + #expect(untyped.label == nil) + } +} diff --git a/SparkApp/Sources/App/AppModel.swift b/SparkApp/Sources/App/AppModel.swift index 8c5b252..79d17b7 100644 --- a/SparkApp/Sources/App/AppModel.swift +++ b/SparkApp/Sources/App/AppModel.swift @@ -23,6 +23,7 @@ enum AppRoute: Hashable { case place(id: String) case integration(service: String) case account(id: String) + case tag(name: String, type: String?) } @MainActor diff --git a/SparkApp/Sources/App/DetailRouteNavigation.swift b/SparkApp/Sources/App/DetailRouteNavigation.swift index 4f4fcd2..91dc41f 100644 --- a/SparkApp/Sources/App/DetailRouteNavigation.swift +++ b/SparkApp/Sources/App/DetailRouteNavigation.swift @@ -67,6 +67,7 @@ extension DeepLink { case .metric(let identifier): .metric(identifier: identifier) case .place(let id): .place(id: id) case .integration(let service): .integration(service: service) + case .tag(let name): .tag(name: name, type: nil) case .today, .day, .authCallback: nil } } @@ -92,6 +93,8 @@ extension View { IntegrationDetailView(integrationId: service) case .account(let id): AccountDetailView(accountId: id) + case .tag(let name, let type): + TagDetailView(tagName: name, tagType: type) } } } diff --git a/SparkApp/Sources/App/RootView.swift b/SparkApp/Sources/App/RootView.swift index 750f79f..902b337 100644 --- a/SparkApp/Sources/App/RootView.swift +++ b/SparkApp/Sources/App/RootView.swift @@ -56,6 +56,8 @@ struct RootView: View { model.pendingRoute = .place(id: id) case .integration(let service): model.pendingRoute = .integration(service: service) + case .tag(let name): + model.pendingRoute = .tag(name: name, type: nil) } } } diff --git a/SparkApp/Sources/App/TabAccessoryCoordinator.swift b/SparkApp/Sources/App/TabAccessoryCoordinator.swift index f96527a..3c14772 100644 --- a/SparkApp/Sources/App/TabAccessoryCoordinator.swift +++ b/SparkApp/Sources/App/TabAccessoryCoordinator.swift @@ -101,8 +101,11 @@ struct TabAccessoryView: View { } } .pickerStyle(.segmented) - .controlSize(.small) - .frame(minWidth: 260) + .controlSize(.regular) + .frame(width: inlineSegmentedWidth) + .fixedSize(horizontal: true, vertical: true) + .padding(.horizontal, 10) + .padding(.vertical, 8) .accessibilityLabel(accessory.title) } @@ -130,4 +133,8 @@ struct TabAccessoryView: View { private var selectedTitle: String { accessory.items.first { $0.id == accessory.selectedID }?.title ?? accessory.title } + + private var inlineSegmentedWidth: CGFloat { + CGFloat(accessory.items.count) * 96 + } } diff --git a/SparkApp/Sources/CheckIn/CheckInHistoryView.swift b/SparkApp/Sources/CheckIn/CheckInHistoryView.swift index a772ef2..e089abd 100644 --- a/SparkApp/Sources/CheckIn/CheckInHistoryView.swift +++ b/SparkApp/Sources/CheckIn/CheckInHistoryView.swift @@ -4,10 +4,16 @@ import SwiftData import SwiftUI struct CheckInHistoryView: View { + let apiClient: APIClient + let container: ModelContainer + @Environment(\.dismiss) private var dismiss @State private var historyVM: CheckInHistoryViewModel + @State private var selectedCheckIn: CheckInHistorySelection? init(apiClient: APIClient, container: ModelContainer, todayViewModel: TodayViewModel) { + self.apiClient = apiClient + self.container = container _historyVM = State(initialValue: CheckInHistoryViewModel(apiClient: apiClient, container: container)) } @@ -18,7 +24,7 @@ struct CheckInHistoryView: View { ScrollView { VStack(alignment: .leading, spacing: SparkSpacing.lg) { - streakHeader + overview daysList } .padding(.horizontal, SparkSpacing.lg) @@ -34,32 +40,60 @@ struct CheckInHistoryView: View { .accessibilityLabel("Close") } } + .sheet(item: $selectedCheckIn, onDismiss: { + Task { await historyVM.load() } + }) { selection in + CheckInHistoryLogSheet( + selection: selection, + apiClient: apiClient, + container: container + ) + } .task { await historyVM.load() } } } - // MARK: - Streak header - - private var streakHeader: some View { + private var overview: some View { GlassCard { - HStack { - VStack(alignment: .leading, spacing: SparkSpacing.xs) { - Text("\(historyVM.streakCount)") - .font(.custom(SparkFonts.displayPostScriptName, size: 40, relativeTo: .largeTitle)) - .foregroundStyle(Color.sparkAccent) - Text("day streak") - .font(SparkTypography.body) + VStack(alignment: .leading, spacing: SparkSpacing.md) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: SparkSpacing.xs) { + Text("Last 28 days") + .font(SparkTypography.bodyStrong) + .foregroundStyle(.primary) + Text("\(historyVM.streakCount) day streak") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + Spacer() + Text("\(loggedPeriodCount)/56") + .font(SparkTypography.monoSmall) + .bold() .foregroundStyle(.secondary) } - Spacer() - Image(systemName: "flame.fill") - .font(.system(size: 36)) - .foregroundStyle(historyVM.streakCount > 0 ? Color.sparkAccent : Color.secondary.opacity(0.4)) + + CheckInHeatmap(days: heatmapDays) } } } - // MARK: - Days list + private var loggedPeriodCount: Int { + historyVM.days.reduce(0) { total, day in + total + (day.morning.completed ? 1 : 0) + (day.afternoon.completed ? 1 : 0) + } + } + + private var heatmapDays: [CheckInHeatmapDay] { + historyVM.days.reversed().map { day in + CheckInHeatmapDay( + id: day.date, + date: day.date, + label: Self.dayNumberLabel(day.date), + morningScore: day.morning.combined, + afternoonScore: day.afternoon.combined + ) + } + } @ViewBuilder private var daysList: some View { @@ -77,17 +111,77 @@ struct CheckInHistoryView: View { } else { LazyVStack(spacing: SparkSpacing.sm) { ForEach(historyVM.days, id: \.date) { day in - CheckInHistoryDayRow(day: day) + CheckInHistoryDayRow(day: day) { date, period in + selectedCheckIn = CheckInHistorySelection(date: date, period: period) + } } } } } + + private static func dayNumberLabel(_ key: String) -> String { + guard let date = Self.dateParser.date(from: key) else { return key } + return String(Calendar.current.component(.day, from: date)) + } + + fileprivate static let dateParser: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + return f + }() +} + +private struct CheckInHistorySelection: Identifiable { + let date: Date + let period: CheckInPeriod + + var id: String { + "\(Self.formatter.string(from: date))-\(period.rawValue)" + } + + private static let formatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + return f + }() } -// MARK: - Day row +private struct CheckInHistoryLogSheet: View { + let selection: CheckInHistorySelection + let apiClient: APIClient + let container: ModelContainer + + @State private var viewModel: TodayViewModel? + + var body: some View { + Group { + if let viewModel { + CheckInModalView( + viewModel: viewModel, + date: selection.date, + initialPeriod: selection.period + ) + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(SparkResolvedAppBackground().ignoresSafeArea()) + .task { + let vm = TodayViewModel( + date: selection.date, + apiClient: apiClient, + container: container + ) + await vm.loadCheckIns() + viewModel = vm + } + } + } + } +} private struct CheckInHistoryDayRow: View { let day: CheckInHistoryDay + let onLog: (Date, CheckInPeriod) -> Void private static let formatter: DateFormatter = { let f = DateFormatter() @@ -95,55 +189,63 @@ private struct CheckInHistoryDayRow: View { return f }() + private var date: Date? { + CheckInHistoryView.dateParser.date(from: day.date) + } + private var dateLabel: String { - let parser = DateFormatter() - parser.dateFormat = "yyyy-MM-dd" - if let date = parser.date(from: day.date) { - return Self.formatter.string(from: date) - } - return day.date + date.map { Self.formatter.string(from: $0) } ?? day.date } var body: some View { GlassCard { - HStack { - Text(dateLabel) - .font(SparkTypography.bodySmall) - .foregroundStyle(.primary) - Spacer() - HStack(spacing: SparkSpacing.sm) { - PeriodChip(label: "AM", period: day.morning) - PeriodChip(label: "PM", period: day.afternoon) + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + HStack { + Text(dateLabel) + .font(SparkTypography.bodyStrong) + .foregroundStyle(.primary) + Spacer() + Text(dayScoreLabel) + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .foregroundStyle(dayScore == nil ? Color.secondary : Color.primary) + .monospacedDigit() + } + + VStack(alignment: .leading, spacing: SparkSpacing.xs) { + CheckInPeriodSummaryRow( + title: "Morning", + status: day.morning.periodStatus, + onTap: { log(.morning) } + ) + CheckInPeriodSummaryRow( + title: "Afternoon", + status: day.afternoon.periodStatus, + onTap: { log(.afternoon) } + ) } } } } -} -private struct PeriodChip: View { - let label: String - let period: CheckInHistoryPeriod + private var dayScoreLabel: String { + dayScore.map(String.init) ?? "not logged" + } - var body: some View { - HStack(spacing: 3) { - Text(label) - .font(SparkTypography.monoSmall) - if period.completed, let combined = period.combined { - Text("\(combined)") - .font(SparkTypography.monoSmall) - .bold() - } - } - .foregroundStyle(period.completed ? Color.sparkAccent : .secondary) - .padding(.horizontal, SparkSpacing.sm) - .padding(.vertical, 3) - .background(period.completed ? Color.sparkAccent.opacity(0.12) : Color.primary.opacity(0.05)) - .clipShape(.capsule) - .overlay { - if !period.completed { - Capsule().strokeBorder(Color.secondary.opacity(0.2), lineWidth: 1) - } - } - .accessibilityLabel(period.completed ? "\(label) logged, \(period.combined ?? 0) out of 10" : "\(label) not logged") + private var dayScore: Int? { + let scores = [day.morning.combined, day.afternoon.combined].compactMap(\.self) + guard !scores.isEmpty else { return nil } + return scores.reduce(0, +) + } + + private func log(_ period: CheckInPeriod) { + guard let date else { return } + onLog(date, period) + } +} + +private extension CheckInHistoryPeriod { + var periodStatus: PeriodStatus { + guard completed, let physical, let mental else { return .pending } + return .completed(physical: physical, mental: mental, notes: notes) } } diff --git a/SparkApp/Sources/CheckIn/CheckInHistoryViewModel.swift b/SparkApp/Sources/CheckIn/CheckInHistoryViewModel.swift index 9dcedb4..fd1b1f7 100644 --- a/SparkApp/Sources/CheckIn/CheckInHistoryViewModel.swift +++ b/SparkApp/Sources/CheckIn/CheckInHistoryViewModel.swift @@ -32,8 +32,8 @@ final class CheckInHistoryViewModel { private func loadFromCache() { let context = ModelContext(container) - let thirtyDaysAgo = Calendar.current.date(byAdding: .day, value: -29, to: .now) ?? .now - let fromKey = Self.isoDate(thirtyDaysAgo) + let firstDay = Calendar.current.date(byAdding: .day, value: -27, to: .now) ?? .now + let fromKey = Self.isoDate(firstDay) let toKey = Self.isoDate(.now) let descriptor = FetchDescriptor( predicate: #Predicate { $0.date >= fromKey && $0.date <= toKey }, @@ -46,8 +46,8 @@ final class CheckInHistoryViewModel { } private func fetchFromAPI() async { - let thirtyDaysAgo = Calendar.current.date(byAdding: .day, value: -29, to: .now) ?? .now - let fromKey = Self.isoDate(thirtyDaysAgo) + let firstDay = Calendar.current.date(byAdding: .day, value: -27, to: .now) ?? .now + let fromKey = Self.isoDate(firstDay) let toKey = Self.isoDate(.now) do { let response = try await apiClient.request( @@ -89,11 +89,11 @@ final class CheckInHistoryViewModel { } let calendar = Calendar.current - let thirtyDaysAgo = calendar.date(byAdding: .day, value: -29, to: calendar.startOfDay(for: .now)) ?? .now + let firstDay = calendar.date(byAdding: .day, value: -27, to: calendar.startOfDay(for: .now)) ?? .now var result: [CheckInHistoryDay] = [] - for offset in 0..<30 { - guard let day = calendar.date(byAdding: .day, value: offset, to: thirtyDaysAgo) else { continue } + for offset in 0..<28 { + guard let day = calendar.date(byAdding: .day, value: offset, to: firstDay) else { continue } let key = Self.isoDate(day) let dayRows = grouped[key] ?? [] let morningRow = dayRows.first { $0.period == "morning" } diff --git a/SparkApp/Sources/CheckIn/CheckInModalView.swift b/SparkApp/Sources/CheckIn/CheckInModalView.swift index 9482343..f2f7a2d 100644 --- a/SparkApp/Sources/CheckIn/CheckInModalView.swift +++ b/SparkApp/Sources/CheckIn/CheckInModalView.swift @@ -7,6 +7,7 @@ struct CheckInModalView: View { let viewModel: TodayViewModel let date: Date let initialPeriod: CheckInPeriod + let allowsPeriodSelection: Bool @Environment(\.dismiss) private var dismiss @@ -18,14 +19,21 @@ struct CheckInModalView: View { @State private var isSubmitting = false @State private var submitError: String? = nil - init(viewModel: TodayViewModel, date: Date, initialPeriod: CheckInPeriod) { + init( + viewModel: TodayViewModel, + date: Date, + initialPeriod: CheckInPeriod, + allowsPeriodSelection: Bool = false + ) { self.viewModel = viewModel self.date = date self.initialPeriod = initialPeriod + self.allowsPeriodSelection = allowsPeriodSelection _period = State(initialValue: initialPeriod) } private var otherPeriodAlsoPending: Bool { + guard allowsPeriodSelection else { return false } switch initialPeriod { case .morning: if case .pending = viewModel.checkInDayStatus.afternoon { return true } @@ -199,6 +207,7 @@ struct CheckInModalView: View { physical: phy, mental: men, date: dateKey, + occurredAt: CheckInPresentation.occurredAtOverride(for: date, period: period), latitude: lat, longitude: lng, address: addr, diff --git a/SparkApp/Sources/CheckIn/CheckInPresentation.swift b/SparkApp/Sources/CheckIn/CheckInPresentation.swift new file mode 100644 index 0000000..31dd5bb --- /dev/null +++ b/SparkApp/Sources/CheckIn/CheckInPresentation.swift @@ -0,0 +1,181 @@ +import SparkKit +import SparkUI +import SwiftUI + +enum CheckInPresentation { + static func scoreColor(_ score: Int?) -> Color { + switch score { + case 2: Color(red: 212/255, green: 61/255, blue: 81/255) + case 3: Color(red: 226/255, green: 115/255, blue: 87/255) + case 4: Color(red: 235/255, green: 160/255, blue: 110/255) + case 5: Color(red: 242/255, green: 202/255, blue: 148/255) + case 6: Color(red: 253/255, green: 241/255, blue: 197/255) + case 7: Color(red: 205/255, green: 214/255, blue: 163/255) + case 8: Color(red: 153/255, green: 188/255, blue: 137/255) + case 9: Color(red: 96/255, green: 162/255, blue: 119/255) + case 10: Color(red: 0/255, green: 135/255, blue: 108/255) + default: .secondary + } + } + + static func physicalEmoji(_ value: Int) -> String { + emoji(value, from: ["💀", "😴", "🚶‍♂️", "🏃‍♂️", "💪"]) + } + + static func mentalEmoji(_ value: Int) -> String { + emoji(value, from: ["😭", "🥹", "😕", "😊", "😄"]) + } + + static func retrospectiveTimestamp(for date: Date, period: CheckInPeriod, calendar: Calendar = .current) -> Date? { + var components = calendar.dateComponents([.year, .month, .day], from: date) + components.hour = period == .morning ? 8 : 16 + components.minute = 0 + components.second = 0 + components.calendar = calendar + components.timeZone = calendar.timeZone + return components.date + } + + static func occurredAtOverride(for date: Date, period: CheckInPeriod, now: Date = .now, calendar: Calendar = .current) -> Date? { + let targetDay = calendar.startOfDay(for: date) + let today = calendar.startOfDay(for: now) + guard targetDay < today else { return nil } + return retrospectiveTimestamp(for: date, period: period, calendar: calendar) + } + + private static func emoji(_ value: Int, from emojis: [String]) -> String { + let index = max(0, min(emojis.count - 1, value - 1)) + return emojis[index] + } +} + +struct CheckInPeriodSummaryRow: View { + let title: String + let status: PeriodStatus + var isEnabled = true + let onTap: () -> Void + + var body: some View { + Group { + switch status { + case .pending where isEnabled: + Button(action: onTap) { content } + .buttonStyle(.plain) + default: + content + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + } + + private var content: some View { + HStack(alignment: .center, spacing: SparkSpacing.md) { + Text(title) + .font(SparkTypography.bodySmall) + .foregroundStyle(isEnabled ? Color.primary : Color.secondary.opacity(0.55)) + Spacer(minLength: SparkSpacing.md) + + switch status { + case .pending: + Text(isEnabled ? "tap to log" : "not logged") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + case let .completed(physical, mental, _): + HStack(spacing: SparkSpacing.sm) { + Text(CheckInPresentation.physicalEmoji(physical)) + Text(CheckInPresentation.mentalEmoji(mental)) + Text("\(physical + mental)") + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .foregroundStyle(CheckInPresentation.scoreColor(physical + mental)) + .monospacedDigit() + } + .font(SparkTypography.bodySmall) + } + } + .padding(.vertical, SparkSpacing.xs) + .contentShape(Rectangle()) + } + + private var accessibilityLabel: String { + switch status { + case .pending: + return isEnabled ? "\(title) pending. Tap to log." : "\(title) not logged." + case let .completed(physical, mental, notes): + let base = "\(title) complete. Physical \(physical) of 5, mental \(mental) of 5, total \(physical + mental) of 10." + if let notes, !notes.isEmpty { return "\(base) Note: \(notes)" } + return base + } + } +} + +struct CheckInHeatmapDay: Identifiable { + let id: String + let date: String + let label: String + let morningScore: Int? + let afternoonScore: Int? +} + +struct CheckInHeatmap: View { + let days: [CheckInHeatmapDay] + + var body: some View { + VStack(alignment: .leading, spacing: SparkSpacing.xs) { + heatmapRow(label: "AM", score: \.morningScore) + heatmapRow(label: "PM", score: \.afternoonScore) + dayLabels + } + } + + private func heatmapRow(label: String, score: KeyPath) -> some View { + HStack(spacing: 4) { + Text(label) + .font(.system(size: 9, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 18, alignment: .leading) + ForEach(days) { day in + CheckInHeatmapCell(score: day[keyPath: score]) + .accessibilityLabel("\(label) \(day.label), \(day[keyPath: score].map { "\($0) out of 10" } ?? "not logged")") + } + } + } + + private var dayLabels: some View { + HStack(spacing: 4) { + Color.clear.frame(width: 18, height: 1) + ForEach(Array(days.enumerated()), id: \.element.id) { index, day in + Text(index % 7 == 0 || index == days.count - 1 ? day.label : "") + .font(.system(size: 7, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 8) + } + } + } +} + +struct CheckInHeatmapCell: View { + let score: Int? + + var body: some View { + RoundedRectangle(cornerRadius: 2) + .fill(score.map { CheckInPresentation.scoreColor($0) } ?? Color.clear) + .overlay { + RoundedRectangle(cornerRadius: 2) + .strokeBorder( + score == nil ? Color.secondary.opacity(0.25) : Color.white.opacity(0.18), + lineWidth: 1 + ) + } + .frame(width: 8, height: 8) + } +} + +extension PeriodStatus { + var combinedScore: Int? { + if case let .completed(physical, mental, _) = self { + return physical + mental + } + return nil + } +} diff --git a/SparkApp/Sources/Detail/BlockDetailView.swift b/SparkApp/Sources/Detail/BlockDetailView.swift index 6446d8d..707e482 100644 --- a/SparkApp/Sources/Detail/BlockDetailView.swift +++ b/SparkApp/Sources/Detail/BlockDetailView.swift @@ -7,6 +7,7 @@ import SwiftUI final class BlockDetailViewModel { let blockId: String private(set) var state: DetailLoadState = .loading + private(set) var rawPayload: String? private let apiClient: APIClient @@ -18,8 +19,9 @@ final class BlockDetailViewModel { func load() async { state = .loading do { - let detail = try await apiClient.request(BlocksEndpoint.detail(id: blockId)) - state = .loaded(detail) + let response = try await apiClient.requestWithRawResponse(BlocksEndpoint.detail(id: blockId)) + rawPayload = response.utf8Body + state = .loaded(response.decoded) } catch APIError.notModified { return } catch { @@ -69,6 +71,7 @@ struct BlockDetailView: View { shareItems: blockShareItems, rawTitle: "Raw block", rawPayload: blockRawPayload, + feedbackContext: blockFeedbackContext, refresh: { await viewModel?.load() } ) .task(id: blockId) { @@ -88,10 +91,22 @@ struct BlockDetailView: View { private var blockRawPayload: String? { guard case .loaded(let detail) = viewModel?.state else { return nil } + if let rawPayload = viewModel?.rawPayload { return rawPayload } return SparkPrettyJSON.string(for: detail) ?? SparkPrettyJSON.fallback(entity: "block", id: detail.block.id, title: detail.block.title) } + private var blockFeedbackContext: SparkFeedbackContext { + if case .loaded(let detail) = viewModel?.state { + return SparkFeedbackContext( + entityType: "block", + entityId: detail.block.id, + title: detail.block.title + ) + } + return SparkFeedbackContext(entityType: "block", entityId: blockId, title: blockId) + } + @ViewBuilder private func content(for detail: BlockDetail) -> some View { heroSection(for: detail) diff --git a/SparkApp/Sources/Detail/EventDetailView.swift b/SparkApp/Sources/Detail/EventDetailView.swift index 339c23a..2dc1e62 100644 --- a/SparkApp/Sources/Detail/EventDetailView.swift +++ b/SparkApp/Sources/Detail/EventDetailView.swift @@ -52,6 +52,7 @@ struct EventDetailView: View { shareItems: eventShareItems, rawTitle: "Raw event", rawPayload: eventRawPayload, + feedbackContext: eventFeedbackContext, refresh: { await viewModel?.retry() } ) .task(id: eventId) { @@ -71,7 +72,25 @@ struct EventDetailView: View { } if !detail.tags.isEmpty { - TagChipRow(detail.tags.names) + FlowLayout(spacing: SparkSpacing.xs + 2) { + ForEach(detail.tags) { tag in + let route = DetailRoute.tag(name: tag.name, type: tag.type) + NavigationLink(value: route) { + TagChip(tag) + } + .buttonStyle(.plain) + .contextMenu { + Button { + appModel.pendingRoute = .tag(name: tag.name, type: tag.type) + } label: { + Label("Open Tag", systemImage: "tag") + } + } preview: { + TagPreviewCard(tag: tag) + .environment(appModel) + } + } + } } metricBaselineStatusRow() @@ -390,10 +409,7 @@ struct EventDetailView: View { private var eventRawPayload: String? { guard case .loaded(let detail) = viewModel?.state else { return nil } - if let metadata = detail.metadata, - let json = SparkPrettyJSON.string(for: metadata) { - return json - } + if let rawPayload = viewModel?.rawPayload { return rawPayload } return SparkPrettyJSON.string(for: detail) ?? SparkPrettyJSON.fallback( entity: "event", @@ -402,6 +418,17 @@ struct EventDetailView: View { ) } + private var eventFeedbackContext: SparkFeedbackContext { + if case .loaded(let detail) = viewModel?.state { + return SparkFeedbackContext( + entityType: "event", + entityId: detail.event.id, + title: eventTitle(for: detail.event) + ) + } + return SparkFeedbackContext(entityType: "event", entityId: eventId, title: eventId) + } + private var eventShareItems: [Any] { guard case .loaded(let detail) = viewModel?.state else { return ["Spark Event: \(eventId)"] diff --git a/SparkApp/Sources/Detail/EventDetailViewModel.swift b/SparkApp/Sources/Detail/EventDetailViewModel.swift index 9777d6c..2d71b6b 100644 --- a/SparkApp/Sources/Detail/EventDetailViewModel.swift +++ b/SparkApp/Sources/Detail/EventDetailViewModel.swift @@ -14,6 +14,7 @@ final class EventDetailViewModel { let eventId: String private(set) var state: DetailLoadState = .loading private(set) var metricBaselineStatus: MetricBaselineStatus? + private(set) var rawPayload: String? private let apiClient: APIClient @@ -26,7 +27,9 @@ final class EventDetailViewModel { state = .loading metricBaselineStatus = nil do { - let detail = try await apiClient.request(EventsEndpoint.detail(id: eventId)) + let response = try await apiClient.requestWithRawResponse(EventsEndpoint.detail(id: eventId)) + let detail = response.decoded + rawPayload = response.utf8Body state = .loaded(detail) await loadMetricBaselineStatus(for: detail) } catch APIError.notModified { @@ -45,9 +48,11 @@ final class EventDetailViewModel { func saveNote(_ note: String) async throws { let trimmed = note.trimmingCharacters(in: .whitespacesAndNewlines) - let updated = try await apiClient.request( + let response = try await apiClient.requestWithRawResponse( EventsEndpoint.updateNote(id: eventId, note: trimmed.isEmpty ? nil : trimmed) ) + let updated = response.decoded + rawPayload = response.utf8Body state = .loaded(updated) await loadMetricBaselineStatus(for: updated) } diff --git a/SparkApp/Sources/Detail/MetricDetailView.swift b/SparkApp/Sources/Detail/MetricDetailView.swift index 307385c..1cbb8f0 100644 --- a/SparkApp/Sources/Detail/MetricDetailView.swift +++ b/SparkApp/Sources/Detail/MetricDetailView.swift @@ -9,6 +9,7 @@ final class MetricDetailViewModel { var range: MetricsEndpoint.Range private(set) var state: DetailLoadState = .loading private(set) var recentEvents: [Event] = [] + private(set) var rawPayload: String? private let apiClient: APIClient @@ -27,9 +28,11 @@ final class MetricDetailViewModel { return } do { - let detail = try await apiClient.request( + let response = try await apiClient.requestWithRawResponse( MetricsEndpoint.detail(identifier: canonicalIdentifier, range: range) ) + let detail = response.decoded + rawPayload = response.utf8Body state = .loaded(detail) recentEvents = await fetchRecentEvents(for: detail) } catch APIError.notModified { @@ -200,6 +203,7 @@ struct MetricDetailView: View { shareItems: metricShareItems, rawTitle: "Raw metric", rawPayload: metricRawPayload, + feedbackContext: metricFeedbackContext, refresh: { await viewModel?.load() } ) .task(id: identifier) { @@ -222,10 +226,22 @@ struct MetricDetailView: View { private var metricRawPayload: String? { guard case .loaded(let detail) = viewModel?.state else { return nil } + if let rawPayload = viewModel?.rawPayload { return rawPayload } return SparkPrettyJSON.string(for: detail) ?? SparkPrettyJSON.fallback(entity: "metric", id: detail.id, title: detail.title) } + private var metricFeedbackContext: SparkFeedbackContext { + if case .loaded(let detail) = viewModel?.state { + return SparkFeedbackContext( + entityType: "metric", + entityId: detail.id, + title: detail.title + ) + } + return SparkFeedbackContext(entityType: "metric", entityId: identifier, title: identifier) + } + @ViewBuilder private func content(for detail: MetricDetail) -> some View { heroSection(detail) diff --git a/SparkApp/Sources/Detail/ObjectDetailView.swift b/SparkApp/Sources/Detail/ObjectDetailView.swift index e356684..b60d9f2 100644 --- a/SparkApp/Sources/Detail/ObjectDetailView.swift +++ b/SparkApp/Sources/Detail/ObjectDetailView.swift @@ -7,6 +7,7 @@ import SwiftUI final class ObjectDetailViewModel { let objectId: String private(set) var state: DetailLoadState = .loading + private(set) var rawPayload: String? private let apiClient: APIClient @@ -18,8 +19,9 @@ final class ObjectDetailViewModel { func load() async { state = .loading do { - let detail = try await apiClient.request(ObjectsEndpoint.detail(id: objectId)) - state = .loaded(detail) + let response = try await apiClient.requestWithRawResponse(ObjectsEndpoint.detail(id: objectId)) + rawPayload = response.utf8Body + state = .loaded(response.decoded) } catch APIError.notModified { return } catch { @@ -64,6 +66,7 @@ struct ObjectDetailView: View { shareItems: objectShareItems, rawTitle: "Raw object", rawPayload: objectRawPayload, + feedbackContext: objectFeedbackContext, refresh: { await viewModel?.load() } ) .task(id: objectId) { @@ -86,10 +89,22 @@ struct ObjectDetailView: View { private var objectRawPayload: String? { guard case .loaded(let detail) = viewModel?.state else { return nil } + if let rawPayload = viewModel?.rawPayload { return rawPayload } return SparkPrettyJSON.string(for: detail) ?? SparkPrettyJSON.fallback(entity: "object", id: detail.object.id, title: detail.object.title) } + private var objectFeedbackContext: SparkFeedbackContext { + if case .loaded(let detail) = viewModel?.state { + return SparkFeedbackContext( + entityType: "object", + entityId: detail.object.id, + title: detail.object.title + ) + } + return SparkFeedbackContext(entityType: "object", entityId: objectId, title: objectId) + } + @ViewBuilder private func content(for detail: ObjectDetail) -> some View { heroSection(for: detail) diff --git a/SparkApp/Sources/Detail/PlaceDetailView.swift b/SparkApp/Sources/Detail/PlaceDetailView.swift index ad34df2..ec8df83 100644 --- a/SparkApp/Sources/Detail/PlaceDetailView.swift +++ b/SparkApp/Sources/Detail/PlaceDetailView.swift @@ -9,6 +9,7 @@ import SwiftUI final class PlaceDetailViewModel { let placeId: String private(set) var state: DetailLoadState = .loading + private(set) var rawPayload: String? private let apiClient: APIClient @@ -20,8 +21,9 @@ final class PlaceDetailViewModel { func load() async { state = .loading do { - let detail = try await apiClient.request(PlacesEndpoint.detail(id: placeId)) - state = .loaded(detail) + let response = try await apiClient.requestWithRawResponse(PlacesEndpoint.detail(id: placeId)) + rawPayload = response.utf8Body + state = .loaded(response.decoded) } catch APIError.notModified { return } catch { @@ -64,6 +66,7 @@ struct PlaceDetailView: View { shareItems: placeShareItems, rawTitle: "Raw place", rawPayload: placeRawPayload, + feedbackContext: placeFeedbackContext, refresh: { await viewModel?.load() } ) .task(id: placeId) { @@ -83,10 +86,22 @@ struct PlaceDetailView: View { private var placeRawPayload: String? { guard case .loaded(let detail) = viewModel?.state else { return nil } + if let rawPayload = viewModel?.rawPayload { return rawPayload } return SparkPrettyJSON.string(for: detail) ?? SparkPrettyJSON.fallback(entity: "place", id: detail.place.id, title: detail.place.title) } + private var placeFeedbackContext: SparkFeedbackContext { + if case .loaded(let detail) = viewModel?.state { + return SparkFeedbackContext( + entityType: "place", + entityId: detail.place.id, + title: detail.place.title + ) + } + return SparkFeedbackContext(entityType: "place", entityId: placeId, title: placeId) + } + @ViewBuilder private func content(for detail: PlaceDetail) -> some View { heroCard(for: detail) diff --git a/SparkApp/Sources/Detail/TagDetailView.swift b/SparkApp/Sources/Detail/TagDetailView.swift new file mode 100644 index 0000000..adc7579 --- /dev/null +++ b/SparkApp/Sources/Detail/TagDetailView.swift @@ -0,0 +1,215 @@ +import SparkKit +import SparkUI +import SwiftUI + +struct TagDetailView: View { + let tagName: String + let tagType: String? + + @Environment(AppModel.self) private var appModel + @State private var results: [SearchResult] = [] + @State private var isLoading = true + @State private var errorMessage: String? + + private var tag: EventTag { EventTag(name: tagName, type: tagType) } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: SparkSpacing.lg) { + headerSection + resultsSection + } + .padding(.horizontal, SparkSpacing.lg) + .padding(.top, SparkSpacing.xxl) + .padding(.bottom, SparkSpacing.xl) + } + .sparkAppBackground() + .navigationBarTitleDisplayMode(.inline) + .task(id: tagName) { + await load() + } + } + + // MARK: - Header + + private var headerSection: some View { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + Text(tagName) + .font(SparkFonts.display(.largeTitle, weight: .bold)) + .foregroundStyle(.primary) + .accessibilityAddTraits(.isHeader) + + HStack(spacing: SparkSpacing.sm) { + if let label = tag.tagTypeLabel { + Text(label) + .font(SparkTypography.captionStrong) + .foregroundStyle(tag.tagTint) + .padding(.horizontal, SparkSpacing.md - 2) + .padding(.vertical, SparkSpacing.xs + 1) + .sparkGlass(.capsule, tint: tag.tagTint.opacity(0.15)) + } + + if !isLoading, !results.isEmpty { + Text("\(results.count) item\(results.count == 1 ? "" : "s")") + .font(SparkTypography.captionStrong) + .foregroundStyle(.secondary) + } + } + } + } + + // MARK: - Results + + @ViewBuilder + private var resultsSection: some View { + if isLoading { + LoadingShimmerCard() + LoadingShimmerCard() + LoadingShimmerCard() + } else if let errorMessage { + EmptyState( + systemImage: "exclamationmark.triangle.fill", + title: "Couldn't load", + message: errorMessage, + actionTitle: "Retry" + ) { Task { await load() } } + } else if results.isEmpty { + EmptyState( + systemImage: "tag", + title: "No items tagged", + message: "Nothing tagged \"\(tagName)\" yet." + ) + } else { + LazyVStack(spacing: SparkSpacing.sm) { + ForEach(results) { result in + if let route = detailRoute(for: result) { + NavigationLink(value: route) { + SearchResultRow(result: result) + } + .buttonStyle(.plain) + } else { + SearchResultRow(result: result) + } + } + } + } + } + + // MARK: - Helpers + + private func detailRoute(for result: SearchResult) -> DetailRoute? { + switch result { + case .event(let h): .event(id: h.id) + case .object(let h): .object(id: h.id) + case .block(let h): .block(id: h.id) + case .metric(let h): .metric(identifier: h.identifier) + case .integration(let h): .integration(service: h.id) + case .place(let h): .place(id: h.id) + case .tag(let h): .tag(name: h.name, type: h.type) + case .intent: nil + } + } + + private func load() async { + isLoading = true + errorMessage = nil + do { + let response = try await appModel.apiClient.request( + SearchEndpoint.query(text: tagName) + ) + results = response.results.filter(\.isTagDetailItem) + } catch APIError.notModified { + // No change — keep existing results + } catch { + SparkObservability.captureHandled(error) + errorMessage = (error as? LocalizedError)?.errorDescription + ?? "Couldn't load items for this tag." + } + isLoading = false + } +} + +// MARK: - Preview card for long-press peek + +/// Compact tag identity card shown in context-menu previews. +struct TagPreviewCard: View { + let tag: EventTag + + @Environment(AppModel.self) private var appModel + @State private var previewResults: [SearchResult] = [] + @State private var loaded = false + + var body: some View { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + VStack(alignment: .leading, spacing: SparkSpacing.xs) { + Text(tag.name) + .font(SparkFonts.display(.title2, weight: .bold)) + .foregroundStyle(.primary) + + if let label = tag.tagTypeLabel { + Text(label) + .font(SparkTypography.captionStrong) + .foregroundStyle(tag.tagTint) + .padding(.horizontal, SparkSpacing.sm) + .padding(.vertical, 3) + .sparkGlass(.capsule, tint: tag.tagTint.opacity(0.15)) + } + } + + if loaded { + if previewResults.isEmpty { + Text("No items tagged yet.") + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: SparkSpacing.xs) { + ForEach(previewResults.prefix(3)) { result in + HStack(spacing: SparkSpacing.sm) { + Circle() + .fill(tag.tagTint.opacity(0.3)) + .frame(width: 6, height: 6) + Text(result.title) + .font(SparkTypography.bodySmall) + .foregroundStyle(.primary) + .lineLimit(1) + } + } + + if previewResults.count > 3 { + Text("+\(previewResults.count - 3) more") + .font(SparkTypography.captionStrong) + .foregroundStyle(.secondary) + } + } + } + } else { + HStack(spacing: SparkSpacing.sm) { + ProgressView() + .scaleEffect(0.7) + Text("Loading…") + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + } + } + } + .padding(SparkSpacing.lg) + .frame(width: 260, alignment: .leading) + .sparkAppBackground() + .task(id: tag.name) { + guard !loaded else { return } + if let response = try? await appModel.apiClient.request( + SearchEndpoint.query(text: tag.name) + ) { + previewResults = response.results.filter(\.isTagDetailItem) + } + loaded = true + } + } +} + +private extension SearchResult { + var isTagDetailItem: Bool { + if case .tag = self { return false } + return true + } +} diff --git a/SparkApp/Sources/Explore/ExploreView.swift b/SparkApp/Sources/Explore/ExploreView.swift index 853c4bc..bce3484 100644 --- a/SparkApp/Sources/Explore/ExploreView.swift +++ b/SparkApp/Sources/Explore/ExploreView.swift @@ -3,7 +3,7 @@ import SwiftUI struct ExploreView: View { @Environment(\.tabAccessoryCoordinator) private var tabAccessoryCoordinator - @State private var section: ExploreSection = .map + @State private var section: ExploreSection = .health var body: some View { currentSectionView @@ -50,7 +50,7 @@ struct ExploreView: View { } enum ExploreSection: CaseIterable, Equatable { - case map, health, metrics, money + case health, money, metrics, map var id: String { switch self { diff --git a/SparkApp/Sources/Explore/HealthExploreView.swift b/SparkApp/Sources/Explore/HealthExploreView.swift index 20f9edd..12282a0 100644 --- a/SparkApp/Sources/Explore/HealthExploreView.swift +++ b/SparkApp/Sources/Explore/HealthExploreView.swift @@ -5,21 +5,9 @@ import SwiftUI struct HealthExploreView: View { @Environment(AppModel.self) private var appModel - @Environment(\.colorScheme) private var colorScheme @State private var viewModel: HealthExploreViewModel? @State private var path: [DetailRoute] = [] - private static let categories: [HealthMetricCategory] = [ - .init(title: "Sleep Score", icon: "moon.zzz.fill", tint: .sparkOcean, identifier: "oura.sleep_score"), - .init(title: "Heart Rate", icon: "heart.fill", tint: .domainHealth, identifier: "oura.heart_rate"), - .init(title: "HRV", icon: "waveform.path.ecg", tint: .domainHealth, identifier: "oura.hrv"), - .init(title: "Steps", icon: "figure.walk", tint: .domainActivity, identifier: "oura.steps"), - .init(title: "Calories", icon: "flame.fill", tint: .domainActivity, identifier: "oura.calories"), - ] - - private var heroCategory: HealthMetricCategory { Self.categories[0] } - private var rowCategories: [HealthMetricCategory] { Array(Self.categories.dropFirst()) } - var body: some View { NavigationStack(path: $path) { ScrollView { @@ -27,27 +15,19 @@ struct HealthExploreView: View { pageHeader .padding(.horizontal, SparkSpacing.lg) - heroHealthCard - .padding(.horizontal, SparkSpacing.lg) + if let vm = viewModel { + rangePicker(vm) + .padding(.horizontal, SparkSpacing.lg) + } - metricRows - .padding(.horizontal, SparkSpacing.lg) + content } .padding(.top, SparkSpacing.md) .padding(.bottom, SparkSpacing.xl) } .sparkAppBackground() .sparkMainNavigationTitle("Health") - .navigationDestination(for: DetailRoute.self) { route in - switch route { - case .metric(let identifier): - MetricDetailView(identifier: identifier) - case .event(let id): - EventDetailView(eventId: id) - default: - EmptyView() - } - } + .sparkDetailDestinations() .refreshable { await viewModel?.refresh() } @@ -66,298 +46,815 @@ struct HealthExploreView: View { } @ViewBuilder - private var heroHealthCard: some View { - let category = heroCategory - GlassCard(radius: 22, padding: SparkSpacing.xl) { - VStack(alignment: .leading, spacing: SparkSpacing.md) { - HStack(alignment: .top, spacing: SparkSpacing.sm) { - VStack(alignment: .leading, spacing: SparkSpacing.xs) { - HStack(spacing: SparkSpacing.sm) { - Image(systemName: category.icon) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(category.tint) - Text(category.title) + private var content: some View { + if let vm = viewModel { + switch vm.loadState { + case .idle, .loading: + if let dashboard = vm.dashboard { + dashboardContent(dashboard) + } else { + loadingContent + .padding(.horizontal, SparkSpacing.lg) + } + case .error(let message): + if let dashboard = vm.dashboard { + dashboardContent(dashboard) + } else { + EmptyState( + systemImage: "exclamationmark.triangle.fill", + title: "Couldn't load health", + message: message, + actionTitle: "Retry" + ) { Task { await vm.refresh() } } + .padding(.horizontal, SparkSpacing.lg) + } + case .loaded: + if let dashboard = vm.dashboard { + dashboardContent(dashboard) + } else { + EmptyState( + systemImage: "heart.text.square.fill", + title: "No health data", + message: "Connected health signals will appear here once Spark receives them." + ) + .padding(.horizontal, SparkSpacing.lg) + } + } + } else { + loadingContent + .padding(.horizontal, SparkSpacing.lg) + } + } + + private func dashboardContent(_ dashboard: HealthDashboard) -> some View { + VStack(alignment: .leading, spacing: SparkSpacing.lg) { + heroSection(dashboard) + .padding(.horizontal, SparkSpacing.lg) + + fitnessSummary(dashboard.fitness.today) + .padding(.horizontal, SparkSpacing.lg) + + if !dashboard.fitness.workouts.isEmpty { + workoutsSection(dashboard.fitness.workouts) + .padding(.horizontal, SparkSpacing.lg) + } + + if !dashboard.bodyMetrics.isEmpty { + bodyMetricsSection(dashboard.bodyMetrics) + .padding(.horizontal, SparkSpacing.lg) + } + + if !dashboard.trends.isEmpty { + trendsSection(dashboard.trends) + .padding(.horizontal, SparkSpacing.lg) + } + + if !dashboard.insights.isEmpty { + insightsSection(dashboard.insights) + .padding(.horizontal, SparkSpacing.lg) + } + + if let vm = viewModel, !vm.rawFeedEntries.isEmpty { + RawFeedJSONView(entries: vm.rawFeedEntries) + .padding(.horizontal, SparkSpacing.lg) + } + } + } + + private func rangePicker(_ vm: HealthExploreViewModel) -> some View { + HStack(spacing: SparkSpacing.xs) { + ForEach(HealthExploreViewModel.DashboardRange.allCases, id: \.self) { range in + Button { + Task { await vm.selectRange(range) } + } label: { + Text(range.label) + .font(SparkTypography.monoSmall) + .fontWeight(.semibold) + .foregroundStyle(vm.selectedRange == range ? Color.sparkTextPrimary : .secondary) + .frame(minWidth: 42) + .padding(.vertical, SparkSpacing.xs + 2) + .sparkGlass( + .capsule, + tint: vm.selectedRange == range ? Color.domainHealth.opacity(0.22) : Color.primary.opacity(0.04) + ) + } + .buttonStyle(.plain) + .accessibilityLabel("\(range.label) range") + } + + Spacer(minLength: 0) + } + } + + @ViewBuilder + private func heroSection(_ dashboard: HealthDashboard) -> some View { + if let hero = dashboard.hero { + let tint = color(forStatus: hero.status) + GlassCard(radius: 22, padding: SparkSpacing.xl, tint: tint.opacity(0.08)) { + VStack(alignment: .leading, spacing: SparkSpacing.lg) { + HStack(alignment: .top, spacing: SparkSpacing.md) { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + Label(hero.kind.replacingOccurrences(of: "_", with: " ").capitalized, systemImage: icon(forHeroKind: hero.kind)) .font(SparkTypography.bodyStrong) - .foregroundStyle(headerTextColor) + .foregroundStyle(tint) + + Text(hero.title) + .font(SparkFonts.display(.title2, weight: .bold)) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) + + Text(hero.subtitle) + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } - if let detail = viewModel?.snapshots[category.identifier] { - VStack(alignment: .leading, spacing: SparkSpacing.sm) { - if let today = detail.today { - Text(formatValue(today, unit: detail.unit)) - .font(SparkFonts.display(.largeTitle, weight: .bold)) - .foregroundStyle(category.tint) - .lineLimit(1) - .minimumScaleFactor(0.75) - } - if let delta = delta(for: detail) { - deltaChip(delta, suffix: "vs 30-day avg") - } + Spacer(minLength: SparkSpacing.sm) + + VStack(spacing: SparkSpacing.xs) { + if let score = hero.score { + Text("\(score)") + .font(.system(size: 54, weight: .bold, design: .rounded)) + .foregroundStyle(tint) + .lineLimit(1) + .minimumScaleFactor(0.65) + Text(statusLabel(hero.status)) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } else { + Image(systemName: "heart.text.square.fill") + .font(.system(size: 34, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 62, height: 62) + .sparkGlass(.circle, tint: tint.opacity(0.18)) } - } else { - LoadingShimmerCard() - .frame(width: 140, height: 74) } + .frame(minWidth: 72) } - Spacer(minLength: SparkSpacing.md) - - Image(systemName: "heart.text.square.fill") - .font(.system(size: 24, weight: .semibold)) - .foregroundStyle(category.tint) - .frame(width: 48, height: 48) - .background { - Circle().fill(category.tint.opacity(0.12)) + if !hero.factors.isEmpty { + FlowLayout(spacing: SparkSpacing.xs) { + ForEach(hero.factors) { factor in + factorChip(factor) + } } + } } + .contentShape(RoundedRectangle(cornerRadius: 22)) + } + .onTapGesture { + if let id = hero.primaryEventId { + path.append(.event(id: id)) + } + } + } else { + GlassCard(radius: 22, padding: SparkSpacing.xl, tint: Color.domainHealth.opacity(0.06)) { + HStack(spacing: SparkSpacing.md) { + Image(systemName: "heart.text.square.fill") + .font(.system(size: 28, weight: .semibold)) + .foregroundStyle(Color.domainHealth) + .frame(width: 54, height: 54) + .sparkGlass(.circle, tint: Color.domainHealth.opacity(0.16)) - if let detail = viewModel?.snapshots[category.identifier] { - SparklineMiniChart(series: Array(detail.series.suffix(14)), tint: category.tint) - .frame(maxWidth: .infinity) - .frame(height: 96) - } else { - LoadingShimmerCard() - .frame(height: 96) + VStack(alignment: .leading, spacing: SparkSpacing.xs) { + Text("Health signals are steady") + .font(SparkTypography.bodyStrong) + .foregroundStyle(.primary) + Text("Readiness will appear here when Spark has current recovery data.") + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } } } } - .contentShape(RoundedRectangle(cornerRadius: 22)) - .onTapGesture { - path.append(.metric(identifier: category.identifier)) + } + + private func factorChip(_ factor: HealthDashboard.Hero.Factor) -> some View { + HStack(spacing: SparkSpacing.xs) { + Text(factor.label) + .lineLimit(1) + if let value = factor.value { + Text(formatSigned(value, unit: factor.unit)) + .font(SparkTypography.monoSmall) + .fontWeight(.semibold) + } } + .font(SparkTypography.caption) + .foregroundStyle(color(forStatus: factor.status)) + .padding(.horizontal, SparkSpacing.sm) + .padding(.vertical, SparkSpacing.xs + 1) + .sparkGlass(.capsule, tint: color(forStatus: factor.status).opacity(0.16)) } - private var metricRows: some View { - VStack(spacing: SparkSpacing.sm) { - ForEach(rowCategories) { category in - Button { - path.append(.metric(identifier: category.identifier)) - } label: { - HealthMetricRow( - category: category, - detail: viewModel?.snapshots[category.identifier], - isLoading: isLoadingMetrics + private func fitnessSummary(_ today: HealthDashboard.Today) -> some View { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + sectionHeader("Today", icon: "figure.run", tint: Color.domainActivity) + + LazyVGrid(columns: metricColumns, spacing: SparkSpacing.sm) { + if let steps = today.steps { + fitnessTile("Steps", icon: "figure.walk", quantity: steps, tint: Color.domainActivity) + } + if let distance = today.distance { + fitnessTile("Distance", icon: "map.fill", quantity: distance, tint: Color.sparkOcean) + } + if let activeEnergy = today.activeEnergy { + fitnessTile("Active", icon: "flame.fill", quantity: activeEnergy, tint: Color.spark500) + } + if let exercise = today.exercise { + fitnessTile("Exercise", icon: "timer", quantity: exercise, tint: Color.domainHealth) + } + if let stand = today.stand { + fitnessTile("Stand", icon: "arrow.up.circle.fill", quantity: stand, tint: Color.sparkInfo) + } + fitnessTile( + "Workouts", + icon: "bolt.heart.fill", + value: "\(today.workoutCount)", + unit: today.workoutCount == 1 ? "session" : "sessions", + delta: nil, + tint: Color.domainActivity + ) + fitnessTile( + "Duration", + icon: "clock.fill", + value: formatDuration(today.workoutDurationSeconds), + unit: nil, + delta: nil, + tint: Color.sparkOcean + ) + if today.workoutEnergyKcal > 0 { + fitnessTile( + "Workout Energy", + icon: "flame.circle.fill", + value: formatNumber(today.workoutEnergyKcal, unit: "kcal"), + unit: "kcal", + delta: nil, + tint: Color.spark500 ) } - .buttonStyle(.plain) + if let volume = today.strengthVolume { + fitnessTile("Strength", icon: "dumbbell.fill", quantity: volume, tint: Color.domainHealth) + } } } } - @ViewBuilder - private func deltaChip(_ d: (value: Double, isPositive: Bool), suffix: String? = nil) -> some View { - HStack(spacing: 3) { - Image(systemName: d.isPositive ? "arrow.up.right" : "arrow.down.right") - .font(.caption2) - Text(deltaLabel(d.value)) - .font(SparkTypography.monoSmall) - if let suffix { - Text(suffix) + private var metricColumns: [GridItem] { + [GridItem(.flexible(), spacing: SparkSpacing.sm), GridItem(.flexible(), spacing: SparkSpacing.sm)] + } + + private func fitnessTile( + _ title: String, + icon: String, + quantity: HealthDashboard.Quantity, + tint: Color + ) -> some View { + fitnessTile( + title, + icon: icon, + value: formatNumber(quantity.value, unit: quantity.unit), + unit: unitLabel(quantity.unit), + delta: quantity.vsBaselinePct, + tint: tint + ) + } + + private func fitnessTile( + _ title: String, + icon: String, + value: String, + unit: String?, + delta: Double?, + tint: Color + ) -> some View { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + HStack(spacing: SparkSpacing.xs) { + Image(systemName: icon) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(tint) + Text(title) .font(SparkTypography.caption) .foregroundStyle(.secondary) + .lineLimit(1) + } + + HStack(alignment: .firstTextBaseline, spacing: SparkSpacing.xs) { + Text(value) + .font(SparkFonts.display(.title3, weight: .bold)) + .foregroundStyle(.primary) + .lineLimit(1) + .minimumScaleFactor(0.65) + if let unit { + Text(unit) + .font(SparkTypography.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + if let delta { + baselineDelta(delta) } } - .foregroundStyle(d.isPositive ? Color.sparkSuccess : Color.sparkWarning) + .frame(maxWidth: .infinity, minHeight: 94, alignment: .topLeading) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md), tint: tint.opacity(0.08)) } - private func delta(for detail: MetricDetail) -> (value: Double, isPositive: Bool)? { - guard let today = detail.today, let avg = detail.average30d else { return nil } - return (today - avg, today >= avg) - } + private func workoutsSection(_ workouts: [HealthDashboard.Workout]) -> some View { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + sectionHeader("Workouts", icon: "bolt.heart.fill", tint: Color.domainActivity) - private func formatValue(_ v: Double, unit: String?) -> String { - switch unit { - case "score", "bpm", "percent", "ms": - return String(Int(v)) - default: - if v >= 1000 { return String(format: "%.1fk", v / 1000) } - return v.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(v)) : String(format: "%.1f", v) + VStack(spacing: SparkSpacing.sm) { + ForEach(workouts) { workout in + Button { + path.append(.event(id: workout.eventId)) + } label: { + workoutRow(workout) + } + .buttonStyle(.plain) + } + } } } - private func deltaLabel(_ diff: Double) -> String { - let sign = diff >= 0 ? "+" : "" - if abs(diff) >= 1000 { return "\(sign)\(String(format: "%.1fk", diff / 1000))" } - return "\(sign)\(diff.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(diff)) : String(format: "%.1f", diff))" + private func workoutRow(_ workout: HealthDashboard.Workout) -> some View { + GlassCard(radius: SparkRadii.lg, padding: SparkSpacing.md, tint: workoutTint(workout).opacity(0.08)) { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + HStack(spacing: SparkSpacing.md) { + Image(systemName: workout.kind == "strength" ? "dumbbell.fill" : "figure.run") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(workoutTint(workout)) + .frame(width: 42, height: 42) + .sparkGlass(.circle, tint: workoutTint(workout).opacity(0.18)) + + VStack(alignment: .leading, spacing: SparkSpacing.xs) { + Text(workout.title) + .font(SparkTypography.bodyStrong) + .foregroundStyle(.primary) + .lineLimit(1) + Text(workoutSubtitle(workout)) + .font(SparkTypography.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer(minLength: SparkSpacing.sm) + + if workout.routeAvailable == true { + Image(systemName: "map.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Color.sparkOcean) + .accessibilityLabel("Route available") + } + } + + if workout.kind == "strength" { + strengthDetails(workout) + } else { + cardioDetails(workout) + } + } + } } - private var headerTextColor: Color { - colorScheme == .dark ? Color.spark100 : Color.sparkTextPrimary + private func cardioDetails(_ workout: HealthDashboard.Workout) -> some View { + HStack(spacing: SparkSpacing.sm) { + workoutStat("Time", value: formatDuration(workout.durationSeconds)) + if let energy = workout.energyKcal { + workoutStat("Energy", value: "\(formatNumber(energy, unit: "kcal")) kcal") + } + if let distance = workout.distance { + workoutStat("Distance", value: "\(formatNumber(distance.value, unit: distance.unit)) \(distance.unit ?? "")") + } + } } - private var headerSubtitle: String { - switch viewModel?.loadState { - case .loaded: - let count = viewModel?.snapshots.count ?? 0 - return "\(count) connected metric\(count == 1 ? "" : "s")" - case .error: - return "Health data unavailable" - case .loading: - return "Loading health signals" - case .idle, .none: - return "Health signals from your connected sources" + private func strengthDetails(_ workout: HealthDashboard.Workout) -> some View { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + HStack(spacing: SparkSpacing.sm) { + if let volume = workout.volume { + workoutStat("Volume", value: "\(formatNumber(volume.value, unit: volume.unit)) \(volume.unit ?? "")") + } + workoutStat("Time", value: formatDuration(workout.durationSeconds)) + } + + if let exercises = workout.exercises, !exercises.isEmpty { + VStack(alignment: .leading, spacing: SparkSpacing.xs) { + ForEach(exercises.prefix(3)) { exercise in + HStack(spacing: SparkSpacing.xs) { + Text(exercise.name) + .font(SparkTypography.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + Spacer(minLength: SparkSpacing.sm) + Text("\(exercise.sets) sets") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + if let volume = exercise.volume { + Text("\(formatNumber(volume.value, unit: volume.unit)) \(volume.unit ?? "")") + .font(SparkTypography.monoSmall) + .fontWeight(.semibold) + .foregroundStyle(.primary) + } + } + } + } + } } } - private var isLoadingMetrics: Bool { - guard let viewModel else { return true } - if case .loading = viewModel.loadState { return true } - return false + private func workoutStat(_ label: String, value: String) -> some View { + VStack(alignment: .leading, spacing: SparkSpacing.xxs) { + Text(label) + .font(SparkTypography.caption) + .foregroundStyle(.tertiary) + Text(value.trimmingCharacters(in: .whitespacesAndNewlines)) + .font(SparkTypography.monoSmall) + .fontWeight(.semibold) + .foregroundStyle(.primary) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + .frame(maxWidth: .infinity, alignment: .leading) } -} -private struct HealthMetricRow: View { - let category: HealthMetricCategory - let detail: MetricDetail? - let isLoading: Bool + private func bodyMetricsSection(_ metrics: [HealthDashboard.BodyMetric]) -> some View { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + sectionHeader("Body Metrics", icon: "waveform.path.ecg", tint: Color.domainHealth) - private var recentSeries: [MetricDetail.Point] { - Array((detail?.series ?? []).suffix(14)) + LazyVGrid(columns: metricColumns, spacing: SparkSpacing.sm) { + ForEach(metrics) { metric in + Button { + path.append(.metric(identifier: metric.id)) + } label: { + bodyMetricTile(metric) + } + .buttonStyle(.plain) + } + } + } } - var body: some View { - ZStack(alignment: .leading) { - if !recentSeries.isEmpty { - SparklineMiniChart(series: recentSeries, tint: category.tint) - .opacity(0.28) - .frame(maxWidth: .infinity) - .frame(height: 92) - .offset(x: 70, y: 18) - .clipShape(RoundedRectangle(cornerRadius: 20)) - - LinearGradient( - stops: [ - .init(color: Color.sparkElevated.opacity(0.95), location: 0), - .init(color: Color.sparkElevated.opacity(0.72), location: 0.40), - .init(color: Color.sparkElevated.opacity(0), location: 0.75), - ], - startPoint: .leading, - endPoint: .trailing - ) - .frame(height: 92) - .clipShape(RoundedRectangle(cornerRadius: 20)) - } - - HStack(spacing: SparkSpacing.md) { - Image(systemName: category.icon) - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 44, height: 44) - .background( - RoundedRectangle(cornerRadius: SparkRadii.sm) - .fill(category.tint) - ) + private func bodyMetricTile(_ metric: HealthDashboard.BodyMetric) -> some View { + let tint = color(forStatus: metric.status) + return VStack(alignment: .leading, spacing: SparkSpacing.sm) { + HStack(spacing: SparkSpacing.xs) { + Image(systemName: icon(forMetric: metric.label)) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(tint) + Text(metric.label) + .font(SparkTypography.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } - VStack(alignment: .leading, spacing: SparkSpacing.xs) { - Text(category.title) - .font(SparkTypography.bodySmall) - .fontWeight(.semibold) + HStack(alignment: .firstTextBaseline, spacing: SparkSpacing.xs) { + Text(formatNumber(metric.value, unit: metric.unit)) + .font(SparkFonts.display(.title3, weight: .bold)) + .foregroundStyle(.primary) + .lineLimit(1) + .minimumScaleFactor(0.65) + if let unit = unitLabel(metric.unit) { + Text(unit) + .font(SparkTypography.caption) .foregroundStyle(.secondary) .lineLimit(1) + } + } + + HStack(spacing: SparkSpacing.xs) { + Text(statusLabel(metric.status)) + .font(SparkTypography.monoSmall) + .foregroundStyle(tint) + if let delta = metric.vsBaselinePct { + baselineDelta(delta) + } + } + } + .frame(maxWidth: .infinity, minHeight: 96, alignment: .topLeading) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md), tint: tint.opacity(0.08)) + } - if let today = detail?.today { - HStack(alignment: .firstTextBaseline, spacing: SparkSpacing.xs) { - Text(formatValue(today, unit: detail?.unit)) - .font(SparkFonts.display(.title, weight: .bold)) - .foregroundStyle(category.tint) + private func trendsSection(_ trends: [HealthDashboard.Trend]) -> some View { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + sectionHeader("Trends", icon: "chart.line.uptrend.xyaxis", tint: Color.sparkOcean) + + VStack(spacing: SparkSpacing.sm) { + ForEach(trends) { trend in + Button { + path.append(.metric(identifier: trend.metric)) + } label: { + trendCard(trend) + } + .buttonStyle(.plain) + } + } + } + } + + private func trendCard(_ trend: HealthDashboard.Trend) -> some View { + GlassCard(radius: SparkRadii.lg, padding: SparkSpacing.md, tint: trendTint(trend).opacity(0.07)) { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: SparkSpacing.xs) { + Text(trend.label ?? trendTitle(trend)) + .font(SparkTypography.bodyStrong) + .foregroundStyle(.primary) + .lineLimit(1) + if let mean = trend.summary?.mean { + Text("Avg \(formatNumber(mean, unit: trend.unit)) \(trend.unit ?? "")") + .font(SparkTypography.caption) + .foregroundStyle(.secondary) .lineLimit(1) - .minimumScaleFactor(0.75) - if let unit = unitLabel(detail?.unit) { - Text(unit) - .font(SparkTypography.bodySmall) - .foregroundStyle(.secondary) - } } + } + Spacer(minLength: SparkSpacing.sm) + if let latest = trend.dailyValues.last?.value { + Text(formatNumber(latest, unit: trend.unit)) + .font(SparkTypography.monoBody) + .fontWeight(.semibold) + .foregroundStyle(trendTint(trend)) + .lineLimit(1) + } + } - if let delta = delta(for: detail) { - HStack(spacing: 3) { - Image(systemName: delta.isPositive ? "arrow.up.right" : "arrow.down.right") - .font(.caption2) - Text(deltaLabel(delta.value)) - .font(SparkTypography.monoSmall) + DashboardTrendChart(trend: trend, tint: trendTint(trend)) + .frame(height: 76) + } + } + } + + private func insightsSection(_ insights: [HealthDashboard.Insight]) -> some View { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + sectionHeader("Flint Insights", icon: "sparkles", tint: Color.sparkAccent) + + VStack(spacing: SparkSpacing.sm) { + ForEach(insights) { insight in + Button { + path.append(.event(id: insight.eventId)) + } label: { + GlassCard(radius: SparkRadii.lg, padding: SparkSpacing.md, tint: Color.sparkAccent.opacity(0.08)) { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + HStack(spacing: SparkSpacing.xs) { + Image(systemName: "sparkles") + .foregroundStyle(Color.sparkAccent) + Text(insight.title) + .font(SparkTypography.bodyStrong) + .foregroundStyle(.primary) + .lineLimit(1) + Spacer(minLength: SparkSpacing.sm) + Text(timeLabel(insight.time)) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + + if let content = insight.content, !content.isEmpty { + Text(content) + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + .lineLimit(4) + .fixedSize(horizontal: false, vertical: true) + } } - .foregroundStyle(delta.isPositive ? Color.sparkSuccess : Color.sparkWarning) } - } else if isLoading { - Text("--") - .font(SparkTypography.monoBody) - .foregroundStyle(.secondary) } + .buttonStyle(.plain) } + } + } + } + + private var loadingContent: some View { + VStack(alignment: .leading, spacing: SparkSpacing.lg) { + LoadingShimmerCard().frame(height: 220) + LazyVGrid(columns: metricColumns, spacing: SparkSpacing.sm) { + LoadingShimmerCard().frame(height: 96) + LoadingShimmerCard().frame(height: 96) + LoadingShimmerCard().frame(height: 96) + LoadingShimmerCard().frame(height: 96) + } + LoadingShimmerCard().frame(height: 112) + LoadingShimmerCard().frame(height: 168) + } + } + + private func sectionHeader(_ title: String, icon: String, tint: Color) -> some View { + HStack(spacing: SparkSpacing.sm) { + Image(systemName: icon) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(tint) + Text(title) + .font(SparkTypography.bodyStrong) + .foregroundStyle(.primary) + Spacer(minLength: 0) + } + } + + private func baselineDelta(_ value: Double) -> some View { + HStack(spacing: 3) { + Image(systemName: value >= 0 ? "arrow.up.right" : "arrow.down.right") + .font(.caption2) + Text("\(value >= 0 ? "+" : "")\(formatCompact(value))%") + .font(SparkTypography.monoSmall) + } + .foregroundStyle(value >= 0 ? Color.sparkSuccess : Color.sparkWarning) + } - Spacer(minLength: 0) + private var headerSubtitle: String { + guard let vm = viewModel else { return "Daily fitness from your connected sources" } + switch vm.loadState { + case .loaded: + guard let dashboard = vm.dashboard else { return "Loading health signals" } + let workouts = dashboard.fitness.today.workoutCount + let appleHealth = dashboard.syncStatus["apple_health"] + if let last = appleHealth?.lastEventTime { + return "\(workouts) workout\(workouts == 1 ? "" : "s") today - Apple Health \(timeLabel(last))" + } + return "\(workouts) workout\(workouts == 1 ? "" : "s") today" + case .loading: + if let dashboard = vm.dashboard { + let workouts = dashboard.fitness.today.workoutCount + return "\(workouts) workout\(workouts == 1 ? "" : "s") today" } - .padding(.horizontal, SparkSpacing.lg) + return "Loading health signals" + case .error: + return "Health data unavailable" + case .idle: + return "Loading health signals" } - .frame(height: 104) - .sparkGlass(.roundedRect(20)) } - private func delta(for detail: MetricDetail?) -> (value: Double, isPositive: Bool)? { - guard let detail, let today = detail.today, let avg = detail.average30d else { return nil } - return (today - avg, today >= avg) + private func workoutSubtitle(_ workout: HealthDashboard.Workout) -> String { + let source = workout.source == "apple_health" ? "Apple Health" : workout.source.capitalized + return "\(source) - \(timeLabel(workout.start))" } - private func formatValue(_ v: Double, unit: String?) -> String { - switch unit { - case "score", "bpm", "percent", "ms": - return String(Int(v)) - default: - if v >= 1000 { return String(format: "%.1fk", v / 1000) } - return v.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(v)) : String(format: "%.1f", v) + private func workoutTint(_ workout: HealthDashboard.Workout) -> Color { + workout.kind == "strength" ? Color.domainHealth : Color.domainActivity + } + + private func trendTint(_ trend: HealthDashboard.Trend) -> Color { + switch trend.service { + case "oura": Color.sparkOcean + case "hevy": Color.domainHealth + case "apple_health" where trend.action.contains("energy"): Color.spark500 + case "apple_health": Color.domainActivity + default: Color.sparkInfo + } + } + + private func color(forStatus status: String) -> Color { + switch status { + case "critical": Color.sparkError + case "low": Color.sparkWarning + case "high": Color.sparkSuccess + default: Color.domainHealth + } + } + + private func statusLabel(_ status: String) -> String { + status.replacingOccurrences(of: "_", with: " ").uppercased() + } + + private func icon(forHeroKind kind: String) -> String { + switch kind { + case "readiness": "heart.text.square.fill" + case "sleep_score": "moon.zzz.fill" + default: "heart.fill" } } + private func icon(forMetric label: String) -> String { + let lower = label.lowercased() + if lower.contains("sleep") { return "moon.zzz.fill" } + if lower.contains("hrv") { return "waveform.path.ecg" } + if lower.contains("heart") { return "heart.fill" } + if lower.contains("temperature") { return "thermometer.medium" } + if lower.contains("stress") { return "bolt.trianglebadge.exclamationmark.fill" } + if lower.contains("vo2") { return "lungs.fill" } + return "heart.text.square.fill" + } + + private func trendTitle(_ trend: HealthDashboard.Trend) -> String { + let stripped = trend.action.hasPrefix("had_") ? String(trend.action.dropFirst(4)) : trend.action + return stripped.split(separator: "_").map { $0.capitalized }.joined(separator: " ") + } + + private func formatNumber(_ value: Double?, unit: String?) -> String { + guard let value else { return "--" } + if abs(value) >= 1000, unit != "kcal" { + return String(format: "%.1fk", value / 1000) + } + if unit == "steps" || unit == "hours" || unit == "min" || unit == "kcal" { + return String(Int(value.rounded())) + } + if value.truncatingRemainder(dividingBy: 1) == 0 { + return String(Int(value)) + } + return String(format: value < 10 ? "%.2f" : "%.1f", value) + } + + private func formatCompact(_ value: Double) -> String { + if value.truncatingRemainder(dividingBy: 1) == 0 { + return String(Int(value)) + } + return String(format: "%.1f", value) + } + + private func formatSigned(_ value: Double, unit: String?) -> String { + let sign = value >= 0 ? "+" : "" + return "\(sign)\(formatNumber(value, unit: unit))\(unit == "percent" ? "%" : "")" + } + + private func formatDuration(_ seconds: Double) -> String { + let minutes = Int((seconds / 60).rounded()) + if minutes < 60 { return "\(minutes)m" } + return "\(minutes / 60)h \(minutes % 60)m" + } + private func unitLabel(_ unit: String?) -> String? { switch unit { - case "score", "steps", "percent", nil: - return nil + case nil, "steps", "percent": + nil + case "hours": + "h" default: - return unit + unit } } - private func deltaLabel(_ diff: Double) -> String { - let sign = diff >= 0 ? "+" : "" - if abs(diff) >= 1000 { return "\(sign)\(String(format: "%.1fk", diff / 1000))" } - return "\(sign)\(diff.truncatingRemainder(dividingBy: 1) == 0 ? String(Int(diff)) : String(format: "%.1f", diff))" + private func timeLabel(_ date: Date) -> String { + Self.timeFormatter.string(from: date) } -} - -private struct HealthMetricCategory: Identifiable { - let title: String - let icon: String - let tint: Color - let identifier: String - var id: String { identifier } + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter + }() } -private struct SparklineMiniChart: View { - let series: [MetricDetail.Point] +private struct DashboardTrendChart: View { + let trend: HealthDashboard.Trend let tint: Color + private var points: [Point] { + trend.dailyValues.compactMap { value in + guard let date = Self.dateFormatter.date(from: value.date), let y = value.value else { return nil } + return Point(date: date, value: y, isAnomaly: value.isAnomaly == true) + } + } + var body: some View { - Chart(series) { point in - AreaMark( - x: .value("Date", point.date), - y: .value("Value", point.value) - ) - .foregroundStyle( - LinearGradient( - colors: [tint.opacity(0.4), tint.opacity(0)], - startPoint: .top, endPoint: .bottom + Chart { + ForEach(points) { point in + AreaMark( + x: .value("Date", point.date), + y: .value("Value", point.value) + ) + .foregroundStyle( + LinearGradient( + colors: [tint.opacity(0.28), tint.opacity(0)], + startPoint: .top, + endPoint: .bottom + ) ) - ) - LineMark( - x: .value("Date", point.date), - y: .value("Value", point.value) - ) - .foregroundStyle(tint) - .lineStyle(StrokeStyle(lineWidth: 1.5)) + + LineMark( + x: .value("Date", point.date), + y: .value("Value", point.value) + ) + .foregroundStyle(tint) + .lineStyle(StrokeStyle(lineWidth: 1.8, lineCap: .round, lineJoin: .round)) + } + + if let baseline = trend.baseline?.mean { + RuleMark(y: .value("Baseline", baseline)) + .foregroundStyle(.secondary.opacity(0.35)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4])) + } } .chartXAxis(.hidden) .chartYAxis(.hidden) .chartLegend(.hidden) } + + private struct Point: Identifiable { + let date: Date + let value: Double + let isAnomaly: Bool + var id: Date { date } + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC") + return formatter + }() } diff --git a/SparkApp/Sources/Explore/HealthExploreViewModel.swift b/SparkApp/Sources/Explore/HealthExploreViewModel.swift index 273fc3f..ee3547d 100644 --- a/SparkApp/Sources/Explore/HealthExploreViewModel.swift +++ b/SparkApp/Sources/Explore/HealthExploreViewModel.swift @@ -6,18 +6,19 @@ import SparkKit @Observable @MainActor final class HealthExploreViewModel { - private static let identifiers: [String] = [ - "oura.sleep_score", - "oura.heart_rate", - "oura.hrv", - "oura.steps", - "oura.calories", - ] + typealias DashboardRange = HealthEndpoint.DashboardRange - enum LoadState { case idle, loading, loaded, error(String) } + enum LoadState { + case idle + case loading + case loaded + case error(String) + } - private(set) var snapshots: [String: MetricDetail] = [:] + private(set) var dashboard: HealthDashboard? + private(set) var rawFeedEntries: [RawFeedJSONEntry] = [] private(set) var loadState: LoadState = .idle + var selectedRange: DashboardRange = .sevenDays private let apiClient: APIClient private let logger = Logger(subsystem: "co.cronx.sparkapp", category: "HealthExplore") @@ -28,35 +29,37 @@ final class HealthExploreViewModel { func load() async { guard case .idle = loadState else { return } - loadState = .loading - await fetchAll() + await fetchDashboard() } func refresh() async { - snapshots = [:] - loadState = .idle - await fetchAll() + await fetchDashboard() + } + + func selectRange(_ range: DashboardRange) async { + guard selectedRange != range else { return } + selectedRange = range + await fetchDashboard() } - private func fetchAll() async { - await withTaskGroup(of: (String, MetricDetail?).self) { group in - let client = apiClient - for id in Self.identifiers { - group.addTask { - do { - let detail = try await client.request( - MetricsEndpoint.detail(identifier: id, range: .sevenDays) - ) - return (id, detail) - } catch { - return (id, nil) - } - } - } - for await (id, detail) in group { - if let detail { snapshots[id] = detail } - } + private func fetchDashboard() async { + loadState = .loading + + do { + let response = try await apiClient.requestWithRawResponse( + HealthEndpoint.dashboard(date: "today", range: selectedRange) + ) + dashboard = response.decoded + rawFeedEntries = [ + RawFeedJSONEntry(title: "GET /health/dashboard", body: response.utf8Body) + ] + loadState = .loaded + } catch where error.isAPICancellation { + loadState = dashboard == nil ? .idle : .loaded + } catch { + logger.error("Health dashboard failed: \(String(describing: error), privacy: .public)") + let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + loadState = .error(message) } - loadState = .loaded } } diff --git a/SparkApp/Sources/Explore/MetricsExploreView.swift b/SparkApp/Sources/Explore/MetricsExploreView.swift index 48eb026..548a12f 100644 --- a/SparkApp/Sources/Explore/MetricsExploreView.swift +++ b/SparkApp/Sources/Explore/MetricsExploreView.swift @@ -72,6 +72,11 @@ struct MetricsExploreView: View { historySection .padding(.horizontal, SparkSpacing.lg) } + + if let viewModel, case .loaded = viewModel.loadState, !viewModel.rawFeedEntries.isEmpty { + RawFeedJSONView(entries: viewModel.rawFeedEntries) + .padding(.horizontal, SparkSpacing.lg) + } } .padding(.top, SparkSpacing.md) .padding(.bottom, SparkSpacing.xl) diff --git a/SparkApp/Sources/Explore/MetricsExploreViewModel.swift b/SparkApp/Sources/Explore/MetricsExploreViewModel.swift index 057292e..9886100 100644 --- a/SparkApp/Sources/Explore/MetricsExploreViewModel.swift +++ b/SparkApp/Sources/Explore/MetricsExploreViewModel.swift @@ -11,6 +11,7 @@ final class MetricsExploreViewModel { private(set) var snapshots: [String: MetricDetail] = [:] private(set) var metrics: [Metric] = [] + private(set) var rawFeedEntries: [RawFeedJSONEntry] = [] private(set) var loadState: LoadState = .idle private(set) var metadataState: MetadataState = .idle @@ -30,6 +31,7 @@ final class MetricsExploreViewModel { func refresh() async { snapshots = [:] metrics = [] + rawFeedEntries = [] loadState = .idle metadataState = .idle await fetchAll() @@ -38,8 +40,11 @@ final class MetricsExploreViewModel { private func fetchAll() async { loadState = .loading do { - let metrics = try await apiClient.request(MetricsEndpoint.list()) - self.metrics = metrics.filter { $0.eventCount > 0 } + let response = try await apiClient.requestWithRawResponse(MetricsEndpoint.list()) + self.metrics = response.decoded.filter { $0.eventCount > 0 } + rawFeedEntries = [ + RawFeedJSONEntry(title: "GET /metrics", body: response.utf8Body) + ] metadataState = .loaded(MetricsMetadataSummary(metrics: self.metrics)) } catch where error.isAPICancellation { loadState = .idle @@ -53,31 +58,38 @@ final class MetricsExploreViewModel { return } - snapshots = await fetchDetails(identifiers: metrics.map(\.identifier)) + let details = await fetchDetails(identifiers: metrics.map(\.identifier)) + snapshots = details.snapshots + rawFeedEntries.append(contentsOf: details.rawEntries) loadState = .loaded } - private func fetchDetails(identifiers: [String]) async -> [String: MetricDetail] { + private func fetchDetails(identifiers: [String]) async -> (snapshots: [String: MetricDetail], rawEntries: [RawFeedJSONEntry]) { var details: [String: MetricDetail] = [:] - await withTaskGroup(of: (String, MetricDetail?).self) { group in + var rawEntries: [RawFeedJSONEntry] = [] + await withTaskGroup(of: (String, MetricDetail?, String?).self) { group in let client = apiClient for id in identifiers { group.addTask { do { - let detail = try await client.request( + let response = try await client.requestWithRawResponse( MetricsEndpoint.detail(identifier: id, range: .thirtyDays) ) - return (id, detail) + return (id, response.decoded, response.utf8Body) } catch { - return (id, nil) + return (id, nil, nil) } } } - for await (id, detail) in group { + for await (id, detail, rawBody) in group { if let detail { details[id] = detail } + if let rawBody { + rawEntries.append(RawFeedJSONEntry(title: "GET /metrics/\(MetricsEndpoint.canonicalIdentifier(id))?range=30d", body: rawBody)) + } } } - return details + rawEntries.sort { $0.title < $1.title } + return (details, rawEntries) } } diff --git a/SparkApp/Sources/Explore/MoneyExploreView.swift b/SparkApp/Sources/Explore/MoneyExploreView.swift index a961382..0db878d 100644 --- a/SparkApp/Sources/Explore/MoneyExploreView.swift +++ b/SparkApp/Sources/Explore/MoneyExploreView.swift @@ -105,6 +105,11 @@ struct MoneyExploreView: View { accountsSection(vm: vm) .padding(.horizontal, SparkSpacing.lg) + + if !vm.rawFeedEntries.isEmpty { + RawFeedJSONView(entries: vm.rawFeedEntries) + .padding(.horizontal, SparkSpacing.lg) + } } } else { shimmerPlaceholder diff --git a/SparkApp/Sources/Explore/MoneyExploreViewModel.swift b/SparkApp/Sources/Explore/MoneyExploreViewModel.swift index 74d351f..4764ab1 100644 --- a/SparkApp/Sources/Explore/MoneyExploreViewModel.swift +++ b/SparkApp/Sources/Explore/MoneyExploreViewModel.swift @@ -16,6 +16,7 @@ final class MoneyExploreViewModel { private(set) var accounts: [MoneyAccount] = [] private(set) var netWorthHistory: [NetWorthPoint] = [] + private(set) var rawFeedEntries: [RawFeedJSONEntry] = [] private(set) var loadState: LoadState = .idle private(set) var historyState: LoadState = .idle @@ -37,8 +38,11 @@ final class MoneyExploreViewModel { guard case .idle = loadState else { return } loadState = .loading do { - let data = try await apiClient.request(MoneyEndpoint.accounts()) - accounts = data.data + let response = try await apiClient.requestWithRawResponse(MoneyEndpoint.accounts()) + accounts = response.decoded.data + rawFeedEntries = [ + RawFeedJSONEntry(title: "GET /money/accounts", body: response.utf8Body) + ] loadState = .loaded await buildNetWorthHistory() } catch where error.isAPICancellation { @@ -53,6 +57,7 @@ final class MoneyExploreViewModel { func refresh() async { accounts = [] netWorthHistory = [] + rawFeedEntries = [] loadState = .idle historyState = .idle await load() @@ -67,24 +72,31 @@ final class MoneyExploreViewModel { historyState = .loading var allBalances: [String: [BalanceEntry]] = [:] + var rawBalances: [String: String] = [:] let snapAccounts = accounts - await withTaskGroup(of: (String, [BalanceEntry]).self) { group in + await withTaskGroup(of: (String, [BalanceEntry], String?).self) { group in for account in snapAccounts { group.addTask { [apiClient] in do { - let page = try await apiClient.request(MoneyEndpoint.balances(accountId: account.id)) - return (account.id, page.data) + let response = try await apiClient.requestWithRawResponse(MoneyEndpoint.balances(accountId: account.id)) + return (account.id, response.decoded.data, response.utf8Body) } catch { - return (account.id, []) + return (account.id, [], nil) } } } - for await (id, entries) in group { + for await (id, entries, rawBody) in group { allBalances[id] = entries + rawBalances[id] = rawBody } } + rawFeedEntries.append(contentsOf: snapAccounts.compactMap { account in + guard let rawBody = rawBalances[account.id] else { return nil } + return RawFeedJSONEntry(title: "GET /money/accounts/\(account.id)/balances", body: rawBody) + }) + let cal = Calendar.current var dateMap: [Date: [String: Double]] = [:] diff --git a/SparkApp/Sources/Explore/RawFeedJSONView.swift b/SparkApp/Sources/Explore/RawFeedJSONView.swift new file mode 100644 index 0000000..ecef47c --- /dev/null +++ b/SparkApp/Sources/Explore/RawFeedJSONView.swift @@ -0,0 +1,114 @@ +import SparkUI +import SwiftUI +import UIKit + +struct RawFeedJSONView: View { + let title: String + let entries: [RawFeedJSONEntry] + + @State private var isExpanded = false + @State private var didCopy = false + @State private var copiedEntryID: String? + + init(title: String = "Raw feed json", entries: [RawFeedJSONEntry]) { + self.title = title + self.entries = entries + } + + init(title: String, json: String) { + self.title = title + self.entries = [RawFeedJSONEntry(title: title, body: json)] + } + + var body: some View { + GlassCard(radius: SparkRadii.lg, padding: SparkSpacing.lg) { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + Button { + withAnimation(.snappy(duration: 0.2)) { + isExpanded.toggle() + } + } label: { + HStack(spacing: SparkSpacing.sm) { + Image(systemName: "chevron.right") + .font(.caption.weight(.bold)) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + .foregroundStyle(.secondary) + + Text(title) + .font(SparkTypography.bodyStrong) + .foregroundStyle(.primary) + + Spacer(minLength: 0) + + Button { + UIPasteboard.general.string = entries.map(\.body).joined(separator: "\n\n") + didCopy = true + Task { + try? await Task.sleep(for: .seconds(1.4)) + didCopy = false + } + } label: { + Label(didCopy ? "Copied" : entries.count == 1 ? "Copy" : "Copy all", systemImage: didCopy ? "checkmark.circle.fill" : "doc.on.doc") + .font(SparkTypography.captionStrong) + .foregroundStyle(didCopy ? Color.sparkSuccess : Color.sparkTextPrimary) + } + .buttonStyle(.borderless) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if isExpanded { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + ForEach(entries) { entry in + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + HStack(spacing: SparkSpacing.sm) { + Text(entry.title) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + .textCase(.uppercase) + + Spacer(minLength: 0) + + Button { + UIPasteboard.general.string = entry.body + copiedEntryID = entry.id + Task { + try? await Task.sleep(for: .seconds(1.4)) + if copiedEntryID == entry.id { + copiedEntryID = nil + } + } + } label: { + Label(copiedEntryID == entry.id ? "Copied" : "Copy", systemImage: copiedEntryID == entry.id ? "checkmark.circle.fill" : "doc.on.doc") + .font(SparkTypography.captionStrong) + .foregroundStyle(copiedEntryID == entry.id ? Color.sparkSuccess : Color.sparkTextPrimary) + } + .buttonStyle(.borderless) + } + + ScrollView(.horizontal, showsIndicators: true) { + Text(entry.body) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .padding(SparkSpacing.md) + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.black.opacity(0.05), in: RoundedRectangle(cornerRadius: SparkRadii.md)) + } + } + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + } + } +} + +struct RawFeedJSONEntry: Identifiable, Sendable { + let title: String + let body: String + + var id: String { title } +} diff --git a/SparkApp/Sources/Flint/FlintView.swift b/SparkApp/Sources/Flint/FlintView.swift index 13b0a3f..31d7662 100644 --- a/SparkApp/Sources/Flint/FlintView.swift +++ b/SparkApp/Sources/Flint/FlintView.swift @@ -151,14 +151,119 @@ private struct FlintDigestSection: View { .font(SparkTypography.bodySmall) .foregroundStyle(.secondary) } else { - VStack(alignment: .leading, spacing: SparkSpacing.md) { - ForEach(digest.blocks) { block in - FlintBlockRow(block: block, viewModel: viewModel, onOpen: onOpen) + blockRows(insightBlocks) + blockRows(questionBlocks) + } + + FlintDigestCheckInPrompt(digest: digest) + + blockRows(editorialBlocks) + } + } + + private var insightBlocks: [FlintDigestBlock] { + digest.blocks.filter { !$0.isQuestion && $0.blockType != "flint_editorial_note" } + } + + private var questionBlocks: [FlintDigestBlock] { + digest.blocks.filter(\.isQuestion) + } + + private var editorialBlocks: [FlintDigestBlock] { + digest.blocks.filter { $0.blockType == "flint_editorial_note" } + } + + @ViewBuilder + private func blockRows(_ blocks: [FlintDigestBlock]) -> some View { + if !blocks.isEmpty { + VStack(alignment: .leading, spacing: SparkSpacing.md) { + ForEach(blocks) { block in + FlintBlockRow(block: block, viewModel: viewModel, onOpen: onOpen) + } + } + } + } +} + +private struct FlintDigestCheckInPrompt: View { + let digest: FlintDigest + + @Environment(AppModel.self) private var appModel + @State private var checkInViewModel: TodayViewModel? + @State private var showCheckIn = false + + private var checkInPeriod: CheckInPeriod? { + switch digest.period { + case .morning: + return .morning + case .afternoon, .evening: + return .afternoon + case nil: + return nil + } + } + + private var digestDate: Date? { + Self.dateFormatter.date(from: digest.date) + } + + var body: some View { + if let period = checkInPeriod, let digestDate { + GlassCard { + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + HStack { + SectionLabel("CHECK-IN") + Spacer() + Text(period.rawValue.uppercased()) + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) } + + CheckInPeriodSummaryRow( + title: "\(period.rawValue.capitalized) Check-in", + status: status(for: period), + onTap: { showCheckIn = true } + ) } } + .sheet(isPresented: $showCheckIn, onDismiss: { + Task { await checkInViewModel?.loadCheckIns() } + }) { + if let checkInViewModel { + CheckInModalView( + viewModel: checkInViewModel, + date: digestDate, + initialPeriod: period + ) + } + } + .task(id: digest.date) { + let vm = TodayViewModel( + date: digestDate, + apiClient: appModel.apiClient, + container: appModel.container + ) + await vm.loadCheckIns() + checkInViewModel = vm + } } } + + private func status(for period: CheckInPeriod) -> PeriodStatus { + guard let checkInViewModel else { return .pending } + switch period { + case .morning: + return checkInViewModel.checkInDayStatus.morning + case .afternoon: + return checkInViewModel.checkInDayStatus.afternoon + } + } + + private static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + return f + }() } private struct FlintDigestHeader: View { @@ -465,6 +570,7 @@ private struct FlintQuestionContent: View { } .buttonStyle(.plain) .disabled(viewModel.answeringBlockIDs.contains(block.id) || submittedAnswer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .frame(maxWidth: .infinity, alignment: .trailing) } } diff --git a/SparkApp/Sources/Integrations/IntegrationDetailView.swift b/SparkApp/Sources/Integrations/IntegrationDetailView.swift index e64a9a4..d8511bb 100644 --- a/SparkApp/Sources/Integrations/IntegrationDetailView.swift +++ b/SparkApp/Sources/Integrations/IntegrationDetailView.swift @@ -34,6 +34,7 @@ struct IntegrationDetailView: View { shareItems: integrationShareItems, rawTitle: "Raw integration", rawPayload: integrationRawPayload, + feedbackContext: integrationFeedbackContext, refresh: { await viewModel?.load() } ) .task(id: integrationId) { @@ -56,6 +57,7 @@ struct IntegrationDetailView: View { private var integrationRawPayload: String? { guard case .loaded(let detail) = viewModel?.state else { return nil } + if let rawPayload = viewModel?.rawPayload { return rawPayload } return SparkPrettyJSON.string(for: detail) ?? SparkPrettyJSON.fallback( entity: "integration", @@ -64,6 +66,17 @@ struct IntegrationDetailView: View { ) } + private var integrationFeedbackContext: SparkFeedbackContext { + if case .loaded(let detail) = viewModel?.state { + return SparkFeedbackContext( + entityType: "integration", + entityId: detail.integration.id, + title: detail.integration.name + ) + } + return SparkFeedbackContext(entityType: "integration", entityId: integrationId, title: integrationId) + } + @ViewBuilder private func content(for detail: IntegrationDetail) -> some View { heroCard(for: detail) diff --git a/SparkApp/Sources/Integrations/IntegrationDetailViewModel.swift b/SparkApp/Sources/Integrations/IntegrationDetailViewModel.swift index 60aeb2a..ca9f847 100644 --- a/SparkApp/Sources/Integrations/IntegrationDetailViewModel.swift +++ b/SparkApp/Sources/Integrations/IntegrationDetailViewModel.swift @@ -9,6 +9,7 @@ import SparkKit final class IntegrationDetailViewModel { let integrationId: String private(set) var state: DetailLoadState = .loading + private(set) var rawPayload: String? private(set) var actionInProgress: Action? private(set) var lastActionMessage: String? @@ -29,8 +30,9 @@ final class IntegrationDetailViewModel { func load() async { state = .loading do { - let detail = try await apiClient.request(IntegrationsEndpoint.detail(id: integrationId)) - state = .loaded(detail) + let response = try await apiClient.requestWithRawResponse(IntegrationsEndpoint.detail(id: integrationId)) + rawPayload = response.utf8Body + state = .loaded(response.decoded) } catch APIError.notModified { return } catch { diff --git a/SparkApp/Sources/Knowledge/KnowledgeItemDetailView.swift b/SparkApp/Sources/Knowledge/KnowledgeItemDetailView.swift index a65a440..e141818 100644 --- a/SparkApp/Sources/Knowledge/KnowledgeItemDetailView.swift +++ b/SparkApp/Sources/Knowledge/KnowledgeItemDetailView.swift @@ -7,6 +7,7 @@ struct KnowledgeItemDetailView: View { @Environment(AppModel.self) private var appModel @Environment(\.openURL) private var openURL @State private var detailState: KnowledgeDetailState = .loading + @State private var rawPayload: String? @State private var reprocessError: String? private var title: String { event.target?.title ?? event.displayName ?? event.action } @@ -32,7 +33,25 @@ struct KnowledgeItemDetailView: View { case .loaded(let payload): headerSection(payload: payload) if !payload.eventDetail.tags.isEmpty { - TagChipRow(payload.eventDetail.tags.names) + FlowLayout(spacing: SparkSpacing.xs + 2) { + ForEach(payload.eventDetail.tags) { tag in + let route = DetailRoute.tag(name: tag.name, type: tag.type) + NavigationLink(value: route) { + TagChip(tag) + } + .buttonStyle(.plain) + .contextMenu { + Button { + appModel.pendingRoute = .tag(name: tag.name, type: tag.type) + } label: { + Label("Open Tag", systemImage: "tag") + } + } preview: { + TagPreviewCard(tag: tag) + .environment(appModel) + } + } + } } contentCards(for: payload) case .error: @@ -58,6 +77,7 @@ struct KnowledgeItemDetailView: View { shareItems: knowledgeShareItems, rawTitle: "Raw knowledge item", rawPayload: knowledgeRawPayload, + feedbackContext: knowledgeFeedbackContext, refresh: { await loadDetail() }, reprocess: { await reprocessKnowledgeEvent() } ) @@ -82,10 +102,15 @@ struct KnowledgeItemDetailView: View { private var knowledgeRawPayload: String? { guard case .loaded(let payload) = detailState else { return nil } + if let rawPayload { return rawPayload } return SparkPrettyJSON.string(for: payload.eventDetail) ?? SparkPrettyJSON.fallback(entity: "knowledge_item", id: event.id, title: title) } + private var knowledgeFeedbackContext: SparkFeedbackContext { + SparkFeedbackContext(entityType: "knowledge", entityId: event.id, title: title) + } + private var reprocessErrorBinding: Binding { Binding( get: { reprocessError != nil }, @@ -116,10 +141,6 @@ struct KnowledgeItemDetailView: View { } VStack(alignment: .leading, spacing: SparkSpacing.sm) { - Image(systemName: "books.vertical.fill") - .font(.system(size: 40, weight: .light)) - .foregroundStyle(.white.opacity(0.75)) - Text(sourceLabel(payload: payload)) .font(SparkTypography.monoSmall) .foregroundStyle(.white.opacity(0.9)) @@ -149,17 +170,10 @@ struct KnowledgeItemDetailView: View { private func headerSection(payload: KnowledgeDetailPayload?) -> some View { VStack(alignment: .leading, spacing: SparkSpacing.sm) { - HStack(spacing: SparkSpacing.xs) { - Text(sourceLabel(payload: payload)) - .font(SparkTypography.captionStrong) + if let time = event.time { + Text(time.formatted(date: .abbreviated, time: .omitted)) + .font(SparkTypography.caption) .foregroundStyle(.secondary) - if let time = event.time { - Text(" — ") - .foregroundStyle(.secondary) - Text(time.formatted(date: .abbreviated, time: .omitted)) - .font(SparkTypography.caption) - .foregroundStyle(.secondary) - } } Text(title) .font(SparkFonts.display(.title, weight: .bold)) @@ -401,8 +415,11 @@ struct KnowledgeItemDetailView: View { private func loadDetail() async { detailState = .loading + rawPayload = nil do { - let detail = try await appModel.apiClient.request(EventsEndpoint.detail(id: event.id)) + let response = try await appModel.apiClient.requestWithRawResponse(EventsEndpoint.detail(id: event.id)) + let detail = response.decoded + rawPayload = response.utf8Body let objectID = detail.target?.id ?? event.target?.id let objectDetail: ObjectDetail? if let objectID { diff --git a/SparkApp/Sources/Knowledge/KnowledgeView.swift b/SparkApp/Sources/Knowledge/KnowledgeView.swift index 02e7dd0..7cf6fa2 100644 --- a/SparkApp/Sources/Knowledge/KnowledgeView.swift +++ b/SparkApp/Sources/Knowledge/KnowledgeView.swift @@ -6,7 +6,7 @@ struct KnowledgeView: View { @Environment(AppModel.self) private var appModel @Environment(\.tabAccessoryCoordinator) private var tabAccessoryCoordinator @State private var viewModel: KnowledgeViewModel? - @State private var path: [Event] = [] + @State private var path = NavigationPath() var body: some View { NavigationStack(path: $path) { @@ -15,6 +15,7 @@ struct KnowledgeView: View { .navigationDestination(for: Event.self) { event in KnowledgeItemDetailView(event: event) } + .sparkDetailDestinations() .sparkMainAppToolbar() .onAppear { updateFilterAccessory() } .onChange(of: viewModel?.filter) { _, _ in updateFilterAccessory() } diff --git a/SparkApp/Sources/Map/MapView.swift b/SparkApp/Sources/Map/MapView.swift index 9073d1f..cb8af30 100644 --- a/SparkApp/Sources/Map/MapView.swift +++ b/SparkApp/Sources/Map/MapView.swift @@ -33,6 +33,8 @@ struct MapView: View { IntegrationDetailView(integrationId: service) case .account(let id): AccountDetailView(accountId: id) + case .tag(let name, let type): + TagDetailView(tagName: name, tagType: type) } } .sparkMainNavigationTitle("Map") diff --git a/SparkApp/Sources/Notifications/NotificationsInboxView.swift b/SparkApp/Sources/Notifications/NotificationsInboxView.swift index 57e6e58..15f75c3 100644 --- a/SparkApp/Sources/Notifications/NotificationsInboxView.swift +++ b/SparkApp/Sources/Notifications/NotificationsInboxView.swift @@ -30,6 +30,8 @@ struct NotificationsInboxView: View { IntegrationDetailView(integrationId: service) case .account(let id): AccountDetailView(accountId: id) + case .tag(let name, let type): + TagDetailView(tagName: name, tagType: type) } } .toolbar { diff --git a/SparkApp/Sources/Search/SearchResultRow.swift b/SparkApp/Sources/Search/SearchResultRow.swift new file mode 100644 index 0000000..9351f2e --- /dev/null +++ b/SparkApp/Sources/Search/SearchResultRow.swift @@ -0,0 +1,57 @@ +import SparkKit +import SparkUI +import SwiftUI + +struct SearchResultRow: View { + let result: SearchResult + + var body: some View { + HStack(spacing: SparkSpacing.md) { + DomainGlyph(icon: glyph, tint: tint, size: 28) + VStack(alignment: .leading, spacing: 2) { + Text(result.title) + .font(SparkTypography.body) + .lineLimit(1) + if let sub = result.subtitle { + Text(sub) + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.lg), tint: Color.sparkElevated.opacity(0.18)) + .contentShape(Rectangle()) + } + + var glyph: String { + switch result { + case .event: "circle.dotted" + case .object: "shippingbox" + case .block: "square.stack.3d.up" + case .metric: "chart.line.uptrend.xyaxis" + case .integration: "link" + case .place: "mappin.circle.fill" + case .tag: "tag.fill" + case .intent(let h): h.symbol ?? "sparkles" + } + } + + var tint: Color { + switch result { + case .event(let h): h.domain.map(Color.domainTint(for:)) ?? .sparkAccent + case .object: .sparkAccent + case .block: .domainKnowledge + case .metric(let h): h.domain.map(Color.domainTint(for:)) ?? .sparkAccent + case .integration: .sparkOcean + case .place: .sparkAccent + case .tag(let h): EventTag(name: h.name, type: h.type).tagTint + case .intent: .sparkAccent + } + } +} diff --git a/SparkApp/Sources/Search/SearchView.swift b/SparkApp/Sources/Search/SearchView.swift index 6443411..e1d005b 100644 --- a/SparkApp/Sources/Search/SearchView.swift +++ b/SparkApp/Sources/Search/SearchView.swift @@ -35,6 +35,8 @@ struct SearchView: View { IntegrationDetailView(integrationId: service) case .account(let id): AccountDetailView(accountId: id) + case .tag(let name, let type): + TagDetailView(tagName: name, tagType: type) } } .sparkMainNavigationTitle("Search") @@ -265,6 +267,7 @@ struct SearchView: View { case .metric(let h): .metric(identifier: h.identifier) case .integration(let h): .integration(service: h.id) case .place(let h): .place(id: h.id) + case .tag(let h): .tag(name: h.name, type: h.type) case .intent: nil // Actions ride the App Intents pipeline (Phase 3). } if let route, path.last != route { @@ -273,57 +276,7 @@ struct SearchView: View { } } -private struct SearchResultRow: View { - let result: SearchResult - - var body: some View { - HStack(spacing: SparkSpacing.md) { - DomainGlyph(icon: glyph, tint: tint, size: 28) - VStack(alignment: .leading, spacing: 2) { - Text(result.title) - .font(SparkTypography.body) - .lineLimit(1) - if let sub = result.subtitle { - Text(sub) - .font(SparkTypography.bodySmall) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - Spacer(minLength: 0) - Image(systemName: "chevron.right") - .font(.caption2) - .foregroundStyle(.secondary) - } - .padding(SparkSpacing.md) - .sparkGlass(.roundedRect(SparkRadii.lg), tint: Color.sparkElevated.opacity(0.18)) - .contentShape(Rectangle()) - } - - private var glyph: String { - switch result { - case .event: "circle.dotted" - case .object: "shippingbox" - case .block: "square.stack.3d.up" - case .metric: "chart.line.uptrend.xyaxis" - case .integration: "link" - case .place: "mappin.circle.fill" - case .intent(let h): h.symbol ?? "sparkles" - } - } - - private var tint: Color { - switch result { - case .event(let h): h.domain.map(Color.domainTint(for:)) ?? .sparkAccent - case .object: .sparkAccent - case .block: .domainKnowledge - case .metric(let h): h.domain.map(Color.domainTint(for:)) ?? .sparkAccent - case .integration: .sparkOcean - case .place: .sparkAccent - case .intent: .sparkAccent - } - } -} +// SearchResultRow is defined in SearchResultRow.swift private struct SearchFilterChip: View { let label: String diff --git a/SparkApp/Sources/Settings/SettingsRootView.swift b/SparkApp/Sources/Settings/SettingsRootView.swift index 7099b28..08aa484 100644 --- a/SparkApp/Sources/Settings/SettingsRootView.swift +++ b/SparkApp/Sources/Settings/SettingsRootView.swift @@ -10,11 +10,10 @@ struct SettingsRootView: View { NavigationStack { Form { Section { - SparkSystemScreenHeader( - title: "Settings", - subtitle: "Manage your account, preferences, connections, and app diagnostics." - ) - .padding(.vertical, SparkSpacing.sm) + Text("Manage your account, preferences, connections, and app diagnostics.") + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + .padding(.vertical, SparkSpacing.sm) } .listRowBackground(Color.clear) diff --git a/SparkApp/Sources/Shared/SparkAppViewSystem.swift b/SparkApp/Sources/Shared/SparkAppViewSystem.swift index faf5b9f..48edf7d 100644 --- a/SparkApp/Sources/Shared/SparkAppViewSystem.swift +++ b/SparkApp/Sources/Shared/SparkAppViewSystem.swift @@ -145,15 +145,32 @@ struct SparkSheetScaffold: View { struct SparkRawPayloadView: View { let text: String + @State private var didCopy = false var body: some View { - Text(text) - .font(SparkTypography.monoSmall) - .foregroundStyle(.primary) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(SparkSpacing.md) - .sparkGlass(.roundedRect(SparkRadii.md)) + VStack(alignment: .leading, spacing: SparkSpacing.md) { + Button { + UIPasteboard.general.string = text + didCopy = true + Task { + try? await Task.sleep(for: .seconds(1.4)) + didCopy = false + } + } label: { + Label(didCopy ? "Copied" : "Copy JSON", systemImage: didCopy ? "checkmark.circle.fill" : "doc.on.doc") + .font(SparkTypography.captionStrong) + .foregroundStyle(didCopy ? Color.sparkSuccess : Color.sparkTextPrimary) + } + .buttonStyle(.bordered) + + Text(text) + .font(SparkTypography.monoSmall) + .foregroundStyle(.primary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(SparkSpacing.md) + .sparkGlass(.roundedRect(SparkRadii.md)) + } } } @@ -216,7 +233,6 @@ struct SparkOnboardingScaffold: View { .padding(.horizontal, SparkSpacing.xl) .padding(.top, SparkSpacing.md) .padding(.bottom, SparkSpacing.xxl) - .background(.ultraThinMaterial) } .background(SparkResolvedAppBackground().ignoresSafeArea()) .navigationBarBackButtonHidden() @@ -233,6 +249,76 @@ struct SparkShareSheet: UIViewControllerRepresentable { func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} } +struct SparkFeedbackContext: Sendable { + let entityType: String + let entityId: String + let title: String + + var displayLabel: String { + "\(entityType.capitalized): \(title)" + } +} + +struct SparkUserFeedbackSheet: View { + let context: SparkFeedbackContext + let profile: UserProfile? + + @Environment(\.dismiss) private var dismiss + @State private var comments = "" + @State private var isSending = false + + private var trimmedComments: String { + comments.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: some View { + NavigationStack { + Form { + Section { + Text(context.displayLabel) + .font(SparkTypography.bodySmall) + .foregroundStyle(.secondary) + } + + Section("Feedback") { + TextEditor(text: $comments) + .frame(minHeight: 160) + .accessibilityLabel("Feedback") + } + } + .navigationTitle("Send Feedback") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + .disabled(isSending) + } + + ToolbarItem(placement: .confirmationAction) { + Button(isSending ? "Sending..." : "Send") { + submit() + } + .disabled(trimmedComments.isEmpty || isSending) + } + } + } + } + + private func submit() { + let comments = trimmedComments + guard !comments.isEmpty else { return } + isSending = true + SparkObservability.captureUserFeedback( + comments: comments, + context: context, + profile: profile + ) + dismiss() + } +} + struct SparkMainAppToolbarModifier: ViewModifier { let isVisible: Bool @@ -301,11 +387,14 @@ struct SparkSubViewToolbarModifier: ViewModifier { let shareItems: [Any] let rawTitle: String let rawPayload: String? + let feedbackContext: SparkFeedbackContext? let refresh: () async -> Void let reprocess: (() async -> Void)? + @Environment(AppModel.self) private var appModel @State private var showShareSheet = false @State private var showRawSheet = false + @State private var showFeedbackSheet = false func body(content: Content) -> some View { content @@ -323,6 +412,13 @@ struct SparkSubViewToolbarModifier: ViewModifier { Menu { Button("Tag") {} .disabled(true) + if feedbackContext != nil { + Button { + showFeedbackSheet = true + } label: { + Label("Send Feedback", systemImage: "bubble.left.and.bubble.right") + } + } Button { Task { await refresh() } } label: { @@ -356,6 +452,14 @@ struct SparkSubViewToolbarModifier: ViewModifier { SparkRawPayloadView(text: rawPayload ?? "{}") } } + .sheet(isPresented: $showFeedbackSheet) { + if let feedbackContext { + SparkUserFeedbackSheet( + context: feedbackContext, + profile: appModel.profile + ) + } + } } } @@ -368,6 +472,7 @@ extension View { shareItems: [Any], rawTitle: String = "Raw", rawPayload: String?, + feedbackContext: SparkFeedbackContext? = nil, refresh: @escaping () async -> Void, reprocess: (() async -> Void)? = nil ) -> some View { @@ -375,6 +480,7 @@ extension View { shareItems: shareItems, rawTitle: rawTitle, rawPayload: rawPayload, + feedbackContext: feedbackContext, refresh: refresh, reprocess: reprocess )) diff --git a/SparkApp/Sources/SparkApp.swift b/SparkApp/Sources/SparkApp.swift index 14fd0e2..618d3a9 100644 --- a/SparkApp/Sources/SparkApp.swift +++ b/SparkApp/Sources/SparkApp.swift @@ -258,6 +258,9 @@ enum SparkObservability { environment.reverbHTTPBaseURL.host() ?? "ws.spark.cronx.co", ] options.tracePropagationTargets = options.failedRequestTargets + options.beforeSend = { event in + isExpectedMetricNotFoundEvent(event) ? nil : event + } // Logging (captures OSLog output) options.enableLogs = true @@ -290,6 +293,49 @@ enum SparkObservability { } } + static func captureUserFeedback( + comments: String, + context: SparkFeedbackContext, + profile: UserProfile? + ) { + let eventId = SentrySDK.capture(message: "User feedback for \(context.displayLabel)") { scope in + scope.setTag(value: "true", key: "feedback") + scope.setTag(value: context.entityType, key: "entity_type") + scope.setTag(value: context.entityId, key: "entity_id") + scope.setContext(value: [ + "type": context.entityType, + "id": context.entityId, + "title": context.title, + ], key: "spark_entity") + } + + let name = profile?.name.trimmingCharacters(in: .whitespacesAndNewlines) + let email = profile?.email.trimmingCharacters(in: .whitespacesAndNewlines) + let feedback = SentryFeedback( + message: comments, + name: name?.isEmpty == false ? name : nil, + email: email?.isEmpty == false ? email : nil, + source: .custom, + associatedEventId: eventId + ) + SentrySDK.capture(feedback: feedback) + } + + private static func isExpectedMetricNotFoundEvent(_ event: Sentry.Event) -> Bool { + guard event.exceptions?.contains(where: { $0.type == "HTTPClientError" }) == true, + let requestURL = event.request?.url, + let url = URL(string: requestURL), + url.path.hasPrefix("/api/v1/mobile/metrics/") + else { + return false + } + + let response = event.context?["response"] + let statusCode = response?["status_code"] as? Int + ?? (response?["status_code"] as? NSNumber)?.intValue + return statusCode == 404 + } + private static func releaseName() -> String { let info = Bundle.main.infoDictionary let short = info?["CFBundleShortVersionString"] as? String ?? "0.0.0" diff --git a/SparkApp/Sources/Today/Cards/CheckInCard.swift b/SparkApp/Sources/Today/Cards/CheckInCard.swift index 50b9dd7..fd6b14f 100644 --- a/SparkApp/Sources/Today/Cards/CheckInCard.swift +++ b/SparkApp/Sources/Today/Cards/CheckInCard.swift @@ -4,93 +4,74 @@ import SwiftData import SwiftUI struct CheckInCard: View { + let date: Date let status: CheckInDayStatus let onTapMorning: () -> Void let onTapAfternoon: () -> Void @Binding var showHistory: Bool @Environment(\.modelContext) private var modelContext - @State private var historyDays: [DayDotData] = [] - @State private var streakCount: Int = 0 - - private var isMorning: Bool { - Calendar.current.component(.hour, from: .now) < 12 - } + @State private var historyDays: [CheckInHeatmapDay] = [] var body: some View { GlassCard { - VStack(alignment: .leading, spacing: 0) { - GlassCardHeader( - icon: "heart.text.clipboard", - tint: .sparkAccent, - title: "Check-ins" - ) - Divider() - .padding(.vertical, SparkSpacing.sm) - morningRow - if !isMorning { - afternoonRow + VStack(alignment: .leading, spacing: SparkSpacing.md) { + VStack(alignment: .leading, spacing: SparkSpacing.xs) { + CheckInPeriodSummaryRow( + title: "Morning Check-in", + status: status.morning, + onTap: onTapMorning + ) + if showsAfternoonRow { + CheckInPeriodSummaryRow( + title: "Afternoon Check-in", + status: status.afternoon, + onTap: onTapAfternoon + ) + } } + Divider() - .padding(.vertical, SparkSpacing.sm) - streakSection + + VStack(alignment: .leading, spacing: SparkSpacing.sm) { + HStack { + SectionLabel("LAST 28 DAYS") + Spacer() + Text("\(completedDayCount) logged") + .font(SparkTypography.monoSmall) + .foregroundStyle(.secondary) + } + CheckInHeatmap(days: historyDays) + } + .contentShape(Rectangle()) + .onTapGesture { showHistory = true } } } .accessibilityElement(children: .contain) - .task(id: status.stableKey) { + .task(id: "\(Self.isoDate(date))-\(status.stableKey)") { await loadHistory() } } - // MARK: - Period rows - - @ViewBuilder - private var morningRow: some View { - switch status.morning { - case let .completed(physical, mental, notes): - CheckInPeriodRow(label: "Morning", status: .completed(physical: physical, mental: mental, notes: notes), onTap: {}) - case .pending where isMorning: - CheckInPeriodRow(label: "Morning", status: .pending, onTap: onTapMorning) - case .pending: - MissedPeriodRow(label: "Morning") - } - } - - @ViewBuilder - private var afternoonRow: some View { - CheckInPeriodRow(label: "Afternoon", status: status.afternoon, onTap: onTapAfternoon) + private var completedDayCount: Int { + historyDays.filter { $0.morningScore != nil || $0.afternoonScore != nil }.count } - // MARK: - Streak section - - private var streakSection: some View { - VStack(alignment: .leading, spacing: SparkSpacing.sm) { - HStack { - SectionLabel("CHECK-IN STREAK") - Spacer() - Text("\(streakCount) day\(streakCount == 1 ? "" : "s")") - .font(SparkTypography.monoSmall) - .foregroundStyle(.secondary) - } - HStack(spacing: 4) { - ForEach(historyDays) { day in - DayDotView(data: day) - } - } + private var showsAfternoonRow: Bool { + let calendar = Calendar.current + if calendar.isDateInToday(date), calendar.component(.hour, from: .now) < 12 { + return false } - .contentShape(Rectangle()) - .onTapGesture { showHistory = true } + return true } - // MARK: - History loading - private func loadHistory() async { let calendar = Calendar.current - let today = calendar.startOfDay(for: .now) - var days: [DayDotData] = [] + let endDay = calendar.startOfDay(for: date) + var days: [CheckInHeatmapDay] = [] - for offset in stride(from: 13, through: 0, by: -1) { - guard let day = calendar.date(byAdding: .day, value: -offset, to: today) else { continue } + for offset in stride(from: 27, through: 0, by: -1) { + guard let day = calendar.date(byAdding: .day, value: -offset, to: endDay) else { continue } let dateKey = Self.isoDate(day) let label = String(calendar.component(.day, from: day)) @@ -98,28 +79,24 @@ struct CheckInCard: View { predicate: #Predicate { $0.date == dateKey } ) let rows = (try? modelContext.fetch(descriptor)) ?? [] - let morningDone = rows.first(where: { $0.period == "morning" })?.completed == true - let afternoonDone = rows.first(where: { $0.period == "afternoon" })?.completed == true + let morning = rows.first(where: { $0.period == "morning" && $0.completed }) + let afternoon = rows.first(where: { $0.period == "afternoon" && $0.completed }) - days.append(DayDotData( + days.append(CheckInHeatmapDay( id: dateKey, + date: dateKey, label: label, - morningDone: morningDone, - afternoonDone: afternoonDone + morningScore: combinedScore(for: morning), + afternoonScore: combinedScore(for: afternoon) )) } historyDays = days + } - var streak = 0 - for day in days.reversed() { - if day.morningDone || day.afternoonDone { - streak += 1 - } else { - break - } - } - streakCount = streak + private func combinedScore(for row: CachedCheckIn?) -> Int? { + guard let physical = row?.physical, let mental = row?.mental else { return nil } + return physical + mental } private static func isoDate(_ date: Date) -> String { @@ -129,165 +106,17 @@ struct CheckInCard: View { } } -// MARK: - Period row - -private struct CheckInPeriodRow: View { - let label: String - let status: PeriodStatus - let onTap: () -> Void - - var body: some View { - Group { - switch status { - case .pending: - Button(action: onTap) { rowContent } - .buttonStyle(.plain) - case .completed: - rowContent - } - } - .accessibilityElement(children: .combine) - .accessibilityLabel(accessibilityLabel) - } - - private var rowContent: some View { - HStack { - Text(label) - .font(SparkTypography.bodySmall) - .foregroundStyle(.primary) - Spacer() +private extension CheckInDayStatus { + var stableKey: String { + func key(_ status: PeriodStatus) -> String { switch status { case .pending: - Text("tap to log →") - .font(SparkTypography.monoSmall) - .foregroundStyle(.secondary) + return "p" case let .completed(physical, mental, _): - ScoreDots(physical: physical, mental: mental) - } - } - .padding(.vertical, SparkSpacing.xs) - } - - private var accessibilityLabel: String { - switch status { - case .pending: - return "\(label) check-in pending. Tap to log." - case let .completed(physical, mental, notes): - let base = "\(label) check-in complete. Physical \(physical) of 5, mental \(mental) of 5." - if let notes, !notes.isEmpty { return "\(base) Note: \(notes)" } - return base - } - } -} - -// MARK: - Missed period row - -private struct MissedPeriodRow: View { - let label: String - - var body: some View { - HStack { - Text(label) - .font(SparkTypography.bodySmall) - .foregroundStyle(Color.secondary.opacity(0.5)) - Spacer() - Text("—") - .font(SparkTypography.monoSmall) - .foregroundStyle(Color.secondary.opacity(0.4)) - } - .padding(.vertical, SparkSpacing.xs) - .accessibilityElement(children: .ignore) - .accessibilityLabel("\(label) check-in missed") - } -} - -// MARK: - Score dots - -private struct ScoreDots: View { - let physical: Int - let mental: Int - - private var combined: Int { physical + mental } - - private var score: Int { - max(1, min(5, (physical + mental + 1) / 2)) - } - - private var tint: Color { - switch combined { - case 2: Color(red: 212/255, green: 61/255, blue: 81/255) - case 3: Color(red: 226/255, green: 115/255, blue: 87/255) - case 4: Color(red: 235/255, green: 160/255, blue: 110/255) - case 5: Color(red: 242/255, green: 202/255, blue: 148/255) - case 6: Color(red: 253/255, green: 241/255, blue: 197/255) - case 7: Color(red: 205/255, green: 214/255, blue: 163/255) - case 8: Color(red: 153/255, green: 188/255, blue: 137/255) - case 9: Color(red: 96/255, green: 162/255, blue: 119/255) - case 10: Color(red: 0/255, green: 135/255, blue: 108/255) - default: .secondary - } - } - - var body: some View { - HStack(spacing: 3) { - ForEach(1...5, id: \.self) { i in - Circle() - .frame(width: 7, height: 7) - .foregroundStyle(i <= score ? tint : tint.opacity(0.2)) + return "c\(physical)-\(mental)" } } - } -} -// MARK: - Day dot - -private struct DayDotData: Identifiable { - let id: String - let label: String - let morningDone: Bool - let afternoonDone: Bool -} - -private struct DayDotView: View { - let data: DayDotData - - private var dotColor: Color { - if data.morningDone && data.afternoonDone { return .sparkAccent } - if data.morningDone || data.afternoonDone { return .sparkWarning } - return .clear - } - - private var strokeColor: Color { - if data.morningDone || data.afternoonDone { return .clear } - return Color.secondary.opacity(0.3) - } - - var body: some View { - VStack(spacing: 2) { - Circle() - .fill(dotColor) - .overlay(Circle().strokeBorder(strokeColor, lineWidth: 1)) - .frame(width: 10, height: 10) - Text(data.label) - .font(.system(size: 8, design: .monospaced)) - .foregroundStyle(.secondary) - } - .accessibilityLabel(accessibilityLabel) - } - - private var accessibilityLabel: String { - if data.morningDone && data.afternoonDone { return "Day \(data.label), both check-ins complete" } - if data.morningDone || data.afternoonDone { return "Day \(data.label), one check-in complete" } - return "Day \(data.label), no check-in" - } -} - -// MARK: - Stable key helper - -private extension CheckInDayStatus { - var stableKey: String { - let m: String = { if case .pending = morning { return "p" }; return "c" }() - let a: String = { if case .pending = afternoon { return "p" }; return "c" }() - return "\(m)\(a)" + return "\(key(morning))-\(key(afternoon))" } } diff --git a/SparkApp/Sources/Today/Cards/FeedSection.swift b/SparkApp/Sources/Today/Cards/FeedSection.swift index a28804c..5621f0a 100644 --- a/SparkApp/Sources/Today/Cards/FeedSection.swift +++ b/SparkApp/Sources/Today/Cards/FeedSection.swift @@ -155,8 +155,8 @@ struct FeedSection: View { case .idle, .loading: ProgressView() .frame(maxWidth: .infinity, minHeight: 220) - case .loaded(let json): - SparkRawPayloadView(text: json) + case .loaded(let entries): + RawFeedJSONView(title: "Raw feed JSON", entries: entries) case .error(let message): EmptyState( systemImage: "exclamationmark.triangle.fill", @@ -173,8 +173,8 @@ struct FeedSection: View { private func loadRawFeed() async { rawFeedState = .loading do { - let events = try await fetchAllFeedEvents() - rawFeedState = .loaded(prettyJSON(for: events)) + let entries = try await fetchAllFeedResponses() + rawFeedState = .loaded(entries) } catch APIError.notModified { rawFeedState = .error("The feed endpoint returned 304 Not Modified, so no raw response body was available.") } catch { @@ -183,32 +183,25 @@ struct FeedSection: View { } } - private func fetchAllFeedEvents() async throws -> [Event] { + private func fetchAllFeedResponses() async throws -> [RawFeedJSONEntry] { var cursor: String? - var events: [Event] = [] + var entries: [RawFeedJSONEntry] = [] + let dateKey = Self.isoKey(for: date) repeat { - let page = try await appModel.apiClient.request( - FeedEndpoint.feed(cursor: cursor, limit: 100, date: Self.isoKey(for: date)) + let response = try await appModel.apiClient.requestWithRawResponse( + FeedEndpoint.feed(cursor: cursor, limit: 100, date: dateKey) ) - events.append(contentsOf: page.data) + let cursorSuffix = cursor.map { "&cursor=\($0)" } ?? "" + entries.append(RawFeedJSONEntry( + title: "GET /feed?date=\(dateKey)&limit=100\(cursorSuffix)", + body: response.utf8Body + )) + let page = response.decoded cursor = page.hasMore ? page.nextCursor : nil } while cursor != nil - return events - } - - private func prettyJSON(for events: [Event]) -> String { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] - guard - let data = try? encoder.encode(events), - let string = String(data: data, encoding: .utf8) - else { - return "[]" - } - return string + return entries } private static func isoKey(for date: Date) -> String { @@ -222,7 +215,7 @@ struct FeedSection: View { private enum RawFeedState { case idle case loading - case loaded(String) + case loaded([RawFeedJSONEntry]) case error(String) } @@ -333,6 +326,8 @@ private struct HourGroup: View { let hour: Int let events: [CachedEvent] + @State private var expandedGroupIDs: Set = [] + private var eventGroups: [EventGroup] { var result: [EventGroup] = [] var i = 0 @@ -376,10 +371,26 @@ private struct HourGroup: View { } .buttonStyle(.plain) case .collapsed(let groupEvents): - NavigationLink(value: DetailRoute.event(id: groupEvents[0].id)) { - row(for: groupEvents[0], surplusCount: groupEvents.count - 1) + let groupID = group.id + if expandedGroupIDs.contains(groupID) { + ForEach(groupEvents) { event in + NavigationLink(value: DetailRoute.event(id: event.id)) { + row(for: event) + } + .buttonStyle(.plain) + } + } else { + Button { + withAnimation(.snappy(duration: 0.22)) { + _ = expandedGroupIDs.insert(groupID) + } + } label: { + row(for: groupEvents[0], surplusCount: groupEvents.count - 1) + } + .buttonStyle(.plain) + .accessibilityLabel("\(primaryTitle(for: groupEvents[0])), \(groupEvents.count - 1) others") + .accessibilityHint("Expands the grouped timeline events") } - .buttonStyle(.plain) } } } diff --git a/SparkApp/Sources/Today/DayPagerView.swift b/SparkApp/Sources/Today/DayPagerView.swift index 6eb69bd..b7db6dd 100644 --- a/SparkApp/Sources/Today/DayPagerView.swift +++ b/SparkApp/Sources/Today/DayPagerView.swift @@ -49,6 +49,8 @@ struct DayPagerView: View { IntegrationDetailView(integrationId: service) case .account(let id): AccountDetailView(accountId: id) + case .tag(let name, let type): + TagDetailView(tagName: name, tagType: type) } } } @@ -85,6 +87,8 @@ struct DayPagerView: View { push(.integration(service: service)) case .account(let id): push(.account(id: id)) + case .tag(let name, let type): + push(.tag(name: name, type: type)) } appModel.pendingRoute = nil } @@ -113,6 +117,7 @@ enum DetailRoute: Hashable { case place(id: String) case integration(service: String) case account(id: String) + case tag(name: String, type: String?) } private struct DayKey: Identifiable, Hashable { diff --git a/SparkApp/Sources/Today/TodayView.swift b/SparkApp/Sources/Today/TodayView.swift index 202f930..deeda65 100644 --- a/SparkApp/Sources/Today/TodayView.swift +++ b/SparkApp/Sources/Today/TodayView.swift @@ -9,9 +9,8 @@ struct TodayView: View { var showsToolbar = true @Environment(AppModel.self) private var appModel @State private var viewModel: TodayViewModel? - @State private var showCheckIn = false + @State private var checkInSelection: CheckInSheetSelection? @State private var showHistory = false - @State private var checkInInitialPeriod: CheckInPeriod = .morning var body: some View { let snapshot = TodaySnapshot( @@ -32,14 +31,13 @@ struct TodayView: View { anomalyPill(for: snapshot) CheckInCard( + date: date, status: snapshot.checkInStatus, onTapMorning: { - checkInInitialPeriod = .morning - showCheckIn = true + checkInSelection = CheckInSheetSelection(date: date, period: .morning) }, onTapAfternoon: { - checkInInitialPeriod = .afternoon - showCheckIn = true + checkInSelection = CheckInSheetSelection(date: date, period: .afternoon) }, showHistory: $showHistory ) @@ -49,6 +47,10 @@ struct TodayView: View { if !snapshot.hasAnyDomainData { loadingOrEmptyState } + + if let vm = viewModel, !vm.rawAPIEntries.isEmpty { + RawFeedJSONView(title: "Raw API response", entries: vm.rawAPIEntries) + } } .padding(.horizontal, SparkSpacing.lg) .padding(.top, SparkSpacing.xl + 72) @@ -58,9 +60,9 @@ struct TodayView: View { .refreshable { await viewModel?.refresh() } } .sparkMainAppToolbar(isVisible: showsToolbar) - .sheet(isPresented: $showCheckIn) { + .sheet(item: $checkInSelection) { selection in if let vm = viewModel { - CheckInModalView(viewModel: vm, date: date, initialPeriod: checkInInitialPeriod) + CheckInModalView(viewModel: vm, date: selection.date, initialPeriod: selection.period) } } .sheet(isPresented: $showHistory) { @@ -191,6 +193,21 @@ struct TodayView: View { } } +private struct CheckInSheetSelection: Identifiable { + let date: Date + let period: CheckInPeriod + + var id: String { + "\(Self.formatter.string(from: date))-\(period.rawValue)" + } + + private static let formatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + return f + }() +} + private extension TodaySnapshot { var hasAnyDomainData: Bool { (health?.hasSleep ?? false) diff --git a/SparkApp/Sources/Today/TodayViewModel.swift b/SparkApp/Sources/Today/TodayViewModel.swift index 480d7b6..90ce9aa 100644 --- a/SparkApp/Sources/Today/TodayViewModel.swift +++ b/SparkApp/Sources/Today/TodayViewModel.swift @@ -16,6 +16,7 @@ final class TodayViewModel { let date: Date private(set) var cached: DaySummary? private(set) var briefingSummaryLine: String? + private(set) var rawAPIEntries: [RawFeedJSONEntry] = [] private(set) var networkState: TodayNetworkState = .idle private(set) var checkInDayStatus: CheckInDayStatus = .allPending @@ -52,6 +53,11 @@ final class TodayViewModel { await revalidateCheckIns() } + func loadCheckIns() async { + loadCachedCheckIns() + await revalidateCheckIns() + } + func submitCheckIn(request: CheckInRequest) async throws { let event = try await apiClient.request(CheckInsEndpoint.submit(request)) let context = ModelContext(container) @@ -96,7 +102,8 @@ final class TodayViewModel { private func revalidateCheckIns() async { let key = Self.isoKey(for: date) do { - let response = try await apiClient.request(CheckInsEndpoint.today(date: key)) + let response = try await apiClient.requestWithRawResponse(CheckInsEndpoint.today(date: key)) + upsertRawAPIEntry(title: "GET /check-ins/today?date=\(key)", body: response.utf8Body) let context = ModelContext(container) func upsertPeriod(_ detail: CheckInPeriodDetail, period: CheckInPeriod) { @@ -111,8 +118,8 @@ final class TodayViewModel { ) } - upsertPeriod(response.morning, period: .morning) - upsertPeriod(response.afternoon, period: .afternoon) + upsertPeriod(response.decoded.morning, period: .morning) + upsertPeriod(response.decoded.afternoon, period: .afternoon) try? context.save() loadCachedCheckIns() } catch APIError.notModified { @@ -137,9 +144,11 @@ final class TodayViewModel { private func revalidate(force: Bool = false) async { networkState = .loading do { - let summary = try await apiClient.request( + let response = try await apiClient.requestWithRawResponse( BriefingEndpoint.today(date: Self.isoKey(for: date)) ) + let summary = response.decoded + upsertRawAPIEntry(title: "GET /today?date=\(Self.isoKey(for: date))", body: response.utf8Body) apply(summary: summary) try await persist(summary) networkState = .idle @@ -165,7 +174,10 @@ final class TodayViewModel { let context = ModelContext(container) repeat { - let page = try await apiClient.request(FeedEndpoint.feed(cursor: cursor, limit: 100, date: dateKey)) + let response = try await apiClient.requestWithRawResponse(FeedEndpoint.feed(cursor: cursor, limit: 100, date: dateKey)) + let page = response.decoded + let cursorSuffix = cursor.map { "&cursor=\($0)" } ?? "" + upsertRawAPIEntry(title: "GET /feed?date=\(dateKey)&limit=100\(cursorSuffix)", body: response.utf8Body) for event in page.data { upsert(event, in: context) } @@ -233,6 +245,11 @@ final class TodayViewModel { } } + private func upsertRawAPIEntry(title: String, body: String) { + rawAPIEntries.removeAll { $0.title == title } + rawAPIEntries.append(RawFeedJSONEntry(title: title, body: body)) + } + private func persist(_ summary: DaySummary) async throws { let context = ModelContext(container) let data = try JSONEncoder().encode(summary)