13 — State Management (Dashboard Module)
- 1. State model
- 2. Cubits
- 3. Fetch flow (OverviewCubit)
- 4. Polling
- 5. WebSocket refresh path
- 6. Period selectors
(planned) - 7. Widgets state
(planned) - 8. Cache & staleness summary
- 9. Error mapping (from
00-shared/06 §5)
Cubit-based state per 00-shared/06 (Bloc/Cubit baseline, cache §3.3, auth/session §3.6). Sources:
dashboard.service.ts(payload),redis-cache.service.ts(server cache),ws-bridge.service.ts+ws.gateway.ts(realtime),Dashboard.md(TTL/events).
1. State model
sealed class DashboardState {
const DashboardState();
}
class DashboardInitial extends DashboardState {}
class DashboardLoading extends DashboardState {} // first load, skeletons
class DashboardLoaded extends DashboardState {
final Overview overview; // dashboard.service.ts:50-70 shape
final DateTime fetchedAt; // freshness tracking
final bool fromCache; // client cache hit?
final Duration cacheAge; // 0 when fresh network
}
class DashboardError extends DashboardState {
final AppError error; // UNAUTHENTICATED / INTERNAL / RATE_LIMITED
final Overview? lastGood; // keep-last-good on refresh failure
}
class DashboardOffline extends DashboardState {
final Overview lastGood; // ≥10 min old → offline banner
}
Overview mirrors dashboard.service.ts:50-70 exactly — no client-side
derivation (09_User_Behaviour.md B9).
2. Cubits
| Cubit | Owns | Source |
|---|---|---|
OverviewCubit | payload, freshness, refresh triggers | E1 (12_API_Mapping.md) |
PeriodCubit (planned) | Today/7d/30d selection | 08_Form_Specifications.md §2 |
WidgetsCubit (planned) | widget order/visibility for customize screen | E4/E5 |
WsSubscriptionCubit (or service) | WS connect state, event filter, debounce | §5 below |
Single OverviewCubit per dashboard route; created in the screen's
BlocProvider, disposed on route pop (poll timer dies with it).
3. Fetch flow (OverviewCubit)
flowchart TD
A[open /dashboard] --> B{cache hit? age<60s}
B -->|yes| C[emit Loaded fromCache=true cacheAge=age]
B -->|no| D[emit Loading only if no lastGood]
D --> E[GET /dashboard/overview]
E -->|200| F[write client cache TTL 60s]
F --> G[emit Loaded fromCache=false]
E -->|401| H[session refresh → replay; fail = session expiry]
E -->|429| I[emit Error RATE_LIMITED, keep lastGood, backoff poll]
E -->|5xx| J[emit Error INTERNAL keep lastGood]
C --> K[poll timer 60s → B]
- Single-flight: one in-flight request; a new trigger while in-flight
sets a
pendingflag and re-runs once on completion (coalescing) (10_Interaction_Specification.md§2). refresh({force}): force skips the cache check (pull-to-refresh, WS).- Client cache key mirrors server:
sl:{tenant}:dashboard:overviewTTL 60 s (00-shared/06 §3.3); volatile — never written on error responses.
4. Polling
Timer.periodic(60s)started onLoaded, cancelled inclose().- Tick →
refresh()non-forced (cache age check skips the request when fresh —10_Interaction_Specification.md§3). - App-background:
WidgetsBindingObserverpauses timer; resume runs an immediate staleness check.
5. WebSocket refresh path
sequenceDiagram
participant S as Socket (WsGateway)
participant C as OverviewCubit
participant R as REST /dashboard/overview
Note over S: auto-joined tenant:{id} (ws.gateway.ts:50)
S->>C: event {eventType, occurredAt, payload} (ws-bridge.service.ts:17-21)
C->>C: filter: AttendanceMarked|ResultPublished|PaymentRecorded|InvoiceGenerated (Dashboard.md:34)
C->>C: debounce 2s (coalesce bursts)
C->>R: refresh(force: true)
R-->>C: fresh Overview
C->>C: emit Loaded; delta ≥1% → animate + live-region
- Unknown/other events ignored (bridge forwards every domain event —
ws-bridge.service.ts:16-22). - WS drop → poll timer continues; after 2 missed polls →
AppOfflineBanner. - Server-pushed KPI payloads
(forward-looking).
6. Period selectors (planned)
PeriodCubitstate{period: today|7d|30d}; change → refetch/dashboard/attendance|finance?period=only when server supports it (E2/E3(planned)); until then the selector renders disabled (08_Form_Specifications.md§2).- Each period keeps its own cache entry
sl:{tenant}:dashboard:{scope}:{period}(TTL 60 s(planned)).
7. Widgets state (planned)
WidgetsCubit:{widgets: List<WidgetConfig>, dirty: bool, savingIds: Set}; reorder/toggle → local optimistic + dirty; Save → per-widgetPATCH(E5) with rollback; conflict (409) → reload from E4.- Layout is server-authoritative (
07_Component_Library.md§7).
8. Cache & staleness summary
| Layer | Key | TTL | Writes on |
|---|---|---|---|
Server (target (planned)) | sl:{tenantId}:dashboard:overview | 60 s (Dashboard.md:41) | rebuild worker + event invalidation (Dashboard.md:50,:34) |
| Client volatile | sl:{tenant}:dashboard:overview | 60 s | successful 200 only |
| Client render | — | — | last-good always kept; StaleBanner past 60 s, offline ≥10 min |
9. Error mapping (from 00-shared/06 §5)
| Error | UI |
|---|---|
| 401 | silent refresh → replay; fail → session expiry screen |
| 403 | never reachable (module hidden) — dashboard.read gate |
| 429 | backoff: skip next poll cycle; AppSnackbar once |
| 404 | not expected on overview; treat as 5xx |
| 5xx | keep lastGood; snackbar ≥ 4 s; no skeleton flash |