diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index eff9c847a..290a0c5e0 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -82,6 +82,39 @@ jobs: sed -i "s/\${PR_NUMBER}/${{ github.event.pull_request.number }}/g" src/index.ts cat wrangler.toml + - name: Delete existing preview container app before redeploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + working-directory: workers/preview + run: | + set -euo pipefail + CONTAINER_NAME="sure-preview-${{ github.event.pull_request.number }}-railscontainer" + echo "Looking for stale preview container app: $CONTAINER_NAME" + + CONTAINER_ID=$(npx wrangler containers list --json | jq -r --arg NAME "$CONTAINER_NAME" ' + map(select((.name // .application_name // .app_name // "") == $NAME)) + | first + | (.id // .container_id // .application_id // empty) + ') + + if [ -n "$CONTAINER_ID" ]; then + echo "Deleting stale preview container app $CONTAINER_NAME ($CONTAINER_ID)" + npx wrangler containers delete "$CONTAINER_ID" + else + echo "No stale preview container app found; continuing" + fi + + - name: Delete existing preview Worker before redeploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + working-directory: workers/preview + run: | + WORKER_NAME="sure-preview-${{ github.event.pull_request.number }}" + echo "Ensuring fresh preview deployment for $WORKER_NAME" + npx wrangler delete --name "$WORKER_NAME" --force || echo "Existing preview not found; continuing" + - name: Create GitHub Deployment id: deployment uses: actions/github-script@v7 diff --git a/Dockerfile.preview b/Dockerfile.preview index 3b687b964..705f8e0fb 100644 --- a/Dockerfile.preview +++ b/Dockerfile.preview @@ -84,6 +84,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" @@ -217,7 +233,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/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 2092700c6..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; @@ -34,6 +57,49 @@ export default class extends Controller { } } + // 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. diff --git a/app/components/DS/pill.html.erb b/app/components/DS/pill.html.erb index 6712f0602..2dc15e0ec 100644 --- a/app/components/DS/pill.html.erb +++ b/app/components/DS/pill.html.erb @@ -9,7 +9,9 @@ title="<%= title || t("ds.pill.aria_label", label: label) %>"> <% else %> - <% if show_dot %> + <% if icon %> + <%= helpers.icon(icon, size: "xs", color: "current") %> + <% elsif show_dot %> <% end %> diff --git a/app/components/DS/pill.rb b/app/components/DS/pill.rb index 0e742612c..e3c7ac2b3 100644 --- a/app/components/DS/pill.rb +++ b/app/components/DS/pill.rb @@ -1,20 +1,23 @@ class DS::Pill < DesignSystemComponent - TONES = %i[violet indigo fuchsia amber gray].freeze + TONES = %i[violet indigo fuchsia amber green gray].freeze STYLES = %i[soft filled outline].freeze SIZES = %i[sm md].freeze - attr_reader :label, :tone, :style, :size, :show_dot, :dot_only, :title + attr_reader :label, :tone, :style, :size, :show_dot, :dot_only, :title, :icon - # Generic inline pill primitive. Currently the home of Beta / Canary - # markers; can be reused for future tags (NEW, PRO, EXPERIMENTAL, etc.) - # without forking the component. + # Generic inline pill primitive. Used for Beta / Canary markers and goal + # status badges, but designed so any future tag (NEW, PRO, EXPERIMENTAL, + # etc.) reuses the same shape without forking. # # - `dot_only: true` renders only the colored dot (no label, no border). # Use on the collapsed sidebar nav, where there's no room for the label. - # - Sure has full violet / indigo / fuchsia / amber / gray ramps in the - # design system; this component picks named tokens at render time. No - # raw hex. - def initialize(label: nil, tone: :violet, style: :soft, size: :sm, show_dot: true, dot_only: false, title: nil) + # - `icon:` overrides the dot with a Lucide icon (sized xs, current color). + # Useful for status pills that benefit from a glyph (circle-check, + # triangle-alert, pause, etc.) rather than the generic dot. + # - Sure has full violet / indigo / fuchsia / amber / green / gray ramps + # in the design system; this component picks named tokens at render + # time. No raw hex. + def initialize(label: nil, tone: :violet, style: :soft, size: :sm, show_dot: true, dot_only: false, title: nil, icon: nil) @label = label || I18n.t("ds.pill.default_label", default: "Beta") @tone = TONES.include?(tone.to_sym) ? tone.to_sym : :violet @style = STYLES.include?(style.to_sym) ? style.to_sym : :soft @@ -22,15 +25,21 @@ class DS::Pill < DesignSystemComponent @show_dot = show_dot @dot_only = dot_only @title = title + @icon = icon 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)" }, - 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)" } + 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)" } }[tone] end diff --git a/app/components/goals/card_component.html.erb b/app/components/goals/card_component.html.erb index df05906f4..51684e8fd 100644 --- a/app/components/goals/card_component.html.erb +++ b/app/components/goals/card_component.html.erb @@ -1,5 +1,5 @@ -
" - data-goals-filter-target="card" +
" + <% if filterable %> data-goals-filter-target="card"<% end %> data-goal-name="<%= goal.name %>" data-goal-status="<%= goal.display_status %>">
@@ -58,6 +58,8 @@ <%= render Goals::AccountStackComponent.new(accounts: linked_accounts, color_map: goal.account_color_map) %> <%= linked_accounts_count_label %>
- <%= footer_line %> + + <%= 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 index 6c0a1e287..4d2a22742 100644 --- a/app/components/goals/card_component.rb +++ b/app/components/goals/card_component.rb @@ -2,11 +2,12 @@ class Goals::CardComponent < ApplicationComponent RING_SIZE = 64 RING_STROKE = 6 - def initialize(goal:) + def initialize(goal:, filterable: true) @goal = goal + @filterable = filterable end - attr_reader :goal + attr_reader :goal, :filterable def progress_percent goal.progress_percent @@ -25,6 +26,17 @@ class Goals::CardComponent < ApplicationComponent @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 diff --git a/app/components/goals/status_pill_component.html.erb b/app/components/goals/status_pill_component.html.erb index f254a31f8..e2f64df78 100644 --- a/app/components/goals/status_pill_component.html.erb +++ b/app/components/goals/status_pill_component.html.erb @@ -1,4 +1 @@ - - <%= helpers.icon(icon_name, size: "xs", color: "current") %> - <%= label %> - +<%= 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 index d1ec2179f..134500b62 100644 --- a/app/components/goals/status_pill_component.rb +++ b/app/components/goals/status_pill_component.rb @@ -1,19 +1,17 @@ class Goals::StatusPillComponent < ApplicationComponent - # Text colors here intentionally use palette steps (green/yellow/gray-700) - # instead of the `text-success` / `text-warning` / `text-secondary` tokens - # because the functional tokens drop below WCAG 1.4.3 4.5:1 on tinted - # surfaces in light mode (~2.88:1 / 3.0:1 / 4.16:1). Each variant carries - # a theme-dark: override so the dark-700 text doesn't disappear against - # the dark-mode tinted surface. Local override only; revert once - # we-promise/sure#1736 lands token-level fixes. + # 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: { classes: "bg-green-500/10 text-green-700 theme-dark:text-green-300", icon: "circle-check" }, - behind: { classes: "bg-surface-inset text-yellow-700 theme-dark:text-yellow-300", icon: "triangle-alert" }, - reached: { classes: "bg-green-500/10 text-green-700 theme-dark:text-green-300", icon: "star" }, - completed: { classes: "bg-green-500/10 text-green-700 theme-dark:text-green-300", icon: "circle-check-big" }, - no_target_date: { classes: "bg-surface-inset text-gray-700 theme-dark:text-gray-200", icon: "infinity" }, - paused: { classes: "bg-surface-inset text-gray-700 theme-dark:text-gray-200", icon: "pause" }, - archived: { classes: "bg-surface-inset text-gray-700 theme-dark:text-gray-200", icon: "archive" } + 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:) @@ -31,12 +29,4 @@ class Goals::StatusPillComponent < ApplicationComponent def label I18n.t("goals.status.#{status_key}", default: status_key.to_s.titleize) end - - def classes - variant[:classes] - end - - def icon_name - variant[:icon] - end end diff --git a/app/controllers/goal_pledges_controller.rb b/app/controllers/goal_pledges_controller.rb index c3c9c438d..45c8fdec0 100644 --- a/app/controllers/goal_pledges_controller.rb +++ b/app/controllers/goal_pledges_controller.rb @@ -1,4 +1,5 @@ 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 diff --git a/app/controllers/goals_controller.rb b/app/controllers/goals_controller.rb index afba329b6..c54a44371 100644 --- a/app/controllers/goals_controller.rb +++ b/app/controllers/goals_controller.rb @@ -1,4 +1,5 @@ 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 @@ -19,9 +20,10 @@ class GoalsController < ApplicationController .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 the separate - # collapsed-by-default section below. + # 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 @@ -194,8 +196,9 @@ class GoalsController < ApplicationController currency = family.primary_currency_code today = Date.current - velocity_30d = family.savings_inflow_velocity(range: (today - 30)..today) - velocity_prior_30d = family.savings_inflow_velocity(range: (today - 60)..(today - 31)) + 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) @@ -219,6 +222,20 @@ class GoalsController < ApplicationController 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), @@ -232,6 +249,7 @@ class GoalsController < ApplicationController behind_count: behind, no_date_count: no_date, paused_count: paused, + tracked_total: tracked_total, active_total: active_goals.size } 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/color_icon_picker_controller.js b/app/javascript/controllers/color_icon_picker_controller.js index 55138dd4a..a208ba969 100644 --- a/app/javascript/controllers/color_icon_picker_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() { diff --git a/app/javascript/controllers/goal_projection_chart_controller.js b/app/javascript/controllers/goal_projection_chart_controller.js index 31e8d4f01..2b60c08c5 100644 --- a/app/javascript/controllers/goal_projection_chart_controller.js +++ b/app/javascript/controllers/goal_projection_chart_controller.js @@ -433,7 +433,12 @@ export default class extends Controller { .attr("pointer-events", "none") .style("display", "none"); - if (root.style.position !== "absolute") root.style.position = "relative"; + // 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.style.cssText = "position:absolute;pointer-events:none;display:none;background:var(--color-gray-900);color:var(--color-white);font-size:12px;line-height:1.35;padding:6px 8px;border-radius:6px;white-space:nowrap;z-index:5;box-shadow:0 2px 8px rgba(0,0,0,0.15);"; root.appendChild(tooltip); diff --git a/app/javascript/controllers/goals_filter_controller.js b/app/javascript/controllers/goals_filter_controller.js index b2479a440..5e68db6f8 100644 --- a/app/javascript/controllers/goals_filter_controller.js +++ b/app/javascript/controllers/goals_filter_controller.js @@ -32,6 +32,10 @@ export default class extends Controller { } } + disconnect() { + clearTimeout(this._urlSyncTimer); + } + filter() { const query = this.hasInputTarget ? this.inputTarget.value.toLocaleLowerCase().trim() @@ -60,7 +64,16 @@ export default class extends Controller { } this.updateEmptyState(visible, query, active); - this.#syncUrl(); + 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() { diff --git a/app/jobs/sweep_expired_goal_pledges_job.rb b/app/jobs/sweep_expired_goal_pledges_job.rb index 29a327683..bf2214764 100644 --- a/app/jobs/sweep_expired_goal_pledges_job.rb +++ b/app/jobs/sweep_expired_goal_pledges_job.rb @@ -3,6 +3,10 @@ class SweepExpiredGoalPledgesJob < ApplicationJob # 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! @@ -10,5 +14,9 @@ class SweepExpiredGoalPledgesJob < ApplicationJob 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/demo/generator.rb b/app/models/demo/generator.rb index 40be65630..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 diff --git a/app/models/family.rb b/app/models/family.rb index 6faafdf08..9cd33c853 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -58,22 +58,13 @@ class Family < ApplicationRecord # 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) - # Defensive scope: goal_id is already family-bound (this family's - # goals), but pinning family_id keeps cross-family bleed-through - # impossible if a goal_account ever ends up pointing at a foreign - # account through a future bug. - account_ids = accounts - .joins(:goal_accounts) - .where(goal_accounts: { goal_id: goals.select(:id) }) - .where(currency: primary_currency_code) - .distinct - .pluck(:id) - return 0 if account_ids.empty? + 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: account_ids, date: range) + .where(account_id: ids, date: range) .where(excluded: false) .merge(Transaction.excluding_pending) .sum(:amount) @@ -81,6 +72,39 @@ class Family < ApplicationRecord -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 index dbc96e4df..da4f597af 100644 --- a/app/models/goal.rb +++ b/app/models/goal.rb @@ -5,6 +5,7 @@ class Goal < ApplicationRecord 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 @@ -35,6 +36,8 @@ class Goal < ApplicationRecord end aasm column: :state do + after_all_transitions :reset_state_dependent_caches! + state :active, initial: true state :paused state :completed @@ -202,7 +205,7 @@ class Goal < ApplicationRecord 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, + required_monthly: monthly_target_amount&.to_f, currency: currency, status: status.to_s, projection_end_value: proj_end.to_f, @@ -397,6 +400,16 @@ class Goal < ApplicationRecord 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. diff --git a/app/views/goals/_color_picker.html.erb b/app/views/goals/_color_picker.html.erb index d3805d466..ffe30b51a 100644 --- a/app/views/goals/_color_picker.html.erb +++ b/app/views/goals/_color_picker.html.erb @@ -23,7 +23,7 @@ data-color-icon-picker-target="popup">
-

Color

+

<%= t("goals.color_picker.color_heading") %>

<% colors.each do |c| %>
-

Icon

+

<%= t("goals.color_picker.icon_heading") %>

<% icons.each do |icon_name| %>