Skip to content

perf: batch tRPC calls, cache device status, fix duplicate fetches#41

Merged
ng merged 2 commits intodevfrom
perf/batch-endpoints-and-cold-launch
Apr 13, 2026
Merged

perf: batch tRPC calls, cache device status, fix duplicate fetches#41
ng merged 2 commits intodevfrom
perf/batch-endpoints-and-cold-launch

Conversation

@ng
Copy link
Copy Markdown
Contributor

@ng ng commented Apr 13, 2026

Summary

Closes the gap between iOS and web connect/load perf. The iOS client was firing unbatched sequential HTTP requests where web uses @trpc/client's httpBatchLink. Cold launch showed a blank "Disconnected" state for ~300ms waiting on the first fetch. Schedule writes were silently failing the server's max(100) cap once AI-curve entries accumulated.

Networking

  • Added batchQuery helper mirroring httpBatchLink's wire format (GET /api/trpc/a,b,c?batch=1&input=…). Per-slot success/failure so non-critical procs can fail independently.
  • getDeviceStatus (hot path, 10s poll): 3 sequential procs → 1 batched round trip. Dropped an unused settings.getAll call that was dead code.
  • getServerStatus (Status tab): 7 sequential procs → 1 batched.
  • Query timeout 30s → 8s; mutate 30s → 15s. Cold-launch failure on a stale saved IP now surfaces in 8s instead of 120s.
  • validateResponse logs the tRPC error body on HTTP 4xx/5xx so we stop swallowing 400s silently.

Schedule writes

Cold launch

  • DeviceManager persists deviceStatus to UserDefaults on every successful fetch (HTTP + WebSocket).
  • Hydrates from cache in init so Temp screen renders last-known values immediately — no more 300ms blank flash.
  • Trade-off: briefly shows cached "connected" state on launch even if the pod is currently unreachable. Fresh fetch arrives within ~300ms and flips to real state. Mirrors how web clients show cached React state.
  • startPolling skips its first immediate tick when deviceStatus != nil, eliminating a redundant cold-start fetch that ran concurrently with startConnection.

Duplicate fetches

  • HealthScreen: refresh() was firing metricsManager.fetchAll (4 parallel) → then a local fetchVitals that duplicated what fetchAll had just fetched → then a checkCalibration that duplicated fetchVitals's inline calibration. 6 requests → 5, now fully parallelized via async let. Removed the redundant local @State vitals that was shadow-storing metricsManager.vitalsRecords.
  • TempScreen: .onAppear.task for fetchActiveCurve. onAppear re-fires on every tab switch; .task fires once per view identity. Stops the runOnce.getActive + environment.getLatestAmbientLight double-fires visible in the request log.

UI

  • Temp screen top bar shows Left • just now / Left • 12s ago via TimelineView, auto-updating every 15s. Lets users distinguish cached from fresh data.
  • Schedule curve on Temp screen: sort setpoints by offset-from-bedtime (using power.on, defaulting to 22:00) instead of by HH:mm string. String sort put 03:00 before 22:00, making the banner anchor to the wake-side point and plotting 22:00/23:00 points ~20h into the chart future. Fixes "Now → 7am 48h out" visual bug.

Test plan

  • xcodebuild build for simulator and device (nPhone)
  • Verified on-device: cold launch shows cached data immediately, Last updated indicator ticks correctly
  • Pod log trace shows batched requests replacing sequential ones
  • Clean-slate schedule write (post DB cleanup) completes in a single batchUpdate
  • Manual: apply AI curve to multiple days — should succeed via chunked batches (previously silently rejected at 100-item cap)
  • Manual: verify Biometrics tab no longer fires duplicate vitals/calibration requests

Followups

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Device status caching on launch to show last-known data offline
    • Temperature records sorted by proximity to bedtime
    • Last-updated timestamp added to temperature screen
    • Bulk schedule update and multi-endpoint batching to reduce network chatter
  • Bug Fixes / Improvements

    • Improved server error reporting with truncated response context
    • Start-up polling/connection sequencing optimized to avoid redundant requests
    • Health/vitals now sourced and fetched via centralized manager
    • Reduced timeouts for quicker failure detection; loading view minimum height standardized

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) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 13, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8c4e0113-436d-4664-86c3-7e012c026ea3

📥 Commits

Reviewing files that changed from the base of the PR and between de655ff and 2696cff.

