6/8 - Public Beta (Discord)
|See Changelog
Skip to main content

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_program analyzes 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.

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
}
}
}
}

Volume Trend Analysis

Track volume progression over time with flexible filtering options.

query VolumeTrend($query: VolumeTrendQueryInput!) {
volumeTrend(query: $query) {
filter
trend {
timeUnitStart
totalVolume
}
}
}

Personal Records

Get comprehensive personal records for a specific exercise.

query PersonalRecords($exerciseId: ID!) {
personalRecords(exerciseId: $exerciseId) {
exerciseId
exerciseName
records
exercise {
id
name
category
muscleGroups {
id
name
}
}
}
}

Short Summary

Get concise metrics for profile display and quick overview.

query ShortSummary {
shortSummary {
workoutCount
totalVolume
consistencyStreakWeeks
strengthImprovement
}
}

Program Analytics

Program Analytics Summary

Get analytics for a specific program with comparison to previous program.

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
}
}
}

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.

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
}
}
}

Exercise Cross-Program Timeline

Track exercise progression across all programs with program boundaries.

query ExerciseCrossProgramTimeline($exerciseId: ID!) {
exerciseCrossProgramTimeline(exerciseId: $exerciseId) {
date
programUserInstanceId
programName
exerciseId
exerciseName
bestSetOfDay {
weight
reps
setId
}
volume
isProgramBoundary
}
}

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);
}
}
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