feat(goals): deposit ledger, edit sheet, real pace, wizard fixes - #40
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Executes
docs/plans/pr-c-goals-complete.md, in its four commit boundaries:1 · Deposit ledger (
a2c8150) —DepositModel(id, goalID, amount, date, note, sourcemanual|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.computegainsmonthDepositTotal(margin = incomes + this income − expenses − deposits this month), fed from a@Queryin the income sheet. Goal detail gets a History section with an AUTO tag. Both quick sheets gain the.alerttheir VMs had state for.2 · Edit sheet (
6f026a6) —GoalEditSheetbehind both dead pencils (goal detail toolbar, dashboard hero card): name, target, all 13 colors, target date on/off, auto-move on/off + amount.GoalEditValidationis 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) —GoalPaceis 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 soMiniCalendarViewis untouched; persistsdeadline: 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" presentsDepositSheetfor 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; twoToggle("")get labels.Test plan
xcodebuild build— BUILD SUCCEEDEDxcodebuild test -skip-testing:SavelyUITests— TEST SUCCEEDED. New:GoalDepositsTests(6, incl. the two migratedapplytests),GoalEditValidationTests(7),GoalPaceTests(12),AutoMoveSuggestionTests.testMonthDepositsReduceTheMargin.swiftlint lint --strict— 0devin 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 —ZDEPOSITMODELcreated (ZAMOUNT ZDATE ZNOTE ZSOURCE ZGOALID ZID), goal and expense rows intact, ledger empty. Dump below.Risks
WarmGoalCardandHeroGoalCardeach run a@Queryfiltered 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