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

WorkoutHistory GraphQL API

The WorkoutHistory API provides comprehensive workout logging and management through GraphQL. This system enables users to log completed workouts, track performance over time, and manage their workout history with detailed analytics and feedback collection.

Core Capabilities

  • Workout Logging: Detailed logging of completed workouts with timeline structure
  • Performance Tracking: Volume, RPE, pain, and subjective feedback tracking
  • Timeline Context: Maintains relationship between planned structure and actual performance
  • Analytics Integration: Comprehensive metrics calculation and stat suppression
  • Pain & Recovery Tracking: Detailed pain reporting and post-workout stretching logs

GraphQL Schema

Core Types

WorkoutHistoryEntry

type WorkoutHistoryEntry {
id: ID!
startTime: DateTime!
endTime: DateTime
durationMinutes: Int!
weekNumber: Int
completionPercentage: Int!
totalVolume: Float
performedExerciseCount: Int
totalRepsPerformed: Int
averageRepsPerSet: Float
averageWeightPerRep: Float
workoutDensity: Float
statSupression: Boolean!
deleted: Boolean!
tags: [String!]!
user: User!
workoutTemplate: ProgramWorkoutTemplate!
timelineContext: [TimelineContext!]!
completedExercises: [CompletedExercise!]!
feedback: WorkoutFeedback
postStretching: PostWorkoutStretching
}

CompletedExercise

type CompletedExercise {
timelineItemOrder: Int!
order: Int!
exerciseNotes: String
totalVolume: Float!
painReported: Boolean!
painLocation: String
painSeverity: Int
exercise: Exercise!
sets: [ExerciseSet!]!
}

ExerciseSet

type ExerciseSet {
setNumber: Int!
weight: Float!
reps: Int!
rpe: Int!
restSeconds: Int!
skipped: Boolean!
notes: String
painType: PainType
painLocation: String
painSeverity: Int
setType: SetType!
}

enum PainType {
SHARP
DULL
ACHING
BURNING
STIFFNESS
SORENESS
}

enum SetType {
WARMUP
WORKING
DROP
FAILURE
}

Timeline Context

type TimelineContext {
timelineItemOrder: Int!
type: String! # "STANDALONE" | "GROUP"
technique: SetTechnique
actualRestAfterGroupSeconds: Int
groupFatigueRating: Int
notes: String
}

Input Types

input CreateWorkoutHistoryInput {
programWorkoutTemplateId: ID!
startTime: DateTime!
endTime: DateTime
programUserInstanceId: ID
weekNumber: Int
tags: [String!]
statSupression: Boolean
completedTimeline: [CompletedTimelineItemInput!]!
feedback: WorkoutFeedbackInput
workoutPostStretching: PostWorkoutStretchingInput
}

input CompletedTimelineItemInput {
timelineItemOrder: Int!
exercises: [CompletedExerciseInput!]!
groupNotes: String
actualRestAfterGroupSeconds: Int
groupFatigueRating: Int
}

input CompletedExerciseInput {
exerciseId: ID!
exerciseNotes: String
sets: [CompletedSetInput!]!
}

input CompletedSetInput {
setNumber: Int!
weight: Float!
reps: Int!
rpe: Int!
restSeconds: Int!
tempo: String
skipped: Boolean
notes: String
pain_type: PainType # Note: snake_case for pain fields
pain_location: String # Note: snake_case for pain fields
pain_severity: Int # Note: snake_case for pain fields
}

Available Operations

Queries

query workoutHistoryEntry($id: ID!) {
workoutHistoryEntry(id: $id) {
id
startTime
endTime
durationMinutes
completionPercentage
totalVolume
performedExerciseCount
totalRepsPerformed
averageRepsPerSet
averageWeightPerRep
workoutDensity
statSupression
tags
weekNumber
user {
id
name
}
workoutTemplate {
id
name
level
trainingFocus {
name
}
}
timelineContext {
timelineItemOrder
type
technique
actualRestAfterGroupSeconds
groupFatigueRating
notes
}
completedExercises {
timelineItemOrder
order
exerciseNotes
totalVolume
painReported
painLocation
painSeverity
exercise {
id
name
description
muscleGroups {
id
name
}
}
sets {
setNumber
weight
reps
rpe
restSeconds
skipped
notes
painType
painLocation
painSeverity
setType
}
}
feedback {
overallRating
difficultyRating
energyLevelBefore
energyLevelAfter
muscleGroupsFatigued
notes
recommendToOthers
}
postStretching {
performed
durationMinutes
focusAreas
notes
}
}
}

Authorization: Workout entry owner only Returns: Complete workout details or null if unauthorized

Mutations

mutation createWorkoutHistory($input: CreateWorkoutHistoryInput!) {
createWorkoutHistory(input: $input) {
id
startTime
endTime
durationMinutes
completionPercentage
totalVolume
performedExerciseCount
totalRepsPerformed
averageRepsPerSet
averageWeightPerRep
workoutDensity
workoutTemplate {
id
name
}
timelineContext {
timelineItemOrder
type
technique
actualRestAfterGroupSeconds
groupFatigueRating
}
completedExercises {
timelineItemOrder
order
exercise {
id
name
}
totalVolume
painReported
sets {
setNumber
weight
reps
rpe
restSeconds
skipped
painType
painLocation
painSeverity
setType
}
}
}
}

Core Processing:

  • Timeline structure validation and transformation
  • Exercise performance metrics calculation
  • Pain and feedback aggregation
  • Template completion percentage calculation

Validations:

  • Template ID must exist and be accessible
  • Exercise IDs must exist in database
  • Timeline order must be sequential
  • Set numbers must be sequential per exercise

Flutter Implementation

1. Service Class

import 'package:graphql_flutter/graphql_flutter.dart';

class WorkoutHistoryService {
final GraphQLClient _client;

WorkoutHistoryService(this._client);

// Create new workout history entry
Future<WorkoutHistoryEntry> createWorkoutHistory(CreateWorkoutHistoryInput input) async {
const String mutation = '''
mutation CreateWorkoutHistory(\$input: CreateWorkoutHistoryInput!) {
createWorkoutHistory(input: \$input) {
id
startTime
endTime
durationMinutes
completionPercentage
totalVolume
performedExerciseCount
totalRepsPerformed
averageRepsPerSet
averageWeightPerRep
workoutDensity
workoutTemplate {
id
name
level
}
timelineContext {
timelineItemOrder
type
technique
actualRestAfterGroupSeconds
groupFatigueRating
notes
}
completedExercises {
timelineItemOrder
order
exerciseNotes
totalVolume
painReported
painLocation
painSeverity
exercise {
id
name
muscleGroups {
id
name
}
}
sets {
setNumber
weight
reps
rpe
restSeconds
skipped
notes
painType
painLocation
painSeverity
setType
}
}
feedback {
overallRating
difficultyRating
energyLevelBefore
energyLevelAfter
notes
}
}
}
''';

final MutationOptions options = MutationOptions(
document: gql(mutation),
variables: {'input': input.toJson()},
);

final QueryResult result = await _client.mutate(options);

if (result.hasException) {
throw result.exception!;
}

return WorkoutHistoryEntry.fromJson(result.data!['createWorkoutHistory']);
}

// Get user's workout history with filtering
Future<List<WorkoutHistoryEntry>> getWorkoutHistory({
int take = 20,
int skip = 0,
DateTime? startDate,
DateTime? endDate,
String? programWorkoutTemplateId,
List<String>? tags,
}) async {
const String query = '''
query WorkoutHistoryForUser(
\$take: Int
\$skip: Int
\$startDate: DateTime
\$endDate: DateTime
\$programWorkoutTemplateId: ID
\$tags: [String!]
) {
workoutHistoryForUser(
take: \$take
skip: \$skip
startDate: \$startDate
endDate: \$endDate
programWorkoutTemplateId: \$programWorkoutTemplateId
tags: \$tags
) {
id
startTime
endTime
durationMinutes
completionPercentage
totalVolume
performedExerciseCount
statSupression
tags
workoutTemplate {
id
name
level
trainingFocus {
name
}
}
completedExercises {
order
exercise {
id
name
}
totalVolume
painReported
sets {
setNumber
weight
reps
rpe
skipped
setType
}
}
feedback {
overallRating
difficultyRating
}
}
}
''';

final QueryOptions options = QueryOptions(
document: gql(query),
variables: {
'take': take,
'skip': skip,
'startDate': startDate?.toIso8601String(),
'endDate': endDate?.toIso8601String(),
'programWorkoutTemplateId': programWorkoutTemplateId,
'tags': tags,
}..removeWhere((key, value) => value == null),
);

final QueryResult result = await _client.query(options);

if (result.hasException) {
throw result.exception!;
}

final historyData = result.data?['workoutHistoryForUser'] as List<dynamic>?;
return historyData?.map((h) => WorkoutHistoryEntry.fromJson(h)).toList() ?? [];
}

// Get single workout entry
Future<WorkoutHistoryEntry?> getWorkoutHistoryEntry(String id) async {
const String query = '''
query WorkoutHistoryEntry(\$id: ID!) {
workoutHistoryEntry(id: \$id) {
id
startTime
endTime
durationMinutes
completionPercentage
totalVolume
performedExerciseCount
totalRepsPerformed
averageRepsPerSet
averageWeightPerRep
workoutDensity
statSupression
tags
weekNumber
workoutTemplate {
id
name
level
description
trainingFocus {
id
name
description
}
}
timelineContext {
timelineItemOrder
type
technique
actualRestAfterGroupSeconds
groupFatigueRating
notes
}
completedExercises {
timelineItemOrder
order
exerciseNotes
totalVolume
painReported
painLocation
painSeverity
exercise {
id
name
description
muscleGroups {
id
name
}
}
sets {
setNumber
weight
reps
rpe
restSeconds
skipped
notes
painType
painLocation
painSeverity
setType
}
}
feedback {
overallRating
difficultyRating
energyLevelBefore
energyLevelAfter
muscleGroupsFatigued
notes
recommendToOthers
}
postStretching {
performed
durationMinutes
focusAreas
notes
}
}
}
''';

final QueryOptions options = QueryOptions(
document: gql(query),
variables: {'id': id},
);

final QueryResult result = await _client.query(options);

if (result.hasException) {
throw result.exception!;
}

final entryData = result.data?['workoutHistoryEntry'];
return entryData != null ? WorkoutHistoryEntry.fromJson(entryData) : null;
}

// Update existing workout
Future<WorkoutHistoryEntry> updateWorkoutHistory(
String historyId,
UpdateWorkoutHistoryInput input
) async {
const String mutation = '''
mutation UpdateWorkoutHistory(\$historyId: ID!, \$input: UpdateWorkoutHistoryInput!) {
updateWorkoutHistory(historyId: \$historyId, input: \$input) {
id
startTime
endTime
durationMinutes
completionPercentage
totalVolume
statSupression
completedExercises {
order
exercise { id name }
totalVolume
sets {
setNumber
weight
reps
rpe
skipped
}
}
}
}
''';

final MutationOptions options = MutationOptions(
document: gql(mutation),
variables: {
'historyId': historyId,
'input': input.toJson(),
},
);

final QueryResult result = await _client.mutate(options);

if (result.hasException) {
throw result.exception!;
}

return WorkoutHistoryEntry.fromJson(result.data!['updateWorkoutHistory']);
}

// Delete workout
Future<WorkoutHistoryEntry> deleteWorkoutHistory(String id) async {
const String mutation = '''
mutation DeleteWorkoutHistory(\$id: ID!) {
deleteWorkoutHistory(id: \$id) {
id
deleted
updatedAt
}
}
''';

final MutationOptions options = MutationOptions(
document: gql(mutation),
variables: {'id': id},
);

final QueryResult result = await _client.mutate(options);

if (result.hasException) {
throw result.exception!;
}

return WorkoutHistoryEntry.fromJson(result.data!['deleteWorkoutHistory']);
}

// Toggle stat suppression
Future<WorkoutHistoryEntry> toggleStatSuppression(String historyId, bool suppress) async {
const String mutation = '''
mutation ToggleWorkoutHistoryStatSuppression(\$history_id: ID!, \$suppress: Boolean!) {
toggleWorkoutHistoryStatSuppression(history_id: \$history_id, suppress: \$suppress) {
id
statSupression
updatedAt
}
}
''';

final MutationOptions options = MutationOptions(
document: gql(mutation),
variables: {
'history_id': historyId,
'suppress': suppress,
},
);

final QueryResult result = await _client.mutate(options);

if (result.hasException) {
throw result.exception!;
}

return WorkoutHistoryEntry.fromJson(result.data!['toggleWorkoutHistoryStatSuppression']);
}
}

