diff --git a/.gitignore b/.gitignore index 6a4d77fe0..19cc81a3a 100644 --- a/.gitignore +++ b/.gitignore @@ -124,3 +124,10 @@ scripts/ .claude_settings.json .security-key logs/security/ + +# Added by codex +.codex + +# Ignore Playwright artifacts +playwright-report/ +.playwright-mcp diff --git a/Dockerfile.preview b/Dockerfile.preview index ace289b12..b1661d9b3 100644 --- a/Dockerfile.preview +++ b/Dockerfile.preview @@ -85,6 +85,22 @@ emit_status() { fi } +summarize_log_tail() { + local file="$1" + local label="$2" + + if [ ! -f "$file" ]; then + printf '%s log unavailable' "$label" + return 0 + fi + + tail -n 80 "$file" 2>&1 | + sed 's/"/'"'"'/g' | + tr '\n' ' ' | + sed 's/ */ /g' | + cut -c 1-1600 +} + trap 'emit_status failed "preview-entrypoint failed on line ${LINENO}"' ERR emit_status boot "preview-entrypoint started" @@ -218,7 +234,7 @@ for i in {1..180}; do ' ) > /tmp/demo-data.log 2>&1 && \ emit_status demo-data-ready "default demo dataset loaded in background" || \ - emit_status demo-data-failed "background demo dataset load failed" + emit_status demo-data-failed "background demo dataset load failed: $(summarize_log_tail /tmp/demo-data.log demo-data)" ) & fi diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css index 3d868c741..ddbda9b04 100644 --- a/app/assets/tailwind/application.css +++ b/app/assets/tailwind/application.css @@ -188,6 +188,20 @@ scrollbar-width:none } +/* Tinted-bg + colored-content avatar used by Goals::AvatarComponent and + the goals color/icon picker. Theme-aware text color: light mode darkens + the letter/icon so pale palette entries (cyan-300, green-300, etc.) keep + ~4.5:1 contrast against the 10%-mix tint over white. Dark mode reverts + to the full color so the letter doesn't disappear against the near-black + surface. */ +.goal-avatar { + background-color: color-mix(in oklab, var(--avatar-color) 10%, transparent); + color: color-mix(in oklab, var(--avatar-color) 55%, black); +} +[data-theme="dark"] .goal-avatar { + color: var(--avatar-color); +} + .invite_code [data-clipboard-target="iconDefault"], .invite_code [data-clipboard-target="iconSuccess"] { transition: opacity 0.2s; diff --git a/app/assets/tailwind/sure-design-system/base.css b/app/assets/tailwind/sure-design-system/base.css index 522cdcf37..b23d9fb9e 100644 --- a/app/assets/tailwind/sure-design-system/base.css +++ b/app/assets/tailwind/sure-design-system/base.css @@ -1,4 +1,15 @@ @layer base { + /* Bind CSS color-scheme to Sure's data-theme attribute so the CSS + `light-dark()` function resolves to the side that matches the active + theme (used by DS::Pill and any future tokens that opt in). */ + :root { + color-scheme: light; + } + + [data-theme="dark"] { + color-scheme: dark; + } + button { @apply cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-alpha-black-300; diff --git a/app/components/DS/dialog_controller.js b/app/components/DS/dialog_controller.js index 5391d42ae..f668693a2 100644 --- a/app/components/DS/dialog_controller.js +++ b/app/components/DS/dialog_controller.js @@ -1,5 +1,14 @@ import { Controller } from "@hotwired/stimulus"; +const FOCUSABLE_SELECTOR = [ + "a[href]", + "button:not([disabled])", + "textarea:not([disabled])", + "input:not([disabled]):not([type=hidden])", + "select:not([disabled])", + "[tabindex]:not([tabindex='-1'])", +].join(", "); + // Connects to data-controller="dialog" export default class extends Controller { static targets = ["content"] @@ -11,12 +20,26 @@ export default class extends Controller { }; connect() { + this._priorFocus = null; + this._onKeydown = this.#onKeydown.bind(this); + this._onClose = this.#onClose.bind(this); + + this.element.addEventListener("keydown", this._onKeydown); + this.element.addEventListener("close", this._onClose); + if (this.element.open) return; if (this.autoOpenValue) { + this._priorFocus = document.activeElement; this.element.showModal(); + this.#focusInitial(); } } - + + disconnect() { + this.element.removeEventListener("keydown", this._onKeydown); + this.element.removeEventListener("close", this._onClose); + } + // If the user clicks anywhere outside of the visible content, close the dialog clickOutside(e) { if (this.disableClickOutsideValue) return; @@ -27,9 +50,61 @@ export default class extends Controller { close() { this.element.close(); + this.#clearParentModalFrame(); if (this.reloadOnCloseValue) { Turbo.visit(window.location.href); } } + + // Move focus to the first focusable child unless the dialog already + // declared one via the autofocus attribute. Native `.showModal()` + // is supposed to do this but the behavior varies across engines. + #focusInitial() { + if (this.element.querySelector("[autofocus]")) return; + this.#focusables()[0]?.focus(); + } + + // Tab/Shift+Tab wrap inside the dialog so focus can't leak to the page + // behind. Without this an a11y user can tab into the backdrop'd content + // and lose the modal context entirely. + #onKeydown(e) { + if (e.key !== "Tab") return; + const focusables = this.#focusables(); + if (focusables.length === 0) { + e.preventDefault(); + return; + } + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + + #onClose() { + const prior = this._priorFocus; + this._priorFocus = null; + if (prior && typeof prior.focus === "function" && document.body.contains(prior)) { + prior.focus(); + } + } + + #focusables() { + return Array.from(this.element.querySelectorAll(FOCUSABLE_SELECTOR)).filter( + (el) => el.offsetParent !== null || el === document.activeElement, + ); + } + + // When the dialog lives inside a top-level , + // emptying the frame on close stops Turbo's page cache from snapshotting + // an open dialog and reopening it on browser back. + #clearParentModalFrame() { + const frame = this.element.closest('turbo-frame[id="modal"]'); + if (frame) frame.innerHTML = ""; + } } diff --git a/app/components/DS/disclosure.rb b/app/components/DS/disclosure.rb index 5f993d6c1..1acb7dfe0 100644 --- a/app/components/DS/disclosure.rb +++ b/app/components/DS/disclosure.rb @@ -3,7 +3,7 @@ class DS::Disclosure < DesignSystemComponent VARIANTS = %i[default card card_inset inline].freeze - attr_reader :title, :align, :open, :variant, :opts + attr_reader :title, :align, :open, :variant, :summary_class_override, :opts # `:default` — bg-surface summary, no chrome on the `
`. Use # for inline expanders that sit inside a parent card (the summary @@ -28,11 +28,12 @@ class DS::Disclosure < DesignSystemComponent # In card / inline variants, callers should pass their own # `summary_content` slot; the built-in title rendering assumes the # `:default` shape. - def initialize(title: nil, align: "right", open: false, variant: :default, **opts) + def initialize(title: nil, align: "right", open: false, variant: :default, summary_class: nil, **opts) @title = title @align = align.to_sym @open = open @variant = variant&.to_sym + @summary_class_override = summary_class @opts = opts raise ArgumentError, "Invalid variant: #{@variant.inspect}. Must be one of #{VARIANTS.inspect}" unless VARIANTS.include?(@variant) @@ -59,6 +60,8 @@ class DS::Disclosure < DesignSystemComponent end def summary_classes + return summary_class_override if summary_class_override.present? + case variant when :card, :card_inset # Card variants: no bg on summary — the parent details *is* the diff --git a/app/components/DS/pill.rb b/app/components/DS/pill.rb index e340466ee..a868d8baa 100644 --- a/app/components/DS/pill.rb +++ b/app/components/DS/pill.rb @@ -59,14 +59,18 @@ class DS::Pill < DesignSystemComponent end def palette + # Light-mode `text` is mixed 30% with black on top of the 700 stop so + # the 10–11px uppercase label still reads against the very pale 50 + # background. Without the mix the perceptual contrast feels low even + # though the raw ratio passes WCAG. { - violet: { bg: "var(--color-violet-50)", bg_dark: "var(--color-violet-tint-10)", text: "var(--color-violet-700)", text_dark: "var(--color-violet-200)", border: "var(--color-violet-200)", dot: "var(--color-violet-500)", fill: "var(--color-violet-500)" }, - indigo: { bg: "var(--color-indigo-50)", bg_dark: "var(--color-indigo-tint-10)", text: "var(--color-indigo-700)", text_dark: "var(--color-indigo-200)", border: "var(--color-indigo-200)", dot: "var(--color-indigo-500)", fill: "var(--color-indigo-500)" }, - fuchsia: { bg: "var(--color-fuchsia-50)", bg_dark: "var(--color-fuchsia-tint-10)", text: "var(--color-fuchsia-700)", text_dark: "var(--color-fuchsia-200)", border: "var(--color-fuchsia-200)", dot: "var(--color-fuchsia-500)", fill: "var(--color-fuchsia-500)" }, - amber: { bg: "var(--color-yellow-50)", bg_dark: "var(--color-yellow-tint-10)", text: "var(--color-yellow-700)", text_dark: "var(--color-yellow-200)", border: "var(--color-yellow-200)", dot: "var(--color-yellow-500)", fill: "var(--color-yellow-500)" }, - green: { bg: "var(--color-green-50)", bg_dark: "var(--color-green-tint-10)", text: "var(--color-green-700)", text_dark: "var(--color-green-200)", border: "var(--color-green-200)", dot: "var(--color-green-500)", fill: "var(--color-green-500)" }, - gray: { bg: "var(--color-gray-100)", bg_dark: "var(--color-gray-tint-10)", text: "var(--color-gray-700)", text_dark: "var(--color-gray-200)", border: "var(--color-gray-200)", dot: "var(--color-gray-500)", fill: "var(--color-gray-500)" }, - red: { bg: "var(--color-red-50)", bg_dark: "var(--color-red-tint-10)", text: "var(--color-red-700)", text_dark: "var(--color-red-200)", border: "var(--color-red-200)", dot: "var(--color-red-500)", fill: "var(--color-red-500)" } + violet: { bg: "var(--color-violet-50)", bg_dark: "var(--color-violet-tint-10)", text: "color-mix(in oklab, var(--color-violet-700), black 30%)", text_dark: "var(--color-violet-200)", border: "var(--color-violet-200)", dot: "var(--color-violet-500)", fill: "var(--color-violet-500)" }, + indigo: { bg: "var(--color-indigo-50)", bg_dark: "var(--color-indigo-tint-10)", text: "color-mix(in oklab, var(--color-indigo-700), black 30%)", text_dark: "var(--color-indigo-200)", border: "var(--color-indigo-200)", dot: "var(--color-indigo-500)", fill: "var(--color-indigo-500)" }, + fuchsia: { bg: "var(--color-fuchsia-50)", bg_dark: "var(--color-fuchsia-tint-10)", text: "color-mix(in oklab, var(--color-fuchsia-700), black 30%)", text_dark: "var(--color-fuchsia-200)", border: "var(--color-fuchsia-200)", dot: "var(--color-fuchsia-500)", fill: "var(--color-fuchsia-500)" }, + amber: { bg: "var(--color-yellow-50)", bg_dark: "var(--color-yellow-tint-10)", text: "color-mix(in oklab, var(--color-yellow-700), black 30%)", text_dark: "var(--color-yellow-200)", border: "var(--color-yellow-200)", dot: "var(--color-yellow-500)", fill: "var(--color-yellow-500)" }, + green: { bg: "var(--color-green-50)", bg_dark: "var(--color-green-tint-10)", text: "color-mix(in oklab, var(--color-green-700), black 30%)", text_dark: "var(--color-green-200)", border: "var(--color-green-200)", dot: "var(--color-green-500)", fill: "var(--color-green-500)" }, + gray: { bg: "var(--color-gray-100)", bg_dark: "var(--color-gray-tint-10)", text: "color-mix(in oklab, var(--color-gray-700), black 30%)", text_dark: "var(--color-gray-200)", border: "var(--color-gray-200)", dot: "var(--color-gray-500)", fill: "var(--color-gray-500)" }, + red: { bg: "var(--color-red-50)", bg_dark: "var(--color-red-tint-10)", text: "color-mix(in oklab, var(--color-red-700), black 30%)", text_dark: "var(--color-red-200)", border: "var(--color-red-200)", dot: "var(--color-red-500)", fill: "var(--color-red-500)" } }[tone] end diff --git a/app/components/goals/account_stack_component.html.erb b/app/components/goals/account_stack_component.html.erb new file mode 100644 index 000000000..99ea5936e --- /dev/null +++ b/app/components/goals/account_stack_component.html.erb @@ -0,0 +1,13 @@ + diff --git a/app/components/goals/account_stack_component.rb b/app/components/goals/account_stack_component.rb new file mode 100644 index 000000000..c1892e100 --- /dev/null +++ b/app/components/goals/account_stack_component.rb @@ -0,0 +1,27 @@ +class Goals::AccountStackComponent < ApplicationComponent + def initialize(accounts:, max: 3, color_map: nil) + @accounts = accounts + @max = max + @color_map = color_map || {} + end + + def shown + @accounts.first(@max) + end + + def extra_count + (@accounts.size - @max).clamp(0..) + end + + def initial_for(account) + account.name.to_s.strip.first&.upcase || "?" + end + + # Color for this account, sourced from the per-goal color map when the + # caller provided one (so the stack on the index card matches the funding + # widget on the show page). Falls back to the name-hashed palette pick + # for backward compatibility with any caller that didn't pass `color_map:`. + def color_for(account) + @color_map[account.id] || Goals::AvatarComponent.color_for(account.name) + end +end diff --git a/app/components/goals/avatar_component.html.erb b/app/components/goals/avatar_component.html.erb new file mode 100644 index 000000000..75722e703 --- /dev/null +++ b/app/components/goals/avatar_component.html.erb @@ -0,0 +1,10 @@ + diff --git a/app/components/goals/avatar_component.rb b/app/components/goals/avatar_component.rb new file mode 100644 index 000000000..dcfaf7159 --- /dev/null +++ b/app/components/goals/avatar_component.rb @@ -0,0 +1,60 @@ +class Goals::AvatarComponent < ApplicationComponent + SIZES = { + "sm" => { box: "w-6 h-6", text: "text-[10px]", radius: "rounded-md" }, + "md" => { box: "w-9 h-9", text: "text-sm", radius: "rounded-lg" }, + "lg" => { box: "w-11 h-11", text: "text-base", radius: "rounded-xl" }, + "xl" => { box: "w-16 h-16", text: "text-2xl", radius: "rounded-2xl" } + }.freeze + + PALETTE = Goal::COLORS + + # Deterministic color pick from the palette so the same string maps to + # the same color across processes (Ruby's String#hash is randomized per + # boot for DoS protection. not stable enough for visual identity). + def self.color_for(name) + return PALETTE.first if name.blank? + PALETTE[Digest::MD5.hexdigest(name).to_i(16) % PALETTE.size] + end + + def initialize(goal: nil, name: nil, color: nil, icon: nil, size: "md") + @goal = goal + @name = name || goal&.name + @color = color || goal&.color || Goal::COLORS.first + @icon = icon || goal&.icon + @size = SIZES.key?(size) ? size : "md" + end + + attr_reader :color + + # Don't expose @icon via attr_reader. `icon` collides with the global + # icon helper used inside the template. + def icon_name + @icon + end + + def initial + return "?" if @name.blank? + @name.strip.first&.upcase || "?" + end + + def icon_size + case @size + when "sm" then "xs" + when "md" then "sm" + when "lg" then "md" + when "xl" then "xl" + end + end + + def box_classes + SIZES[@size][:box] + end + + def text_classes + SIZES[@size][:text] + end + + def radius_classes + SIZES[@size][:radius] + end +end diff --git a/app/components/goals/card_component.html.erb b/app/components/goals/card_component.html.erb new file mode 100644 index 000000000..7e2174885 --- /dev/null +++ b/app/components/goals/card_component.html.erb @@ -0,0 +1,65 @@ +
" + <% if filterable %> data-goals-filter-target="card"<% end %> + data-goal-name="<%= goal.name %>" + data-goal-status="<%= goal.display_status %>"> +
+ <%= render Goals::AvatarComponent.new(goal: goal, size: "lg") %> +
+
+

+ + <%= goal.name %> + +

+ <%= render Goals::StatusPillComponent.new(goal: goal) %> +
+

<%= secondary_line %>

+
+ +
+ + +
+
+ +
+
+ <%= goal.current_balance_money.format(precision: 0) %> + / <%= goal.target_amount_money.format(precision: 0) %> +
+ <% if pace_line %> +

<%= pace_line %>

+ <% end %> +
+ +
+
+ <%= render Goals::AccountStackComponent.new(accounts: linked_accounts, color_map: goal.account_color_map) %> + <%= linked_accounts_count_label %> +
+ "> + <%= footer_line %><% if has_pending_pledge? %> · <%= t("goals.goal_card.pending_count", count: pending_pledges_count) %><% end %> + +
+
diff --git a/app/components/goals/card_component.rb b/app/components/goals/card_component.rb new file mode 100644 index 000000000..0f68d4839 --- /dev/null +++ b/app/components/goals/card_component.rb @@ -0,0 +1,120 @@ +class Goals::CardComponent < ApplicationComponent + RING_SIZE = 64 + RING_STROKE = 6 + + def initialize(goal:, filterable: true) + @goal = goal + @filterable = filterable + end + + attr_reader :goal, :filterable + + def progress_percent + goal.progress_percent + end + + def ring_color + case goal.status + when :reached, :on_track then "var(--color-success)" + when :behind then "var(--color-warning)" + else "var(--color-gray-400)" + end + end + + def linked_accounts + @linked_accounts ||= goal.linked_accounts.to_a + end + + # Open + unexpired pledges are preloaded on the index via the + # `.includes(:open_pledges, ...)` chain in GoalsController#index, so + # this is a hit on the in-memory association — no N+1. + def has_pending_pledge? + pending_pledges_count.positive? + end + + def pending_pledges_count + @pending_pledges_count ||= goal.open_pledges.size + end + + def linked_accounts_count_label + I18n.t("goals.goal_card.accounts", count: linked_accounts.size) + end + + # Single screen-reader sentence for the card's title aria-label. + # Without this, the whole-card link would inherit every nested text node + # as its accessible name (>15 strings on a typical card). + def aria_label + status_text = I18n.t("goals.status.#{goal.display_status}") + progress_text = I18n.t("goals.goal_card.aria_progress", + percent: progress_percent, + target: goal.target_amount_money.format(precision: 0)) + [ goal.name, status_text, progress_text ].join(", ") + end + + def secondary_line + if goal.completed? + I18n.t("goals.goal_card.completed") + elsif goal.target_date.nil? + I18n.t("goals.goal_card.no_target_date") + else + days = (goal.target_date - Date.current).to_i + if days >= 0 + I18n.t("goals.goal_card.days_left_by", count: days, date: I18n.l(goal.target_date, format: :long)) + else + I18n.t("goals.goal_card.past_due") + end + end + end + + def ring_circumference + @ring_circumference ||= 2 * Math::PI * ring_radius + end + + def ring_radius + @ring_radius ||= (RING_SIZE - RING_STROKE) / 2.0 + end + + def ring_offset + pct = [ [ progress_percent.to_i, 0 ].max, 100 ].min + ring_circumference * (1 - pct / 100.0) + end + + def pace_line + return nil if goal.archived? || goal.paused? || goal.completed? || goal.status == :reached + + avg = goal.pace_money.format(precision: 0) + target = goal.monthly_target_amount ? Money.new(goal.monthly_target_amount, goal.currency).format(precision: 0) : nil + if target + I18n.t("goals.goal_card.pace_with_target", avg: avg, target: target) + else + I18n.t("goals.goal_card.pace_no_target", avg: avg) + end + end + + def footer_line + if goal.archived? + I18n.t("goals.goal_card.footer_archived") + elsif goal.paused? + I18n.t("goals.goal_card.footer_paused") + elsif goal.completed? || goal.status == :reached + I18n.t("goals.goal_card.footer_reached") + elsif goal.status == :behind && goal.monthly_target_amount + I18n.t("goals.goal_card.footer_catch_up", amount: goal.catch_up_delta_money.format(precision: 0)) + elsif goal.status == :no_target_date + I18n.t("goals.goal_card.footer_no_deadline") + else + days = goal.last_matched_pledge_days_ago + if days.nil? + I18n.t("goals.goal_card.footer_no_pledges") + elsif days.zero? + I18n.t("goals.goal_card.footer_last_today") + else + I18n.t("goals.goal_card.footer_last_days", count: days) + end + end + end + + def footer_has_money? + goal.status == :behind && goal.monthly_target_amount + end +end diff --git a/app/components/goals/funding_accounts_breakdown_component.html.erb b/app/components/goals/funding_accounts_breakdown_component.html.erb new file mode 100644 index 000000000..587038a8d --- /dev/null +++ b/app/components/goals/funding_accounts_breakdown_component.html.erb @@ -0,0 +1,64 @@ +<% if rows.empty? %> +
+

<%= t("goals.show.funding_accounts.empty.heading") %>

+

<%= t("goals.show.funding_accounts.empty.body") %>

+
+<% else %> +
+

<%= t("goals.show.funding_accounts_heading") %>

+ + <%# Distribution bar. proportional weight of each account in this goal. + Color encoding is shared with the row avatar so the bar reads as a + legend by itself; no separate dot/name/% strip needed. %> + <% if rows.size > 1 && total.positive? %> +
+ <% rows.each do |row| %> + <% next if row[:balance].to_d.zero? %> +
+ <% end %> +
+ <% end %> + + <%# Per-account detail. avatar / name+type / % / last-30d + last-90d %> +
+
+ <% rows.each_with_index do |row, idx| %> + <% account = row[:account] %> + <% color = color_for(account) %> +
+ <%= render Goals::AvatarComponent.new(name: account.name, color: color, size: "sm") %> + +
+

<%= account.name %>

+

+ <%= accountable_label(account) %> · <%= row[:balance_money].format(precision: 0) %> +

+
+ + <% if rows.size > 1 %> + + <% else %> + + <% end %> + +
+
+

<%= row[:last_30_money].format(precision: 0) %>

+

<%= t("goals.show.funding_last_30d") %>

+
+
+

<%= row[:last_90_money].format(precision: 0) %>

+

<%= t("goals.show.funding_last_90d") %>

+
+
+
+ <% if idx < rows.size - 1 %> + <%= render "shared/ruler", classes: "mx-4" %> + <% end %> + <% end %> +
+
+
+<% end %> diff --git a/app/components/goals/funding_accounts_breakdown_component.rb b/app/components/goals/funding_accounts_breakdown_component.rb new file mode 100644 index 000000000..345d40988 --- /dev/null +++ b/app/components/goals/funding_accounts_breakdown_component.rb @@ -0,0 +1,89 @@ +class Goals::FundingAccountsBreakdownComponent < ApplicationComponent + COLUMN_WINDOW_DAYS = 30 + TREND_WINDOW_DAYS = 90 + + def initialize(goal:) + @goal = goal + end + + attr_reader :goal + + def rows + @rows ||= goal.linked_accounts.sort_by { |a| -a.balance.to_d }.map do |account| + totals = inflow_totals_for(account) + { + account: account, + balance: account.balance.to_d, + balance_money: Money.new(account.balance.to_d, goal.currency), + last_30_money: Money.new(totals[:last_30], goal.currency), + last_90_money: Money.new(totals[:last_90], goal.currency) + } + end + end + + def total + @total ||= rows.sum { |r| r[:balance].to_d } + end + + def percent_for(balance) + return 0 if total.zero? + ((balance.to_d / total) * 100).round + end + + # Pull from the goal's per-goal account color map so the colors here + # (distribution bar, row avatars) match the AccountStackComponent on the + # index card. Stable + collision-free within the goal up to PALETTE size. + def color_for(account) + goal.account_color_map[account.id] || Goals::AvatarComponent.color_for(account.name) + end + + # Label shown beneath the account name. Prefers the depository subtype + # ("Savings", "HSA"…) over the bare accountable_type ("Depository") so the + # subline carries useful signal. Falls back to the accountable type's i18n + # entry (`accounts.types.*`), and finally to a `titleize` so the row is + # never blank if a string is missing. + def accountable_label(account) + if account.subtype.present? + I18n.t("goals.form.subtypes.#{account.subtype}", default: account.subtype.titleize) + else + type = account.accountable_type.to_s + I18n.t("accounts.types.#{type.underscore}", default: type.titleize) + end + end + + private + # Per-account net inflow for both windows in one pass over the 90-day + # entries set. Entry amount sign in Sure: inflow is negative; flip and + # clamp ≥ 0. + def inflow_totals_for(account) + inflow_totals_map[account.id] || { last_30: 0.to_d, last_90: 0.to_d } + end + + def inflow_totals_map + @inflow_totals_map ||= begin + account_ids = goal.linked_accounts.map(&:id) + return {} if account_ids.empty? + + cutoff_30 = COLUMN_WINDOW_DAYS.days.ago.to_date + + rows = Entry + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where(account_id: account_ids, date: TREND_WINDOW_DAYS.days.ago.to_date..Date.current) + .where(excluded: false) + .merge(Transaction.excluding_pending) + .pluck(:account_id, :date, :amount) + + result = Hash.new { |h, k| h[k] = { last_30: 0.to_d, last_90: 0.to_d } } + rows.each do |aid, date, amount| + inflow = (-amount.to_d).clamp(0..) + result[aid][:last_90] += inflow + result[aid][:last_30] += inflow if date >= cutoff_30 + end + result + end + rescue StandardError => e + Rails.logger.error("Inflow totals map for goal #{goal.id} failed: #{e.class}: #{e.message}") + Sentry.capture_exception(e) if defined?(Sentry) + {} + end +end diff --git a/app/components/goals/progress_ring_component.html.erb b/app/components/goals/progress_ring_component.html.erb new file mode 100644 index 000000000..273555103 --- /dev/null +++ b/app/components/goals/progress_ring_component.html.erb @@ -0,0 +1,18 @@ +
" + class="relative mx-auto" + style="width: <%= size %>px; height: <%= size %>px;"> +
+
+
+ <%= t("goals.show.ring.saved") %> + <%= percent %>% +
+
+
diff --git a/app/components/goals/progress_ring_component.rb b/app/components/goals/progress_ring_component.rb new file mode 100644 index 000000000..67f92525a --- /dev/null +++ b/app/components/goals/progress_ring_component.rb @@ -0,0 +1,31 @@ +class Goals::ProgressRingComponent < ApplicationComponent + def initialize(goal:, size: 180) + @goal = goal + @size = size + end + + attr_reader :goal, :size + + def percent + goal.progress_percent + end + + def amount_label + goal.current_balance_money.format + end + + def target_label + goal.target_amount_money.format + end + + def remaining_label + goal.remaining_amount_money.format + end + + def percent_text_class + case goal.status + when :reached then "text-success" + else "text-primary" + end + end +end diff --git a/app/components/goals/status_pill_component.html.erb b/app/components/goals/status_pill_component.html.erb new file mode 100644 index 000000000..e2f64df78 --- /dev/null +++ b/app/components/goals/status_pill_component.html.erb @@ -0,0 +1 @@ +<%= render DS::Pill.new(label: label, tone: variant[:tone], style: :outline, icon: variant[:icon]) %> diff --git a/app/components/goals/status_pill_component.rb b/app/components/goals/status_pill_component.rb new file mode 100644 index 000000000..134500b62 --- /dev/null +++ b/app/components/goals/status_pill_component.rb @@ -0,0 +1,32 @@ +class Goals::StatusPillComponent < ApplicationComponent + # Maps the goal's display_status to the DS::Pill primitive's tone + + # glyph. Outline style is used so the pill keeps its colored border on + # any card background (resting bg-container, hover bg-surface-hover); + # the filled / soft variants blended into the hover state and lost + # contrast on cards. + VARIANTS = { + on_track: { tone: :green, icon: "circle-check" }, + behind: { tone: :amber, icon: "triangle-alert" }, + reached: { tone: :green, icon: "star" }, + completed: { tone: :green, icon: "circle-check-big" }, + no_target_date: { tone: :gray, icon: "infinity" }, + paused: { tone: :gray, icon: "pause" }, + archived: { tone: :gray, icon: "archive" } + }.freeze + + def initialize(goal:) + @goal = goal + end + + def status_key + @goal.display_status + end + + def variant + VARIANTS.fetch(status_key, VARIANTS[:no_target_date]) + end + + def label + I18n.t("goals.status.#{status_key}", default: status_key.to_s.titleize) + end +end diff --git a/app/controllers/goal_pledges_controller.rb b/app/controllers/goal_pledges_controller.rb new file mode 100644 index 000000000..45c8fdec0 --- /dev/null +++ b/app/controllers/goal_pledges_controller.rb @@ -0,0 +1,89 @@ +class GoalPledgesController < ApplicationController + before_action :require_preview_features! + before_action :set_goal + before_action :set_pledge, only: %i[renew destroy] + rescue_from ActiveRecord::RecordNotFound, with: :record_not_found + + def new + # The form is dialog-only. A direct GET (F5, bookmark, deep-link + # gone stale) lands the user back on the goal show page with the + # modal auto-opened via the catch-up CTA params, rather than + # rendering a freestanding DS::Dialog over an empty page. + unless turbo_frame_request? + redirect_to goal_path(@goal) and return + end + + account = preselected_account + @pledge = @goal.goal_pledges.new( + currency: @goal.currency, + account: account, + kind: account&.default_pledge_kind || "transfer", + amount: params[:amount].presence + ) + end + + def create + @pledge = @goal.goal_pledges.new(pledge_params) + @pledge.account = lookup_account(params.dig(:goal_pledge, :account_id)) + @pledge.kind = @pledge.account&.default_pledge_kind || "transfer" + @pledge.currency = @goal.currency + + if @pledge.save + flash[:notice] = t(".success") + respond_to do |format| + format.html { redirect_to goal_path(@goal) } + format.turbo_stream do + render turbo_stream: turbo_stream.action(:redirect, goal_path(@goal)) + end + end + else + render :new, status: :unprocessable_entity + end + end + + def renew + @pledge.extend! + redirect_to goal_path(@goal), notice: t(".success") + rescue GoalPledge::NotOpenError + redirect_to goal_path(@goal), alert: t(".not_open") + end + + def destroy + @pledge.cancel! + redirect_to goal_path(@goal), notice: t(".success") + rescue GoalPledge::NotOpenError + redirect_to goal_path(@goal), alert: t(".not_open") + end + + private + def set_goal + # Preload linked accounts + their providers so any_connected_account? + # and the new-pledge form's per-account helpers don't trigger N+1 + # queries on account_providers. + @goal = Current.family.goals + .includes(:open_pledges, linked_accounts: :account_providers) + .find(params[:goal_id]) + end + + def set_pledge + @pledge = @goal.goal_pledges.find(params[:id]) + end + + def pledge_params + params.require(:goal_pledge).permit(:amount) + end + + def lookup_account(id) + return nil if id.blank? + @goal.linked_accounts.find_by(id: id) + end + + def preselected_account + requested = params[:account_id].presence && @goal.linked_accounts.find_by(id: params[:account_id]) + requested || @goal.linked_accounts.first + end + + def record_not_found + redirect_to goals_path, alert: t("goals.errors.not_found") + end +end diff --git a/app/controllers/goals_controller.rb b/app/controllers/goals_controller.rb new file mode 100644 index 000000000..c54a44371 --- /dev/null +++ b/app/controllers/goals_controller.rb @@ -0,0 +1,270 @@ +class GoalsController < ApplicationController + before_action :require_preview_features! + before_action :set_goal, only: %i[show edit update destroy pause resume complete archive unarchive] + rescue_from ActiveRecord::RecordNotFound, with: :goal_not_found + + STATE_FILTERS = %w[all active paused completed archived].freeze + ACTIVE_STATUS_RANK = { behind: 0, on_track: 1, no_target_date: 2 }.freeze + + def index + state_counts = Current.family.goals.group(:state).count + @counts = STATE_FILTERS.each_with_object({}) do |state, h| + h[state] = state == "all" ? state_counts.values.sum : (state_counts[state] || 0) + end + + all_goals = Current.family.goals + .alphabetically + .includes(:open_pledges, linked_accounts: :account_providers) + .to_a + @active_goals = all_goals.reject { |g| %w[completed archived].include?(g.state) } + .sort_by { |g| [ g.paused? ? 3 : ACTIVE_STATUS_RANK.fetch(g.status, 4), g.name.downcase ] } + @completed_goals = all_goals.select { |g| g.state == "completed" }.sort_by { |g| g.name.downcase } + @archived_goals = all_goals.select { |g| g.state == "archived" } + # Completed goals join the chip-filterable grid below the active ones + # so the `completed` chip can isolate them. Archived stays in a + # separate collapsed-by-default section, opted out of the filter + # entirely (rendered with filterable: false). + @grid_goals = @active_goals + @completed_goals + + @linkable_account_count = Current.family.accounts.where(accountable_type: "Depository").visible.count + @kpi = kpi_payload(@active_goals) + @any_pending_pledge = @active_goals.any? { |g| g.open_pledges.any? } + @show_search = @grid_goals.size > 6 + @breadcrumbs = [ + [ t("breadcrumbs.home"), root_path ], + [ t("goals.index.title"), nil ] + ] + end + + def show + @open_pledges = @goal.open_pledges.reverse_chronological.to_a + @breadcrumbs = [ + [ t("breadcrumbs.home"), root_path ], + [ t("goals.index.title"), goals_path ], + [ @goal.name, nil ] + ] + end + + def new + @goal = Current.family.goals.new( + color: Goal::COLORS.sample, + currency: Current.family.primary_currency_code + ) + @linkable_accounts = linkable_accounts_for_new + @breadcrumbs = [ + [ t("breadcrumbs.home"), root_path ], + [ t("goals.index.title"), goals_path ], + [ t("goals.new.heading"), nil ] + ] + end + + def create + @goal = Current.family.goals.new(goal_params) + accounts = lookup_accounts(params.dig(:goal, :account_ids)) + @goal.currency = accounts.first.currency if accounts.any? && @goal.currency.blank? + + Goal.transaction do + accounts.each { |a| @goal.goal_accounts.build(account: a) } + @goal.save! + end + + flash[:notice] = t(".success") + respond_to do |format| + format.html { redirect_to goal_path(@goal) } + format.turbo_stream do + render turbo_stream: turbo_stream.action(:redirect, goal_path(@goal)) + end + end + rescue ActiveRecord::RecordInvalid + @linkable_accounts = linkable_accounts_for_new + render :new, status: :unprocessable_entity + end + + def edit + @linkable_accounts = linkable_accounts_for_new + @currently_linked_account_ids = @goal.goal_accounts.pluck(:account_id).map(&:to_s) + end + + def update + account_ids = params.dig(:goal, :account_ids) + accounts_supplied = !account_ids.nil? + accounts = accounts_supplied ? lookup_accounts(account_ids) : [] + + if accounts_supplied && accounts.empty? + @goal.errors.add(:base, :at_least_one_linked_account_required) + @linkable_accounts = linkable_accounts_for_new + @currently_linked_account_ids = @goal.goal_accounts.pluck(:account_id).map(&:to_s) + render :edit, status: :unprocessable_entity + return + end + + Goal.transaction do + @goal.update!(goal_update_params) + sync_linked_accounts!(@goal, accounts) if accounts_supplied + end + + flash[:notice] = t(".success") + respond_to do |format| + format.html { redirect_to goal_path(@goal) } + format.turbo_stream do + render turbo_stream: turbo_stream.action(:redirect, goal_path(@goal)) + end + end + rescue ActiveRecord::RecordInvalid + @linkable_accounts = linkable_accounts_for_new + @currently_linked_account_ids = @goal.goal_accounts.pluck(:account_id).map(&:to_s) + render :edit, status: :unprocessable_entity + end + + def destroy + unless @goal.archived? + redirect_to goal_path(@goal), alert: t(".archive_first") + return + end + + @goal.destroy! + redirect_to goals_path, notice: t(".success") + end + + def pause + perform_transition!(:pause) + end + + def resume + perform_transition!(:resume) + end + + def complete + perform_transition!(:complete) + end + + def archive + perform_transition!(:archive) + end + + def unarchive + perform_transition!(:unarchive) + end + + private + def set_goal + @goal = Current.family.goals + .includes(:open_pledges, linked_accounts: :account_providers) + .find(params[:id]) + end + + def goal_not_found + redirect_to goals_path, alert: t("goals.errors.not_found") + end + + def goal_params + params.require(:goal).permit(:name, :target_amount, :target_date, :color, :icon, :notes) + end + + def goal_update_params + params.require(:goal).permit(:name, :target_amount, :target_date, :color, :icon, :notes) + end + + def lookup_accounts(ids) + return [] if ids.blank? + + ids = Array(ids).reject(&:blank?) + Current.family.accounts.where(accountable_type: "Depository").visible.where(id: ids).to_a + end + + def linkable_accounts_for_new + Current.family.accounts.where(accountable_type: "Depository").visible.alphabetically.to_a + end + + def sync_linked_accounts!(goal, accounts) + desired_ids = accounts.map(&:id).to_set + current_ids = goal.goal_accounts.pluck(:account_id).to_set + + (current_ids - desired_ids).each do |id| + goal.goal_accounts.where(account_id: id).destroy_all + end + additions = accounts.reject { |a| current_ids.include?(a.id) } + additions.each { |a| goal.goal_accounts.build(account: a) } + # Save through the goal so currency / depository / family + # validations fire. `create!` on goal_accounts directly bypasses them + # and let cross-currency / non-depository attachments through. + goal.save! + end + + def kpi_payload(active_goals) + family = Current.family + currency = family.primary_currency_code + today = Date.current + + windows = family.savings_inflow_windows(window_days: 30, now: today) + velocity_30d = windows[:current] + velocity_prior_30d = windows[:prior] + delta_amount = velocity_30d - velocity_prior_30d + delta_percent = velocity_prior_30d.zero? ? nil : ((delta_amount / velocity_prior_30d.abs) * 100).round(1) + + # Sign decoupling: the headline-amount sign reflects this month's + # direction ("−$200 last 30d" = net outflow); the delta direction + # (↑/↓ vs prior 30d) goes on the subline. Conflating them produced the + # "−$1234" + "↓ 27%" tile where the minus looked like a loss but the + # $1234 was actually the (positive) amount contributed. + headline_sign = velocity_30d.negative? ? "−" : "" + delta_direction = if delta_amount.positive? then :up + elsif delta_amount.negative? then :down + else :flat + end + + needs = active_goals + .select { |g| g.status == :behind } + .sum { |g| g.monthly_target_amount.to_d } + behind = active_goals.count { |g| g.status == :behind } + on_track = active_goals.count { |g| g.status == :on_track } + reached = active_goals.count { |g| g.status == :reached } + no_date = active_goals.count { |g| g.status == :no_target_date } + paused = active_goals.count(&:paused?) + + # Denominator of the "Goals on track" tile. A goal only belongs in + # the fraction if there is a benchmark to compare against: + # - reached → target already hit, no longer tracked toward pace + # - paused → user stopped the pace clock on purpose + # - no_target_date → open-ended saving (emergency fund, sabbatical + # fund, etc.) has no required monthly pace, so "on track" is + # undefined. Counting it would penalise the user for having + # open-ended goals — they'd never improve the ratio. + # When this hits zero the tile swaps to a celebration / empty + # state in the view. + tracked_total = active_goals.count do |g| + !g.paused? && g.status != :reached && g.status != :no_target_date + end + + { + currency: currency, + velocity_30d_money: Money.new(velocity_30d.abs, currency), + velocity_prior_30d_money: Money.new(velocity_prior_30d.abs, currency), + velocity_30d_sign: headline_sign, + velocity_delta_percent: delta_percent, + velocity_direction: delta_direction, + needs_this_month_money: Money.new(needs, currency), + on_track_count: on_track, + reached_count: reached, + behind_count: behind, + no_date_count: no_date, + paused_count: paused, + tracked_total: tracked_total, + active_total: active_goals.size + } + end + + def perform_transition!(event) + if @goal.aasm.may_fire_event?(event) + @goal.public_send("#{event}!") + respond_to do |format| + format.html { redirect_to goal_path(@goal), notice: t(".success") } + format.turbo_stream do + render turbo_stream: turbo_stream.action(:redirect, goal_path(@goal)) + end + end + else + redirect_to goal_path(@goal), alert: t(".invalid_transition") + end + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 9f2c0a647..369cb9df9 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -77,6 +77,16 @@ module ApplicationHelper current_page?(path) || (request.path.start_with?(path) && path != "/") end + # Wraps a nav-item hash so a single call performs both halves of a + # preview-gated entry: returns `nil` for users without the flag (so the + # entry never reaches the rendered nav), and stamps `preview: true` on + # the hash for users with the flag (so the partial paints the violet + # dot on the icon). Use inside an `Array#compact` nav-items list. + def preview_gated_nav_item(item) + return nil unless preview_features_enabled? + item.merge(preview: true) + end + # Wrapper around I18n.l to support custom date formats def format_date(object, format = :default, options = {}) date = object.to_date diff --git a/app/javascript/controllers/category_controller.js b/app/javascript/controllers/color_icon_picker_controller.js similarity index 81% rename from app/javascript/controllers/category_controller.js rename to app/javascript/controllers/color_icon_picker_controller.js index ec0ab6457..a208ba969 100644 --- a/app/javascript/controllers/category_controller.js +++ b/app/javascript/controllers/color_icon_picker_controller.js @@ -22,31 +22,42 @@ export default class extends Controller { presetColors: Array, }; - initialize() { - this.pickerBtnTarget.addEventListener("click", () => { - this.showPaletteSection(); - }); - - this.colorInputTarget.addEventListener("input", (e) => { - this.picker.setColor(e.target.value); - }); - - this.detailsTarget.addEventListener("toggle", (e) => { + connect() { + // Bound references stored on the instance so disconnect() can remove + // them. Without this, every Turbo navigation that re-renders the + // picker stacks another listener on the same node. + this._onPickerBtnClick = () => this.showPaletteSection(); + this._onColorInputInput = (e) => this.picker?.setColor(e.target.value); + this._onDetailsToggle = (e) => { if (!this.colorInputTarget.checkValidity()) { e.preventDefault(); this.colorInputTarget.reportValidity(); e.target.open = true; } - this.updatePopupPosition() - }); + this.updatePopupPosition(); + }; + + this.pickerBtnTarget.addEventListener("click", this._onPickerBtnClick); + this.colorInputTarget.addEventListener("input", this._onColorInputInput); + this.detailsTarget.addEventListener("toggle", this._onDetailsToggle); + document.addEventListener("mousedown", this.handleOutsideClick); this.selectedIcon = null; if (!this.presetColorsValue.includes(this.colorInputTarget.value)) { this.colorPickerRadioBtnTarget.checked = true; } + } - document.addEventListener("mousedown", this.handleOutsideClick); + disconnect() { + this.pickerBtnTarget.removeEventListener("click", this._onPickerBtnClick); + this.colorInputTarget.removeEventListener("input", this._onColorInputInput); + this.detailsTarget.removeEventListener("toggle", this._onDetailsToggle); + document.removeEventListener("mousedown", this.handleOutsideClick); + if (this.picker) { + this.picker.destroyAndRemove(); + this.picker = null; + } } initPicker() { @@ -85,8 +96,12 @@ export default class extends Controller { } updateAvatarColors(color) { - this.avatarTarget.style.backgroundColor = `${this.#backgroundColor(color)}`; - this.avatarTarget.style.color = color; + // Update the `--avatar-color` CSS variable instead of overriding + // `style.color` / `style.backgroundColor` directly. The `.goal-avatar` + // class does theme-aware `color-mix` work off the variable (light mode + // darkens the letter, dark mode uses the full color) — overriding the + // resolved values inline killed that contrast logic. + this.avatarTarget.style.setProperty("--avatar-color", color); } handleIconColorChange(e) { diff --git a/app/javascript/controllers/goal_form_controller.js b/app/javascript/controllers/goal_form_controller.js new file mode 100644 index 000000000..251d50aae --- /dev/null +++ b/app/javascript/controllers/goal_form_controller.js @@ -0,0 +1,141 @@ +import { Controller } from "@hotwired/stimulus"; + +// Single-form controller for the goal create / edit modal. +// +// Replaces the 2-step stepper: the form is short enough that all fields +// fit on one panel, so the previous review step (which only showed a +// derived "Save $X/mo to hit it on time" hint) collapses into an inline +// live hint below the target date. Validation + avatar preview from the +// name field still live here. +export default class extends Controller { + static targets = [ + "nameInput", + "amountInput", + "dateInput", + "avatarPreview", + "nameError", + "amountError", + "accountsError", + "linkedAccountCheckbox", + "suggested", + ]; + + static INVALID_INPUT_CLASSES = ["ring-2", "ring-destructive", "border-destructive"]; + + static values = { + currency: { type: String, default: "USD" }, + suggestedWithDate: { type: String, default: "Save {monthly}/mo across {accounts} to hit it on time." }, + suggestedNoDate: { type: String, default: "Set a target date to project a finish line." }, + }; + + connect() { + // Capture the default avatar contents (the "target" icon SVG) so we + // can restore it when the user clears the name field after typing. + if (this.hasAvatarPreviewTarget) { + this._defaultAvatarHTML = this.avatarPreviewTarget.innerHTML; + } + this.updateSuggested(); + } + + nameChanged() { + if (this.hasNameInputTarget) { + this.clearFieldError(this.nameInputTarget, this.hasNameErrorTarget ? this.nameErrorTarget : null); + } + if (!this.hasAvatarPreviewTarget || !this.hasNameInputTarget) return; + + // If the user has explicitly picked an icon, leave it alone. Name + // changes shouldn't undo an explicit choice. + const iconPicked = this.element.querySelector('input[name="goal[icon]"]:checked'); + if (iconPicked) return; + + const name = this.nameInputTarget.value.trim(); + if (name) { + this.avatarPreviewTarget.textContent = name.charAt(0).toUpperCase(); + } else if (this._defaultAvatarHTML) { + // Captured at connect. Restore the default "target" icon from the + // server-rendered template, not a "?" character. + this.avatarPreviewTarget.innerHTML = this._defaultAvatarHTML; + } + } + + amountChanged() { + if (this.hasAmountInputTarget) { + this.clearFieldError(this.amountInputTarget, this.hasAmountErrorTarget ? this.amountErrorTarget : null); + } + } + + linkedAccountChanged() { + this.updateSuggested(); + if (this.linkedAccountCheckboxTargets.some((cb) => cb.checked) && this.hasAccountsErrorTarget) { + this.accountsErrorTarget.classList.add("hidden"); + } + } + + // Hook for any input that influences the suggested-pace hint + // (target_amount, target_date). Also re-evaluates as accounts toggle. + suggestedChanged() { + this.amountChanged(); + this.updateSuggested(); + } + + updateSuggested() { + if (!this.hasSuggestedTarget) return; + + const amount = this.hasAmountInputTarget ? Number.parseFloat(this.amountInputTarget.value) : Number.NaN; + const dateValue = this.hasDateInputTarget ? this.dateInputTarget.value : null; + const checkedCount = this.linkedAccountCheckboxTargets.filter((cb) => cb.checked).length; + + const amountValid = Number.isFinite(amount) && amount > 0; + if (!amountValid || checkedCount === 0) { + this.suggestedTarget.classList.add("hidden"); + this.suggestedTarget.textContent = ""; + return; + } + + let text; + if (dateValue) { + const months = this.#monthsBetween(new Date(), new Date(dateValue)); + if (months <= 0) { + this.suggestedTarget.classList.add("hidden"); + this.suggestedTarget.textContent = ""; + return; + } + const perMonth = Math.ceil(amount / months); + const accountLabel = `${checkedCount} ${checkedCount === 1 ? "account" : "accounts"}`; + text = this.suggestedWithDateValue + .replace("{monthly}", this.#money(perMonth)) + .replace("{accounts}", accountLabel); + } else { + text = this.suggestedNoDateValue; + } + + this.suggestedTarget.textContent = text; + this.suggestedTarget.classList.remove("hidden"); + } + + showFieldError(input, errorEl) { + if (input) input.classList.add(...this.constructor.INVALID_INPUT_CLASSES); + if (errorEl) errorEl.classList.remove("hidden"); + } + + clearFieldError(input, errorEl) { + if (input) input.classList.remove(...this.constructor.INVALID_INPUT_CLASSES); + if (errorEl) errorEl.classList.add("hidden"); + } + + #money(value) { + try { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: this.currencyValue || "USD", + maximumFractionDigits: 0, + }).format(value); + } catch { + return `${this.currencyValue || "$"}${Math.round(value).toLocaleString()}`; + } + } + + #monthsBetween(from, to) { + return (to - from) / (1000 * 60 * 60 * 24 * 30.44); + } +} diff --git a/app/javascript/controllers/goal_pledge_preview_controller.js b/app/javascript/controllers/goal_pledge_preview_controller.js new file mode 100644 index 000000000..625ee4af7 --- /dev/null +++ b/app/javascript/controllers/goal_pledge_preview_controller.js @@ -0,0 +1,90 @@ +import { Controller } from "@hotwired/stimulus"; + +// Live impact preview for the record-pledge modal. Reads current balance + +// target amount from values and updates a preview sentence each keystroke. +// Template strings come from ERB so the wording stays localized. +export default class extends Controller { + static targets = [ + "amountInput", + "preview", + "accountSelect", + "helperConnected", + "helperManual", + ]; + static values = { + currentBalance: Number, + targetAmount: Number, + currency: String, + templateZero: String, + templateNonzero: String, + templateReached: String, + }; + + connect() { + this.update(); + this.accountChanged(); + } + + // Helper text reacts to the currently-selected account, not the goal as a + // whole. A mixed-funding goal (one connected account + one manual) used to + // paint the "connected" helper even if the user then picked the manual + // account from the dropdown; the saved pledge would be `kind: manual_save` + // (correct, per `kind_for_account` in the controller) but the helper read + // "transfer-style" copy until submission. + accountChanged() { + if (!this.hasAccountSelectTarget) return; + if (!this.hasHelperConnectedTarget || !this.hasHelperManualTarget) return; + const opt = this.accountSelectTarget.selectedOptions[0]; + const isManual = opt?.dataset.manual === "true"; + this.helperConnectedTarget.hidden = isManual; + this.helperManualTarget.hidden = !isManual; + } + + update() { + if (!this.hasPreviewTarget) return; + + const amount = this.#amountValue(); + const newTotal = this.currentBalanceValue + amount; + const target = this.targetAmountValue; + const reached = newTotal >= target && target > 0; + const percent = target > 0 ? Math.min(100, Math.round((newTotal / target) * 100)) : 0; + + let text; + if (reached) { + text = this.templateReachedValue.replace("{target}", this.#money(target)); + } else if (amount === 0) { + text = this.templateZeroValue + .replaceAll("{percent}", percent.toString()) + .replaceAll("{current}", this.#money(this.currentBalanceValue)) + .replaceAll("{target}", this.#money(target)); + } else { + text = this.templateNonzeroValue + .replaceAll("{percent}", percent.toString()) + .replaceAll("{newTotal}", this.#money(newTotal)) + .replaceAll("{target}", this.#money(target)); + } + + this.previewTarget.textContent = text; + } + + #amountValue() { + if (!this.hasAmountInputTarget) return 0; + const parsed = Number.parseFloat(this.amountInputTarget.value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; + } + + #money(value) { + try { + // Let Intl pick the currency-specific default fraction digits so + // USD/EUR previews show cents while JPY/KRW stay whole-unit. The + // server saves the user-entered amount verbatim; the preview must + // not silently round it. + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: this.currencyValue || "USD", + }).format(value); + } catch { + return `${this.currencyValue || "$"}${value.toLocaleString()}`; + } + } +} diff --git a/app/javascript/controllers/goal_projection_chart_controller.js b/app/javascript/controllers/goal_projection_chart_controller.js new file mode 100644 index 000000000..cefddedeb --- /dev/null +++ b/app/javascript/controllers/goal_projection_chart_controller.js @@ -0,0 +1,572 @@ +import { Controller } from "@hotwired/stimulus"; +import * as d3 from "d3"; + +// Projection chart for a goal. Renders: +// - Saved area + line from goal creation → today (solid) +// - Dashed projection line from today → target date (yellow if behind, +// green if on track) +// - Horizontal dashed target line with label +// - Today marker (vertical line + dot) +// +// Data shape passed via `data-goal-projection-chart-data-value` +// matches Goal#projection_payload. +export default class extends Controller { + static values = { + data: Object, + ariaLabel: String, + ariaDescription: String, + todayLabel: { type: String, default: "Today" }, + projectedTemplate: { type: String, default: "Projected: {amount}" }, + savedTemplate: { type: String, default: "Saved: {amount}" }, + }; + + connect() { + this._resize = this._draw.bind(this); + window.addEventListener("resize", this._resize); + // Container may have 0 width on initial connect (Turbo restoration, + // hidden parent, etc). Re-draw whenever the box settles into a real + // size. The first observer callback also performs the initial paint. + if (typeof ResizeObserver !== "undefined") { + this._observer = new ResizeObserver(() => this._draw()); + this._observer.observe(this.element); + } else { + this._draw(); + } + // Repaint when the user toggles theme so SVG attributes (which bake + // light/dark hex values at draw time) follow data-theme. Lives here + // until theme_controller broadcasts a theme:change event upstream. + if (typeof MutationObserver !== "undefined") { + this._themeObserver = new MutationObserver((mutations) => { + if (mutations.some((m) => m.attributeName === "data-theme")) this._draw(); + }); + this._themeObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + } + // After a Turbo render (eg. after saving the goal from the edit modal + // and redirecting back to show), the chart container can be left empty + // its children may be wiped by the morph even though connect() was + // already called, and ResizeObserver doesn't fire because the size + // didn't change. Listen for the render event so we redraw when needed. + this._onTurboRender = () => { + if (!this.element.querySelector("svg")) this._draw(); + }; + document.addEventListener("turbo:render", this._onTurboRender); + document.addEventListener("turbo:frame-load", this._onTurboRender); + } + + disconnect() { + window.removeEventListener("resize", this._resize); + this._observer?.disconnect(); + this._themeObserver?.disconnect(); + if (this._onTurboRender) { + document.removeEventListener("turbo:render", this._onTurboRender); + document.removeEventListener("turbo:frame-load", this._onTurboRender); + } + } + + _draw() { + const root = this.element; + root.innerHTML = ""; + + const data = this.dataValue || {}; + const width = root.clientWidth || 720; + const height = root.clientHeight || 240; + if (width <= 0 || height <= 0) return; + + const isDark = document.documentElement.getAttribute("data-theme") === "dark"; + const textPrimary = isDark ? "#ffffff" : "#171717"; + const textSecondary = isDark ? "#cfcfcf" : "#737373"; + const borderSubdued = isDark ? "rgba(255,255,255,0.15)" : "rgba(0,0,0,0.10)"; + const containerBg = isDark ? "#0a0a0a" : "#ffffff"; + + // Reserve gutter for y-axis labels when there's room. Mobile (< 320) + // keeps the tighter left margin and skips the y-axis entirely. + const yAxisVisible = width - 16 - 24 >= 320; + const margin = { top: 28, right: 24, bottom: 28, left: yAxisVisible ? 44 : 16 }; + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + + // Date-only payload strings ("YYYY-MM-DD") parse as UTC midnight in + // `new Date(str)`, which shifts displayed days back one for users west + // of Greenwich. Parse components so today/target/saved_series sit on + // local-midnight. + const parseLocalDate = (s) => { + if (!s) return null; + const [ y, m, d ] = s.split("-").map(Number); + return new Date(y, m - 1, d); + }; + const start = parseLocalDate(data.start_date); + const today = parseLocalDate(data.today); + const target = parseLocalDate(data.target_date); + const targetAmount = data.target_amount || 0; + const currentAmount = data.current_amount || 0; + const avgMonthly = data.avg_monthly || 0; + + // Past-due goals: pin endDate at today so the "today" marker stays inside + // the x-domain instead of clipping right at the edge. + const endDate = target + ? new Date(Math.max(target.getTime(), today.getTime())) + : new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000); + + // Drop any same-day-or-later points from the balance series: we own the + // endpoint with `currentAmount` (live `linked_accounts.sum(:balance)`) + // so the saved line meets the projection's starting point with no gap. + // Without this, the snapshot in `balances` for today could differ from + // the live read (sync timing) and the chart showed a vertical jump. + const rawSavedSeries = (data.saved_series || []) + .map((p) => ({ date: parseLocalDate(p.date), value: p.value })) + .filter((p) => p.date < today); + const firstContribDate = rawSavedSeries[0]?.date; + const savedSeries = []; + // Only seed a (start, 0) point when start_date predates the first + // contribution. Otherwise the line draws a vertical jump up at the + // chart's left edge. + if (!firstContribDate || firstContribDate.getTime() > start.getTime()) { + savedSeries.push({ date: start, value: 0 }); + } + savedSeries.push(...rawSavedSeries); + // Always close the saved line at (today, currentAmount) — the projection + // line starts here too, guaranteeing visual continuity at the today + // marker. + savedSeries.push({ date: today, value: currentAmount }); + + const projectionEnd = target + ? Math.max(currentAmount, currentAmount + avgMonthly * Math.max(0, this._monthsBetween(today, target))) + : currentAmount; + const projectionSeries = target + ? [ + { date: today, value: currentAmount }, + { date: target, value: projectionEnd }, + ] + : []; + + const requiredMonthly = data.required_monthly || 0; + const requiredEnd = target && requiredMonthly > 0 + ? currentAmount + requiredMonthly * Math.max(0, this._monthsBetween(today, target)) + : currentAmount; + const requiredSeries = target && requiredMonthly > 0 && requiredEnd > currentAmount + ? [ + { date: today, value: currentAmount }, + { date: target, value: requiredEnd }, + ] + : []; + + const yMax = Math.max(targetAmount * 1.05, projectionEnd, requiredEnd, currentAmount, 1); + + const x = d3.scaleTime().domain([start, endDate]).range([margin.left, margin.left + innerWidth]); + const y = d3.scaleLinear().domain([0, yMax]).range([margin.top + innerHeight, margin.top]); + + const svg = d3 + .select(root) + .append("svg") + .attr("width", width) + .attr("height", height) + .attr("viewBox", `0 0 ${width} ${height}`); + + // Drop the child; browsers render it as a native hover tooltip + // that fights with our own crosshair tooltip. aria-label gives the same + // SR accessible name without the tooltip side-effect. + const descId = `chart-desc-${this._id()}`; + svg.attr("role", "img").attr("aria-label", this.ariaLabelValue || "Goal projection"); + svg.append("desc").attr("id", descId).text(this.ariaDescriptionValue || ""); + svg.attr("aria-describedby", descId); + + const defs = svg.append("defs"); + const gradient = defs + .append("linearGradient") + .attr("id", `saved-fill-${this._id()}`) + .attr("x1", 0).attr("y1", 0).attr("x2", 0).attr("y2", 1); + gradient.append("stop").attr("offset", "0%").attr("stop-color", textPrimary).attr("stop-opacity", 0.22); + gradient.append("stop").attr("offset", "100%").attr("stop-color", textPrimary).attr("stop-opacity", 0); + + const COLLISION_PX = 18; + const targetY = targetAmount > 0 ? y(targetAmount) : null; + const yTicks = yAxisVisible ? y.ticks(3) : []; + const targetCollidesWithTick = + targetY !== null && yTicks.some((tv) => Math.abs(y(tv) - targetY) < COLLISION_PX); + + if (yAxisVisible) { + yTicks.forEach((tickValue) => { + svg + .append("line") + .attr("x1", margin.left) + .attr("x2", margin.left + innerWidth) + .attr("y1", y(tickValue)) + .attr("y2", y(tickValue)) + .attr("stroke", borderSubdued) + .attr("stroke-width", 1); + // Skip the y-axis label when its row is close to the target line. + // The target's own label will take over that y-slot below. + if (targetY !== null && Math.abs(y(tickValue) - targetY) < COLLISION_PX) return; + svg + .append("text") + .attr("x", margin.left - 6) + .attr("y", y(tickValue) + 3) + .attr("text-anchor", "end") + .attr("font-size", 12) + .attr("fill", textSecondary) + .text(this._fmtMoneyShort(tickValue, data.currency)); + }); + } + + if (targetAmount > 0) { + svg + .append("line") + .attr("x1", margin.left) + .attr("x2", margin.left + innerWidth) + .attr("y1", y(targetAmount)) + .attr("y2", y(targetAmount)) + .attr("stroke", borderSubdued) + .attr("stroke-width", 1) + .attr("stroke-dasharray", "3 3"); + + if (targetCollidesWithTick) { + // Merge target label into the y-axis column at the target's y-row. + // The collided y-axis tick was suppressed above so this label takes + // over that slot cleanly. + svg + .append("text") + .attr("x", margin.left - 6) + .attr("y", targetY + 3) + .attr("text-anchor", "end") + .attr("font-size", 12) + .attr("fill", textPrimary) + .text(`Target · ${data.target_amount_short_label}`); + } else { + // Plenty of room: keep the right-side full-format label. + svg + .append("text") + .attr("x", margin.left + innerWidth - 4) + .attr("y", targetY - 6) + .attr("text-anchor", "end") + .attr("font-size", 12) + .attr("fill", textPrimary) + .text(`Target · ${data.target_amount_label}`); + } + } + + const area = d3 + .area() + .x((d) => x(d.date)) + .y0(margin.top + innerHeight) + .y1((d) => y(d.value)) + .curve(d3.curveMonotoneX); + + const line = d3 + .line() + .x((d) => x(d.date)) + .y((d) => y(d.value)) + .curve(d3.curveMonotoneX); + + svg + .append("path") + .datum(savedSeries) + .attr("fill", `url(#saved-fill-${this._id()})`) + .attr("d", area); + + svg + .append("path") + .datum(savedSeries) + .attr("fill", "none") + .attr("stroke", textPrimary) + .attr("stroke-width", 2) + .attr("stroke-linejoin", "round") + .attr("stroke-linecap", "round") + .attr("d", line); + + if (requiredSeries.length) { + // Light dashed reference line: the path needed to hit the target. + // Neutral stroke (text-secondary) instead of green: both the + // projection and the required line are otherwise green when the + // goal is on track, and the two would visually merge. + svg + .append("path") + .datum(requiredSeries) + .attr("fill", "none") + .attr("stroke", textSecondary) + .attr("stroke-width", 1.2) + .attr("stroke-linecap", "round") + .attr("stroke-dasharray", "2 4") + .attr("opacity", 0.5) + .attr("d", line); + } + + if (projectionSeries.length) { + const willHit = projectionEnd >= targetAmount; + const projColor = willHit ? "var(--color-green-600)" : "var(--color-yellow-600)"; + svg + .append("path") + .datum(projectionSeries) + .attr("fill", "none") + .attr("stroke", projColor) + .attr("stroke-width", 2) + .attr("stroke-linecap", "round") + .attr("stroke-dasharray", "4 4") + .attr("d", line); + + svg + .append("circle") + .attr("cx", x(target)) + .attr("cy", y(projectionEnd)) + .attr("r", 4) + .attr("fill", projColor) + .attr("stroke", containerBg) + .attr("stroke-width", 2); + + // Suppress the projection-end label when it would visually collide + // with the target label above. In a barely-on-track case the dot + // already conveys "you'll hit the target". duplicating "$2.4K" + // beside "Target · $2,400" adds noise. + const projDotY = y(projectionEnd); + const collidesWithTargetLabel = targetAmount > 0 && Math.abs(projDotY - y(targetAmount)) < 18; + + if (innerWidth >= 320 && !(willHit && collidesWithTargetLabel)) { + // Server-rendered labels: projection_end_label is the full-format + // currency for the on-track endpoint, projection_shortfall_label + // is the "$X short" string when we fall short. + const labelText = willHit + ? data.projection_end_label + : (data.projection_shortfall_label ? `${data.projection_shortfall_label} short` : ""); + if (labelText) { + svg + .append("text") + .attr("x", x(target) - 8) + .attr("y", y(projectionEnd) - 8) + .attr("text-anchor", "end") + .attr("font-size", 12) + .attr("fill", textSecondary) + .attr("paint-order", "stroke") + .attr("stroke", containerBg) + .attr("stroke-width", 4) + .attr("stroke-linejoin", "round") + .text(labelText); + } + } + } + + svg + .append("line") + .attr("x1", x(today)) + .attr("x2", x(today)) + .attr("y1", margin.top) + .attr("y2", margin.top + innerHeight) + .attr("stroke", borderSubdued) + .attr("stroke-width", 1) + .attr("stroke-dasharray", "2 4"); + + svg + .append("circle") + .attr("cx", x(today)) + .attr("cy", y(currentAmount)) + .attr("r", 4) + .attr("fill", textPrimary) + .attr("stroke", containerBg) + .attr("stroke-width", 2); + + if (innerWidth >= 320) { + svg + .append("text") + .attr("x", x(today)) + .attr("y", margin.top - 4) + .attr("text-anchor", "middle") + .attr("font-size", 12) + .attr("fill", textSecondary) + .text(this.todayLabelValue); + } + + // Full 4-digit year so the terminal "Jan 2027" reads as the year, not + // as "Jan 27" (which scans as January 27th). Slightly wider per tick; + // the de-dupe logic below keeps the count sane. + const tickFmt = d3.timeFormat("%b %Y"); + const tickCount = Math.min(5, Math.max(2, Math.round(innerWidth / 80))); + const ticks = x.ticks(tickCount); + const tickGroup = svg.append("g"); + tickGroup + .selectAll("text") + .data(ticks) + .enter() + .append("text") + .attr("x", (d) => x(d)) + .attr("y", height - 8) + .attr("text-anchor", "middle") + .attr("font-size", 12) + .attr("fill", textSecondary) + .text((d) => tickFmt(d)); + // De-dupe adjacent equal tick labels (e.g. multiple "May '26" on a + // short window where d3.ticks oversamples). + const tickNodes = tickGroup.selectAll("text").nodes(); + for (let i = tickNodes.length - 1; i > 0; i--) { + if (tickNodes[i].textContent === tickNodes[i - 1].textContent) { + tickNodes[i].remove(); + } + } + + // Hover interactivity: crosshair + dots + tooltip on pointermove. + // Transparent rect catches pointer events across the plot area. + const crosshair = svg + .append("line") + .attr("y1", margin.top) + .attr("y2", margin.top + innerHeight) + .attr("stroke", textSecondary) + .attr("stroke-width", 1) + .attr("stroke-dasharray", "2 2") + .attr("pointer-events", "none") + .style("display", "none"); + + const hoverSavedDot = svg + .append("circle") + .attr("r", 4) + .attr("fill", textPrimary) + .attr("stroke", containerBg) + .attr("stroke-width", 2) + .attr("pointer-events", "none") + .style("display", "none"); + + const hoverProjDot = svg + .append("circle") + .attr("r", 4) + .attr("fill", projectionSeries.length && projectionEnd >= targetAmount ? "var(--color-green-600)" : "var(--color-yellow-600)") + .attr("stroke", containerBg) + .attr("stroke-width", 2) + .attr("pointer-events", "none") + .style("display", "none"); + + // Only promote root to a positioned ancestor when it currently has no + // positioning context. Inline checks against `root.style.position` + // miss positions set via CSS (the inline style is empty), so we'd + // clobber a stylesheet `position: fixed/sticky/absolute` with our + // own `relative`. Read the computed style instead. + if (getComputedStyle(root).position === "static") root.style.position = "relative"; + const tooltip = document.createElement("div"); + tooltip.className = "bg-container text-primary text-sm font-sans absolute p-2 border border-secondary rounded-lg pointer-events-none z-50 privacy-sensitive"; + tooltip.style.display = "none"; + root.appendChild(tooltip); + + const overlay = svg + .append("rect") + .attr("x", margin.left) + .attr("y", margin.top) + .attr("width", innerWidth) + .attr("height", innerHeight) + .attr("fill", "transparent") + .style("cursor", "crosshair"); + + const bisectDate = d3.bisector((d) => d.date).left; + const dateFmt = d3.timeFormat("%b %d, %Y"); + const todayTs = today.getTime(); + const targetTs = target ? target.getTime() : null; + const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000; + + const showAt = (xPos, yPos) => { + const xVal = x.invert(xPos); + if (!savedSeries.length) return; + + const future = xVal.getTime() > todayTs && projectionSeries.length && targetTs; + + // Date the crosshair + the active dot snaps to. Past = nearest saved + // contribution (sparse, monthly-ish). Future = weekly steps along the + // projection segment so the cursor doesn't jitter pixel-by-pixel. + let hoverDate; + if (future) { + const weeks = Math.round((xVal.getTime() - todayTs) / MS_PER_WEEK); + let snapped = todayTs + weeks * MS_PER_WEEK; + if (snapped > targetTs) snapped = targetTs; + if (snapped < todayTs) snapped = todayTs; + hoverDate = new Date(snapped); + } else { + const i = bisectDate(savedSeries, xVal); + const a = savedSeries[Math.max(0, i - 1)]; + const b = savedSeries[Math.min(savedSeries.length - 1, i)]; + hoverDate = !a ? b.date : !b ? a.date : (xVal - a.date < b.date - xVal ? a.date : b.date); + } + + const hoverX = x(hoverDate); + crosshair.attr("x1", hoverX).attr("x2", hoverX).style("display", null); + + const lines = [dateFmt(hoverDate)]; + + if (future) { + // Projection segment: interpolate along the dashed line; saved dot + // stays hidden (no saved value in the future). + const tFrac = (hoverDate.getTime() - todayTs) / (targetTs - todayTs); + const projValue = currentAmount + tFrac * (projectionEnd - currentAmount); + hoverProjDot.attr("cx", hoverX).attr("cy", y(projValue)).style("display", null); + hoverSavedDot.style("display", "none"); + lines.push(this.projectedTemplateValue.replace("{amount}", this._fmtMoney(projValue, data.currency))); + } else { + // Saved segment: hoverDate is already snapped to nearest savedSeries + // entry above, so reuse that entry directly instead of running + // bisectDate a second time. + const savedPoint = savedSeries.find((p) => p.date.getTime() === hoverDate.getTime()) || savedSeries[savedSeries.length - 1]; + hoverSavedDot.attr("cx", x(savedPoint.date)).attr("cy", y(savedPoint.value)).style("display", null); + hoverProjDot.style("display", "none"); + lines.push(this.savedTemplateValue.replace("{amount}", this._fmtMoney(savedPoint.value, data.currency))); + } + + tooltip.textContent = lines.join("\n"); + tooltip.style.whiteSpace = "pre"; + tooltip.style.display = "block"; + const tipRect = tooltip.getBoundingClientRect(); + const left = Math.min(width - tipRect.width - 4, Math.max(4, xPos + 12)); + const top = Math.max(4, yPos - tipRect.height - 8); + tooltip.style.left = `${left}px`; + tooltip.style.top = `${top}px`; + }; + + const hide = () => { + crosshair.style("display", "none"); + hoverSavedDot.style("display", "none"); + hoverProjDot.style("display", "none"); + tooltip.style.display = "none"; + }; + + overlay.on("pointermove", (event) => { + const [mx, my] = d3.pointer(event); + showAt(mx, my); + }); + overlay.on("pointerleave", hide); + } + + _monthsBetween(a, b) { + return (b - a) / (1000 * 60 * 60 * 24 * 30.44); + } + + _fmtMoney(amount, currency) { + try { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: currency || "USD", + maximumFractionDigits: 0, + }).format(amount); + } catch { + // Same server-shipped symbol path as `_fmtMoneyShort`. + const symbol = this.dataValue?.currency_symbol || "$"; + return `${symbol}${Math.round(amount).toLocaleString()}`; + } + } + + _fmtMoneyShort(amount, _currency) { + // The server ships `currency_symbol` via projection_payload (resolved + // through Money.new(0, code).currency.symbol so EUR/GBP/JPY/etc. render + // with the family-locale-correct glyph). Fall back to "$" if a stale + // payload reaches us mid-deploy. + const symbol = this.dataValue?.currency_symbol || "$"; + const abs = Math.abs(amount); + if (abs >= 1_000_000) { + return `${symbol}${(amount / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; + } + if (abs >= 1_000) { + return `${symbol}${(amount / 1_000).toFixed(1).replace(/\.0$/, "")}K`; + } + return `${symbol}${Math.round(amount).toLocaleString()}`; + } + + _id() { + if (!this._cachedId) { + this._cachedId = Math.random().toString(36).slice(2, 8); + } + return this._cachedId; + } +} diff --git a/app/javascript/controllers/goals_filter_controller.js b/app/javascript/controllers/goals_filter_controller.js new file mode 100644 index 000000000..5e68db6f8 --- /dev/null +++ b/app/javascript/controllers/goals_filter_controller.js @@ -0,0 +1,164 @@ +import { Controller } from "@hotwired/stimulus"; + +// Free-text + status-chip filter for the goals index grid. +// Mirrors the providers-filter pattern. Each card has data-goal-name +// and data-goal-status; the controller toggles `.hidden` on cards +// based on the active query/chip. +export default class extends Controller { + static targets = [ + "input", + "chip", + "card", + "empty", + "emptyCopy", + "emptyClearSearch", + "emptyClearFilter", + "grid", + "count", + ]; + static values = { + status: { type: String, default: "all" }, + emptyQuery: { type: String, default: "" }, + emptyFilter: { type: String, default: "" }, + emptyBoth: { type: String, default: "" }, + emptyDefault: { type: String, default: "" }, + }; + + connect() { + this.#hydrateFromUrl(); + this.syncChipState(); + if (this.statusValue !== "all" || (this.hasInputTarget && this.inputTarget.value)) { + this.filter(); + } + } + + disconnect() { + clearTimeout(this._urlSyncTimer); + } + + filter() { + const query = this.hasInputTarget + ? this.inputTarget.value.toLocaleLowerCase().trim() + : ""; + const active = this.statusValue; + let visible = 0; + + this.cardTargets.forEach((card) => { + const name = (card.dataset.goalName || "").toLocaleLowerCase(); + const status = card.dataset.goalStatus || ""; + const matchesQuery = !query || name.includes(query); + const matchesStatus = active === "all" || status === active; + const show = matchesQuery && matchesStatus; + card.classList.toggle("hidden", !show); + if (show) visible++; + }); + + if (this.hasEmptyTarget) { + this.emptyTarget.classList.toggle("hidden", visible > 0); + } + if (this.hasGridTarget) { + this.gridTarget.classList.toggle("hidden", visible === 0); + } + if (this.hasCountTarget) { + this.countTarget.textContent = visible; + } + + this.updateEmptyState(visible, query, active); + this.#scheduleUrlSync(); + } + + // Debounced wrapper. Firing replaceState on every keystroke is wasteful + // and produced visible jank on slow CPUs; deferring 200 ms collapses a + // typing burst into a single URL update without losing back-button + // fidelity (replaceState doesn't create history entries anyway). + #scheduleUrlSync() { + clearTimeout(this._urlSyncTimer); + this._urlSyncTimer = setTimeout(() => this.#syncUrl(), 200); + } + + #hydrateFromUrl() { + const params = new URLSearchParams(window.location.search); + const status = params.get("filter"); + if (status && this.chipTargets.some((c) => c.dataset.status === status)) { + this.statusValue = status; + } + const q = params.get("q"); + if (q && this.hasInputTarget) { + this.inputTarget.value = q; + } + } + + #syncUrl() { + const params = new URLSearchParams(window.location.search); + if (this.statusValue && this.statusValue !== "all") { + params.set("filter", this.statusValue); + } else { + params.delete("filter"); + } + const q = this.hasInputTarget ? this.inputTarget.value.trim() : ""; + if (q) { + params.set("q", q); + } else { + params.delete("q"); + } + const qs = params.toString(); + const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname; + window.history.replaceState(window.history.state, "", url); + } + + updateEmptyState(visible, query, active) { + if (visible > 0 || !this.hasEmptyCopyTarget) return; + const rawQuery = this.hasInputTarget ? this.inputTarget.value.trim() : ""; + const hasQuery = rawQuery.length > 0; + const hasFilter = active !== "all"; + let copy; + if (hasQuery && hasFilter) { + copy = this.emptyBothValue.replace("__QUERY__", rawQuery); + } else if (hasQuery) { + copy = this.emptyQueryValue.replace("__QUERY__", rawQuery); + } else if (hasFilter) { + copy = this.emptyFilterValue; + } else { + copy = this.emptyDefaultValue; + } + this.emptyCopyTarget.textContent = copy; + if (this.hasEmptyClearSearchTarget) { + this.emptyClearSearchTarget.classList.toggle("hidden", !hasQuery); + } + if (this.hasEmptyClearFilterTarget) { + this.emptyClearFilterTarget.classList.toggle("hidden", !hasFilter); + } + } + + clearSearch() { + if (this.hasInputTarget) { + this.inputTarget.value = ""; + this.inputTarget.focus(); + } + this.filter(); + } + + clearFilter() { + this.statusValue = "all"; + this.syncChipState(); + this.filter(); + } + + selectChip(event) { + this.statusValue = event.currentTarget.dataset.status || "all"; + this.syncChipState(); + this.filter(); + } + + syncChipState() { + if (!this.hasChipTarget) return; + this.chipTargets.forEach((chip) => { + const active = chip.dataset.status === this.statusValue; + chip.setAttribute("aria-pressed", active); + chip.classList.toggle("bg-container", active); + chip.classList.toggle("shadow-border-xs", active); + chip.classList.toggle("text-primary", active); + chip.classList.toggle("text-secondary", !active); + }); + } +} diff --git a/app/jobs/sweep_expired_goal_pledges_job.rb b/app/jobs/sweep_expired_goal_pledges_job.rb new file mode 100644 index 000000000..bf2214764 --- /dev/null +++ b/app/jobs/sweep_expired_goal_pledges_job.rb @@ -0,0 +1,22 @@ +class SweepExpiredGoalPledgesJob < ApplicationJob + queue_as :scheduled + + # Per-record rescue so one bad pledge (lock contention, missing FK, + # stale row) doesn't abort the sweep and leave the rest open forever. + # The outer rescue catches query-phase failures (DB blip, OOM mid-cursor) + # so a single bad batch surfaces to Sentry rather than disappearing into + # Sidekiq's generic retry log. Re-raise after reporting so the retry + # behaviour still kicks in. + def perform + GoalPledge.open_and_expired_now.find_each do |pledge| + pledge.expire! + rescue => e + Rails.logger.error("SweepExpiredGoalPledgesJob: pledge ##{pledge.id} expire failed: #{e.class}: #{e.message}") + Sentry.capture_exception(e) if defined?(Sentry) + end + rescue StandardError => e + Rails.logger.error("SweepExpiredGoalPledgesJob: cursor failed: #{e.class}: #{e.message}") + Sentry.capture_exception(e) if defined?(Sentry) + raise + end +end diff --git a/app/models/account.rb b/app/models/account.rb index ac33c1dad..48a64b796 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -25,6 +25,9 @@ class Account < ApplicationRecord has_many :holdings, dependent: :destroy has_many :balances, dependent: :destroy has_many :recurring_transactions, dependent: :destroy + has_many :goal_accounts, dependent: :destroy + has_many :goals, through: :goal_accounts + has_many :goal_pledges, dependent: :destroy # Inverse for recurring transfers where this account is the destination. # Account#recurring_transactions only matches account_id; without this # association, destroying the destination account would hit the FK @@ -347,11 +350,26 @@ class Account < ApplicationRecord def manual_crypto_exchange? accountable_type == "Crypto" && accountable&.subtype == "exchange" && - account_providers.none? && + manual? + end + + # True when the account has no live sync provider attached. Mirrors the + # `Account.manual` scope so per-instance checks don't drift from the query. + def manual? + account_providers.none? && plaid_account_id.blank? && simplefin_account_id.blank? end + # Default GoalPledge kind for this account. Manual accounts get + # `manual_save` (resolves on the next valuation), live-synced accounts + # get `transfer` (resolves when the synced deposit posts). Keeps the + # decision in one place so the new-pledge controller / preview helper + # can't disagree on what they're going to save. + def default_pledge_kind + manual? ? "manual_save" : "transfer" + end + def logo_url if institution_domain.present? && Setting.brand_fetch_client_id.present? logo_size = Setting.brand_fetch_logo_size diff --git a/app/models/account/provider_import_adapter.rb b/app/models/account/provider_import_adapter.rb index b4f8e17dc..f5bfe202f 100644 --- a/app/models/account/provider_import_adapter.rb +++ b/app/models/account/provider_import_adapter.rb @@ -241,6 +241,19 @@ class Account::ProviderImportAdapter entry.save! entry.transaction.save! if entry.transaction.changed? + # Auto-resolve any open Goal pledges on this account whose tolerance + # window matches the posted transaction. Idempotent via the partial-unique + # index on transactions.extra->'goal'->>'pledge_id'. + # + # Short-circuit when the account isn't linked to any goal: a 2k-row + # historical Plaid import on an unlinked account otherwise pays one + # SELECT per row. goal_accounts membership is stable across a sync + # batch, so memoize once per adapter instance (one query per account + # synced, not per transaction). + if !incoming_pending && account_linked_to_any_goal? + GoalPledge::Reconciler.new(entry).run + end + # AFTER save: For NEW posted transactions, check for fuzzy matches to SUGGEST (not auto-claim) # This handles tip adjustments where auto-matching is too risky if is_new_posted @@ -987,6 +1000,14 @@ class Account::ProviderImportAdapter private + # Memoized per adapter instance (which is per-account). Membership in + # goal_accounts is stable across a sync batch. + def account_linked_to_any_goal? + return @account_linked_to_any_goal if defined?(@account_linked_to_any_goal) + + @account_linked_to_any_goal = account.goal_accounts.exists? + end + def clear_pending_flags_from_extra(extra) ex = (extra || {}).deep_dup ex = {} unless ex.is_a?(Hash) diff --git a/app/models/account/reconciliation_manager.rb b/app/models/account/reconciliation_manager.rb index 6fadcfa1a..774e9b330 100644 --- a/app/models/account/reconciliation_manager.rb +++ b/app/models/account/reconciliation_manager.rb @@ -12,6 +12,7 @@ class Account::ReconciliationManager unless dry_run prepared_valuation.save! + GoalPledge::Reconciler.new(prepared_valuation).run end ReconciliationResult.new( diff --git a/app/models/assistant.rb b/app/models/assistant.rb index 78fcbb556..583737761 100644 --- a/app/models/assistant.rb +++ b/app/models/assistant.rb @@ -29,7 +29,8 @@ module Assistant Function::GetIncomeStatement, Function::GetBudget, Function::ImportBankStatement, - Function::SearchFamilyFiles + Function::SearchFamilyFiles, + Function::CreateGoal ] end diff --git a/app/models/assistant/function/create_goal.rb b/app/models/assistant/function/create_goal.rb new file mode 100644 index 000000000..9452ccf27 --- /dev/null +++ b/app/models/assistant/function/create_goal.rb @@ -0,0 +1,184 @@ +class Assistant::Function::CreateGoal < Assistant::Function + class << self + def name + "create_goal" + end + + def description + <<~INSTRUCTIONS + Creates a goal for the user's family. + + Use when the user describes a target they want to save toward — e.g. + "vacation in 4 months for $5000", "downpayment for a car next year", + "build an emergency fund of $10k". + + Before calling, confirm the key details by paraphrasing back to the + user: the name, target amount, target date (if mentioned), and which + of their accounts will fund it. Only call once they've confirmed. + + Constraints: + - The goal must link to at least one of the user's Depository + accounts (checking, savings, HSA, CD, money-market). + - All linked accounts must share the same currency. + - Use account names exactly as listed in the user's Depository + accounts. + + On success returns the new goal's URL so you can point the user to + it. On a soft failure (e.g. account name doesn't match), the + response includes the available account list so you can re-ask. + INSTRUCTIONS + end + end + + def strict_mode? + false + end + + def params_schema + build_schema( + required: %w[name target_amount linked_account_names], + properties: { + name: { + type: "string", + description: "Short goal name, e.g. 'Vacation in Italy'." + }, + target_amount: { + type: "number", + description: "Total amount to save, in the linked accounts' currency." + }, + target_date: { + type: "string", + description: "Optional ISO 8601 date (YYYY-MM-DD) for when the user wants to reach the target." + }, + linked_account_names: { + type: "array", + items: { type: "string" }, + description: "Names of the user's Depository accounts to link. Must contain at least one. Use names exactly as they appear in the available accounts list. The goal's balance is the balance of these accounts." + }, + notes: { + type: "string", + description: "Optional freeform notes." + } + } + ) + end + + def call(params = {}) + name = params["name"].to_s.strip + target_amount = parse_decimal(params["target_amount"]) + target_date = parse_date(params["target_date"]) + linked_account_names = Array(params["linked_account_names"]).map { |n| n.to_s.strip }.reject(&:blank?) + notes = params["notes"].to_s.strip + + return error("name_required", "Please provide a name for the goal.") if name.blank? + + return error("target_amount_invalid", "Target amount must be greater than zero.") unless target_amount && target_amount > 0 + + if linked_account_names.empty? + return error( + "no_linked_accounts", + "Please specify at least one Depository account to link to this goal.", + available_accounts: depository_account_payload + ) + end + + available = family.accounts.where(accountable_type: "Depository").visible.where(name: linked_account_names) + missing = linked_account_names - available.pluck(:name).uniq + if missing.any? + return error( + "unknown_accounts", + "Some account names didn't match the user's Depository accounts.", + unknown_names: missing, + available_accounts: depository_account_payload + ) + end + + # Multiple accounts can share a name. Block silent over-linking by + # surfacing the ambiguity so the assistant re-asks with disambiguated + # input rather than attaching every same-named account to the goal. + grouped = available.group_by(&:name) + ambiguous_names = grouped.select { |_, accts| accts.size > 1 }.keys + if ambiguous_names.any? + return error( + "ambiguous_accounts", + "Multiple accounts share a name. Ask the user which one to use.", + ambiguous_names: ambiguous_names, + available_accounts: depository_account_payload + ) + end + + matched = linked_account_names.map { |name| grouped[name].first } + + currencies = matched.map(&:currency).uniq + if currencies.size > 1 + return error( + "currency_mismatch", + "All linked accounts must share the same currency. Found: #{currencies.join(', ')}." + ) + end + + goal = nil + Goal.transaction do + goal = family.goals.new( + name: name, + target_amount: target_amount, + target_date: target_date, + currency: currencies.first, + notes: notes.presence, + color: Goal::COLORS.sample + ) + matched.each { |a| goal.goal_accounts.build(account: a) } + goal.save! + end + + { + success: true, + goal_id: goal.id, + name: goal.name, + target_amount_formatted: goal.target_amount_money.format, + currency: goal.currency, + target_date: goal.target_date&.iso8601, + url: absolute_url_for(goal), + linked_account_names: matched.map(&:name), + message: "Created goal '#{goal.name}' (target #{goal.target_amount_money.format}). View it at #{absolute_url_for(goal)}." + } + rescue ActiveRecord::RecordInvalid => e + error("validation_failed", e.record.errors.full_messages.join("; ")) + end + + private + # Build an absolute URL for the new goal so chat clients (which render + # outside the request that produced the goal) can link directly. Falls + # back to the relative path when no host is configured (e.g. self-hosted + # in a job without ENV). + def absolute_url_for(goal) + host_opts = Rails.application.config.action_mailer.default_url_options || {} + if host_opts[:host].present? + Rails.application.routes.url_helpers.goal_url(goal, host_opts) + else + Rails.application.routes.url_helpers.goal_path(goal) + end + end + + def parse_decimal(value) + return nil if value.nil? + BigDecimal(value.to_s) + rescue ArgumentError, TypeError + nil + end + + def parse_date(value) + return nil if value.blank? + Date.iso8601(value.to_s) + rescue Date::Error + nil + end + + def depository_account_payload + family.accounts.where(accountable_type: "Depository").visible.pluck(:name, :currency).map { |n, c| { name: n, currency: c } } + end + + def error(key, message, extras = {}) + { success: false, error: key, message: message }.merge(extras) + end +end diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index 474a1095c..e047424cc 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -72,6 +72,9 @@ class Demo::Generator create_realistic_accounts!(family) create_realistic_transactions!(family) generate_budget_auto_fill!(family) + + puts "🎯 Seeding goals..." + generate_goals!(family) end family.sync_later @@ -103,6 +106,9 @@ class Demo::Generator # Auto-fill current-month budget based on recent spending averages generate_budget_auto_fill!(family) + puts "🎯 Seeding goals..." + generate_goals!(family) + puts "✅ Realistic demo data loaded successfully!" end end @@ -247,14 +253,14 @@ class Demo::Generator def create_realistic_accounts!(family) # Checking accounts (USD) - @chase_checking = family.accounts.create!(accountable: Depository.new, name: "Chase Premier Checking", balance: 0, currency: "USD") - @ally_checking = family.accounts.create!(accountable: Depository.new, name: "Ally Online Checking", balance: 0, currency: "USD") + @chase_checking = family.accounts.create!(accountable: Depository.new(subtype: "checking"), name: "Chase Premier Checking", balance: 0, currency: "USD") + @ally_checking = family.accounts.create!(accountable: Depository.new(subtype: "checking"), name: "Ally Online Checking", balance: 0, currency: "USD") # Savings account (USD) - @marcus_savings = family.accounts.create!(accountable: Depository.new, name: "Marcus High-Yield Savings", balance: 0, currency: "USD") + @marcus_savings = family.accounts.create!(accountable: Depository.new(subtype: "savings"), name: "Marcus High-Yield Savings", balance: 0, currency: "USD") # EUR checking (EUR) - @eu_checking = family.accounts.create!(accountable: Depository.new, name: "Deutsche Bank EUR Account", balance: 0, currency: "EUR") + @eu_checking = family.accounts.create!(accountable: Depository.new(subtype: "checking"), name: "Deutsche Bank EUR Account", balance: 0, currency: "EUR") # Credit cards (USD) @amex_gold = family.accounts.create!(accountable: CreditCard.new, name: "Amex Gold Card", balance: 0, currency: "USD") @@ -1274,4 +1280,172 @@ class Demo::Generator puts " ✅ Set property and vehicle valuations" end + + def generate_goals!(family) + # Order so primary/secondary picks stay stable across reseeds — the + # state-coverage matrix below routes goals to specific accounts to + # surface every status branch, so DB return order can't pick. + depository_accounts = family.accounts + .where(accountable_type: "Depository") + .visible + .order(:created_at, :id) + .to_a + return if depository_accounts.empty? + + currency = depository_accounts.first.currency + eligible = depository_accounts.select { |a| a.currency == currency } + primary = eligible.first + secondary = (eligible - [ primary ]).first || primary + + # Demo coverage matrix. The demo seeds a heavy primary checking + # balance (~$150k) plus a smaller secondary account (~$10k). To + # surface every goal state we deliberately route goals to different + # account pools so progress lands above or below the target: + # + # AASM states: active, paused, completed, archived + # Computed status (on active goals): + # :reached, :on_track, :behind, :no_target_date + # Edge surfaces: past-due target_date ("was due"), open pledge + # banner, matched pledge ("last pledge matched") + goals = [ + # active · behind — secondary account only, target above its balance + { + name: "Vacation in Italy", + target: 20_000, + target_date: 4.months.from_now.to_date, + accounts: [ secondary ], + pledges: [ + { account: secondary, amount: 250, kind: "transfer", status: "open", expires_at: 5.days.from_now } + ] + }, + # active · reached — primary balance comfortably above target + { + name: "Wedding fund", + target: 2_400, + target_date: 12.months.from_now.to_date, + accounts: [ primary ] + }, + # active · no_target_date — secondary so progress doesn't auto-cap at 100% + { + name: "Emergency fund", + target: 30_000, + target_date: nil, + accounts: [ secondary ] + }, + # active · behind big — combined pools still well short of the target + { + name: "House downpayment", + target: 500_000, + target_date: 24.months.from_now.to_date, + accounts: eligible.first(2), + pledges: [ + { account: primary, amount: 2_000, kind: "transfer", status: "open", expires_at: 4.days.from_now } + ] + }, + # active · on_track — primary balance close to target, long horizon makes + # the required monthly rate small enough for the demo's pace to cover + { + name: "Long-term portfolio", + target: 200_000, + target_date: 60.months.from_now.to_date, + accounts: [ primary ] + }, + # active · past-due — exercises "was due" header copy + the + # months_remaining = 0 branch in monthly_target_amount + { + name: "Tax prep buffer", + target: 1_200, + target_date: 2.months.ago.to_date, + accounts: [ secondary ] + }, + # AASM paused + { + name: "Sabbatical", + target: 15_000, + target_date: 18.months.from_now.to_date, + state: "paused", + accounts: [ primary ] + }, + # AASM archived + { + name: "Old laptop fund", + target: 1_500, + target_date: 12.months.ago.to_date, + state: "archived", + accounts: [ primary ] + }, + # AASM completed + { + name: "Paid-off car", + target: 8_000, + target_date: 6.months.ago.to_date, + state: "completed", + accounts: [ primary ] + } + ] + + wedding_goal = nil + goals.each do |goal_spec| + goal = family.goals.new( + name: goal_spec[:name], + target_amount: goal_spec[:target], + target_date: goal_spec[:target_date], + currency: currency, + color: Goal::COLORS.sample, + state: goal_spec[:state] || "active" + ) + goal_spec[:accounts].uniq.each { |a| goal.goal_accounts.build(account: a) } + goal.save! + wedding_goal = goal if goal_spec[:name] == "Wedding fund" + + Array(goal_spec[:pledges]).each do |pledge_spec| + goal.goal_pledges.create!( + account: pledge_spec[:account] || goal.linked_accounts.first, + amount: pledge_spec[:amount], + currency: currency, + kind: pledge_spec[:kind] || "transfer", + status: pledge_spec[:status] || "open", + expires_at: pledge_spec[:expires_at] || 7.days.from_now + ) + end + end + + seed_matched_pledge_demo_for_wedding!(wedding_goal, currency, primary) if wedding_goal && primary + + puts " ✅ Seeded #{goals.size} goals" + end + + # Bind one matched pledge on the Wedding fund to a real recent demo + # inflow Transaction. Surfaces the "Last pledge matched N days ago" + # header copy + exercises the partial-unique index on + # transactions.extra->'goal'->>'pledge_id'. + def seed_matched_pledge_demo_for_wedding!(wedding, currency, primary) + return unless wedding && primary + + recent_inflow_entry = Entry + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where(account_id: primary.id, excluded: false) + .where("entries.amount < 0") + .where("entries.date >= ?", 30.days.ago.to_date) + .where("(transactions.extra -> 'goal' ->> 'pledge_id') IS NULL") + .order("entries.date DESC", "entries.id DESC") + .first + + return unless recent_inflow_entry + + pledge = wedding.goal_pledges.create!( + account: primary, + amount: recent_inflow_entry.amount.to_d.abs, + currency: currency, + kind: "transfer", + status: "matched", + matched_transaction_id: recent_inflow_entry.entryable_id, + expires_at: 7.days.ago + ) + + txn = recent_inflow_entry.entryable + new_extra = (txn.extra || {}).deep_dup + new_extra["goal"] = (new_extra["goal"] || {}).merge("pledge_id" => pledge.id) + txn.update!(extra: new_extra) + end end diff --git a/app/models/family.rb b/app/models/family.rb index 4313ab4a5..0d44fce73 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -43,6 +43,68 @@ class Family < ApplicationRecord has_many :budgets, dependent: :destroy has_many :budget_categories, through: :budgets + has_many :goals, dependent: :destroy + + # Net inflow into every depository account linked to any primary-currency + # goal, over the given window. Transfers between linked accounts net to zero + # because both sides of an internal move land inside the same account set; + # external transfers (e.g. checking → linked savings) net positive. + # + # Scoped to the family's primary currency: mixed-currency families would + # otherwise sum raw EUR + USD numbers and surface the result as primary. + # Foreign-currency goals are excluded from this KPI until FX conversion is + # added. + # + # Entry amount convention in Sure: inflow is negative, so flip the sign. + # Result is allowed to go negative (net outflow last 30d) so the headline + # reflects reality; the controller decides how to render. + def savings_inflow_velocity(range: 30.days.ago.to_date..Date.current, account_ids: nil) + ids = account_ids || goal_linked_account_ids + return 0 if ids.empty? + + net = Entry + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where(account_id: ids, date: range) + .where(excluded: false) + .merge(Transaction.excluding_pending) + .sum(:amount) + + -net.to_d + end + + # Two velocity windows in a single pair of sums that share one + # account-id lookup. The kpi tile on the index reads both the current + # 30d window and the prior 30d window; without this helper the + # `accounts.joins(:goal_accounts)…pluck(:id)` query runs twice per + # request even though the answer is identical. + def savings_inflow_windows(window_days: 30, now: Date.current) + ids = goal_linked_account_ids + { + current: savings_inflow_velocity(range: (now - window_days)..now, account_ids: ids), + prior: savings_inflow_velocity(range: (now - 2 * window_days)..(now - window_days - 1), account_ids: ids) + } + end + + private + + # Depository accounts linked to this family's goals, restricted to the + # primary currency until FX is added. Memoized for the lifetime of the + # Family instance so a single request that reads velocity twice (the + # KPI tile uses current vs prior 30d) doesn't re-run the join+pluck. + # `accounts` is already scoped by the has_many association, and the + # join restricts to this family's goals — so cross-family bleed + # remains impossible. + def goal_linked_account_ids + @goal_linked_account_ids ||= accounts + .joins(:goal_accounts) + .where(goal_accounts: { goal_id: goals.select(:id) }) + .where(currency: primary_currency_code) + .distinct + .pluck(:id) + end + + public + has_many :llm_usages, dependent: :destroy has_many :recurring_transactions, dependent: :destroy diff --git a/app/models/goal.rb b/app/models/goal.rb new file mode 100644 index 000000000..da4f597af --- /dev/null +++ b/app/models/goal.rb @@ -0,0 +1,490 @@ +class Goal < ApplicationRecord + include AASM, Monetizable + + COLORS = Category::COLORS + ICONS = Category.icon_codes + + validates :icon, inclusion: { in: ICONS, allow_nil: true } + validates :color, format: { with: /\A#[0-9A-Fa-f]{6}\z/ }, allow_nil: true + + belongs_to :family + has_many :goal_accounts, dependent: :destroy + has_many :linked_accounts, through: :goal_accounts, source: :account + has_many :goal_pledges, dependent: :destroy + has_many :open_pledges, + -> { where(status: "open").where("expires_at >= ?", Time.current) }, + class_name: "GoalPledge" + + validates :name, presence: true, length: { maximum: 255 } + validates :target_amount, presence: true, numericality: { greater_than: 0 } + validates :currency, presence: true + validate :must_have_at_least_one_linked_account + validate :linked_accounts_must_be_depository + validate :linked_accounts_must_match_goal_currency + validate :linked_accounts_must_belong_to_family + validate :currency_locked_once_linked + + monetize :target_amount + + scope :alphabetically, -> { order(Arel.sql("LOWER(name) ASC")) } + scope :active_first, lambda { + order(Arel.sql("CASE state WHEN 'active' THEN 0 WHEN 'paused' THEN 1 WHEN 'completed' THEN 2 ELSE 3 END")) + } + + def self.advisory_lock_key_for(family_id) + Digest::SHA1.hexdigest("goals:family:#{family_id}").to_i(16) % (2**63) + end + + aasm column: :state do + after_all_transitions :reset_state_dependent_caches! + + state :active, initial: true + state :paused + state :completed + state :archived + + event :pause do + transitions from: :active, to: :paused + end + + event :resume do + transitions from: :paused, to: :active + end + + event :complete do + transitions from: [ :active, :paused ], to: :completed + end + + event :archive do + transitions from: [ :active, :paused, :completed ], to: :archived + end + + event :unarchive do + transitions from: :archived, to: :active + end + end + + # Balance is the live balance of every linked depository account that + # matches the goal's currency. The model validates this invariant at + # write time, but defensive filter + telemetry here guards against any + # drift caused by direct DB writes, account-currency edits outside + # goal validation, or future code that bypasses the validation chain. + # v1.1+: minus other goals' allocations via the upcoming GoalBacking + # query. + def current_balance + @current_balance ||= begin + matching = linked_accounts.select { |a| a.currency == currency } + if matching.size != linked_accounts.size + Rails.logger.warn("Goal##{id} linked-account currency drift: #{linked_accounts.size - matching.size} of #{linked_accounts.size} mismatched (expected #{currency})") + Sentry.capture_message("Goal linked-account currency drift", level: :warning, extra: { goal_id: id, expected_currency: currency }) if defined?(Sentry) + end + matching.sum { |a| a.balance.to_d } + end + end + + def current_balance_money + @current_balance_money ||= Money.new(current_balance, currency) + end + + def remaining_amount + @remaining_amount ||= [ target_amount - current_balance, 0 ].max + end + + def remaining_amount_money + @remaining_amount_money ||= Money.new(remaining_amount, currency) + end + + def progress_percent + return @progress_percent if defined?(@progress_percent) + + @progress_percent = if completed? + 100 + elsif target_amount.to_d.zero? + 0 + else + [ ((current_balance.to_d / target_amount.to_d) * 100).round, 100 ].min + end + end + + # Day-precision so the near-deadline cliff doesn't kick in: at + # calendar-month precision, May 30 → June 1 returned 1 ("save $5k this + # month") then June 1 → June 1 returned 0 (falls through to + # "remaining_amount in one month"). Now a 2-day-out deadline reports + # ~0.07 months and `monthly_target_amount` scales accordingly. + def months_remaining + return nil unless target_date + + days = (target_date - Date.current).to_i + [ (days / 30.0), 0.0 ].max + end + + def monthly_target_amount + return @monthly_target_amount if defined?(@monthly_target_amount) + + @monthly_target_amount = if target_date.nil? + nil + elsif months_remaining.zero? + remaining_amount + else + (remaining_amount.to_d / months_remaining.to_d).ceil(2) + end + end + + # 90-day rolling monthly pace: net inflow into linked accounts divided by + # three months. Transfers between linked accounts net to zero (both sides + # land inside this account set). Transfers from outside (e.g. checking + # into linked savings) net positive, which is the behaviour we want: the + # user records a pledge, the transfer arrives, balance goes up, pace + # goes up, status flips off "behind". Excludes user-flagged-excluded + # entries. Entry amount sign convention in Sure: inflow is negative. + def pace + return @pace if defined?(@pace) + + @pace = if linked_accounts.empty? + 0 + else + account_ids = linked_accounts.map(&:id) + net = Entry + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where(account_id: account_ids, date: 90.days.ago.to_date..Date.current) + .where(excluded: false) + .merge(Transaction.excluding_pending) + .sum(:amount) + (-net.to_d / 3).round(2) + end + end + + def pace_money + @pace_money ||= Money.new(pace, currency) + end + + # Months of cash on hand at current pace (open-ended goals). + def months_of_runway + return nil if target_date.present? + return nil if pace.zero? || pace.negative? + + (current_balance.to_d / pace.to_d).round(1) + end + + def to_donut_segments_json + filled = current_balance.to_d + rem = remaining_amount.to_d + + if filled.zero? && rem.zero? + return [ { color: "var(--budget-unused-fill)", amount: 1, id: "unused" } ] + end + + segments = [] + segments << { color: color.presence || "var(--color-blue-500)", amount: filled, id: "saved" } if filled.positive? + segments << { color: "var(--budget-unused-fill)", amount: rem, id: "unused" } if rem.positive? + segments + end + + # 90-day balance trajectory of linked accounts. Used by the projection chart + # to render the saved-to-date line. Returns an empty series when the linked + # account lacks ≥30 days of history. Ships pre-formatted labels for the + # static chart annotations (target line, projection-end / shortfall, + # pending-pledge badge) so the Stimulus controller only has to render + # strings server-side rather than build them with its own Intl calls. + def projection_payload + series_values = balance_series_values + saved_series = series_values.map { |v| { date: v.date.to_s, value: v.value.amount.to_f } } + + earliest = series_values.first&.date || created_at.to_date + target_amt = target_amount.to_d + proj_end = projection_end_amount + + { + saved_series: saved_series, + start_date: earliest.to_s, + today: Date.current.to_s, + target_date: target_date&.to_s, + target_amount: target_amt.to_f, + target_amount_label: Money.new(target_amt, currency).format(precision: 0), + target_amount_short_label: short_money(target_amt, currency), + currency_symbol: Money.new(0, currency).currency.symbol, + current_amount: current_balance.to_f, + avg_monthly: pace.to_f, + required_monthly: monthly_target_amount&.to_f, + currency: currency, + status: status.to_s, + projection_end_value: proj_end.to_f, + projection_end_label: Money.new(proj_end, currency).format(precision: 0), + projection_shortfall_label: (target_amt > proj_end ? Money.new(target_amt - proj_end, currency).format(precision: 0) : nil) + } + end + + # Projected balance at the target_date given the current pace. Mirrors + # the JS calculation so the server can pre-format the chart annotation + # without re-rendering after each Stimulus draw. + def projection_end_amount + return current_balance.to_d if target_date.nil? + months = ((target_date - Date.current).to_f / 30.44).clamp(0.0, Float::INFINITY) + projected = current_balance.to_d + (pace.to_d * months) + [ current_balance.to_d, projected ].max + end + + def display_status + return @display_status if defined?(@display_status) + + @display_status = if archived? + :archived + elsif paused? + :paused + elsif completed? + :completed + else + status + end + end + + # :reached → progress_percent >= 100 + # :on_track → has target_date and pace >= required monthly + # :behind → has target_date and pace < required monthly + # :no_target_date → open-ended + def status + return @status if defined?(@status) + + @status = if progress_percent >= 100 + :reached + elsif target_date.nil? + :no_target_date + elsif monthly_target_amount.to_d <= pace.to_d + :on_track + else + :behind + end + end + + # Date of the most-recently-matched pledge's underlying entry. Used by the + # show header to display "Last saved N days ago". Anchoring on the entry's + # date keeps the readout stable under sync re-runs (which would bump + # pledge#updated_at). Returns nil if no pledge has resolved yet. + def last_matched_pledge_at + return @last_matched_pledge_at if defined?(@last_matched_pledge_at) + + @last_matched_pledge_at = Entry + .where(entryable_type: "Transaction") + .joins("INNER JOIN goal_pledges ON goal_pledges.matched_transaction_id = entries.entryable_id") + .where(goal_pledges: { goal_id: id, status: "matched" }) + .maximum(:date) + end + + def last_matched_pledge_days_ago + last = last_matched_pledge_at + return nil if last.nil? + + (Date.current - last).to_i + end + + # True when any linked account is wired to a live sync provider (Plaid, + # SimpleFIN, or any AccountProvider. Brex, Enable Banking, IBKR, Kraken, + # SnapTrade, Lunchflow). Drives the pledge-create copy: connected accounts + # get the "I just transferred…" path; manual-only accounts get "I just + # saved…" so users aren't told to wait for a sync that won't happen. + def any_connected_account? + linked_accounts.any? { |a| !a.manual? } + end + + # "I just transferred" for bank-connected accounts, "I just saved" for manual-only. + def pledge_action_label_key + any_connected_account? ? "goals.show.pledge_just_transferred" : "goals.show.pledge_just_saved" + end + + # { account_id => palette_hex } for this goal's linked accounts. Stable + # within a goal (so the preview-card avatar stack on the index and the + # funding-widget rows + distribution bar on the show page agree on which + # color belongs to which account) and collision-free up to PALETTE size + # (10 colors). Sort by id so the assignment doesn't shuffle when the + # accounts are re-loaded in a different order. + def account_color_map + @account_color_map ||= begin + palette = Goals::AvatarComponent::PALETTE + linked_accounts.sort_by(&:id).each_with_index.to_h do |account, i| + [ account.id, palette[i % palette.size] ] + end + end + end + + # Single-line state summary rendered between the header and the ring on + # the show page. Replaces the stacked catch-up alert + inline status pill; + # carries the same actionable copy without owning a CTA. Returns nil when + # the projection-side cards already convey state (paused / archived / + # completed / reached) so the callout doesn't double up. + def status_callout_context + return nil if paused? || archived? || completed? || status == :reached + + case status + when :behind + delta = catch_up_delta_money.amount + if delta.positive? + I18n.t("goals.show.status_callout.behind", + amount: catch_up_delta_money.format(precision: 0)) + else + I18n.t("goals.show.status_callout.behind_covered") + end + when :on_track + if target_date && pace.to_d.positive? + months = (remaining_amount.to_d / pace.to_d).ceil + I18n.t("goals.show.status_callout.on_track", + date: I18n.l(Date.current >> months.to_i, format: "%b %Y")) + end + when :no_target_date + I18n.t("goals.show.status_callout.no_target_date") + end + end + + # Header copy under the goal title on show. Used to live as a multi-line + # if/elsif block in show.html.erb. Keeps the view template free of date + # math + i18n key picking. + def header_summary + parts = [] + if target_date + days = (target_date - Date.current).to_i + past_due = days < 0 && !(completed? || status == :reached) + if past_due + parts << I18n.t("goals.show.header.target_by_past", + amount: target_amount_money.format(precision: 0), + date: I18n.l(target_date, format: :long)) + else + parts << I18n.t("goals.show.header.target_by", + amount: target_amount_money.format(precision: 0), + date: I18n.l(target_date, format: :long)) + if days > 0 && !(completed? || status == :reached) + parts << I18n.t("goals.goal_card.days_left", count: days) + end + end + else + parts << I18n.t("goals.show.header.target", + amount: target_amount_money.format(precision: 0)) + end + parts.join(" · ") + end + + # Single source of truth for the projection-chart subtitle / chart-aria + # description. Used to live inline in show.html.erb as a 17-line if/elsif + # chain. Returns an `html_safe` string when it picks the `_html` variant. + def projection_summary + return @projection_summary if defined?(@projection_summary) + + @projection_summary = + if completed? || progress_percent >= 100 + I18n.t("goals.show.projection.reached") + elsif target_date.nil? + I18n.t("goals.show.projection.no_target_date") + elsif monthly_target_amount && pace.to_d < monthly_target_amount.to_d + I18n.t("goals.show.projection.behind") + elsif pace.positive? + months = (remaining_amount.to_d / pace.to_d).ceil + I18n.t( + "goals.show.projection.on_track_html", + date: I18n.l(Date.current >> months.to_i, format: "%b %Y") + ) + else + I18n.t("goals.show.projection.no_pace") + end + end + + # Monthly extra needed beyond the current pace + currently-open pledges + # to hit the target on time. Pending pledges are approximate (one-off + # amounts treated as this-month inflow) but excluding them produced the + # bad case where the alert demanded $X/mo while the user had already + # pledged $X, telling them to act on top of the action they just took. + # Clamps at zero so a fully-covered goal doesn't surface a $0 demand. + def catch_up_delta_money + return Money.new(0, currency) if monthly_target_amount.nil? + + pending = open_pledges.sum(:amount).to_d + delta = [ monthly_target_amount.to_d - pace.to_d - pending, 0 ].max + Money.new(delta, currency) + end + + private + # Cleared after every AASM transition. The state column drives the + # display_status / projection_summary memos; without this the same + # instance keeps returning the pre-transition value if a controller + # calls archive! / pause! and then renders without reload. + def reset_state_dependent_caches! + %i[@display_status @projection_summary].each do |ivar| + remove_instance_variable(ivar) if instance_variable_defined?(ivar) + end + end + + # K/M shorthand for narrow chart annotations (axis ticks, projection + # short-form, pending-pledge badge). Locale-aware currency symbol via + # Money so the chart matches the rest of the app for EUR/GBP families. + def short_money(amount, code) + amount_f = amount.to_f + symbol = Money.new(0, code).currency.symbol + abs = amount_f.abs + if abs >= 1_000_000 + short = (amount_f / 1_000_000.0).round(1) + "#{symbol}#{short == short.to_i ? short.to_i : short}M" + elsif abs >= 1_000 + short = (amount_f / 1_000.0).round(1) + "#{symbol}#{short == short.to_i ? short.to_i : short}K" + else + "#{symbol}#{amount_f.round.to_i.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse}" + end + end + + def balance_series_values + return [] if linked_accounts.empty? + + Balance::ChartSeriesBuilder.new( + account_ids: linked_accounts.map(&:id), + currency: currency, + period: Period.last_90_days + ).balance_series.values + rescue StandardError => e + # Degrade gracefully (chart drops to target-line-only) but surface + # the failure; silent fallbacks here masked real Builder bugs. + Rails.logger.error("Goal##{id} balance series failed: #{e.class}: #{e.message}") + Sentry.capture_exception(e) if defined?(Sentry) + [] + end + + def must_have_at_least_one_linked_account + return unless goal_accounts.reject(&:marked_for_destruction?).empty? + + errors.add(:base, :at_least_one_linked_account_required) + end + + def linked_accounts_must_be_depository + offending = goal_accounts.reject(&:marked_for_destruction?).reject do |sga| + sga.account&.depository? + end + return if offending.empty? + + errors.add(:linked_accounts, :must_be_depository) + end + + def linked_accounts_must_match_goal_currency + return if currency.blank? + + mismatched = goal_accounts.reject(&:marked_for_destruction?).reject do |sga| + sga.account.nil? || sga.account.currency == currency + end + return if mismatched.empty? + + errors.add(:linked_accounts, :currency_mismatch) + end + + def linked_accounts_must_belong_to_family + return if family.nil? + + foreign = goal_accounts.reject(&:marked_for_destruction?).reject do |sga| + sga.account.nil? || sga.account.family_id == family_id + end + return if foreign.empty? + + errors.add(:linked_accounts, :must_belong_to_family) + end + + def currency_locked_once_linked + return unless persisted? && currency_changed? + return unless goal_accounts.where.not(id: nil).exists? + + errors.add(:currency, :locked_after_linked) + end +end diff --git a/app/models/goal_account.rb b/app/models/goal_account.rb new file mode 100644 index 000000000..4ab38bb68 --- /dev/null +++ b/app/models/goal_account.rb @@ -0,0 +1,6 @@ +class GoalAccount < ApplicationRecord + belongs_to :goal + belongs_to :account + + validates :account_id, uniqueness: { scope: :goal_id } +end diff --git a/app/models/goal_pledge.rb b/app/models/goal_pledge.rb new file mode 100644 index 000000000..a72b2cb5b --- /dev/null +++ b/app/models/goal_pledge.rb @@ -0,0 +1,169 @@ +class GoalPledge < ApplicationRecord + include Monetizable + + KINDS = %w[transfer manual_save].freeze + STATUSES = %w[open matched cancelled expired].freeze + + DEFAULT_WINDOW_DAYS = 7 + EXTEND_DAYS = 7 + MATCH_DATE_TOLERANCE_DAYS = 5 + MATCH_AMOUNT_TOLERANCE_ABSOLUTE = BigDecimal("0.50") + MATCH_AMOUNT_TOLERANCE_RATIO = BigDecimal("0.01") + + belongs_to :goal + belongs_to :account + belongs_to :matched_transaction, class_name: "Transaction", optional: true + + enum :kind, KINDS.index_by(&:itself), prefix: :kind + enum :status, STATUSES.index_by(&:itself), prefix: :status + + validates :amount, presence: true, numericality: { greater_than: 0 } + validates :currency, presence: true + validates :expires_at, presence: true + validate :account_must_be_linked_to_goal + validate :currency_matches_goal + validate :no_duplicate_open_pledge, on: :create + + monetize :amount + + # Newest first. Used by the show page to render pending-pledge banners in + # "most-recent on top" order. Not actually chronological; kept for clarity. + scope :reverse_chronological, -> { order(created_at: :desc) } + scope :open_and_expired_now, -> { + where(status: "open").where("expires_at < ?", Time.current) + } + + before_validation :assign_defaults, on: :create + before_destroy :clear_matched_transaction_extra + + # Tolerance check: entry date within [created_at − 5d, expires_at] (so + # extend! widens the upper bound) and amount within ±$0.50 OR ±1%. + # Transfer pledges only fire on inflows (Sure convention: inflow < 0). + # Without this guard, .abs below lets a $200 outflow satisfy a $200 + # transfer pledge as readily as a $200 deposit. + def matches?(entry) + return false unless status_open? + return false unless entry.account_id == account_id + return false if kind_transfer? && !entry.amount.to_d.negative? + + earliest = created_at.to_date - MATCH_DATE_TOLERANCE_DAYS.days + latest = [ created_at.to_date + MATCH_DATE_TOLERANCE_DAYS.days, expires_at.to_date ].max + return false unless entry.date >= earliest && entry.date <= latest + + txn_amount = entry.amount.to_d.abs + pledge_amount = amount.to_d + diff_abs = (txn_amount - pledge_amount).abs + + return true if diff_abs <= MATCH_AMOUNT_TOLERANCE_ABSOLUTE + return true if pledge_amount.positive? && (diff_abs / pledge_amount) <= MATCH_AMOUNT_TOLERANCE_RATIO + + false + end + + def resolve_with!(transaction) + with_lock do + raise NotOpenError, "Pledge no longer open" unless status_open? + + transaction.with_lock do + pledge_id_in_extra = transaction.extra.dig("goal", "pledge_id") + if pledge_id_in_extra.present? && pledge_id_in_extra != id + raise AlreadyClaimedError, "Transaction ##{transaction.id} already claimed by pledge ##{pledge_id_in_extra}" + end + + extra = transaction.extra || {} + extra["goal"] = (extra["goal"] || {}).merge("pledge_id" => id) + transaction.update!(extra: extra) + + update!(status: "matched", matched_transaction_id: transaction.id) + end + end + end + + # Valuation-backed match: no transaction to stamp, just flip the pledge. + def resolve_with_valuation! + with_lock do + raise NotOpenError, "Pledge no longer open" unless status_open? + + update!(status: "matched") + end + end + + class NotOpenError < StandardError; end + # Raised when a Transaction is already claimed by a different open + # pledge. Lets the reconciler distinguish a known race ("another worker + # got there first") from a generic validation failure. + class AlreadyClaimedError < StandardError; end + + def extend!(days: EXTEND_DAYS) + raise NotOpenError, "Only open pledges can be extended" unless status_open? + + update!(expires_at: expires_at + days.days) + end + + def cancel! + raise NotOpenError, "Only open pledges can be cancelled" unless status_open? + + update!(status: "cancelled") + end + + def expire! + return unless status_open? + + update!(status: "expired") + end + + def days_left + return 0 unless status_open? + + delta = ((expires_at - Time.current) / 1.day).ceil + [ delta, 0 ].max + end + + private + def assign_defaults + self.kind ||= "transfer" + self.status ||= "open" + self.expires_at ||= Time.current + DEFAULT_WINDOW_DAYS.days + self.currency ||= goal&.currency + end + + def account_must_be_linked_to_goal + return if goal.nil? || account.nil? + return if goal.goal_accounts.where(account_id: account_id).exists? + + errors.add(:account, :must_be_linked_to_goal) + end + + def currency_matches_goal + return if goal.nil? || currency.blank? + return if currency == goal.currency + + errors.add(:currency, :must_match_goal) + end + + # Guards against a double-click that creates two identical open pledges, + # which would render two yellow banners and leave one orphaned to expiry. + def no_duplicate_open_pledge + return unless goal_id && account_id && amount && status_open? + + exists = GoalPledge + .where(goal_id: goal_id, account_id: account_id, amount: amount, status: "open") + .where("expires_at >= ?", Time.current) + .exists? + + errors.add(:base, :duplicate_open_pledge) if exists + end + + def clear_matched_transaction_extra + return if matched_transaction_id.blank? + + txn = Transaction.find_by(id: matched_transaction_id) + return if txn.nil? + return unless txn.extra.dig("goal", "pledge_id") == id + + new_extra = txn.extra.deep_dup + new_extra["goal"]&.delete("pledge_id") + new_extra.delete("goal") if new_extra["goal"]&.empty? + txn.update!(extra: new_extra) + end +end diff --git a/app/models/goal_pledge/reconciler.rb b/app/models/goal_pledge/reconciler.rb new file mode 100644 index 000000000..f3309dc9e --- /dev/null +++ b/app/models/goal_pledge/reconciler.rb @@ -0,0 +1,66 @@ +class GoalPledge::Reconciler + attr_reader :entry + + def initialize(entry) + @entry = entry + end + + def run + return unless eligible_entry? + return if already_stamped? + + # Older pledges resolve first. Deterministic so "first claim wins" + # under ties doesn't depend on PK ordering (which find_each batches + # by) — relevant the day Sure adopts ULID/UUID PKs. + GoalPledge + .where(account_id: entry.account_id, status: "open", kind: expected_kind) + .where("expires_at >= ?", Time.current) + .order(:created_at, :id) + .each do |pledge| + next unless pledge.matches?(entry) + + begin + if entry.entryable.is_a?(Transaction) + pledge.resolve_with!(entry.transaction) + elsif entry.entryable.is_a?(Valuation) + pledge.resolve_with_valuation! + end + Rails.logger.info("GoalPledge ##{pledge.id} matched entry ##{entry.id}") + return + rescue GoalPledge::NotOpenError, + GoalPledge::AlreadyClaimedError, + ActiveRecord::RecordInvalid, + ActiveRecord::RecordNotUnique => e + # Race vs another worker (this pledge got claimed, or this txn got + # stamped by another pledge). Fall through and try the next pledge. + Rails.logger.warn("GoalPledge ##{pledge.id} match failed: #{e.class}: #{e.message}") + end + end + rescue StandardError => e + # Don't let an unexpected reconcile failure break the importer pipeline + # we're hooked into (ProviderImportAdapter / ReconciliationManager). + # Surface to Sentry so the actual bug doesn't hide behind a warn-level + # log; the inner narrow rescue handles known races without coming + # through here. + Rails.logger.error("GoalPledge::Reconciler failed for entry ##{entry&.id}: #{e.class}: #{e.message}") + Sentry.capture_exception(e) if defined?(Sentry) + end + + private + def eligible_entry? + return false if entry.account_id.blank? + return false if entry.excluded? + + entry.entryable.is_a?(Transaction) || entry.entryable.is_a?(Valuation) + end + + def already_stamped? + return false unless entry.entryable.is_a?(Transaction) + + entry.transaction.extra.dig("goal", "pledge_id").present? + end + + def expected_kind + entry.entryable.is_a?(Valuation) ? "manual_save" : "transfer" + end +end diff --git a/app/views/categories/_color_avatar.html.erb b/app/views/categories/_color_avatar.html.erb index b8cde18ae..8e690c3b8 100644 --- a/app/views/categories/_color_avatar.html.erb +++ b/app/views/categories/_color_avatar.html.erb @@ -1,7 +1,7 @@ <%# locals: (category:) %> <span - data-category-target="avatar" + data-color-icon-picker-target="avatar" class="w-14 h-14 flex items-center justify-center rounded-full" style="background-color: color-mix(in oklab, <%= category.color %> 10%, transparent); color: <%= category.color %>"> <%= icon(category.lucide_icon, size: "2xl", color: "current") %> diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb index 8cc890885..a46284903 100644 --- a/app/views/categories/_form.html.erb +++ b/app/views/categories/_form.html.erb @@ -1,41 +1,46 @@ <%# locals: (category:, categories:) %> -<div data-controller="category" data-category-preset-colors-value="<%= Category::COLORS %>"> +<div data-controller="color-icon-picker" data-color-icon-picker-preset-colors-value="<%= Category::COLORS %>"> <%= styled_form_with model: category, class: "space-y-4" do |f| %> <section class="space-y-4"> <div class="w-fit mx-auto relative"> <%= render partial: "color_avatar", locals: { category: category } %> - <details data-category-target="details" data-action="mousedown->category#handleOutsideClick"> - <summary class="cursor-pointer absolute -bottom-2 -right-2 flex justify-center items-center bg-surface-inset hover:bg-surface-inset-hover border-2 w-7 h-7 border-subdued rounded-full text-secondary"> + <%= render DS::Disclosure.new( + summary_class: "cursor-pointer absolute -bottom-2 -right-2 flex justify-center items-center bg-surface-inset hover:bg-surface-inset-hover border-2 w-7 h-7 border-subdued rounded-full text-secondary", + data: { + color_icon_picker_target: "details", + action: "mousedown->color-icon-picker#handleOutsideClick" + }) do |d| %> + <% d.with_summary_content do %> <%= icon("pen", size: "sm") %> - </summary> + <% end %> - <div class="fixed right-0 sm:right-auto mx-2 sm:ml-8 sm:mr-0 mt-2 z-50 bg-container p-4 border border-alpha-black-25 rounded-2xl shadow-xs h-fit" data-category-target="popup"> - <div class="flex gap-2 flex-col mb-4" data-category-target="selection" style="<%= "display:none;" if @category.subcategory? %>"> - <div data-category-target="pickerSection"></div> + <div class="fixed right-0 sm:right-auto mx-2 sm:ml-8 sm:mr-0 mt-2 z-50 bg-container p-4 border border-alpha-black-25 rounded-2xl shadow-xs h-fit" data-color-icon-picker-target="popup"> + <div class="flex gap-2 flex-col mb-4" data-color-icon-picker-target="selection" style="<%= "display:none;" if @category.subcategory? %>"> + <div data-color-icon-picker-target="pickerSection"></div> <h4 class="text-secondary text-sm"><%= t(".color") %></h4> - <div class="flex flex-wrap md:flex-nowrap gap-2 items-center" data-category-target="colorsSection"> + <div class="flex flex-wrap md:flex-nowrap gap-2 items-center" data-color-icon-picker-target="colorsSection"> <% Category::COLORS.each do |color| %> <label class="relative"> - <%= f.radio_button :color, color, class: "sr-only peer", data: { action: "change->category#handleColorChange" } %> + <%= f.radio_button :color, color, class: "sr-only peer", data: { action: "change->color-icon-picker#handleColorChange" } %> <div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-gray-500" style="background-color: <%= color %>"></div> </label> <% end %> <label class="relative"> - <%= f.radio_button :color, "custom-color", class: "sr-only peer", data: { category_target: "colorPickerRadioBtn"} %> - <div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500" data-category-target="pickerBtn" style="background: conic-gradient(red,orange,yellow,lime,green,teal,cyan,blue,indigo,purple,magenta,pink,red)"></div> + <%= f.radio_button :color, "custom-color", class: "sr-only peer", data: { color_icon_picker_target: "colorPickerRadioBtn"} %> + <div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-blue-500" data-color-icon-picker-target="pickerBtn" style="background: conic-gradient(red,orange,yellow,lime,green,teal,cyan,blue,indigo,purple,magenta,pink,red)"></div> </label> </div> - <div class="flex gap-2 items-center hidden flex-col" data-category-target="paletteSection"> + <div class="flex gap-2 items-center hidden flex-col" data-color-icon-picker-target="paletteSection"> <div class="flex gap-2 items-center w-full"> - <div class="w-6 h-6 p-4 rounded-full cursor-pointer" style="background-color: <%= category.color %>" data-category-target="colorPreview"></div> - <%= f.text_field :color, data: { category_target: "colorInput" }, inline: true, pattern: "^#[0-9A-Fa-f]{6}$" %> - <%= icon "palette", size: "2xl", data: { action: "click->category#toggleSections" } %> + <div class="w-6 h-6 p-4 rounded-full cursor-pointer" style="background-color: <%= category.color %>" data-color-icon-picker-target="colorPreview"></div> + <%= f.text_field :color, data: { color_icon_picker_target: "colorInput" }, inline: true, pattern: "^#[0-9A-Fa-f]{6}$" %> + <%= icon "palette", size: "2xl", data: { action: "click->color-icon-picker#toggleSections" } %> </div> - <div data-category-target="validationMessage" class="hidden self-start flex gap-1 items-center text-xs text-destructive "> + <div data-color-icon-picker-target="validationMessage" class="hidden self-start flex gap-1 items-center text-xs text-destructive "> <span><%= t(".poor_contrast") %></span> - <button type="button" class="underline cursor-pointer" data-action="category#autoAdjust"><%= t(".auto_adjust") %></button> + <button type="button" class="underline cursor-pointer" data-action="color-icon-picker#autoAdjust"><%= t(".auto_adjust") %></button> </div> </div> </div> @@ -45,8 +50,8 @@ <div class="flex flex-wrap gap-0.5 max-h-52 overflow-auto"> <% Category.icon_codes.each do |icon| %> <label class="relative"> - <%= f.radio_button :lucide_icon, icon, class: "sr-only peer", data: { action: "change->category#handleIconChange change->category#handleIconColorChange", category_target:"icon" } %> - <div class="text-secondary w-7 h-7 flex m-0.5 items-center justify-center rounded-full cursor-pointer hover:bg-container-inset-hover peer-checked:bg-container-inset border-1 border-transparent"> + <%= f.radio_button :lucide_icon, icon, class: "sr-only peer", data: { action: "change->color-icon-picker#handleIconChange change->color-icon-picker#handleIconColorChange", color_icon_picker_target:"icon" } %> + <div class="text-secondary w-7 h-7 flex m-0.5 items-center justify-center rounded-full cursor-pointer hover:bg-container-inset-hover peer-checked:bg-container-inset border border-transparent"> <%= icon(icon, size: "sm", color: "current") %> </div> </label> @@ -54,7 +59,7 @@ </div> </div> </div> - </details> + <% end %> </div> <% if category.errors.any? %> @@ -64,7 +69,7 @@ <div class="space-y-2"> <%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, label: t(".name_label"), data: { color_avatar_target: "name" } %> <% unless category.parent? %> - <%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: t(".unassigned"), label: t(".parent_category_label") }, disabled: category.parent?, data: { action: "change->category#handleParentChange" } %> + <%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: t(".unassigned"), label: t(".parent_category_label") }, disabled: category.parent?, data: { action: "change->color-icon-picker#handleParentChange" } %> <% end %> </div> </section> diff --git a/app/views/goal_pledges/new.html.erb b/app/views/goal_pledges/new.html.erb new file mode 100644 index 000000000..ac5767c95 --- /dev/null +++ b/app/views/goal_pledges/new.html.erb @@ -0,0 +1,65 @@ +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(@goal.pledge_action_label_key)) %> + <% dialog.with_body do %> + <% if @pledge.errors.any? %> + <%= render "shared/form_errors", model: @pledge %> + <% end %> + + <% + account_options = @goal.linked_accounts.map do |a| + [ a.name, a.id, { data: { manual: a.manual?.to_s } } ] + end + %> + + <%= styled_form_with model: @pledge, + url: goal_pledges_path(@goal), + class: "space-y-3", + data: { + controller: "goal-pledge-preview", + goal_pledge_preview_current_balance_value: @goal.current_balance.to_f, + goal_pledge_preview_target_amount_value: @goal.target_amount.to_f, + goal_pledge_preview_currency_value: @goal.currency, + goal_pledge_preview_template_zero_value: t(".preview_zero"), + goal_pledge_preview_template_nonzero_value: t(".preview_nonzero"), + goal_pledge_preview_template_reached_value: t(".preview_reached") + } do |f| %> + <p class="text-sm text-secondary" + data-goal-pledge-preview-target="helperConnected" + hidden> + <%= t(".helper_transfer") %> + </p> + <p class="text-sm text-secondary" + data-goal-pledge-preview-target="helperManual" + hidden> + <%= t(".helper_manual") %> + </p> + + <%= f.money_field :amount, + label: t(".amount_label"), + hide_currency: true, + autofocus: true, + required: true, + amount_data: { + goal_pledge_preview_target: "amountInput", + action: "input->goal-pledge-preview#update" + } %> + + <p class="text-xs text-secondary tabular-nums -mt-1 privacy-sensitive" + data-goal-pledge-preview-target="preview"></p> + + <%= f.select :account_id, + options_for_select(account_options, @pledge.account_id), + { label: t(".account_label") }, + { + data: { + goal_pledge_preview_target: "accountSelect", + action: "change->goal-pledge-preview#accountChanged" + } + } %> + + <div class="flex justify-end pt-2"> + <%= f.submit t(".submit") %> + </div> + <% end %> + <% end %> +<% end %> diff --git a/app/views/goals/_color_picker.html.erb b/app/views/goals/_color_picker.html.erb new file mode 100644 index 000000000..fb4d2891a --- /dev/null +++ b/app/views/goals/_color_picker.html.erb @@ -0,0 +1,73 @@ +<%# locals: (form:, colors:, icons:) %> +<div data-controller="color-icon-picker" data-color-icon-picker-preset-colors-value="<%= colors %>"> + <div class="w-fit relative"> + <span class="goal-avatar inline-flex items-center justify-center w-11 h-11 rounded-xl font-semibold text-base" + style="--avatar-color: <%= form.object.color %>;" + data-color-icon-picker-target="avatar" + data-goal-form-target="avatarPreview"> + <% if form.object.icon.present? %> + <%= icon(form.object.icon, color: "current", size: "md") %> + <% elsif form.object.name.present? %> + <%= form.object.name.strip.first&.upcase %> + <% else %> + <%= icon("target", color: "current", size: "md") %> + <% end %> + </span> + + <%= render DS::Disclosure.new( + summary_class: "cursor-pointer absolute -bottom-1 -right-1 flex justify-center items-center bg-surface-inset hover:bg-surface-inset-hover border-2 w-6 h-6 border-subdued rounded-full text-secondary", + data: { + color_icon_picker_target: "details", + action: "mousedown->color-icon-picker#handleOutsideClick" + }) do |d| %> + <% d.with_summary_content do %> + <%= icon("pen", size: "xs") %> + <% end %> + + <div class="absolute top-full left-1/2 -translate-x-1/2 mt-2 z-50 bg-container p-3 border border-alpha-black-25 rounded-2xl shadow-xs w-80 max-w-[calc(100vw-2rem)] max-h-[60vh] overflow-y-auto" + data-color-icon-picker-target="popup"> + <div class="flex gap-2 flex-col mb-3" data-color-icon-picker-target="selection"> + <div data-color-icon-picker-target="pickerSection"></div> + <h4 class="text-secondary text-xs uppercase tracking-wide"><%= t("goals.color_picker.color_heading") %></h4> + <div class="flex flex-wrap gap-2 items-center" data-color-icon-picker-target="colorsSection"> + <% colors.each do |c| %> + <label class="relative"> + <%= form.radio_button :color, c, class: "sr-only peer", data: { action: "change->color-icon-picker#handleColorChange" } %> + <div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-alpha-black-500" style="background-color: <%= c %>"></div> + </label> + <% end %> + <label class="relative"> + <%= form.radio_button :color, "custom-color", class: "sr-only peer", data: { color_icon_picker_target: "colorPickerRadioBtn" } %> + <div class="w-6 h-6 rounded-full cursor-pointer peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-alpha-black-500" data-color-icon-picker-target="pickerBtn" style="background: conic-gradient(red,orange,yellow,lime,green,teal,cyan,blue,indigo,purple,magenta,pink,red)"></div> + </label> + </div> + <div class="flex gap-2 items-center hidden flex-col" data-color-icon-picker-target="paletteSection"> + <div class="flex gap-2 items-center w-full"> + <div class="w-6 h-6 p-4 rounded-full cursor-pointer" style="background-color: <%= form.object.color %>" data-color-icon-picker-target="colorPreview"></div> + <%= form.text_field :color, data: { color_icon_picker_target: "colorInput" }, inline: true, pattern: "^#[0-9A-Fa-f]{6}$" %> + <%= icon "palette", size: "2xl", data: { action: "click->color-icon-picker#toggleSections" } %> + </div> + <div data-color-icon-picker-target="validationMessage" class="hidden self-start flex gap-1 items-center text-xs text-destructive"> + <span><%= t("goals.color_picker.poor_contrast") %></span> + <button type="button" class="underline cursor-pointer" data-action="color-icon-picker#autoAdjust"><%= t("goals.color_picker.auto_adjust") %></button> + </div> + </div> + </div> + + <div class="flex flex-col gap-2"> + <h4 class="text-secondary text-xs uppercase tracking-wide"><%= t("goals.color_picker.icon_heading") %></h4> + <div class="flex flex-wrap gap-0.5 max-h-40 overflow-y-auto scrollbar"> + <% icons.each do |icon_name| %> + <label class="relative"> + <%= form.radio_button :icon, icon_name, class: "sr-only peer", data: { action: "change->color-icon-picker#handleIconChange change->color-icon-picker#handleIconColorChange", color_icon_picker_target: "icon" } %> + <div class="text-secondary w-7 h-7 flex m-0.5 items-center justify-center rounded-full cursor-pointer hover:bg-container-inset-hover peer-checked:bg-container-inset border border-transparent"> + <%= icon(icon_name, size: "sm", color: "current") %> + </div> + </label> + <% end %> + </div> + </div> + </div> + <% end %> + </div> +</div> diff --git a/app/views/goals/_empty_state.html.erb b/app/views/goals/_empty_state.html.erb new file mode 100644 index 000000000..c08260e32 --- /dev/null +++ b/app/views/goals/_empty_state.html.erb @@ -0,0 +1,29 @@ +<%# locals: (linkable_account_count:) %> + +<div class="bg-container rounded-xl shadow-border-xs py-20"> + <div class="flex flex-col items-center text-center max-w-md mx-auto px-6"> + <div class="w-24 h-24 rounded-full bg-surface-inset flex items-center justify-center mb-5 text-secondary"> + <%= icon("target", size: "2xl") %> + </div> + <h2 class="text-lg font-medium text-primary mb-2"><%= t("goals.empty_state.heading") %></h2> + <p class="text-sm text-secondary leading-relaxed mb-5"><%= t("goals.empty_state.body") %></p> + + <% if linkable_account_count > 0 %> + <%= render DS::Link.new( + text: t("goals.empty_state.new_goal"), + variant: "primary", + href: new_goal_path, + icon: "plus", + frame: :modal + ) %> + <% else %> + <p class="text-sm text-secondary mb-3"><%= t("goals.empty_state.no_depository_accounts") %></p> + <%= render DS::Link.new( + text: t("goals.empty_state.add_account"), + variant: "primary", + href: new_account_path(return_to: goals_path), + icon: "plus" + ) %> + <% end %> + </div> +</div> diff --git a/app/views/goals/_form.html.erb b/app/views/goals/_form.html.erb new file mode 100644 index 000000000..ac64c468e --- /dev/null +++ b/app/views/goals/_form.html.erb @@ -0,0 +1,89 @@ +<%# locals: (goal:, linkable_accounts:, currently_linked_account_ids: []) %> + +<div data-controller="goal-form" + data-goal-form-currency-value="<%= Current.family.primary_currency_code %>" + data-goal-form-suggested-with-date-value="<%= t("goals.form.suggested_with_date") %>" + data-goal-form-suggested-no-date-value="<%= t("goals.form.suggested_no_date") %>"> + <% if goal.errors[:base].any? %> + <%= render "shared/form_errors", model: goal %> + <% end %> + + <%= styled_form_with model: goal, + url: goal.persisted? ? goal_path(goal) : goals_path, + method: goal.persisted? ? :patch : :post, + class: "space-y-5" do |f| %> + <div class="flex justify-center"> + <%= render "color_picker", form: f, colors: Goal::COLORS, icons: Goal::ICONS %> + </div> + + <div> + <%= f.text_field :name, + placeholder: t("goals.form.fields.name_placeholder"), + autofocus: true, + required: true, + label: t("goals.form.fields.name"), + data: { goal_form_target: "nameInput", action: "input->goal-form#nameChanged" } %> + <p class="hidden mt-1.5 text-xs text-destructive" data-goal-form-target="nameError"><%= t("goals.form.errors.name_required") %></p> + </div> + + <div class="grid grid-cols-2 gap-3"> + <div> + <%= f.money_field :target_amount, + label: t("goals.form.fields.target_amount"), + hide_currency: true, + required: true, + amount_data: { goal_form_target: "amountInput", action: "input->goal-form#suggestedChanged" } %> + <p class="hidden mt-1.5 text-xs text-destructive" data-goal-form-target="amountError"><%= t("goals.form.errors.amount_required") %></p> + </div> + <%= f.date_field :target_date, + label: t("goals.form.fields.target_date"), + data: { goal_form_target: "dateInput", action: "input->goal-form#suggestedChanged" } %> + </div> + + <p class="text-xs text-secondary italic tabular-nums privacy-sensitive hidden" data-goal-form-target="suggested"></p> + + <div> + <div class="mb-2"> + <span class="block text-sm font-medium text-primary"><%= t("goals.form.fields.funding_accounts") %></span> + <p class="text-xs text-secondary mt-0.5"><%= t("goals.form.fields.funding_accounts_hint") %></p> + </div> + <div class="bg-container-inset rounded-lg p-1"> + <% grouped = linkable_accounts.group_by { |a| a.subtype.to_s.presence || "other" } %> + <% grouped.each_with_index do |(subtype, accts), group_idx| %> + <div class="px-3 py-2 text-[11px] font-medium uppercase tracking-wide text-secondary"><%= t("goals.form.subtypes.#{subtype}", default: subtype.titleize) %></div> + <div class="bg-container rounded-md <%= "mb-1" if group_idx < grouped.size - 1 %>"> + <% accts.each_with_index do |account, idx| %> + <label class="flex items-center gap-3 px-3 py-2.5 cursor-pointer hover:bg-surface-hover <%= "border-t border-subdued" if idx > 0 %>"> + <%= check_box_tag "goal[account_ids][]", + account.id, + currently_linked_account_ids.include?(account.id.to_s), + id: "goal_account_ids_#{account.id}", + class: "checkbox checkbox--light shrink-0", + data: { + goal_form_target: "linkedAccountCheckbox", + action: "change->goal-form#linkedAccountChanged" + } %> + <%= render Goals::AvatarComponent.new(name: account.name, color: Goals::AvatarComponent.color_for(account.name), size: "md") %> + <div class="flex-1 min-w-0"> + <p class="text-sm font-medium text-primary truncate"><%= account.name %></p> + <p class="text-xs text-secondary"><%= (account.subtype || subtype).titleize %></p> + </div> + <span class="text-sm text-primary tabular-nums privacy-sensitive"><%= Money.new(account.balance, account.currency).format %></span> + </label> + <% end %> + </div> + <% end %> + </div> + <p class="hidden mt-1.5 text-xs text-destructive" data-goal-form-target="accountsError"><%= t("goals.form.errors.accounts_required") %></p> + </div> + + <%= f.text_area :notes, + label: t("goals.form.fields.notes"), + rows: 2, + placeholder: t("goals.form.fields.notes_placeholder") %> + + <div class="flex justify-end pt-2"> + <%= f.submit goal.persisted? ? t("goals.form.save") : t("goals.form.create") %> + </div> + <% end %> +</div> diff --git a/app/views/goals/_pending_pledge_banner.html.erb b/app/views/goals/_pending_pledge_banner.html.erb new file mode 100644 index 000000000..0a0049083 --- /dev/null +++ b/app/views/goals/_pending_pledge_banner.html.erb @@ -0,0 +1,41 @@ +<% + account = pledge.account + amount_money = pledge.amount_money + days_left = pledge.days_left + body_key = pledge.kind_transfer? ? "goals.show.pending_pledge.body_transfer" : "goals.show.pending_pledge.body_manual" + title = t("goals.show.pending_pledge.title", amount: amount_money.format(precision: 0), account: account.name, count: days_left) +%> +<div class="rounded-lg bg-surface-inset px-3 py-2.5 text-sm" role="status" aria-live="polite"> + <div class="flex items-start gap-2.5"> + <span class="text-secondary shrink-0 mt-0.5"><%= icon("info", size: "sm") %></span> + <div class="flex-1 min-w-0"> + <p class="text-primary"> + <span class="font-medium privacy-sensitive"><%= title %></span> + <span class="text-secondary">· <%= t("goals.show.pending_pledge.pledged_at", time_ago: time_ago_in_words(pledge.created_at)) %></span> + </p> + <p class="text-xs text-subdued mt-0.5"><%= t(body_key) %></p> + <div class="mt-2 flex items-center gap-2 flex-wrap"> + <%= render DS::Button.new( + text: t("goals.show.pending_pledge.extend"), + href: renew_goal_pledge_path(pledge.goal, pledge), + method: :patch, + variant: "outline", + size: "sm" + ) %> + <%= render DS::Button.new( + text: t("goals.show.pending_pledge.cancel"), + href: goal_pledge_path(pledge.goal, pledge), + method: :delete, + variant: "ghost", + size: "sm", + confirm: CustomConfirm.new( + destructive: true, + title: t("goals.show.pending_pledge.confirm_cancel_title"), + body: t("goals.show.pending_pledge.confirm_cancel_body", amount: amount_money.format(precision: 0)), + btn_text: t("goals.show.pending_pledge.confirm_cancel_cta") + ) + ) %> + </div> + </div> + </div> +</div> diff --git a/app/views/goals/_status_callout.html.erb b/app/views/goals/_status_callout.html.erb new file mode 100644 index 000000000..a2c609ad3 --- /dev/null +++ b/app/views/goals/_status_callout.html.erb @@ -0,0 +1,28 @@ +<% + context = goal.status_callout_context + return if context.blank? + + variant_classes = case goal.status + when :behind + "bg-warning/10 border-warning/20 text-warning" + when :on_track + "bg-success/10 border-success/20 text-success" + else + "bg-surface-inset border-secondary text-secondary" + end + + icon_glyph = case goal.status + when :behind then "triangle-alert" + when :on_track then "circle-check" + when :no_target_date then "infinity" + else "info" + end + + label = t("goals.status.#{goal.status}", default: goal.status.to_s.titleize) +%> +<div class="rounded-lg border px-3 py-2 text-sm flex items-center gap-2 <%= variant_classes %>"> + <span class="shrink-0"><%= icon(icon_glyph, size: "sm") %></span> + <span class="font-medium"><%= label %></span> + <span class="opacity-60">·</span> + <span class="opacity-90 <%= "privacy-sensitive" if goal.status == :behind %>"><%= context %></span> +</div> diff --git a/app/views/goals/edit.html.erb b/app/views/goals/edit.html.erb new file mode 100644 index 000000000..de4628c7c --- /dev/null +++ b/app/views/goals/edit.html.erb @@ -0,0 +1,6 @@ +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".heading")) %> + <% dialog.with_body do %> + <%= render "form", goal: @goal, linkable_accounts: @linkable_accounts, currently_linked_account_ids: @currently_linked_account_ids %> + <% end %> +<% end %> diff --git a/app/views/goals/index.html.erb b/app/views/goals/index.html.erb new file mode 100644 index 000000000..3c3e69b95 --- /dev/null +++ b/app/views/goals/index.html.erb @@ -0,0 +1,191 @@ +<div class="space-y-8 pb-6 lg:pb-12"> + <header> + <div class="flex items-center gap-2"> + <h1 class="text-2xl font-semibold text-primary"><%= t(".title") %></h1> + <%= render DS::Pill.new(label: t("shared.preview"), size: :md) %> + </div> + <p class="text-sm text-secondary mt-1"><%= t(".subtitle") %></p> + </header> + + <% if @counts["all"].zero? %> + <%= render "empty_state", linkable_account_count: @linkable_account_count %> + <% else %> + <%# KPI strip. Contributed last 30d (with prior-30d comparison) / Needs / On track %> + <section class="grid grid-cols-1 md:grid-cols-3 gap-3"> + <div class="bg-container rounded-xl shadow-border-xs px-5 py-5"> + <p class="text-[11px] font-medium uppercase tracking-wide text-secondary"><%= t(".kpi.contributed_label") %></p> + <p class="text-3xl font-medium text-primary tabular-nums mt-2 privacy-sensitive"> + <%= @kpi[:velocity_30d_sign] %><%= @kpi[:velocity_30d_money].format %> + </p> + <% if @kpi[:velocity_direction] == :flat %> + <p class="text-xs text-secondary mt-1 tabular-nums"> + <% if @kpi[:velocity_prior_30d_money].zero? && @kpi[:velocity_30d_money].zero? %> + <%= t(".kpi.velocity_delta_zero_base") %> + <% else %> + <%= t(".kpi.velocity_delta_flat") %> + <% end %> + </p> + <% elsif @kpi[:velocity_delta_percent].nil? %> + <p class="text-xs text-success mt-1 tabular-nums"><%= t(".kpi.velocity_delta_zero_base") %></p> + <% else %> + <p class="text-xs <%= @kpi[:velocity_direction] == :up ? "text-success" : "text-destructive" %> mt-1 tabular-nums"> + <%= t(".kpi.velocity_delta_#{@kpi[:velocity_direction]}", percent: @kpi[:velocity_delta_percent].abs) %> + </p> + <% end %> + </div> + + <div class="bg-container rounded-xl shadow-border-xs px-5 py-5"> + <p class="text-[11px] font-medium uppercase tracking-wide text-secondary"><%= t(".kpi.needs_this_month_label") %></p> + <p class="text-3xl font-medium text-primary tabular-nums mt-2 privacy-sensitive"><%= @kpi[:needs_this_month_money].format %></p> + <p class="text-xs text-secondary mt-1"> + <% if @kpi[:behind_count].zero? %> + <%= t(".kpi.needs_this_month_zero_sub") %> + <% else %> + <%= t(".kpi.needs_this_month_sub", count: @kpi[:behind_count]) %> + <% end %> + </p> + </div> + + <div class="bg-container rounded-xl shadow-border-xs px-5 py-5"> + <p class="text-[11px] font-medium uppercase tracking-wide text-secondary"><%= t(".kpi.on_track_label") %></p> + <% if @kpi[:tracked_total].zero? && @kpi[:reached_count].positive? %> + <%# All active goals already hit their target — fraction would + read "0 of 0" or paper over success. Swap to a celebratory + empty state instead. %> + <p class="text-3xl font-medium text-primary mt-2"> + <%= t(".kpi.on_track_all_caught_up") %> + </p> + <p class="text-xs text-secondary mt-1"> + <%= t(".kpi.on_track_sub_parts.reached", count: @kpi[:reached_count]) %> + </p> + <% else %> + <p class="text-3xl font-medium text-primary tabular-nums mt-2"> + <%= t(".kpi.on_track_value", on_track: @kpi[:on_track_count], total: @kpi[:tracked_total]) %> + </p> + <p class="text-xs text-secondary mt-1"> + <% + # Reached goals are intentionally absent from the subline when + # the fraction is calculable. They no longer count toward pace + # tracking, and surfacing "N reached" next to "X of Y" muddied + # the message (the fraction is a needle, "N reached" is a + # trophy. different signals). + parts = [] + parts << t(".kpi.on_track_sub_parts.behind", count: @kpi[:behind_count]) if @kpi[:behind_count].positive? + parts << t(".kpi.on_track_sub_parts.no_date", count: @kpi[:no_date_count]) if @kpi[:no_date_count].positive? + parts << t(".kpi.on_track_sub_parts.paused", count: @kpi[:paused_count]) if @kpi[:paused_count].positive? + %> + <% if parts.any? %> + <%= parts.join(" · ") %> + <% else %> + <%= t(".kpi.on_track_sub_all_good") %> + <% end %> + </p> + <% end %> + </div> + </section> + + <% if @any_pending_pledge %> + <%= render DS::Alert.new(variant: "info", message: t(".pending_pledges_callout"), live: :polite) %> + <% end %> + + <%# Goals section %> + <section data-controller="goals-filter" + data-goals-filter-empty-query-value="<%= t(".search.empty_with_query", query: "__QUERY__") %>" + data-goals-filter-empty-filter-value="<%= t(".search.empty_with_filter") %>" + data-goals-filter-empty-both-value="<%= t(".search.empty_with_both", query: "__QUERY__") %>" + data-goals-filter-empty-default-value="<%= t(".search.empty") %>"> + <% if @linkable_account_count > 0 %> + <div class="flex items-center justify-end mb-3"> + <%= render DS::Link.new( + text: t(".new_goal"), + variant: "primary", + href: new_goal_path, + icon: "plus", + frame: :modal + ) %> + </div> + <% end %> + + <% if @show_search %> + <div class="flex flex-wrap items-center gap-2.5 mb-4"> + <%= render DS::SearchInput.new( + aria_label: t(".search.aria_label"), + placeholder: t(".search.placeholder"), + class: "flex-1 min-w-[200px]", + data: { + goals_filter_target: "input", + action: "input->goals-filter#filter" + } + ) %> + <div class="inline-flex items-center gap-1 p-1 bg-surface-inset rounded-xl"> + <% %w[all on_track behind no_target_date paused completed].each do |status| %> + <% active = status == "all" %> + <button type="button" + data-goals-filter-target="chip" + data-action="click->goals-filter#selectChip" + data-status="<%= status %>" + aria-pressed="<%= active %>" + class="px-2.5 py-1 text-xs font-medium rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-100 <%= active ? "bg-container shadow-border-xs text-primary" : "text-secondary" %>"> + <%= t(".chips.#{status}") %> + </button> + <% end %> + </div> + </div> + <% end %> + + <% if @grid_goals.any? %> + <div class="flex items-center gap-1.5 mb-4 text-[11px] font-medium uppercase tracking-wide text-secondary"> + <span><%= t(".ongoing_section.heading") %></span> + <span class="text-subdued">·</span> + <span class="tabular-nums" data-goals-filter-target="count"><%= @grid_goals.size %></span> + </div> + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5" data-goals-filter-target="grid"> + <% @grid_goals.each do |goal| %> + <%= render Goals::CardComponent.new(goal: goal) %> + <% end %> + </div> + <div class="hidden bg-container rounded-xl shadow-border-xs py-10 text-center" data-goals-filter-target="empty"> + <p class="text-sm text-secondary" data-goals-filter-target="emptyCopy"><%= t(".search.empty") %></p> + <div class="mt-3 flex items-center justify-center gap-2"> + <button type="button" + class="hidden text-xs font-medium text-secondary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-100" + data-goals-filter-target="emptyClearSearch" + data-action="click->goals-filter#clearSearch"> + <%= t(".search.clear_search") %> + </button> + <button type="button" + class="hidden text-xs font-medium text-secondary underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-alpha-black-100" + data-goals-filter-target="emptyClearFilter" + data-action="click->goals-filter#clearFilter"> + <%= t(".search.show_all") %> + </button> + </div> + </div> + <% else %> + <div class="bg-container rounded-xl shadow-border-xs py-12 text-center"> + <p class="text-sm text-secondary"><%= t(".empty_filtered") %></p> + </div> + <% end %> + </section> + + <% if @archived_goals.any? %> + <section> + <%= render DS::Disclosure.new(variant: :inline) do |disclosure| %> + <% disclosure.with_summary_content do %> + <span class="inline-flex items-center gap-1.5 mb-4 text-[11px] font-medium uppercase tracking-wide text-secondary"> + <span class="text-subdued group-open:rotate-90 motion-safe:transition-transform"><%= icon("chevron-right", size: "sm") %></span> + <span><%= t(".archived_section.heading") %></span> + <span class="text-subdued">·</span> + <span class="tabular-nums"><%= @archived_goals.size %></span> + </span> + <% end %> + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5"> + <% @archived_goals.each do |goal| %> + <%= render Goals::CardComponent.new(goal: goal, filterable: false) %> + <% end %> + </div> + <% end %> + </section> + <% end %> + <% end %> +</div> diff --git a/app/views/goals/new.html.erb b/app/views/goals/new.html.erb new file mode 100644 index 000000000..fb4162f45 --- /dev/null +++ b/app/views/goals/new.html.erb @@ -0,0 +1,30 @@ +<% if turbo_frame_request? %> + <%= render DS::Dialog.new(width: "lg") do |dialog| %> + <% dialog.with_header(custom_header: true) do %> + <div class="flex items-start justify-between gap-3"> + <div class="flex items-start gap-3"> + <%= render DS::FilledIcon.new(variant: :container, icon: "target", size: "md", rounded: true) %> + <div> + <h2 class="text-base font-medium text-primary"><%= t(".heading") %></h2> + <p class="text-sm text-secondary mt-0.5"><%= t(".subtitle") %></p> + </div> + </div> + <%= render DS::Button.new(variant: "icon", icon: "x", title: t("common.close"), aria_label: t("common.close"), data: { action: "DS--dialog#close" }) %> + </div> + <% end %> + <% dialog.with_body do %> + <%= render "form", goal: @goal, linkable_accounts: @linkable_accounts %> + <% end %> + <% end %> +<% else %> + <div class="max-w-2xl mx-auto py-8 px-4"> + <header class="mb-6 flex items-start gap-3"> + <%= render DS::FilledIcon.new(variant: :container, icon: "target", size: "md", rounded: true) %> + <div> + <h1 class="text-xl font-medium text-primary"><%= t(".heading") %></h1> + <p class="text-sm text-secondary mt-0.5"><%= t(".subtitle") %></p> + </div> + </header> + <%= render "form", goal: @goal, linkable_accounts: @linkable_accounts %> + </div> +<% end %> diff --git a/app/views/goals/show.html.erb b/app/views/goals/show.html.erb new file mode 100644 index 000000000..21bc8ec22 --- /dev/null +++ b/app/views/goals/show.html.erb @@ -0,0 +1,261 @@ +<div class="space-y-4 pb-6 lg:pb-12"> + <header class="flex items-start gap-3 sm:gap-4"> + <div class="hidden sm:block"> + <%= render Goals::AvatarComponent.new(goal: @goal, size: "xl") %> + </div> + <div class="min-w-0 flex-1"> + <h1 class="text-2xl font-semibold text-primary break-words"><%= @goal.name %></h1> + <p class="text-sm text-secondary mt-1 privacy-sensitive"><%= @goal.header_summary %></p> + <% last_days = @goal.last_matched_pledge_days_ago %> + <% unless last_days.nil? %> + <p class="text-xs text-subdued mt-0.5"> + <%= last_days.zero? ? t("goals.goal_card.footer_last_today") : t("goals.goal_card.footer_last_days", count: last_days) %> + </p> + <% end %> + </div> + <div class="shrink-0"> + <%= render DS::Menu.new do |menu| %> + <%# Edit lives in the kebab, matching the rest of Sure (accounts, + categories, rules, family_merchants, chats, transactions all + put their Edit action inside the kebab dropdown). Keeps the + header to one primary action. %> + <% menu.with_item(variant: "link", text: t(".edit"), icon: "pencil", href: edit_goal_path(@goal), data: { turbo_frame: :modal }) %> + <% if @goal.may_pause? %> + <% menu.with_item(variant: "button", text: t(".pause"), icon: "pause", href: pause_goal_path(@goal), method: :patch) %> + <% end %> + <% if @goal.may_resume? %> + <% menu.with_item(variant: "button", text: t(".resume"), icon: "play", href: resume_goal_path(@goal), method: :patch) %> + <% end %> + <% if @goal.may_complete? %> + <% complete_body = if @goal.progress_percent < 100 + t(".confirm_complete_body_short", + progress: @goal.progress_percent, + saved: @goal.current_balance_money.format(precision: 0), + target: @goal.target_amount_money.format(precision: 0)) + else + t(".confirm_complete_body") + end %> + <% menu.with_item( + variant: "button", + text: t(".complete"), + icon: "circle-check-big", + href: complete_goal_path(@goal), + method: :patch, + confirm: CustomConfirm.new( + title: t(".confirm_complete_title"), + body: complete_body, + btn_text: t(".confirm_complete_cta") + ) + ) %> + <% end %> + <% if @goal.may_archive? %> + <% menu.with_item( + variant: "button", + text: t(".archive"), + icon: "archive", + href: archive_goal_path(@goal), + method: :patch, + confirm: CustomConfirm.new( + title: t(".confirm_archive_title"), + body: t(".confirm_archive_body"), + btn_text: t(".confirm_archive_cta") + ) + ) %> + <% end %> + <% if @goal.may_unarchive? %> + <% menu.with_item(variant: "button", text: t(".unarchive"), icon: "archive-restore", href: unarchive_goal_path(@goal), method: :patch) %> + <% end %> + <% if @goal.archived? %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: goal_path(@goal), + method: :delete, + destructive: true, + confirm: CustomConfirm.for_resource_deletion(@goal.name, high_severity: true) + ) %> + <% end %> + <% end %> + </div> + </header> + + <%= render "status_callout", goal: @goal %> + + <% @open_pledges.each do |pledge| %> + <%= render "pending_pledge_banner", pledge: pledge %> + <% end %> + + <% if @goal.paused? %> + <%= render DS::Alert.new(variant: "info", title: t("goals.show.paused_banner.title")) do %> + <p class="text-secondary"><%= t("goals.show.paused_banner.body") %></p> + <div class="mt-2"> + <%= render DS::Button.new( + text: t("goals.show.paused_banner.resume_cta"), + href: resume_goal_path(@goal), + variant: "primary", + size: "sm", + method: :patch + ) %> + </div> + <% end %> + <% elsif @goal.archived? %> + <%= render DS::Alert.new(variant: "info", title: t("goals.show.archived_banner.title")) do %> + <p class="text-secondary"><%= t("goals.show.archived_banner.body") %></p> + <% if @goal.may_unarchive? %> + <div class="mt-2"> + <%= render DS::Button.new( + text: t("goals.show.archived_banner.restore_cta"), + href: unarchive_goal_path(@goal), + variant: "primary", + size: "sm", + method: :patch + ) %> + </div> + <% end %> + <% end %> + <% end %> + + <%# Top row: ring panel (status, no elevation) + projection chart card %> + <section class="grid grid-cols-1 lg:grid-cols-[320px_minmax(0,1fr)] gap-3"> + <div class="rounded-xl p-5 flex flex-col items-center justify-center text-center"> + <%= render Goals::ProgressRingComponent.new(goal: @goal, size: 180) %> + <p class="text-xl font-medium text-primary tabular-nums privacy-sensitive mt-4"><%= @goal.current_balance_money.format(precision: 0) %></p> + <% unless @goal.completed? %> + <p class="text-xs text-subdued tabular-nums mt-0.5 privacy-sensitive"><%= t(".ring.to_go", amount: @goal.remaining_amount_money.format(precision: 0)) %></p> + <% end %> + <% unless @goal.completed? || @goal.status == :reached || @goal.paused? || @goal.archived? %> + <%# Single Record pledge entry point on the page. Pre-filled with the + catch-up delta when behind so accepting once funds the gap. %> + <% prefill_amount = @goal.status == :behind && @goal.catch_up_delta_money.amount.positive? ? @goal.catch_up_delta_money.amount.to_f : nil %> + <div class="mt-4"> + <%= render DS::Link.new( + text: t(".record_pledge_cta"), + variant: "primary", + size: "sm", + href: new_goal_pledge_path(@goal, amount: prefill_amount), + icon: "plus", + frame: :modal + ) %> + </div> + <% end %> + </div> + + <% if @goal.archived? || @goal.paused? %> + <div class="bg-container rounded-xl shadow-border-xs p-5 flex flex-col items-center justify-center text-center"> + <div class="w-16 h-16 rounded-full bg-surface-inset inline-flex items-center justify-center text-secondary mb-3"> + <%= icon(@goal.archived? ? "archive" : "pause", size: "2xl") %> + </div> + <h2 class="text-lg font-semibold text-primary"> + <%= t(@goal.archived? ? ".inactive.heading_archived" : ".inactive.heading_paused") %> + </h2> + <p class="text-sm text-secondary mt-1 max-w-md tabular-nums privacy-sensitive"> + <%= t(".inactive.body", saved: @goal.current_balance_money.format(precision: 0), target: @goal.target_amount_money.format(precision: 0)) %> + </p> + </div> + <% elsif @goal.completed? || @goal.status == :reached %> + <div class="bg-container rounded-xl shadow-border-xs p-5 flex flex-col items-center justify-center text-center"> + <div class="w-16 h-16 rounded-full bg-success/10 inline-flex items-center justify-center text-success mb-3"> + <%= icon("party-popper", size: "2xl", color: "success") %> + </div> + <h2 class="text-lg font-semibold text-primary"><%= t(".celebration.heading") %></h2> + <p class="text-sm text-secondary mt-1 max-w-md privacy-sensitive"><%= t(".celebration.body", saved: @goal.current_balance_money.format(precision: 0), target: @goal.target_amount_money.format(precision: 0)) %></p> + <% if @goal.may_archive? %> + <div class="mt-4"> + <%= render DS::Button.new( + text: t(".celebration.archive_cta"), + href: archive_goal_path(@goal), + variant: "outline", + size: "sm", + method: :patch + ) %> + </div> + <% end %> + </div> + <% elsif @goal.current_balance.to_d.zero? && @goal.pace.to_d.zero? %> + <%# No movement yet on the linked account: render an inline "make your first transfer" + CTA card instead of a flat-at-$0 chart that looks broken. %> + <div class="bg-container rounded-xl shadow-border-xs p-5 flex flex-col items-center justify-center text-center"> + <div class="w-16 h-16 rounded-full bg-surface-inset inline-flex items-center justify-center text-secondary mb-3"> + <%= icon("piggy-bank", size: "2xl") %> + </div> + <h2 class="text-lg font-semibold text-primary"><%= t(".empty.heading") %></h2> + <p class="text-sm text-secondary mt-1 max-w-md"><%= t(".empty.body") %></p> + <div class="mt-4"> + <%= render DS::Link.new( + text: t(".record_pledge_cta"), + variant: "primary", + size: "sm", + href: new_goal_pledge_path(@goal), + icon: "plus", + frame: :modal + ) %> + </div> + </div> + <% else %> + <div class="bg-container rounded-xl shadow-border-xs p-5 flex flex-col"> + <div class="flex flex-col gap-2 mb-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3"> + <div class="min-w-0"> + <h2 class="text-sm font-medium text-primary"><%= t(".projection.heading") %></h2> + <p class="text-xs text-secondary mt-0.5"><%= sanitize @goal.projection_summary %></p> + <% if @goal.status == :behind && @goal.monthly_target_amount %> + <p class="text-xs text-secondary mt-0.5 tabular-nums privacy-sensitive"> + <%= t("goals.show.catch_up.body", avg: @goal.pace_money.format(precision: 0), required: Money.new(@goal.monthly_target_amount, @goal.currency).format(precision: 0)) %> + </p> + <% end %> + </div> + <% projection_color = @goal.status == :on_track ? "var(--color-green-600)" : "var(--color-yellow-600)" %> + <div class="flex items-center flex-wrap gap-x-5 gap-y-1 text-[11px] text-secondary sm:gap-x-3 sm:shrink-0"> + <span class="inline-flex items-center gap-1.5"> + <svg width="18" height="6" class="text-primary"><line x1="0" y1="3" x2="18" y2="3" stroke="currentColor" stroke-width="2" /></svg> + <%= t(".projection.legend_saved") %> + </span> + <% unless @goal.target_date.nil? %> + <span class="inline-flex items-center gap-1.5"> + <svg width="18" height="6"><line x1="0" y1="3" x2="18" y2="3" stroke="<%= projection_color %>" stroke-width="2" stroke-dasharray="3 3" /></svg> + <%= t(".projection.legend_projection") %> + </span> + <% if @goal.monthly_target_amount.to_d.positive? && @goal.remaining_amount.to_d.positive? %> + <span class="inline-flex items-center gap-1.5"> + <svg width="18" height="6" class="text-secondary"><line x1="0" y1="3" x2="18" y2="3" stroke="currentColor" stroke-width="2" stroke-dasharray="2 4" opacity="0.5" /></svg> + <%= t(".projection.legend_required") %> + </span> + <% end %> + <% end %> + </div> + </div> + <div class="flex-1 min-h-[200px]" + data-controller="goal-projection-chart" + data-goal-projection-chart-data-value="<%= @goal.projection_payload.to_json %>" + data-goal-projection-chart-aria-label-value="<%= t("goals.show.projection.aria_label", name: @goal.name) %>" + data-goal-projection-chart-aria-description-value="<%= strip_tags(@goal.projection_summary) %>" + data-goal-projection-chart-today-label-value="<%= t("goals.show.projection.today_marker") %>" + data-goal-projection-chart-projected-template-value="<%= t("goals.show.projection.tooltip_projected", amount: "{amount}") %>" + data-goal-projection-chart-saved-template-value="<%= t("goals.show.projection.tooltip_saved", amount: "{amount}") %>"></div> + <% if @goal.target_date.nil? %> + <div class="mt-3 flex items-center gap-2 text-xs text-secondary"> + <span class="text-subdued"><%= icon("calendar-plus", size: "sm") %></span> + <span class="flex-1"><%= t(".no_target_date.body") %></span> + <%= link_to t(".no_target_date.cta"), + edit_goal_path(@goal), + data: { turbo_frame: :modal }, + class: "font-medium text-primary underline-offset-2 hover:underline" %> + </div> + <% end %> + </div> + <% end %> + </section> + + <% if @goal.linked_accounts.any? %> + <section class="bg-container rounded-xl shadow-border-xs p-5"> + <%= render Goals::FundingAccountsBreakdownComponent.new(goal: @goal) %> + </section> + <% end %> + + <% if @goal.notes.present? %> + <section class="bg-container rounded-xl shadow-border-xs p-5"> + <h2 class="text-sm font-medium text-primary mb-2"><%= t(".notes") %></h2> + <p class="text-sm text-secondary whitespace-pre-line"><%= @goal.notes %></p> + </section> + <% end %> +</div> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index cffcc2c7f..39077cc6a 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -11,8 +11,9 @@ else { name: t(".nav.transactions"), path: transactions_path, icon: "credit-card", icon_custom: false, active: page_active?(transactions_path) }, { name: t(".nav.reports"), path: reports_path, icon: "chart-bar", icon_custom: false, active: page_active?(reports_path) }, { name: t(".nav.budgets"), path: budgets_path, icon: "map", icon_custom: false, active: page_active?(budgets_path) }, + preview_gated_nav_item({ name: t(".nav.goals"), path: goals_path, icon: "piggy-bank", icon_custom: false, active: page_active?(goals_path) }), { name: t(".nav.assistant"), path: chats_path, icon: "icon-assistant", icon_custom: true, active: page_active?(chats_path), mobile_only: true } - ] + ].compact end %> <% desktop_nav_items = mobile_nav_items.reject { |item| item[:mobile_only] } %> diff --git a/app/views/layouts/shared/_nav_item.html.erb b/app/views/layouts/shared/_nav_item.html.erb index 3c700e184..b7b226c42 100644 --- a/app/views/layouts/shared/_nav_item.html.erb +++ b/app/views/layouts/shared/_nav_item.html.erb @@ -1,14 +1,19 @@ -<%# locals:(name:, path:, icon:, icon_custom:, active:, mobile_only: false) %> +<%# locals: (name:, path:, icon:, icon_custom:, active:, mobile_only: false, preview: false) %> <%= link_to path, class: "space-y-1 group block relative pb-1", aria: { current: ("page" if active) } do %> <div class="grow flex flex-col lg:flex-row gap-1 items-center"> <%= tag.div class: class_names("w-4 h-1 lg:w-1 lg:h-4 rounded-bl-sm rounded-br-sm lg:rounded-tr-sm lg:rounded-br-sm lg:rounded-bl-none", "bg-nav-indicator" => active) %> <%= tag.div class: class_names( - "w-8 h-8 flex items-center justify-center mx-auto rounded-lg", + "w-8 h-8 flex items-center justify-center mx-auto rounded-lg relative", active ? "bg-container shadow-xs text-primary" : "group-hover:bg-surface-hover text-secondary" ) do %> <%= icon(icon, color: active ? "current" : "default", custom: icon_custom) %> + <% if preview %> + <span class="absolute -top-0.5 -right-0.5"> + <%= render DS::Pill.new(tone: :violet, dot_only: true, title: t("shared.preview")) %> + </span> + <% end %> <% end %> </div> diff --git a/config/locales/models/goal/en.yml b/config/locales/models/goal/en.yml new file mode 100644 index 000000000..1df923657 --- /dev/null +++ b/config/locales/models/goal/en.yml @@ -0,0 +1,25 @@ +--- +en: + activerecord: + attributes: + goal: + name: Name + target_amount: Target amount + currency: Currency + target_date: Target date + color: Color + notes: Notes + state: State + linked_accounts: Linked accounts + errors: + models: + goal: + attributes: + base: + at_least_one_linked_account_required: Pick at least one depository account to fund this goal. + linked_accounts: + must_be_depository: All linked accounts must be depository (checking, savings, HSA, CD, money-market). + currency_mismatch: All linked accounts must share the same currency. + must_belong_to_family: Linked accounts must belong to the same family as the goal. + currency: + locked_after_linked: Can't change the currency after the goal is linked to accounts. diff --git a/config/locales/models/goal_pledge/en.yml b/config/locales/models/goal_pledge/en.yml new file mode 100644 index 000000000..eda34dbc3 --- /dev/null +++ b/config/locales/models/goal_pledge/en.yml @@ -0,0 +1,20 @@ +--- +en: + activerecord: + attributes: + goal_pledge: + amount: Amount + currency: Currency + account: Account + kind: Kind + status: Status + expires_at: Expires at + errors: + models: + goal_pledge: + attributes: + account: + must_be_linked_to_goal: Pick one of the goal's linked accounts. + currency: + must_match_goal: Pledge currency must match the goal currency. + duplicate_open_pledge: You already have an open pledge for this amount on this account. Cancel or extend the existing one before recording another. diff --git a/config/locales/views/goal_pledges/en.yml b/config/locales/views/goal_pledges/en.yml new file mode 100644 index 000000000..299ae5844 --- /dev/null +++ b/config/locales/views/goal_pledges/en.yml @@ -0,0 +1,20 @@ +--- +en: + goal_pledges: + new: + helper_transfer: Sure will look for a matching deposit in your linked account. The pledge stays pending for 7 days, then auto-confirms once Sure spots it. + helper_manual: Sure will record this on your next manual balance edit and confirm the contribution. + amount_label: Amount + account_label: Into account + submit: Record pledge + preview_zero: "Currently {current} of {target} saved." + preview_nonzero: "Reaches {percent}%, {newTotal} of {target}." + preview_reached: "Hits your {target} target. Goal reached." + create: + success: Pledge recorded. Sure will confirm it on the next sync. + renew: + success: Pledge window extended 7 days. + not_open: Only open pledges can be extended. + destroy: + success: Pledge cancelled. + not_open: Only open pledges can be cancelled. diff --git a/config/locales/views/goals/en.yml b/config/locales/views/goals/en.yml new file mode 100644 index 000000000..031633272 --- /dev/null +++ b/config/locales/views/goals/en.yml @@ -0,0 +1,269 @@ +--- +en: + goals: + color_picker: + color_heading: Color + icon_heading: Icon + poor_contrast: Poor contrast, choose darker color or + auto_adjust: auto-adjust. + index: + title: Goals + subtitle: Save toward what matters. + new_goal: New goal + empty_filtered: No goals match. + pending_pledges_callout: You have pending pledges. Sure will confirm them on the next sync. + kpi: + contributed_label: Contributed · last 30d + velocity_delta_up: "↑ %{percent}%% vs. prior 30d" + velocity_delta_down: "↓ %{percent}%% vs. prior 30d" + velocity_delta_flat: vs. prior 30d + velocity_delta_zero_base: First 30d of activity + needs_this_month_label: Needs this month + needs_this_month_sub: + one: 1 goal behind pace + other: "%{count} goals behind pace" + needs_this_month_zero_sub: No goals behind pace + on_track_label: Goals on track + on_track_value: "%{on_track} of %{total}" + on_track_sub_parts: + reached: + one: 1 reached + other: "%{count} reached" + behind: + one: 1 behind + other: "%{count} behind" + no_date: + one: 1 without a deadline + other: "%{count} without a deadline" + paused: + one: 1 paused + other: "%{count} paused" + on_track_sub_all_good: All active goals on pace + on_track_all_caught_up: All caught up + goals_section: + heading: Goals + subtitle: Save toward what matters. + ongoing_section: + heading: Goals + archived_section: + heading: Archived + search: + placeholder: Search goals… + aria_label: Search goals + empty: No goals match. + empty_with_query: "No goals match \"%{query}\"." + empty_with_filter: No goals match this filter. + empty_with_both: "No goals match \"%{query}\" with this filter." + clear_search: Clear search + show_all: Show all + chips: + all: All + on_track: On track + behind: Behind + no_target_date: Open + paused: Paused + completed: Completed + new: + heading: New goal + subtitle: Save toward something specific. + edit: + heading: Edit goal + save: Save changes + create: + success: Goal created. + update: + success: Goal updated. + destroy: + success: Goal deleted. + archive_first: Archive the goal before deleting it. + pause: + success: Goal paused. + invalid_transition: Goal can't be paused from its current state. + resume: + success: Goal resumed. + invalid_transition: Goal can't be resumed from its current state. + complete: + success: Goal marked complete. + invalid_transition: Goal can't be completed from its current state. + archive: + success: Goal archived. + invalid_transition: Goal can't be archived from its current state. + unarchive: + success: Goal restored. + invalid_transition: Goal can't be restored from its current state. + show: + edit: Edit + pause: Pause + resume: Resume + complete: Mark complete + archive: Archive + unarchive: Restore + delete: Delete permanently + record_pledge_cta: Record pledge + pledge_just_transferred: Log a transfer you made + pledge_just_saved: Log money you set aside + funding_accounts_heading: Funding accounts + funding_accounts: + empty: + heading: No funding accounts linked yet + body: Edit the goal to link the depository accounts you save into. + notes: Notes + funding_last_30d: last 30d + funding_last_90d: last 90d + status_callout: + behind: "save %{amount}/mo more to catch up" + behind_covered: "pending pledges close the gap" + on_track: "reaches goal around %{date}" + no_target_date: "set a target date to project a finish line" + pending_pledge: + title: + zero: "Pending: %{amount} into %{account} · expires today" + one: "Pending: %{amount} into %{account} · 1 day left" + other: "Pending: %{amount} into %{account} · %{count} days left" + body_transfer: Auto-confirms when Sure spots a matching deposit on the next sync. + body_manual: Confirms on your next manual balance edit. + pledged_at: "Pledged %{time_ago} ago" + extend: Extend 7 days + cancel: Cancel + confirm_cancel_title: Cancel this pledge? + confirm_cancel_body: "The %{amount} pledge will be removed. You can record a new one anytime." + confirm_cancel_cta: Cancel pledge + header: + target: "Target %{amount}" + target_by: "Target %{amount} by %{date}" + target_by_past: "Target %{amount} · was due %{date}" + ring: + saved: Saved + of: "of %{target}" + to_go: "%{amount} to go" + of_target: of target + aria_label: "Goal %{percent}% complete. %{amount} of %{target} saved." + projection: + heading: Projection + legend_saved: Saved + legend_projection: Projection + legend_required: Required + reached: You've hit the target. No projection needed. + no_target_date: No target date set. Set one to project a finish line. + no_pace: No deposits yet. Add money to a linked account to start a projection. + behind: Falling short at current pace. + on_track_html: At your current pace, you'll reach this goal around <strong class="text-primary whitespace-nowrap">%{date}</strong>. + aria_label: "Projection chart for %{name}" + today_marker: Today + tooltip_projected: "Projected: %{amount}" + tooltip_saved: "Saved: %{amount}" + catch_up: + title: "Save %{amount}/mo more to catch up" + body: "Current pace %{avg}/mo · required %{required}/mo to hit your target." + adjust_target_cta: Adjust target instead + confirm_complete_title: Mark this goal complete? + confirm_complete_body: It leaves the Ongoing list. You can still archive or restore it later. + confirm_complete_body_short: "You're at %{progress}%, %{saved} of %{target}. Marking complete records this as your achievement instead of the original target. Continue, or close this and adjust the target instead?" + confirm_complete_cta: Mark complete + confirm_archive_title: Archive this goal? + confirm_archive_body: Archived goals disappear from the main list. You can restore them later. + confirm_archive_cta: Archive + paused_banner: + title: This goal is paused + body: Resume it to keep tracking your progress. + resume_cta: Resume goal + archived_banner: + title: This goal is archived + body: Restore it to continue contributing, or leave it as a record. + restore_cta: Restore goal + celebration: + heading: Goal reached. Nice work. + body: "Goal closed at %{saved} of %{target}. Keep it as a record, or archive it now." + archive_cta: Archive goal + inactive: + heading_paused: This goal is paused + heading_archived: This goal is archived + body: "%{saved} of %{target} saved so far." + no_target_date: + heading: Add a target date + body: Set a deadline to project a finish line and track required pace. + cta: Set target date + empty: + heading: No deposits yet + body: Make a transfer into your linked account. Sure will catch it on the next sync. Or update your manual account balance. + errors: + not_found: This goal couldn't be found. It may have been deleted. + states: + active: Active + paused: Paused + completed: Completed + archived: Archived + status: + on_track: On track + behind: Behind + reached: Reached + completed: Completed + no_target_date: Open + paused: Paused + archived: Archived + empty_state: + heading: No goals yet + body: Set a target, link the accounts you save into, and watch your progress add up. + subtitle: Set a target and start saving toward it. + new_goal: Create your first goal + no_depository_accounts: You need at least one depository account (checking, savings, HSA, CD, money-market) before creating a goal. + add_account: Add an account + goal_card: + no_accounts: No linked accounts + n_accounts: "%{first} +%{count}" + left: left + aria_progress: "%{percent}% of %{target}" + accounts: + one: 1 account + other: "%{count} accounts" + no_target_date: Open + completed: Completed + past_due: Past due + days_left: + one: 1 day left + other: "%{count} days left" + days_left_by: + one: 1 day left · by %{date} + other: "%{count} days left · by %{date}" + pace_with_target: "%{avg}/mo · target %{target}/mo" + pace_no_target: "%{avg}/mo avg" + footer_paused: Paused + footer_archived: Archived + footer_reached: Goal reached + footer_catch_up: "Save %{amount}/mo to catch up" + footer_no_deadline: Open + pending_pledge: Pending pledge + pending_count: + one: 1 pending + other: "%{count} pending" + footer_no_pledges: No matched pledges yet + footer_last_today: Last pledge matched today + footer_last_days: + one: Last pledge matched 1 day ago + other: "Last pledge matched %{count} days ago" + form: + create: Create goal + save: Save changes + suggested_with_date: "Save {monthly}/mo across {accounts} to hit it on time." + suggested_no_date: Set a target date to project a finish line. + errors: + name_required: Give your goal a name. + amount_required: Set a target above zero. + accounts_required: Pick at least one funding account. + fields: + name: Name + name_placeholder: Emergency fund, House down payment… + target_amount: Target amount + target_date: Target date + color: Color + notes: Notes (optional) + notes_placeholder: A reminder for future you… + funding_accounts: Funding accounts + funding_accounts_hint: This goal's balance is the balance of these accounts. + subtypes: + checking: Checking + savings: Savings + hsa: HSA + cd: CD + money_market: Money market + other: Other diff --git a/config/locales/views/layout/en.yml b/config/locales/views/layout/en.yml index e5702c5e7..e38487900 100644 --- a/config/locales/views/layout/en.yml +++ b/config/locales/views/layout/en.yml @@ -9,6 +9,7 @@ en: budgets: Budgets home: Home reports: Reports + goals: Goals transactions: Transactions auth: existing_account: Already have an account? diff --git a/config/locales/views/shared/en.yml b/config/locales/views/shared/en.yml index 095846333..50599089c 100644 --- a/config/locales/views/shared/en.yml +++ b/config/locales/views/shared/en.yml @@ -4,6 +4,7 @@ en: self_hostable: redis_configured: "Redis is now configured properly! You can now setup your Sure application." shared: + preview: Preview sync_toast: message: "Your data has been updated" refresh: "Refresh now" diff --git a/config/routes.rb b/config/routes.rb index 7eeb12395..23886529c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -303,6 +303,22 @@ Rails.application.routes.draw do resources :budget_categories, only: %i[index show update] end + resources :goals do + member do + patch :pause + patch :resume + patch :complete + patch :archive + patch :unarchive + end + + resources :pledges, only: %i[new create destroy], controller: "goal_pledges" do + member do + patch :renew + end + end + end + resources :family_merchants, only: %i[index new create edit update destroy] do collection do get :merge diff --git a/config/schedule.yml b/config/schedule.yml index 5475d7b0a..6e1c8bb3a 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -48,3 +48,9 @@ refresh_demo_family: class: "DemoFamilyRefreshJob" queue: "scheduled" description: "Refreshes demo family data and emails super admins with daily usage summary" + +sweep_expired_goal_pledges: + cron: "*/15 * * * *" # every 15 minutes + class: "SweepExpiredGoalPledgesJob" + queue: "scheduled" + description: "Marks goal pledges that passed their 7-day window as expired" diff --git a/db/migrate/20260511100000_create_goals.rb b/db/migrate/20260511100000_create_goals.rb new file mode 100644 index 000000000..68b536ed7 --- /dev/null +++ b/db/migrate/20260511100000_create_goals.rb @@ -0,0 +1,64 @@ +class CreateGoals < ActiveRecord::Migration[7.2] + def change + create_enum :goal_pledge_kind, %w[transfer manual_save] + create_enum :goal_pledge_status, %w[open matched cancelled expired] + + create_table :goals, id: :uuid do |t| + t.references :family, null: false, foreign_key: { on_delete: :cascade }, type: :uuid + t.string :name, null: false + t.decimal :target_amount, precision: 19, scale: 4, null: false + t.string :currency, null: false + t.date :target_date + t.string :color + t.text :notes + t.string :state, null: false, default: "active" + + t.timestamps + + t.string :icon + end + + add_index :goals, [ :family_id, :state ] + add_check_constraint :goals, "char_length(name) <= 255", name: "chk_savings_goals_name_length" + add_check_constraint :goals, "target_amount > 0", name: "chk_savings_goals_target_amount_positive" + add_check_constraint :goals, + "state IN ('active','paused','completed','archived')", + name: "chk_savings_goals_state_enum" + + create_table :goal_accounts, id: :uuid do |t| + t.references :goal, null: false, foreign_key: { on_delete: :cascade }, type: :uuid + t.references :account, null: false, foreign_key: { on_delete: :restrict }, type: :uuid + + t.timestamps + end + + add_index :goal_accounts, [ :goal_id, :account_id ], + unique: true, + name: "index_savings_goal_accounts_on_goal_and_account" + + create_table :goal_pledges, id: :uuid do |t| + t.references :goal, null: false, foreign_key: { on_delete: :cascade }, type: :uuid + t.references :account, null: false, foreign_key: { on_delete: :restrict }, type: :uuid + t.decimal :amount, precision: 19, scale: 4, null: false + t.string :currency, null: false + t.enum :kind, enum_type: :goal_pledge_kind, null: false + t.enum :status, enum_type: :goal_pledge_status, null: false, default: "open" + t.datetime :expires_at, null: false + t.uuid :matched_transaction_id + + t.timestamps + end + + add_foreign_key :goal_pledges, :transactions, column: :matched_transaction_id, on_delete: :nullify + + add_index :goal_pledges, [ :goal_id, :status ] + add_index :goal_pledges, [ :status, :expires_at ], + where: "status = 'open'", + name: "index_goal_pledges_open_by_expiry" + add_index :goal_pledges, :matched_transaction_id, + unique: true, + where: "matched_transaction_id IS NOT NULL" + + add_check_constraint :goal_pledges, "amount > 0", name: "chk_goal_pledges_amount_positive" + end +end diff --git a/db/migrate/20260514120002_add_pledge_id_index_to_transactions.rb b/db/migrate/20260514120002_add_pledge_id_index_to_transactions.rb new file mode 100644 index 000000000..ed78a5777 --- /dev/null +++ b/db/migrate/20260514120002_add_pledge_id_index_to_transactions.rb @@ -0,0 +1,12 @@ +class AddPledgeIdIndexToTransactions < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_index :transactions, + "((extra -> 'goal' ->> 'pledge_id'))", + unique: true, + where: "(extra -> 'goal' ->> 'pledge_id') IS NOT NULL", + name: "ix_transactions_extra_goal_pledge_id", + algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index fb44f8a0d..88fa3aae9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -18,6 +18,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do # Custom types defined in this database. # Note that some types may not work with other database engines. Be careful if changing database. create_enum "account_status", ["ok", "syncing", "error"] + create_enum "goal_pledge_kind", ["transfer", "manual_save"] + create_enum "goal_pledge_status", ["open", "matched", "cancelled", "expired"] create_table "account_providers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false @@ -51,6 +53,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.string "content_type", limit: 100, null: false t.bigint "byte_size", null: false t.string "checksum", limit: 64, null: false + t.string "content_sha256" t.string "source", default: "manual_upload", null: false t.string "upload_status", default: "stored", null: false t.string "institution_name_hint", limit: 200 @@ -67,7 +70,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.jsonb "sanitized_parser_output", default: {}, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.string "content_sha256" t.index ["account_id", "period_start_on", "period_end_on"], name: "index_account_statements_on_account_period" t.index ["account_id"], name: "index_account_statements_on_account_id" t.index ["family_id", "checksum"], name: "index_account_statements_on_family_checksum" @@ -252,9 +254,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.string "institution_domain" t.string "institution_url" t.string "institution_color" - t.string "status", default: "good", null: false - t.boolean "scheduled_for_deletion", default: false, null: false - t.boolean "pending_account_setup", default: false, null: false + t.string "status", default: "good" + t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false t.datetime "sync_start_date" t.jsonb "raw_payload" t.text "api_key" @@ -756,6 +758,54 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.index ["merchant_id"], name: "index_family_merchant_associations_on_merchant_id" end + create_table "goal_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "goal_id", null: false + t.uuid "account_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_goal_accounts_on_account_id" + t.index ["goal_id", "account_id"], name: "index_savings_goal_accounts_on_goal_and_account", unique: true + t.index ["goal_id"], name: "index_goal_accounts_on_goal_id" + end + + create_table "goal_pledges", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "goal_id", null: false + t.uuid "account_id", null: false + t.decimal "amount", precision: 19, scale: 4, null: false + t.string "currency", null: false + t.enum "kind", null: false, enum_type: "goal_pledge_kind" + t.enum "status", default: "open", null: false, enum_type: "goal_pledge_status" + t.datetime "expires_at", null: false + t.uuid "matched_transaction_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_goal_pledges_on_account_id" + t.index ["goal_id", "status"], name: "index_goal_pledges_on_goal_id_and_status" + t.index ["goal_id"], name: "index_goal_pledges_on_goal_id" + t.index ["matched_transaction_id"], name: "index_goal_pledges_on_matched_transaction_id", unique: true, where: "(matched_transaction_id IS NOT NULL)" + t.index ["status", "expires_at"], name: "index_goal_pledges_open_by_expiry", where: "(status = 'open'::goal_pledge_status)" + t.check_constraint "amount > 0::numeric", name: "chk_goal_pledges_amount_positive" + end + + create_table "goals", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "name", null: false + t.decimal "target_amount", precision: 19, scale: 4, null: false + t.string "currency", null: false + t.date "target_date" + t.string "color" + t.text "notes" + t.string "state", default: "active", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "icon" + t.index ["family_id", "state"], name: "index_goals_on_family_id_and_state" + t.index ["family_id"], name: "index_goals_on_family_id" + t.check_constraint "char_length(name::text) <= 255", name: "chk_savings_goals_name_length" + t.check_constraint "state::text = ANY (ARRAY['active'::character varying::text, 'paused'::character varying::text, 'completed'::character varying::text, 'archived'::character varying::text])", name: "chk_savings_goals_state_enum" + t.check_constraint "target_amount > 0::numeric", name: "chk_savings_goals_target_amount_positive" + end + create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "security_id", null: false @@ -789,9 +839,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.decimal "current_balance", precision: 19, scale: 4 t.decimal "cash_balance", precision: 19, scale: 4 t.jsonb "institution_metadata" - t.jsonb "raw_holdings_payload", default: [] - t.jsonb "raw_activities_payload", default: {} - t.jsonb "raw_cash_report_payload", default: [] + t.jsonb "raw_holdings_payload", default: [], null: false + t.jsonb "raw_activities_payload", default: {}, null: false + t.jsonb "raw_cash_report_payload", default: [], null: false t.date "report_date" t.datetime "last_holdings_sync" t.datetime "last_activities_sync" @@ -805,8 +855,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do create_table "ibkr_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "family_id", null: false t.string "name" - t.string "status", default: "good" - t.boolean "scheduled_for_deletion", default: false + t.string "status", default: "good", null: false + t.boolean "scheduled_for_deletion", default: false, null: false t.boolean "pending_account_setup", default: false, null: false t.jsonb "raw_payload" t.string "query_id" @@ -1786,6 +1836,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do t.string "external_id" t.jsonb "extra", default: {}, null: false t.string "investment_activity_label" + t.index "(((extra -> 'goal'::text) ->> 'pledge_id'::text))", name: "ix_transactions_extra_goal_pledge_id", unique: true, where: "(((extra -> 'goal'::text) ->> 'pledge_id'::text) IS NOT NULL)" t.index ["category_id"], name: "index_transactions_on_category_id" t.index ["external_id"], name: "index_transactions_on_external_id" t.index ["extra"], name: "index_transactions_on_extra", using: :gin @@ -1928,6 +1979,12 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_153000) do add_foreign_key "family_exports", "families" add_foreign_key "family_merchant_associations", "families" add_foreign_key "family_merchant_associations", "merchants" + add_foreign_key "goal_accounts", "accounts", on_delete: :restrict + add_foreign_key "goal_accounts", "goals", on_delete: :cascade + add_foreign_key "goal_pledges", "accounts", on_delete: :restrict + add_foreign_key "goal_pledges", "goals", on_delete: :cascade + add_foreign_key "goal_pledges", "transactions", column: "matched_transaction_id", on_delete: :nullify + add_foreign_key "goals", "families", on_delete: :cascade add_foreign_key "holdings", "account_providers" add_foreign_key "holdings", "accounts", on_delete: :cascade add_foreign_key "holdings", "securities" diff --git a/docs/llm-guides/gating-a-preview-feature.md b/docs/llm-guides/gating-a-preview-feature.md index 008513ed3..43bec54f5 100644 --- a/docs/llm-guides/gating-a-preview-feature.md +++ b/docs/llm-guides/gating-a-preview-feature.md @@ -57,6 +57,23 @@ Wrap the relevant fragment in the helper: Same pattern works for dashboard widgets, scoreboard cards, anything that surfaces preview data alongside non-preview data. The helper resolves on every request and reflects the current user's preference. +## Gating the main nav + +The desktop sidebar rail and the mobile bottom nav both render from `app/views/layouts/shared/_nav_item.html.erb`. The partial accepts an optional `preview:` local — when true, it overlays a violet dot-only pill on the icon so opted-in users can tell at a glance that the rail entry leads to a preview surface. + +Use the `preview_gated_nav_item` helper to wrap the entry. It returns `nil` for users without preview access (so the entry never enters the nav, once `Array#compact` runs) and stamps `preview: true` for opted-in users (so the partial paints the dot). One call, both halves of the gate: + +```erb +<% mobile_nav_items = [ + { name: t(".nav.home"), path: root_path, icon: "pie-chart", icon_custom: false, active: page_active?(root_path) }, + { name: t(".nav.transactions"), path: transactions_path, icon: "credit-card", icon_custom: false, active: page_active?(transactions_path) }, + preview_gated_nav_item({ name: t(".nav.goals"), path: goals_path, icon: "piggy-bank", icon_custom: false, active: page_active?(goals_path) }), + { name: t(".nav.assistant"), path: chats_path, icon: "icon-assistant", icon_custom: true, active: page_active?(chats_path), mobile_only: true } +].compact %> +``` + +You don't need to touch `_nav_item.html.erb` or set `preview: true` by hand. Adding a new preview nav entry is one helper call wrapped around the same hash you'd write anyway. + ## Marking the feature in the UI When a preview surface renders for an opted-in user, mark it. The pill component lives in the design system: @@ -114,7 +131,7 @@ When a feature moves from preview to general availability, removing the gate is 1. Drop the `before_action :require_preview_features!` line from the controller. 2. Unwrap the `if preview_features_enabled?` blocks in views. -3. Drop the `DS::Pill` markers from headers, nav, and section titles. +3. Drop the `DS::Pill` markers from headers and section titles, and unwrap the `preview_gated_nav_item(...)` call back into a plain nav-item hash. 4. Delete the controller / view tests that exercise the redirect. Grep for `require_preview_features!` and `preview_features_enabled?` near your feature to confirm nothing's left behind. diff --git a/docs/llm-guides/goals.md b/docs/llm-guides/goals.md new file mode 100644 index 000000000..7592d917d --- /dev/null +++ b/docs/llm-guides/goals.md @@ -0,0 +1,336 @@ +# Working with the Goals feature + +Reference for changes to the savings-goals feature. Covers the data +model, the surfaces that consume it, the load-bearing invariants, and +the gotchas worth knowing before you touch the code. + +## Architecture overview + +```text +GoalsController#index + → @active_goals = Family.goals.includes(:open_pledges, linked_accounts: :account_providers) + → KPI strip + per-goal cards (Goals::CardComponent) + → pending-pledges callout if any goal has an open pledge + +GoalsController#show + → @goal.open_pledges.reverse_chronological → pending-pledge banners + → progress ring (Goals::ProgressRingComponent) + → projection chart (data-controller="goal-projection-chart") + → Goals::FundingAccountsBreakdownComponent (linked-account rows) + → Notes section if @goal.notes.present? + +GoalPledgesController#create (turbo-frame: modal) + → goal.goal_pledges.new(amount:, account:, kind: kind_for_account(account)) + → save! → matches?-loop runs once the next sync arrives + +Account::ProviderImportAdapter#import_transaction + → GoalPledge::Reconciler.new(entry).run (transfer-kind path) +Account::ReconciliationManager#reconcile + → GoalPledge::Reconciler.new(prepared_valuation).run (manual_save path) + +SweepExpiredGoalPledgesJob (cron, every 15 minutes) + → GoalPledge.open_and_expired_now.find_each(&:expire!) +``` + +## Key files + +Model layer: + +- `app/models/goal.rb` — balance, pace, status, projection, color map. +- `app/models/goal_pledge.rb` — pledge, match policy, lifecycle. +- `app/models/goal_pledge/reconciler.rb` — entry-to-pledge resolver, called from the import adapters. +- `app/models/account.rb` — `#manual?` instance method (mirrors the `Account.manual` scope) drives pledge kind detection. +- `app/models/family.rb` — `#savings_inflow_velocity` powers the KPI strip. + +Controllers / routes: + +- `app/controllers/goals_controller.rb` — index / show / new / create / edit / update / destroy / pause / resume / complete / archive / unarchive. +- `app/controllers/goal_pledges_controller.rb` — new / create / renew / destroy. +- `config/routes.rb` — `resources :goals do resources :pledges ... member { patch :renew } end`. + +Views: + +- `app/views/goals/index.html.erb`, `show.html.erb`, `new.html.erb`, `edit.html.erb`. +- `app/views/goals/_form_stepper.html.erb`, `_form_edit.html.erb`, `_pending_pledge_banner.html.erb`, `_empty_state.html.erb`, `_color_picker.html.erb`. +- `app/views/goal_pledges/new.html.erb`. + +View components: + +- `app/components/goals/card_component.{rb,html.erb}` — goal card on the index. +- `app/components/goals/funding_accounts_breakdown_component.{rb,html.erb}` — per-account widget on show. +- `app/components/goals/avatar_component.{rb,html.erb}` — colored letter/icon avatar. +- `app/components/goals/account_stack_component.{rb,html.erb}` — overlapping account avatars on the card. +- `app/components/goals/progress_ring_component.{rb,html.erb}` — show-page ring. +- `app/components/goals/status_pill_component.{rb,html.erb}` — status chip. + +Stimulus controllers: + +- `app/javascript/controllers/goal_stepper_controller.js` — two-step create modal. +- `app/javascript/controllers/goal_pledge_preview_controller.js` — live amount-impact preview + helper-text toggle. +- `app/javascript/controllers/goal_projection_chart_controller.js` — D3 projection chart on show. +- `app/javascript/controllers/goals_filter_controller.js` — index filter chips + search, with URL state. + +Schema / migrations: + +- `db/migrate/20260514120000_create_goal_pledges.rb` — table + enums + partial indexes + amount check. +- `db/migrate/20260514120001_drop_goal_contributions.rb` — old ledger. +- `db/migrate/20260514120002_add_pledge_id_index_to_transactions.rb` — partial unique on `transactions.extra->'goal'->>'pledge_id'`. + +Tests / fixtures: + +- `test/models/goal_test.rb`, `goal_pledge_test.rb`, `goal_pledge/reconciler_test.rb`. +- `test/controllers/goals_controller_test.rb`, `goal_pledges_controller_test.rb`. +- `test/jobs/sweep_expired_goal_pledges_job_test.rb`. +- `test/fixtures/goals.yml`, `goal_accounts.yml`, `goal_pledges.yml`. + +Locales: + +- `config/locales/views/goals/en.yml`, `goal_pledges/en.yml`. +- `config/locales/models/goal/en.yml`, `goal_pledge/en.yml`. + +## Data model + +A goal records a name, target amount, optional target date, color, optional +icon, optional notes, currency, and an AASM `state` (`active` / `paused` / +`completed` / `archived`). It links to depository accounts via the join +table `goal_accounts`. + +The goal's *progress* is the live balance of every linked account. There +is no ledger of contributions. `Goal#current_balance` reads +`linked_accounts.sum(:balance)` at request time. + +A `GoalPledge` is an intent: amount, account, kind, status, expires_at. +The status enum is `open` / `matched` / `cancelled` / `expired`. The kind +enum is `transfer` / `manual_save`; kind is decided at create time from +the selected account's connection state. + +## Status semantics + +`Goal#status` is computed at render time: + +- `:reached` when `progress_percent >= 100`. +- `:no_target_date` when `target_date.nil?`. +- `:on_track` when the goal has a deadline and `monthly_target_amount <= pace`. +- `:behind` otherwise. + +The AASM `state` is independent. Read `Goal#display_status` (not `#status`) +to get the right pill label: it returns the AASM state when it's not +`:active`, otherwise falls through to `#status`. + +`Goal#pace` is the rolling 90-day net inflow into the linked accounts, +divided by three. The query joins `entries` with `transactions` +(valuations excluded by join shape), drops excluded entries, and drops +pending provider transactions via `Transaction.excluding_pending`. This +last filter matters: a pending Plaid deposit that later reverses would +otherwise quietly reshape pace. + +`Goal#monthly_target_amount` is `(remaining_amount / months_remaining).ceil(2)`. +`months_remaining` uses day precision: `(target_date - Date.current) / 30.0`, +clamped at zero. Calendar-month math is wrong here — it produces a cliff +in the last 30 days where the required monthly rate spikes. + +`Goal#catch_up_delta_money` returns `max(0, monthly_target - pace - +sum_of_open_pledges)`. The show-page catch-up alert hides when this is +zero; the pledge CTA inside the alert pre-fills with this delta, so +accepting it once funds the gap rather than stacking the full required +rate on top. + +## Pledge match window + +`GoalPledge#matches?` checks three things: + +1. The pledge is open. +2. The entry is on the pledge's `account_id`. +3. The entry's `date` sits in `[created_at - 5d, max(created_at + 5d, expires_at)]`, + and the entry's `|amount|` is within `$0.50` or `1%` of the pledge + amount, whichever is larger. + +The upper-bound date widens when `extend!` pushes `expires_at` forward. +Without that widening, "Extend 7 days" would push the expiry forward but +the actual match window would stay anchored at creation. + +The reconciler picks pledges by `(account_id, status: "open", kind: +expected_kind, expires_at >= NOW())`. `expected_kind` is `"manual_save"` +for valuation entries and `"transfer"` for transactions. + +When a pledge resolves on a transaction, the reconciler stamps +`transaction.extra["goal"]["pledge_id"] = pledge.id` and sets +`pledge.matched_transaction_id`. Two partial unique indexes enforce +single-claim semantics: + +- `goal_pledges (matched_transaction_id) WHERE matched_transaction_id IS NOT NULL` +- `transactions ((extra -> 'goal' ->> 'pledge_id')) WHERE (extra -> 'goal' ->> 'pledge_id') IS NOT NULL` + +`Goal#last_matched_pledge_at` joins through `matched_transaction_id` to +the entry's `date`, so the show-page header reads the actual entry date, +not `goal_pledges.updated_at`. The distinction matters: a sync resync +would otherwise touch `updated_at` on every matched pledge and reset the +"Last pledge matched N days ago" copy across every goal. + +## Connected vs manual accounts + +`Account#manual?` returns true when the account has no +`account_providers` association rows, no `plaid_account_id`, and no +`simplefin_account_id`. This mirrors the `Account.manual` query scope. + +`Goal#any_connected_account?` returns true when *any* linked account is +not manual. It drives the modal-title copy: connected accounts get +"I just transferred…", manual-only goals get "I just saved…" + +`GoalPledgesController#kind_for_account(account)` is per-account: +manual → `manual_save`, connected → `transfer`. A goal with one manual +and one connected linked account works correctly; the kind reflects the +specific account the user picked, not the goal as a whole. + +## Color map + +`Goal#account_color_map` returns `{ account_id => palette_hex }` for the +goal's linked accounts, sorted by id and assigned palette colors in +order. Three surfaces consume the map: `AccountStackComponent` on the +goal card, the distribution bar in the funding widget, and the avatars +in the funding widget rows. A given account renders the same color on +every surface within a goal. + +Account avatars outside a goal context (the new-goal account checklist) +still call `Goals::AvatarComponent.color_for(account.name)`. The +mismatch is acceptable because the form is a one-shot picker, not a +recurring view. + +## Common tasks + +### Adding a new field to `Goal` + +1. Migration: `add_column :goals, :your_field, :type`. Add a partial + index if the field is queried. +2. Validation: add to `Goal` if presence/range rules apply. +3. Strong params: update `goal_params` and `goal_update_params` in + `GoalsController`. +4. Form: surface in `app/views/goals/_form_stepper.html.erb` (create) and + `_form_edit.html.erb` (edit). +5. Locales: add labels under `goals.form_stepper.step1.fields.*` and + `activerecord.attributes.goal.*`. +6. Display: pick the right surface (header on show, secondary line on + the card, etc). +7. Tests: extend `test/models/goal_test.rb` for validation; controller + tests for the form-param flow. + +### Adding a new status to `Goal#status` + +The enum is implicit in the method body (symbol returns); adding a +state means touching: + +1. `Goal#status` to return the new symbol from the right branch. +2. `Goal#display_status` if the new status interacts with the AASM + states. +3. `Goals::StatusPillComponent::VARIANTS` to add the chip styling + (classes + icon). +4. `Goals::CardComponent#footer_line` if the footer copy depends. +5. `GoalsController#kpi_payload` if the KPI strip counts it. +6. `config/locales/views/goals/en.yml` under `goals.status.*` for the + pill label, plus chip and subtitle keys if the new status filters + on the index. +7. `Goals::StatusPillComponent#status_key` and the goal-filter Stimulus + controller (`data-status="..."` on chips) if the new status filters. + +### Adding a new pledge kind + +The kind is a Postgres enum (`goal_pledge_kind`) backing the +`GoalPledge#kind` attribute. Adding a new value: + +1. Migration: `ALTER TYPE goal_pledge_kind ADD VALUE 'your_kind'`. + This is irreversible in Postgres; consider whether you really need a + new kind versus a different match strategy on an existing one. +2. `GoalPledge::KINDS` constant. +3. `GoalPledgesController#kind_for_account` if the new kind has a + per-account trigger. +4. `GoalPledge::Reconciler#expected_kind` if the new kind matches a + different entry shape. +5. Locale + modal helper text in `goal_pledges.new.helper_*`. + +### Touching the reconciler + +The reconciler is hot — every imported transaction across every provider +calls it. Things to watch: + +- The outer `rescue StandardError` is protective: an unexpected raise + here would break the importer for every account. Keep the rescue, but + forward to Sentry so the underlying bug stays visible. +- The inner rescue catches `NotOpenError`, `RecordInvalid`, and + `RecordNotUnique`. These cover the known race conditions (another + worker claimed the pledge first; another pledge claimed the + transaction first). Adding new exception classes here should be a + deliberate decision. +- The `find_each` loop returns from the method on first successful + resolve. On a rescued failure it falls through to the next candidate + pledge. + +## Gotchas + +The same depository account can fund two goals. Both will read the +full balance and double-count progress toward their targets. This is a +known limitation; an allocation primitive that splits the balance +proportionally (or by explicit user weights) would be the way out. + +`Goal#pace` includes paychecks, rent, debit-card spend — anything on +the linked account. For a goal linked to primary checking, the metric +matches "net change in balance," not "intentional savings." A user +living paycheck-to-paycheck shows near-zero pace even when they +consciously transfer money in. Isolating intentional savings would need +transfer-pair detection. + +Status transitions on a single sub-pace month. The current behaviour is +honest but jarring; a two-month moving condition or a recovery banner +would soften the "great for five months, vacation in June, suddenly +Behind" case. + +Light-mode contrast on pale palette entries is weak against +`bg-container`. The fix lives in the design system, not in the goal +feature. The distribution bar segments and the goal-card ring are the +visible surfaces. + +`Goal#balance_series_values` rescues `StandardError` and logs to Sentry +when `Balance::ChartSeriesBuilder` raises. The chart degrades to +target-line-only rather than 500ing. If you're debugging "why is the +projection saved-line empty," check Sentry first. + +## Demo data + +`Demo::Generator#generate_goals!` seeds nine goals chosen to surface +every state on at least one card: + +- Active + computed status: `:reached`, `:on_track`, `:behind`, + `:no_target_date`, plus a past-due active goal that exercises the + "was due" header copy. +- AASM: paused, archived, completed. +- Two open pledges (banner + index callout). +- One matched pledge bound to a real recent inflow transaction + (exercises the "Last pledge matched N days ago" header). + +Routing goals to different account pools (primary checking holds +the bulk of the balance; secondary checking holds a tenth) is what +forces certain goals to land below their target instead of overshooting. +If you change the demo's account balances, the goal targets need to +move too. + +To regenerate from scratch: + +```sh +bundle exec rails db:drop db:create db:schema:load +SKIP_CLEAR=1 bundle exec rake demo_data:default +``` + +`SKIP_CLEAR=0` clears existing data first; on a freshly-loaded schema +the clear step has known issues with the `trades` constraint so the +`SKIP_CLEAR=1` path is the reliable one. + +## Background processes + +`SweepExpiredGoalPledgesJob` runs every 15 minutes via sidekiq-cron +(`config/schedule.yml`). It scans `GoalPledge.open_and_expired_now` and +flips matching rows to `expired`. + +`GoalPledge::Reconciler` runs synchronously inside the existing import +pipeline; it is not a separate job. Any provider sync (Plaid, +SimpleFIN, Lunchflow, Enable Banking, Brex, IBKR, Kraken, SnapTrade) and +any manual balance reconciliation feeds through `Account::ProviderImportAdapter` +or `Account::ReconciliationManager` and trips the reconciler hook. diff --git a/test/controllers/goal_pledges_controller_test.rb b/test/controllers/goal_pledges_controller_test.rb new file mode 100644 index 000000000..9395cad32 --- /dev/null +++ b/test/controllers/goal_pledges_controller_test.rb @@ -0,0 +1,94 @@ +require "test_helper" + +class GoalPledgesControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true)) + sign_in @user + @goal = goals(:vacation_italy) + @account = accounts(:depository) + @pledge = goal_pledges(:open_transfer) + ensure_tailwind_build + end + + test "redirects users without preview access" do + @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false)) + + get new_goal_pledge_url(@goal), headers: { "Turbo-Frame" => "modal" } + + assert_redirected_to root_path + assert_match(/preview/i, flash[:alert]) + end + + test "new renders the pledge form inside a turbo frame" do + get new_goal_pledge_url(@goal), headers: { "Turbo-Frame" => "modal" } + assert_response :success + end + + test "new redirects to the goal show page on a non-frame GET" do + get new_goal_pledge_url(@goal) + assert_redirected_to goal_path(@goal) + end + + test "create opens a pledge with default kind" do + assert_difference -> { GoalPledge.count } => 1 do + post goal_pledges_url(@goal), params: { + goal_pledge: { + amount: "150", + account_id: @account.id + } + } + end + pledge = GoalPledge.order(created_at: :desc).first + assert_equal "open", pledge.status + assert_equal @goal.id, pledge.goal_id + assert_redirected_to goal_path(@goal) + end + + test "create rejects amount <= 0" do + assert_no_difference "GoalPledge.count" do + post goal_pledges_url(@goal), params: { + goal_pledge: { amount: "0", account_id: @account.id } + } + end + assert_response :unprocessable_entity + end + + test "extend pushes expires_at forward" do + before = @pledge.expires_at + patch renew_goal_pledge_url(@goal, @pledge) + assert_redirected_to goal_path(@goal) + assert @pledge.reload.expires_at > before + end + + test "extend on non-open pledge flashes alert" do + pledge = goal_pledges(:matched_transfer) + patch renew_goal_pledge_url(@goal, pledge) + assert_redirected_to goal_path(@goal) + assert flash[:alert].present? + end + + test "destroy cancels an open pledge" do + delete goal_pledge_url(@goal, @pledge) + assert_redirected_to goal_path(@goal) + assert @pledge.reload.status_cancelled? + end + + test "destroy on non-open pledge flashes alert" do + pledge = goal_pledges(:matched_transfer) + delete goal_pledge_url(@goal, pledge) + assert_redirected_to goal_path(@goal) + assert flash[:alert].present? + end + + test "another family's goal returns redirect" do + other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC") + other_account = Account.create!(family: other_family, accountable: Depository.new, name: "Foreign", currency: "USD", balance: 100) + other_goal = other_family.goals.new(name: "Foreign goal", target_amount: 100, currency: "USD") + other_goal.goal_accounts.build(account: other_account) + other_goal.save! + + get new_goal_pledge_url(other_goal) + assert_redirected_to goals_path + end +end diff --git a/test/controllers/goals_controller_test.rb b/test/controllers/goals_controller_test.rb new file mode 100644 index 000000000..c20ae7e73 --- /dev/null +++ b/test/controllers/goals_controller_test.rb @@ -0,0 +1,242 @@ +require "test_helper" + +class GoalsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:family_admin) + @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => true)) + sign_in @user + @goal = goals(:vacation_italy) + @depository = accounts(:depository) + @connected = accounts(:connected) + ensure_tailwind_build + end + + test "redirects users without preview access" do + @user.update!(preferences: (@user.preferences || {}).merge("preview_features_enabled" => false)) + + get goals_url + + assert_redirected_to root_path + assert_match(/preview/i, flash[:alert]) + end + + test "index renders with active filter by default" do + get goals_url + assert_response :success + assert_match(/Goals/i, response.body) + end + + test "index honors state filter" do + get goals_url(state: "paused") + assert_response :success + end + + test "show renders the goal" do + get goal_url(@goal) + assert_response :success + assert_match(@goal.name, response.body) + end + + test "new renders the modal form" do + get new_goal_url + assert_response :success + end + + test "create persists a goal with linked accounts" do + assert_difference -> { Goal.count } => 1, + -> { GoalAccount.count } => 2 do + post goals_url, params: { + goal: { + name: "New goal", + target_amount: "1000", + target_date: 3.months.from_now.to_date.iso8601, + color: "#4da568", + account_ids: [ @depository.id, @connected.id ] + } + } + end + + goal = Goal.order(created_at: :desc).first + assert_redirected_to goal_path(goal) + end + + test "create rejects missing account_ids" do + assert_no_difference "Goal.count" do + post goals_url, params: { + goal: { + name: "Bad goal", + target_amount: "1000", + color: "#4da568" + } + } + end + assert_response :unprocessable_entity + end + + test "create rejects foreign accounts" do + other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC") + foreign = Account.create!(family: other_family, accountable: Depository.new, name: "Foreign", currency: "USD", balance: 100) + + assert_no_difference "Goal.count" do + post goals_url, params: { + goal: { + name: "Foreign goal", + target_amount: "1000", + color: "#4da568", + account_ids: [ foreign.id ] + } + } + end + assert_response :unprocessable_entity + end + + test "update modifies identity fields" do + patch goal_url(@goal), params: { goal: { name: "Renamed" } } + assert_redirected_to goal_path(@goal) + assert_equal "Renamed", @goal.reload.name + end + + test "update without account_ids leaves linked accounts intact" do + before = @goal.goal_accounts.pluck(:account_id).sort + patch goal_url(@goal), params: { goal: { name: "Still here" } } + assert_redirected_to goal_path(@goal) + assert_equal before, @goal.reload.goal_accounts.pluck(:account_id).sort + end + + test "update with account_ids syncs linked accounts (add + remove)" do + patch goal_url(@goal), params: { goal: { account_ids: [ @connected.id ] } } + assert_redirected_to goal_path(@goal) + assert_equal [ @connected.id ], @goal.reload.goal_accounts.pluck(:account_id) + end + + test "update with empty account_ids re-renders with error" do + patch goal_url(@goal), params: { goal: { account_ids: [ "" ] } } + assert_response :unprocessable_entity + assert_not_empty @goal.reload.goal_accounts + end + + test "update rejects a cross-currency account attachment" do + # Regression: sync_linked_accounts! used to call goal_accounts.create! + # directly, bypassing Goal#linked_accounts_must_match_goal_currency. + eur_account = Account.create!( + family: @goal.family, + accountable: Depository.new, + name: "EUR Checking", + currency: "EUR", + balance: 100 + ) + before_ids = @goal.goal_accounts.pluck(:account_id).sort + + patch goal_url(@goal), params: { goal: { account_ids: [ eur_account.id ] } } + + assert_response :unprocessable_entity + assert_equal before_ids, @goal.reload.goal_accounts.pluck(:account_id).sort + end + + test "pause/resume/complete/archive/unarchive flow" do + fresh = goals(:emergency_fund) + patch pause_goal_url(fresh) + assert fresh.reload.paused? + patch resume_goal_url(fresh) + assert fresh.reload.active? + patch complete_goal_url(fresh) + assert fresh.reload.completed? + patch archive_goal_url(fresh) + assert fresh.reload.archived? + patch unarchive_goal_url(fresh) + assert fresh.reload.active? + end + + test "destroy on non-archived is rejected" do + assert_no_difference "Goal.count" do + delete goal_url(@goal) + end + assert_redirected_to goal_path(@goal) + end + + test "destroy on archived deletes" do + @goal.archive! + assert_difference "Goal.count", -1 do + delete goal_url(@goal) + end + assert_redirected_to goals_path + end + + test "index KPI swaps to 'All caught up' when every tracked goal is reached" do + family = users(:family_admin).family + family.goals.destroy_all + # Real reached state: target $1 against the depository fixture's + # $5000 balance. Stubbing :status hides whether the controller + # actually reads the right method on each goal. + build_goal(family, "Wedding", target_amount: 1, target_date: 1.year.from_now) + + get goals_url + assert_response :success + assert_match(/All caught up/i, response.body) + assert_match(/1\s*reached/i, response.body) + end + + test "index KPI 'on track' denominator excludes no-target-date goals" do + family = users(:family_admin).family + family.goals.destroy_all + # One trackable goal (has target_date) + one open-ended (no target_date). + # The trackable one should be the only thing in the denominator; + # open-ended goals can't be off pace because they have no required pace. + build_goal(family, "House", target_amount: 1_000_000, target_date: 1.year.from_now) + build_goal(family, "Emergency", target_amount: 1_000_000, target_date: nil) + + get goals_url + assert_response :success + # Expect "0 of 1" — the open-ended goal stays out of the fraction + # even though it's active. + assert_match(/0\s*of\s*1/i, response.body) + assert_match(/without a deadline/i, response.body) + end + + private + def build_goal(family, name, target_amount: 1_000_000, target_date: nil) + g = family.goals.new(name: name, target_amount: target_amount, target_date: target_date, currency: "USD") + g.goal_accounts.build(account: @depository) + g.save! + g + end + + public + + test "create ignores forbidden params (family_id, state)" do + family = users(:family_admin).family + other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC") + + assert_difference -> { family.goals.count }, 1 do + post goals_url, params: { + goal: { + name: "Hijack target", + target_amount: 100, + currency: "USD", + state: "archived", + family_id: other_family.id, + account_ids: [ @depository.id ] + } + } + end + + goal = family.goals.order(:created_at).last + # Strong params must strip both `state` (AASM-managed) and `family_id` + # (cross-family pivot) — otherwise a crafted POST would create rows + # outside the current family or skip the active-state assumption. + assert_equal "active", goal.state + assert_equal family.id, goal.family_id + end + + test "another family's goal returns 404" do + other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC") + other_account = Account.create!(family: other_family, accountable: Depository.new, name: "Foreign", currency: "USD", balance: 100) + other_goal = other_family.goals.new(name: "Foreign goal", target_amount: 100, currency: "USD") + other_goal.goal_accounts.build(account: other_account) + other_goal.save! + + get goal_url(other_goal) + assert_redirected_to goals_path + assert_equal I18n.t("goals.errors.not_found"), flash[:alert] + end +end diff --git a/test/fixtures/goal_accounts.yml b/test/fixtures/goal_accounts.yml new file mode 100644 index 000000000..a9d1a47b3 --- /dev/null +++ b/test/fixtures/goal_accounts.yml @@ -0,0 +1,15 @@ +vacation_italy_depository: + goal: vacation_italy + account: depository + +vacation_italy_connected: + goal: vacation_italy + account: connected + +emergency_fund_depository: + goal: emergency_fund + account: depository + +car_paydown_depository: + goal: car_paydown + account: depository diff --git a/test/fixtures/goal_pledges.yml b/test/fixtures/goal_pledges.yml new file mode 100644 index 000000000..ee4f2a8bf --- /dev/null +++ b/test/fixtures/goal_pledges.yml @@ -0,0 +1,26 @@ +open_transfer: + goal: vacation_italy + account: depository + amount: 200 + currency: USD + kind: transfer + status: open + expires_at: <%= 7.days.from_now %> + +matched_transfer: + goal: vacation_italy + account: connected + amount: 300 + currency: USD + kind: transfer + status: matched + expires_at: <%= 6.days.ago %> + +expired_transfer: + goal: emergency_fund + account: depository + amount: 100 + currency: USD + kind: transfer + status: expired + expires_at: <%= 8.days.ago %> diff --git a/test/fixtures/goals.yml b/test/fixtures/goals.yml new file mode 100644 index 000000000..6c0faafaa --- /dev/null +++ b/test/fixtures/goals.yml @@ -0,0 +1,25 @@ +vacation_italy: + family: dylan_family + name: Vacation in Italy + target_amount: 5000 + currency: USD + target_date: <%= 4.months.from_now.to_date %> + color: "#4da568" + state: active + +emergency_fund: + family: dylan_family + name: Emergency fund + target_amount: 10000 + currency: USD + color: "#6471eb" + state: active + +car_paydown: + family: dylan_family + name: Paid-off car + target_amount: 8000 + currency: USD + target_date: <%= 12.months.from_now.to_date %> + color: "#e99537" + state: paused diff --git a/test/jobs/sweep_expired_goal_pledges_job_test.rb b/test/jobs/sweep_expired_goal_pledges_job_test.rb new file mode 100644 index 000000000..dce1bc908 --- /dev/null +++ b/test/jobs/sweep_expired_goal_pledges_job_test.rb @@ -0,0 +1,64 @@ +require "test_helper" + +class SweepExpiredGoalPledgesJobTest < ActiveJob::TestCase + test "marks open pledges past expires_at as expired" do + pledge = goal_pledges(:open_transfer) + pledge.update_columns(expires_at: 1.day.ago) + + SweepExpiredGoalPledgesJob.perform_now + + assert pledge.reload.status_expired? + end + + test "leaves open pledges still inside window alone" do + pledge = goal_pledges(:open_transfer) + assert pledge.expires_at > Time.current + + SweepExpiredGoalPledgesJob.perform_now + + assert pledge.reload.status_open? + end + + test "ignores already-matched, cancelled, or already-expired pledges" do + matched = goal_pledges(:matched_transfer) + expired = goal_pledges(:expired_transfer) + # Build the cancelled pledge inline rather than baking it into fixtures + # so the cancelled-path coverage stays test-local. + cancelled = matched.goal.goal_pledges.create!( + account: matched.account, + amount: 25, + currency: matched.currency, + kind: matched.kind, + status: "cancelled", + expires_at: 2.days.ago + ) + + SweepExpiredGoalPledgesJob.perform_now + + assert matched.reload.status_matched? + assert expired.reload.status_expired? + assert cancelled.reload.status_cancelled? + end + + test "logs and continues when a single pledge fails to expire" do + pledge = goal_pledges(:open_transfer) + pledge.update_columns(expires_at: 1.day.ago) + GoalPledge.any_instance.stubs(:expire!).raises(StandardError, "boom") + + assert_nothing_raised { SweepExpiredGoalPledgesJob.perform_now } + end + + test "second pass is a no-op (idempotent)" do + pledge = goal_pledges(:open_transfer) + pledge.update_columns(expires_at: 1.day.ago) + + SweepExpiredGoalPledgesJob.perform_now + first_updated_at = pledge.reload.updated_at + + travel 1.second do + SweepExpiredGoalPledgesJob.perform_now + assert_equal first_updated_at.to_i, pledge.reload.updated_at.to_i, + "second sweep should not touch a pledge that is already expired" + end + end +end diff --git a/test/models/assistant/function/create_goal_test.rb b/test/models/assistant/function/create_goal_test.rb new file mode 100644 index 000000000..0631a6487 --- /dev/null +++ b/test/models/assistant/function/create_goal_test.rb @@ -0,0 +1,88 @@ +require "test_helper" + +class Assistant::Function::CreateGoalTest < ActiveSupport::TestCase + setup do + @user = users(:family_admin) + @family = @user.family + @depository = accounts(:depository) + @fn = Assistant::Function::CreateGoal.new(@user) + end + + test "to_definition returns valid JSON shape" do + definition = @fn.to_definition + assert_equal "create_goal", definition[:name] + assert_kind_of String, definition[:description] + assert_equal "object", definition[:params_schema][:type] + assert_includes definition[:params_schema][:required], "name" + assert_includes definition[:params_schema][:required], "target_amount" + assert_includes definition[:params_schema][:required], "linked_account_names" + end + + test "creates a goal with linked accounts" do + assert_difference -> { Goal.count } => 1, + -> { GoalAccount.count } => 1 do + result = @fn.call( + "name" => "Vacation", + "target_amount" => 1500, + "target_date" => 3.months.from_now.to_date.iso8601, + "linked_account_names" => [ @depository.name ] + ) + + assert result[:success] + assert_match(/Vacation/, result[:message]) + assert result[:url].present? + assert_equal "USD", result[:currency] + end + end + + test "soft error when name is missing" do + result = @fn.call("target_amount" => 100, "linked_account_names" => [ @depository.name ]) + assert_equal false, result[:success] + assert_equal "name_required", result[:error] + end + + test "soft error when target_amount is zero" do + result = @fn.call("name" => "X", "target_amount" => 0, "linked_account_names" => [ @depository.name ]) + assert_equal false, result[:success] + assert_equal "target_amount_invalid", result[:error] + end + + test "soft error when no linked accounts" do + result = @fn.call("name" => "X", "target_amount" => 100, "linked_account_names" => []) + assert_equal false, result[:success] + assert_equal "no_linked_accounts", result[:error] + assert_kind_of Array, result[:available_accounts] + assert(result[:available_accounts].all? { |a| a.is_a?(Hash) && a.key?(:name) }) + end + + test "soft error when account name doesn't match" do + result = @fn.call("name" => "X", "target_amount" => 100, "linked_account_names" => [ "Nonexistent Account" ]) + assert_equal false, result[:success] + assert_equal "unknown_accounts", result[:error] + assert_includes result[:unknown_names], "Nonexistent Account" + end + + test "soft error when currencies differ across linked accounts" do + eur = Account.create!(family: @family, accountable: Depository.new, name: "EUR Account", currency: "EUR", balance: 100) + result = @fn.call( + "name" => "Mixed", + "target_amount" => 100, + "linked_account_names" => [ @depository.name, eur.name ] + ) + assert_equal false, result[:success] + assert_equal "currency_mismatch", result[:error] + end + + test "scopes to the user's family" do + other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC") + Account.create!(family: other_family, accountable: Depository.new, name: "Foreign Checking", currency: "USD", balance: 100) + + result = @fn.call( + "name" => "X", + "target_amount" => 100, + "linked_account_names" => [ "Foreign Checking" ] + ) + assert_equal false, result[:success] + assert_equal "unknown_accounts", result[:error] + end +end diff --git a/test/models/goal_pledge/reconciler_test.rb b/test/models/goal_pledge/reconciler_test.rb new file mode 100644 index 000000000..52a25c72c --- /dev/null +++ b/test/models/goal_pledge/reconciler_test.rb @@ -0,0 +1,93 @@ +require "test_helper" + +class GoalPledge::ReconcilerTest < ActiveSupport::TestCase + setup do + @pledge = goal_pledges(:open_transfer) + @account = @pledge.account + end + + test "matches and stamps a posted Transaction within tolerance" do + entry = create_transaction_entry(amount: -200, date: @pledge.created_at.to_date) + + GoalPledge::Reconciler.new(entry).run + + assert_equal @pledge.id, entry.transaction.reload.extra.dig("goal", "pledge_id") + assert @pledge.reload.status_matched? + assert_equal entry.transaction.id, @pledge.matched_transaction_id + end + + test "skips entries already stamped with a pledge_id" do + entry = create_transaction_entry(amount: -200, date: @pledge.created_at.to_date) + entry.transaction.update!(extra: { "goal" => { "pledge_id" => "abc" } }) + + GoalPledge::Reconciler.new(entry).run + + assert_equal "abc", entry.transaction.reload.extra.dig("goal", "pledge_id") + assert_not @pledge.reload.status_matched? + end + + test "skips pledges outside amount tolerance" do + entry = create_transaction_entry(amount: -300, date: @pledge.created_at.to_date) + + GoalPledge::Reconciler.new(entry).run + + assert_not @pledge.reload.status_matched? + assert_nil entry.transaction.reload.extra.dig("goal", "pledge_id") + end + + test "skips entries on accounts with no open pledges" do + other_account = accounts(:investment) + entry = create_transaction_entry(amount: -200, date: Date.current, account: other_account) + + GoalPledge::Reconciler.new(entry).run + + assert_not @pledge.reload.status_matched? + end + + test "ignores excluded entries" do + entry = create_transaction_entry(amount: -200, date: @pledge.created_at.to_date, excluded: true) + + GoalPledge::Reconciler.new(entry).run + + assert_not @pledge.reload.status_matched? + end + + test "manual_save kind matches on Valuation entries" do + manual_pledge = @pledge.goal.goal_pledges.create!( + account: @account, + amount: 150, + currency: "USD", + kind: "manual_save" + ) + + entry = create_valuation_entry(amount: 150, date: manual_pledge.created_at.to_date) + + GoalPledge::Reconciler.new(entry).run + + assert manual_pledge.reload.status_matched? + end + + private + def create_transaction_entry(amount:, date:, account: @account, excluded: false) + Entry.create!( + account: account, + name: "Test", + amount: BigDecimal(amount.to_s), + currency: "USD", + date: date, + excluded: excluded, + entryable: Transaction.new(kind: "standard") + ) + end + + def create_valuation_entry(amount:, date:, account: @account) + Entry.create!( + account: account, + name: "Manual balance", + amount: BigDecimal(amount.to_s), + currency: "USD", + date: date, + entryable: Valuation.new(kind: "reconciliation") + ) + end +end diff --git a/test/models/goal_pledge_test.rb b/test/models/goal_pledge_test.rb new file mode 100644 index 000000000..62c84f9ca --- /dev/null +++ b/test/models/goal_pledge_test.rb @@ -0,0 +1,166 @@ +require "test_helper" + +class GoalPledgeTest < ActiveSupport::TestCase + setup do + @goal = goals(:vacation_italy) + @account = accounts(:depository) + @pledge = goal_pledges(:open_transfer) + end + + test "valid fixture pledge saves" do + assert @pledge.valid? + end + + test "amount must be positive" do + @pledge.amount = 0 + assert_not @pledge.valid? + end + + test "account must be linked to goal" do + other_account = accounts(:investment) + pledge = @goal.goal_pledges.new(account: other_account, amount: 50, currency: "USD") + assert_not pledge.valid? + assert_includes pledge.errors[:account], "Pick one of the goal's linked accounts." + end + + test "currency must match goal currency" do + @pledge.currency = "EUR" + assert_not @pledge.valid? + assert_includes @pledge.errors[:currency], "Pledge currency must match the goal currency." + end + + test "defaults populate on create" do + pledge = @goal.goal_pledges.new(account: @account, amount: 50) + pledge.valid? + assert_equal "open", pledge.status + assert_equal "transfer", pledge.kind + assert_not_nil pledge.expires_at + assert pledge.expires_at > Time.current + assert_equal @goal.currency, pledge.currency + end + + test "matches? returns true within tolerances" do + entry = build_entry(account: @account, amount: -200.25, date: @pledge.created_at.to_date + 1.day) + assert @pledge.matches?(entry) + end + + test "matches? returns false outside date window" do + entry = build_entry(account: @account, amount: -200, date: @pledge.created_at.to_date + 10.days) + assert_not @pledge.matches?(entry) + end + + test "matches? returns false outside amount tolerance" do + entry = build_entry(account: @account, amount: -250, date: @pledge.created_at.to_date) + assert_not @pledge.matches?(entry) + end + + test "matches? returns true within ratio tolerance" do + entry = build_entry(account: @account, amount: -201.99, date: @pledge.created_at.to_date) + assert @pledge.matches?(entry) + end + + test "matches? returns false on wrong account" do + other_account = accounts(:connected) + entry = build_entry(account: other_account, amount: -200, date: @pledge.created_at.to_date) + assert_not @pledge.matches?(entry) + end + + test "matches? rejects outflows of the same magnitude on transfer pledges" do + # Sure convention: outflow > 0, inflow < 0. A +$200 purchase must not + # satisfy a $200 transfer pledge after the .abs amount-tolerance step. + entry = build_entry(account: @account, amount: 200, date: @pledge.created_at.to_date) + assert_not @pledge.matches?(entry) + end + + test "matches? returns false on already-matched pledge" do + matched = goal_pledges(:matched_transfer) + entry = build_entry(account: matched.account, amount: -matched.amount.to_d, date: matched.created_at.to_date) + assert_not matched.matches?(entry) + end + + test "extend! pushes expires_at forward" do + before = @pledge.expires_at + @pledge.extend! + assert @pledge.expires_at > before + 6.days + end + + test "matches? widens upper bound to expires_at after extend!" do + # Day 8 — past the default 5-day creation-anchored window but inside the + # extended expiry window. Without the widening this would be a regression + # of B7 (extend doesn't actually buy match runway). + @pledge.extend! + far_date = @pledge.created_at.to_date + 8.days + assert far_date <= @pledge.expires_at.to_date + entry = build_entry(account: @account, amount: -200, date: far_date) + assert @pledge.matches?(entry) + end + + test "matches? rejects entries past extended expires_at" do + @pledge.extend! + far_date = @pledge.expires_at.to_date + 1.day + entry = build_entry(account: @account, amount: -200, date: far_date) + assert_not @pledge.matches?(entry) + end + + test "duplicate open pledge for same goal+account+amount is rejected on create" do + dup = @goal.goal_pledges.new(account: @account, amount: @pledge.amount, currency: @goal.currency) + assert_not dup.valid? + assert dup.errors[:base].any? { |m| m.include?("open pledge") } + end + + test "duplicate validation does not block different amounts" do + dup = @goal.goal_pledges.new(account: @account, amount: @pledge.amount.to_d + 1, currency: @goal.currency) + assert dup.valid?, dup.errors.full_messages.to_sentence + end + + test "extend! raises for non-open pledge" do + pledge = goal_pledges(:matched_transfer) + assert_raises(GoalPledge::NotOpenError) { pledge.extend! } + end + + test "cancel! transitions open to cancelled" do + @pledge.cancel! + assert @pledge.status_cancelled? + end + + test "expire! transitions open to expired" do + @pledge.expire! + assert @pledge.status_expired? + end + + test "days_left counts down" do + @pledge.expires_at = 3.days.from_now + assert_includes 2..3, @pledge.days_left + end + + test "days_left returns 0 for non-open" do + pledge = goal_pledges(:matched_transfer) + assert_equal 0, pledge.days_left + end + + test "amount cannot be negative" do + @pledge.amount = -5 + assert_not @pledge.valid? + assert_includes @pledge.errors[:amount], "must be greater than 0" + end + + test "expire! is a no-op on an already-expired pledge" do + @pledge.expire! + expired_at = @pledge.updated_at + travel 1.second do + @pledge.expire! + assert_equal expired_at.to_i, @pledge.updated_at.to_i, "second expire! should not touch the row" + end + assert @pledge.status_expired? + end + + test "cancel! raises on non-open pledge" do + pledge = goal_pledges(:matched_transfer) + assert_raises(GoalPledge::NotOpenError) { pledge.cancel! } + end + + private + def build_entry(account:, amount:, date:) + OpenStruct.new(account_id: account.id, amount: BigDecimal(amount.to_s), date: date.to_date) + end +end diff --git a/test/models/goal_test.rb b/test/models/goal_test.rb new file mode 100644 index 000000000..70529f149 --- /dev/null +++ b/test/models/goal_test.rb @@ -0,0 +1,251 @@ +require "test_helper" + +class GoalTest < ActiveSupport::TestCase + include EntriesTestHelper + + setup do + @family = families(:dylan_family) + @depository = accounts(:depository) + @connected = accounts(:connected) + @goal = goals(:vacation_italy) + end + + test "valid fixture goal saves" do + assert @goal.valid? + end + + test "name is required" do + @goal.name = "" + assert_not @goal.valid? + assert_includes @goal.errors[:name], "can't be blank" + end + + test "target_amount must be positive" do + @goal.target_amount = 0 + assert_not @goal.valid? + end + + test "color must match hex format" do + @goal.color = "red; cursor: pointer" + assert_not @goal.valid? + assert_includes @goal.errors[:color], "is invalid" + end + + test "color accepts standard 6-digit hex" do + @goal.color = "#abcdef" + assert @goal.valid?, @goal.errors.full_messages.to_sentence + end + + test "display_status follows AASM state after pause! on the same instance" do + @goal.update!(color: "#4da568") if @goal.color.blank? + initial = @goal.display_status + @goal.pause! + assert_equal :paused, @goal.display_status, "stale memo would have returned #{initial.inspect}" + end + + test "must have at least one linked account on create" do + new_goal = @family.goals.new(name: "Test", target_amount: 100, currency: "USD") + assert_not new_goal.valid? + assert_match(/at least one/i, new_goal.errors[:base].join) + end + + test "linked accounts must be depository" do + investment = accounts(:investment) + new_goal = @family.goals.new(name: "Test", target_amount: 100, currency: "USD") + new_goal.goal_accounts.build(account: investment) + assert_not new_goal.valid? + assert_includes new_goal.errors[:linked_accounts], "All linked accounts must be depository (checking, savings, HSA, CD, money-market)." + end + + test "linked accounts must belong to family" do + other_family = Family.create!(name: "Other", currency: "USD", locale: "en", country: "US", timezone: "UTC") + foreign_account = Account.create!( + family: other_family, + accountable: Depository.new, + name: "Foreign", + currency: "USD", + balance: 100 + ) + new_goal = @family.goals.new(name: "T", target_amount: 100, currency: "USD") + new_goal.goal_accounts.build(account: foreign_account) + assert_not new_goal.valid? + assert_includes new_goal.errors[:linked_accounts], "Linked accounts must belong to the same family as the goal." + end + + test "linked accounts must share currency with goal" do + eur_account = Account.create!( + family: @family, + accountable: Depository.new, + name: "Euro Cash", + currency: "EUR", + balance: 100 + ) + new_goal = @family.goals.new(name: "T", target_amount: 100, currency: "USD") + new_goal.goal_accounts.build(account: eur_account) + assert_not new_goal.valid? + assert_includes new_goal.errors[:linked_accounts], "All linked accounts must share the same currency." + end + + test "currency can't change once linked accounts exist" do + assert @goal.linked_accounts.exists? + @goal.currency = "EUR" + assert_not @goal.valid? + assert_includes @goal.errors[:currency], "Can't change the currency after the goal is linked to accounts." + end + + test "current_balance sums linked account balances" do + expected = @goal.linked_accounts.sum(&:balance).to_d + assert_equal expected, @goal.current_balance.to_d + end + + test "progress_percent caps at 100" do + @goal.target_amount = 1 + assert_equal 100, @goal.progress_percent + end + + test "progress_percent is 0 for empty active goal" do + fresh = goals(:car_paydown) + fresh.update!(target_amount: 10_000) + fresh.linked_accounts.update_all(balance: 0) + # Refetch instead of poking @current_balance directly so the test + # exercises the real memo lifecycle (a request reads progress_percent + # on a freshly-loaded record after the underlying balances changed). + reloaded = Goal.find(fresh.id) + assert_equal 0, reloaded.progress_percent + end + + test "remaining_amount is non-negative" do + @goal.target_amount = 1 + assert_equal 0, @goal.remaining_amount + end + + test "pace is zero on a goal whose linked accounts have no transactions" do + fresh_account = Account.create!( + family: @family, + accountable: Depository.new, + name: "Empty Savings", + currency: "USD", + balance: 0 + ) + fresh = @family.goals.create!( + name: "Fresh goal", + target_amount: 100, + currency: "USD" + ) { |g| g.goal_accounts.build(account: fresh_account) } + + assert_equal 0, fresh.pace.to_d + end + + test "pace averages 90-day net inflow, excluding pending and excluded entries" do + account = Account.create!( + family: @family, + accountable: Depository.new, + name: "Pace Savings", + currency: "USD", + balance: 0 + ) + goal = @family.goals.create!( + name: "Pace goal", + target_amount: 10_000, + currency: "USD" + ) { |g| g.goal_accounts.build(account: account) } + + # Three inflows over the 90-day window. Sure convention: inflows are + # negative. Net = -900 → pace = 900 / 3 = 300. + create_transaction(account: account, amount: -300, date: 80.days.ago.to_date) + create_transaction(account: account, amount: -300, date: 40.days.ago.to_date) + create_transaction(account: account, amount: -300, date: 5.days.ago.to_date) + + # Pending inflow that must be excluded by `Transaction.excluding_pending`. + pending_entry = create_transaction(account: account, amount: -1_000, date: 10.days.ago.to_date) + pending_entry.transaction.update!(extra: { "plaid" => { "pending" => true } }) + + # User-excluded outflow that must be excluded by `entries.excluded = false`. + excluded_entry = create_transaction(account: account, amount: 500, date: 20.days.ago.to_date) + excluded_entry.update!(excluded: true) + + # Entry outside the 90-day window — must be ignored. + create_transaction(account: account, amount: -10_000, date: 200.days.ago.to_date) + + assert_equal 300, goal.pace.to_d + end + + test "months_of_runway is nil when goal has a target date" do + assert_not_nil @goal.target_date + assert_nil @goal.months_of_runway + end + + test "months_of_runway is nil when pace is zero" do + fresh = goals(:emergency_fund) + assert_nil fresh.months_of_runway + end + + test "AASM transitions" do + fresh = goals(:emergency_fund) + assert fresh.active? + fresh.pause! + assert fresh.paused? + fresh.resume! + assert fresh.active? + fresh.complete! + assert fresh.completed? + fresh.archive! + assert fresh.archived? + fresh.unarchive! + assert fresh.active? + end + + test "status: reached when balance >= target" do + @goal.target_amount = 1 + assert_equal :reached, @goal.status + end + + test "status: no_target_date when target_date is nil" do + @goal.target_date = nil + @goal.target_amount = 10_000 + @goal.linked_accounts.update_all(balance: 100) + assert_equal :no_target_date, @goal.status + end + + test "display_status returns :archived for archived goal regardless of progress" do + @goal.save! + @goal.archive! + assert_equal :archived, @goal.display_status + end + + test "display_status returns :paused for paused goal regardless of progress" do + @goal.save! + @goal.pause! + assert_equal :paused, @goal.display_status + end + + test "display_status falls through to status for active goals" do + @goal.target_amount = 1 + assert_equal :reached, @goal.display_status + end + + test "advisory_lock_key_for is stable per family" do + k1 = Goal.advisory_lock_key_for(@family.id) + k2 = Goal.advisory_lock_key_for(@family.id) + assert_equal k1, k2 + assert_kind_of Integer, k1 + end + + test "any_connected_account? reflects plaid_account presence" do + assert @goal.any_connected_account? + only_manual = goals(:emergency_fund) + only_manual.goal_accounts.where(account_id: @connected.id).destroy_all + assert_not only_manual.reload.any_connected_account? + end + + test "pledge_action_label_key flips on manual-only goals" do + assert_equal "goals.show.pledge_just_transferred", @goal.pledge_action_label_key + @goal.goal_accounts.where(account_id: @connected.id).destroy_all + # After removing the only connected account, the goal is manual-only; + # the copy must flip to "pledge_just_saved" so users aren't told to + # wait for a sync that won't run. Refetch to exercise the real + # request lifecycle rather than poking a memo on the same instance. + reloaded = Goal.find(@goal.id) + assert_equal "goals.show.pledge_just_saved", reloaded.pledge_action_label_key + end +end diff --git a/workers/preview/src/index.ts b/workers/preview/src/index.ts index 93c9c8991..addd30bc4 100644 --- a/workers/preview/src/index.ts +++ b/workers/preview/src/index.ts @@ -96,7 +96,7 @@ export class RailsContainer extends Container { pingEndpoint = "localhost/up"; entrypoint = ["/rails/bin/preview-entrypoint", "bundle", "exec", "puma", "-C", "config/puma.rb"]; envVars = { - RAILS_ENV: "production", + RAILS_ENV: "development", RAILS_LOG_TO_STDOUT: "true", RAILS_SERVE_STATIC_FILES: "true", SECRET_KEY_BASE: "preview-secret-key-base-for-pr-${PR_NUMBER}",