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

Progression Playbooks API

The Progression Playbooks API provides a sophisticated rule-based progression system that enables users to create custom workout progression logic. This system evaluates workout performance against user-defined rules and provides intelligent recommendations for weight, reps, and complete workout structures.

⚠️ CRITICAL: THIN CLIENT IMPLEMENTATION ONLY

What Flutter SHOULD do:

  • ✅ Display progression rule builders and playbook management UI
  • ✅ Show simulation results and workout recommendations
  • ✅ Handle playbook CRUD operations through GraphQL
  • ✅ Present rule templates and condition/action options
  • ✅ Cache playbook data and recommendations for offline use

What Flutter MUST NOT do:

  • ❌ Implement progression rule evaluation logic
  • ❌ Calculate workout recommendations or weight progressions
  • ❌ Process condition matching or action execution
  • ❌ Generate warmup set progressions or RPE calculations
  • ❌ Determine training focus parameters or rep ranges

Core Concepts

Progression Playbooks

A Progression Playbook is a collection of conditional rules that automatically determine workout progressions. Each playbook contains:

  • Name & Description: Playbook identification and purpose
  • Visibility: Public (shareable) or Private (personal use)
  • Rules: Ordered list of condition-action pairs
  • Soft Delete: Keeps playbooks active in existing programs while hiding from new selections

Progression Rules

Progression Rules define the core logic with:

  • Conditions: IF statements that evaluate workout performance (e.g., "All sets completed", "RPE < 8")
  • Actions: THEN statements that specify progressions (e.g., "Increase weight by 5 lbs", "Reset reps to base")
  • Order: Rules are evaluated in sequence until a match is found
  • User Message: Custom feedback shown when the rule triggers

Condition Types

The system supports various condition categories:

Completion Conditions:

  • ALL_SETS_COMPLETED: All workout sets successfully completed
  • ANY_SET_FAILED: At least one set was not completed

Streak Conditions:

  • CONSECUTIVE_SUCCESSES: Number of successful workouts in a row
  • CONSECUTIVE_FAILURES: Number of failed workouts in a row

Intensity Conditions:

  • LAST_SET_RPE: Rate of Perceived Exertion of final set
  • LAST_SET_AMRAP_REPS: Reps achieved in last AMRAP (As Many Reps As Possible) set

Action Types

Actions specify how to modify the next workout:

Weight Progression:

  • INCREASE_WEIGHT_ABSOLUTE: Add specific weight amount
  • DECREASE_WEIGHT_PERCENT: Reduce weight by percentage (deloads)

Rep Progression:

  • INCREASE_REPS_ABSOLUTE: Add specific number of reps
  • RESET_REPS_TO_BASE: Return to original starting reps

Maintenance:

  • MAINTAIN_ALL: Keep all parameters unchanged

Enhanced Workout Recommendations

Beyond basic weight/rep suggestions, the system generates complete workout structures:

  • Warmup Sets: Progressive loading with activation, preparation, and opener sets
  • Working Sets: Target weight, reps, RPE, and rest periods
  • Training Focus: Optimal parameters based on user's program goals
  • Time Estimates: Total workout duration calculations
  • Alternative Options: Deload, intensity boost, and modification suggestions

GraphQL Schema

Types

type ProgressionPlaybook {
id: ID!
name: String!
description: String
visibility: PlaybookVisibility!
createdAt: DateTime!
updatedAt: DateTime!

# Soft delete support
isDeleted: Boolean!
deletedAt: DateTime

# Relations
createdBy: User!
deletedBy: User

# Rule structure
rules: [ProgressionRule!]!
}

type ProgressionRule {
ruleId: String!
order: Int!
userMessage: String
conditions: [ProgressionCondition!]!
actions: [ProgressionAction!]!
}

type ProgressionCondition {
type: String!
operator: String
value: Float
count: Int
}

type ProgressionAction {
type: String!
value: Float
}

type WorkoutRecommendation {
exerciseId: ID!
exerciseName: String
trainingFocus: TrainingFocusInfo!
warmupSets: [WarmupSetRecommendation!]!
workingSets: [WorkingSetRecommendation!]!
totalEstimatedTime: Int!
progressionMessage: String!
alternativeOptions: JSON
}

type WarmupSetRecommendation {
setNumber: Int!
weight: Float!
reps: Int!
rpe: Float!
restSeconds: Int!
purpose: String!
notes: String
}

type WorkingSetRecommendation {
setNumber: Int!
weight: Float!
reps: Int!
rpe: Float!
restSeconds: Int!
isAmrap: Boolean
notes: String
}

type TrainingFocusInfo {
name: String!
targetRpe: Float!
restSeconds: Int!
repRange: JSON!
}

type SimulationResult {
triggeredRule: TriggeredRule
suggestedWeight: Float!
suggestedReps: Int!
message: String!
warnings: [String!]!
errors: [String!]!
}

type EnhancedSimulationResult {
triggeredRule: TriggeredRule
suggestedWeight: Float!
suggestedReps: Int!
message: String!
warnings: [String!]!
errors: [String!]!
workoutRecommendation: WorkoutRecommendation
}

