Skip to content

Implemented profile goal setting UI + networking#126

Open
caitlynjin wants to merge 10 commits intomainfrom
caitlyn/goal-setting
Open

Implemented profile goal setting UI + networking#126
caitlynjin wants to merge 10 commits intomainfrom
caitlyn/goal-setting

Conversation

@caitlynjin
Copy link
Copy Markdown
Contributor

@caitlynjin caitlynjin commented Mar 24, 2026

Overview

Implemented workout goal setting for the profile page, including new UI components and networking. Major changes include differentiation between onboarding and profile versions of goal setting, and the once every 30 day limit for changing goals.

Changes Made

Goal Setting Logic

  • Moved goal setting logic used in onboarding from MainViewModel to SetGoalsViewModel, so that both onboarding and profile goal setting can share logic.
  • Changed default workout goal to 1 for the minimum slider amount.
  • isGoalChangeLocked checks whether the last workout goal change was within 30 days ago, and if so, the workout goal cannot be changed. Date formatting matches what backend gives to us.
  • Added success/failure handling to workout goal setting.
    • For onboarding context, success should send the user to the next onboarding step page.
    • For profile context, success should close the popup modal and set the state to saved, disabling the slider and save button.

UI Changes

  • In SetGoalsView, isOnboarding ensures that the UI for onboarding vs profile versions of goal setting is different.
  • Implemented new GoalSettingModal, which pops up a warning after clicking “save” in the profile goal setting view. This is meant to warn users that they can only change their goal every 30 days. After pressing continue, the workout goal automatically saves.

Screenshots

Goal Setting on Onboarding
Goal Setting on Profile - Saving new goal Goal Setting on Profile - Cannot save new goal b/c within 30 days
goal-setting-profile-edit.mp4
goal-setting-profile-30-days.mp4

Summary by CodeRabbit

  • New Features

    • Added warning modal when modifying fitness goals in the profile section
    • Goal-setting workflow now includes save functionality with a 30-day lockout period to prevent frequent changes
    • Fitness goals lock after updates are saved
  • Refactor

    • Restructured goal-setting experience to support both onboarding and profile modes

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 24, 2026

📝 Walkthrough

Walkthrough

This PR introduces a goal-setting modal confirmation flow and migrates workout goal management from MainViewModel to SetGoalsViewModel. It adds support for both onboarding and profile view modes, implements a 30-day goal-change lock mechanism with date validation, and includes enhanced error logging in authentication interceptors.

Changes

Cohort / File(s) Summary
Goal Setting Modal & Assets
Uplift.xcodeproj/project.pbxproj, Uplift/Views/Supporting/GoalSettingModal.swift, Uplift/Resources/Assets.xcassets/clock_outlined.imageset/Contents.json, Uplift/Utils/Constants.swift
New GoalSettingModal view with clock icon asset and constant reference. Modal displays warning about goal change delays with "Continue" and "Cancel" callbacks.
View Model Goal Logic
Uplift/ViewModels/SetGoalsViewModel.swift, Uplift/ViewModels/MainViewModel.swift, Uplift/ViewModels/ProfileViewModel.swift
Migrated setWorkoutGoal() and daysAWeek from MainViewModel to SetGoalsViewModel. Added goal-locking logic with 30-day cooldown validation, warning modal state, and slider binding. Changed default workout goal from 5 to 1 in ProfileViewModel.
View Integration & Routing
Uplift/Views/Onboarding/SetGoalsView.swift, Uplift/Views/ProfileView.swift, Uplift/Core/UpliftApp.swift
SetGoalsView now supports isOnboarding parameter with conditional onboarding vs. profile UI layouts. Integrated warning modal with goal-change attempts. ProfileView now links to SetGoalsView instead of static display. UpliftApp passes onboarding flag when presenting goals view.
Authentication Logging
Uplift/Services/Networking/AuthorizationInterceptor.swift, Uplift/Services/UserSessionManager.swift
Added critical error logging when access or refresh tokens are missing during authorization and login operations.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant SetGoalsView
    participant SetGoalsViewModel
    participant GoalSettingModal
    participant API as GraphQL API
    participant Logger

    User->>SetGoalsView: Adjust slider + tap Save/Next
    SetGoalsView->>SetGoalsViewModel: isGoalChangeLocked(lastGoalChange)
    SetGoalsViewModel->>SetGoalsViewModel: Parse date & validate 30-day cooldown
    alt Goal locked or unsaved changes
        SetGoalsViewModel-->>SetGoalsView: Return lock status
        SetGoalsView->>GoalSettingModal: Show warning modal
        User->>GoalSettingModal: Tap Continue or Cancel
    end
    alt User confirms
        GoalSettingModal->>SetGoalsView: onContinue callback
        SetGoalsView->>SetGoalsViewModel: setWorkoutGoal(userId, goal, completion)
        SetGoalsViewModel->>API: Publish SetWorkoutGoalsMutation
        API-->>SetGoalsViewModel: Success/Failure response
        alt Mutation succeeds
            SetGoalsViewModel->>Logger: Log success (DEBUG)
            SetGoalsViewModel-->>SetGoalsView: completion(.success)
            SetGoalsView->>SetGoalsView: Update inSavedState, dismiss modal
        else Mutation fails
            SetGoalsViewModel->>Logger: Log critical error
            SetGoalsViewModel-->>SetGoalsView: completion(.failure)
            SetGoalsView->>SetGoalsView: Handle error, keep modal visible
        end
    else User cancels
        GoalSettingModal->>SetGoalsView: onCancel callback
        SetGoalsView->>SetGoalsView: Dismiss modal without API call
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • amonsalve1
  • JiwonJeong414

Poem

🐰 A modal appears with a warning so wise,
"Wait thirty days!" the clock testifies,
Goals shift and flow through onboarding streams,
While profiles settle their workout dreams. ⏰

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% 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
Title check ✅ Passed The title accurately summarizes the main change: implementing profile goal setting UI with networking, which aligns with the primary objective of the PR.
Description check ✅ Passed The description includes Overview, Changes Made sections with detailed subsections, and Screenshots with videos. All template sections are either completed or marked appropriately.

✏️ 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 caitlyn/goal-setting

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

@caitlynjin caitlynjin marked this pull request as ready for review March 24, 2026 23:56
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

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

Inline comments:
In `@Uplift/ViewModels/SetGoalsViewModel.swift`:
- Around line 51-53: The timestamp parse failure currently returns false which
lets the client treat the cooldown as expired; instead make the conservative
choice and prevent unlocking on parse errors by changing the guard fallback to
return true (i.e., treat unparseable lastGoalChange as "still in cooldown").
Update the guard in SetGoalsViewModel where you call formatter.date(from:
lastGoalChange) (the lastChangeDate parsing block) to log the error as you do
and then return true so the 30-day guard is not bypassed.
- Around line 70-84: The receiveValue closure must validate the mutation payload
before reporting success: in the mutationPublisher receiveValue (the closure
parameter often named `result`), unwrap and verify
`result.data?.setWorkoutGoals` exists (following the pattern in ProfileViewModel
where `result.data` is validated) and only call `completion(.success(()))` when
that value is present; otherwise log a clear error via Logger.data and call
`completion(.failure(...))` with an appropriate Error (or map to an existing
network/validation error). Ensure this check is done inside SetGoalsViewModel's
mutation handling so the code doesn't ignore an empty/malformed response.

