Workout Analytics API
The Workout Analytics API provides comprehensive performance insights, personal record tracking, and program-aware analytics for user workout data. This system analyzes workout history to deliver meaningful metrics, trends, and comparisons across different time periods and programs.
⚠️ CRITICAL: THIN CLIENT IMPLEMENTATION ONLY
What Flutter SHOULD do:
- ✅ Display analytics dashboards and performance charts
- ✅ Show personal records and strength progression
- ✅ Present program comparisons and timeline visualizations
- ✅ Handle analytics query parameters and filtering
- ✅ Cache analytics data for offline viewing
What Flutter MUST NOT do:
- ❌ Calculate personal records or strength gains
- ❌ Process volume trends or statistical analysis
- ❌ Implement 1RM estimation algorithms
- ❌ Generate program comparison metrics
- ❌ Calculate workout consistency streaks
Core Concepts
Analytics Periods
The system supports various time periods for analysis:
- Fixed Periods:
last_7d,last_30d,last_90d,last_180d,last_360d,all_time - Program-Aware:
current_programanalyzes data from the active program enrollment - Comparison: Each period includes current vs previous period comparison data
Volume Analytics
Volume refers to the total weight lifted (weight × reps × sets) and is a key metric for:
- Trend Analysis: Track volume progression over time
- Program Effectiveness: Compare volume across different programs
- Muscle Group Balance: Analyze volume distribution across muscle groups
- Recovery Assessment: Identify volume spikes and recovery patterns
Personal Records (PRs)
The system tracks multiple types of personal records:
- Estimated 1RM: Calculated using proven strength formulas (Epley, Brzycki, etc.)
- Best Weight for Reps: Maximum weight lifted for specific rep ranges (1-15 reps)
- Max Volume Session: Highest total volume achieved in a single workout for an exercise
Program-Aware Analytics
Advanced analytics that understand program context:
- Program Boundaries: Identifies where one program ends and another begins
- Cross-Program Comparison: Compares performance across different training programs
- Program Effectiveness: Analyzes strength gains and volume progression within programs
- Timeline Analysis: Shows exercise progression with program transition markers
Muscle Group Categorization
Analytics organize muscle groups into broad categories:
- Back: Latissimus Dorsi, Trapezius, Rhomboids, Erector Spinae
- Chest: Pectoralis Major, Serratus Anterior
- Shoulders: All Deltoids, Rotator Cuff muscles
- Arms: Biceps, Triceps, Forearms
- Legs: Quadriceps, Hamstrings, Glutes, Calves
- Core: Abdominals, Obliques
GraphQL Schema
Types
type AnalyticsSummary {
period: String!
current: PeriodAnalytics!
previous: PeriodAnalytics!
}
type PeriodAnalytics {
startDate: DateTime
endDate: DateTime
workoutCount: Int!
suppressedWorkoutCount: Int!
totalDuration: Int!
totalVolume: Float!
totalSets: Int!
muscleDistribution: [MuscleDistributionItem!]!
detailedMuscleSetCounts: [DetailedMuscleSetCountItem!]!
topExercises: [TopExerciseItem!]!
}
type MuscleDistributionItem {
categoryName: String!
setCount: Int!
}
type DetailedMuscleSetCountItem {
muscleGroupId: ID!
muscleName: String!
setCount: Int!
}
type TopExerciseItem {
exerciseId: ID!
exerciseName: String!
workoutCount: Int!
exercise: Exercise!
}
type VolumeTrend {
filter: JSON!
trend: [VolumeTrendDataPoint!]!
}
type VolumeTrendDataPoint {
timeUnitStart: DateTime!
totalVolume: Float!
}
type PersonalRecordsResponse {
exerciseId: ID!
exerciseName: String!
records: JSON!
exercise: Exercise!
}
type ShortSummary {
workoutCount: JSON!
totalVolume: JSON!
consistencyStreakWeeks: Int!
strengthImprovement: JSON
}
type ProgramMetadata {
programUserInstanceId: ID!
programName: String!
startDate: DateTime!
expectedEndDate: DateTime!
actualEndDate: DateTime
status: String!
weekNumber: Int!
durationWeeks: Int!
}
type ProgramComparison {
programUserInstanceId: ID!
programName: String!
startDate: DateTime!
endDate: DateTime
totalWorkouts: Int!
totalVolume: Float!
totalSets: Int!
averageWorkoutsPerWeek: Float!
strengthGains: [ExerciseProgression!]!
muscleDistribution: [MuscleDistributionItem!]!
}
type ExerciseProgression {
exerciseId: ID!
exerciseName: String!
startingBest: PRSourceSet
endingBest: PRSourceSet
percentageGain: Float
exercise: Exercise!
}
type PRSourceSet {
weight: Float!
reps: Int!
setId: ID
}
type CrossProgramTimelineItem {
date: DateTime!
programUserInstanceId: ID!
programName: String!
exerciseId: ID!
exerciseName: String!
bestSetOfDay: PRSourceSet!
volume: Float!
isProgramBoundary: Boolean
exercise: Exercise!
}
enum AnalyticsPeriod {
last_7d
last_30d
last_90d
last_180d
last_360d
all_time
current_program
}
enum Granularity {
daily
weekly
monthly
}
enum BroadCategory {
Back
Chest
Core
Shoulders
Arms
Legs
}
Input Types
input VolumeTrendQueryInput {
period: AnalyticsPeriod!
granularity: Granularity
category: BroadCategory
muscleGroupId: ID
exerciseId: ID
programUserInstanceId: ID
}
input ProgramAnalyticsQueryInput {
programUserInstanceId: ID
includeCurrentOnly: Boolean
comparePrograms: Boolean
}
Queries
Analytics Summary
Get comprehensive analytics for a period with current vs previous comparison.
- GraphQL Query
- Variables
- Response
query AnalyticsSummary($period: AnalyticsPeriod!) {
analyticsSummary(period: $period) {
period
current {
startDate
endDate
workoutCount
suppressedWorkoutCount
totalDuration
totalVolume
totalSets
muscleDistribution {
categoryName
setCount
}
detailedMuscleSetCounts {
muscleGroupId
muscleName
setCount
}
topExercises {
exerciseId
exerciseName
workoutCount
exercise {
id
name
category
}
}
}
previous {
workoutCount
totalVolume
totalSets
muscleDistribution {
categoryName
setCount
}
}
}
}
{
"period": "last_30d"
}
{
"data": {
"analyticsSummary": {
"period": "last_30d",
"current": {
"startDate": "2023-11-01T00:00:00.000Z",
"endDate": "2023-11-30T23:59:59.999Z",
"workoutCount": 12,
"suppressedWorkoutCount": 1,
"totalDuration": 900,
"totalVolume": 28500.5,
"totalSets": 186,
"muscleDistribution": [
{ "categoryName": "Legs", "setCount": 62 },
{ "categoryName": "Back", "setCount": 48 },
{ "categoryName": "Chest", "setCount": 38 },
{ "categoryName": "Shoulders", "setCount": 24 },
{ "categoryName": "Arms", "setCount": 14 }
],
"detailedMuscleSetCounts": [
{ "muscleGroupId": "60f0cf0b2f8fb814a8a3d111", "muscleName": "Quadriceps Femoris", "setCount": 32 },
{ "muscleGroupId": "60f0cf0b2f8fb814a8a3d112", "muscleName": "Pectoralis Major", "setCount": 28 },
{ "muscleGroupId": "60f0cf0b2f8fb814a8a3d113", "muscleName": "Latissimus Dorsi", "setCount": 25 }
],
"topExercises": [
{
"exerciseId": "60f0cf0b2f8fb814a8a3d201",
"exerciseName": "Squat",
"workoutCount": 8,
"exercise": {
"id": "60f0cf0b2f8fb814a8a3d201",
"name": "Squat",
"category": "Compound"
}
},
{
"exerciseId": "60f0cf0b2f8fb814a8a3d202",
"exerciseName": "Bench Press",
"workoutCount": 6,
"exercise": {
"id": "60f0cf0b2f8fb814a8a3d202",
"name": "Bench Press",
"category": "Compound"
}
}
]
},
"previous": {
"workoutCount": 10,
"totalVolume": 24800.0,
"totalSets": 165,
"muscleDistribution": [
{ "categoryName": "Legs", "setCount": 55 },
{ "categoryName": "Back", "setCount": 42 },
{ "categoryName": "Chest", "setCount": 35 },
{ "categoryName": "Shoulders", "setCount": 22 },
{ "categoryName": "Arms", "setCount": 11 }
]
}
}
}
}
Volume Trend Analysis
Track volume progression over time with flexible filtering options.
- GraphQL Query
- Variables
- Response
query VolumeTrend($query: VolumeTrendQueryInput!) {
volumeTrend(query: $query) {
filter
trend {
timeUnitStart
totalVolume
}
}
}
{
"query": {
"period": "last_90d",
"granularity": "weekly",
"category": "Legs"
}
}
{
"data": {
"volumeTrend": {
"filter": {
"period": "last_90d",
"granularity": "weekly",
"category": "Legs",
"muscleGroupId": null,
"exerciseId": null,
"programUserInstanceId": null
},
"trend": [
{
"timeUnitStart": "2023-09-04T00:00:00.000Z",
"totalVolume": 3250.5
},
{
"timeUnitStart": "2023-09-11T00:00:00.000Z",
"totalVolume": 3420.0
},
{
"timeUnitStart": "2023-09-18T00:00:00.000Z",
"totalVolume": 3680.5
},
{
"timeUnitStart": "2023-09-25T00:00:00.000Z",
"totalVolume": 3890.0
},
{
"timeUnitStart": "2023-10-02T00:00:00.000Z",
"totalVolume": 4120.5
},
{
"timeUnitStart": "2023-10-09T00:00:00.000Z",
"totalVolume": 4350.0
},
{
"timeUnitStart": "2023-10-16T00:00:00.000Z",
"totalVolume": 4580.5
},
{
"timeUnitStart": "2023-10-23T00:00:00.000Z",
"totalVolume": 4750.0
},
{
"timeUnitStart": "2023-10-30T00:00:00.000Z",
"totalVolume": 4925.5
},
{
"timeUnitStart": "2023-11-06T00:00:00.000Z",
"totalVolume": 5180.0
},
{
"timeUnitStart": "2023-11-13T00:00:00.000Z",
"totalVolume": 5420.5
},
{
"timeUnitStart": "2023-11-20T00:00:00.000Z",
"totalVolume": 5650.0
},
{
"timeUnitStart": "2023-11-27T00:00:00.000Z",
"totalVolume": 5890.5
}
]
}
}
}
Personal Records
Get comprehensive personal records for a specific exercise.
- GraphQL Query
- Variables
- Response
query PersonalRecords($exerciseId: ID!) {
personalRecords(exerciseId: $exerciseId) {
exerciseId
exerciseName
records
exercise {
id
name
category
muscleGroups {
id
name
}
}
}
}
{
"exerciseId": "60f0cf0b2f8fb814a8a3d201"
}
{
"data": {
"personalRecords": {
"exerciseId": "60f0cf0b2f8fb814a8a3d201",
"exerciseName": "Squat",
"records": {
"estimated1RM": {
"value": 315.5,
"unit": "lbs",
"dateAchieved": "2023-11-28T10:00:00.000Z",
"sourceSet": {
"weight": 275,
"reps": 5,
"setId": "60f0cf0b2f8fb814a8a3d301"
}
},
"bestWeightForReps": [
{ "reps": 1, "weight": 300, "unit": "lbs", "dateAchieved": "2023-11-15T10:00:00.000Z" },
{ "reps": 2, "weight": 290, "unit": "lbs", "dateAchieved": "2023-11-20T10:00:00.000Z" },
{ "reps": 3, "weight": 285, "unit": "lbs", "dateAchieved": "2023-11-25T10:00:00.000Z" },
{ "reps": 4, "weight": 280, "unit": "lbs", "dateAchieved": "2023-11-28T10:00:00.000Z" },
{ "reps": 5, "weight": 275, "unit": "lbs", "dateAchieved": "2023-11-28T10:00:00.000Z" },
{ "reps": 6, "weight": 265, "unit": "lbs", "dateAchieved": "2023-11-10T10:00:00.000Z" },
{ "reps": 7, "weight": 255, "unit": "lbs", "dateAchieved": "2023-11-05T10:00:00.000Z" },
{ "reps": 8, "weight": 245, "unit": "lbs", "dateAchieved": "2023-10-28T10:00:00.000Z" },
{ "reps": 9, "weight": 235, "unit": "lbs", "dateAchieved": "2023-10-20T10:00:00.000Z" },
{ "reps": 10, "weight": 225, "unit": "lbs", "dateAchieved": "2023-10-15T10:00:00.000Z" }
],
"maxVolumeSession": {
"value": 8250.0,
"unit": "lbs",
"dateAchieved": "2023-11-28T10:00:00.000Z"
}
},
"exercise": {
"id": "60f0cf0b2f8fb814a8a3d201",
"name": "Squat",
"category": "Compound",
"muscleGroups": [
{ "id": "60f0cf0b2f8fb814a8a3d111", "name": "Quadriceps Femoris" },
{ "id": "60f0cf0b2f8fb814a8a3d112", "name": "Gluteus Maximus" },
{ "id": "60f0cf0b2f8fb814a8a3d113", "name": "Biceps Femoris" }
]
}
}
}
}
Short Summary
Get concise metrics for profile display and quick overview.
- GraphQL Query
- Response
query ShortSummary {
shortSummary {
workoutCount
totalVolume
consistencyStreakWeeks
strengthImprovement
}
}
{
"data": {
"shortSummary": {
"workoutCount": {
"current": 12,
"previous": 10
},
"totalVolume": {
"current": 28500.5,
"previous": 24800.0,
"unit": "lbs"
},
"consistencyStreakWeeks": 8,
"strengthImprovement": {
"exerciseName": "Squat",
"percentage": 15.2,
"periodDays": 90
}
}
}
}
Program Analytics
Program Analytics Summary
Get analytics for a specific program with comparison to previous program.
- GraphQL Query
- Variables
- Response
query ProgramAnalyticsSummary($programUserInstanceId: ID) {
programAnalyticsSummary(programUserInstanceId: $programUserInstanceId) {
period
current {
startDate
endDate
workoutCount
totalVolume
totalSets
muscleDistribution {
categoryName
setCount
}
topExercises {
exerciseId
exerciseName
workoutCount
}
}
previous {
workoutCount
totalVolume
totalSets
}
}
}
{
"programUserInstanceId": "60f0cf0b2f8fb814a8a3d401"
}
{
"data": {
"programAnalyticsSummary": {
"period": "program_instance",
"current": {
"startDate": "2023-10-01T00:00:00.000Z",
"endDate": "2023-11-30T23:59:59.999Z",
"workoutCount": 24,
"totalVolume": 52300.5,
"totalSets": 342,
"muscleDistribution": [
{ "categoryName": "Legs", "setCount": 118 },
{ "categoryName": "Back", "setCount": 86 },
{ "categoryName": "Chest", "setCount": 72 },
{ "categoryName": "Shoulders", "setCount": 42 },
{ "categoryName": "Arms", "setCount": 24 }
],
"topExercises": [
{ "exerciseId": "60f0cf0b2f8fb814a8a3d201", "exerciseName": "Squat", "workoutCount": 16 },
{ "exerciseId": "60f0cf0b2f8fb814a8a3d202", "exerciseName": "Bench Press", "workoutCount": 12 },
{ "exerciseId": "60f0cf0b2f8fb814a8a3d203", "exerciseName": "Deadlift", "workoutCount": 8 }
]
},
"previous": {
"workoutCount": 20,
"totalVolume": 41500.0,
"totalSets": 285
}
}
}
}
Program Metadata
Get detailed program information and progress.
query ProgramMetadata($programUserInstanceId: ID!) {
programMetadata(programUserInstanceId: $programUserInstanceId) {
programUserInstanceId
programName
startDate
expectedEndDate
actualEndDate
status
weekNumber
durationWeeks
}
}
User Program Comparison
Compare performance across multiple programs.
- GraphQL Query
- Variables
- Response
query UserProgramComparison($programUserInstanceIds: [ID!]) {
userProgramComparison(programUserInstanceIds: $programUserInstanceIds) {
programUserInstanceId
programName
startDate
endDate
totalWorkouts
totalVolume
totalSets
averageWorkoutsPerWeek
strengthGains {
exerciseId
exerciseName
startingBest {
weight
reps
}
endingBest {
weight
reps
}
percentageGain
}
muscleDistribution {
categoryName
setCount
}
}
}
{
"programUserInstanceIds": [
"60f0cf0b2f8fb814a8a3d401",
"60f0cf0b2f8fb814a8a3d402",
"60f0cf0b2f8fb814a8a3d403"
]
}
{
"data": {
"userProgramComparison": [
{
"programUserInstanceId": "60f0cf0b2f8fb814a8a3d401",
"programName": "Beginner 5/3/1",
"startDate": "2023-07-01T00:00:00.000Z",
"endDate": "2023-09-30T23:59:59.999Z",
"totalWorkouts": 36,
"totalVolume": 75200.5,
"totalSets": 486,
"averageWorkoutsPerWeek": 3.2,
"strengthGains": [
{
"exerciseId": "60f0cf0b2f8fb814a8a3d201",
"exerciseName": "Squat",
"startingBest": { "weight": 185, "reps": 5 },
"endingBest": { "weight": 225, "reps": 5 },
"percentageGain": 21.6
},
{
"exerciseId": "60f0cf0b2f8fb814a8a3d202",
"exerciseName": "Bench Press",
"startingBest": { "weight": 135, "reps": 5 },
"endingBest": { "weight": 165, "reps": 5 },
"percentageGain": 22.2
}
],
"muscleDistribution": [
{ "categoryName": "Legs", "setCount": 168 },
{ "categoryName": "Chest", "setCount": 132 },
{ "categoryName": "Back", "setCount": 108 },
{ "categoryName": "Shoulders", "setCount": 48 },
{ "categoryName": "Arms", "setCount": 30 }
]
},
{
"programUserInstanceId": "60f0cf0b2f8fb814a8a3d402",
"programName": "Intermediate PPL",
"startDate": "2023-10-01T00:00:00.000Z",
"endDate": null,
"totalWorkouts": 24,
"totalVolume": 52300.5,
"totalSets": 342,
"averageWorkoutsPerWeek": 4.0,
"strengthGains": [
{
"exerciseId": "60f0cf0b2f8fb814a8a3d201",
"exerciseName": "Squat",
"startingBest": { "weight": 225, "reps": 5 },
"endingBest": { "weight": 275, "reps": 5 },
"percentageGain": 22.2
}
],
"muscleDistribution": [
{ "categoryName": "Legs", "setCount": 118 },
{ "categoryName": "Back", "setCount": 86 },
{ "categoryName": "Chest", "setCount": 72 },
{ "categoryName": "Shoulders", "setCount": 42 },
{ "categoryName": "Arms", "setCount": 24 }
]
}
]
}
}
Exercise Cross-Program Timeline
Track exercise progression across all programs with program boundaries.
- GraphQL Query
- Variables
- Response
query ExerciseCrossProgramTimeline($exerciseId: ID!) {
exerciseCrossProgramTimeline(exerciseId: $exerciseId) {
date
programUserInstanceId
programName
exerciseId
exerciseName
bestSetOfDay {
weight
reps
setId
}
volume
isProgramBoundary
}
}
{
"exerciseId": "60f0cf0b2f8fb814a8a3d201"
}
{
"data": {
"exerciseCrossProgramTimeline": [
{
"date": "2023-07-03T10:00:00.000Z",
"programUserInstanceId": "60f0cf0b2f8fb814a8a3d401",
"programName": "Beginner 5/3/1",
"exerciseId": "60f0cf0b2f8fb814a8a3d201",
"exerciseName": "Squat",
"bestSetOfDay": { "weight": 185, "reps": 5, "setId": "60f0cf0b2f8fb814a8a3d501" },
"volume": 2775.0,
"isProgramBoundary": true
},
{
"date": "2023-07-05T10:00:00.000Z",
"programUserInstanceId": "60f0cf0b2f8fb814a8a3d401",
"programName": "Beginner 5/3/1",
"exerciseId": "60f0cf0b2f8fb814a8a3d201",
"exerciseName": "Squat",
"bestSetOfDay": { "weight": 185, "reps": 5, "setId": "60f0cf0b2f8fb814a8a3d502" },
"volume": 2775.0,
"isProgramBoundary": false
},
{
"date": "2023-07-10T10:00:00.000Z",
"programUserInstanceId": "60f0cf0b2f8fb814a8a3d401",
"programName": "Beginner 5/3/1",
"exerciseId": "60f0cf0b2f8fb814a8a3d201",
"exerciseName": "Squat",
"bestSetOfDay": { "weight": 195, "reps": 5, "setId": "60f0cf0b2f8fb814a8a3d503" },
"volume": 2925.0,
"isProgramBoundary": false
},
{
"date": "2023-09-28T10:00:00.000Z",
"programUserInstanceId": "60f0cf0b2f8fb814a8a3d401",
"programName": "Beginner 5/3/1",
"exerciseId": "60f0cf0b2f8fb814a8a3d201",
"exerciseName": "Squat",
"bestSetOfDay": { "weight": 225, "reps": 5, "setId": "60f0cf0b2f8fb814a8a3d520" },
"volume": 3375.0,
"isProgramBoundary": false
},
{
"date": "2023-10-02T10:00:00.000Z",
"programUserInstanceId": "60f0cf0b2f8fb814a8a3d402",
"programName": "Intermediate PPL",
"exerciseId": "60f0cf0b2f8fb814a8a3d201",
"exerciseName": "Squat",
"bestSetOfDay": { "weight": 225, "reps": 5, "setId": "60f0cf0b2f8fb814a8a3d521" },
"volume": 3375.0,
"isProgramBoundary": true
},
{
"date": "2023-11-28T10:00:00.000Z",
"programUserInstanceId": "60f0cf0b2f8fb814a8a3d402",
"programName": "Intermediate PPL",
"exerciseId": "60f0cf0b2f8fb814a8a3d201",
"exerciseName": "Squat",
"bestSetOfDay": { "weight": 275, "reps": 5, "setId": "60f0cf0b2f8fb814a8a3d540" },
"volume": 4125.0,
"isProgramBoundary": false
}
]
}
}
Flutter Integration
State Management Architecture
// Workout analytics state management
class WorkoutAnalyticsProvider extends ChangeNotifier {
AnalyticsSummary? _analyticsSummary;
Map<String, VolumeTrend> _volumeTrends = {};
Map<String, PersonalRecordsResponse> _personalRecords = {};
ShortSummary? _shortSummary;
List<ProgramComparison> _programComparisons = [];
Map<String, List<CrossProgramTimelineItem>> _exerciseTimelines = {};
AnalyticsSummary? get analyticsSummary => _analyticsSummary;
ShortSummary? get shortSummary => _shortSummary;
List<ProgramComparison> get programComparisons => _programComparisons;
VolumeTrend? getVolumeTrend(String key) => _volumeTrends[key];
PersonalRecordsResponse? getPersonalRecords(String exerciseId) => _personalRecords[exerciseId];
List<CrossProgramTimelineItem> getExerciseTimeline(String exerciseId) =>
_exerciseTimelines[exerciseId] ?? [];
// ✅ CORRECT: Load analytics summary from server
Future<void> loadAnalyticsSummary(AnalyticsPeriod period) async {
final result = await _graphqlClient.query(AnalyticsSummaryQuery(
variables: AnalyticsSummaryArguments(period: period),
));
if (result.hasException) throw result.exception!;
_analyticsSummary = result.parsedData!.analyticsSummary;
notifyListeners();
}
// ✅ CORRECT: Load volume trend with caching
Future<VolumeTrend> loadVolumeTrend(VolumeTrendQueryInput query) async {
final key = _generateVolumeTrendKey(query);
if (_volumeTrends.containsKey(key)) {
return _volumeTrends[key]!;
}
final result = await _graphqlClient.query(VolumeTrendQuery(
variables: VolumeTrendArguments(query: query),
));
if (result.hasException) throw result.exception!;
final trend = result.parsedData!.volumeTrend;
_volumeTrends[key] = trend;
notifyListeners();
return trend;
}
// ✅ CORRECT: Load personal records with caching
Future<PersonalRecordsResponse> loadPersonalRecords(String exerciseId) async {
if (_personalRecords.containsKey(exerciseId)) {
return _personalRecords[exerciseId]!;
}
final result = await _graphqlClient.query(PersonalRecordsQuery(
variables: PersonalRecordsArguments(exerciseId: exerciseId),
));
if (result.hasException) throw result.exception!;
final records = result.parsedData!.personalRecords;
_personalRecords[exerciseId] = records;
notifyListeners();
return records;
}
// ✅ CORRECT: Load program comparisons
Future<void> loadProgramComparisons([List<String>? programIds]) async {
final result = await _graphqlClient.query(UserProgramComparisonQuery(
variables: UserProgramComparisonArguments(
programUserInstanceIds: programIds,
),
));
if (result.hasException) throw result.exception!;
_programComparisons = result.parsedData!.userProgramComparison;
notifyListeners();
}
String _generateVolumeTrendKey(VolumeTrendQueryInput query) {
return '${query.period}_${query.granularity}_${query.category}_${query.muscleGroupId}_${query.exerciseId}';
}
}
Analytics Dashboard
class AnalyticsDashboardScreen extends StatefulWidget {
const AnalyticsDashboardScreen({super.key});
@override
State<AnalyticsDashboardScreen> createState() => _AnalyticsDashboardScreenState();
}
class _AnalyticsDashboardScreenState extends State<AnalyticsDashboardScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
AnalyticsPeriod _selectedPeriod = AnalyticsPeriod.last_30d;
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
_loadInitialData();
}
Future<void> _loadInitialData() async {
final provider = context.read<WorkoutAnalyticsProvider>();
await Future.wait([
provider.loadAnalyticsSummary(_selectedPeriod),
provider.loadShortSummary(),
]);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Analytics'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(100),
child: Column(
children: [
// Period selector
Container(
height: 50,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: AnalyticsPeriod.values.map((period) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: Text(_getPeriodLabel(period)),
selected: _selectedPeriod == period,
onSelected: (selected) {
if (selected) {
setState(() => _selectedPeriod = period);
_onPeriodChanged();
}
},
),
);
}).toList(),
),
),
// Tab bar
TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'Summary'),
Tab(text: 'Trends'),
Tab(text: 'Records'),
Tab(text: 'Programs'),
],
),
],
),
),
),
body: TabBarView(
controller: _tabController,
children: [
AnalyticsSummaryTab(period: _selectedPeriod),
VolumeTrendsTab(period: _selectedPeriod),
PersonalRecordsTab(),
ProgramComparisonTab(),
],
),
);
}
Future<void> _onPeriodChanged() async {
final provider = context.read<WorkoutAnalyticsProvider>();
await provider.loadAnalyticsSummary(_selectedPeriod);
}
String _getPeriodLabel(AnalyticsPeriod period) {
switch (period) {
case AnalyticsPeriod.last_7d:
return '7 Days';
case AnalyticsPeriod.last_30d:
return '30 Days';
case AnalyticsPeriod.last_90d:
return '90 Days';
case AnalyticsPeriod.last_180d:
return '6 Months';
case AnalyticsPeriod.last_360d:
return '1 Year';
case AnalyticsPeriod.all_time:
return 'All Time';
case AnalyticsPeriod.current_program:
return 'Current Program';
}
}
}
Analytics Summary Display
class AnalyticsSummaryTab extends StatelessWidget {
final AnalyticsPeriod period;
const AnalyticsSummaryTab({super.key, required this.period});
@override
Widget build(BuildContext context) {
return Consumer<WorkoutAnalyticsProvider>(
builder: (context, provider, child) {
final summary = provider.analyticsSummary;
if (summary == null) {
return const Center(child: CircularProgressIndicator());
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Key metrics cards
Row(
children: [
Expanded(
child: MetricCard(
title: 'Workouts',
current: summary.current.workoutCount,
previous: summary.previous.workoutCount,
isHigherBetter: true,
),
),
const SizedBox(width: 16),
Expanded(
child: MetricCard(
title: 'Total Volume',
current: summary.current.totalVolume,
previous: summary.previous.totalVolume,
isHigherBetter: true,
unit: 'lbs',
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: MetricCard(
title: 'Total Sets',
current: summary.current.totalSets,
previous: summary.previous.totalSets,
isHigherBetter: true,
),
),
const SizedBox(width: 16),
Expanded(
child: MetricCard(
title: 'Avg Duration',
current: summary.current.totalDuration / summary.current.workoutCount,
previous: summary.previous.totalDuration / summary.previous.workoutCount,
isHigherBetter: false,
unit: 'min',
),
),
],
),
const SizedBox(height: 24),
// Muscle distribution chart
Text(
'Muscle Group Distribution',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
MuscleDistributionChart(
distribution: summary.current.muscleDistribution,
previousDistribution: summary.previous.muscleDistribution,
),
const SizedBox(height: 24),
// Top exercises
Text(
'Top Exercises',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
...summary.current.topExercises.map((exercise) {
return TopExerciseCard(
exercise: exercise,
onTap: () => _navigateToExerciseDetails(context, exercise.exerciseId),
);
}),
],
),
);
},
);
}
void _navigateToExerciseDetails(BuildContext context, String exerciseId) {
Navigator.pushNamed(
context,
'/exercise-analytics',
arguments: ExerciseAnalyticsArguments(exerciseId: exerciseId),
);
}
}
class MetricCard extends StatelessWidget {
final String title;
final double current;
final double previous;
final bool isHigherBetter;
final String? unit;
const MetricCard({
super.key,
required this.title,
required this.current,
required this.previous,
required this.isHigherBetter,
this.unit,
});
@override
Widget build(BuildContext context) {
final difference = current - previous;
final percentageChange = previous != 0 ? (difference / previous) * 100 : 0.0;
final isImprovement = isHigherBetter ? difference > 0 : difference < 0;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 8),
Text(
'${_formatNumber(current)}${unit != null ? ' $unit' : ''}',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 4),
Row(
children: [
Icon(
isImprovement ? Icons.trending_up : Icons.trending_down,
size: 16,
color: isImprovement ? Colors.green : Colors.red,
),
const SizedBox(width: 4),
Text(
'${percentageChange.abs().toStringAsFixed(1)}%',
style: TextStyle(
color: isImprovement ? Colors.green : Colors.red,
fontSize: 12,
),
),
],
),
],
),
),
);
}
String _formatNumber(double value) {
if (value >= 1000) {
return '${(value / 1000).toStringAsFixed(1)}K';
}
return value.toStringAsFixed(0);
}
}
Volume Trends Chart
class VolumeTrendsTab extends StatefulWidget {
final AnalyticsPeriod period;
const VolumeTrendsTab({super.key, required this.period});
@override
State<VolumeTrendsTab> createState() => _VolumeTrendsTabState();
}
class _VolumeTrendsTabState extends State<VolumeTrendsTab> {
BroadCategory? _selectedCategory;
String? _selectedMuscleGroupId;
String? _selectedExerciseId;
Granularity _granularity = Granularity.weekly;
VolumeTrend? _currentTrend;
bool _isLoading = false;
@override
void initState() {
super.initState();
_loadTrend();
}
@override
void didUpdateWidget(VolumeTrendsTab oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.period != oldWidget.period) {
_loadTrend();
}
}
Future<void> _loadTrend() async {
setState(() => _isLoading = true);
try {
final provider = context.read<WorkoutAnalyticsProvider>();
final query = VolumeTrendQueryInput(
period: widget.period,
granularity: _granularity,
category: _selectedCategory,
muscleGroupId: _selectedMuscleGroupId,
exerciseId: _selectedExerciseId,
);
final trend = await provider.loadVolumeTrend(query);
setState(() => _currentTrend = trend);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load volume trend: $e')),
);
} finally {
setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// Filter controls
Container(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Granularity selector
Row(
children: [
Text('Granularity: '),
...Granularity.values.map((g) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: Text(g.name.toUpperCase()),
selected: _granularity == g,
onSelected: (selected) {
if (selected) {
setState(() => _granularity = g);
_loadTrend();
}
},
),
);
}),
],
),
const SizedBox(height: 8),
// Category filter
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
FilterChip(
label: const Text('All'),
selected: _selectedCategory == null &&
_selectedMuscleGroupId == null &&
_selectedExerciseId == null,
onSelected: (selected) {
if (selected) {
setState(() {
_selectedCategory = null;
_selectedMuscleGroupId = null;
_selectedExerciseId = null;
});
_loadTrend();
}
},
),
const SizedBox(width: 8),
...BroadCategory.values.map((category) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: Text(category.name),
selected: _selectedCategory == category,
onSelected: (selected) {
if (selected) {
setState(() {
_selectedCategory = category;
_selectedMuscleGroupId = null;
_selectedExerciseId = null;
});
_loadTrend();
}
},
),
);
}),
],
),
),
],
),
),
// Chart
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _currentTrend != null
? VolumeLineChart(trend: _currentTrend!)
: const Center(child: Text('No data available')),
),
],
);
}
}
class VolumeLineChart extends StatelessWidget {
final VolumeTrend trend;
const VolumeLineChart({super.key, required this.trend});
@override
Widget build(BuildContext context) {
return Container(
height: 300,
padding: const EdgeInsets.all(16),
child: LineChart(
LineChartData(
gridData: FlGridData(show: true),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
return Text(
_formatVolumeValue(value),
style: const TextStyle(fontSize: 10),
);
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index >= 0 && index < trend.trend.length) {
final date = trend.trend[index].timeUnitStart;
return Text(
DateFormat('M/d').format(date),
style: const TextStyle(fontSize: 10),
);
}
return const Text('');
},
),
),
topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
borderData: FlBorderData(show: true),
lineBarsData: [
LineChartBarData(
spots: trend.trend.asMap().entries.map((entry) {
return FlSpot(
entry.key.toDouble(),
entry.value.totalVolume,
);
}).toList(),
isCurved: true,
color: Theme.of(context).colorScheme.primary,
barWidth: 2,
isStrokeCapRound: true,
dotData: FlDotData(show: true),
belowBarData: BarAreaData(
show: true,
color: Theme.of(context).colorScheme.primary.withOpacity(0.1),
),
),
],
),
),
);
}
String _formatVolumeValue(double value) {
if (value >= 1000) {
return '${(value / 1000).toStringAsFixed(1)}K';
}
return value.toStringAsFixed(0);
}
}
Personal Records Display
class PersonalRecordsTab extends StatelessWidget {
const PersonalRecordsTab({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: [
// Exercise search/selection
Container(
padding: const EdgeInsets.all(16),
child: ExerciseSearchField(
onExerciseSelected: (exercise) {
Navigator.pushNamed(
context,
'/personal-records-detail',
arguments: PersonalRecordsDetailArguments(
exerciseId: exercise.id,
exerciseName: exercise.name,
),
);
},
),
),
// Quick access to popular exercises
Expanded(
child: Consumer<WorkoutAnalyticsProvider>(
builder: (context, provider, child) {
// Show popular exercises for quick access
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
Text(
'Popular Exercises',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
// Get from analytics summary top exercises
if (provider.analyticsSummary != null)
...provider.analyticsSummary!.current.topExercises
.map((exercise) {
return ExercisePersonalRecordsCard(
exerciseId: exercise.exerciseId,
exerciseName: exercise.exerciseName,
onTap: () => _showPersonalRecordsDetail(
context,
exercise.exerciseId,
exercise.exerciseName,
),
);
}),
],
);
},
),
),
],
);
}
void _showPersonalRecordsDetail(
BuildContext context,
String exerciseId,
String exerciseName,
) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => DraggableScrollableSheet(
initialChildSize: 0.7,
maxChildSize: 0.95,
minChildSize: 0.5,
expand: false,
builder: (context, scrollController) {
return PersonalRecordsDetailSheet(
exerciseId: exerciseId,
exerciseName: exerciseName,
scrollController: scrollController,
);
},
),
);
}
}
class PersonalRecordsDetailSheet extends StatefulWidget {
final String exerciseId;
final String exerciseName;
final ScrollController scrollController;
const PersonalRecordsDetailSheet({
super.key,
required this.exerciseId,
required this.exerciseName,
required this.scrollController,
});
@override
State<PersonalRecordsDetailSheet> createState() => _PersonalRecordsDetailSheetState();
}
class _PersonalRecordsDetailSheetState extends State<PersonalRecordsDetailSheet> {
PersonalRecordsResponse? _records;
bool _isLoading = false;
@override
void initState() {
super.initState();
_loadPersonalRecords();
}
Future<void> _loadPersonalRecords() async {
setState(() => _isLoading = true);
try {
final provider = context.read<WorkoutAnalyticsProvider>();
final records = await provider.loadPersonalRecords(widget.exerciseId);
setState(() => _records = records);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load personal records: $e')),
);
} finally {
setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: Column(
children: [
// Handle
Container(
width: 40,
height: 4,
margin: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
// Header
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'${widget.exerciseName} Records',
style: Theme.of(context).textTheme.titleLarge,
),
),
// Content
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _records != null
? _buildRecordsContent()
: const Center(child: Text('No records found')),
),
],
),
);
}
Widget _buildRecordsContent() {
final records = _records!.records as Map<String, dynamic>;
return ListView(
controller: widget.scrollController,
padding: const EdgeInsets.all(16),
children: [
// Estimated 1RM
if (records['estimated1RM'] != null)
PersonalRecordCard(
title: 'Estimated 1RM',
value: '${records['estimated1RM']['value']} ${records['estimated1RM']['unit']}',
date: DateTime.parse(records['estimated1RM']['dateAchieved']),
sourceSet: records['estimated1RM']['sourceSet'],
icon: Icons.trending_up,
color: Colors.red,
),
const SizedBox(height: 16),
// Best weights for reps
Text(
'Best Weight for Reps',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
if (records['bestWeightForReps'] != null)
...((records['bestWeightForReps'] as List).take(10)).map((record) {
return BestWeightForRepsCard(
reps: record['reps'],
weight: record['weight'],
unit: record['unit'],
date: record['dateAchieved'] != null
? DateTime.parse(record['dateAchieved'])
: null,
);
}),
const SizedBox(height: 16),
// Max volume session
if (records['maxVolumeSession'] != null)
PersonalRecordCard(
title: 'Max Volume Session',
value: '${records['maxVolumeSession']['value']} ${records['maxVolumeSession']['unit']}',
date: DateTime.parse(records['maxVolumeSession']['dateAchieved']),
icon: Icons.fitness_center,
color: Colors.green,
),
],
);
}
}
Error Handling
Common Error Scenarios
# Authentication required
{
"errors": [
{
"message": "Authentication is required.",
"extensions": {
"code": "UNAUTHENTICATED"
}
}
]
}
# Invalid period for current program analytics
{
"errors": [
{
"message": "User has no active program for current_program analytics",
"extensions": {
"code": "NO_ACTIVE_PROGRAM"
}
}
]
}
# Exercise not found
{
"errors": [
{
"message": "Exercise not found or user has no data for this exercise",
"extensions": {
"code": "EXERCISE_NOT_FOUND",
"exerciseId": "60f0cf0b2f8fb814a8a3d999"
}
}
]
}
# Insufficient data
{
"errors": [
{
"message": "Insufficient workout data for meaningful analytics",
"extensions": {
"code": "INSUFFICIENT_DATA",
"minimumWorkouts": 3,
"userWorkouts": 1
}
}
]
}
Flutter Error Handling
class WorkoutAnalyticsErrorHandler {
static void handleAnalyticsError(BuildContext context, dynamic error) {
String message = 'An unexpected error occurred';
if (error is GraphQLError) {
final code = error.extensions?['code'];
switch (code) {
case 'NO_ACTIVE_PROGRAM':
message = 'You need an active program to view current program analytics.';
break;
case 'EXERCISE_NOT_FOUND':
message = 'Exercise not found or you haven\'t performed this exercise yet.';
break;
case 'INSUFFICIENT_DATA':
final minWorkouts = error.extensions?['minimumWorkouts'];
message = 'Need at least $minWorkouts workouts for meaningful analytics.';
break;
default:
message = error.message;
}
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
Testing Examples
Integration Tests
void main() {
group('Workout Analytics Integration Tests', () {
testWidgets('analytics summary displays key metrics', (tester) async {
// Arrange: Mock analytics data
when(mockClient.query<AnalyticsSummary>(any)).thenAnswer((_) async =>
QueryResult(
data: {
'analyticsSummary': {
'period': 'last_30d',
'current': {
'workoutCount': 12,
'totalVolume': 28500.5,
'totalSets': 186,
'muscleDistribution': [
{'categoryName': 'Legs', 'setCount': 62},
{'categoryName': 'Back', 'setCount': 48},
],
'topExercises': [
{'exerciseId': 'exercise1', 'exerciseName': 'Squat', 'workoutCount': 8},
],
},
'previous': {
'workoutCount': 10,
'totalVolume': 24800.0,
'totalSets': 165,
}
}
},
source: QueryResultSource.network,
options: QueryOptions(document: gql('')),
));
// Act: Load analytics screen
await tester.pumpWidget(testApp);
await tester.tap(find.text('Analytics'));
await tester.pumpAndSettle();
// Assert: Key metrics displayed
expect(find.text('12'), findsOneWidget); // Workout count
expect(find.text('28.5K lbs'), findsOneWidget); // Total volume
expect(find.text('Legs'), findsOneWidget); // Top muscle group
expect(find.text('Squat'), findsOneWidget); // Top exercise
});
testWidgets('volume trend chart displays data', (tester) async {
// Arrange: Mock volume trend data
when(mockClient.query<VolumeTrend>(any)).thenAnswer((_) async =>
QueryResult(
data: {
'volumeTrend': {
'filter': {'period': 'last_30d', 'granularity': 'weekly'},
'trend': [
{'timeUnitStart': '2023-11-01T00:00:00.000Z', 'totalVolume': 3250.5},
{'timeUnitStart': '2023-11-08T00:00:00.000Z', 'totalVolume': 3420.0},
{'timeUnitStart': '2023-11-15T00:00:00.000Z', 'totalVolume': 3680.5},
{'timeUnitStart': '2023-11-22T00:00:00.000Z', 'totalVolume': 3890.0},
]
}
},
source: QueryResultSource.network,
options: QueryOptions(document: gql('')),
));
// Act: Navigate to trends tab
await tester.pumpWidget(testApp);
await tester.tap(find.text('Trends'));
await tester.pumpAndSettle();
// Assert: Chart displays trend data
expect(find.byType(LineChart), findsOneWidget);
});
});
}
Unit Tests
void main() {
group('WorkoutAnalyticsProvider', () {
late WorkoutAnalyticsProvider provider;
late MockGraphQLClient mockClient;
setUp(() {
mockClient = MockGraphQLClient();
provider = WorkoutAnalyticsProvider(mockClient);
});
test('loadAnalyticsSummary updates state correctly', () async {
// Arrange
when(mockClient.query<AnalyticsSummary>(any)).thenAnswer((_) async =>
QueryResult(
data: {
'analyticsSummary': {
'period': 'last_30d',
'current': {
'workoutCount': 12,
'totalVolume': 28500.5,
'muscleDistribution': [],
'topExercises': [],
},
'previous': {
'workoutCount': 10,
'totalVolume': 24800.0,
}
}
},
source: QueryResultSource.network,
options: QueryOptions(document: gql('')),
));
// Act
await provider.loadAnalyticsSummary(AnalyticsPeriod.last_30d);
// Assert
expect(provider.analyticsSummary, isNotNull);
expect(provider.analyticsSummary!.current.workoutCount, equals(12));
expect(provider.analyticsSummary!.current.totalVolume, equals(28500.5));
});
test('loadVolumeTrend caches results by key', () async {
// Arrange
when(mockClient.query<VolumeTrend>(any)).thenAnswer((_) async =>
QueryResult(
data: {
'volumeTrend': {
'filter': {'period': 'last_30d'},
'trend': [
{'timeUnitStart': '2023-11-01T00:00:00.000Z', 'totalVolume': 3250.5}
]
}
},
source: QueryResultSource.network,
options: QueryOptions(document: gql('')),
));
final query = VolumeTrendQueryInput(
period: AnalyticsPeriod.last_30d,
granularity: Granularity.weekly,
);
// Act
final trend1 = await provider.loadVolumeTrend(query);
final trend2 = await provider.loadVolumeTrend(query); // Should use cache
// Assert
expect(trend1, equals(trend2));
verify(mockClient.query<VolumeTrend>(any)).called(1); // Only called once
});
});
}
Business Logic Summary
The Workout Analytics API provides comprehensive performance insights through:
⚠️ IMPLEMENTATION BOUNDARY
All analytics calculations, statistical analysis, and performance insights are server-side only. Flutter must NEVER implement analytics computations locally.
Server-Side Intelligence:
- Volume Calculations: Computes total workout volume (weight × reps × sets)
- Personal Record Tracking: Identifies and tracks various types of PRs across all workouts
- Strength Analysis: Calculates percentage gains and progression rates
- Statistical Processing: Performs trend analysis, comparison calculations, and data aggregation
- Program-Aware Analytics: Understands program boundaries and provides context-specific insights
- 1RM Estimation: Uses validated strength formulas to estimate one-rep maximums
- Consistency Tracking: Calculates workout streaks and adherence metrics
Client Responsibilities:
- Display analytics dashboards with charts and visualizations
- Present personal records and achievement celebrations
- Show program comparisons and timeline displays
- Handle analytics filtering and query parameter selection
- Cache analytics results for offline viewing and faster loading
- Provide intuitive navigation between different analytics views