Skip to content

feat(goals): deposit ledger, edit sheet, real pace, wizard fixes - #40

Merged
Ivan-LB merged 4 commits into
devfrom
feat/goals-complete
Aug 16, 2026
Merged

Ivan-LB merged 4 commits into
devfrom
feat/goals-complete

Conversation

@Ivan-LB

@Ivan-LB Ivan-LB commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Summary

Executes docs/plans/pr-c-goals-complete.md, in its four commit boundaries:

1 · Deposit ledger (a2c8150) — DepositModel (id, goalID, amount, date, note, source manual|auto-move), additive. GoalDeposits.record(...) is now the only way money enters a goal: same clamp as before, inserts the ledger row (amount = intent, note trimmed / nil), saves with do/catch, throws on ≤ 0. All four writers migrated (goal detail card, dashboard sheet, quick-deposit sheet, payday auto-move — AutoMoveSuggestion.apply() deleted; a failed move after the income saved keeps the income and says so). NOTE fields finally persist. AutoMoveSuggestion.compute gains monthDepositTotal (margin = incomes + this income − expenses − deposits this month), fed from a @Query in the income sheet. Goal detail gets a History section with an AUTO tag. Both quick sheets gain the .alert their VMs had state for.

2 · Edit sheet (6f026a6) — GoalEditSheet behind both dead pencils (goal detail toolbar, dashboard hero card): name, target, all 13 colors, target date on/off, auto-move on/off + amount. GoalEditValidation is a pure, tested rule set (name, target > 0, target ≥ saved, future deadline only when on, auto amount > 0 only when on). Star is a toggle now.

3 · Real pace / ETA / completion (2c76cea) — GoalPace is the one implementation of the pace policy: required = remaining ÷ weeks-to-deadline (AutoMoveSuggestion calls the same function now); actual = 4-week deposit average, falling back to the configured auto-move for a goal with no deposits ever; on track ⇔ complete / no deadline / actual ≥ required, else Behind (clay); ETA = remaining ÷ actual as a date, "1+ year" past 52 weeks, "—" at zero pace — never faked. Goal detail: status line under the ring (deadline finally shown), pills Remaining · Needed/wk · ETA. Dashboard PACE and Goals list read the policy. Goals at 100% move to a Completed section (out of the active count) with a one-time pop (mirrors AchievementRow/AchievementStore, instant under Reduce Motion).

4 · Wizard traps (d128a6c) — "No date" is real (AddGoalState.hasDeadline; calendar Date stays non-optional so MiniCalendarView is untouched; persists deadline: nil, autoMoveAmount: 0; Step 3/4 say "—"/"No date"); "Plant goal" disabled until valid instead of a silent no-op, save is do/catch with rollback; success-screen "Add a deposit" presents DepositSheet for the goal just created; Step 4 Skip removed; emoji picker + Reminders recap row removed (collected, never persisted) — emoji renders become the name initial on the goal color; two Toggle("") get labels.

Test plan

  • xcodebuild build — BUILD SUCCEEDED
  • xcodebuild test -skip-testing:SavelyUITests — TEST SUCCEEDED. New: GoalDepositsTests (6, incl. the two migrated apply tests), GoalEditValidationTests (7), GoalPaceTests (12), AutoMoveSuggestionTests.testMonthDepositsReduceTheMargin.
  • swiftlint lint --strict — 0
  • In-place upgrade verified, no store reset: built dev in a worktree, installed, seeded a goal ($278 / $5,000, auto-move 277) + a categorized expense into the pre-C store via SQLite, installed this build over it, launched — ZDEPOSITMODEL created (ZAMOUNT ZDATE ZNOTE ZSOURCE ZGOALID ZID), goal and expense rows intact, ledger empty. Dump below.
  • Simulator UI walk NOT done by me (Claude Code simulator panel is down for this session; no taps). Please run the plan's acceptance path: create goal with and without date → edit it (name/target/color/deadline/auto-move; try target < saved → clear message) → deposit with a note from all three flows → history shows all with the AUTO tag after an armed auto-move income → PACE/ETA react to deadline changes → complete a goal → pop once, listed under Completed → second income same month sees a margin reduced by the first deposit.
  • Test data uninstalled
=== entities after upgrade ===
1|DepositModel  2|ExpenseModel  3|GoalModel  4|IncomeModel  5|TipModel
=== ZDEPOSITMODEL columns ===
Z_PK Z_ENT Z_OPT ZAMOUNT ZDATE ZNOTE ZSOURCE ZGOALID ZID
ZNAME|ZCURRENT|ZTARGET|ZAUTOMOVEAMOUNT
Legacy trip|278.0|5000.0|277.0
ZEXPENSEDESCRIPTION|ZCATEGORY
Zara|Shopping
deposits: 0

Risks

  • New entity (additive) — verified above.
  • Behavior: goals with a past deadline are no longer "on track" by default; a goal with a deadline and no deposits/auto-move reads Behind — honest, but visible.
  • WarmGoalCard and HeroGoalCard each run a @Query filtered by goal ID (one per row). Fine at Savely's goal counts; flagging in case the list ever grows large.
  • AutoMoveSuggestion.apply() is gone; anything outside this repo calling it would break (nothing does).

Checklist

  • feat/ branch, Conventional Commits, no AI attribution
  • New user-facing strings are literals per the plan's string policy
  • No secrets staged
  • Tests added