📒 Files selected for processing (6)
  • Sleepypod/Models/SleepCurve.swift
  • Sleepypod/Networking/SleepypodCoreClient.swift
  • Sleepypod/Services/DeviceManager.swift
  • Sleepypod/Services/StatusManager.swift
  • Sleepypod/SleepypodApp.swift
  • Sleepypod/Views/Temp/LoadingView.swift
💤 Files with no reviewable changes (2)
  • Sleepypod/Models/SleepCurve.swift
  • Sleepypod/Services/StatusManager.swift
✅ Files skipped from review due to trivial changes (1)
  • Sleepypod/Views/Temp/LoadingView.swift
🚧 Files skipped from review as they are similar to previous changes (3)
  • Sleepypod/SleepypodApp.swift
  • Sleepypod/Services/DeviceManager.swift
  • Sleepypod/Networking/SleepypodCoreClient.swift

📝 Walkthrough

Walkthrough

Adds tRPC GET batching and bulk schedule mutations, introduces UserDefaults-backed device status caching with adjusted startup/polling order, refactors several views to use manager-provided state, and tweaks timeouts/response validation and sorting/display logic in temperature UI.

Changes

Cohort / File(s) Summary
API Client & Batching
Sleepypod/Networking/SleepypodCoreClient.swift
Added batchQuery to coalesce multiple tRPC GETs and return per-slot results; added BatchCall/tryDecode; rewrote updateSchedules to accumulate deletes/creates and call schedules.batchUpdate with per-100-element chunking; lowered GET timeout to 8s and POST to 15s; extended validateResponse to log truncated body with procedure context.
Device State Caching & Startup
Sleepypod/Services/DeviceManager.swift, Sleepypod/SleepypodApp.swift
DeviceManager now loads/saves DeviceStatus from UserDefaults, sets hasLiveFetched and avoids flipping isConnected on transient failures after a live fetch; switchBackend clears cache; app startup/poll ordering revised to await connection/fetch before starting polling (demo and non-demo paths).
Views: Health & Temp
Sleepypod/Views/Data/HealthScreen.swift, Sleepypod/Views/Temp/TempScreen.swift, Sleepypod/Views/Temp/LoadingView.swift
HealthScreen removed local vitals state, uses metricsManager.vitalsRecords and concurrently fetches vitals+calibration; TempScreen uses bedtime-relative sorting, adds time helpers and periodic last-updated TimelineView, replaced .onAppear with .task; LoadingView min-height fixed to 480.
Models & Managers Minor
Sleepypod/Models/SleepCurve.swift, Sleepypod/Services/StatusManager.swift
Removed an unused local binding in SleepCurve.generate; removed systemDateSubtitle(from:) and its use in categories, simplifying category subtitles.

Sequence Diagram(s)

sequenceDiagram
    participant UI as App/UI
    participant DM as DeviceManager
    participant Client as SleepypodCoreClient
    participant Server as SleepypodCoreServer
    participant UD as UserDefaults

    UI->>DM: startup / user action
    DM->>UD: loadCachedStatus()
    alt cached status exists
        UD-->>DM: cached DeviceStatus
        DM-->>UI: populate UI (isConnected = true)
    else no cache
        DM->>Client: batchQuery([health,wifi,...]) / fetchStatus()
        Client->>Server: HTTP batch GET (tRPC)
        Server-->>Client: batch envelope (array of slots)
        Client->>Client: parse slots -> per-slot Result<Data,Error>
        Client-->>DM: decoded DeviceStatus(s)
        DM->>UD: cacheStatus(status)
        DM-->>UI: update UI (hasLiveFetched = true)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

released

Poem