In `@Uplift/Views/Onboarding/SetGoalsView.swift`:
- Around line 99-101: Add an isSubmitting Bool on the view model and use it to
short-circuit both entry points that trigger network writes: in
GoalSettingModal.onContinue and wherever the Continue/Next buttons call
createUser and setWorkoutGoal, return early if isSubmitting is true; set
isSubmitting = true before starting the async createUser -> setWorkoutGoal chain
and set it back to false in all completion paths (success and failure) and also
ensure viewModel.showWarningModal is only set false after the chain completes
(or guarded by isSubmitting) so taps while the request is in flight are ignored.
- Around line 28-30: The view currently computes the 30-day lock solely from
user?.lastGoalChange (via the isGoalChangeLocked property calling
viewModel.isGoalChangeLocked(lastGoalChange:)), but after a successful save you
only flip local view state (lines ~89-93), leaving the backing User stale;
update the source-of-truth when save succeeds by persisting the new
lastGoalChange (e.g., set user.lastGoalChange = Date()) or by invoking the
viewModel method that refreshes/updates the backing User model so
isGoalChangeLocked will reflect the new cooldown across view dismissals and
reopenings.
- Around line 83-87: The current code in SetGoalsView.swift calls
viewModel.setWorkoutGoal with userId: Int(user.id) ?? 0 which silently uses 0 on
parse failure; change this to fail fast by validating user.id before calling
setWorkoutGoal — e.g., guard that user exists and that Int(user.id) successfully
converts, otherwise log a clear error (including the raw user.id) and return
early; update the call site that uses viewModel.setWorkoutGoal to pass the
validated Int userId instead of a fallback 0.

In `@Uplift/Views/Supporting/GoalSettingModal.swift`:
- Around line 28-32: Update the warning copy in GoalSettingModal so it matches
the view model's 30-day cooldown: replace the string in the Text view that
currently says "1 month" with "30 days" (e.g., "Goals can only be changed again
after 30 days, do you want to continue?") in the GoalSettingModal SwiftUI view
so the UI and the view model cooldown semantics are consistent.
- Around line 47-53: The Back button in GoalSettingModal is wired to
onContinue() which triggers saving and the 30-day lockout; change the Button
action to call the cancel/dismiss handler (e.g., onCancel() or the component's
existing cancel callback) instead of onContinue() so pressing "Back" follows the
cancel path and does not save the new goal; update the Button action from
onContinue() to the cancel method used by this view (replace onContinue() with
onCancel() or the appropriate cancel closure).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9ac04267-0043-4f91-b518-72e3fdf470d8

📥 Commits

Reviewing files that changed from the base of the PR and between 82c0678 and 1af3de6.

⛔ Files ignored due to path filters (3)
  • Uplift/Resources/Assets.xcassets/clock_outlined.imageset/Clock-icon-new.png is excluded by !**/*.png
  • Uplift/Resources/Assets.xcassets/clock_outlined.imageset/Clock-icon-new@2x.png is excluded by !**/*.png
  • Uplift/Resources/Assets.xcassets/clock_outlined.imageset/Clock-icon-new@3x.png is excluded by !**/*.png
📒 Files selected for processing (10)
  • Uplift.xcodeproj/project.pbxproj
  • Uplift/Core/UpliftApp.swift
  • Uplift/Resources/Assets.xcassets/clock_outlined.imageset/Contents.json
  • Uplift/Utils/Constants.swift
  • Uplift/ViewModels/MainViewModel.swift
  • Uplift/ViewModels/ProfileViewModel.swift
  • Uplift/ViewModels/SetGoalsViewModel.swift
  • Uplift/Views/Onboarding/SetGoalsView.swift
  • Uplift/Views/ProfileView.swift
  • Uplift/Views/Supporting/GoalSettingModal.swift
💤 Files with no reviewable changes (1)
  • Uplift/ViewModels/MainViewModel.swift

Comment on lines +28 to +30
private var isGoalChangeLocked: Bool {
viewModel.isGoalChangeLocked(lastGoalChange: user?.lastGoalChange)
}
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

Update the cooldown source of truth after a successful profile save.

Lines 28-30 and 74-79 derive the lock entirely from the incoming user, but Lines 89-93 only flip local view state. Once this view is dismissed and opened again, the screen will recompute from the stale user.lastGoalChange until the parent refetches, which breaks the 30-day guard in the profile flow. Persist the new lock state locally or refresh the backing User model on success.

Also applies to: 74-79, 89-93

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

In `@Uplift/Views/Onboarding/SetGoalsView.swift` around lines 28 - 30, The view
currently computes the 30-day lock solely from user?.lastGoalChange (via the
isGoalChangeLocked property calling
viewModel.isGoalChangeLocked(lastGoalChange:)), but after a successful save you
only flip local view state (lines ~89-93), leaving the backing User stale;
update the source-of-truth when save succeeds by persisting the new
lastGoalChange (e.g., set user.lastGoalChange = Date()) or by invoking the
viewModel method that refreshes/updates the backing User model so
isGoalChangeLocked will reflect the new cooldown across view dismissals and
reopenings.

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

♻️ Duplicate comments (3)
Uplift/Views/Onboarding/SetGoalsView.swift (2)

28-30: ⚠️ Potential issue | 🟠 Major

Refresh the cooldown source of truth after a successful profile save.

isGoalChangeLocked always reads from user?.lastGoalChange, but success only toggles local view state. If this screen is dismissed and reopened before the parent refreshes User, the stale timestamp can make the 30-day lock disappear.

Also applies to: 74-79, 89-93

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

In `@Uplift/Views/Onboarding/SetGoalsView.swift` around lines 28 - 30,
isGoalChangeLocked reads from user?.lastGoalChange (via
viewModel.isGoalChangeLocked(lastGoalChange:)), but successful profile saves
only toggle local UI state so the persisted cooldown timestamp can remain stale;
on successful profile save/update (the handler that currently mutates local view
state) update the source-of-truth lastGoalChange used by this view — e.g., set
user?.lastGoalChange (or call the parent refresh/commit method in the view
model) to the new timestamp returned from the save so isGoalChangeLocked
reflects the updated cooldown immediately; apply the same fix to the other
places that rely on lastGoalChange (the handlers referenced by the other blocks
around the save flow).

82-103: ⚠️ Potential issue | 🟠 Major

Keep the save/create paths single-flight and dismiss the modal only on success.

Line 101 clears showWarningModal immediately after starting setWorkoutGoal, and neither this path nor nextButton blocks repeat taps. A fast double tap can queue duplicate writes, and failures still look like a successful dismissal. Add an in-flight guard, disable both triggers while the request is running, and move the modal dismissal into the .success path.

Also applies to: 275-291, 311-339

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

In `@Uplift/Views/Onboarding/SetGoalsView.swift` around lines 82 - 103, The save
path currently starts viewModel.setWorkoutGoal and immediately clears
viewModel.showWarningModal, allowing duplicate taps and hiding failures; add an
"in-flight" guard (e.g., a Bool like isSaving either in this View or the
ViewModel) that is checked at the start of the onContinue and nextButton
handlers, set it true before calling viewModel.setWorkoutGoal and false on both
success and failure, disable the buttons/triggers while isSaving is true, and
move the viewModel.showWarningModal = false (modal dismissal) into the .success
case where you also set inSavedState and viewModel.currWorkoutGoal; ensure
failure keeps the modal visible and logs the error.
Uplift/ViewModels/SetGoalsViewModel.swift (1)

70-83: ⚠️ Potential issue | 🔴 Critical

Don't treat every emitted value as a successful goal save.

receiveValue ignores result and always calls completion(.success(())). If the mutation returns an empty/null setWorkoutGoals payload, the profile flow still flips to Saved and locally starts the 30-day lock without a persisted change.

Suggested fix
             .sink { response in
                 if case let .failure(error) = response {
                     Logger.data.critical("Error in SetGoalsViewModel.setWorkoutGoal: \(error)")
                     completion(.failure(error))
                 }
-            } receiveValue: { _ in
+            } receiveValue: { result in
+                guard result.data?.setWorkoutGoals != nil else {
+                    let error = NSError(
+                        domain: "SetGoalsViewModel",
+                        code: 0,
+                        userInfo: [NSLocalizedDescriptionKey: "Missing setWorkoutGoals payload"]
+                    )
+                    Logger.data.error("SetWorkoutGoals returned no payload for userId \(userId)")
+                    completion(.failure(error))
+                    return
+                }
 `#if` DEBUG
                 Logger.data.log("User id \(userId) has set goal to \(workoutGoal)")
 `#endif`
                 completion(.success(()))
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Uplift/ViewModels/SetGoalsViewModel.swift` around lines 70 - 83, The
receiveValue closure in SetGoalsViewModel.setWorkoutGoal currently ignores the
mutation result and always returns success; change it to inspect the mutation
response payload (the value passed into receiveValue from
Network.client.mutationPublisher for SetWorkoutGoalsMutation), verify that the
returned setWorkoutGoals (or equivalent field) is non-nil and indicates success,
and only call completion(.success(())) when that payload is present/valid; if
the payload is nil or indicates failure, log a meaningful error via Logger.data
and call completion(.failure(...)) (create/use an appropriate Error for a
missing/empty payload) so the profile flow won't flip to Saved on a
non-persisted change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Uplift/Views/Onboarding/SetGoalsView.swift`:
- Around line 74-79: Clamp the seeded workout goal when the view appears: in the
SetGoalsView .onAppear block, instead of assigning user.workoutGoal verbatim to
viewModel.sliderWorkoutGoal and viewModel.currWorkoutGoal, coerce the optional
user.workoutGoal into the new valid range 1...7 (use min/max or a clamp helper)
and assign that sanitized Int/Double to viewModel.sliderWorkoutGoal and
viewModel.currWorkoutGoal; keep the existing guard on isOnboarding/user and
retain setting inSavedState = isGoalChangeLocked.

---

Duplicate comments:
In `@Uplift/ViewModels/SetGoalsViewModel.swift`:
- Around line 70-83: The receiveValue closure in
SetGoalsViewModel.setWorkoutGoal currently ignores the mutation result and
always returns success; change it to inspect the mutation response payload (the
value passed into receiveValue from Network.client.mutationPublisher for
SetWorkoutGoalsMutation), verify that the returned setWorkoutGoals (or
equivalent field) is non-nil and indicates success, and only call
completion(.success(())) when that payload is present/valid; if the payload is
nil or indicates failure, log a meaningful error via Logger.data and call
completion(.failure(...)) (create/use an appropriate Error for a missing/empty
payload) so the profile flow won't flip to Saved on a non-persisted change.

In `@Uplift/Views/Onboarding/SetGoalsView.swift`:
- Around line 28-30: isGoalChangeLocked reads from user?.lastGoalChange (via
viewModel.isGoalChangeLocked(lastGoalChange:)), but successful profile saves
only toggle local UI state so the persisted cooldown timestamp can remain stale;
on successful profile save/update (the handler that currently mutates local view
state) update the source-of-truth lastGoalChange used by this view — e.g., set
user?.lastGoalChange (or call the parent refresh/commit method in the view
model) to the new timestamp returned from the save so isGoalChangeLocked
reflects the updated cooldown immediately; apply the same fix to the other
places that rely on lastGoalChange (the handlers referenced by the other blocks
around the save flow).
- Around line 82-103: The save path currently starts viewModel.setWorkoutGoal
and immediately clears viewModel.showWarningModal, allowing duplicate taps and
hiding failures; add an "in-flight" guard (e.g., a Bool like isSaving either in
this View or the ViewModel) that is checked at the start of the onContinue and
nextButton handlers, set it true before calling viewModel.setWorkoutGoal and
false on both success and failure, disable the buttons/triggers while isSaving
is true, and move the viewModel.showWarningModal = false (modal dismissal) into
the .success case where you also set inSavedState and viewModel.currWorkoutGoal;
ensure failure keeps the modal visible and logs the error.
🪄 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: a557073c-c5b8-468d-8637-874a4951ec90

📥 Commits

Reviewing files that changed from the base of the PR and between 1af3de6 and 3d3d686.

📒 Files selected for processing (5)
  • Uplift/Services/Networking/AuthorizationInterceptor.swift
  • Uplift/Services/UserSessionManager.swift
  • Uplift/ViewModels/SetGoalsViewModel.swift
  • Uplift/Views/Onboarding/SetGoalsView.swift
  • Uplift/Views/Supporting/GoalSettingModal.swift
✅ Files skipped from review due to trivial changes (1)
  • Uplift/Services/UserSessionManager.swift

Copy link
Copy Markdown
Contributor

@JiwonJeong414 JiwonJeong414 left a comment

Choose a reason for hiding this comment

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

Caitlyn ur my idol 🤩

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.

2 participants