mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 23:01:26 +00:00
* feat(goals): reserves you maintain, not goals you finish An emergency fund is not a goal you reach and close — it is a level you hold, and every withdrawal is a shortfall to make good. Sure treated it like anything else: at 100% it offered to close it, which would release the very money being set aside; a withdrawal dropped the bar with no sign that anything was owed. `kind` (added by the lifecycle lot without behavior) now means something. A maintained goal is `funded` or `depleted`, never `reached` — sitting at its floor is a steady state, not an achievement to file away. `complete` is refused by an AASM guard rather than merely hidden, so no path can release a reserve's earmark. Two ordering traps, both of which would have made a drained reserve invisible: `ACTIVE_DISPLAY_STATUS_RANK` falls back to 4 for any status it does not know, so an unranked `:depleted` would sort a drained emergency fund below everything else — the exact opposite of what it means. It ranks alongside `:behind` now, and `:funded` sorts near the end with the goals that need nothing. `behind_pace?` excludes reserves. `monthly_target_amount` and `pace` both derive from `target_date`, which a reserve does not have, so "save X/month to catch up" would be advice about a deadline that does not exist. The form leads with the choice, since it changes what the rest of it means, and hides the target date for a reserve rather than disabling it — a hidden field cannot submit a stale value that would then drive a pace. The card states the shortfall, which is exactly `remaining_amount`. The panel that offers a one-off its closing action tells a reserve it is intact and offers nothing, because there is nothing to do. Scope: fixed targets only. Targets expressed in months of expenses, the monthly refresh job, and the depletion insight are the next two PRs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DJ1npaGEHr6t2HW1rYZdt4 * fix(goals): let a reserve behave like one everywhere it is shown Addresses review feedback on #3167. The kind selector never hid the target date. `data-controller="goal-kind"` sat on the selector div while its `dateField` target is a sibling, so `dateFieldTargets` came back empty and picking "Reserve to maintain" left the deadline on screen and submittable. The controller moves to the form wrapper, which encloses both. Hiding a field is not enforcement, so the model now clears `target_date` for a maintained goal. Normalising rather than rejecting: the field is hidden, and an error about something the user cannot see is not actionable. A date could only arrive through a conversion or a crafted request, and either way a stored deadline would drive a pace the reserve does not have. A completed goal could be switched to `maintained` from the edit form. It then sat in a released state — one that has handed its earmark back — while the show page promised its money stays reserved, and `complete` for reserves is refused precisely to prevent that state. `kind` is now locked while released: reopen first. Reserves counted against the "goals on track" tile. Their statuses are `funded`/`depleted`, which match none of the exclusions in `tracked_total`, so they could never reach the numerator and a family with one reserve read "0 of 1 on track" for a goal working exactly as intended. Two more places still spoke of pace to something that has none. `pace_line` is suppressed for reserves on the card, and a depleted reserve gets its own panel before the projection card — the projection's summary, catch-up line and colour are all built from a deadline. What a drained reserve needs is the number the projection cannot show: how much is missing from the floor. The French celebration copy read "Votre réserve est à son niveau", which never says which level. Each guard was confirmed load-bearing by removing it and watching its test fail. bin/rails test: 6962 runs, 28003 assertions, 0 failures. RuboCop, erb_lint and Brakeman clean. Left open deliberately: extracting the show page's lifecycle panel into a ViewComponent. The guideline behind it is right, but the refactor is wider than this round of fixes and belongs on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): finish teaching the status consumers about reserves Second round of review feedback on #3167: two consumers still had no branch for the reserve statuses. `ProgressRingComponent#percent_text_class` styled only `:reached` as success, so a funded reserve — a floor the user is holding exactly as intended — fell back to the neutral colour and read as unfinished. `status_callout_context` had no `:depleted` branch, so a drained reserve showed no callout at all: the one status that most deserves a line of explanation was the only one saying nothing. It now names the shortfall. `:funded` deliberately keeps no callout — a reserve at its level has nothing to report, and the celebration panel already says so. A test pins that, so the silence reads as a decision rather than another missing branch. bin/rails test: 6964 runs, 28008 assertions, 0 failures. RuboCop and Brakeman clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): lock the kind on the state the goal is actually in Addresses review feedback on #3167, on the guard that landed in c1c7f4f7. `kind_locked_while_released` read the in-memory `state`, so a single write setting `state: "active"` alongside the new kind saw the goal as already reopened and waved it through. The end state looks legitimate — active and maintained — which is why the hole is easy to miss. It is not: the direct write skipped the `reopen` transition, and with it `thaw_completed_amount!`. `completed_amount` survived, so `current_balance` returned that frozen snapshot forever on a live reserve. Reopening has to be its own gesture, because it is the gesture that thaws. Now reads `state_in_database`, with a regression test on the combined write asserting both that it is refused and that the frozen amount is untouched. Confirmed load-bearing by reading the attribute again and watching it fail. bin/rails test: 6969 runs, 28019 assertions, 0 failures. RuboCop and Brakeman clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): send an empty reserve to the shortfall panel, not the empty state Addresses the last review thread on #3167. The `maintained?` branch sat after the zero-balance/zero-pace one, so a brand-new reserve matched the generic "make your first transfer" card. I had put it there on purpose, thinking a reserve with nothing in it wanted the first-transfer nudge. The review is right that it does not: it is still a reserve short of its floor, and the shortfall panel says so with the saved, target and missing amounts, where the generic card says none of them. Ordering it after also meant evaluating `pace` on a goal that has no pace to evaluate. bin/rails test: 6965 runs, 0 failures. RuboCop and erb_lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): stop a paused goal outranking a reserve that is whole Review on #3175. `:funded` and paused both ranked 3 in `active_display_sort`, so the tie broke on name and a paused goal called "Alpha" sat above a reserve called "Zeta" that was fully funded — the list saying the paused one wanted attention more. Paused now ranks behind every status, which is what the comment above the table already claimed. The seven panels on the goal page were hand-rolled repetitions of `DS::Card`'s exact shell, two of them adjacent and identical. They render through the primitive now, so their surface styling cannot drift apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * refactor(goals): move the lifecycle panel decision out of the template Review on #3167 and #3180. Which panel a goal gets is a lifecycle question with five answers, and the template worked it out inline from `completed?`, `maintained?`, `one_off?`, `status` and `may_complete?` — five predicates deep in ERB where the ordering between them was load-bearing and nothing said so. `Goals::LifecyclePanelComponent` answers it in Ruby and the template renders the answer. The markup moves across unchanged, keys made absolute because a relative `t(".x")` in a component resolves against the component's own path rather than the page these strings belong to. The order is now stated once, where it can be read and tested: `:reserve_shortfall` before `:empty`, because a brand-new reserve sits at zero balance and zero pace and the generic "make your first transfer" card would otherwise swallow it. Closing from the panel now confirms, as the header menu already did. Completing releases the goal's earmarked money, and the panel offered that in one click. Both go through `goal_complete_confirm` rather than building the wording twice — two copies drifting apart is how one ends up describing the wrong consequence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): refresh the pace suggestion when the deadline is cleared Assigning `input.value = ""` fires no event, so `goal-form#suggestedChanged` never ran: selecting "Reserve to maintain" cleared the date but left the monthly pace suggestion on screen, derived from a deadline the goal no longer has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(goals): let a depleted reserve look as urgent as it is Review on #3179 and #3180. `Goal#needs_attention?` names the pair of statuses that mean "this one wants looking at" — a goal off its pace and a reserve below its floor. Three places were spelling that out and the Plan hub's progress bar had fallen behind, so a depleted reserve got a neutral bar an inch from its own amber status pill: the same goal reported as needing attention and not. `projection_summary` told a funded reserve it had "hit the target, no projection needed". A reserve holds a level; there is no finish line to project toward and no target to have hit. It does not reach that panel today — the shortfall and celebration panels catch it first — but the method reads as the single source of truth for that subtitle and should not hand a caller a one-off's wording. The legend swatches are bordered spans now rather than inline SVG, and the label takes `text-xs` instead of an arbitrary 11px. The projection swatch keeps the chart's own colour variables in an inline style rather than `border-success` / `border-warning`: the chart hard-codes green-600 and yellow-600, and a legend whose colour does not match the line it describes is worse than the markup it would save. The Plan-card test counts warning bars rather than matching one. A fixture goal is already off its pace, so the markup is on the page either way and a presence check passed without the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * feat(goals): tell the user when a reserve has dropped below its level A maintained reserve exists to hold a floor. Nothing said when it stopped holding it: the goal page shows the shortfall, but only to someone who thought to look, and a reserve is precisely the thing you stop looking at once it is full. That silence is the gap this closes. High priority, unlike most of this feed. `IdleCashGenerator` is a nudge about money doing nothing; this is the opposite — a floor the user deliberately set is no longer there. It is also the one signal a reserve can produce that a one-off goal cannot, which is what makes it worth a generator of its own. Three deliberate limits: - **Active reserves only.** A paused one is shelved on purpose, and `behind_pace?` already excludes paused goals for the same reason. Nagging about a goal someone put down is noise. - **The dedup key rotates monthly.** A reserve can sit short for weeks while it is rebuilt, and re-raising the same shortfall every night trains people to dismiss the feed. - **Two at a time, worst shortfall first.** A family running four drained reserves has one problem, not four. Loaded through `Goal.prepared_for` so the family-wide pooled allocations are read once: asking each reserve for its status reaches `current_balance`, and without that injection every one of them would re-read the whole pool. bin/rails test: 7079 runs, 28491 assertions, 0 failures. RuboCop and Brakeman clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(insights): stop an insight naming something that has been renamed Review on #3175: renaming a depleted reserve leaves the stored title naming the old one for the rest of the month, because the name is not part of the metadata that drives a refresh. Rather than making the name material, the same-signal branch now refreshes the title alongside the facts. The title is built from I18n and the generator's own data — the model writes the body, not this — so keeping it current costs nothing, and a rename is not a reason to resurface an insight the user has already read. The body still says the old name until the numbers move. Forcing an LLM rewrite on every rename is the wrong trade, and making the name material would do that *and* re-nag the user. This is generic to every generator whose title embeds a name, not only the reserve one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GTNba5qE5NwzaHzbp27ye * fix(insights): refresh the title on reactivation too A gap in the previous commit: the expired-and-returned branch resurfaces an insight without touching its title, so a subject renamed while the insight was expired came back naming the old one. Same reasoning as the same-signal branch — the title is I18n plus the generator's own data, not the model's prose, so keeping it current costs nothing. 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>
74 lines
3.0 KiB
Ruby
74 lines
3.0 KiB
Ruby
# A proactive, typed observation about a family's finances, produced nightly
|
|
# by GenerateInsightsJob. The financial logic lives in Insight::Generators::*;
|
|
# the LLM (when configured) only writes the `body` prose from pre-computed
|
|
# numbers, so rows are safe to render verbatim.
|
|
#
|
|
# Status semantics: `read` and `acknowledged` are user actions; `expired` is the
|
|
# system's — set when a signal stops being generated (the condition cleared).
|
|
#
|
|
# "Acknowledged" rather than "dismissed" because that is what the state has
|
|
# always actually meant. GenerateInsightsJob resurfaces a row whose bucketed
|
|
# metadata changes materially even if the user acknowledged the stale version,
|
|
# and 6 of 8 generators scope `dedup_key` to a month, so acknowledging July's
|
|
# budget card says nothing about August's. The contract is: acknowledgement
|
|
# covers the numbers you saw; new numbers are a new insight. The DB value stays
|
|
# `"dismissed"` (and the `dismissed_at` column keeps its name) so this needed no
|
|
# migration — only the vocabulary the code and the UI speak was wrong.
|
|
class Insight < ApplicationRecord
|
|
belongs_to :family
|
|
|
|
TYPES = %w[
|
|
spending_anomaly
|
|
cash_flow_warning
|
|
net_worth_milestone
|
|
subscription_audit
|
|
savings_rate_change
|
|
idle_cash
|
|
budget_at_risk
|
|
budget_on_track
|
|
maintained_goal_depleted
|
|
].freeze
|
|
|
|
# How many the dashboard widget shows. Shared so PagesController (first render)
|
|
# and InsightsController (re-render after acknowledging) can't drift apart.
|
|
FEED_LIMIT = 3
|
|
|
|
enum :status, { active: "active", read: "read", acknowledged: "dismissed", expired: "expired" }
|
|
enum :priority, { high: "high", medium: "medium", low: "low" }, prefix: true
|
|
|
|
validates :insight_type, presence: true, inclusion: { in: TYPES }
|
|
validates :title, :body, :dedup_key, presence: true
|
|
# Mirrors the DB unique index so direct callers get a validation error
|
|
# instead of ActiveRecord::RecordNotUnique; races still hit the index.
|
|
validates :dedup_key, uniqueness: { scope: :family_id }
|
|
|
|
# Everything the user hasn't acknowledged; what the feed renders.
|
|
scope :visible, -> { where(status: [ :active, :read ]) }
|
|
scope :ordered, -> {
|
|
order(Arel.sql("CASE insights.priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END"))
|
|
.order(generated_at: :desc)
|
|
}
|
|
|
|
def mark_read!
|
|
return unless active?
|
|
|
|
update!(status: :read, read_at: Time.current)
|
|
end
|
|
|
|
def acknowledge!
|
|
update!(status: :acknowledged, dismissed_at: Time.current)
|
|
end
|
|
|
|
# Undoes an acknowledgement without re-badging the insight as new — the user
|
|
# has obviously seen it, so it returns as read. Guarded to only reverse an
|
|
# actual acknowledgement: a stale/replayed undo (e.g. an old toast link
|
|
# clicked after GenerateInsightsJob has since expired or resurrected this
|
|
# insight) would otherwise force it back to :read from whatever state it's
|
|
# really in, including bringing an :expired insight back into view.
|
|
def unacknowledge!
|
|
return unless acknowledged?
|
|
|
|
update!(status: :read, dismissed_at: nil, read_at: read_at || Time.current)
|
|
end
|
|
end
|