mirror of
https://github.com/we-promise/sure.git
synced 2026-05-30 07:49:01 +00:00
fix(savings_goals): neutral ring percent, chart start vertical-line, contribution select wrapper, deterministic account colors
Ring percentage no longer takes the warning yellow tint when behind — the colored ring stroke + status pill + catch-up alert already signal the state, doubling it on the percent number was noise. Reached stays green (celebratory), everything else uses text-primary (white/dark). Chart vertical line at the left edge was the (start_date, $0) point the controller prepended to the saved series. When start_date equals the first contribution date (now common after the earlier earliest- contribution fix), this drew a vertical jump from $0 to first contribution at x=start. Skip the prepend when there's no temporal gap so the line starts at the first real point. Add Contribution modal — wrap the source-account select in the styled form-field via f.select instead of label_tag + bare select_tag. Match the rest of Sure's form controls. Also pass hide_currency on the amount field so single-currency families don't see a redundant USD dropdown. Account avatar colors — replace Ruby String#hash (randomized per process by Ruby for DoS protection) with a deterministic MD5-based pick from Savings::GoalAvatarComponent::PALETTE. Same account name now resolves to the same color across processes and across components. Apply via a new Savings::GoalAvatarComponent.color_for helper used by both the form stepper account list and the goal-card AccountStackComponent (which was hardcoding blue-500 for every avatar in the stack, hence Chase + Ally looking identical on the wedding card).
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<span class="inline-flex items-center">
|
||||
<% shown.each_with_index do |account, i| %>
|
||||
<span class="inline-flex items-center justify-center w-5 h-5 rounded-full text-inverse text-[9px] font-semibold ring-2 ring-container"
|
||||
style="background-color: var(--color-blue-500); <%= "margin-left: -6px;" if i > 0 %>"
|
||||
style="background-color: <%= Savings::GoalAvatarComponent.color_for(account.name) %>; <%= "margin-left: -6px;" if i > 0 %>"
|
||||
title="<%= account.name %>">
|
||||
<%= initial_for(account) %>
|
||||
</span>
|
||||
|
||||
@@ -6,6 +6,16 @@ class Savings::GoalAvatarComponent < ApplicationComponent
|
||||
"xl" => { box: "w-16 h-16", text: "text-2xl", radius: "rounded-2xl" }
|
||||
}.freeze
|
||||
|
||||
PALETTE = SavingsGoal::COLORS
|
||||
|
||||
# Deterministic color pick from the palette so the same string maps to
|
||||
# the same color across processes (Ruby's String#hash is randomized per
|
||||
# boot for DoS protection — not stable enough for visual identity).
|
||||
def self.color_for(name)
|
||||
return PALETTE.first if name.blank?
|
||||
PALETTE[Digest::MD5.hexdigest(name).to_i(16) % PALETTE.size]
|
||||
end
|
||||
|
||||
def initialize(goal: nil, name: nil, color: nil, size: "md")
|
||||
@goal = goal
|
||||
@name = name || goal&.name
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div data-donut-chart-target="contentContainer" class="flex items-center justify-center h-full">
|
||||
<div data-donut-chart-target="defaultContent" class="flex flex-col items-center text-center">
|
||||
<span class="text-secondary text-xs mb-1"><%= t("savings_goals.show.ring.saved") %></span>
|
||||
<span class="text-3xl font-medium tabular-nums privacy-sensitive" style="color: <%= percent_text_color %>;"><%= percent %>%</span>
|
||||
<span class="text-3xl font-medium tabular-nums privacy-sensitive <%= percent_text_class %>"><%= percent %>%</span>
|
||||
<span class="text-xs text-subdued tabular-nums mt-1"><%= amount_label %></span>
|
||||
<span class="text-xs text-subdued tabular-nums">of <%= target_label %></span>
|
||||
</div>
|
||||
|
||||
@@ -22,11 +22,10 @@ class Savings::ProgressRingComponent < ApplicationComponent
|
||||
goal.remaining_amount_money.format
|
||||
end
|
||||
|
||||
def percent_text_color
|
||||
def percent_text_class
|
||||
case goal.status
|
||||
when :reached then "var(--color-green-600)"
|
||||
when :behind then "var(--color-yellow-600)"
|
||||
else "var(--text-primary)"
|
||||
when :reached then "text-success"
|
||||
else "text-primary"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -51,10 +51,17 @@ export default class extends Controller {
|
||||
|
||||
const endDate = target || new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const savedSeries = [{ date: start, value: 0 }].concat(
|
||||
(data.saved_series || []).map((p) => ({ date: new Date(p.date), value: p.value })),
|
||||
);
|
||||
if (savedSeries[savedSeries.length - 1].date < today) {
|
||||
const rawSavedSeries = (data.saved_series || []).map((p) => ({ date: new Date(p.date), value: p.value }));
|
||||
const firstContribDate = rawSavedSeries[0]?.date;
|
||||
const savedSeries = [];
|
||||
// Only seed a (start, 0) point when start_date predates the first
|
||||
// contribution. Otherwise the line draws a vertical jump up at the
|
||||
// chart's left edge.
|
||||
if (!firstContribDate || firstContribDate.getTime() > start.getTime()) {
|
||||
savedSeries.push({ date: start, value: 0 });
|
||||
}
|
||||
savedSeries.push(...rawSavedSeries);
|
||||
if (savedSeries.length && savedSeries[savedSeries.length - 1].date < today) {
|
||||
savedSeries.push({ date: today, value: currentAmount });
|
||||
}
|
||||
|
||||
|
||||
@@ -10,18 +10,15 @@
|
||||
class: "space-y-3" do |f| %>
|
||||
<%= f.money_field :amount,
|
||||
label: t(".amount"),
|
||||
required: true,
|
||||
hide_currency: true,
|
||||
autofocus: true %>
|
||||
|
||||
<%= label_tag "savings_contribution[account_id]", t(".source_account"), class: "block text-sm text-secondary" %>
|
||||
<%= select_tag "savings_contribution[account_id]",
|
||||
options_from_collection_for_select(@savings_goal.linked_accounts, :id, :name),
|
||||
class: "w-full",
|
||||
include_blank: t(".select_account") %>
|
||||
<%= f.select :account_id,
|
||||
options_from_collection_for_select(@savings_goal.linked_accounts, :id, :name, @contribution.account_id),
|
||||
{ label: t(".source_account"), include_blank: t(".select_account") } %>
|
||||
|
||||
<%= f.date_field :contributed_at,
|
||||
label: t(".contributed_at"),
|
||||
required: true %>
|
||||
label: t(".contributed_at") %>
|
||||
|
||||
<%= f.text_area :notes, label: t(".notes"), rows: 2 %>
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
account_subtype: account.subtype || subtype,
|
||||
account_balance: account.balance
|
||||
} %>
|
||||
<%= render Savings::GoalAvatarComponent.new(name: account.name, color: Category::COLORS[account.name.hash.abs % Category::COLORS.size], size: "md") %>
|
||||
<%= render Savings::GoalAvatarComponent.new(name: account.name, color: Savings::GoalAvatarComponent.color_for(account.name), size: "md") %>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-primary truncate"><%= account.name %></p>
|
||||
<p class="text-xs text-secondary"><%= (account.subtype || subtype).titleize %></p>
|
||||
|
||||
Reference in New Issue
Block a user