mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 06:41:08 +00:00
* feat(goals): a reserve measured in months of spending, not a fixed sum "Six months of expenses" is the way people actually describe an emergency fund, and it is a moving number: what covered six months last January does not cover six months today. A reserve pinned to a figure typed once drifts quietly out of date, and the drift always runs the wrong way — the bar reads full while the cover shrinks. `target_amount` stays the single source of truth, rewritten monthly by RefreshMaintainedGoalTargetsJob. That is the whole architectural decision here. An `effective_target_amount` would have been the obvious shape and the wrong one: `remaining_amount`, `progress_percent`, `Goal.summary_for`, the ring, the card and every future caller would each have had to learn which target to read. None of them change. The job refuses to write more often than it writes, on purpose: - a family with no spending history yet computes a floor of zero, which would both violate the `target_amount > 0` constraint and read to the user as "your reserve is complete". The previous target stands. - a figure identical to the current one is not rewritten, so a reserve does not collect a fresh updated_at every month for nothing. - a write that fails validation leaves the target alone and is recorded through DebugLogEntry, not just the application log: a reserve frozen at a stale floor is invisible to the user, who has no reason to suspect the number stopped moving. The job reads the family's spending, not a member's view. IncomeStatement falls back to Current.user when nobody says otherwise, which in a background job is nobody — so the scope is the whole family, and the number is the same whoever is looking. That is deliberate, and matches how the rollover chain had to be pinned. `target_months` is refused outside a months-mode reserve rather than tolerated: a number nothing reads would sit there looking meaningful, and the job would skip it for reasons no one could see. schema.rb is hand-edited again — verified against a real migration on a throwaway database, structures identical. The dumper on this Rails version also rewrites every check-constraint cast, so the new constraint is written in the file's existing style rather than the dumper's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DJ1npaGEHr6t2HW1rYZdt4 * fix(goals): pin the reserve calculation to the family, and get it right on day one Review of the previous commit raised two things, and they are the same thing seen from either end. `IncomeStatement.new(family)` looked family-wide but was only so by accident. Its constructor falls back to `Current.user`, and eligible_accounts narrows to that user's accounts when one is present. The single caller was a background job, where nobody is current — so the figure was correct for the reason that it happened to be computed nowhere else. `target_amount` belongs to the whole family: derived from a viewer's slice of the accounts, it would have started moving with whoever last triggered it. This is the same fallback that made the budget rollover carry depend on its reader. The account scope is now passed explicitly, so the calculation is safe whatever calls it. That mattered immediately, because the second point required a new caller. A reserve created as "6 months of expenses" had no floor computed until the 1st of the following month: the user chose the mode, guessed an amount, and lived with a wrong target for up to a month. The feature's first impression was its least convincing moment. The floor is now computed on creation, and whenever the mode or the number of months changes — but never on an unrelated save, so the monthly job keeps owning the cadence and renaming a goal cannot silently move a financial figure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DJ1npaGEHr6t2HW1rYZdt4 * fix(goals): keep a months-based floor derived, and in the right currency Review on #3180. The median comes back in FAMILY currency and `target_amount` is stored in the GOAL's, so a EUR reserve in a USD family read a 3,000 dollar floor as 3,000 euros — and rewrote it that way every month, silently. Converted now, and when there is no rate for the day the previous target stands: the same safe failure the method already took for a family with no spending history, because a stale floor beats a wrong one. The callback also skipped a target_amount edit, so the form could persist an arbitrary figure under a "six months of expenses" label until the next monthly refresh. It runs on that edit now and overwrites it — and when there is nothing to derive from, restores what the reserve already had rather than accepting the typed figure. The form marks the field read-only in that mode. The model does not depend on it, but a field that silently discards what you type is worse than one you cannot type into. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
55 lines
2.3 KiB
JavaScript
55 lines
2.3 KiB
JavaScript
import { Controller } from "@hotwired/stimulus"
|
|
|
|
// A maintained reserve has no deadline: it is a level to hold, not something
|
|
// due on a date. Hiding the target-date field keeps a stale value from being
|
|
// submitted and driving a pace the goal does not have.
|
|
export default class extends Controller {
|
|
static targets = ["radio", "dateField", "modeField", "modeSelect", "monthsField", "amountField"]
|
|
|
|
connect() {
|
|
this.refresh()
|
|
}
|
|
|
|
refresh() {
|
|
const maintained = this.radioTargets.some((radio) => radio.checked && radio.value === "maintained")
|
|
|
|
this.dateFieldTargets.forEach((field) => field.classList.toggle("hidden", maintained))
|
|
if (maintained) this.#clearInputs(this.dateFieldTargets)
|
|
|
|
// The target mode is a reserve's business only; a one-off is always a
|
|
// fixed amount. Reset it on the way out so a one-off cannot be saved
|
|
// carrying a months mode nothing would ever refresh.
|
|
this.modeFieldTargets.forEach((field) => field.classList.toggle("hidden", !maintained))
|
|
if (!maintained && this.hasModeSelectTarget) this.modeSelectTarget.value = "fixed"
|
|
|
|
const months = maintained && this.hasModeSelectTarget && this.modeSelectTarget.value === "months_of_expenses"
|
|
this.monthsFieldTargets.forEach((field) => field.classList.toggle("hidden", !months))
|
|
if (!months) this.#clearInputs(this.monthsFieldTargets)
|
|
|
|
// In months mode the floor is derived from the family's spending, not
|
|
// chosen. Shown, because it is the figure the user is saving against, but
|
|
// not editable — the model overwrites a typed one anyway, and a field that
|
|
// silently discards what you put in it is worse than one you cannot type
|
|
// into.
|
|
this.amountFieldTargets.forEach((field) => {
|
|
field.querySelectorAll("input").forEach((input) => {
|
|
input.readOnly = months
|
|
input.classList.toggle("opacity-60", months)
|
|
})
|
|
})
|
|
}
|
|
|
|
#clearInputs(fields) {
|
|
fields.forEach((field) => {
|
|
const input = field.querySelector("input")
|
|
if (!input) return
|
|
|
|
input.value = ""
|
|
// Assigning `value` fires nothing, so the pace suggestion bound to this
|
|
// input's action kept showing a monthly figure derived from a deadline
|
|
// the goal no longer has.
|
|
input.dispatchEvent(new Event("input", { bubbles: true }))
|
|
})
|
|
}
|
|
}
|