Merge origin/feat/goals-v2-architecture; reconcile beta→preview rename

Remote branch added a beta_gated_nav_item helper + 'Gating the main nav'
docs section. Main concurrently renamed the beta-features gate to
preview-features (concern, predicate, JSONB key, locale flash). Rename
the new helper / partial local / pill marker to match preview naming and
port the nav-gating docs into gating-a-preview-feature.md so the
improvement survives the rename.

Resolved conflicts:
- db/schema.rb: take the later schema version (2026_05_19_100000).
- docs/llm-guides/gating-a-beta-feature.md: accept main's deletion;
  port the 'Gating the main nav' section into the preview guide.

Renames carried through to keep the gate wired end-to-end:
- application_helper.rb: beta_gated_nav_item → preview_gated_nav_item;
  beta_features_enabled? → preview_features_enabled?; beta: → preview:.
- _nav_item.html.erb: beta: local → preview: local; shared.beta i18n
  key → shared.preview.
- application.html.erb: caller renamed to preview_gated_nav_item.
- goals/index.html.erb: pill label uses shared.preview.
- shared/en.yml: 'beta: Beta' → 'preview: Preview'.
- goals_controller, goal_pledges_controller: require_beta_features! →
  require_preview_features!.
- goals_controller_test, goal_pledges_controller_test: flip the
  preference key, flash matcher, and test names to 'preview'.
