Skip to content

Close the subscription list's offline write hole, and stop four catch blocks hiding defects - #157

Merged
rghvgrv merged 1 commit into
mainfrom
fix/list-offline-guard-and-notification-copy
Aug 10, 2026
Merged

Close the subscription list's offline write hole, and stop four catch blocks hiding defects#157
rghvgrv merged 1 commit into
mainfrom
fix/list-offline-guard-and-notification-copy

Conversation

@rghvgrv

@rghvgrv rghvgrv commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Three review findings from a full-repo pass, all in the mobile client. #155 from the same pass is already in via #156 and is not touched here.

Closes #152 — the list screen's offline write hole

The subscription list was the one write-capable screen with no offline guard, and it is the one most likely to be open without a network — it serves itself from the SQLite mirror and says so ("Offline — showing last synced data"). Two writes sat on it unguarded:

  • swipe-to-delete
  • "Mark as paid"

Both stayed enabled with no network, and both reported the resulting lost write with read wording — "You appear to be offline." — which reads as "we'll sync it". There is no outbox (#144), so the write was simply gone.

Mark-paid is the worse of the two. The row keeps its OVERDUE chip whether the call landed or not, so nothing on screen contradicts the misreading — the user believes they cleared a charge that is still outstanding.

It now follows the contract the other four write-capable view models already share:

before after
IConnectivityService not injected injected
IsOffline re-read on load and after a failed write
CanSubmit gates both affordances via IsEnabled in XAML
failed-write message ToDisplayMessage ToWriteFailureMessage

IsBusy is new here rather than reused from IsLoading, which drives the pull-to-refresh spinner. Mark-paid is not idempotent — it advances the billing date one cycle per call — so a double tap would settle two periods off one payment.

Closes #153 — four catch blocks laundering defects into offline errors

ApiErrorMapper.IsApiFailure exists to be a catch filter; 21 of 25 blocks used it, four did not. The two load paths were the dangerous ones: they fall back to cache, so a defect of ours (a null in RebuildGroups, a bad cache round trip, a converter throwing) showed stale rows under "showing last synced data" on a phone that was perfectly online, with nothing logged. That is the hardest class of bug to get a report about, because it looks like it is working as designed.

All four now filter. SubscriptionDetailViewModel:253 is left alone — its broad catch is documented as intentional and nothing falls back to cache there.

Closes #154 — comments describing a job that no longer exists

RelativeDate.cs told the reader a past billing date "means the app is looking at stale data" because "the server's billing-date job rolls them forward". That job was deliberately removed; a past date is the intended overdue signal and is what SubscriptionDto.IsOverdue reads. The comment invited exactly the change CLAUDE.md forbids. Same drift in RenewalNotificationPlanner.cs.

Verification

dotnet test tests/SubVora.Mobile.Tests      → 264 passed, 0 failed
dotnet test tests/SubVora.Application.Tests →  38 passed, 0 failed
dotnet build src/SubVora.Api                → 0 warnings, 0 errors

Test coverage added, since the absence of it is why #152 went unnoticed:

  • OfflineWriteGuardTests now includes the list in both CanSubmit assertions, plus a failed mark-paid, a failed swipe-delete, and the drop-mid-session case
  • SubscriptionListViewModelTests / DashboardViewModelTests assert a defect propagates instead of turning into a cache fallback

No API, schema or contract changes.

The subscription list was the one write-capable screen with no offline
guard, and the one most likely to be open without a network - it serves
itself from the SQLite mirror. Swipe-to-delete and mark-as-paid stayed
enabled, and both reported a lost write with read wording ("You appear to
be offline"), which reads as "we will sync it". There is no outbox, so
the write was simply gone. Mark-paid is the worse of the two: the row
keeps its OVERDUE chip either way, so nothing on screen contradicts the
misreading.

It now follows the same contract as the other four write-capable view
models: IConnectivityService injected, IsOffline re-read on load and
after a failed write, CanSubmit gating both affordances in XAML, and
ToWriteFailureMessage on the write paths. IsBusy is new here rather than
reused from IsLoading, which drives the pull-to-refresh spinner - a
double tap on mark-paid would settle two billing periods off one payment.

Also stops four catch blocks laundering defects into offline errors. The
two load paths were the dangerous ones: they fall back to cache, so a bug
of ours showed stale rows under "showing last synced data" on a phone
that was perfectly online, with nothing logged. All four now filter on
ApiErrorMapper.IsApiFailure, as the other 21 blocks already did.

And corrects two comments that still described the removed nightly
billing-date job - both told the reader the opposite of the rule in
CLAUDE.md, inviting the exact change it forbids.

Closes #152
Closes #153
Closes #154

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rghvgrv
rghvgrv merged commit 928e3a7 into main Aug 10, 2026
3 checks passed
@rghvgrv
rghvgrv deleted the fix/list-offline-guard-and-notification-copy branch August 10, 2026 17:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant