fix(charts): distinguish offline from loading; make retry reachable [PERA-4581]#1013
Conversation
yasin-ce
left a comment
There was a problem hiding this comment.
🔴 Blocking
-
common.offline_refresh_bodyno longer exists onmain— the offline body will render the raw key after rebase. (BalanceLineChart.tsx:96)The PR branch is 3 commits behind
mainand itsen.jsonstill carries this key via an inherited pre-revert merge — but #1014 (75f5fe049, the PERA-4586 revert) removed it frommain, and no commit unique to this PR re-adds it. So on the branch it works, but once you bringmainin (which you must to merge),t('common.offline_refresh_body')falls through to the literal string and the user seescommon.offline_refresh_bodyas the offline body.The PR description's "reuses existing
common.offline_mode/common.offline_refresh_body" is only true against the stale base — on currentmain,offline_modesurvives (it predates #986) butoffline_refresh_bodydoes not.Fix: add
common.offline_refresh_bodytoen.jsonin this PR. (Rebase first — the tests import real i18n, so a rebased branch would surface this immediately.)
🟡 Should-fix
Nothing blocking-adjacent. The one real item is above.
⚪ Nits
period ?? 'one-week'inusePriceTrend.ts:749is dead —UsePriceTrendParams.periodis the requiredHistoryPeriod, so the fallback can't fire. It's faithfully carried over from the old inline code, so not a regression; either drop the?? 'one-week'or make the paramperiod?:if callers can actually passundefined.- A couple of the intent comments run long (the
renderStateand allowlist blocks). They explain real gotchas so I'd keep them, but we've been trimming comment verbosity lately — could tighten.
🔵 Questions / awareness
- Retry button in the offline state is visible but no-ops while offline (
useBalanceLineChart.ts:412-419). The guard is sound (skip the doomed 30s request), but should we show a button that silently does nothing when tapped, given the copy already promises an auto-refresh on reconnect? Not blocking — the same button works the instant connectivity returns — just worth a UX confirm. usePriceTrend'sisHidden = isPaused && !chartDatatreats an empty array as "has data" (![]isfalse), so a paused query resolved with[]would render a 0.00% trend rather than hiding. Edge-y (a paused query rarely carriesdata: []) — flag for awareness, not a change.- The
shouldDehydrateQueryallowlist correctly runs ahead of the exclusion block (abalance-historykey also matchesisAccountQuery, so order matters) — nicely done and tested. Confirm the two predicates stay prefix-only ([0]/[1], and[2]for price history) if the query-key shapes ever change; the negative tests guard exactly this.
| ) : renderState === 'offline' ? ( | ||
| // Offline must not masquerade as loading: a paused query | ||
| // reports isPending forever, so without this branch the | ||
| // spinner never yields and retry is unreachable (PERA-4581). | ||
| <EmptyView | ||
| title={t('common.offline_mode')} | ||
| body={t('common.offline_refresh_body')} | ||
| button={retryButton} | ||
| /> | ||
| ) : renderState === 'error' ? ( | ||
| // A failed request must not masquerade as "no history" — show a | ||
| // distinct error state so the user can retry rather than assume | ||
| // there's nothing to display. | ||
| <EmptyView | ||
| title={t('common.error.title')} | ||
| body={errorBody ?? t('common.error.body')} | ||
| button={retryButton} | ||
| /> | ||
| ) : renderState === 'loading' ? ( | ||
| <LoadingView | ||
| variant='circle' | ||
| size='lg' | ||
| /> | ||
| ) : ( | ||
| <EmptyView | ||
| title='' | ||
| body={emptyBody} | ||
| /> |
There was a problem hiding this comment.
nit: this nested ternary thing is super hard to read - might be worth a renderEmptyView() function or something to do it with some slightly saner branching or switches
…ne retry [PERA-4580] Align the Transaction Details screen with the PERA-4581 charts contract (PR #1013): a device that goes offline carrying a prior non-offline error (e.g. a timeout) now shows the offline surface via `isPaused || (isError && !hasInternet)` instead of a timeout screen with a Retry that just re-pauses. The retry also short-circuits while offline so it no longer fires a doomed request, matching the charts retry. Addresses review feedback on #1008.
- Re-add common.offline_refresh_body to en.json (removed from main by #1014's revert; the branch inherited it from a stale base, so it would fall through to the raw key after rebase) - Drop dead 'period ?? one-week' fallback in usePriceTrend (period is a required HistoryPeriod) - Hide the price trend when paused with an empty series, not just a missing one, matching the hook's documented contract - Replace the nested ternary in BalanceLineChart with a switch on renderState
Pressing the chart retry button while offline previously did nothing — a button that silently no-ops is confusing. Instead, open the standard informational bottom sheet (ConfirmActionContent) explaining that the device is offline and the chart will refresh on reconnect, reusing the existing common.offline_mode / common.offline_refresh_body / common.ok strings (no new i18n). Online retry is unchanged. Renames useBalanceLineChart.ts -> .tsx since the hook now builds the sheet's content element, matching useSettingsScreen/useInfoButton.
fd46f8c to
17f74a6
Compare
Description
Charts previously collapsed every failure mode into
isPending ? spinner : isError ? error : …. A query paused offline reportsisPending: trueforever, so true offline meant an eternal spinner with an unreachable retry, and chart data was never persisted, so cold offline starts had nothing to show. This PR makes the chart stack paused-aware and cache-friendly:useBalanceLineCharthook (colocated withBalanceLineChart) implementing the epic's data-first state contract: chart (fresh or stale) → offline → error → loading → empty. Stale data always beats state screens; a paused query renders a localized offline state (common.offline_mode/common.offline_refresh_body— no new i18n strings) instead of the spinner.useNetworkStatusStore.getState().hasInternetat press time and short-circuits while offline instead of dispatching a doomed 30 s request; an errored query on a known-offline device also collapses into the offline surface. Refetch auto-resumes on reconnect via the existing onlineManager wiring.isPausedthreaded from the raw query results throughWealthChart,AssetPriceChart, andAssetWealthChart;PriceTrendlogic extracted into a colocatedusePriceTrendhook that hides the trend (instead of showing a fake 0.00%) when paused with no data.shouldDehydrateQueryextracted fromQueryProviderinto a testableapps/mobile/src/providers/query-persistence.ts; successful['accounts','balance-history',…]and['assets','prices','history',…]snapshots are now dehydrated (network-only, no SQLite table backs them, no PII) so last-known charts survive restarts withinreactQueryPersistenceAge. New key predicatesisAccountBalancesHistoryQuery/isAssetPriceHistoryQuerylive in their packages; the per-account asset-history key is deliberately NOT allowlisted (ticket scope).CHART_QUERY_TIMEOUT_MSkept at 30 s — decision documented indocs/OFFLINE_PAUSED_STATE.md: no production latency data to justify lowering it, the endpoints are documented as routinely exceeding ky's 10 s default, and the offline short-circuit removes the doomed-wait UX that motivated reconsidering it.LineChartprops, intervals, ranges, and pointer interactions untouched; chart queries stay pure-network (networkModeuntouched, per the paused-state contract doc).Related Issues
Checklist
pnpm pre-pushgreen; fullpnpm testgreen — 549 files / 3937 mobile tests; every change built TDD red→green)docs/OFFLINE_PAUSED_STATE.md: chart adoption of the paused-state contract, persistence allowlist rationale, timeout decision)useBalanceLineChart+usePriceTrend, component branch tests incl. paused/offline-retry,query-persistenceeligibility spec, package-level key-predicate tests)Additional Notes
PriceTrendhides offline, theAssetMarketstrend row'sspace-betweenlayout left-aligns the lone date text.AssetWealthChartgotisPausedthreading (same shared component, same eternal-spinner bug) but its query key is intentionally outside the persistence allowlist, matching the ticket's explicit scope.🤖 Generated with Claude Code