Deposits used to mutate GoalModel.current in place in four different
places, with no record: notes were collected and discarded, history was
unrepresentable, and the payday auto-move's month-margin math could not
see money already moved into goals this month.

- DepositModel (new @model, additive): id, goalID, amount, date, note,
  source ("manual" | "auto-move"). Registered in the container.
- GoalDeposits.record(goal:amount:note:source:context:) is now the only
  way money enters a goal: same clamp as before (min(current+amount,
  target)), inserts the ledger row (amount = intent, note trimmed / nil
  when empty), saves with do/catch. Throws on non-positive amounts.
- All four writers migrated: GoalDetailView's DepositCard, the Dashboard
  DepositSheet, WarmQuickDepositView, and the auto-move apply path in
  WarmQuickIncomeView (AutoMoveSuggestion.apply() deleted; if the move
  fails after the income saved, the income stays and the sheet says so).
  Each flow surfaces a save error instead of `try?`. The two NOTE fields
  finally persist. Both quick sheets gain the .alert their view models
  already had state for.
- AutoMoveSuggestion.compute gains monthDepositTotal, subtracted from
  the margin; WarmQuickIncomeView passes the current-month sum from a
  @query of deposits.
- GoalDetailView: History section (deposits for the goal, newest first,
  relative date, note, AUTO tag for payday moves, honest empty state).
- Tests: GoalDepositsTests (record adds / clamps — migrated from the two
  apply tests — ledger row shape, empty note → nil, non-positive throws
  and writes nothing, month total); AutoMoveSuggestionTests gains the
  deposits-reduce-the-margin case.
Nothing about a goal could be changed after the wizard — both edit
pencils were empty closures.

- GoalEditSheet: name, target, color (all thirteen GoalColor cases; the
  wizard shows six), target date with an on/off toggle, payday auto-move
  toggle + amount. One explicit Save; do/catch around the store save.
- GoalEditValidation: pure, tested rules — name non-empty, target > 0,
  target not below money already saved (shrinking under saved money
  would read >100% and mean nothing), deadline in the future only when
  enabled, auto-move amount > 0 only when enabled. First broken rule is
  what the user hears.
- Wired to GoalDetailView's toolbar pencil and the Dashboard hero card's
  pencil, both as sheets, both with an accessibility label.
- GoalsViewModel.setFavorite is a toggle now: tapping the current
  favorite's star un-favorites it; tapping another goal moves the star.
Goal stats were theatre: "Per week" divided the remaining amount by a
hardcoded 24, the ETA was derived from that same number (circular), the
Dashboard PACE always said "On track", and the Goals list said "On track"
for anything past 50%.

- GoalPace: the one implementation of the pace policy. Required weekly =
  remaining ÷ weeks-to-deadline (AutoMoveSuggestion now calls this same
  function instead of its private copy). Actual weekly = average of the
  goal's deposits over the last four weeks; a goal with no deposits ever
  falls back to its configured auto-move as a weekly figure. On track ⇔
  complete, or no deadline, or actual ≥ required; otherwise Behind. ETA
  = remaining ÷ actual pace, shown as a date, "1+ year" past 52 weeks,
  or "—" when the pace is zero — never a made-up date.
- GoalDetailView: status line under the ring ("On track · by Jun 12,
  2027 · $150/wk", or "no target date"), pills become Remaining ·
  Needed/wk · ETA. The deadline is finally shown somewhere.
- Dashboard hero card PACE and Goals list subtitle come from the policy;
  Behind reads in clay.
- GoalsView: goals at 100% move to a Completed section (out of the
  active count) with a one-time pop — same high-damping spring + amber
  glow as AchievementRow, remembered per goal in UserDefaults
  (GoalCelebrationStore mirrors AchievementStore), instant under Reduce
  Motion.
- GoalPaceTests: required pace, no-deadline, this-week deadline, actual
  pace window and per-goal filter, auto-move fallback, zero pace, behind
  vs on track, complete, ETA date and the 1+ year cap.
- "No date" now means no date: AddGoalState.hasDeadline (the calendar's
  Date stays non-optional so MiniCalendarView is untouched). The preset
  flips it off; any day or duration flips it back on. plantGoal persists
  deadline nil and autoMoveAmount 0 (pace is undefined without a date —
  set later in the edit sheet). Step 3's pace card and Step 4's tiles /
  recap say "—" / "No date" instead of quoting a stale 18-month default.
- weeklyPace goes through GoalPace.requiredWeeklyPace — the same
  function the rest of the app uses.
- "Plant goal" is disabled (and dimmed) until name and amount are valid,
  instead of guard-returning into nothing. Saving is do/catch with an
  alert; a failed insert is rolled back.
- Success screen "Add a deposit" presents DepositSheet for the goal that
  was just created (the reference used to be dropped), dismissing the
  flow afterwards.
- Step 4's Skip was an empty closure — the header hides it there.
- The emoji picker and the Reminders recap row collected values nothing
  persisted; both are gone (AddGoalState.emoji / showReminders deleted).
  The three emoji renders become the goal-name initial on the goal color
  (GoalInitialCircle — the pattern WarmGoalCard already uses). The wizard
  stays four steps.
- Two Toggle("") in the wizard get real labels for VoiceOver.
@Ivan-LB
Ivan-LB merged commit f032fcf into dev Aug 16, 2026
1 check passed
@Ivan-LB
Ivan-LB deleted the feat/goals-complete branch August 16, 2026 06:41
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