type HistoricalSimulationResult {
triggeredRule: TriggeredRule
suggestedWeight: Float!
suggestedReps: Int!
message: String!
warnings: [String!]!
errors: [String!]!
originalWorkout: WorkoutHistoryDetails!
consecutiveSuccessesAtTime: Int!
consecutiveFailuresAtTime: Int!
actualNextWorkout: WorkoutHistoryDetails
wouldHaveMatched: Boolean
executionDetails: String
}

type ConditionType {
type: String!
displayName: String!
description: String!
category: String!
parameters: [ParameterDefinition!]!
}

type ActionType {
type: String!
displayName: String!
description: String!
category: String!
parameters: [ParameterDefinition!]!
}

type ParameterDefinition {
name: String!
type: String!
required: Boolean!
description: String!
defaultValue: JSONValue
}

enum PlaybookVisibility {
PUBLIC
PRIVATE
}

Input Types

input CreateProgressionPlaybookInput {
name: String!
description: String
visibility: PlaybookVisibility
rules: [ProgressionRuleInput!]!
}

input UpdateProgressionPlaybookInput {
name: String
description: String
}

input ProgressionRuleInput {
order: Int!
userMessage: String
conditions: [ProgressionConditionInput!]!
actions: [ProgressionActionInput!]!
}

input ProgressionConditionInput {
type: String!
operator: String
value: Float
count: Int
}

input ProgressionActionInput {
type: String!
value: Float
}

input TestWorkoutInput {
sets: [TestWorkoutSetInput!]!
targetReps: Int!
consecutiveSuccesses: Int
consecutiveFailures: Int
}

input TestWorkoutSetInput {
weight: Float!
reps: Int!
rpe: Float
skipped: Boolean
}

input SimulatePlaybookWithHistoryInput {
playbookId: ID!
workoutHistoryEntryId: ID!
exerciseId: ID!
targetRepsOverride: Int
}

type DeleteProgressionPlaybookResponse {
success: Boolean!
message: String!
}

Queries

Get All Available Playbooks

Retrieve paginated list of public playbooks and user's private playbooks.

