Skip to content

feat: Use Now split button for run-once curves (#251)#34

Merged
ng merged 14 commits intodevfrom
feature/251-run-once-ui
Mar 29, 2026
Merged

feat: Use Now split button for run-once curves (#251)#34
ng merged 14 commits intodevfrom
feature/251-run-once-ui

Conversation

@ng
Copy link
Copy Markdown
Contributor

@ng ng commented Mar 23, 2026

Summary

Adds "Use Now" button alongside "Apply to Schedule" in SmartCurveView.

  • Apply to Schedule: existing behavior, persists as recurring weekly entries
  • Use Now: regenerates curve with bedtime=now, calls runOnce.start API to apply from now until wake time

Requires sleepypod/core#251 (run-once backend).

Changes

  • BetaModels.swift: RunOnceSession, RunOnceStartResponse, RunOnceSetPoint models
  • SleepypodProtocol.swift: startRunOnce, getActiveRunOnce, cancelRunOnce methods
  • SleepypodCoreClient.swift: tRPC implementations
  • SmartCurveView.swift: split button UI + useNow() function
  • All client stubs (FreeSleep, Mock, Test)

Test plan

  • Split button renders with "Apply to Schedule" and "Use Now"
  • "Apply to Schedule" works as before
  • "Use Now" fires immediately, shows "Started!" confirmation
  • Works for both sides when "Both" is selected

Summary by CodeRabbit

  • New Features

    • Bed temperature trend card with selectable ranges (1H/6H/12H/24H) and charts
    • "Use Now" action to apply a temperature curve immediately (run-once)
    • Active-curve banners showing current run-once sessions with stop controls
    • Dashboard/Temp tab auto-switch to show active curve after starting a run-once
  • Improvements

    • Extended temperature history available to charts (more samples seeded on connect)

ng added 2 commits March 22, 2026 17:32
- New BedTempTrendView: three lines (left/right/ambient), time range
  selector (1H/6H/12H/24H), legend, and summary stats row
- Fetch historical bed temp from tRPC environment.getBedTemp endpoint
- Add BedTempReading model, getBedTempHistory to protocol + all clients
- Fix ISO8601 date parsing to handle fractional seconds
- Guard against stale async responses on rapid range switching
- Remove old WS-only tempTrendCard (replaced by tRPC-backed chart)
- Matches web core's BED TEMPERATURE card
Split the "Apply to Schedule" button into two actions:
- Apply to Schedule: existing behavior, persists as recurring weekly entries
- Use Now: applies curve from now until wake time via runOnce.start API

Adds RunOnceSession model, networking methods (start/getActive/cancel)
to protocol and all client implementations.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 23, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds bed-temperature history viewing and “Run Once” curve lifecycle: models, protocol and client methods, seed-and-live temp buffering, UI to start/monitor/cancel run-once sessions, AI curve now-variant support, and App Store metadata + minor project wiring. (50 words)

Changes

Cohort / File(s) Summary
Xcode Project Wiring
Sleepypod.xcodeproj/project.pbxproj
Wired new Swift sources BedTempTrendView.swift and RunOnceActiveBanner.swift into the app target build phase and appropriate groups.
Data Models
Sleepypod/Models/BetaModels.swift
Added BedTempReading, RunOnceStartResponse, RunOnceSession, RunOnceSetPoint with date parsing and temperature unit conversion helpers.
Networking Protocol
Sleepypod/Networking/SleepypodProtocol.swift
Extended protocol with getBedTempHistory, startRunOnce, getActiveRunOnce, cancelRunOnce.
API Clients
Sleepypod/Networking/FreeSleepClient.swift, Sleepypod/Networking/SleepypodCoreClient.swift, Sleepypod/Networking/MockClient.swift
Implemented new protocol methods across clients: tRPC-backed queries/mutations in core client, stubs in mock/free clients; formatting of dates and payloads included.
Sensor Streaming
Sleepypod/Services/SensorStreamService.swift
Increased temp buffer to 120, added one-time async seedTempHistory() fetch of recent bed-temp history (guarded against race with live frames), and non-fatal error handling for seeding.
Sensor UI
Sleepypod/Views/Sensor/BedSensorScreen.swift, Sleepypod/Views/Sensor/BedTempTrendView.swift
Replaced inline temp-trend card with new BedTempTrendView component (range selector, async fetch, Charts-based lines, stats, placeholders); fetch cancellation guards and loading UX added.
Schedule UI — Smart Curve
Sleepypod/Views/Schedule/SmartCurveView.swift
Added Use Now action and isRunOnce state; implemented useNow() to generate a curve for immediate run-once execution and call api.startRunOnce for one or both sides; posts .switchToTempTab on success.
Schedule UI — AI Curve Prompt
Sleepypod/Views/Schedule/AICurvePromptView.swift
Added Use Now floating button that prefers nowCurve when present or shifts times proportionally; calls api.startRunOnce, triggers haptics, and posts tab-switch notification; minor chart styling adjustments.
Run-Once Active Banner
Sleepypod/Views/Schedule/RunOnceActiveBanner.swift
New RunOnceActiveBanner view showing session wake time, set-point timeline chart with overnight wrapping, “Now” rule mark, and a cancellable “Stop Curve” button.
Status & Temp Integration
Sleepypod/Views/Status/StatusScreen.swift, Sleepypod/Views/Temp/TempScreen.swift, Sleepypod/SleepypodApp.swift
StatusScreen and TempScreen fetch/display active run-once sessions and provide cancel flow; TempScreen maintains ActiveCurve; SleepypodApp listens for .switchToTempTab notification to switch tabs.
Curve Generation
Sleepypod/Services/CurveGenerator.swift
Added optional nowCurve to GeneratedCurve, updated AI prompt schema, parsing/validation/clamping for nowCurve, and helper currentTimeString().
Tests / Mocks
SleepypodTests/MockAPIClient.swift
Mock implementations for bed-temp history and run-once APIs (empty/stubbed responses, no-op cancel).
Docs & Config
metadata/app_store_submission.md, .serena/project.yml
Added App Store submission metadata file; added ls_specific_settings and ignored_memory_patterns config keys to project YAML.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant UI as "SmartCurveView / AICurvePromptView"
participant API as "SleepypodCoreClient / FreeSleepClient"
participant Server as "tRPC / Backend"
participant App as "SleepypodApp / TempScreen"
UI->>API: startRunOnce(side,setPoints,wakeTime)
API->>Server: mutation runOnce.start
Server-->>API: RunOnceStartResponse(sessionId,expiresAt)
API-->>UI: returns RunOnceStartResponse
UI->>App: post Notification(.switchToTempTab)
App->>API: getActiveRunOnce(side)
API->>Server: query runOnce.getActive
Server-->>API: RunOnceSession
API-->>App: RunOnceSession (displayed via RunOnceActiveBanner)

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested labels

released

Poem

🐰 A temp curve blooms, now trending bright,
“Use Now” hops forward to warm the night,
Charts seed the past while banners show the run,
We nudge the API — the session’s begun! 🌙✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.32% 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 accurately describes the primary change: adding a 'Use Now' split button for run-once curves alongside the existing 'Apply to Schedule' button in SmartCurveView.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/251-run-once-ui

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: 7

🧹 Nitpick comments (1)
Sleepypod/Networking/SleepypodProtocol.swift (1)

41-41: Consider using a strongly-typed RunOnceSetPoint array instead of [[String: Any]].

The setPoints: [[String: Any]] parameter loses type safety. Since BetaModels.swift likely defines RunOnceSetPoint, using [RunOnceSetPoint] (or a similar typed structure) would provide compile-time validation and better documentation of the expected shape.

However, if the backend schema is still evolving or the setPoints structure varies, the current approach is a reasonable pragmatic choice.

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

In `@Sleepypod/Networking/SleepypodProtocol.swift` at line 41, The startRunOnce
signature uses untyped dictionaries which loses compile-time safety; change the
parameter type in func startRunOnce(side: Side, setPoints: [[String: Any]],
wakeTime: String) async throws -> RunOnceStartResponse to use a strongly-typed
array (e.g., [RunOnceSetPoint]) and update all callers to pass RunOnceSetPoint
instances; ensure RunOnceSetPoint in BetaModels.swift conforms to the needed
protocols (Codable/Encodable or whatever the networking layer expects) and
adjust any JSON encoding/decoding logic inside the implementation of
startRunOnce to use the typed model instead of raw dictionaries.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@metadata/app_store_submission.md`:
- Around line 32-70: The fenced code blocks in the metadata content (e.g., the
block starting with "sleepypod — local control for your Eight Sleep Pod", the
tags block "sleep,temperature,eight sleep,pod,...", the numbered steps block
beginning "1. On first launch, tap \"Explore Demo\"", and the release notes
block "Initial release — local control for your Eight Sleep Pod.") are untyped
and trigger MD040; update each triple-backtick fence to include a language
identifier (use text) so they read ```text ... ``` to satisfy markdownlint.
- Around line 134-145: The privacy table currently contradicts itself: the
"Collected? = Yes — on-device only" cell for "Health & Fitness (heart rate,
sleep)" is inconsistent with the later narrative "Data linked to user: No — all
data stays on the local device/pod" and "Data used for tracking: No"; reconcile
these by choosing the exact App Store Connect options and matching wording
across both table and narrative — update the "Collected?" column and/or
narrative to use Apple’s precise labels (e.g., mark Health & Fitness as "No" if
truly never collected/retained off‑device, or keep "Yes" and explicitly state
"Collected but not transmitted/off‑device" and ensure "Linked to User?" and
"Used for Tracking?" reflect that choice) and apply the same change for each row
(Health & Fitness, Usage Data, Diagnostics, Location, Contacts, Identifiers) so
the table cells and the bullet points are fully aligned.

In `@Sleepypod/Views/Schedule/SmartCurveView.swift`:
- Around line 225-244: The "Use Now" CTA must be gated by an explicit run-once
capability instead of just isRunOnce; add a boolean (e.g.,
backendSupportsRunOnce) derived from the backend/client capability (query
APIBackend.current or the created client for a run-once feature flag) and change
the Button to only be shown/enabled when both isRunOnce and
backendSupportsRunOnce are true; ensure the action useNow() and the UI states
(isSaving, showSuccess) are only reachable when that capability is present and
otherwise hide or disable the button so users on the legacy backend
(Sleepypod/Networking/APIBackend.swift / APIBackend.current.createClient())
cannot attempt the unsupported action.
- Around line 795-821: The "Use Now" action currently regenerates a curve via
SleepCurve.generate (using roundedNow, intensity, minTemp, maxTemp) which can
silently replace an on-screen custom curve (curve/customCurvePoints) that
applyToSchedule() expects; either change the behavior so the displayed curve's
datetime is shifted to roundedNow before converting to set points (i.e., update
curve/customCurvePoints' bedtime to roundedNow and reuse that curve when
building setPoints), or disable the "Use Now" action when a custom curve is
active (check customCurvePoints/isCustomCurve flag) and prevent
SleepCurve.generate from running; locate the logic around roundedNow,
SleepCurve.generate(...), curve/customCurvePoints, and applyToSchedule() to
implement one of these fixes.
- Around line 822-849: The success UI is shown even when startRunOnce fails or
only one side succeeds; fix by moving the success/state updates (showSuccess,
Haptics, isRunOnce=false) inside the do block after all startRunOnce calls
succeed, capture the session id(s) returned from
APIBackend.current.createClient().startRunOnce(...) for the first side, and if
starting the second side (when scheduleManager.selectedSide == .both) fails,
call api.cancelRunOnce(sessionId: firstSessionId) to unwind the started session;
ensure the catch block sets isSaving = false and performs any necessary
cleanup/logging so partial failures do not leave a running session or show
"Started!".

In `@Sleepypod/Views/Sensor/BedTempTrendView.swift`:
- Around line 211-223: The code hardcodes "F" when calling getBedTempHistory;
instead read the user-selected temperature unit from the app's settings (use the
same API as other views, e.g. SettingsManager or TemperatureConversion used in
SmartCurveView) and pass that unit string into getBedTempHistory; also ensure
TempPoint construction uses the same conversion/formatter (or accepts values
already in the requested unit) so left/right/ambient/humidity values match the
requested unit. Locate the call to
APIBackend.current.createClient().getBedTempHistory and replace the literal "F"
with the settings-derived unit, and mirror the conversion logic/pattern used in
Sleepypod/Views/Schedule/SmartCurveView (TemperatureConversion/SettingsManager)
when creating TempPoint.
- Around line 226-228: In the catch block in BedTempTrendView where the
active-range request currently swallows errors, clear the stale chart state by
resetting the stored series (set points = []), and update any active-range UI
state (e.g., set a “stale”/loading flag or clear the active range variable) so
the view doesn’t continue rendering previous data; also ensure the UI is updated
on the main thread after modifying points.

---

Nitpick comments:
In `@Sleepypod/Networking/SleepypodProtocol.swift`:
- Line 41: The startRunOnce signature uses untyped dictionaries which loses
compile-time safety; change the parameter type in func startRunOnce(side: Side,
setPoints: [[String: Any]], wakeTime: String) async throws ->
RunOnceStartResponse to use a strongly-typed array (e.g., [RunOnceSetPoint]) and
update all callers to pass RunOnceSetPoint instances; ensure RunOnceSetPoint in
BetaModels.swift conforms to the needed protocols (Codable/Encodable or whatever
the networking layer expects) and adjust any JSON encoding/decoding logic inside
the implementation of startRunOnce to use the typed model instead of raw
dictionaries.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a2b697c8-9d4f-4a73-ad59-784a9a56afe0

📥 Commits

Reviewing files that changed from the base of the PR and between 3973880 and a1cf7aa.

📒 Files selected for processing (12)
  • Sleepypod.xcodeproj/project.pbxproj
  • Sleepypod/Models/BetaModels.swift
  • Sleepypod/Networking/FreeSleepClient.swift
  • Sleepypod/Networking/MockClient.swift
  • Sleepypod/Networking/SleepypodCoreClient.swift
  • Sleepypod/Networking/SleepypodProtocol.swift
  • Sleepypod/Services/SensorStreamService.swift
  • Sleepypod/Views/Schedule/SmartCurveView.swift
  • Sleepypod/Views/Sensor/BedSensorScreen.swift
  • Sleepypod/Views/Sensor/BedTempTrendView.swift
  • SleepypodTests/MockAPIClient.swift
  • metadata/app_store_submission.md

Comment on lines +32 to +70
```
sleepypod — local control for your Eight Sleep Pod

Take control of your sleep environment without the cloud. sleepypod connects directly to your Eight Sleep Pod (3, 4, or 5) on your home network, giving you fast, private, and reliable temperature control.

TEMPERATURE CONTROL
- Adjust bed temperature with a precision dial
- Set independent temperatures for each side
- Link both sides for synchronized control

SMART SCHEDULING
- Create temperature curves for the perfect night
- Design custom curves with AI assistance
- Save and reuse your favorite sleep profiles

ON-DEVICE SLEEP ANALYSIS
- Sleep stage classification performed on-device using validated heuristics
- Heart rate and breathing rate extracted from raw piezo signals
- Sleep quality scoring with circadian-aware algorithms
- Track weekly trends and movement patterns

LIVE SENSOR DATA
- Real-time piezo waveform with DSP-based vitals extraction
- Bed temperature matrix with zone-level detail
- Capacitive presence detection with variance analysis
- Data pipeline visualization showing live sensor data flow

HAPTIC ALARMS
- Configure vibration wake-up alarms
- Choose from preset patterns or create custom ones
- Set per-side alarm schedules

PRIVACY FIRST
- Data is processed and stored locally on your pod — no cloud account required
- No telemetry or tracking
- Your sleep data belongs to you

Requires an Eight Sleep Pod (3, 4, or 5) with sleepypod-core installed and running on your home network. Use "Explore Demo" on first launch to preview the full app experience without hardware.
```
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 | 🟡 Minor

Add language identifiers to fenced blocks to clear MD040 warnings.

These code fences are untyped and trigger markdownlint (MD040). Add explicit languages (for example text) to keep docs CI clean.

Suggested patch
-```
+```text
 sleepypod — local control for your Eight Sleep Pod
 ...
 Requires an Eight Sleep Pod (3, 4, or 5) with sleepypod-core installed and running on your home network. Use "Explore Demo" on first launch to preview the full app experience without hardware.

@@
- +text
sleep,temperature,eight sleep,pod,mattress,sleep tracking,heart rate,HRV,smart bed,local control

@@
-```
+```text
1. On first launch, tap "Explore Demo" to use the app with simulated data
...
To exit demo: Settings gear (top-right) → "Connect to Real Pod"

@@
- +text
Initial release — local control for your Eight Sleep Pod.
...
• Demo mode for exploring without hardware

Also applies to: 74-76, 105-120, 149-158

🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 32-32: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

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

In `@metadata/app_store_submission.md` around lines 32 - 70, The fenced code
blocks in the metadata content (e.g., the block starting with "sleepypod — local
control for your Eight Sleep Pod", the tags block "sleep,temperature,eight
sleep,pod,...", the numbered steps block beginning "1. On first launch, tap
\"Explore Demo\"", and the release notes block "Initial release — local control
for your Eight Sleep Pod.") are untyped and trigger MD040; update each
triple-backtick fence to include a language identifier (use text) so they read
```text ... ``` to satisfy markdownlint.

Comment on lines +134 to +145
| Data Type | Collected? | Linked to User? | Used for Tracking? |
|-----------|-----------|-----------------|-------------------|
| Health & Fitness (heart rate, sleep) | Yes — on-device only | No | No |
| Usage Data | No | — | — |
| Diagnostics | No | — | — |
| Location | No | — | — |
| Contacts | No | — | — |
| Identifiers | No | — | — |

- **Data collection purpose**: App Functionality (sleep tracking, temperature control)
- **Data linked to user**: No — all data stays on the local device/pod
- **Data used for tracking**: No
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

Reconcile privacy-label wording before App Store submission.

Collected? = Yes — on-device only conflicts with the later claim that data stays local and is not linked/tracked. If this table is used directly for App Store Connect answers, it can create a privacy/compliance mismatch during review. Please align this section with the exact App Privacy definitions you intend to submit.

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

In `@metadata/app_store_submission.md` around lines 134 - 145, The privacy table
currently contradicts itself: the "Collected? = Yes — on-device only" cell for
"Health & Fitness (heart rate, sleep)" is inconsistent with the later narrative
"Data linked to user: No — all data stays on the local device/pod" and "Data
used for tracking: No"; reconcile these by choosing the exact App Store Connect
options and matching wording across both table and narrative — update the
"Collected?" column and/or narrative to use Apple’s precise labels (e.g., mark
Health & Fitness as "No" if truly never collected/retained off‑device, or keep
"Yes" and explicitly state "Collected but not transmitted/off‑device" and ensure
"Linked to User?" and "Used for Tracking?" reflect that choice) and apply the
same change for each row (Health & Fitness, Usage Data, Diagnostics, Location,
Contacts, Identifiers) so the table cells and the bullet points are fully
aligned.

Comment thread Sleepypod/Views/Schedule/SmartCurveView.swift Outdated
Comment thread Sleepypod/Views/Schedule/SmartCurveView.swift Outdated
Comment on lines +822 to +849
Task {
do {
let api = APIBackend.current.createClient()
let _ = try await api.startRunOnce(
side: side,
setPoints: setPoints,
wakeTime: wakeTimeStr
)

// If both sides, start the other side too
if scheduleManager.selectedSide == .both {
let otherSide: Side = side == .left ? .right : .left
let _ = try await api.startRunOnce(
side: otherSide,
setPoints: setPoints,
wakeTime: wakeTimeStr
)
}
} catch {
Log.general.error("Failed to start run-once: \(error)")
}

isSaving = false
withAnimation { showSuccess = true }
Haptics.heavy()
try? await Task.sleep(for: .seconds(2))
withAnimation { showSuccess = false }
isRunOnce = false
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

Only show “Started!” after every run-once call succeeds.

The success UI runs after the catch, so a first-call failure—or a second-side failure when .both is selected—still ends in “Started!”. In the partial-failure case, one side can remain running with no cleanup. Move the success state into the do block, and use cancelRunOnce(sessionId:) to unwind the first session if the second start fails.

Proposed fix
         Task {
+            let api = APIBackend.current.createClient()
+            var startedSessionId: Int?
             do {
-                let api = APIBackend.current.createClient()
-                let _ = try await api.startRunOnce(
+                let started = try await api.startRunOnce(
                     side: side,
                     setPoints: setPoints,
                     wakeTime: wakeTimeStr
                 )
+                startedSessionId = started.sessionId
 
                 // If both sides, start the other side too
                 if scheduleManager.selectedSide == .both {
                     let otherSide: Side = side == .left ? .right : .left
-                    let _ = try await api.startRunOnce(
+                    _ = try await api.startRunOnce(
                         side: otherSide,
                         setPoints: setPoints,
                         wakeTime: wakeTimeStr
                     )
                 }
+
+                isSaving = false
+                withAnimation { showSuccess = true }
+                Haptics.heavy()
+                try? await Task.sleep(for: .seconds(2))
+                withAnimation { showSuccess = false }
+                isRunOnce = false
             } catch {
+                if let startedSessionId, scheduleManager.selectedSide == .both {
+                    try? await api.cancelRunOnce(sessionId: startedSessionId)
+                }
                 Log.general.error("Failed to start run-once: \(error)")
+                isSaving = false
+                isRunOnce = false
+                return
             }
-
-            isSaving = false
-            withAnimation { showSuccess = true }
-            Haptics.heavy()
-            try? await Task.sleep(for: .seconds(2))
-            withAnimation { showSuccess = false }
-            isRunOnce = false
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sleepypod/Views/Schedule/SmartCurveView.swift` around lines 822 - 849, The
success UI is shown even when startRunOnce fails or only one side succeeds; fix
by moving the success/state updates (showSuccess, Haptics, isRunOnce=false)
inside the do block after all startRunOnce calls succeed, capture the session
id(s) returned from APIBackend.current.createClient().startRunOnce(...) for the
first side, and if starting the second side (when scheduleManager.selectedSide
== .both) fails, call api.cancelRunOnce(sessionId: firstSessionId) to unwind the
started session; ensure the catch block sets isSaving = false and performs any
necessary cleanup/logging so partial failures do not leave a running session or
show "Started!".

Comment thread Sleepypod/Views/Sensor/BedTempTrendView.swift
Comment thread Sleepypod/Views/Sensor/BedTempTrendView.swift
ng added 2 commits March 22, 2026 23:50
- Active curve banner on Temp/Schedule/Status screens with chart,
  NOW marker, and Stop button
- Stop Curve powers off the heater
- Fade in/out animations on banner
- AI curve wizard: Save | Apply | Use Now split bar
- Use Now time-shifts curve proportionally (bedtime=now, wake preserved)
- Switches to Temp tab after Use Now
- Chart: single accent line + phase-colored dots, centered legend
- NOW line uses minute-offset interpolation for accurate positioning
- X-axis: 4 evenly spaced ticks, rotated labels, bottom padding
The AI prompt now asks for two curves:
- "points": reusable template curve (bedtime to wake, as before)
- "nowCurve": properly shaped curve starting from current time to wake

When the user taps "Use Now", the AI's nowCurve is used if available
(properly adapted phases for shorter duration). Falls back to
proportional time-shifting if nowCurve is missing.

Also: chart line uses single accent color, dots show phase colors,
legend centered.
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: 3

🧹 Nitpick comments (4)
Sleepypod/Views/Schedule/AICurvePromptView.swift (1)

932-936: Duplicate helper function.

hhmm(_:) duplicates clockMinutes(_:) from RunOnceActiveBanner.swift (Lines 118-122). Consider extracting to a shared utility.

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

In `@Sleepypod/Views/Schedule/AICurvePromptView.swift` around lines 932 - 936, The
hhmm(_:) helper in AICurvePromptView duplicates clockMinutes(_:) from
RunOnceActiveBanner; extract a shared utility (e.g., a new function in a common
TimeUtils.swift or an existing shared helper) that implements the time string
"HH:mm" -> total minutes logic, replace both hhmm(_:) and clockMinutes(_:) to
call that shared function, remove the duplicate implementation from
AICurvePromptView and RunOnceActiveBanner, and update any references in those
files to use the new shared function name to avoid duplication.
Sleepypod/Views/Schedule/ScheduleScreen.swift (2)

60-75: Cancel logic duplicated across screens.

The cancel handler (Lines 63-72) duplicates the same pattern found in TempScreen.stopCurve() (see context snippet 1). Consider extracting to a shared helper or view model method.

♻️ Example: Extract to a shared helper
// In a shared location (e.g., RunOnceHelpers.swift or extension on DeviceManager)
func cancelRunOnceAndPowerOff(side: Side) async {
    let api = APIBackend.current.createClient()
    try? await api.cancelRunOnce(side: side)
    let powerOff = SideStatusUpdate(isOn: false)
    var update = DeviceStatusUpdate()
    if side == .left { update.left = powerOff } else { update.right = powerOff }
    try? await api.updateDeviceStatus(update)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sleepypod/Views/Schedule/ScheduleScreen.swift` around lines 60 - 75, The
cancel handler logic used in RunOnceActiveBanner (inside ScheduleScreen where
activeRunOnce is handled) is duplicated with TempScreen.stopCurve(); extract it
into a shared async helper (e.g., cancelRunOnceAndPowerOff(side:)) located in a
common utility or view-model so both callers reuse it: move the calls that
create the client via APIBackend.current.createClient(), call
cancelRunOnce(side:), construct SideStatusUpdate(isOn: false) and
DeviceStatusUpdate, set the correct left/right field, then call
updateDeviceStatus(update); replace the inline Task blocks in ScheduleScreen
(RunOnceActiveBanner closure) and TempScreen.stopCurve() to call that shared
helper and await it, keeping the withAnimation { activeRunOnce = nil } animation
local to ScheduleScreen.

163-169: Potential double-fetch on initial load.

Both .onAppear (Line 165) and .task(id: scheduleManager.selectedSide) (Lines 167-169) will trigger fetchActiveRunOnce() on initial load since .task(id:) also runs immediately. Consider removing the .onAppear call or using a flag to skip the redundant fetch.

💡 Suggested simplification
     .onAppear {
         savedTemplates = CurveTemplate.loadAll()
-        Task { await fetchActiveRunOnce() }
     }
     .task(id: scheduleManager.selectedSide) {
         await fetchActiveRunOnce()
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sleepypod/Views/Schedule/ScheduleScreen.swift` around lines 163 - 169, The
code currently calls fetchActiveRunOnce() both in .onAppear and in .task(id:
scheduleManager.selectedSide), causing a redundant initial fetch; remove the
Task { await fetchActiveRunOnce() } inside .onAppear and keep savedTemplates =
CurveTemplate.loadAll() there, relying on .task(id:
scheduleManager.selectedSide) to run initially and on selection changes to
perform fetchActiveRunOnce(); alternatively, if you prefer .onAppear to own the
initial fetch, remove the .task block instead — but do not call
fetchActiveRunOnce() in both places.
Sleepypod/Views/Schedule/RunOnceActiveBanner.swift (1)

118-127: Silent failure on malformed time strings.

clockMinutes(_:) returns 0 for any invalid format (e.g., missing colon, non-numeric parts). This could cause incorrect chart positioning without any error indication. Consider logging a warning for debugging purposes.

💡 Optional: Add debug logging for malformed times
 private func clockMinutes(_ 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 }
+    guard parts.count == 2, let h = Int(parts[0]), let m = Int(parts[1]) else {
+        `#if` DEBUG
+        print("⚠️ RunOnceActiveBanner: Invalid time format '\(time)'")
+        `#endif`
+        return 0
+    }
     return h * 60 + m
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sleepypod/Views/Schedule/RunOnceActiveBanner.swift` around lines 118 - 127,
clockMinutes(_:) currently swallows malformed time strings by returning 0;
update it to emit a debug/warning log when the guard fails (include the raw time
string and context like "RunOnceActiveBanner" or the affected function name)
before returning 0 so malformed inputs are visible during debugging, and keep
minuteOffset(_:) behavior unchanged to preserve downstream behavior. Ensure the
logging uses the app's existing logging facility (not just print) so logs are
consistent with other diagnostics.
🤖 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/Views/Schedule/AICurvePromptView.swift`:
- Around line 880-930: The success UI (withAnimation { isRunOnce = true } and
NotificationCenter.post) is outside the do/catch in useNow(), so failures
(including a failing second startRunOnce when selectedSide == .both) still show
success and don't roll back the first side; move the success handling so it only
runs after all API calls succeed: perform the first startRunOnce, then if
scheduleManager.selectedSide == .both perform the second, and only after both
awaits succeed set isRunOnce, call Haptics.success(), sleep, and post
.switchToTempTab; if the second call fails attempt to rollback the
already-started side by calling the appropriate API stop/cancel method (e.g.,
the client method that cancels a run-once) inside the catch before rethrowing or
logging so partial starts are undone and the success UI is never shown.

In `@Sleepypod/Views/Schedule/RunOnceActiveBanner.swift`:
- Around line 79-100: The Stop button sets isCancelling = true but never resets
it if onCancel returns without dismissing the banner, causing a stuck disabled
UI; update the RunOnceActiveBanner's onCancel signature to accept a completion
callback (e.g., onCancel: (`@escaping` () -> Void) -> Void) and change the Button
action (in the Button/HStack block) to set isCancelling = true, call onCancel {
isCancelling = false } so the caller can signal completion and the UI will
re-enable the button; alternatively, if changing the API is undesired, add a
fallback reset by scheduling a short DispatchQueue.main.asyncAfter timeout to
set isCancelling = false after a few seconds. Ensure you update all call sites
(e.g., TempScreen and ScheduleScreen usages) to call the new onCancel completion
when their cancellation work finishes.
- Around line 140-145: nowMinuteOffset can exceed the chart's visible domain and
place the "Now" RuleMark off-screen; update the computed property
nowMinuteOffset to clamp its result into the visible range [0, totalMinuteSpan]
(use anchorMinutes and totalMinuteSpan to compute the raw offset as before and
then apply min/max or a clamp) so the "Now" marker is always inside the chart
bounds referenced by totalMinuteSpan and used by the RuleMark rendering.

---

Nitpick comments:
In `@Sleepypod/Views/Schedule/AICurvePromptView.swift`:
- Around line 932-936: The hhmm(_:) helper in AICurvePromptView duplicates
clockMinutes(_:) from RunOnceActiveBanner; extract a shared utility (e.g., a new
function in a common TimeUtils.swift or an existing shared helper) that
implements the time string "HH:mm" -> total minutes logic, replace both hhmm(_:)
and clockMinutes(_:) to call that shared function, remove the duplicate
implementation from AICurvePromptView and RunOnceActiveBanner, and update any
references in those files to use the new shared function name to avoid
duplication.

In `@Sleepypod/Views/Schedule/RunOnceActiveBanner.swift`:
- Around line 118-127: clockMinutes(_:) currently swallows malformed time
strings by returning 0; update it to emit a debug/warning log when the guard
fails (include the raw time string and context like "RunOnceActiveBanner" or the
affected function name) before returning 0 so malformed inputs are visible
during debugging, and keep minuteOffset(_:) behavior unchanged to preserve
downstream behavior. Ensure the logging uses the app's existing logging facility
(not just print) so logs are consistent with other diagnostics.

In `@Sleepypod/Views/Schedule/ScheduleScreen.swift`:
- Around line 60-75: The cancel handler logic used in RunOnceActiveBanner
(inside ScheduleScreen where activeRunOnce is handled) is duplicated with
TempScreen.stopCurve(); extract it into a shared async helper (e.g.,
cancelRunOnceAndPowerOff(side:)) located in a common utility or view-model so
both callers reuse it: move the calls that create the client via
APIBackend.current.createClient(), call cancelRunOnce(side:), construct
SideStatusUpdate(isOn: false) and DeviceStatusUpdate, set the correct left/right
field, then call updateDeviceStatus(update); replace the inline Task blocks in
ScheduleScreen (RunOnceActiveBanner closure) and TempScreen.stopCurve() to call
that shared helper and await it, keeping the withAnimation { activeRunOnce = nil
} animation local to ScheduleScreen.
- Around line 163-169: The code currently calls fetchActiveRunOnce() both in
.onAppear and in .task(id: scheduleManager.selectedSide), causing a redundant
initial fetch; remove the Task { await fetchActiveRunOnce() } inside .onAppear
and keep savedTemplates = CurveTemplate.loadAll() there, relying on .task(id:
scheduleManager.selectedSide) to run initially and on selection changes to
perform fetchActiveRunOnce(); alternatively, if you prefer .onAppear to own the
initial fetch, remove the .task block instead — but do not call
fetchActiveRunOnce() in both places.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a81db560-c6aa-4a56-93b4-a09e5e4d7b19

📥 Commits

Reviewing files that changed from the base of the PR and between a1cf7aa and 876f839.

📒 Files selected for processing (8)
  • Sleepypod.xcodeproj/project.pbxproj
  • Sleepypod/SleepypodApp.swift
  • Sleepypod/Views/Schedule/AICurvePromptView.swift
  • Sleepypod/Views/Schedule/RunOnceActiveBanner.swift
  • Sleepypod/Views/Schedule/ScheduleScreen.swift
  • Sleepypod/Views/Schedule/SmartCurveView.swift
  • Sleepypod/Views/Status/StatusScreen.swift
  • Sleepypod/Views/Temp/TempScreen.swift
✅ Files skipped from review due to trivial changes (2)
  • Sleepypod/Views/Status/StatusScreen.swift
  • Sleepypod.xcodeproj/project.pbxproj

Comment on lines +880 to +930
private func useNow() {
guard let result = parsedResult else { return }
let side = scheduleManager.selectedSide.primarySide

// Shift curve times: remap so bedtime=now, wake stays the same
let now = Date()
let cal = Calendar.current
let nowMins = cal.component(.hour, from: now) * 60 + cal.component(.minute, from: now)
let roundedNow = (nowMins / 5) * 5 // round to nearest 5 min
let bedMins = hhmm(result.bedtime)
let wakeMins = hhmm(result.wake)
let originalSpan = (wakeMins - bedMins + 1440) % 1440
let newSpan = (wakeMins - roundedNow + 1440) % 1440

let setPoints: [[String: Any]] = editablePoints.map { p in
let ptMins = hhmm(p.time)
let origOffset = (ptMins - bedMins + 1440) % 1440
// Scale proportionally into the new span
let newOffset = originalSpan > 0 ? origOffset * newSpan / originalSpan : origOffset
let newMins = (roundedNow + newOffset) % 1440
let newTime = String(format: "%02d:%02d", newMins / 60, newMins % 60)
return ["time": newTime, "temperature": p.tempF]
}

Task {
do {
let api = APIBackend.current.createClient()
let _ = try await api.startRunOnce(
side: side,
setPoints: setPoints,
wakeTime: result.wake
)

if scheduleManager.selectedSide == .both {
let other: Side = side == .left ? .right : .left
let _ = try await api.startRunOnce(
side: other,
setPoints: setPoints,
wakeTime: result.wake
)
}

withAnimation { isRunOnce = true }
Haptics.success()
try? await Task.sleep(for: .seconds(1))
NotificationCenter.default.post(name: .switchToTempTab, object: nil)
} catch {
Log.general.error("Failed to start run-once from AI curve: \(error)")
}
}
}
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

Success UI shown even when API calls fail.

Similar to the past review comment on SmartCurveView (Lines 822-849), the success handling (Lines 922-925) is outside the do block, so failures still show "Started!" and post the tab switch notification. Additionally, if selectedSide == .both and the second call fails, the first side remains running with no rollback.

🐛 Proposed fix: Move success handling inside do block and handle partial failures
         Task {
+            let api = APIBackend.current.createClient()
             do {
-                let api = APIBackend.current.createClient()
                 let _ = try await api.startRunOnce(
                     side: side,
                     setPoints: setPoints,
                     wakeTime: result.wake
                 )

                 if scheduleManager.selectedSide == .both {
                     let other: Side = side == .left ? .right : .left
-                    let _ = try await api.startRunOnce(
+                    do {
+                        let _ = try await api.startRunOnce(
+                            side: other,
+                            setPoints: setPoints,
+                            wakeTime: result.wake
+                        )
+                    } catch {
+                        // Rollback first side on partial failure
+                        try? await api.cancelRunOnce(side: side)
+                        throw error
+                    }
-                        side: other,
-                        setPoints: setPoints,
-                        wakeTime: result.wake
-                    )
                 }

-            } catch {
-                Log.general.error("Failed to start run-once from AI curve: \(error)")
-            }
-
-            withAnimation { isRunOnce = true }
-            Haptics.success()
-            try? await Task.sleep(for: .seconds(1))
-            NotificationCenter.default.post(name: .switchToTempTab, object: nil)
+                withAnimation { isRunOnce = true }
+                Haptics.success()
+                try? await Task.sleep(for: .seconds(1))
+                NotificationCenter.default.post(name: .switchToTempTab, object: nil)
+            } catch {
+                Log.general.error("Failed to start run-once from AI curve: \(error)")
+            }
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sleepypod/Views/Schedule/AICurvePromptView.swift` around lines 880 - 930, The
success UI (withAnimation { isRunOnce = true } and NotificationCenter.post) is
outside the do/catch in useNow(), so failures (including a failing second
startRunOnce when selectedSide == .both) still show success and don't roll back
the first side; move the success handling so it only runs after all API calls
succeed: perform the first startRunOnce, then if scheduleManager.selectedSide ==
.both perform the second, and only after both awaits succeed set isRunOnce, call
Haptics.success(), sleep, and post .switchToTempTab; if the second call fails
attempt to rollback the already-started side by calling the appropriate API
stop/cancel method (e.g., the client method that cancels a run-once) inside the
catch before rethrowing or logging so partial starts are undone and the success
UI is never shown.

Comment thread Sleepypod/Views/Schedule/RunOnceActiveBanner.swift Outdated
Comment thread Sleepypod/Views/Schedule/RunOnceActiveBanner.swift
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: 1

🤖 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/Services/CurveGenerator.swift`:
- Around line 169-185: When the JSON contains the "nowCurve" key but its
contents are malformed or result in fewer than 3 valid points, do not silently
drop it; instead surface a parse error. In the parsing block that reads
curveJSON["nowCurve"] as? [String: Any] (variables nowRaw, nowPoints, timeRegex,
and nowCurve), validate entries and if any invalid format is detected or
nowPoints.count < 3, return/throw a descriptive parsing error or call the
existing error-reporting path (rather than leaving nowCurve nil), so callers can
detect and handle the provided-but-invalid nowCurve input.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 11106922-7e45-46ed-be3e-529d223bbbe5

📥 Commits

Reviewing files that changed from the base of the PR and between 876f839 and d4c4ba1.

📒 Files selected for processing (2)
  • Sleepypod/Services/CurveGenerator.swift
  • Sleepypod/Views/Schedule/AICurvePromptView.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • Sleepypod/Views/Schedule/AICurvePromptView.swift

Comment thread Sleepypod/Services/CurveGenerator.swift Outdated
Comment on lines +169 to +185
// Parse optional nowCurve
var nowCurve: [String: Int]?
if let nowRaw = curveJSON["nowCurve"] as? [String: Any] {
var nowPoints: [String: Int] = [:]
for (time, temp) in nowRaw {
guard time.wholeMatch(of: timeRegex) != nil else { continue }
if let t = temp as? Int {
nowPoints[time] = max(55, min(110, t))
} else if let t = temp as? Double {
nowPoints[time] = max(55, min(110, Int(t)))
}
}
if nowPoints.count >= 3 {
nowCurve = nowPoints
}
}

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 | 🟡 Minor

Don’t silently accept malformed nowCurve when the key is present.

Right now, provided-but-invalid nowCurve data is dropped without surfacing a parse error, which can hide prompt/contract regressions and unexpectedly trigger fallback behavior.

Suggested fix
-        if let nowRaw = curveJSON["nowCurve"] as? [String: Any] {
+        if let nowAny = curveJSON["nowCurve"] {
+            guard let nowRaw = nowAny as? [String: Any] else {
+                error = "\"nowCurve\" must be an object when provided."
+                return nil
+            }
             var nowPoints: [String: Int] = [:]
             for (time, temp) in nowRaw {
-                guard time.wholeMatch(of: timeRegex) != nil else { continue }
+                guard time.wholeMatch(of: timeRegex) != nil else { continue }
                 if let t = temp as? Int {
                     nowPoints[time] = max(55, min(110, t))
                 } else if let t = temp as? Double {
                     nowPoints[time] = max(55, min(110, Int(t)))
                 }
             }
-            if nowPoints.count >= 3 {
-                nowCurve = nowPoints
-            }
+            guard nowPoints.count >= 3 else {
+                error = "\"nowCurve\" must contain at least 3 valid set points when provided."
+                return nil
+            }
+            nowCurve = nowPoints
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sleepypod/Services/CurveGenerator.swift` around lines 169 - 185, When the
JSON contains the "nowCurve" key but its contents are malformed or result in
fewer than 3 valid points, do not silently drop it; instead surface a parse
error. In the parsing block that reads curveJSON["nowCurve"] as? [String: Any]
(variables nowRaw, nowPoints, timeRegex, and nowCurve), validate entries and if
any invalid format is detected or nowPoints.count < 3, return/throw a
descriptive parsing error or call the existing error-reporting path (rather than
leaving nowCurve nil), so callers can detect and handle the provided-but-invalid
nowCurve input.

ng added 3 commits March 28, 2026 17:48
The Temp page now always shows the active curve — whether it came from
a "Use Now" run-once session or today's recurring schedule:

1. Run-once active → show with "ACTIVE CURVE" header + Stop button
2. Today's recurring schedule has set points → show with "TONIGHT'S
   SCHEDULE" header (no Stop button — edit on Schedule page)
3. Neither → show manual - power + controls

Adds ScheduleManager to TempScreen environment, unified ActiveCurve
model, and isSchedule flag on RunOnceActiveBanner.
The polling loop and failure handler were calling fetchStatus (HTTP to
hardware) while a mutation was in flight, causing hardware contention
and temporary disconnects.

- Add isSendingMutation flag to block polling during active mutations
- Only call fetchStatus on failure when WS isn't providing updates
  (the event bus already pushes the correct state after mutations)
- Trust the WS event bus as the primary state source
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: 2

🤖 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/Views/Temp/TempScreen.swift`:
- Around line 21-37: In stopCurve(), don’t silently ignore errors from
APIBackend.current.createClient() calls (cancelRunOnce and updateDeviceStatus);
instead await them inside a do/catch, handle failures by presenting user
feedback (e.g., call your toast/error handler or set a view-state like
showErrorToast/message on deviceManager), and only clear activeCurve
(withAnimation { activeCurve = nil }) after successful API calls and
fetchStatus; also ensure fetchStatus errors are caught and surfaced so the UI
and backend remain consistent. Use the existing stopCurve,
APIBackend.current.createClient(), cancelRunOnce(side:), updateDeviceStatus(_:)
and deviceManager.fetchStatus() symbols to locate where to add the do/catch,
error-toasting, and conditional activeCurve clearing.
- Around line 39-52: The fetchActiveCurve function currently swallows all errors
by using try? on APIBackend.current.createClient().getActiveRunOnce, so network
failures are treated the same as "no active session"; change this to perform a
proper do/try/catch around getActiveRunOnce (inside fetchActiveCurve) and handle
the cases distinctly: on successful non-nil response construct ActiveCurve as
now, if the API returns nil treat it as "no active run-once" (leave activeCurve
unset), and on thrown/network errors log/record the error (e.g., via
processLogger or a view-model error state) and surface retry or error UI instead
of silently falling back; reference getActiveRunOnce, fetchActiveCurve,
ActiveCurve and APIBackend.current.createClient() when making the change.
🪄 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: 92dbdff5-d346-478c-bba9-166d7984ba56

📥 Commits

Reviewing files that changed from the base of the PR and between e759946 and dea1650.

📒 Files selected for processing (2)
  • Sleepypod/Views/Schedule/RunOnceActiveBanner.swift
  • Sleepypod/Views/Temp/TempScreen.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • Sleepypod/Views/Schedule/RunOnceActiveBanner.swift

Comment on lines +21 to +37
private func stopCurve() {
guard let curve = activeCurve else { return }
let side = deviceManager.selectedSide.primarySide

if curve.source == .runOnce {
Task {
let api = APIBackend.current.createClient()
try? await api.cancelRunOnce(side: side)
let powerOff = SideStatusUpdate(isOn: false)
var update = DeviceStatusUpdate()
if side == .left { update.left = powerOff } else { update.right = powerOff }
try? await api.updateDeviceStatus(update)
await deviceManager.fetchStatus()
withAnimation { activeCurve = nil }
}
}
}
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 | 🟡 Minor

Silent failure on cancel may leave user confused.

Both cancelRunOnce and updateDeviceStatus use try?, silently swallowing errors. If the API call fails, the UI will remain unchanged without any user feedback, yet the backend state remains active.

Consider showing an error toast or keeping the banner visible on failure:

💡 Suggested approach
 if curve.source == .runOnce {
     Task {
         let api = APIBackend.current.createClient()
-        try? await api.cancelRunOnce(side: side)
-        let powerOff = SideStatusUpdate(isOn: false)
-        var update = DeviceStatusUpdate()
-        if side == .left { update.left = powerOff } else { update.right = powerOff }
-        try? await api.updateDeviceStatus(update)
-        await deviceManager.fetchStatus()
-        withAnimation { activeCurve = nil }
+        do {
+            try await api.cancelRunOnce(side: side)
+            let powerOff = SideStatusUpdate(isOn: false)
+            var update = DeviceStatusUpdate()
+            if side == .left { update.left = powerOff } else { update.right = powerOff }
+            try await api.updateDeviceStatus(update)
+            await deviceManager.fetchStatus()
+            withAnimation { activeCurve = nil }
+        } catch {
+            // TODO: Show error feedback to user
+            print("Failed to cancel run-once: \(error)")
+        }
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private func stopCurve() {
guard let curve = activeCurve else { return }
let side = deviceManager.selectedSide.primarySide
if curve.source == .runOnce {
Task {
let api = APIBackend.current.createClient()
try? await api.cancelRunOnce(side: side)
let powerOff = SideStatusUpdate(isOn: false)
var update = DeviceStatusUpdate()
if side == .left { update.left = powerOff } else { update.right = powerOff }
try? await api.updateDeviceStatus(update)
await deviceManager.fetchStatus()
withAnimation { activeCurve = nil }
}
}
}
private func stopCurve() {
guard let curve = activeCurve else { return }
let side = deviceManager.selectedSide.primarySide
if curve.source == .runOnce {
Task {
let api = APIBackend.current.createClient()
do {
try await api.cancelRunOnce(side: side)
let powerOff = SideStatusUpdate(isOn: false)
var update = DeviceStatusUpdate()
if side == .left { update.left = powerOff } else { update.right = powerOff }
try await api.updateDeviceStatus(update)
await deviceManager.fetchStatus()
withAnimation { activeCurve = nil }
} catch {
// TODO: Show error feedback to user
print("Failed to cancel run-once: \(error)")
}
}
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sleepypod/Views/Temp/TempScreen.swift` around lines 21 - 37, In stopCurve(),
don’t silently ignore errors from APIBackend.current.createClient() calls
(cancelRunOnce and updateDeviceStatus); instead await them inside a do/catch,
handle failures by presenting user feedback (e.g., call your toast/error handler
or set a view-state like showErrorToast/message on deviceManager), and only
clear activeCurve (withAnimation { activeCurve = nil }) after successful API
calls and fetchStatus; also ensure fetchStatus errors are caught and surfaced so
the UI and backend remain consistent. Use the existing stopCurve,
APIBackend.current.createClient(), cancelRunOnce(side:), updateDeviceStatus(_:)
and deviceManager.fetchStatus() symbols to locate where to add the do/catch,
error-toasting, and conditional activeCurve clearing.

Comment on lines +39 to +52
private func fetchActiveCurve() async {
let side = deviceManager.selectedSide.primarySide

// 1. Check for run-once session (overrides recurring)
if let session = try? await APIBackend.current.createClient().getActiveRunOnce(side: side) {
activeCurve = ActiveCurve(
id: "runonce-\(session.id)",
source: .runOnce,
session: session,
setPoints: session.setPoints,
wakeTime: session.wakeTime
)
return
}
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 | 🟡 Minor

Network errors silently fall back to schedule display.

Using try? on getActiveRunOnce means a network error causes the UI to show the schedule (or nothing) even if an active run-once session exists on the server. This could mislead users about what's actually controlling their device.

Consider distinguishing between "no active session" (expected nil return) vs "network failure":

💡 Suggested differentiation
-if let session = try? await APIBackend.current.createClient().getActiveRunOnce(side: side) {
+do {
+    if let session = try await APIBackend.current.createClient().getActiveRunOnce(side: side) {
+        activeCurve = ActiveCurve(...)
+        return
+    }
+    // nil means no active session - fall through to schedule
+} catch {
+    // Network error - could show stale state or error indicator
+    print("Failed to fetch run-once status: \(error)")
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sleepypod/Views/Temp/TempScreen.swift` around lines 39 - 52, The
fetchActiveCurve function currently swallows all errors by using try? on
APIBackend.current.createClient().getActiveRunOnce, so network failures are
treated the same as "no active session"; change this to perform a proper
do/try/catch around getActiveRunOnce (inside fetchActiveCurve) and handle the
cases distinctly: on successful non-nil response construct ActiveCurve as now,
if the API returns nil treat it as "no active run-once" (leave activeCurve
unset), and on thrown/network errors log/record the error (e.g., via
processLogger or a view-model error state) and surface retry or error UI instead
of silently falling back; reference getActiveRunOnce, fetchActiveCurve,
ActiveCurve and APIBackend.current.createClient() when making the change.

ng added 7 commits March 28, 2026 18:00
- Hide "Use Now" button when backend is FreeSleep (not supported)
- Use displayed curve for "Use Now" (custom or generated, not always regenerated)
- Only show "Started!" after all API calls succeed
- BedTempTrendView respects user's temperature format (C/F)
- Clear stale chart data when range switch fails
- isCancelling resets after 5s timeout if banner isn't dismissed
- Now marker clamped to chart domain (no off-screen rendering)
- Warn on malformed nowCurve (log, don't block main curve)
# Conflicts:
#	Sleepypod.xcodeproj/project.pbxproj
#	Sleepypod/Models/BetaModels.swift
#	Sleepypod/Networking/MockClient.swift
#	Sleepypod/Views/Sensor/BedTempTrendView.swift
@ng ng merged commit 41b2ad7 into dev Mar 29, 2026
1 of 2 checks passed
@ng ng deleted the feature/251-run-once-ui branch March 29, 2026 02:54
ng added a commit that referenced this pull request Mar 29, 2026
* feat: bed temperature trend chart with historical data (#33)

- New BedTempTrendView: three lines (left/right/ambient), time range
  selector (1H/6H/12H/24H), legend, and summary stats row
- Fetch historical bed temp from tRPC environment.getBedTemp endpoint
- Add BedTempReading model, getBedTempHistory to protocol + all clients
- Fix ISO8601 date parsing to handle fractional seconds
- Guard against stale async responses on rapid range switching
- Remove old WS-only tempTrendCard (replaced by tRPC-backed chart)
- Matches web core's BED TEMPERATURE card

* feat: Use Now split button for run-once curves (#251) (#34)

* feat: bed temperature trend chart with historical data

- New BedTempTrendView: three lines (left/right/ambient), time range
  selector (1H/6H/12H/24H), legend, and summary stats row
- Fetch historical bed temp from tRPC environment.getBedTemp endpoint
- Add BedTempReading model, getBedTempHistory to protocol + all clients
- Fix ISO8601 date parsing to handle fractional seconds
- Guard against stale async responses on rapid range switching
- Remove old WS-only tempTrendCard (replaced by tRPC-backed chart)
- Matches web core's BED TEMPERATURE card

* feat: "Use Now" split button for one-off curve application (#251)

Split the "Apply to Schedule" button into two actions:
- Apply to Schedule: existing behavior, persists as recurring weekly entries
- Use Now: applies curve from now until wake time via runOnce.start API

Adds RunOnceSession model, networking methods (start/getActive/cancel)
to protocol and all client implementations.

* feat: active curve banner, AI curve Use Now, polish

- Active curve banner on Temp/Schedule/Status screens with chart,
  NOW marker, and Stop button
- Stop Curve powers off the heater
- Fade in/out animations on banner
- AI curve wizard: Save | Apply | Use Now split bar
- Use Now time-shifts curve proportionally (bedtime=now, wake preserved)
- Switches to Temp tab after Use Now
- Chart: single accent line + phase-colored dots, centered legend
- NOW line uses minute-offset interpolation for accurate positioning
- X-axis: 4 evenly spaced ticks, rotated labels, bottom padding

* feat: AI prompt returns nowCurve for properly shaped start-now variant

The AI prompt now asks for two curves:
- "points": reusable template curve (bedtime to wake, as before)
- "nowCurve": properly shaped curve starting from current time to wake

When the user taps "Use Now", the AI's nowCurve is used if available
(properly adapted phases for shorter duration). Falls back to
proportional time-shifting if nowCurve is missing.

Also: chart line uses single accent color, dots show phase colors,
legend centered.

* fix: fetch active curve on every Temp tab appearance, not just first load

* feat: show tonight's active curve from any source on Temp page

The Temp page now always shows the active curve — whether it came from
a "Use Now" run-once session or today's recurring schedule:

1. Run-once active → show with "ACTIVE CURVE" header + Stop button
2. Today's recurring schedule has set points → show with "TONIGHT'S
   SCHEDULE" header (no Stop button — edit on Schedule page)
3. Neither → show manual - power + controls

Adds ScheduleManager to TempScreen environment, unified ActiveCurve
model, and isSchedule flag on RunOnceActiveBanner.

* fix: prevent disconnect/reconnect when adjusting temperature

The polling loop and failure handler were calling fetchStatus (HTTP to
hardware) while a mutation was in flight, causing hardware contention
and temporary disconnects.

- Add isSendingMutation flag to block polling during active mutations
- Only call fetchStatus on failure when WS isn't providing updates
  (the event bus already pushes the correct state after mutations)
- Trust the WS event bus as the primary state source

* fix: eliminate fetchStatus calls during mutations, add 2s cooldown

* fix: only show disconnected view when never connected, not on transient failures

* fix: sync side selector when Use Now switches to Temp tab

* fix: auto-retry mDNS discovery after Local Network permission prompt

* fix: add eight-pod.local hostname fallback when mDNS multicast fails

* fix: address all PR #34 review feedback

- Hide "Use Now" button when backend is FreeSleep (not supported)
- Use displayed curve for "Use Now" (custom or generated, not always regenerated)
- Only show "Started!" after all API calls succeed
- BedTempTrendView respects user's temperature format (C/F)
- Clear stale chart data when range switch fails
- isCancelling resets after 5s timeout if banner isn't dismissed
- Now marker clamped to chart domain (no off-screen rendering)
- Warn on malformed nowCurve (log, don't block main curve)

* fix: remove processingStatus contract test (endpoint removed in core)
@github-actions
Copy link
Copy Markdown

🎉 This PR is included in version 1.2.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant