high priority medium complexity backend pending backend specialist Tier 4

Acceptance Criteria

OfflineCacheService exposes two methods: Future<void> saveAnnualStats(String cacheKey, Map<String, dynamic> rawStats, DateTime fetchedAt) and Future<CachedAnnualStats?> loadAnnualStats(String cacheKey)
CachedAnnualStats is a data class with fields: rawStats (Map<String, dynamic>), fetchedAt (DateTime), isStale (bool — true when fetchedAt is more than 24 hours ago)
Cache key is generated as sha256(userId + '_' + startDate.toIso8601String() + '_' + endDate.toIso8601String()) to produce a deterministic, collision-resistant key that encodes both user and period
On successful Supabase fetch, the service writes the raw Map response (not the parsed AnnualStatsResult) to the cache before returning the result to the caller
On AnnualStatsException(reason: networkError), the service reads from cache; if a cached entry exists, it returns the parsed AnnualStatsResult AND logs a debug warning that cached data is being used
The AnnualStatsResult returned from cache has its periodStart/periodEnd set from the cache key period (not from the raw data), so the UI always shows the correct period labels
Cache entries older than 7 days are purged automatically when the service initialises (call pruneExpiredEntries() in the service constructor or a lazy init guard)
BLoC/Riverpod state carries an optional cachedAt (DateTime?) field — non-null when data was served from cache — so the UI layer can show a 'Data from [date]' banner
Unit tests cover: cache write called after successful fetch; cache read called after network failure; stale flag is true when fetchedAt is 25 hours ago; pruneExpiredEntries removes entries older than 7 days

Technical Requirements

frameworks
Flutter
Riverpod
sqflite or shared_preferences for local cache storage
apis
OfflineCacheService (internal)
AnnualStatsRepository (internal)
data models
CachedAnnualStats
AnnualStatsResult
SummaryPeriod
performance requirements
Cache read must complete in under 100ms for a single entry — use sqflite with a single-row keyed lookup, not a full table scan
Cache write must be fire-and-forget (unawaited or queued) and must not block the return of the fresh result to the caller
Prune operation must be async and must not block service initialisation
security requirements
Cached data is stored on-device in the app's private sandbox — no external storage permissions required
Cache key must include userId so one user's cached stats are never served to another user (e.g. after account switch)
Raw stats map stored in cache must not include auth tokens or session data — only the aggregated numeric payload from the RPC response
Cache entries must be encrypted at rest if the project's sqflite setup uses SQLCipher — check existing database.dart for encryption config and match it

Execution Context

Execution Tier
Tier 4

Tier 4 - 323 tasks

Can start after Tier 3 completes

Implementation Notes

Implement OfflineCacheService as a thin wrapper over sqflite with a table named annual_stats_cache (columns: cache_key TEXT PRIMARY KEY, raw_stats TEXT, fetched_at INTEGER as Unix timestamp milliseconds). Store raw_stats as a JSON string (jsonEncode/jsonDecode). Use crypto package's sha256 for the cache key hash — this keeps keys at a fixed length regardless of date string format. Make the cache write fire-and-forget using unawaited(cacheService.saveAnnualStats(...)) so the fresh result is returned to the caller immediately without waiting for the disk write.

The 7-day TTL for prune is a business decision — make it a named constant (kCacheMaxAgedays = 7) in a constants file so it can be adjusted without hunting through the code. Expose cachedAt as an optional field on the AnnualStatsState (BLoC) or an AsyncValue annotation (Riverpod) rather than embedding it in AnnualStatsResult — the result model is a domain object and should not carry infrastructure concerns like cache timestamps.

Testing Requirements

Unit tests (flutter_test) with an in-memory fake OfflineCacheService. Test groups: (1) Write-through — mock repository returns success; assert saveAnnualStats was called with correct key and data; (2) Cache hit on failure — repository throws networkError; mock cache returns a CachedAnnualStats with fetchedAt = 2 hours ago; assert returned AnnualStatsResult matches cached data and cachedAt is set; (3) Cache miss on failure — repository throws networkError; cache returns null; assert AnnualStatsException is rethrown; (4) Staleness flag — CachedAnnualStats with fetchedAt = 25 hours ago has isStale == true; (5) Prune — insert entries with fetchedAt = 8 days ago and 1 day ago; call pruneExpiredEntries; assert only the 8-day-old entry is removed. Integration test: use actual sqflite in-memory database to verify the full write → read cycle round-trips correctly.

Component
Annual Stats Aggregation Service
service high
Epic Risks (3)
high impact medium prob integration

Activity records may contain duplicate entries (as evidenced by the duplicate-detection feature dependency) or proxy-registered activities that should be attributed differently. Including duplicates or mis-attributed records would produce inflated stats, undermining trust in the summary.

Mitigation & Contingency

Mitigation: Implement the aggregation query to join against the deduplication-reviewed-flag on activity records and filter out unresolved duplicates. Coordinate with the duplicate-detection feature team to confirm the authoritative flag field before implementing the RPC. Include a data-quality warning in the summary when unresolved duplicates are detected.

Contingency: If deduplication state is unreliable at release time, add a prominent disclaimer in the summary UI noting that figures reflect all registered activities and may include duplicates pending review. Track a follow-up task to re-aggregate after deduplication runs.

medium impact high prob scope

Each organisation wants to define their own milestone thresholds (e.g., NHF's counting model differs from HLF's certification model). Implementing configurable thresholds may expand scope significantly if the configuration UI is expected in this epic.

Mitigation & Contingency

Mitigation: Scope this epic strictly to the evaluation engine and a hardcoded default threshold set. Define the MilestoneDefinition interface with an organisation_id discriminator so per-org configs can be loaded from the database in a later sprint. Build the admin configuration UI as a separate follow-on task outside this epic.

Contingency: If stakeholders require per-org milestone configuration before launch, deliver a JSON-based configuration file per org as an interim solution, loaded from Supabase storage, until a full admin UI is built.

medium impact medium prob technical

Android 13+ restricts access to media collections and requires READ_MEDIA_IMAGES permission for gallery saves, while older Android versions use WRITE_EXTERNAL_STORAGE. Handling both permission models correctly across the device matrix is error-prone.

Mitigation & Contingency

Mitigation: Use the permission_handler Flutter package with version-aware permission requests abstracted behind the summary-share-service interface. Write platform-specific unit tests for both Android API levels in the test harness. Test on a minimum of three Android versions (API 29, 32, 34) in CI.

Contingency: If gallery save is broken on specific Android versions at launch, disable the 'Save to gallery' option on affected API levels and surface only clipboard and system share sheet, which require no media permissions.