query ProgressionPlaybooks($first: Int, $after: String) {
progressionPlaybooks(first: $first, after: $after) {
edges {
node {
id
name
description
visibility
createdAt
updatedAt
isDeleted

createdBy {
id
name
}

rules {
ruleId
order
userMessage
conditions {
type
operator
value
count
}
actions {
type
value
}
}
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
totalCount
}
}

Get User's Private Playbooks

Retrieve only the user's private playbooks.

query MyProgressionPlaybooks($first: Int, $after: String) {
myProgressionPlaybooks(first: $first, after: $after) {
edges {
node {
id
name
description
createdAt
updatedAt

rules {
ruleId
order
userMessage
conditions {
type
operator
value
count
}
actions {
type
value
}
}
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
totalCount
}
}

Get Single Playbook

Retrieve a specific playbook by ID.

query ProgressionPlaybook($id: ID!) {
progressionPlaybook(id: $id) {
id
name
description
visibility
createdAt
updatedAt
isDeleted
deletedAt

createdBy {
id
name
}

rules {
ruleId
order
userMessage
conditions {
type
operator
value
count
}
actions {
type
value
}
}
}
}

Get Rule Builder Metadata

Retrieve available condition and action types for building rules.

query RuleBuilderMetadata {
conditionTypes {
type
displayName
description
category
parameters {
name
type
required
description
defaultValue
}
}

actionTypes {
type
displayName
description
category
parameters {
name
type
required
description
defaultValue
}
}

ruleTemplates {
ruleId
order
userMessage
conditions {
type
operator
value
count
}
actions {
type
value
}
}
}

Generate Workout Recommendation

Create complete workout structure with warmup and working sets.

query GenerateWorkoutRecommendation(
$exerciseId: ID!
$workingWeight: Float!
$workingReps: Int!
$trainingFocusId: ID!
$progressionMessage: String
) {
generateWorkoutRecommendation(
exerciseId: $exerciseId
workingWeight: $workingWeight
workingReps: $workingReps
trainingFocusId: $trainingFocusId
progressionMessage: $progressionMessage
) {
exerciseId
exerciseName
progressionMessage
totalEstimatedTime

trainingFocus {
name
targetRpe
restSeconds
repRange
}

warmupSets {
setNumber
weight
reps
rpe
restSeconds
purpose
notes
}

workingSets {
setNumber
weight
reps
rpe
restSeconds
isAmrap
notes
}

alternativeOptions
}
}

Enhanced Progression with Program Context

Generate progression recommendation using user's program training focus.

query GenerateEnhancedProgression(
$exerciseId: ID!
$suggestedWeight: Float!
$suggestedReps: Int!
$progressionMessage: String!
$trainingFocusId: ID
) {
generateEnhancedProgression(
exerciseId: $exerciseId
suggestedWeight: $suggestedWeight
suggestedReps: $suggestedReps
progressionMessage: $progressionMessage
trainingFocusId: $trainingFocusId
) {
exerciseId
exerciseName
progressionMessage
totalEstimatedTime

warmupSets {
setNumber
weight
reps
rpe
purpose
notes
}

workingSets {
setNumber
weight
reps
rpe
isAmrap
notes
}
}
}

Mutations

Create Progression Playbook

Create a new custom progression playbook with rules.

mutation CreateProgressionPlaybook($input: CreateProgressionPlaybookInput!) {
createProgressionPlaybook(input: $input) {
id
name
description
visibility
createdAt

rules {
ruleId
order
userMessage
conditions {
type
operator
value
count
}
actions {
type
value
}
}
}
}

Update Progression Playbook

Update playbook name and description.

mutation UpdateProgressionPlaybook($playbookId: ID!, $input: UpdateProgressionPlaybookInput!) {
updateProgressionPlaybook(playbookId: $playbookId, input: $input) {
id
name
description
updatedAt
}
}

Clone Progression Playbook

Create a copy of an existing playbook with a new name.

mutation CloneProgressionPlaybook($input: CloneProgressionPlaybookInput!) {
cloneProgressionPlaybook(input: $input) {
id
name
description
createdAt

rules {
ruleId
order
userMessage
conditions {
type
operator
value
count
}
actions {
type
value
}
}
}
}

Rule Management

Add Rule to Playbook

mutation AddRuleToPlaybook($playbookId: ID!, $rule: ProgressionRuleInput!) {
addRuleToPlaybook(playbookId: $playbookId, rule: $rule) {
id
name
rules {
ruleId
order
userMessage
conditions {
type
operator
value
count
}
actions {
type
value
}
}
}
}

Update Rule in Playbook

mutation UpdateRuleInPlaybook(
$playbookId: ID!
$ruleId: String!
$rule: UpdateProgressionRuleInput!
) {
updateRuleInPlaybook(playbookId: $playbookId, ruleId: $ruleId, rule: $rule) {
id
rules {
ruleId
order
userMessage
conditions {
type
operator
value
}
actions {
type
value
}
}
}
}

Delete Rule from Playbook

mutation DeleteRuleFromPlaybook($playbookId: ID!, $ruleId: String!) {
deleteRuleFromPlaybook(playbookId: $playbookId, ruleId: $ruleId) {
id
rules {
ruleId
order
}
}
}

Reorder Rules in Playbook

mutation ReorderRulesInPlaybook($playbookId: ID!, $input: ReorderRulesInput!) {
reorderRulesInPlaybook(playbookId: $playbookId, input: $input) {
id
rules {
ruleId
order
userMessage
}
}
}

Playbook Lifecycle Management

Soft Delete Playbook

mutation SoftDeleteProgressionPlaybook(
$playbookId: ID!
$input: SoftDeleteProgressionPlaybookInput
) {
softDeleteProgressionPlaybook(playbookId: $playbookId, input: $input) {
success
message
}
}

Restore Playbook

mutation RestoreProgressionPlaybook(
$playbookId: ID!
$input: RestoreProgressionPlaybookInput
) {
restoreProgressionPlaybook(playbookId: $playbookId, input: $input) {
id
name
isDeleted
deletedAt
}
}

Permanent Delete Playbook

mutation DeleteProgressionPlaybook($playbookId: ID!) {
deleteProgressionPlaybook(playbookId: $playbookId) {
success
message
}
}

Simulation and Testing

Simulate Playbook with Test Data

Test how a playbook would respond to a hypothetical workout.

mutation SimulatePlaybook($playbookId: ID!, $testWorkout: TestWorkoutInput!) {
simulatePlaybook(playbookId: $playbookId, testWorkout: $testWorkout) {
triggeredRule {
ruleId
order
userMessage
conditions {
type
operator
value
count
}
actions {
type
value
}
}
suggestedWeight
suggestedReps
message
warnings
errors
}
}

Enhanced Playbook Simulation

Get complete workout recommendation with warmup and working sets.

mutation SimulatePlaybookEnhanced(
$playbookId: ID!
$testWorkout: TestWorkoutInput!
$exerciseId: ID!
$trainingFocusId: ID
) {
simulatePlaybookEnhanced(
playbookId: $playbookId
testWorkout: $testWorkout
exerciseId: $exerciseId
trainingFocusId: $trainingFocusId
) {
triggeredRule {
ruleId
userMessage
}
suggestedWeight
suggestedReps
message
warnings
errors

workoutRecommendation {
exerciseId
exerciseName
progressionMessage
totalEstimatedTime

trainingFocus {
name
targetRpe
restSeconds
repRange
}

warmupSets {
setNumber
weight
reps
rpe
purpose
notes
}

workingSets {
setNumber
weight
reps
rpe
isAmrap
notes
}

alternativeOptions
}
}
}

Historical Simulation

Test playbook against actual workout history data.

mutation SimulatePlaybookWithHistory($input: SimulatePlaybookWithHistoryInput!) {
simulatePlaybookWithHistory(input: $input) {
triggeredRule {
ruleId
userMessage
}
suggestedWeight
suggestedReps
message
warnings
errors

originalWorkout {
entryId
date
exerciseName
targetReps
actualWeight
sets {
weight
reps
rpe
skipped
}
}

consecutiveSuccessesAtTime
consecutiveFailuresAtTime

actualNextWorkout {
entryId
date
actualWeight
sets {
weight
reps
}
}

wouldHaveMatched
executionDetails
}
}

Flutter Integration

State Management Architecture

// Progression playbook state management
class ProgressionPlaybookProvider extends ChangeNotifier {
List<ProgressionPlaybook> _playbooks = [];
List<ConditionType> _conditionTypes = [];
List<ActionType> _actionTypes = [];
List<ProgressionRule> _ruleTemplates = [];

List<ProgressionPlaybook> get playbooks => _playbooks;
List<ConditionType> get conditionTypes => _conditionTypes;
List<ActionType> get actionTypes => _actionTypes;
List<ProgressionRule> get ruleTemplates => _ruleTemplates;

// ✅ CORRECT: Load playbooks from server
Future<void> loadPlaybooks() async {
final result = await _graphqlClient.query(ProgressionPlaybooksQuery());

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

final connection = result.parsedData!.progressionPlaybooks;
_playbooks = connection.edges.map((e) => e.node).toList();
notifyListeners();
}

// ✅ CORRECT: Load rule builder metadata
Future<void> loadRuleBuilderMetadata() async {
final result = await _graphqlClient.query(RuleBuilderMetadataQuery());

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

_conditionTypes = result.parsedData!.conditionTypes;
_actionTypes = result.parsedData!.actionTypes;
_ruleTemplates = result.parsedData!.ruleTemplates;
notifyListeners();
}

// ✅ CORRECT: Create playbook through server
Future<ProgressionPlaybook> createPlaybook(CreateProgressionPlaybookInput input) async {
final result = await _graphqlClient.mutate(CreateProgressionPlaybookMutation(
variables: CreateProgressionPlaybookArguments(input: input),
));

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

final newPlaybook = result.parsedData!.createProgressionPlaybook;
_playbooks.add(newPlaybook);
notifyListeners();

return newPlaybook;
}

// ✅ CORRECT: Simulate playbook with server intelligence
Future<EnhancedSimulationResult> simulatePlaybook(
String playbookId,
TestWorkoutInput testWorkout,
String exerciseId,
) async {
final result = await _graphqlClient.mutate(SimulatePlaybookEnhancedMutation(
variables: SimulatePlaybookEnhancedArguments(
playbookId: playbookId,
testWorkout: testWorkout,
exerciseId: exerciseId,
),
));

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

return result.parsedData!.simulatePlaybookEnhanced;
}
}

Playbook Management UI

class PlaybookManagementScreen extends StatefulWidget {
const PlaybookManagementScreen({super.key});

@override
State<PlaybookManagementScreen> createState() => _PlaybookManagementScreenState();
}

class _PlaybookManagementScreenState extends State<PlaybookManagementScreen> {
@override
void initState() {
super.initState();
_loadData();
}

Future<void> _loadData() async {
final provider = context.read<ProgressionPlaybookProvider>();
await Future.wait([
provider.loadPlaybooks(),
provider.loadRuleBuilderMetadata(),
]);
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Progression Playbooks'),
actions: [
IconButton(
onPressed: _createNewPlaybook,
icon: const Icon(Icons.add),
),
],
),
body: Consumer<ProgressionPlaybookProvider>(
builder: (context, provider, child) {
if (provider.playbooks.isEmpty) {
return const Center(child: CircularProgressIndicator());
}

return ListView.builder(
itemCount: provider.playbooks.length,
itemBuilder: (context, index) {
final playbook = provider.playbooks[index];
return PlaybookCard(
playbook: playbook,
onTap: () => _editPlaybook(playbook),
onClone: () => _clonePlaybook(playbook),
onSimulate: () => _simulatePlaybook(playbook),
onDelete: () => _deletePlaybook(playbook),
);
},
);
},
),
);
}

Future<void> _createNewPlaybook() async {
final result = await Navigator.pushNamed(
context,
'/playbook-builder',
arguments: PlaybookBuilderArguments(
mode: PlaybookBuilderMode.create,
),
);

if (result == true) {
_loadData(); // Refresh the list
}
}

Future<void> _editPlaybook(ProgressionPlaybook playbook) async {
await Navigator.pushNamed(
context,
'/playbook-builder',
arguments: PlaybookBuilderArguments(
mode: PlaybookBuilderMode.edit,
playbook: playbook,
),
);
}

Future<void> _clonePlaybook(ProgressionPlaybook playbook) async {
final provider = context.read<ProgressionPlaybookProvider>();

try {
await provider.clonePlaybook(CloneProgressionPlaybookInput(
sourcePlaybookId: playbook.id,
name: '${playbook.name} (Copy)',
description: 'Copy of ${playbook.description ?? playbook.name}',
));

ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Playbook cloned successfully')),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to clone playbook: $e')),
);
}
}

