Skip to content

ADR-0040: Budget card shows real utilization above 100%

  • Status: Accepted
  • Date: 2026-10-12
  • Deciders: maintainers
  • Related: ADR-0006, ADR-0036

Context

A category card's percentage label was capped at 100% even when spending exceeded the limit. BudgetScreen computed a single (spent / limit).coerceIn(0f, 1f) fraction and fed the same clamped value to both the fill bar and the % label. Only the label was wrong: Over by X and the error coloring already used the true over-limit comparison, and BudgetViewModel.utilization (used for ordering) was already uncapped, so the list ranked over-budget rows correctly while the card read 100%.

Decision

The percentage label shows the real, uncapped utilization, truncated to a whole number: percent(spent, limit) = (spent * 100 / limit) as an Int, yielding e.g. 120% for 12000 / 10000. The fill bar keeps clamping its fraction to [0, 1], so the bar still fills the full width and never overflows the card.

  • BudgetViewModel.percent(spent: Long, limit: Long?): Int is the single source of the displayed value; it returns 0 when limit is null or <= 0 (categories with no limit show no percentage at all).
  • BudgetScreen keeps an uncapped fraction for the label and derives fillFraction = fraction.coerceIn(0f, 1f) for animateFloatAsState / fillMaxWidth.
  • Exactly-at-limit still reads 100%; Over by X, error color, and the No limit set · spent X branch are unchanged.

Consequences

  • Over-budget cards communicate how far over they are (119%, 120%, …) instead of plateauing at 100%.
  • The bar remains a bounded progress indicator; it is already the accepted convention that it saturates at full width while the text carries the excess.
  • The label and the sort order (utilization) now agree on the underlying ratio, removing the previous label/order inconsistency.

Rejected alternatives

  • Cap the label at 100% — the status quo; hides overspend and contradicts the already-uncapped ordering and Over by X text.
  • Let the fill bar exceed full width / overflow the card — breaks the rounded-clip layout and gives no additional information beyond the label.

Addendum — 2026-10-16: progress surface and over-limit contrast (#152)

The card no longer tints its whole surface with the progress fill: text drawn on that alpha-composited error tint measured only 3.0:1 (light) and 3.5:1 (dark), below the WCAG 4.5:1 normal-text minimum. The clamped fill now renders in a 6 dp track pinned to the card's bottom edge, and all card text sits on the opaque card container. The over-limit foreground is budgetOverLimitTextColor(error, cardContainer), which resolves ensureContrast(error, cardContainer, 4.5), so it clears 4.5:1 in light, dark and dynamic palettes. The uncapped % label, the Over by X text and the error-toned fill keep the over-limit state legible without relying on colour alone. The clamping behaviour above is unchanged.