This commit is contained in:
Guillem Arias
2026-05-20 21:47:27 +02:00
37 changed files with 585 additions and 127 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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 `<dialog>.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 <turbo-frame id="modal">,
// emptying the frame on close stops Turbo's page cache from snapshotting
// an open dialog and reopening it on browser back.

View File

@@ -9,7 +9,9 @@
title="<%= title || t("ds.pill.aria_label", label: label) %>"></span>
<% else %>
<span class="<%= container_classes %>" style="<%= container_styles %>" title="<%= title || label %>">
<% if show_dot %>
<% if icon %>
<%= helpers.icon(icon, size: "xs", color: "current") %>
<% elsif show_dot %>
<span class="inline-block shrink-0 rounded-full"
style="width: <%= dot_size_px %>px; height: <%= dot_size_px %>px; background-color: <%= dot_color %>;"></span>
<% end %>

View File

@@ -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 1011px 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

View File

@@ -1,5 +1,5 @@
<div class="group relative bg-container rounded-xl shadow-border-xs hover:bg-surface-hover transition-colors p-6 <%= "opacity-75" if goal.paused? || goal.archived? %>"
data-goals-filter-target="card"
<div class="group relative bg-container rounded-xl shadow-border-xs hover:bg-container-hover transition-colors p-6 <%= "opacity-75" if goal.paused? || goal.archived? %>"
<% if filterable %> data-goals-filter-target="card"<% end %>
data-goal-name="<%= goal.name %>"
data-goal-status="<%= goal.display_status %>">
<div class="flex items-start gap-3">
@@ -58,6 +58,8 @@
<%= render Goals::AccountStackComponent.new(accounts: linked_accounts, color_map: goal.account_color_map) %>
<span class="text-xs text-subdued"><%= linked_accounts_count_label %></span>
</div>
<span class="text-xs text-subdued tabular-nums"><%= footer_line %></span>
<span class="text-xs text-subdued tabular-nums">
<%= footer_line %><% if has_pending_pledge? %> · <%= t("goals.goal_card.pending_count", count: pending_pledges_count) %><% end %>
</span>
</div>
</div>

View File

@@ -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

View File

@@ -1,4 +1 @@
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap <%= classes %>" aria-label="<%= label %>">
<%= helpers.icon(icon_name, size: "xs", color: "current") %>
<%= label %>
</span>
<%= render DS::Pill.new(label: label, tone: variant[:tone], style: :outline, icon: variant[:icon]) %>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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() {

View File

@@ -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);

View File

@@ -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() {

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -23,7 +23,7 @@
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">Color</h4>
<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">
@@ -43,14 +43,14 @@
<%= 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>Poor contrast, choose darker color or</span>
<button type="button" class="underline cursor-pointer" data-action="color-icon-picker#autoAdjust">auto-adjust.</button>
<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">Icon</h4>
<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">

View File

@@ -18,7 +18,7 @@
else "info"
end
label = I18n.t("goals.status.#{goal.status}", default: goal.status.to_s.titleize)
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>

View File

@@ -1,6 +1,9 @@
<div class="space-y-8 pb-6 lg:pb-12">
<header>
<h1 class="text-2xl font-semibold text-primary"><%= t(".title") %></h1>
<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>
@@ -45,28 +48,44 @@
<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>
<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[:active_total]) %>
</p>
<p class="text-xs text-secondary mt-1">
<%
parts = []
parts << t(".kpi.on_track_sub_parts.reached", count: @kpi[:reached_count]) if @kpi[:reached_count].positive?
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>
<% 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: "warning", message: t(".pending_pledges_callout"), live: :polite) %>
<%= render DS::Alert.new(variant: "info", message: t(".pending_pledges_callout"), live: :polite) %>
<% end %>
<%# Goals section %>
@@ -163,7 +182,7 @@
</summary>
<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) %>
<%= render Goals::CardComponent.new(goal: goal, filterable: false) %>
<% end %>
</div>
</details>

View File

@@ -11,9 +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) },
{ name: t(".nav.goals"), path: goals_path, icon: "piggy-bank", icon_custom: false, active: page_active?(goals_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] } %>

View File

@@ -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>

View File

@@ -1,6 +1,11 @@
---
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.
@@ -34,6 +39,7 @@ en:
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.
@@ -227,6 +233,10 @@ en:
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:

View File

@@ -4,6 +4,7 @@ en:
self_hostable:
redis_configured: "Redis is now configured properly! You can now setup your Sure application."
shared:
preview: Preview
confirm_modal:
accept: Confirm
body_html: "<p>You will not be able to undo this decision</p>"

View File

@@ -6,7 +6,7 @@ class DropGoalContributions < ActiveRecord::Migration[7.2]
def down
create_table :goal_contributions, 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.references :account, null: false, foreign_key: { on_delete: :cascade }, type: :uuid
t.decimal :amount, precision: 19, scale: 4, null: false
t.string :currency, null: false
t.string :source, default: "manual", null: false

View File

@@ -0,0 +1,19 @@
class RestrictGoalAccountsAccountFk < ActiveRecord::Migration[7.2]
# Goal#must_have_at_least_one_linked_account is enforced at write time
# via model validation, but the original goal_accounts → accounts FK
# was on_delete: :cascade. Deleting a linked account silently destroys
# the goal_account row, and a Goal whose only link points at that
# account ends up with zero linked accounts — the model invariant the
# validation was meant to guarantee. Flip the FK to :restrict so the
# DB rejects the deletion. Callers (Account#destroy paths) must detach
# the account from goals first.
def up
remove_foreign_key :goal_accounts, :accounts
add_foreign_key :goal_accounts, :accounts, on_delete: :restrict
end
def down
remove_foreign_key :goal_accounts, :accounts
add_foreign_key :goal_accounts, :accounts, on_delete: :cascade
end
end

6
db/schema.rb generated
View File

@@ -91,9 +91,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_19_100000) do
t.check_constraint "match_confidence IS NULL OR match_confidence >= 0::numeric AND match_confidence <= 1::numeric", name: "chk_account_statements_match_confidence"
t.check_constraint "parser_confidence IS NULL OR parser_confidence >= 0::numeric AND parser_confidence <= 1::numeric", name: "chk_account_statements_parser_confidence"
t.check_constraint "period_start_on IS NULL OR period_end_on IS NULL OR period_start_on <= period_end_on", name: "chk_account_statements_period_order"
t.check_constraint "review_status::text = ANY (ARRAY['unmatched'::character varying, 'linked'::character varying, 'rejected'::character varying]::text[])", name: "chk_account_statements_review_status"
t.check_constraint "review_status::text = ANY (ARRAY['unmatched'::character varying::text, 'linked'::character varying::text, 'rejected'::character varying::text])", name: "chk_account_statements_review_status"
t.check_constraint "source::text = 'manual_upload'::text", name: "chk_account_statements_source"
t.check_constraint "upload_status::text = ANY (ARRAY['stored'::character varying, 'failed'::character varying]::text[])", name: "chk_account_statements_upload_status"
t.check_constraint "upload_status::text = ANY (ARRAY['stored'::character varying::text, 'failed'::character varying::text])", name: "chk_account_statements_upload_status"
end
create_table "accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -1971,7 +1971,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_19_100000) 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: :cascade
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

View File

@@ -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.

View File

@@ -2,13 +2,24 @@ require "test_helper"
class GoalPledgesControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
@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

View File

@@ -2,13 +2,24 @@ require "test_helper"
class GoalsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
@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
@@ -151,6 +162,72 @@ class GoalsControllerTest < ActionDispatch::IntegrationTest
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)

View File

@@ -47,4 +47,18 @@ class SweepExpiredGoalPledgesJobTest < ActiveJob::TestCase
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

View File

@@ -138,6 +138,27 @@ class GoalPledgeTest < ActiveSupport::TestCase
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)

View File

@@ -25,6 +25,24 @@ class GoalTest < ActiveSupport::TestCase
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?
@@ -87,11 +105,13 @@ class GoalTest < ActiveSupport::TestCase
test "progress_percent is 0 for empty active goal" do
fresh = goals(:car_paydown)
fresh.target_amount = 10_000
fresh.update!(target_amount: 10_000)
fresh.linked_accounts.update_all(balance: 0)
fresh.instance_variable_set(:@current_balance, nil)
fresh.linked_accounts.reload
assert_equal 0, fresh.progress_percent
# 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
@@ -221,11 +241,11 @@ class GoalTest < ActiveSupport::TestCase
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
@goal.reload
@goal.instance_variable_set(:@current_balance, nil)
# 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.
assert_equal "goals.show.pledge_just_saved", @goal.pledge_action_label_key
# 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

View File

@@ -65,7 +65,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}",