Future<void> _simulatePlaybook(ProgressionPlaybook playbook) async {
await Navigator.pushNamed(
context,
'/playbook-simulator',
arguments: PlaybookSimulatorArguments(playbook: playbook),
);
}

Future<void> _deletePlaybook(ProgressionPlaybook playbook) async {
final confirmed = await _showDeleteConfirmation(playbook);
if (!confirmed) return;

final provider = context.read<ProgressionPlaybookProvider>();

try {
await provider.deletePlaybook(playbook.id);

ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Playbook deleted successfully')),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to delete playbook: $e')),
);
}
}

Future<bool> _showDeleteConfirmation(ProgressionPlaybook playbook) async {
return await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Playbook'),
content: Text('Are you sure you want to delete "${playbook.name}"? This cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Delete'),
),
],
),
) ?? false;
}
}

Rule Builder UI

class RuleBuilderWidget extends StatefulWidget {
final ProgressionRule? initialRule;
final List<ConditionType> conditionTypes;
final List<ActionType> actionTypes;
final Function(ProgressionRule) onRuleChanged;

const RuleBuilderWidget({
super.key,
this.initialRule,
required this.conditionTypes,
required this.actionTypes,
required this.onRuleChanged,
});

@override
State<RuleBuilderWidget> createState() => _RuleBuilderWidgetState();
}

class _RuleBuilderWidgetState extends State<RuleBuilderWidget> {
late ProgressionRule _rule;

@override
void initState() {
super.initState();
_rule = widget.initialRule ?? ProgressionRule(
ruleId: 'new_rule_${DateTime.now().millisecondsSinceEpoch}',
order: 0,
userMessage: '',
conditions: [],
actions: [],
);
}

@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Rule order and user message
Row(
children: [
SizedBox(
width: 80,
child: TextFormField(
initialValue: _rule.order.toString(),
decoration: const InputDecoration(labelText: 'Order'),
keyboardType: TextInputType.number,
onChanged: (value) {
_rule = _rule.copyWith(order: int.tryParse(value) ?? 0);
_notifyRuleChanged();
},
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
initialValue: _rule.userMessage,
decoration: const InputDecoration(
labelText: 'User Message',
hintText: 'Message shown when rule triggers',
),
onChanged: (value) {
_rule = _rule.copyWith(userMessage: value);
_notifyRuleChanged();
},
),
),
],
),

const SizedBox(height: 16),

// Conditions section
Text(
'IF (Conditions)',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
..._rule.conditions.asMap().entries.map((entry) {
final index = entry.key;
final condition = entry.value;
return ConditionBuilderWidget(
condition: condition,
conditionTypes: widget.conditionTypes,
onConditionChanged: (newCondition) {
final newConditions = List<ProgressionCondition>.from(_rule.conditions);
newConditions[index] = newCondition;
_rule = _rule.copyWith(conditions: newConditions);
_notifyRuleChanged();
},
onRemove: () {
final newConditions = List<ProgressionCondition>.from(_rule.conditions);
newConditions.removeAt(index);
_rule = _rule.copyWith(conditions: newConditions);
_notifyRuleChanged();
},
);
}),

ElevatedButton.icon(
onPressed: _addCondition,
icon: const Icon(Icons.add),
label: const Text('Add Condition'),
),

const SizedBox(height: 16),

// Actions section
Text(
'THEN (Actions)',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
..._rule.actions.asMap().entries.map((entry) {
final index = entry.key;
final action = entry.value;
return ActionBuilderWidget(
action: action,
actionTypes: widget.actionTypes,
onActionChanged: (newAction) {
final newActions = List<ProgressionAction>.from(_rule.actions);
newActions[index] = newAction;
_rule = _rule.copyWith(actions: newActions);
_notifyRuleChanged();
},
onRemove: () {
final newActions = List<ProgressionAction>.from(_rule.actions);
newActions.removeAt(index);
_rule = _rule.copyWith(actions: newActions);
_notifyRuleChanged();
},
);
}),

ElevatedButton.icon(
onPressed: _addAction,
icon: const Icon(Icons.add),
label: const Text('Add Action'),
),
],
),
),
);
}

void _addCondition() {
final newConditions = List<ProgressionCondition>.from(_rule.conditions);
newConditions.add(ProgressionCondition(
type: widget.conditionTypes.first.type,
));
_rule = _rule.copyWith(conditions: newConditions);
_notifyRuleChanged();
}

void _addAction() {
final newActions = List<ProgressionAction>.from(_rule.actions);
newActions.add(ProgressionAction(
type: widget.actionTypes.first.type,
));
_rule = _rule.copyWith(actions: newActions);
_notifyRuleChanged();
}

void _notifyRuleChanged() {
widget.onRuleChanged(_rule);
}
}

Playbook Simulation UI

class PlaybookSimulatorScreen extends StatefulWidget {
final ProgressionPlaybook playbook;

const PlaybookSimulatorScreen({super.key, required this.playbook});

@override
State<PlaybookSimulatorScreen> createState() => _PlaybookSimulatorScreenState();
}

class _PlaybookSimulatorScreenState extends State<PlaybookSimulatorScreen> {
final List<TestWorkoutSetInput> _sets = [];
int _targetReps = 5;
int _consecutiveSuccesses = 0;
int _consecutiveFailures = 0;
String? _selectedExerciseId;
EnhancedSimulationResult? _simulationResult;
bool _isSimulating = false;

@override
void initState() {
super.initState();
_addDefaultSets();
}

void _addDefaultSets() {
_sets.addAll([
TestWorkoutSetInput(weight: 185, reps: 5, rpe: 8.0, skipped: false),
TestWorkoutSetInput(weight: 185, reps: 5, rpe: 8.5, skipped: false),
TestWorkoutSetInput(weight: 185, reps: 4, rpe: 9.0, skipped: false),
]);
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Simulate ${widget.playbook.name}'),
actions: [
ElevatedButton(
onPressed: _canSimulate() ? _runSimulation : null,
child: _isSimulating
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Simulate'),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Exercise selection
ExercisePickerWidget(
selectedExerciseId: _selectedExerciseId,
onExerciseSelected: (exerciseId) {
setState(() => _selectedExerciseId = exerciseId);
},
),

const SizedBox(height: 16),

// Workout parameters
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Workout Parameters',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextFormField(
initialValue: _targetReps.toString(),
decoration: const InputDecoration(labelText: 'Target Reps'),
keyboardType: TextInputType.number,
onChanged: (value) {
setState(() {
_targetReps = int.tryParse(value) ?? 5;
});
},
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
initialValue: _consecutiveSuccesses.toString(),
decoration: const InputDecoration(labelText: 'Consecutive Successes'),
keyboardType: TextInputType.number,
onChanged: (value) {
setState(() {
_consecutiveSuccesses = int.tryParse(value) ?? 0;
});
},
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
initialValue: _consecutiveFailures.toString(),
decoration: const InputDecoration(labelText: 'Consecutive Failures'),
keyboardType: TextInputType.number,
onChanged: (value) {
setState(() {
_consecutiveFailures = int.tryParse(value) ?? 0;
});
},
),
),
],
),
],
),
),
),

const SizedBox(height: 16),

// Sets input
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Workout Sets',
style: Theme.of(context).textTheme.titleMedium,
),
IconButton(
onPressed: _addSet,
icon: const Icon(Icons.add),
),
],
),
const SizedBox(height: 8),
..._sets.asMap().entries.map((entry) {
final index = entry.key;
final set = entry.value;
return SetInputWidget(
setNumber: index + 1,
set: set,
onSetChanged: (newSet) {
setState(() => _sets[index] = newSet);
},
onRemove: () {
setState(() => _sets.removeAt(index));
},
);
}),
],
),
),
),

const SizedBox(height: 16),

// Simulation results
if (_simulationResult != null)
SimulationResultsWidget(
result: _simulationResult!,
playbook: widget.playbook,
),
],
),
),
);
}

bool _canSimulate() {
return _selectedExerciseId != null && _sets.isNotEmpty && !_isSimulating;
}

Future<void> _runSimulation() async {
if (!_canSimulate()) return;

setState(() {
_isSimulating = true;
_simulationResult = null;
});

try {
final provider = context.read<ProgressionPlaybookProvider>();

final testWorkout = TestWorkoutInput(
sets: _sets,
targetReps: _targetReps,
consecutiveSuccesses: _consecutiveSuccesses,
consecutiveFailures: _consecutiveFailures,
);

final result = await provider.simulatePlaybook(
widget.playbook.id,
testWorkout,
_selectedExerciseId!,
);

setState(() => _simulationResult = result);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Simulation failed: $e')),
);
} finally {
setState(() => _isSimulating = false);
}
}

void _addSet() {
setState(() {
_sets.add(TestWorkoutSetInput(
weight: _sets.isNotEmpty ? _sets.last.weight : 185,
reps: _targetReps,
rpe: 8.0,
skipped: false,
));
});
}
}

Workout Recommendation Display

class WorkoutRecommendationWidget extends StatelessWidget {
final WorkoutRecommendation recommendation;

const WorkoutRecommendationWidget({
super.key,
required this.recommendation,
});

@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
recommendation.exerciseName ?? 'Exercise',
style: Theme.of(context).textTheme.titleLarge,
),
Text(
recommendation.progressionMessage,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Text(
'${recommendation.totalEstimatedTime ~/ 60} min',
style: Theme.of(context).textTheme.labelSmall,
),
),
],
),

const SizedBox(height: 16),

// Training focus
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Training Focus: ${recommendation.trainingFocus.name}',
style: Theme.of(context).textTheme.titleSmall,
),
Text(
'Target RPE: ${recommendation.trainingFocus.targetRpe} • '
'Rest: ${recommendation.trainingFocus.restSeconds}s • '
'Rep Range: ${(recommendation.trainingFocus.repRange as Map)['min']}-${(recommendation.trainingFocus.repRange as Map)['max']}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),

const SizedBox(height: 16),

// Warmup sets
if (recommendation.warmupSets.isNotEmpty) ...[
Text(
'Warmup Sets',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
...recommendation.warmupSets.map((set) => WarmupSetTile(set: set)),
const SizedBox(height: 16),
],

// Working sets
Text(
'Working Sets',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
...recommendation.workingSets.map((set) => WorkingSetTile(set: set)),

// Alternative options
if (recommendation.alternativeOptions != null) ...[
const SizedBox(height: 16),
ExpansionTile(
title: const Text('Alternative Options'),
children: [
AlternativeOptionsWidget(
alternatives: recommendation.alternativeOptions,
),
],
),
],
],
),
),
);
}
}

class WarmupSetTile extends StatelessWidget {
final WarmupSetRecommendation set;

const WarmupSetTile({super.key, required this.set});

@override
Widget build(BuildContext context) {
return ListTile(
dense: true,
leading: CircleAvatar(
radius: 16,
backgroundColor: _getPurposeColor(context, set.purpose),
child: Text(
'${set.setNumber}',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
title: Text('${set.weight} lbs × ${set.reps} reps'),
subtitle: Text(
'RPE ${set.rpe} • ${set.purpose.toUpperCase()}${set.notes != null ? ' • ${set.notes}' : ''}',
style: Theme.of(context).textTheme.bodySmall,
),
);
}

Color _getPurposeColor(BuildContext context, String purpose) {
switch (purpose) {
case 'activation':
return Colors.green;
case 'preparation':
return Colors.orange;
case 'opener':
return Colors.blue;
default:
return Theme.of(context).colorScheme.primary;
}
}
}

class WorkingSetTile extends StatelessWidget {
final WorkingSetRecommendation set;

const WorkingSetTile({super.key, required this.set});

@override
Widget build(BuildContext context) {
return ListTile(
dense: true,
leading: CircleAvatar(
radius: 16,
backgroundColor: set.isAmrap == true ? Colors.red : Theme.of(context).colorScheme.primary,
child: Text(
'${set.setNumber}',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
title: Text(
'${set.weight} lbs × ${set.reps} reps${set.isAmrap == true ? '+' : ''}',
style: TextStyle(
fontWeight: set.isAmrap == true ? FontWeight.bold : FontWeight.normal,
),
),
subtitle: Text(
'RPE ${set.rpe}${set.isAmrap == true ? ' • AMRAP' : ''}${set.notes != null ? ' • ${set.notes}' : ''}',
style: Theme.of(context).textTheme.bodySmall,
),
trailing: set.isAmrap == true
? Icon(
Icons.trending_up,
color: Colors.red,
size: 16,
)
: null,
);
}
}

Error Handling

Common Error Scenarios

# Playbook not found or access denied
{
"errors": [
{
"message": "You do not have permission to access this playbook.",
"extensions": {
"code": "FORBIDDEN"
}
}
]
}

# Invalid rule structure
{
"errors": [
{
"message": "Rule conditions cannot be empty",
"extensions": {
"code": "INVALID_RULE_STRUCTURE",
"ruleIndex": 2
}
}
]
}

# Simulation failed
{
"errors": [
{
"message": "Exercise not found for simulation",
"extensions": {
"code": "EXERCISE_NOT_FOUND",
"exerciseId": "60f0cf0b2f8fb814a8a3d789"
}
}
]
}

# Playbook in use (cannot delete)
{
"errors": [
{
"message": "Cannot delete playbook that is in use by active programs",
"extensions": {
"code": "PLAYBOOK_IN_USE",
"activePrograms": 3,
"totalReferences": 15
}
}
]
}

Flutter Error Handling

class ProgressionPlaybookErrorHandler {
static void handlePlaybookError(BuildContext context, dynamic error) {
String message = 'An unexpected error occurred';

if (error is GraphQLError) {
final code = error.extensions?['code'];

switch (code) {
case 'FORBIDDEN':
message = 'You do not have permission to access this playbook.';
break;
case 'INVALID_RULE_STRUCTURE':
final ruleIndex = error.extensions?['ruleIndex'];
message = 'Rule ${ruleIndex != null ? '#${ruleIndex + 1}' : ''} has invalid structure. Please check conditions and actions.';
break;
case 'EXERCISE_NOT_FOUND':
message = 'Exercise not found. Please select a valid exercise for simulation.';
break;
case 'PLAYBOOK_IN_USE':
final activePrograms = error.extensions?['activePrograms'];
message = 'Cannot delete this playbook as it is used by $activePrograms active program(s). Try soft delete instead.';
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('Progression Playbook Integration Tests', () {
testWidgets('create and test playbook flow', (tester) async {
// Arrange: Mock GraphQL responses
when(mockClient.mutate<CreateProgressionPlaybook>(any)).thenAnswer((_) async =>
QueryResult(
data: {
'createProgressionPlaybook': {
'id': 'test_playbook_id',
'name': 'Test Playbook',
'rules': [
{
'ruleId': 'rule_1',
'order': 0,
'userMessage': 'Adding weight!',
'conditions': [{'type': 'ALL_SETS_COMPLETED'}],
'actions': [{'type': 'INCREASE_WEIGHT_ABSOLUTE', 'value': 5.0}],
}
]
}
},
source: QueryResultSource.network,
options: QueryOptions(document: gql('')),
));

// Act: Create playbook
await tester.pumpWidget(testApp);
await tester.tap(find.text('Create Playbook'));
await tester.pumpAndSettle();

// Fill in playbook details
await tester.enterText(find.byKey(const Key('playbook_name')), 'Test Playbook');
await tester.tap(find.text('Add Rule'));
await tester.pumpAndSettle();

// Configure rule
await tester.tap(find.text('ALL_SETS_COMPLETED'));
await tester.tap(find.text('INCREASE_WEIGHT_ABSOLUTE'));
await tester.enterText(find.byKey(const Key('action_value')), '5');

await tester.tap(find.text('Save Playbook'));
await tester.pumpAndSettle();

// Assert: Playbook created successfully
expect(find.text('Test Playbook'), findsOneWidget);
});

testWidgets('simulate playbook produces recommendation', (tester) async {
// Arrange: Mock simulation response
when(mockClient.mutate<SimulatePlaybookEnhanced>(any)).thenAnswer((_) async =>
QueryResult(
data: {
'simulatePlaybookEnhanced': {
'suggestedWeight': 190.0,
'suggestedReps': 5,
'message': 'Adding weight!',
'workoutRecommendation': {
'exerciseId': 'exercise_id',
'exerciseName': 'Bench Press',
'progressionMessage': 'Adding weight!',
'warmupSets': [],
'workingSets': [
{
'setNumber': 1,
'weight': 190.0,
'reps': 5,
'rpe': 8.0,
'isAmrap': false,
}
],
'totalEstimatedTime': 900,
}
}
},
source: QueryResultSource.network,
options: QueryOptions(document: gql('')),
));

// Act: Run simulation
await tester.pumpWidget(testApp);
await tester.tap(find.text('Simulate'));
await tester.pumpAndSettle();

// Assert: Simulation results displayed
expect(find.text('Adding weight!'), findsOneWidget);
expect(find.text('190.0 lbs × 5 reps'), findsOneWidget);
});
});
}

Unit Tests

void main() {
group('ProgressionPlaybookProvider', () {
late ProgressionPlaybookProvider provider;
late MockGraphQLClient mockClient;

setUp(() {
mockClient = MockGraphQLClient();
provider = ProgressionPlaybookProvider(mockClient);
});

test('loadPlaybooks fetches and caches playbook data', () async {
// Arrange
when(mockClient.query<ProgressionPlaybooks>(any)).thenAnswer((_) async =>
QueryResult(
data: {
'progressionPlaybooks': {
'edges': [
{
'node': {
'id': 'playbook_1',
'name': 'Linear Progression',
'rules': [],
}
}
]
}
},
source: QueryResultSource.network,
options: QueryOptions(document: gql('')),
));

// Act
await provider.loadPlaybooks();

// Assert
expect(provider.playbooks.length, equals(1));
expect(provider.playbooks.first.name, equals('Linear Progression'));
});

test('createPlaybook adds new playbook to list', () async {
// Arrange
when(mockClient.mutate<CreateProgressionPlaybook>(any)).thenAnswer((_) async =>
QueryResult(
data: {
'createProgressionPlaybook': {
'id': 'new_playbook_id',
'name': 'New Playbook',
'rules': [],
}
},
source: QueryResultSource.network,
options: QueryOptions(document: gql('')),
));

// Act
final playbook = await provider.createPlaybook(CreateProgressionPlaybookInput(
name: 'New Playbook',
rules: [],
));

// Assert
expect(playbook.name, equals('New Playbook'));
expect(provider.playbooks.length, equals(1));
});

test('simulatePlaybook returns enhanced results', () async {
// Arrange
when(mockClient.mutate<SimulatePlaybookEnhanced>(any)).thenAnswer((_) async =>
QueryResult(
data: {
'simulatePlaybookEnhanced': {
'suggestedWeight': 200.0,
'suggestedReps': 5,
'message': 'Great work!',
'warnings': [],
'errors': [],
'workoutRecommendation': {
'exerciseId': 'exercise_id',
'totalEstimatedTime': 900,
'warmupSets': [],
'workingSets': [],
}
}
},
source: QueryResultSource.network,
options: QueryOptions(document: gql('')),
));

// Act
final result = await provider.simulatePlaybook(
'playbook_id',
TestWorkoutInput(
sets: [TestWorkoutSetInput(weight: 185, reps: 5, skipped: false)],
targetReps: 5,
),
'exercise_id',
);

// Assert
expect(result.suggestedWeight, equals(200.0));
expect(result.message, equals('Great work!'));
expect(result.workoutRecommendation, isNotNull);
});
});
}

Business Logic Summary

The Progression Playbook API provides intelligent workout progression through:

⚠️ IMPLEMENTATION BOUNDARY

All rule evaluation, progression calculations, and workout recommendations are server-side only. Flutter must NEVER implement progression logic locally.

Server-Side Intelligence:

  • Rule Engine: Evaluates complex conditional logic for workout progression
  • Progression Algorithms: Calculates optimal weight, rep, and set progressions
  • Workout Generation: Creates complete workout structures with warmup and working sets
  • Training Focus Integration: Applies program-specific parameters and goals
  • Historical Analysis: Compares playbook suggestions against actual workout history
  • Simulation Engine: Tests playbook effectiveness with various workout scenarios

Client Responsibilities:

  • Present rule builder UI with available condition/action types
  • Display playbook management and organization interfaces
  • Show simulation results and workout recommendations
  • Handle playbook CRUD operations through GraphQL mutations
  • Cache playbook data and recommendations for offline access
  • Present historical simulation analysis and accuracy metrics