Skip to content

DS::ProgressRing: unify the three progress-ring implementations under one primitive #1899

@gariasf

Description

@gariasf

There are currently three circular progress-ring implementations in the codebase, each with slightly different chrome, color handling, and accessibility coverage:

  1. Goals::ProgressRingComponent (app/components/goals/progress_ring_component.{rb,html.erb}) — full ViewComponent. Uses the donut-chart Stimulus controller, has role="progressbar" + aria-valuenow + aria-label, sized via size: param, semantic color via percent_text_classtext-success / text-primary. Coupled to a goal: object via the model methods it calls (progress_percent, current_balance_money, target_amount_money, status). Rendered on goals/show.

  2. Goals::CardComponent (app/components/goals/card_component.html.erb:21-43) — inline <svg> with two <circle> elements, rendered in goal index cards at 64px. Uses CSS variables for stroke color, but ring_color previously used palette tokens (just cleaned up to semantic in feat(goals): balance-derived + pledges #1798); the track background still uses var(--budget-unused-fill), which is a budget-namespaced token leaking into goals.

  3. app/views/loans/tabs/_overview.html.erb (incoming via Feat/loans overview new insights #1775) — inline <svg> with raw hex strokes (#3f3f46, #f59e0b), no ARIA, no theme awareness. Currently the worst of the three; should be replaced rather than duplicated.

There's also app/views/budgets/_budget_donut.html.erb and app/views/pages/dashboard/_outflows_donut.html.erb, both using the donut-chart Stimulus controller directly. Those are full segmented donuts (categories), not single-progress rings — they probably stay as-is. But they share the --budget-unused-fill track token.

Proposed shape

A DS::ProgressRing primitive (app/components/DS/progress_ring.{rb,html.erb}) that decouples the visual from any specific model:

DS::ProgressRing.new(
  percent: 42,                     # 0..100
  size: 64,                        # px
  stroke_width: 6,                 # px
  tone: :success,                  # :success | :warning | :destructive | :neutral
  label: t("..."),                 # for aria-label
  show_percent: true               # render the % in the center
)

Then:

  • Goals::ProgressRingComponent becomes a thin wrapper that resolves goal → tone/percent/label and renders DS::ProgressRing (or gets folded in entirely).
  • Goals::CardComponent swaps its inline <svg> for DS::ProgressRing.new(percent:, size: 64, tone:).
  • The loan overview ring (Feat/loans overview new insights #1775) renders DS::ProgressRing.new(percent: loan.balance_paid_ratio * 100, size: 64, tone: ...) instead of inline SVG with raw hex.

Token rename (bundled here)

While extracting, also rename --budget-unused-fill--color-progress-track-fill (or similar generic name) in design/tokens/sure.tokens.json. Currently the four call sites are split between budgets, goals, and the donut-chart controller — the budget-namespaced name was historical, and a track-fill token serves any donut. Affected:

  • design/tokens/sure.tokens.json (the definition)
  • app/javascript/controllers/donut_chart_controller.js:194 (segment hover fallback)
  • app/models/goal.rb:174,179 (donut segments)
  • app/models/budget_category.rb:218,225 (donut segments — actually --budget-unallocated-fill, separate)
  • app/models/budget.rb:210,217 (donut segments — also --budget-unallocated-fill)

budget-unallocated-fill stays under budget: since it's budget-specific.

Why this issue exists

Discovered while reviewing the loan overview PR (#1775). The progress ring there reaches for inline <svg> with raw hex because the existing Goals::ProgressRingComponent is too coupled to a goal: object to reuse. Promoting it to a DS primitive would prevent the next surface (savings sub-accounts, IRA progress, etc.) from rolling its own again.

Out of scope

  • Segmented donut charts (budgets, dashboard outflows). Those are a different shape; donut-chart Stimulus controller already covers them.
  • Status semantics — :success/:warning/:neutral is the proposed API but the caller decides how to map their domain status to those tones.

Refs: #1775 (loan overview), #1798 (goals v2)

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions