mirror of
https://github.com/we-promise/sure.git
synced 2026-05-29 23:39:03 +00:00
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:
33
.github/workflows/preview-deploy.yml
vendored
33
.github/workflows/preview-deploy.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]) %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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] } %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
6
db/schema.rb
generated
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user