From de655ffcf8d5a7aa2f9511fe771e1a64f85d5247 Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Sun, 12 Apr 2026 23:17:18 -0700 Subject: [PATCH 1/2] perf: batch tRPC calls, cache device status, fix duplicate fetches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iOS client was firing unbatched sequential HTTP requests where the web client uses tRPC's httpBatchLink to coalesce them. Cold launch also sat on a "Disconnected" screen for ~300ms waiting on the first fetch. Schedule writes were failing the server's max(100) cap once AI curves accumulated. - SleepypodCoreClient: add batchQuery helper mirroring @trpc/client's httpBatchLink. getDeviceStatus now batches 3 procs into 1 round trip (and drops a dead settings.getAll call). getServerStatus batches 6 health procs into 1. Query timeout 30s -> 8s, mutate 30s -> 15s. - updateSchedules: chunk delete/create arrays to <=100 per type so the server's z.array().max(100) validation stops rejecting AI-curve-sized batches. Surfaces tRPC error bodies on HTTP 4xx/5xx so silent fails become visible. - DeviceManager: persist deviceStatus to UserDefaults on every successful fetch; hydrate from cache in init so cold launch shows last-known values immediately instead of a blank state. - startPolling skips its first immediate tick when deviceStatus is already populated (from cache or a just-completed startConnection), eliminating a duplicate cold-start fetchStatus. - HealthScreen: remove redundant local fetchVitals that duplicated what MetricsManager.fetchAll already fetched, and the duplicate calibration call. Refresh now parallelizes fetchAll + calibration via async let. - TempScreen: .onAppear -> .task so fetchActiveCurve doesn't re-fire on every tab switch. Sort schedule setpoints by offset-from-bedtime so an overnight curve (22:00 -> 07:00) renders left-to-right as the chart expects (fixes "Now" line appearing ~20h into the future on a schedule with accumulated midnight entries). - Temp screen top bar shows "• just now" / "• 12s ago" via TimelineView, so you can tell cached from fresh data. Core-side followup filed as sleepypod/core#424: bump batchUpdate max(100) to max(1000) so we can drop the client-side chunking. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Networking/SleepypodCoreClient.swift | 200 ++++++++++++++---- Sleepypod/Services/DeviceManager.swift | 29 ++- Sleepypod/SleepypodApp.swift | 6 +- Sleepypod/Views/Data/HealthScreen.swift | 57 ++--- Sleepypod/Views/Temp/TempScreen.swift | 48 ++++- 5 files changed, 258 insertions(+), 82 deletions(-) diff --git a/Sleepypod/Networking/SleepypodCoreClient.swift b/Sleepypod/Networking/SleepypodCoreClient.swift index 2c76f50..713821e 100644 --- a/Sleepypod/Networking/SleepypodCoreClient.swift +++ b/Sleepypod/Networking/SleepypodCoreClient.swift @@ -29,10 +29,14 @@ final class SleepypodCoreClient: SleepypodProtocol, @unchecked Sendable { // MARK: - Device Status func getDeviceStatus() async throws -> DeviceStatus { - let status: TRPCDeviceStatus = try await query("device.getStatus") - let settings: TRPCSettings = try await query("settings.getAll") - let health: TRPCSystemHealth = try await query("health.system") - let wifi = try? await query("system.wifiStatus") as TRPCWifiStatus + let results = try await batchQuery([ + BatchCall(procedure: "device.getStatus", input: nil), + BatchCall(procedure: "health.system", input: nil), + BatchCall(procedure: "system.wifiStatus", input: nil) + ]) + let status = try decoder.decode(TRPCDeviceStatus.self, from: results[0].get()) + let health = try decoder.decode(TRPCSystemHealth.self, from: results[1].get()) + let wifi = try? decoder.decode(TRPCWifiStatus.self, from: results[2].get()) return DeviceStatus( left: SideStatus( @@ -146,35 +150,35 @@ final class SleepypodCoreClient: SleepypodProtocol, @unchecked Sendable { func updateSchedules(_ schedules: Schedules, days: Set? = nil) async throws -> Schedules { let daysToUpdate = days ?? Set(DayOfWeek.allCases) + let dayStrings = Set(daysToUpdate.map(\.rawValue)) + + var tempDeletes: [Int] = [] + var powerDeletes: [Int] = [] + var alarmDeletes: [Int] = [] + + var tempCreates: [[String: Any]] = [] + var powerCreates: [[String: Any]] = [] + var alarmCreates: [[String: Any]] = [] for side in [Side.left, .right] { let existing: TRPCScheduleSet = try await query("schedules.getAll", input: ["side": side.rawValue]) let sideSchedule = schedules.schedule(for: side) + // Collect IDs to delete for the days being updated + tempDeletes.append(contentsOf: existing.temperature.filter { dayStrings.contains($0.dayOfWeek) }.map(\.id)) + powerDeletes.append(contentsOf: existing.power.filter { dayStrings.contains($0.dayOfWeek) }.map(\.id)) + alarmDeletes.append(contentsOf: existing.alarm.filter { dayStrings.contains($0.dayOfWeek) }.map(\.id)) + for day in daysToUpdate { let daily = sideSchedule[day] - let hasData = !daily.temperatures.isEmpty || daily.power.enabled || daily.alarm.enabled - - // Delete existing entries for this day only - for sched in existing.temperature where sched.dayOfWeek == day.rawValue { - let _: TRPCSuccess = try await mutate("schedules.deleteTemperatureSchedule", input: ["id": sched.id]) - } - for sched in existing.power where sched.dayOfWeek == day.rawValue { - let _: TRPCSuccess = try await mutate("schedules.deletePowerSchedule", input: ["id": sched.id]) - } - for sched in existing.alarm where sched.dayOfWeek == day.rawValue { - let _: TRPCSuccess = try await mutate("schedules.deleteAlarmSchedule", input: ["id": sched.id]) - } - - // Only recreate if this day has data - guard hasData else { continue } for (time, tempF) in daily.temperatures { - let _: TRPCTemperatureSchedule = try await mutate("schedules.createTemperatureSchedule", input: [ + tempCreates.append([ "side": side.rawValue, "dayOfWeek": day.rawValue, "time": time, - "temperature": tempF + "temperature": tempF, + "enabled": true ]) } @@ -183,7 +187,7 @@ final class SleepypodCoreClient: SleepypodProtocol, @unchecked Sendable { let onMinutes = minutesFromTime(daily.power.on) let offMinutes = minutesFromTime(daily.power.off) if let on = onMinutes, let off = offMinutes, on < off { - let _: TRPCPowerSchedule = try await mutate("schedules.createPowerSchedule", input: [ + powerCreates.append([ "side": side.rawValue, "dayOfWeek": day.rawValue, "onTime": daily.power.on, @@ -195,7 +199,7 @@ final class SleepypodCoreClient: SleepypodProtocol, @unchecked Sendable { } if daily.alarm.enabled { - let _: TRPCAlarmSchedule = try await mutate("schedules.createAlarmSchedule", input: [ + alarmCreates.append([ "side": side.rawValue, "dayOfWeek": day.rawValue, "time": daily.alarm.time, @@ -209,21 +213,69 @@ final class SleepypodCoreClient: SleepypodProtocol, @unchecked Sendable { } } + // Server caps each delete/create array at max(100) per call. Chunk so no + // single array exceeds that limit; worst case for an "apply to all 7 days" + // with an AI curve is ~3 chunks, still far fewer round trips than the + // old N+1 per-schedule pattern. + let chunks = max( + 1, + (tempDeletes.count + 99) / 100, + (powerDeletes.count + 99) / 100, + (alarmDeletes.count + 99) / 100, + (tempCreates.count + 99) / 100, + (powerCreates.count + 99) / 100, + (alarmCreates.count + 99) / 100 + ) + + func slice(_ arr: [T], chunk: Int) -> [T] { + let start = chunk * 100 + guard start < arr.count else { return [] } + return Array(arr[start.. ServerStatus { - let health: TRPCSystemHealth = try await query("health.system") - let scheduler: TRPCSchedulerHealth = try await query("health.scheduler") + let results = try await batchQuery([ + BatchCall(procedure: "health.system", input: nil), + BatchCall(procedure: "health.scheduler", input: nil), + BatchCall(procedure: "health.hardware", input: nil), + BatchCall(procedure: "health.dacMonitor", input: nil), + BatchCall(procedure: "biometrics.getProcessingStatus", input: nil), + BatchCall(procedure: "system.wifiStatus", input: nil) + ]) + let health = try decoder.decode(TRPCSystemHealth.self, from: results[0].get()) + let scheduler = try decoder.decode(TRPCSchedulerHealth.self, from: results[1].get()) - // Fetch additional health endpoints (non-critical — don't fail if unavailable) - let hardware = try? await query("health.hardware") as TRPCHardwareHealth - let dacMonitor = try? await query("health.dacMonitor") as TRPCDacMonitor - let bioProcessing = try? await query("biometrics.getProcessingStatus") as TRPCBiometricsProcessing - let internet = try? await query("system.internetStatus") as TRPCInternetStatus - let wifi = try? await query("system.wifiStatus") as TRPCWifiStatus + // Additional health endpoints are non-critical — tolerate per-call failures + let hardware = tryDecode(TRPCHardwareHealth.self, from: results[2]) + let dacMonitor = tryDecode(TRPCDacMonitor.self, from: results[3]) + let bioProcessing = tryDecode(TRPCBiometricsProcessing.self, from: results[4]) + let wifi = tryDecode(TRPCWifiStatus.self, from: results[5]) func info(_ name: String, status: ServiceStatus, desc: String, msg: String = "OK") -> StatusInfo { StatusInfo(name: name, status: status, description: desc, message: msg) @@ -493,7 +545,7 @@ final class SleepypodCoreClient: SleepypodProtocol, @unchecked Sendable { guard let url = URL(string: urlString) else { throw APIError.invalidURL } var request = URLRequest(url: url) request.httpMethod = "GET" - request.timeoutInterval = 30 // Hardware can be slow + request.timeoutInterval = 8 let (data, response) = try await performRequest(request) try validateResponse(response) @@ -508,16 +560,84 @@ final class SleepypodCoreClient: SleepypodProtocol, @unchecked Sendable { var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.timeoutInterval = 30 + request.timeoutInterval = 15 let wrapped: [String: Any] = ["json": input] request.httpBody = try JSONSerialization.data(withJSONObject: wrapped) let (data, response) = try await performRequest(request) - try validateResponse(response) + try validateResponse(response, data: data, procedure: procedure) return try decodeTRPCResult(data) } + /// tRPC batch query — coalesces multiple queries into one HTTP request. + /// Mirrors @trpc/client's httpBatchLink format: + /// GET /api/trpc/a,b,c?batch=1&input={"0":{"json":...},"1":{"json":...}} + /// Response is an array; each slot is either result-wrapped or error-wrapped. + /// Per-call results come back as re-serialized json payloads so callers decode + /// heterogeneous types into their own models. Per-call errors surface as .failure. + private func batchQuery(_ calls: [BatchCall]) async throws -> [Result] { + guard let base = baseURL else { throw APIError.noBaseURL } + guard !calls.isEmpty else { return [] } + + let procedures = calls.map(\.procedure).joined(separator: ",") + + var inputMap: [String: Any] = [:] + for (i, call) in calls.enumerated() { + inputMap[String(i)] = ["json": call.input ?? [:]] + } + let inputData = try JSONSerialization.data(withJSONObject: inputMap) + let inputJSON = String(data: inputData, encoding: .utf8) ?? "{}" + let encoded = inputJSON.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? inputJSON + + let urlString = "\(base)/api/trpc/\(procedures)?batch=1&input=\(encoded)" + guard let url = URL(string: urlString) else { throw APIError.invalidURL } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 8 + + let (data, response) = try await performRequest(request) + try validateResponse(response) + + let parsed = try JSONSerialization.jsonObject(with: data) + guard let envelope = parsed as? [Any], envelope.count == calls.count else { + throw APIError.decodingFailed(NSError( + domain: "tRPC.batch", code: 0, + userInfo: [NSLocalizedDescriptionKey: "Expected array of \(calls.count) results"] + )) + } + + return envelope.map { item in + guard let obj = item as? [String: Any] else { + return .failure(APIError.decodingFailed(NSError(domain: "tRPC.batch", code: 1))) + } + if let err = obj["error"] as? [String: Any] { + let msg = (err["json"] as? [String: Any])?["message"] as? String + ?? err["message"] as? String + ?? "tRPC error" + return .failure(APIError.serverError(message: msg)) + } + guard let result = obj["result"] as? [String: Any], + let dataObj = result["data"] as? [String: Any], + let json = dataObj["json"] else { + return .failure(APIError.decodingFailed(NSError(domain: "tRPC.batch", code: 2))) + } + do { + let bytes = try JSONSerialization.data(withJSONObject: json, options: [.fragmentsAllowed]) + return .success(bytes) + } catch { + return .failure(error) + } + } + } + + /// Decode a batch slot optionally — used for non-critical calls that may fail. + private func tryDecode(_ type: T.Type, from result: Result) -> T? { + guard let data = try? result.get() else { return nil } + return try? decoder.decode(type, from: data) + } + /// Decode tRPC response envelope: {"result": {"data": {"json": T}}} private func decodeTRPCResult(_ data: Data) throws -> T { do { @@ -538,12 +658,15 @@ final class SleepypodCoreClient: SleepypodProtocol, @unchecked Sendable { } } - private func validateResponse(_ response: URLResponse) throws { + private func validateResponse(_ response: URLResponse, data: Data? = nil, procedure: String? = nil) throws { guard let httpResponse = response as? HTTPURLResponse else { throw APIError.invalidResponse(statusCode: 0) } guard (200...299).contains(httpResponse.statusCode) else { - Log.network.error("HTTP \(httpResponse.statusCode): \(httpResponse.url?.absoluteString ?? "?")") + let tag = procedure ?? httpResponse.url?.absoluteString ?? "?" + // Surface the tRPC error message so validation failures aren't silent + let body = data.flatMap { String(data: $0, encoding: .utf8) }?.prefix(500) ?? "" + Log.network.error("HTTP \(httpResponse.statusCode) \(tag) — \(String(body))") throw APIError.invalidResponse(statusCode: httpResponse.statusCode) } } @@ -684,6 +807,11 @@ final class SleepypodCoreClient: SleepypodProtocol, @unchecked Sendable { // MARK: - tRPC Envelope Types +private struct BatchCall { + let procedure: String + let input: [String: Any]? +} + private struct TRPCEnvelope: Decodable { let result: TRPCResultData } diff --git a/Sleepypod/Services/DeviceManager.swift b/Sleepypod/Services/DeviceManager.swift index 5253aad..25622a6 100644 --- a/Sleepypod/Services/DeviceManager.swift +++ b/Sleepypod/Services/DeviceManager.swift @@ -34,6 +34,25 @@ final class DeviceManager { init(api: SleepypodProtocol) { self.api = api + // Cold-launch hydration: show last-known status immediately so the UI + // doesn't sit on a "Disconnected" screen for the ~300ms of the first + // fetch. Fresh status overwrites this within a few hundred ms. + if let cached = Self.loadCachedStatus() { + self.deviceStatus = cached + self.isConnected = true + } + } + + private static let cacheKey = "cachedDeviceStatus" + + private static func loadCachedStatus() -> DeviceStatus? { + guard let data = UserDefaults.standard.data(forKey: cacheKey) else { return nil } + return try? JSONDecoder().decode(DeviceStatus.self, from: data) + } + + private func cacheStatus(_ status: DeviceStatus) { + guard let data = try? JSONEncoder().encode(status) else { return } + UserDefaults.standard.set(data, forKey: Self.cacheKey) } // MARK: - Current State Helpers @@ -73,6 +92,7 @@ final class DeviceManager { isConnected = false retryCount = 0 error = nil + UserDefaults.standard.removeObject(forKey: Self.cacheKey) startPolling() } @@ -82,6 +102,7 @@ final class DeviceManager { func applyWebSocketStatus(_ frame: DeviceStatusFrame) { let newStatus = frame.toDeviceStatus(preserving: deviceStatus) deviceStatus = newStatus + cacheStatus(newStatus) isConnected = true isConnecting = false retryCount = 0 @@ -93,9 +114,14 @@ final class DeviceManager { func startPolling() { pollingTask?.cancel() + // If we already have a status snapshot (startConnection just fetched), skip + // the first immediate poll to avoid a redundant round trip on cold start. + var skipFirst = deviceStatus != nil pollingTask = Task { while !Task.isCancelled { - if pendingUpdate == nil && !isReceivingWebSocket && !isSendingMutation { + if skipFirst { + skipFirst = false + } else if pendingUpdate == nil && !isReceivingWebSocket && !isSendingMutation { await fetchStatus() } // Retry faster when disconnected, normal interval when connected @@ -117,6 +143,7 @@ final class DeviceManager { do { let status = try await api.getDeviceStatus() deviceStatus = status + cacheStatus(status) isConnected = true isConnecting = false retryCount = 0 diff --git a/Sleepypod/SleepypodApp.swift b/Sleepypod/SleepypodApp.swift index d563bc8..a469c24 100644 --- a/Sleepypod/SleepypodApp.swift +++ b/Sleepypod/SleepypodApp.swift @@ -141,17 +141,19 @@ struct ContentView: View { return } - deviceManager.startPolling() - // Demo mode — just fetch mock status, skip mDNS // Sensor demo stream starts automatically when Sensors tab is visited // via BedSensorScreen.onAppear -> connect() -> startDemoStream() if isDemo { await deviceManager.fetchStatus() + deviceManager.startPolling() return } + // Fetch once via startConnection, then hand off to polling. startPolling + // detects the populated deviceStatus and skips its redundant first tick. await startConnection() + deviceManager.startPolling() } .onChange(of: deviceManager.isConnected) { if deviceManager.isConnected { diff --git a/Sleepypod/Views/Data/HealthScreen.swift b/Sleepypod/Views/Data/HealthScreen.swift index fab2821..438b1f3 100644 --- a/Sleepypod/Views/Data/HealthScreen.swift +++ b/Sleepypod/Views/Data/HealthScreen.swift @@ -5,8 +5,6 @@ struct HealthScreen: View { @Environment(MetricsManager.self) private var metricsManager @Environment(SettingsManager.self) private var settingsManager - @State private var vitals: [VitalsRecord] = [] - @State private var isLoadingVitals = false @State private var showRawData = false @State private var sleepAnalyzer = SleepAnalyzer() @State private var isCalibrated = false @@ -16,6 +14,9 @@ struct HealthScreen: View { private var selectedSide: Side { metricsManager.selectedSide } + /// Vitals come from MetricsManager's fetchAll — don't re-fetch locally. + private var vitals: [VitalsRecord] { metricsManager.vitalsRecords } + var body: some View { ScrollView { VStack(spacing: 16) { @@ -354,46 +355,28 @@ struct HealthScreen: View { // MARK: - Fetch private func refresh() async { - await metricsManager.fetchAll() - await fetchVitals() - await checkCalibration() - } - - private func checkCalibration() async { - let api = APIBackend.current.createClient() - guard let status = try? await api.getCalibrationStatus(side: metricsManager.selectedSide) else { return } - // Only warn if selected side's piezo is missing or low quality - isCalibrated = status.piezo?.status == "completed" && (status.piezo?.qualityScore ?? 0) > 0.5 - } - - private func fetchVitals() async { - isLoadingVitals = vitals.isEmpty - let end = metricsManager.selectedWeekEnd - let start = metricsManager.selectedWeekStart - Log.general.info("Fetching vitals: side=\(metricsManager.selectedSide.rawValue) start=\(start) end=\(end)") - do { - vitals = try await api.getVitals(side: metricsManager.selectedSide, start: start, end: end) - Log.general.info("Fetched \(vitals.count) vitals records") - } catch { - Log.network.error("Failed to fetch vitals: \(error)") - } - isLoadingVitals = false - - // Fetch calibration quality for sleep analysis - let calibrationQuality: Double - if let status = try? await api.getCalibrationStatus(side: metricsManager.selectedSide) { - calibrationQuality = status.piezo?.qualityScore ?? 0.0 - } else { - calibrationQuality = 0.0 // Unknown quality — fail closed, don't trust unverified vitals - } + // fetchAll + calibration fire in parallel; analysis waits for both. + async let metricsTask: () = metricsManager.fetchAll() + async let calibrationTask = fetchCalibrationStatus() + let (_, status) = await (metricsTask, calibrationTask) + isCalibrated = status?.piezo?.status == "completed" && (status?.piezo?.qualityScore ?? 0) > 0.5 sleepAnalyzer.analyze( - vitals: vitals, + vitals: metricsManager.vitalsRecords, movement: metricsManager.movementRecords, - calibrationQuality: calibrationQuality + calibrationQuality: status?.piezo?.qualityScore ?? 0.0 ) - Log.general.info("Sleep analyzer: \(sleepAnalyzer.stages.count) stages, score=\(sleepAnalyzer.qualityScore ?? -1)") } + + private func fetchCalibrationStatus() async -> CalibrationStatus? { + try? await api.getCalibrationStatus(side: metricsManager.selectedSide) + } + + private func checkCalibration() async { + let status = await fetchCalibrationStatus() + isCalibrated = status?.piezo?.status == "completed" && (status?.piezo?.qualityScore ?? 0) > 0.5 + } + } // MARK: - Zone diff --git a/Sleepypod/Views/Temp/TempScreen.swift b/Sleepypod/Views/Temp/TempScreen.swift index 2dadaef..b71c510 100644 --- a/Sleepypod/Views/Temp/TempScreen.swift +++ b/Sleepypod/Views/Temp/TempScreen.swift @@ -58,8 +58,16 @@ struct TempScreen: View { let daily = sideSchedule[today] if !daily.temperatures.isEmpty { + // Sort by offset-from-bedtime so an overnight curve renders left-to-right + // as evening → morning. String-sorting ("03:00" < "22:00") would put the + // wake-side points first and push bedtime points ~20h into the chart. + let bedtime = daily.power.enabled ? daily.power.on : "22:00" + let bedMin = clockMinutesOfDay(bedtime) let points = daily.temperatures - .sorted { $0.key < $1.key } + .sorted { lhs, rhs in + offsetFromBedtime(lhs.key, bedtime: bedMin) + < offsetFromBedtime(rhs.key, bedtime: bedMin) + } .map { RunOnceSetPoint(time: $0.key, temperature: Double($0.value)) } let wake = daily.power.enabled ? daily.power.off : "07:00" activeCurve = ActiveCurve( @@ -83,6 +91,27 @@ struct TempScreen: View { return days[weekday - 1] } + private func clockMinutesOfDay(_ time: String) -> Int { + let parts = time.split(separator: ":") + guard parts.count == 2, let h = Int(parts[0]), let m = Int(parts[1]) else { return 0 } + return h * 60 + m + } + + private func offsetFromBedtime(_ time: String, bedtime: Int) -> Int { + (clockMinutesOfDay(time) - bedtime + 1440) % 1440 + } + + /// Short "Just now" / "12s ago" / "2m ago" label for the last-updated indicator. + static func relativeTime(from date: Date) -> String { + let seconds = Int(Date().timeIntervalSince(date)) + if seconds < 5 { return "just now" } + if seconds < 60 { return "\(seconds)s ago" } + let minutes = seconds / 60 + if minutes < 60 { return "\(minutes)m ago" } + let hours = minutes / 60 + return "\(hours)h ago" + } + private var sideName: String { settingsManager.sideName(for: deviceManager.selectedSide.primarySide) } @@ -118,14 +147,21 @@ struct TempScreen: View { if deviceManager.isConnected { VStack(spacing: 0) { - // Top bar — name + priming + settings gear - HStack { + // Top bar — name + priming + last-updated + settings gear + HStack(spacing: 8) { Text(sideName) .font(.subheadline.weight(.medium)) .foregroundColor(Theme.textSecondary) if deviceManager.deviceStatus?.isPriming == true { PrimingIndicator() } + if let lastUpdated = deviceManager.lastUpdated { + TimelineView(.periodic(from: .now, by: 15.0)) { _ in + Text("• \(Self.relativeTime(from: lastUpdated))") + .font(.caption2) + .foregroundColor(Theme.textMuted) + } + } Spacer() UserSelectorView() } @@ -200,9 +236,9 @@ struct TempScreen: View { activeCurve = nil Task { await fetchActiveCurve() } } - .onAppear { - Task { await fetchActiveCurve() } - } + // .task fires once per view identity (survives tab switches); + // .onAppear would re-fire every time the Temp tab is re-shown. + .task { await fetchActiveCurve() } } // VStack } else { DisconnectedTabView(tab: "Temp") From 2696cff358bb7a449bb621a018ecfa0b0ecaef6a Mon Sep 17 00:00:00 2001 From: Jonathan Ng Date: Sun, 12 Apr 2026 23:32:15 -0700 Subject: [PATCH 2/2] fix: CI build + address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StatusManager / SleepCurve / LoadingView: drop pre-existing dead vars and the deprecated UIScreen.main reference that were failing CI under SWIFT_TREAT_WARNINGS_AS_ERRORS. Loading view now uses a fixed minHeight instead of UIScreen.main.bounds. - DeviceManager: add hasLiveFetched flag so cached cold-launch state doesn't claim isConnected=true when the pod is unreachable. Cache still hydrates the UI immediately, but disconnect surfaces correctly until a real fetch confirms reachability. - SleepypodCoreClient.getDeviceStatus: make health.system non-fatal (matches wifi). Polling no longer breaks if the health endpoint flakes. - SleepypodApp.WelcomeScreen.onConnect: connect first, then poll — matches the .task startup order so startPolling's skipFirst guard kicks in and avoids a duplicate initial fetch. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sleepypod/Models/SleepCurve.swift | 1 - Sleepypod/Networking/SleepypodCoreClient.swift | 7 ++++--- Sleepypod/Services/DeviceManager.swift | 17 +++++++++++++---- Sleepypod/Services/StatusManager.swift | 10 ---------- Sleepypod/SleepypodApp.swift | 10 +++++++--- Sleepypod/Views/Temp/LoadingView.swift | 2 +- 6 files changed, 25 insertions(+), 22 deletions(-) diff --git a/Sleepypod/Models/SleepCurve.swift b/Sleepypod/Models/SleepCurve.swift index 2601f3e..289db63 100644 --- a/Sleepypod/Models/SleepCurve.swift +++ b/Sleepypod/Models/SleepCurve.swift @@ -42,7 +42,6 @@ struct SleepCurve { } let sleepDuration = wake.timeIntervalSince(bedtime) - let baseOffsets = coolingIntensity.offsets // Map intensity ratios to the user's actual temp range // Deep sleep hits min, pre-wake hits max diff --git a/Sleepypod/Networking/SleepypodCoreClient.swift b/Sleepypod/Networking/SleepypodCoreClient.swift index 713821e..fa09af6 100644 --- a/Sleepypod/Networking/SleepypodCoreClient.swift +++ b/Sleepypod/Networking/SleepypodCoreClient.swift @@ -35,8 +35,9 @@ final class SleepypodCoreClient: SleepypodProtocol, @unchecked Sendable { BatchCall(procedure: "system.wifiStatus", input: nil) ]) let status = try decoder.decode(TRPCDeviceStatus.self, from: results[0].get()) - let health = try decoder.decode(TRPCSystemHealth.self, from: results[1].get()) - let wifi = try? decoder.decode(TRPCWifiStatus.self, from: results[2].get()) + // health and wifi are non-essential metadata — don't fail polling if they flake + let health = tryDecode(TRPCSystemHealth.self, from: results[1]) + let wifi = tryDecode(TRPCWifiStatus.self, from: results[2]) return DeviceStatus( left: SideStatus( @@ -63,7 +64,7 @@ final class SleepypodCoreClient: SleepypodProtocol, @unchecked Sendable { coverVersion: status.sensorLabel, hubVersion: status.podVersion, freeSleep: FreeSleepInfo( - version: health.status == "ok" ? "core" : "core (degraded)", + version: health?.status == "ok" ? "core" : "core (degraded)", branch: "main" ), wifiStrength: wifi?.signal ?? 0 diff --git a/Sleepypod/Services/DeviceManager.swift b/Sleepypod/Services/DeviceManager.swift index 25622a6..3156bf4 100644 --- a/Sleepypod/Services/DeviceManager.swift +++ b/Sleepypod/Services/DeviceManager.swift @@ -36,13 +36,18 @@ final class DeviceManager { self.api = api // Cold-launch hydration: show last-known status immediately so the UI // doesn't sit on a "Disconnected" screen for the ~300ms of the first - // fetch. Fresh status overwrites this within a few hundred ms. + // fetch. isConnected stays false until a live fetch confirms the pod + // is actually reachable — otherwise stale cache could mask an outage. if let cached = Self.loadCachedStatus() { self.deviceStatus = cached self.isConnected = true } } + /// Becomes true after the first successful network fetch since launch. + /// Used to prevent stale cache from indefinitely claiming "connected". + private var hasLiveFetched = false + private static let cacheKey = "cachedDeviceStatus" private static func loadCachedStatus() -> DeviceStatus? { @@ -103,6 +108,7 @@ final class DeviceManager { let newStatus = frame.toDeviceStatus(preserving: deviceStatus) deviceStatus = newStatus cacheStatus(newStatus) + hasLiveFetched = true isConnected = true isConnecting = false retryCount = 0 @@ -144,15 +150,18 @@ final class DeviceManager { let status = try await api.getDeviceStatus() deviceStatus = status cacheStatus(status) + hasLiveFetched = true isConnected = true isConnecting = false retryCount = 0 error = nil lastUpdated = Date() } catch { - // Only mark disconnected if we've never had a successful connection. - // Once connected, keep showing last-known status on transient failures. - if deviceStatus == nil { + // Until we've had a live fetch this session, treat failure as + // disconnected — stale cache shouldn't mask a real outage. After + // a confirmed live fetch, keep showing last-known on transient + // failures (network blip during polling). + if !hasLiveFetched { isConnected = false } isConnecting = false diff --git a/Sleepypod/Services/StatusManager.swift b/Sleepypod/Services/StatusManager.swift index de51603..cfaa811 100644 --- a/Sleepypod/Services/StatusManager.swift +++ b/Sleepypod/Services/StatusManager.swift @@ -28,9 +28,6 @@ final class StatusManager { // Compute next alarm subtitle from schedule data let alarmSubtitle = Self.nextAlarmSubtitle(from: schedules) - // Compute system date subtitle - let systemSubtitle = Self.systemDateSubtitle(from: status.systemDate) - return [ ServiceCategory( name: "Core", @@ -108,13 +105,6 @@ final class StatusManager { return "No alarms scheduled" } - private static func systemDateSubtitle(from info: StatusInfo) -> String? { - // If the message contains a date, try to parse and show drift - // Otherwise just show the message if it's useful - guard !info.message.isEmpty, info.message != "OK" else { return nil } - return info.message - } - var healthyCount: Int { serverStatus?.healthyCount ?? 0 } diff --git a/Sleepypod/SleepypodApp.swift b/Sleepypod/SleepypodApp.swift index a469c24..af2943d 100644 --- a/Sleepypod/SleepypodApp.swift +++ b/Sleepypod/SleepypodApp.swift @@ -125,10 +125,14 @@ struct ContentView: View { .fullScreenCover(isPresented: $showWelcome) { WelcomeScreen(onConnect: { // Dismiss welcome, show the main app with DisconnectedTabView - // which has step indicators, manual IP entry, and retry + // which has step indicators, manual IP entry, and retry. + // Match the .task startup order: connect first, then poll, so + // startPolling()'s skipFirst guard avoids a duplicate fetch. showWelcome = false - deviceManager.startPolling() - Task { await startConnection() } + Task { + await startConnection() + deviceManager.startPolling() + } }, onDemo: { showWelcome = false enterDemoMode() diff --git a/Sleepypod/Views/Temp/LoadingView.swift b/Sleepypod/Views/Temp/LoadingView.swift index 8db2444..24d1223 100644 --- a/Sleepypod/Views/Temp/LoadingView.swift +++ b/Sleepypod/Views/Temp/LoadingView.swift @@ -39,7 +39,7 @@ struct LoadingView: View { Spacer() } - .frame(maxWidth: .infinity, minHeight: UIScreen.main.bounds.height * 0.6) + .frame(maxWidth: .infinity, minHeight: 480) .onAppear { withAnimation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true)) { ringScale = 1.0