2. Model Classes

class WorkoutHistoryEntry {
final String id;
final DateTime startTime;
final DateTime? endTime;
final int durationMinutes;
final int? weekNumber;
final int completionPercentage;
final double? totalVolume;
final int? performedExerciseCount;
final int? totalRepsPerformed;
final double? averageRepsPerSet;
final double? averageWeightPerRep;
final double? workoutDensity;
final bool statSupression;
final bool deleted;
final List<String> tags;
final User? user;
final ProgramWorkoutTemplate workoutTemplate;
final List<TimelineContext> timelineContext;
final List<CompletedExercise> completedExercises;
final WorkoutFeedback? feedback;
final PostWorkoutStretching? postStretching;

WorkoutHistoryEntry({
required this.id,
required this.startTime,
this.endTime,
required this.durationMinutes,
this.weekNumber,
required this.completionPercentage,
this.totalVolume,
this.performedExerciseCount,
this.totalRepsPerformed,
this.averageRepsPerSet,
this.averageWeightPerRep,
this.workoutDensity,
required this.statSupression,
required this.deleted,
required this.tags,
this.user,
required this.workoutTemplate,
required this.timelineContext,
required this.completedExercises,
this.feedback,
this.postStretching,
});

factory WorkoutHistoryEntry.fromJson(Map<String, dynamic> json) {
return WorkoutHistoryEntry(
id: json['id'],
startTime: DateTime.parse(json['startTime']),
endTime: json['endTime'] != null ? DateTime.parse(json['endTime']) : null,
durationMinutes: json['durationMinutes'],
weekNumber: json['weekNumber'],
completionPercentage: json['completionPercentage'],
totalVolume: json['totalVolume']?.toDouble(),
performedExerciseCount: json['performedExerciseCount'],
totalRepsPerformed: json['totalRepsPerformed'],
averageRepsPerSet: json['averageRepsPerSet']?.toDouble(),
averageWeightPerRep: json['averageWeightPerRep']?.toDouble(),
workoutDensity: json['workoutDensity']?.toDouble(),
statSupression: json['statSupression'] ?? false,
deleted: json['deleted'] ?? false,
tags: List<String>.from(json['tags'] ?? []),
user: json['user'] != null ? User.fromJson(json['user']) : null,
workoutTemplate: ProgramWorkoutTemplate.fromJson(json['workoutTemplate']),
timelineContext: (json['timelineContext'] as List<dynamic>?)
?.map((tc) => TimelineContext.fromJson(tc))
.toList() ?? [],
completedExercises: (json['completedExercises'] as List<dynamic>?)
?.map((ce) => CompletedExercise.fromJson(ce))
.toList() ?? [],
feedback: json['feedback'] != null ? WorkoutFeedback.fromJson(json['feedback']) : null,
postStretching: json['postStretching'] != null
? PostWorkoutStretching.fromJson(json['postStretching']) : null,
);
}

Map<String, dynamic> toJson() {
return {
'id': id,
'startTime': startTime.toIso8601String(),
'endTime': endTime?.toIso8601String(),
'durationMinutes': durationMinutes,
'weekNumber': weekNumber,
'completionPercentage': completionPercentage,
'totalVolume': totalVolume,
'performedExerciseCount': performedExerciseCount,
'totalRepsPerformed': totalRepsPerformed,
'averageRepsPerSet': averageRepsPerSet,
'averageWeightPerRep': averageWeightPerRep,
'workoutDensity': workoutDensity,
'statSupression': statSupression,
'deleted': deleted,
'tags': tags,
'user': user?.toJson(),
'workoutTemplate': workoutTemplate.toJson(),
'timelineContext': timelineContext.map((tc) => tc.toJson()).toList(),
'completedExercises': completedExercises.map((ce) => ce.toJson()).toList(),
'feedback': feedback?.toJson(),
'postStretching': postStretching?.toJson(),
};
}

// Helper methods
Duration get workoutDuration =>
endTime != null ? endTime!.difference(startTime) : Duration(minutes: durationMinutes);

bool get hasPainReported => completedExercises.any((ex) => ex.painReported);

double get averageRPE {
final allSets = completedExercises.expand((ex) => ex.sets).where((set) => !set.skipped);
if (allSets.isEmpty) return 0.0;
return allSets.map((set) => set.rpe).reduce((a, b) => a + b) / allSets.length;
}

List<Exercise> get exercisesPerformed =>
completedExercises.map((ce) => ce.exercise).toList();
}

Business Logic

Timeline Processing Architecture

The WorkoutHistory system maintains a sophisticated dual-array structure that preserves both the planned workout structure and the actual performance:

1. The "Plan": timelineContext

Describes the structural intent of the workout:

class TimelineContext {
final int timelineItemOrder; // Original block order (0, 1, 2...)
final String type; // "STANDALONE" | "GROUP"
final SetTechnique? technique; // SUPERSET, TRI_SET, etc.
final int? actualRestAfterGroupSeconds; // Actual rest taken
final int? groupFatigueRating; // Subjective difficulty (1-10)
final String? notes; // User notes for this block

TimelineContext({
required this.timelineItemOrder,
required this.type,
this.technique,
this.actualRestAfterGroupSeconds,
this.groupFatigueRating,
this.notes,
});

factory TimelineContext.fromJson(Map<String, dynamic> json) {
return TimelineContext(
timelineItemOrder: json['timelineItemOrder'],
type: json['type'],
technique: json['technique'] != null
? SetTechnique.values.byName(json['technique']) : null,
actualRestAfterGroupSeconds: json['actualRestAfterGroupSeconds'],
groupFatigueRating: json['groupFatigueRating'],
notes: json['notes'],
);
}
}

2. The "Performance": completedExercises

Flat log of actual exercise performance linked back to timeline context via timelineItemOrder.