🐰 I hopped through batches, coalesced each call,
Cached a heartbeat so the app won't stall,
Bedtime-sorted temps in a cozy heap,
Chunked schedules bundled for a smoother sweep,
A tiny rabbit cheers — hop, commit, and nap! 🥕✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 46.15% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and accurately summarizes the three main objectives of the changeset: batching tRPC calls, caching device status, and eliminating duplicate fetches.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/batch-endpoints-and-cold-launch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sleepypod/Networking/SleepypodCoreClient.swift`:
- Around line 32-39: The batch response decoding currently treats the
health.system slot as fatal; update the decoding in SleepypodCoreClient.swift so
that only the device.getStatus decode (from results[0].get()) remains throwing,
but decode the health.system slot (results[1].get()) the same way as wifi (use
try? decoder.decode(TRPCSystemHealth.self, from: ...)) and if it fails
substitute a sensible default TRPCSystemHealth metadata value; keep the wifi
decode as-is (try?) and leave batchQuery and device.getStatus logic unchanged so
polling continues even if health.system flakes.

In `@Sleepypod/Services/DeviceManager.swift`:
- Around line 37-43: The init() cold-launch hydration sets deviceStatus and
isConnected from cache which causes stale cached snapshots to look "connected"
after a failed first fetch; change the logic to track whether the current status
is cached vs live (e.g. add a hasLiveStatus or lastUpdated flag) when loading
cache in init() and set isConnected only if hasLiveStatus is true, then update
fetchStatus() to set hasLiveStatus = true and isConnected = true on a successful
network response and set hasLiveStatus = false and isConnected = false on a
failed fetch (even if deviceStatus is non‑nil) so cached snapshots no longer
cause the UI to render as connected indefinitely (refer to init(),
fetchStatus(), deviceStatus, isConnected and add hasLiveStatus/lastUpdated).

In `@Sleepypod/SleepypodApp.swift`:
- Around line 153-156: WelcomeScreen.onConnect currently calls
deviceManager.startPolling() before invoking startConnection(), causing a
duplicate fetchStatus() on manual connect; change the order in
WelcomeScreen.onConnect to await startConnection() first and then call
deviceManager.startPolling() so it matches the .task startup flow and lets
startPolling()/skipFirst logic skip the redundant initial fetchStatus() call.

In `@Sleepypod/Views/Data/HealthScreen.swift`:
- Around line 17-18: The view binds vitals directly to
MetricsManager.vitalsRecords which can update when fetchVitals completes before
other fetches finish, causing inconsistent UI; modify HealthScreen.swift to stop
reading metricsManager.vitalsRecords directly and instead snapshot the results
after the full fetchAll completes (e.g., after awaiting metricsTask in the
caller) or change MetricsManager.fetchAll() to publish atomically only after all
sub-fetches (fetchVitals, calibration, sleep-analysis) finish; specifically,
replace the computed property private var vitals: [VitalsRecord] {
metricsManager.vitalsRecords } with a local stored snapshot updated once after
await metricsTask (or adjust fetchAll() to commit a single published state) so
charts never observe mid-refresh intermediate vitalsRecords.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ba49964f-e24c-4a2a-8cf9-f03e177f1251

📥 Commits

Reviewing files that changed from the base of the PR and between f2b659d and de655ff.

📒 Files selected for processing (5)
  • Sleepypod/Networking/SleepypodCoreClient.swift
  • Sleepypod/Services/DeviceManager.swift
  • Sleepypod/SleepypodApp.swift
  • Sleepypod/Views/Data/HealthScreen.swift
  • Sleepypod/Views/Temp/TempScreen.swift

Comment thread Sleepypod/Networking/SleepypodCoreClient.swift Outdated
Comment thread Sleepypod/Services/DeviceManager.swift
Comment thread Sleepypod/SleepypodApp.swift
Comment on lines +17 to +18
/// Vitals come from MetricsManager's fetchAll — don't re-fetch locally.
private var vitals: [VitalsRecord] { metricsManager.vitalsRecords }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid binding the charts directly to MetricsManager.vitalsRecords mid-refresh.

MetricsManager.fetchAll() publishes vitalsRecords as soon as fetchVitals finishes in Sleepypod/Services/MetricsManager.swift, Lines 65-94, while calibration and the other metric fetches can still be in flight. With vitals now reading that observable directly, this view can briefly show fresh vitals against stale sleep-analysis/calibration state during a side or week change. Snapshot the refreshed records after await metricsTask, or make fetchAll() commit its results atomically.

Also applies to: 357-368

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sleepypod/Views/Data/HealthScreen.swift` around lines 17 - 18, The view binds
vitals directly to MetricsManager.vitalsRecords which can update when
fetchVitals completes before other fetches finish, causing inconsistent UI;
modify HealthScreen.swift to stop reading metricsManager.vitalsRecords directly
and instead snapshot the results after the full fetchAll completes (e.g., after
awaiting metricsTask in the caller) or change MetricsManager.fetchAll() to
publish atomically only after all sub-fetches (fetchVitals, calibration,
sleep-analysis) finish; specifically, replace the computed property private var
vitals: [VitalsRecord] { metricsManager.vitalsRecords } with a local stored
snapshot updated once after await metricsTask (or adjust fetchAll() to commit a
single published state) so charts never observe mid-refresh intermediate
vitalsRecords.

- 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) <noreply@anthropic.com>
@ng ng merged commit fd8b34b into dev Apr 13, 2026
5 checks passed
@ng ng deleted the perf/batch-endpoints-and-cold-launch branch April 13, 2026 06:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant