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
- Create Workout
- Update Workout
- Workout Feedback
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
}
input UpdateWorkoutHistoryInput {
startTime: DateTime
endTime: DateTime
weekNumber: Int
tags: [String!]
statSupression: Boolean
completedTimeline: [CompletedTimelineItemInput!]
feedback: WorkoutFeedbackInput
workoutPostStretching: PostWorkoutStretchingInput
}
input WorkoutFeedbackInput {
overallRating: Int! # 1-10 scale
difficultyRating: Int! # 1-10 scale
energyLevelBefore: Int! # 1-10 scale
energyLevelAfter: Int! # 1-10 scale
muscleGroupsFatigued: [String!]
notes: String
recommendToOthers: Boolean
}
input PostWorkoutStretchingInput {
performed: Boolean!
durationMinutes: Int
focusAreas: [String!]
notes: String
}
Available Operations
Queries
- Single Workout
- User Workout History
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
query workoutHistoryForUser(
$take: Int = 20
$skip: Int = 0
$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
}
completedExercises {
order
exercise {
id
name
}
totalVolume
painReported
sets {
setNumber
weight
reps
rpe
skipped
}
}
feedback {
overallRating
difficultyRating
}
}
}
Authorization: Authenticated users (own history only) Filtering: Date range, template, tags Pagination: take/skip pagination Ordering: Latest workouts first (by startTime DESC)
Mutations
- Create Workout
- Update Workout
- Delete Workout
- Toggle Stat Suppression
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
mutation updateWorkoutHistory(
$historyId: ID!
$input: UpdateWorkoutHistoryInput!
) {
updateWorkoutHistory(historyId: $historyId, input: $input) {
id
startTime
endTime
durationMinutes
completionPercentage
totalVolume
performedExerciseCount
statSupression
completedExercises {
order
exercise { id name }
totalVolume
sets {
setNumber
weight
reps
rpe
skipped
}
}
}
}
Authorization: Workout entry owner only Partial Updates: All fields optional, metrics recalculated automatically Timeline Replacement: If completedTimeline provided, completely replaces existing data
mutation deleteWorkoutHistory($id: ID!) {
deleteWorkoutHistory(id: $id) {
id
deleted
updatedAt
}
}
Authorization: Workout entry owner only Soft Delete: Sets deleted=true, preserves data for recovery Analytics Impact: Excluded from all analytics and progression calculations
mutation toggleWorkoutHistoryStatSuppression(
$history_id: ID!
$suppress: Boolean!
) {
toggleWorkoutHistoryStatSuppression(
history_id: $history_id
suppress: $suppress
) {
id
statSupression
updatedAt
}
}
Purpose: Include/exclude specific workouts from analytics (e.g., sick days, off days) Impact: Affects progression calculations and analytics aggregations
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
- WorkoutHistoryEntry
- CompletedExercise
- Input 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();
}
class CompletedExercise {
final int timelineItemOrder;
final int order;
final String? exerciseNotes;
final double totalVolume;
final bool painReported;
final String? painLocation;
final int? painSeverity;
final Exercise exercise;
final List<ExerciseSet> sets;
CompletedExercise({
required this.timelineItemOrder,
required this.order,
this.exerciseNotes,
required this.totalVolume,
required this.painReported,
this.painLocation,
this.painSeverity,
required this.exercise,
required this.sets,
});
factory CompletedExercise.fromJson(Map<String, dynamic> json) {
return CompletedExercise(
timelineItemOrder: json['timelineItemOrder'],
order: json['order'],
exerciseNotes: json['exerciseNotes'],
totalVolume: json['totalVolume'].toDouble(),
painReported: json['painReported'],
painLocation: json['painLocation'],
painSeverity: json['painSeverity'],
exercise: Exercise.fromJson(json['exercise']),
sets: (json['sets'] as List<dynamic>)
.map((set) => ExerciseSet.fromJson(set))
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'timelineItemOrder': timelineItemOrder,
'order': order,
'exerciseNotes': exerciseNotes,
'totalVolume': totalVolume,
'painReported': painReported,
'painLocation': painLocation,
'painSeverity': painSeverity,
'exercise': exercise.toJson(),
'sets': sets.map((set) => set.toJson()).toList(),
};
}
// Helper methods
List<ExerciseSet> get workingSets => sets.where((set) => !set.skipped).toList();
double get averageRPE {
final validSets = workingSets;
if (validSets.isEmpty) return 0.0;
return validSets.map((set) => set.rpe).reduce((a, b) => a + b) / validSets.length;
}
int get totalReps => workingSets.map((set) => set.reps).reduce((a, b) => a + b);
double get maxWeight => workingSets.map((set) => set.weight).reduce((a, b) => a > b ? a : b);
}
class ExerciseSet {
final int setNumber;
final double weight;
final int reps;
final int rpe;
final int restSeconds;
final bool skipped;
final String? notes;
final PainType? painType;
final String? painLocation;
final int? painSeverity;
final SetType setType;
ExerciseSet({
required this.setNumber,
required this.weight,
required this.reps,
required this.rpe,
required this.restSeconds,
required this.skipped,
this.notes,
this.painType,
this.painLocation,
this.painSeverity,
required this.setType,
});
factory ExerciseSet.fromJson(Map<String, dynamic> json) {
return ExerciseSet(
setNumber: json['setNumber'],
weight: json['weight'].toDouble(),
reps: json['reps'],
rpe: json['rpe'],
restSeconds: json['restSeconds'],
skipped: json['skipped'],
notes: json['notes'],
painType: json['painType'] != null ? PainType.values.byName(json['painType']) : null,
painLocation: json['painLocation'],
painSeverity: json['painSeverity'],
setType: SetType.values.byName(json['setType']),
);
}
Map<String, dynamic> toJson() {
return {
'setNumber': setNumber,
'weight': weight,
'reps': reps,
'rpe': rpe,
'restSeconds': restSeconds,
'skipped': skipped,
'notes': notes,
'painType': painType?.name,
'painLocation': painLocation,
'painSeverity': painSeverity,
'setType': setType.name,
};
}
// Helper methods
double get volume => skipped ? 0.0 : weight * reps;
bool get hasPain => painType != null;
Duration get restDuration => Duration(seconds: restSeconds);
}
enum PainType {
SHARP,
DULL,
ACHING,
BURNING,
STIFFNESS,
SORENESS
}
enum SetType {
WARMUP,
WORKING,
DROP,
FAILURE
}
class CreateWorkoutHistoryInput {
final String programWorkoutTemplateId;
final DateTime startTime;
final DateTime? endTime;
final String? programUserInstanceId;
final int? weekNumber;
final List<String>? tags;
final bool? statSupression;
final List<CompletedTimelineItemInput> completedTimeline;
final WorkoutFeedbackInput? feedback;
final PostWorkoutStretchingInput? workoutPostStretching;
CreateWorkoutHistoryInput({
required this.programWorkoutTemplateId,
required this.startTime,
this.endTime,
this.programUserInstanceId,
this.weekNumber,
this.tags,
this.statSupression,
required this.completedTimeline,
this.feedback,
this.workoutPostStretching,
});
Map<String, dynamic> toJson() {
return {
'programWorkoutTemplateId': programWorkoutTemplateId,
'startTime': startTime.toIso8601String(),
'endTime': endTime?.toIso8601String(),
'programUserInstanceId': programUserInstanceId,
'weekNumber': weekNumber,
'tags': tags,
'statSupression': statSupression,
'completedTimeline': completedTimeline.map((item) => item.toJson()).toList(),
'feedback': feedback?.toJson(),
'workoutPostStretching': workoutPostStretching?.toJson(),
};
}
}
class CompletedTimelineItemInput {
final int timelineItemOrder;
final List<CompletedExerciseInput> exercises;
final String? groupNotes;
final int? actualRestAfterGroupSeconds;
final int? groupFatigueRating;
CompletedTimelineItemInput({
required this.timelineItemOrder,
required this.exercises,
this.groupNotes,
this.actualRestAfterGroupSeconds,
this.groupFatigueRating,
});
Map<String, dynamic> toJson() {
return {
'timelineItemOrder': timelineItemOrder,
'exercises': exercises.map((ex) => ex.toJson()).toList(),
'groupNotes': groupNotes,
'actualRestAfterGroupSeconds': actualRestAfterGroupSeconds,
'groupFatigueRating': groupFatigueRating,
};
}
}
class CompletedExerciseInput {
final String exerciseId;
final String? exerciseNotes;
final List<CompletedSetInput> sets;
CompletedExerciseInput({
required this.exerciseId,
this.exerciseNotes,
required this.sets,
});
Map<String, dynamic> toJson() {
return {
'exerciseId': exerciseId,
'exerciseNotes': exerciseNotes,
'sets': sets.map((set) => set.toJson()).toList(),
};
}
}
class CompletedSetInput {
final int setNumber;
final double weight;
final int reps;
final int rpe;
final int restSeconds;
final String? tempo;
final bool skipped;
final String? notes;
final PainType? painType; // Note: Dart uses camelCase
final String? painLocation; // Note: Dart uses camelCase
final int? painSeverity; // Note: Dart uses camelCase
CompletedSetInput({
required this.setNumber,
required this.weight,
required this.reps,
required this.rpe,
required this.restSeconds,
this.tempo,
this.skipped = false,
this.notes,
this.painType,
this.painLocation,
this.painSeverity,
});
Map<String, dynamic> toJson() {
return {
'setNumber': setNumber,
'weight': weight,
'reps': reps,
'rpe': rpe,
'restSeconds': restSeconds,
'tempo': tempo,
'skipped': skipped,
'notes': notes,
'pain_type': painType?.name, // Note: GraphQL uses snake_case
'pain_location': painLocation, // Note: GraphQL uses snake_case
'pain_severity': painSeverity, // Note: GraphQL uses snake_case
};
}
}
class WorkoutFeedbackInput {
final int overallRating;
final int difficultyRating;
final int energyLevelBefore;
final int energyLevelAfter;
final List<String>? muscleGroupsFatigued;
final String? notes;
final bool? recommendToOthers;
WorkoutFeedbackInput({
required this.overallRating,
required this.difficultyRating,
required this.energyLevelBefore,
required this.energyLevelAfter,
this.muscleGroupsFatigued,
this.notes,
this.recommendToOthers,
});
Map<String, dynamic> toJson() {
return {
'overallRating': overallRating,
'difficultyRating': difficultyRating,
'energyLevelBefore': energyLevelBefore,
'energyLevelAfter': energyLevelAfter,
'muscleGroupsFatigued': muscleGroupsFatigued,
'notes': notes,
'recommendToOthers': recommendToOthers,
};
}
}
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:
- Check the GraphQL schema introspection for latest field definitions
- Review the timeline processing architecture for complex workout structures
- Validate timeline items and exercise data client-side before mutations
- Contact the backend team for pain data aggregation questions