Metrics Calculation

class WorkoutMetrics {
static double calculateExerciseVolume(List<ExerciseSet> sets) {
return sets
.where((set) => !set.skipped)
.map((set) => set.weight * set.reps)
.fold(0.0, (total, volume) => total + volume);
}

static double calculateWorkoutVolume(List<CompletedExercise> exercises) {
return exercises
.map((ex) => ex.totalVolume)
.fold(0.0, (total, volume) => total + volume);
}

static double calculateWorkoutDensity(double totalVolume, int durationMinutes) {
return durationMinutes > 0 ? totalVolume / durationMinutes : 0.0;
}

static int calculateCompletionPercentage(
int performedExercises,
int plannedExercises
) {
return plannedExercises > 0
? ((performedExercises / plannedExercises) * 100).round()
: 0;
}

static double calculateAverageRPE(List<CompletedExercise> exercises) {
final allSets = exercises
.expand((ex) => ex.sets)
.where((set) => !set.skipped && set.rpe > 0);

if (allSets.isEmpty) return 0.0;

return allSets.map((set) => set.rpe).reduce((a, b) => a + b) / allSets.length;
}
}

Error Handling

Common Error Scenarios

class WorkoutHistoryException implements Exception {
final String message;
final String code;
final String? field;

WorkoutHistoryException(this.message, this.code, [this.field]);

@override
String toString() => message;
}

class WorkoutHistoryNotFoundException extends WorkoutHistoryException {
WorkoutHistoryNotFoundException(String workoutId)
: super('Workout history entry not found: $workoutId', 'NOT_FOUND');
}

class UnauthorizedWorkoutAccessException extends WorkoutHistoryException {
UnauthorizedWorkoutAccessException()
: super('You do not have permission to access this workout history entry', 'FORBIDDEN');
}

class InvalidTimelineException extends WorkoutHistoryException {
InvalidTimelineException(String message)
: super('Invalid timeline structure: $message', 'BAD_USER_INPUT', 'completedTimeline');
}

class InvalidExerciseException extends WorkoutHistoryException {
InvalidExerciseException(String exerciseId)
: super('Exercise not found: $exerciseId', 'BAD_USER_INPUT', 'exerciseId');
}

Error Handling in Service

Future<WorkoutHistoryEntry> createWorkoutHistoryWithErrorHandling(
CreateWorkoutHistoryInput input
) async {
try {
return await _workoutHistoryService.createWorkoutHistory(input);
} on OperationException catch (e) {
if (e.graphqlErrors.isNotEmpty) {
final error = e.graphqlErrors.first;
final code = error.extensions?['code'] as String?;
final field = error.extensions?['field'] as String?;

switch (code) {
case 'NOT_FOUND':
throw WorkoutHistoryNotFoundException('Resource not found');
case 'FORBIDDEN':
throw UnauthorizedWorkoutAccessException();
case 'BAD_USER_INPUT':
if (field == 'completedTimeline') {
throw InvalidTimelineException(error.message);
} else if (field == 'exerciseId') {
throw InvalidExerciseException(error.message);
}
break;
default:
throw WorkoutHistoryException(error.message, code ?? 'UNKNOWN_ERROR');
}
}
throw WorkoutHistoryException('Network error occurred', 'NETWORK_ERROR');
}
}

Best Practices

1. Workout Logging Pattern

class WorkoutLogger {
final WorkoutHistoryService _service;

WorkoutLogger(this._service);

Future<WorkoutHistoryEntry> logWorkout({
required String templateId,
required DateTime startTime,
required List<CompletedTimelineItemInput> timeline,
DateTime? endTime,
WorkoutFeedbackInput? feedback,
}) async {
// Validate timeline before submission
_validateTimeline(timeline);

// Calculate end time if not provided
final actualEndTime = endTime ?? DateTime.now();

final input = CreateWorkoutHistoryInput(
programWorkoutTemplateId: templateId,
startTime: startTime,
endTime: actualEndTime,
completedTimeline: timeline,
feedback: feedback,
);

try {
final workout = await _service.createWorkoutHistory(input);

// Cache workout locally for offline access
await _cacheWorkout(workout);

return workout;
} catch (e) {
// Handle offline scenarios
await _saveWorkoutForLater(input);
rethrow;
}
}

void _validateTimeline(List<CompletedTimelineItemInput> timeline) {
if (timeline.isEmpty) {
throw InvalidTimelineException('Timeline cannot be empty');
}

// Validate sequential ordering
for (int i = 0; i < timeline.length; i++) {
if (timeline[i].timelineItemOrder != i) {
throw InvalidTimelineException('Timeline order must be sequential starting from 0');
}
}

// Validate exercise data
for (final item in timeline) {
for (final exercise in item.exercises) {
if (exercise.sets.isEmpty) {
throw InvalidTimelineException('Exercise must have at least one set');
}

// Validate set numbering
for (int i = 0; i < exercise.sets.length; i++) {
if (exercise.sets[i].setNumber != i + 1) {
throw InvalidTimelineException('Set numbers must be sequential starting from 1');
}
}
}
}
}
}

2. Performance Tracking

class WorkoutAnalytics {
static List<WorkoutHistoryEntry> filterByDateRange(
List<WorkoutHistoryEntry> workouts,
DateTime start,
DateTime end,
) {
return workouts.where((workout) =>
workout.startTime.isAfter(start) && workout.startTime.isBefore(end)
).toList();
}

static Map<String, double> calculateWeeklyVolume(List<WorkoutHistoryEntry> workouts) {
final weeklyVolume = <String, double>{};

for (final workout in workouts) {
if (workout.statSupression || workout.totalVolume == null) continue;

final weekKey = _getWeekKey(workout.startTime);
weeklyVolume[weekKey] = (weeklyVolume[weekKey] ?? 0) + workout.totalVolume!;
}

return weeklyVolume;
}

static List<ExerciseProgressData> analyzeExerciseProgress(
List<WorkoutHistoryEntry> workouts,
String exerciseId,
) {
final progressData = <ExerciseProgressData>[];

for (final workout in workouts) {
final exercise = workout.completedExercises
.where((ex) => ex.exercise.id == exerciseId)
.firstOrNull;

if (exercise != null) {
progressData.add(ExerciseProgressData(
date: workout.startTime,
maxWeight: exercise.maxWeight,
totalVolume: exercise.totalVolume,
averageRPE: exercise.averageRPE,
));
}
}

return progressData..sort((a, b) => a.date.compareTo(b.date));
}

static String _getWeekKey(DateTime date) {
final startOfWeek = date.subtract(Duration(days: date.weekday - 1));
return '${startOfWeek.year}-W${startOfWeek.month.toString().padLeft(2, '0')}-${startOfWeek.day.toString().padLeft(2, '0')}';
}
}

class ExerciseProgressData {
final DateTime date;
final double maxWeight;
final double totalVolume;
final double averageRPE;

ExerciseProgressData({
required this.date,
required this.maxWeight,
required this.totalVolume,
required this.averageRPE,
});
}

3. Testing

void main() {
group('WorkoutHistoryService', () {
late MockGraphQLClient mockClient;
late WorkoutHistoryService service;

setUp(() {
mockClient = MockGraphQLClient();
service = WorkoutHistoryService(mockClient);
});

test('should create workout history with complete timeline', () async {
// Arrange
final input = CreateWorkoutHistoryInput(
programWorkoutTemplateId: 'template123',
startTime: DateTime.now().subtract(Duration(hours: 1)),
endTime: DateTime.now(),
completedTimeline: [
CompletedTimelineItemInput(
timelineItemOrder: 0,
exercises: [
CompletedExerciseInput(
exerciseId: 'exercise123',
sets: [
CompletedSetInput(
setNumber: 1,
weight: 100.0,
reps: 10,
rpe: 8,
restSeconds: 60,
),
],
),
],
),
],
);

when(() => mockClient.mutate(any())).thenAnswer((_) async => QueryResult(
data: {
'createWorkoutHistory': {
'id': 'workout123',
'startTime': input.startTime.toIso8601String(),
// ... mock response data
}
},
source: QueryResultSource.network,
options: MutationOptions(document: gql('')),
));

// Act
final result = await service.createWorkoutHistory(input);

// Assert
expect(result.id, equals('workout123'));
expect(result.completedExercises.length, equals(1));
});

test('should handle timeline validation errors', () async {
// Arrange
final input = CreateWorkoutHistoryInput(
programWorkoutTemplateId: 'template123',
startTime: DateTime.now(),
completedTimeline: [], // Empty timeline should cause error
);

when(() => mockClient.mutate(any())).thenAnswer((_) async => QueryResult(
data: null,
source: QueryResultSource.network,
options: MutationOptions(document: gql('')),
exception: OperationException(
graphqlErrors: [
GraphQLError(
message: 'Timeline cannot be empty',
extensions: {'code': 'BAD_USER_INPUT', 'field': 'completedTimeline'},
),
],
),
));

// Act & Assert
expect(
() => service.createWorkoutHistory(input),
throwsA(isA<OperationException>()),
);
});
});
}

Migration Notes

  • All workout IDs are ObjectId strings
  • Timeline uses dual-array structure (context + exercises)
  • Pain data uses snake_case in GraphQL, camelCase in Dart
  • Stat suppression affects analytics but preserves data
  • Soft delete preserves workout data for recovery
  • Metrics are calculated server-side and cached

Support

For questions about this API:

  1. Check the GraphQL schema introspection for latest field definitions
  2. Review the timeline processing architecture for complex workout structures
  3. Validate timeline items and exercise data client-side before mutations
  4. Contact the backend team for pain data aggregation questions