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 completedANY_SET_FAILED: At least one set was not completed
Streak Conditions:
CONSECUTIVE_SUCCESSES: Number of successful workouts in a rowCONSECUTIVE_FAILURES: Number of failed workouts in a row
Intensity Conditions:
LAST_SET_RPE: Rate of Perceived Exertion of final setLAST_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 amountDECREASE_WEIGHT_PERCENT: Reduce weight by percentage (deloads)
Rep Progression:
INCREASE_REPS_ABSOLUTE: Add specific number of repsRESET_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.
- GraphQL Mutation
- Variables
- Response
mutation CreateProgressionPlaybook($input: CreateProgressionPlaybookInput!) {
createProgressionPlaybook(input: $input) {
id
name
description
visibility
createdAt
rules {
ruleId
order
userMessage
conditions {
type
operator
value
count
}
actions {
type
value
}
}
}
}
{
"input": {
"name": "Linear Progression with Deload",
"description": "Simple linear progression with automatic deload after 3 failures",
"visibility": "PRIVATE",
"rules": [
{
"order": 0,
"userMessage": "Perfect! Adding 5 lbs for next time.",
"conditions": [
{ "type": "ALL_SETS_COMPLETED" }
],
"actions": [
{ "type": "INCREASE_WEIGHT_ABSOLUTE", "value": 5 }
]
},
{
"order": 1,
"userMessage": "Tough one. Let's stick with this weight.",
"conditions": [
{ "type": "ANY_SET_FAILED" }
],
"actions": [
{ "type": "MAINTAIN_ALL" }
]
},
{
"order": 2,
"userMessage": "Plateau detected. Deloading 10% to reset.",
"conditions": [
{ "type": "CONSECUTIVE_FAILURES", "count": 3 }
],
"actions": [
{ "type": "DECREASE_WEIGHT_PERCENT", "value": 10 }
]
}
]
}
}
{
"data": {
"createProgressionPlaybook": {
"id": "60f0cf0b2f8fb814a8a3d123",
"name": "Linear Progression with Deload",
"description": "Simple linear progression with automatic deload after 3 failures",
"visibility": "PRIVATE",
"createdAt": "2023-12-01T10:00:00.000Z",
"rules": [
{
"ruleId": "rule_1",
"order": 0,
"userMessage": "Perfect! Adding 5 lbs for next time.",
"conditions": [
{ "type": "ALL_SETS_COMPLETED" }
],
"actions": [
{ "type": "INCREASE_WEIGHT_ABSOLUTE", "value": 5 }
]
},
{
"ruleId": "rule_2",
"order": 1,
"userMessage": "Tough one. Let's stick with this weight.",
"conditions": [
{ "type": "ANY_SET_FAILED" }
],
"actions": [
{ "type": "MAINTAIN_ALL" }
]
},
{
"ruleId": "rule_3",
"order": 2,
"userMessage": "Plateau detected. Deloading 10% to reset.",
"conditions": [
{ "type": "CONSECUTIVE_FAILURES", "count": 3 }
],
"actions": [
{ "type": "DECREASE_WEIGHT_PERCENT", "value": 10 }
]
}
]
}
}
}
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.
- GraphQL Mutation
- Variables
- Response
mutation CloneProgressionPlaybook($input: CloneProgressionPlaybookInput!) {
cloneProgressionPlaybook(input: $input) {
id
name
description
createdAt
rules {
ruleId
order
userMessage
conditions {
type
operator
value
count
}
actions {
type
value
}
}
}
}
{
"input": {
"sourcePlaybookId": "60f0cf0b2f8fb814a8a3d123",
"name": "My Custom Linear Progression",
"description": "Modified version of linear progression for my needs"
}
}
{
"data": {
"cloneProgressionPlaybook": {
"id": "60f0cf0b2f8fb814a8a3d456",
"name": "My Custom Linear Progression",
"description": "Modified version of linear progression for my needs",
"createdAt": "2023-12-01T11:00:00.000Z",
"rules": [
{
"ruleId": "rule_1_cloned",
"order": 0,
"userMessage": "Perfect! Adding 5 lbs for next time.",
"conditions": [
{ "type": "ALL_SETS_COMPLETED" }
],
"actions": [
{ "type": "INCREASE_WEIGHT_ABSOLUTE", "value": 5 }
]
}
]
}
}
}
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.
- GraphQL Mutation
- Variables
- Response
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
}
}
{
"playbookId": "60f0cf0b2f8fb814a8a3d123",
"testWorkout": {
"sets": [
{ "weight": 225, "reps": 5, "rpe": 8.5, "skipped": false },
{ "weight": 225, "reps": 5, "rpe": 9.0, "skipped": false },
{ "weight": 225, "reps": 3, "rpe": 9.5, "skipped": false }
],
"targetReps": 5,
"consecutiveSuccesses": 2,
"consecutiveFailures": 0
}
}
{
"data": {
"simulatePlaybook": {
"triggeredRule": {
"ruleId": "rule_2",
"order": 1,
"userMessage": "Tough one. Let's stick with this weight.",
"conditions": [
{ "type": "ANY_SET_FAILED" }
],
"actions": [
{ "type": "MAINTAIN_ALL" }
]
},
"suggestedWeight": 225,
"suggestedReps": 5,
"message": "Tough one. Let's stick with this weight.",
"warnings": ["Last set RPE was very high (9.5)"],
"errors": []
}
}
}
Enhanced Playbook Simulation
Get complete workout recommendation with warmup and working sets.
- GraphQL Mutation
- Variables
- Response
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
}
}
}
{
"playbookId": "60f0cf0b2f8fb814a8a3d123",
"exerciseId": "60f0cf0b2f8fb814a8a3d789",
"testWorkout": {
"sets": [
{ "weight": 185, "reps": 5, "rpe": 7.5, "skipped": false },
{ "weight": 185, "reps": 5, "rpe": 7.5, "skipped": false },
{ "weight": 185, "reps": 5, "rpe": 8.0, "skipped": false }
],
"targetReps": 5,
"consecutiveSuccesses": 1,
"consecutiveFailures": 0
}
}
{
"data": {
"simulatePlaybookEnhanced": {
"triggeredRule": {
"ruleId": "rule_1",
"userMessage": "Perfect! Adding 5 lbs for next time."
},
"suggestedWeight": 190,
"suggestedReps": 5,
"message": "Perfect! Adding 5 lbs for next time.",
"warnings": [],
"errors": [],
"workoutRecommendation": {
"exerciseId": "60f0cf0b2f8fb814a8a3d789",
"exerciseName": "Bench Press",
"progressionMessage": "Perfect! Adding 5 lbs for next time.",
"totalEstimatedTime": 900,
"trainingFocus": {
"name": "Strength",
"targetRpe": 8.5,
"restSeconds": 180,
"repRange": { "min": 3, "max": 6 }
},
"warmupSets": [
{
"setNumber": 1,
"weight": 95,
"reps": 8,
"rpe": 5.0,
"purpose": "activation",
"notes": "Light activation set"
},
{
"setNumber": 2,
"weight": 135,
"reps": 5,
"rpe": 6.0,
"purpose": "preparation",
"notes": "Moderate preparation"
},
{
"setNumber": 3,
"weight": 165,
"reps": 3,
"rpe": 7.0,
"purpose": "opener",
"notes": "Opener set"
}
],
"workingSets": [
{
"setNumber": 1,
"weight": 190,
"reps": 5,
"rpe": 8.0,
"isAmrap": false,
"notes": "Focus on controlled tempo"
},
{
"setNumber": 2,
"weight": 190,
"reps": 5,
"rpe": 8.5,
"isAmrap": false,
"notes": "Maintain form quality"
},
{
"setNumber": 3,
"weight": 190,
"reps": 5,
"rpe": 9.0,
"isAmrap": true,
"notes": "Final set - push for max reps"
}
],
"alternativeOptions": {
"deloadOption": {
"weight": 171,
"description": "10% deload if feeling fatigued"
},
"intensityBoost": {
"weight": 200,
"description": "Add 10 lbs if feeling exceptionally strong"
}
}
}
}
}
}
Historical Simulation
Test playbook against actual workout history data.
- GraphQL Mutation
- Variables
- Response
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
}
}
{
"input": {
"playbookId": "60f0cf0b2f8fb814a8a3d123",
"workoutHistoryEntryId": "60f0cf0b2f8fb814a8a3d987",
"exerciseId": "60f0cf0b2f8fb814a8a3d789",
"targetRepsOverride": 5
}
}
{
"data": {
"simulatePlaybookWithHistory": {
"triggeredRule": {
"ruleId": "rule_1",
"userMessage": "Perfect! Adding 5 lbs for next time."
},
"suggestedWeight": 190,
"suggestedReps": 5,
"message": "Perfect! Adding 5 lbs for next time.",
"warnings": [],
"errors": [],
"originalWorkout": {
"entryId": "60f0cf0b2f8fb814a8a3d987",
"date": "2023-11-28T10:00:00.000Z",
"exerciseName": "Bench Press",
"targetReps": 5,
"actualWeight": 185,
"sets": [
{ "weight": 185, "reps": 5, "rpe": 7.5, "skipped": false },
{ "weight": 185, "reps": 5, "rpe": 7.5, "skipped": false },
{ "weight": 185, "reps": 5, "rpe": 8.0, "skipped": false }
]
},
"consecutiveSuccessesAtTime": 2,
"consecutiveFailuresAtTime": 0,
"actualNextWorkout": {
"entryId": "60f0cf0b2f8fb814a8a3d988",
"date": "2023-11-30T10:00:00.000Z",
"actualWeight": 190,
"sets": [
{ "weight": 190, "reps": 5 },
{ "weight": 190, "reps": 5 },
{ "weight": 190, "reps": 4 }
]
},
"wouldHaveMatched": true,
"executionDetails": "Rule matched ALL_SETS_COMPLETED condition and suggested 5lb increase, which matches actual progression used."
}
}
}
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