diff --git a/app/components/DS/progress_ring.html.erb b/app/components/DS/progress_ring.html.erb
new file mode 100644
index 000000000..d3940cca8
--- /dev/null
+++ b/app/components/DS/progress_ring.html.erb
@@ -0,0 +1,20 @@
+<%= tag.div class: "relative inline-flex shrink-0",
+ style: "width: #{size}px; height: #{size}px;",
+ **wrapper_aria do %>
+
+
+
+
+ <% if show_percent %>
+
(issue #1899) so goals, loans,
+# sub-account funding, etc. stop each hand-rolling the same two-circle SVG with
+# slightly different chrome / colors / a11y. Pass a percent and a tone; the
+# component owns the geometry (radius, circumference, dash offset) and the
+# accessible progressbar markup.
+#
+# Not a segmented donut — that's the `donut-chart` Stimulus controller's job
+# (budget/dashboard breakdowns, and the goals/show ring). This is the simple
+# "X% of one thing" ring.
+class DS::ProgressRing < DesignSystemComponent
+ TONES = {
+ success: "var(--color-success)",
+ warning: "var(--color-warning)",
+ destructive: "var(--color-destructive)",
+ neutral: "var(--color-gray-400)"
+ }.freeze
+
+ # Track (unfilled remainder) color. Reuses the existing token to keep the
+ # goal card pixel-identical. TODO(#1899 follow-up): rename this to a generic
+ # --color-progress-track-fill in the token source — that change also touches
+ # the budget donut surfaces, so it's deferred out of this extraction.
+ DEFAULT_TRACK = "var(--budget-unused-fill)".freeze
+
+ attr_reader :size, :stroke_width, :label, :show_percent
+
+ def initialize(percent:, size: 64, stroke_width: 6, tone: :neutral, label: nil, show_percent: true, track: DEFAULT_TRACK)
+ @percent = percent
+ @size = size
+ @stroke_width = stroke_width
+ @tone = tone.to_sym
+ @label = label
+ @show_percent = show_percent
+ @track = track
+ end
+
+ def clamped_percent
+ [ [ @percent.to_i, 0 ].max, 100 ].min
+ end
+
+ def stroke_color
+ TONES.fetch(@tone, TONES[:neutral])
+ end
+
+ def track_color
+ @track
+ end
+
+ def center
+ size / 2.0
+ end
+
+ def radius
+ (size - stroke_width) / 2.0
+ end
+
+ def circumference
+ 2 * Math::PI * radius
+ end
+
+ # Length of the dash gap that hides the unfilled portion of the arc.
+ def dash_offset
+ circumference * (1 - clamped_percent / 100.0)
+ end
+
+ # Center label scales with the ring so 64px reads ~11px (the goal card's size)
+ # and a 180px ring reads ~30px without a per-callsite font class.
+ def percent_font_px
+ (size * 0.17).round
+ end
+
+ # role=progressbar + value/label only when a label is supplied; otherwise the
+ # ring is decorative (aria-hidden via the inner svg) and the caller labels it.
+ def wrapper_aria
+ return {} if label.blank?
+
+ { role: "progressbar", aria: { valuenow: clamped_percent, valuemin: 0, valuemax: 100, label: label } }
+ end
+end
diff --git a/app/components/goals/card_component.html.erb b/app/components/goals/card_component.html.erb
index 7e2174885..c42340d19 100644
--- a/app/components/goals/card_component.html.erb
+++ b/app/components/goals/card_component.html.erb
@@ -18,29 +18,7 @@
<%= secondary_line %>
-
-
-
-
-
-
- <%= progress_percent %>%
-
-
+ <%= render DS::ProgressRing.new(percent: progress_percent, tone: ring_tone) %>
diff --git a/app/components/goals/card_component.rb b/app/components/goals/card_component.rb
index 0f68d4839..7ebca94dd 100644
--- a/app/components/goals/card_component.rb
+++ b/app/components/goals/card_component.rb
@@ -1,7 +1,4 @@
class Goals::CardComponent < ApplicationComponent
- RING_SIZE = 64
- RING_STROKE = 6
-
def initialize(goal:, filterable: true)
@goal = goal
@filterable = filterable
@@ -13,11 +10,13 @@ class Goals::CardComponent < ApplicationComponent
goal.progress_percent
end
- def ring_color
+ # Maps goal status to a DS::ProgressRing tone (the ring geometry/colors now
+ # live in that primitive — see #1899).
+ def ring_tone
case goal.status
- when :reached, :on_track then "var(--color-success)"
- when :behind then "var(--color-warning)"
- else "var(--color-gray-400)"
+ when :reached, :on_track then :success
+ when :behind then :warning
+ else :neutral
end
end
@@ -66,19 +65,6 @@ class Goals::CardComponent < ApplicationComponent
end
end
- def ring_circumference
- @ring_circumference ||= 2 * Math::PI * ring_radius
- end
-
- def ring_radius
- @ring_radius ||= (RING_SIZE - RING_STROKE) / 2.0
- end
-
- def ring_offset
- pct = [ [ progress_percent.to_i, 0 ].max, 100 ].min
- ring_circumference * (1 - pct / 100.0)
- end
-
def pace_line
return nil if goal.archived? || goal.paused? || goal.completed? || goal.status == :reached
diff --git a/test/components/DS/progress_ring_test.rb b/test/components/DS/progress_ring_test.rb
new file mode 100644
index 000000000..14ca25f95
--- /dev/null
+++ b/test/components/DS/progress_ring_test.rb
@@ -0,0 +1,45 @@
+require "test_helper"
+
+class DS::ProgressRingTest < ViewComponent::TestCase
+ test "renders a track circle and a progress arc" do
+ render_inline(DS::ProgressRing.new(percent: 50, tone: :success))
+ assert_selector "svg circle", count: 2
+ end
+
+ test "renders the center percent by default and clamps it" do
+ render_inline(DS::ProgressRing.new(percent: 140))
+ assert_text "100%"
+
+ render_inline(DS::ProgressRing.new(percent: -10))
+ assert_text "0%"
+ end
+
+ test "show_percent: false omits the center label" do
+ render_inline(DS::ProgressRing.new(percent: 40, show_percent: false))
+ assert_no_text "40%"
+ end
+
+ test "exposes a progressbar role and value only when labelled" do
+ render_inline(DS::ProgressRing.new(percent: 30, label: "Goal progress"))
+ assert_selector "[role='progressbar'][aria-valuenow='30'][aria-label='Goal progress']"
+
+ render_inline(DS::ProgressRing.new(percent: 30))
+ assert_no_selector "[role='progressbar']"
+ end
+
+ test "tone selects the arc stroke color token" do
+ assert_equal "var(--color-success)", DS::ProgressRing.new(percent: 1, tone: :success).stroke_color
+ assert_equal "var(--color-warning)", DS::ProgressRing.new(percent: 1, tone: :warning).stroke_color
+ assert_equal "var(--color-destructive)", DS::ProgressRing.new(percent: 1, tone: :destructive).stroke_color
+ # Unknown tone falls back to neutral.
+ assert_equal "var(--color-gray-400)", DS::ProgressRing.new(percent: 1, tone: :bogus).stroke_color
+ end
+
+ test "dash offset runs from full circumference at 0% to zero at 100%" do
+ ring = DS::ProgressRing.new(percent: 0)
+ assert_in_delta ring.circumference, ring.dash_offset, 0.001
+
+ full = DS::ProgressRing.new(percent: 100)
+ assert_in_delta 0.0, full.dash_offset, 0.001
+ end
+end
diff --git a/test/components/previews/progress_ring_component_preview.rb b/test/components/previews/progress_ring_component_preview.rb
new file mode 100644
index 000000000..b4476500c
--- /dev/null
+++ b/test/components/previews/progress_ring_component_preview.rb
@@ -0,0 +1,68 @@
+class ProgressRingComponentPreview < ViewComponent::Preview
+ # @param percent number
+ # @param size number
+ # @param stroke_width number
+ # @param tone select ["success", "warning", "destructive", "neutral"]
+ # @param show_percent toggle
+ # @param label text
+ def default(percent: 65, size: 64, stroke_width: 6, tone: "neutral", show_percent: true, label: nil)
+ render DS::ProgressRing.new(
+ percent: percent,
+ size: size,
+ stroke_width: stroke_width,
+ tone: tone.to_sym,
+ show_percent: show_percent,
+ label: label.presence
+ )
+ end
+
+ # @!group Tones (50%)
+ def success
+ render DS::ProgressRing.new(percent: 50, tone: :success)
+ end
+
+ def warning
+ render DS::ProgressRing.new(percent: 50, tone: :warning)
+ end
+
+ def destructive
+ render DS::ProgressRing.new(percent: 50, tone: :destructive)
+ end
+
+ def neutral
+ render DS::ProgressRing.new(percent: 50, tone: :neutral)
+ end
+ # @!endgroup
+
+ # @!group Sizes
+ def small_48
+ render DS::ProgressRing.new(percent: 72, size: 48, stroke_width: 5, tone: :success)
+ end
+
+ def medium_64
+ render DS::ProgressRing.new(percent: 72, size: 64, stroke_width: 6, tone: :success)
+ end
+
+ def large_180
+ render DS::ProgressRing.new(percent: 72, size: 180, stroke_width: 10, tone: :success)
+ end
+ # @!endgroup
+
+ # @!group Edges
+ def empty_0
+ render DS::ProgressRing.new(percent: 0, tone: :neutral)
+ end
+
+ def full_100
+ render DS::ProgressRing.new(percent: 100, tone: :success)
+ end
+
+ def clamps_over_100
+ render DS::ProgressRing.new(percent: 140, tone: :success)
+ end
+
+ def without_center_percent
+ render DS::ProgressRing.new(percent: 40, tone: :warning, show_percent: false)
+ end
+ # @!endgroup
+end