critical priority high complexity backend pending backend specialist Tier 3

Acceptance Criteria

AnnualStatsAggregationService exposes a single public method: Future<AnnualStatsResult> getStats(String userId, SummaryPeriod period)
The service calls AnnualStatsRepository.fetchAnnualStats() and maps the raw Map response to an AnnualStatsResult — no raw Supabase calls occur inside the service itself
Total hours is parsed from the total_hours field as a double; if the field is null or the value is negative, it defaults to 0.0
activityTypeDistribution is computed from the raw activity_type_counts map by dividing each count by the total count sum and multiplying by 100.0, normalised so the values sum to exactly 100.0 (handle floating-point remainder by adding it to the largest bucket)
Streak calculation: the service queries a separate list of activity dates for the user (sorted descending) and counts consecutive calendar days from today (or the period end date) backwards — a gap of more than 1 day breaks the streak
When AnnualStatsException(reason: networkError) is thrown by the repository, the service attempts to read from the offline cache (injected OfflineCacheService); if a cached result exists, it is returned; if not, AnnualStatsException is rethrown
The returned AnnualStatsResult always has periodStart and periodEnd set to period.startDate and period.endDate respectively
Service is injectable (constructor injection for AnnualStatsRepository and OfflineCacheService) and registered with Riverpod or a DI container
All computation logic (distribution normalisation, streak calculation) is in private pure functions that can be unit-tested independently of Supabase

Technical Requirements

frameworks
Flutter
Riverpod
BLoC
apis
AnnualStatsRepository (internal)
OfflineCacheService (internal)
data models
AnnualStatsResult
SummaryPeriod
AnnualStatsException
performance requirements
getStats() must complete within 2 seconds for online queries on a 4G connection
Distribution normalisation must handle maps with up to 20 activity types without perceptible delay
Streak calculation must handle up to 730 activity dates (2 years) in under 50ms — use a sorted list iteration rather than a Set lookup loop
security requirements
userId must not be logged to console in production builds — use kDebugMode guards around any diagnostic prints
Service must validate that the period end date is not in the future before querying; clamp period.endDate to DateTime.now() if it is
Service must not cache results from other users — cache key must include userId as a component

Execution Context

Execution Tier
Tier 3

Tier 3 - 413 tasks

Can start after Tier 2 completes

Implementation Notes

Structure the service as a class in lib/features/annual_summary/domain/services/annual_stats_aggregation_service.dart. Extract the distribution normalisation into a pure static method _normaliseDistribution(Map counts) → Map and the streak calculation into _calculateStreak(List activityDates, DateTime referenceDate) → int — both can then be tested directly without constructing the full service. For the normalisation remainder fix: compute percentages as doubles, then find the largest bucket and assign it the value needed to make the sum exactly 100.0. For streak: sort dates descending, iterate and compare consecutive pairs using DateUtils.dateOnly() to strip time components before comparison.

Inject dependencies via constructor with default Riverpod providers so integration is seamless; use @riverpod annotation if the project uses riverpod_generator. Consider using Result as the return type instead of throwing — this makes the caller's error handling more explicit, but only adopt this if the rest of the codebase follows this pattern to maintain consistency.

Testing Requirements

Unit tests (flutter_test) with mocked dependencies. Test groups: (1) Happy path — mock repository returns a known raw map; assert AnnualStatsResult fields match expected computed values including distribution normalisation and streak; (2) Distribution edge cases — single activity type maps to 100.0%; empty activity_type_counts produces empty distribution map; floating-point remainder is handled so values sum to exactly 100.0; (3) Streak calculation — 5 consecutive days = streak of 5; gap on day 3 = streak of 2; no activities = streak of 0; (4) Fallback — repository throws networkError; mock cache returns a prior result; assert getStats returns the cached result; (5) Cache miss — repository throws networkError AND cache returns null; assert getStats rethrows AnnualStatsException. Target 90% line coverage.

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.