Skip to content

Always-visible burn-rate banner that follows the user's edits - #108

Merged
rghvgrv merged 1 commit into
mainfrom
feat/burn-rate-banner
Aug 6, 2026
Merged

Always-visible burn-rate banner that follows the user's edits#108
rghvgrv merged 1 commit into
mainfrom
feat/burn-rate-banner

Conversation

@rghvgrv

@rghvgrv rghvgrv commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Puts the spend figures in Shell.TitleView so they're on screen everywhere, and refreshes them whenever the user does something that changes them.

USD 12.50/wk | 54.17/mo | 650.00/yr

No AI involved. The arithmetic is cost / cycle_days summed and projected, and it already lives server-side in BurnRateCalculator — per the architectural rule that burn-rate math is never duplicated in the client. This PR is presentation and refresh plumbing only; not one line of the calculation changed.

The problem

The figures existed, but only on the Dashboard tab — out of sight at exactly the moment a user is on the Subscriptions tab adding the thing that changes them. Each page reloads in OnAppearing, so the number was correct but only once you navigated back to it.

How "dynamic" works

Refresh-on-mutation. Not polling, not SignalR: this account's spend only changes when this user acts, so there's no second writer to hear about.

SubscriptionsChangedMessage is published after:

Trigger Where
Create / update succeeded SubscriptionDetailViewModel.SaveAsync
Delete succeeded SubscriptionListViewModel.DeleteSubscriptionAsync
Home currency changed SettingsViewModel.SaveAsync

That last one matters and is easy to miss — every figure is converted into the home currency, so switching it moves all three numbers without a single subscription being touched.

A failed save publishes nothing. The headline must not move for a write the server rejected; there's a test for it.

Design notes

IMessenger from CommunityToolkit.Mvvm, already a dependency — no hand-rolled event bus. Injected rather than using the static WeakReferenceMessenger.Default, so each test gets an isolated instance and nothing leaks between them.

DashboardViewModel becomes the one singleton view model. The banner and DashboardPage bind to the same instance, so one fetch feeds both and they can't drift apart. Everything else stays transient.

Summary is derived, not stored. [NotifyPropertyChangedFor(nameof(Summary))] on the four properties that feed it means there's no second copy of the numbers to keep in sync. Its emptiness doubles as the banner's visibility via the toolkit's IsStringNotNullOrEmptyConverter — no separate IsVisible flag to forget.

SessionEndedMessage clears the figures on sign-out and on an unrecoverable 401. The login page lives inside the same Shell, so without this one user's spend would still be sitting above the next person's sign-in form.

The offline notice keeps its place, layered over the summary in the same Grid. The title bar has room for one line, and "these numbers may be stale" is the more urgent thing to say when both are true. The existing offline burn-rate cache means the banner shows last-known figures rather than going blank.

Tests

tests/SubVora.Mobile.Tests/BurnRateBannerTests.cs, 10 cases:

  • summary formatting after a load, and empty before one
  • PropertyChanged fires for Summary when the figures move
  • SubscriptionsChangedMessage triggers a refetch and the string updates
  • SessionEndedMessage clears figures, currency, and the category breakdown
  • save / delete / currency-change each publish exactly one message
  • a failed save publishes none
  • sign-out publishes SessionEndedMessage
88 passed, 0 failed

The currency-change test earned its place immediately — it caught the publish landing in LoadAsync instead of SaveAsync (both methods contain the same two assignment lines).

What I could not verify

The XAML binding is compile-checked only — x:DataType on the Shell with compiled bindings means a typo in Summary would fail the build, and it doesn't. But I did not run the GUI, so the strip's appearance on a real device is unverified: Shell.TitleView sizing differs between Android and iOS, and the summary may want a smaller font or a shorter format on a narrow screen. Worth a look on a device before shipping.

Follow-on

TitleView fits one short line, so the full "You are spending $X per week" sentence stays on the Dashboard page — unchanged by this PR. If you'd rather have the long form always visible, that needs a different container than the title bar.

The spend figures existed but only on the Dashboard tab, so the number was out
of sight exactly when a user was adding the subscription that changes it. This
puts a one-line summary in Shell.TitleView - "USD 12.50/wk | 54.17/mo |
650.00/yr" - visible on every screen, and refreshes it whenever something that
feeds it changes.

Refresh-on-mutation, not polling and not a push channel. This account's spend
only changes when this user acts, so there is no second writer to hear about.
SubscriptionsChangedMessage is published after a successful create, update or
delete, and after a home-currency change in Settings, which moves every figure
without a subscription being touched. A failed save publishes nothing - the
headline must not move for a write the server rejected.

Uses IMessenger from CommunityToolkit.Mvvm, already a dependency, rather than a
hand-rolled event bus. Injected rather than the static Default so each test gets
an isolated instance.

DashboardViewModel becomes the one singleton view model: the banner and the
dashboard page bind to the same instance, so one fetch feeds both and they
cannot drift apart. Summary is derived from the existing figures with
NotifyPropertyChangedFor, so there is no second copy of the numbers to keep in
sync, and its emptiness doubles as the banner's visibility - no separate flag.

SessionEndedMessage clears the figures on sign-out and on an unrecoverable 401.
The login page lives inside the same Shell, so without this one user's spend
would still be on screen behind the next person's sign-in form.

The offline notice keeps its place in the same strip, layered over the summary:
the title bar has room for one line, and "these numbers may be stale" is the
more urgent thing to say when both are true. The existing offline burn-rate
cache means the banner keeps showing the last known figures rather than going
blank.

No AI anywhere in this: the arithmetic is cost / cycle_days summed and projected,
and it already lives server-side in BurnRateCalculator per the architectural rule
that burn-rate math is not duplicated in the client.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rghvgrv rghvgrv self-assigned this Aug 6, 2026
@rghvgrv
rghvgrv merged commit 63c71e5 into main Aug 6, 2026
@rghvgrv
rghvgrv deleted the feat/burn-rate-banner branch August 6, 2026 18:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant