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

Program User Instances API

The Program User Instances API manages user enrollment in fitness programs, workout scheduling, progression tracking, and exercise customization. This system creates individualized program instances for users with their preferred training days and custom progression settings.

⚠️ CRITICAL: THIN CLIENT IMPLEMENTATION ONLY

What Flutter SHOULD do:

  • ✅ Display program enrollment UI and enrollment forms
  • ✅ Show weekly workout schedules and program progress
  • ✅ Handle workout start/completion user interactions
  • ✅ Cache program instance data for offline viewing
  • ✅ Display workout templates and exercise details

What Flutter MUST NOT do:

  • ❌ Calculate workout scheduling logic
  • ❌ Determine program progression rules
  • ❌ Implement workout completion validation
  • ❌ Calculate catch-up workout schedules
  • ❌ Process exercise playbook assignments

Core Concepts

Program User Instance

A Program User Instance (PUI) represents a user's enrollment in a specific fitness program. Each instance contains:

  • Program Snapshot: Immutable copy of the program at enrollment time
  • Scheduling Preferences: User's preferred training days and timing
  • Progression State: Current week, completed workouts, and advancement
  • Exercise Customization: Custom playbook assignments for exercises
  • Status Management: Active, paused, completed, or ended states

Workout Scheduling System

The system automatically generates weekly workout schedules based on:

  • Program Type: Linear (progressive) vs Cycling (repeating templates)
  • Training Days: User's preferred days of the week
  • Current Progress: Which week and workout order the user is on
  • Catch-up Logic: Automatic rescheduling of missed workouts

Program Progression

Users advance through programs via:

  • Workout Completion: Marking scheduled workouts as completed
  • Automatic Advancement: Moving to next workout in sequence
  • Week Progression: Advancing to new program weeks
  • Catch-up Scheduling: Handling missed workout recovery

GraphQL Schema

Types

type ProgramUserInstance {
id: ID!
status: UserProgramInstanceStatus!
startDate: DateTime!
expectedProgramEndDate: DateTime!
actualProgramEndDate: DateTime
currentPlanWeekNumber: Int!
lastCompletedPwtOrderInWeek: Int
lastActivityDate: DateTime

# Relations
user: User!
programPlan: ProgramPlan!

# Schedule and preferences
preferredTrainingDays: [Int]
weeklySchedule(year: Int!, isoWeek: Int!): [UserProgramScheduledWeekWorkout!]!

# Program snapshot data
programSnapshot: ProgramSnapshot
}

type UserProgramScheduledWeekWorkout {
id: ID!
year: Int!
isoWeek: Int!
dayOfWeek: Int!
planWeekNumber: Int!
status: ScheduledWorkoutStatus!
completedWorkoutHistoryId: ID

# Workout details
masterPwtIdForSchedule: ID!
workoutTemplateName: String
workoutTemplate: ProgramWorkoutTemplate

# Schedule analysis
scheduledDate: DateTime!
isCatchUp: Boolean!
originalWeekNumber: Int
}

type ProgramSnapshot {
masterProgramPlanId: ID!
nameAtEnrollment: String!
versionAtEnrollment: String!
descriptionAtEnrollment: String
durationWeeksAtEnrollment: Int!
createdAt: DateTime!
pwtSnapshots: [ProgramWorkoutTemplateSnapshot!]!
}

type ProgramWorkoutTemplateSnapshot {
masterPwtId: ID!
orderInPlanSnapshot: Int!
nameAtEnrollment: String!
descriptionAtEnrollment: String
durationMinutesAtEnrollment: Int!
exerciseSnapshots: [ProgramExerciseSnapshot!]!
}

type ProgramExerciseSnapshot {
masterExerciseId: ID!
orderInPwtSnapshot: Int!
groupOrderAtEnrollment: Int
setsAtEnrollment: Int!
repsMinAtEnrollment: Int!
repsMaxAtEnrollment: Int
restSecondsAtEnrollment: Int!
originalRepsAtEnrollment: Int!
baseTrainingWeight: Float
progressionPlaybookId: ID
progressionState: JSON
}

enum UserProgramInstanceStatus {
ACTIVE
PAUSED
COMPLETED
ENDED_BY_USER
}

enum ScheduledWorkoutStatus {
SCHEDULED
RUNNING
COMPLETED
SKIPPED
MISSED_SYSTEM
}

Input Types

input EnrollInProgramInput {
programPlanId: ID!
preferredTrainingDays: [DayOfWeek]
playbookConfiguration: PlaybookConfigurationInput
}

input PlaybookConfigurationInput {
defaultPlaybookId: ID
exerciseOverrides: [ExercisePlaybookOverrideInput]
}

input ExercisePlaybookOverrideInput {
exerciseId: ID!
playbookId: ID!
}

input UpdatePreferredDaysInput {
programUserInstanceId: ID!
preferredTrainingDays: [DayOfWeek]
}

input ProgramUserInstanceIdInput {
programUserInstanceId: ID!
}

input StartScheduledWorkoutInput {
scheduledWorkoutId: ID!
}

input CompleteScheduledWorkoutInput {
scheduledWorkoutId: ID!
workoutHistoryEntryId: ID!
}

input SkipScheduledWorkoutInput {
scheduledWorkoutId: ID!
}

input UpdateExercisePlaybookInput {
programUserInstanceId: ID!
exerciseId: ID!
newPlaybookId: ID!
}

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

Queries

Get Active Program

Retrieve the user's currently active or paused program instance.

query ActiveProgram {
activeProgram {
id
status
startDate
expectedProgramEndDate
currentPlanWeekNumber
lastCompletedPwtOrderInWeek
preferredTrainingDays

programPlan {
id
name
description
level
goal
durationWeeks
daysPerWeek
}

programSnapshot {
nameAtEnrollment
versionAtEnrollment
durationWeeksAtEnrollment
pwtSnapshots {
masterPwtId
orderInPlanSnapshot
nameAtEnrollment
durationMinutesAtEnrollment
}
}
}
}

Get Weekly Schedule

Retrieve workout schedule for a specific week.

query WeeklySchedule($puiId: ID!, $year: Int!, $isoWeek: Int!) {
activeProgram {
id
weeklySchedule(year: $year, isoWeek: $isoWeek) {
id
dayOfWeek
planWeekNumber
status
scheduledDate
isCatchUp
originalWeekNumber
workoutTemplateName

workoutTemplate {
id
name
description
durationMinutes
exercises {
id
name
sets
repsMin
repsMax
restSeconds
}
}
}
}
}

Mutations

Enroll in Program

Enroll a user in a fitness program with custom preferences and playbook configuration.

mutation EnrollInProgram($input: EnrollInProgramInput!) {
enrollInProgram(input: $input) {
id
status
startDate
expectedProgramEndDate
currentPlanWeekNumber
preferredTrainingDays

programPlan {
id
name
description
level
goal
durationWeeks
daysPerWeek
}

programSnapshot {
nameAtEnrollment
versionAtEnrollment
durationWeeksAtEnrollment
}
}
}

Program State Management

Pause Program

mutation PauseProgram($input: ProgramUserInstanceIdInput!) {
pauseProgram(input: $input) {
id
status
lastActivityDate
}
}

Resume Program

mutation ResumeProgram($input: ProgramUserInstanceIdInput!) {
resumeProgram(input: $input) {
id
status
currentPlanWeekNumber
expectedProgramEndDate
}
}

End Program

mutation EndProgramByUser($input: ProgramUserInstanceIdInput!) {
endProgramByUser(input: $input) {
id
status
actualProgramEndDate
}
}

Delete Program Instance

mutation DeleteProgramUserInstance($input: ProgramUserInstanceIdInput!) {
deleteProgramUserInstance(input: $input) {
success
message
}
}

Update Preferred Training Days

Change user's preferred training days and regenerate schedule.

mutation UpdatePreferredTrainingDays($input: UpdatePreferredDaysInput!) {
updatePreferredTrainingDays(input: $input) {
id
preferredTrainingDays
currentPlanWeekNumber

# Check updated schedule
weeklySchedule(year: 2023, isoWeek: 49) {
id
dayOfWeek
scheduledDate
status
workoutTemplateName
}
}
}

Workout Execution Flow

Start Scheduled Workout

Begin a scheduled workout and create pre-filled workout history entry.

mutation StartScheduledWorkout($input: StartScheduledWorkoutInput!) {
startScheduledWorkout(input: $input) {
id
name
startTime
status
estimatedDurationMinutes

exercises {
id
exercise {
id
name
}
sets
repsMin
repsMax
restSeconds
targetWeight

workoutExerciseProgression {
currentProgressionWeek
baselineWeight
progressionState
}
}
}
}

Complete Scheduled Workout

Mark a workout as completed and advance program progression.

mutation CompleteScheduledWorkout($input: CompleteScheduledWorkoutInput!) {
completeScheduledWorkout(input: $input) {
id
status
currentPlanWeekNumber
lastCompletedPwtOrderInWeek
lastActivityDate

# Check if week advanced
weeklySchedule(year: 2023, isoWeek: 50) {
id
dayOfWeek
planWeekNumber
status
isCatchUp
workoutTemplateName
}
}
}

Skip Scheduled Workout

Skip a workout and advance program progression.

mutation SkipScheduledWorkout($input: SkipScheduledWorkoutInput!) {
skipScheduledWorkout(input: $input) {
id
currentPlanWeekNumber
lastCompletedPwtOrderInWeek
lastActivityDate
}
}

Exercise Customization

Update Exercise Playbook

Customize progression playbook for a specific exercise within the program.

mutation UpdateExercisePlaybook($input: UpdateExercisePlaybookInput!) {
updateExercisePlaybook(input: $input) {
id
programSnapshot {
pwtSnapshots {
exerciseSnapshots {
masterExerciseId
progressionPlaybookId
progressionState
}
}
}
}
}

Flutter Integration

State Management Architecture

// Program instance state management
class ProgramInstanceProvider extends ChangeNotifier {
ProgramUserInstance? _activeProgram;
Map<String, List<UserProgramScheduledWeekWorkout>> _weeklySchedules = {};

ProgramUserInstance? get activeProgram => _activeProgram;
List<UserProgramScheduledWeekWorkout> getWeeklySchedule(int year, int isoWeek) {
return _weeklySchedules["$year-$isoWeek"] ?? [];
}

// ✅ CORRECT: Consume server intelligence
Future<void> enrollInProgram(String programPlanId, List<DayOfWeek> preferredDays) async {
final result = await _graphqlClient.mutate(EnrollInProgramMutation(
variables: EnrollInProgramArguments(
input: EnrollInProgramInput(
programPlanId: programPlanId,
preferredTrainingDays: preferredDays,
),
),
));

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

_activeProgram = result.parsedData!.enrollInProgram;
notifyListeners();
}

// ✅ CORRECT: Let server handle scheduling logic
Future<List<UserProgramScheduledWeekWorkout>> loadWeeklySchedule(
int year,
int isoWeek
) async {
final result = await _graphqlClient.query(WeeklyScheduleQuery(
variables: WeeklyScheduleArguments(
puiId: _activeProgram!.id,
year: year,
isoWeek: isoWeek,
),
));

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

final schedule = result.parsedData!.activeProgram!.weeklySchedule;
_weeklySchedules["$year-$isoWeek"] = schedule;
notifyListeners();

return schedule;
}
}

Program Enrollment Flow

class ProgramEnrollmentScreen extends StatefulWidget {
final String programPlanId;

const ProgramEnrollmentScreen({super.key, required this.programPlanId});

@override
State<ProgramEnrollmentScreen> createState() => _ProgramEnrollmentScreenState();
}

class _ProgramEnrollmentScreenState extends State<ProgramEnrollmentScreen> {
List<DayOfWeek> selectedTrainingDays = [];
String? selectedDefaultPlaybookId;
Map<String, String> exercisePlaybookOverrides = {};

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Enroll in Program')),
body: Column(
children: [
// Training days selector
TrainingDaysSelector(
selectedDays: selectedTrainingDays,
onDaysChanged: (days) => setState(() => selectedTrainingDays = days),
),

// Playbook configuration
PlaybookConfigurationWidget(
selectedDefaultPlaybookId: selectedDefaultPlaybookId,
exerciseOverrides: exercisePlaybookOverrides,
onDefaultPlaybookChanged: (id) => setState(() => selectedDefaultPlaybookId = id),
onExerciseOverrideChanged: (exerciseId, playbookId) {
setState(() => exercisePlaybookOverrides[exerciseId] = playbookId);
},
),

// Enrollment button
ElevatedButton(
onPressed: _enrollInProgram,
child: const Text('Start Program'),
),
],
),
);
}

Future<void> _enrollInProgram() async {
try {
final provider = context.read<ProgramInstanceProvider>();
await provider.enrollInProgram(
widget.programPlanId,
selectedTrainingDays,
playbookConfiguration: PlaybookConfiguration(
defaultPlaybookId: selectedDefaultPlaybookId,
exerciseOverrides: exercisePlaybookOverrides.entries
.map((e) => ExercisePlaybookOverride(
exerciseId: e.key,
playbookId: e.value,
))
.toList(),
),
);

if (mounted) {
Navigator.pushReplacementNamed(context, '/program-dashboard');
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Enrollment failed: $e')),
);
}
}
}

Weekly Schedule Display

class WeeklyScheduleWidget extends StatefulWidget {
final int year;
final int isoWeek;

const WeeklyScheduleWidget({
super.key,
required this.year,
required this.isoWeek,
});

@override
State<WeeklyScheduleWidget> createState() => _WeeklyScheduleWidgetState();
}

class _WeeklyScheduleWidgetState extends State<WeeklyScheduleWidget> {
@override
void initState() {
super.initState();
_loadSchedule();
}

Future<void> _loadSchedule() async {
final provider = context.read<ProgramInstanceProvider>();
await provider.loadWeeklySchedule(widget.year, widget.isoWeek);
}

@override
Widget build(BuildContext context) {
return Consumer<ProgramInstanceProvider>(
builder: (context, provider, child) {
final schedule = provider.getWeeklySchedule(widget.year, widget.isoWeek);

return Column(
children: [
Text(
'Week ${widget.isoWeek}, ${widget.year}',
style: Theme.of(context).textTheme.headlineSmall,
),
...schedule.map((workout) => WorkoutCard(
workout: workout,
onStart: () => _startWorkout(workout.id),
onComplete: (historyEntryId) => _completeWorkout(workout.id, historyEntryId),
onSkip: () => _skipWorkout(workout.id),
)),
],
);
},
);
}

Future<void> _startWorkout(String scheduledWorkoutId) async {
final provider = context.read<ProgramInstanceProvider>();
final historyEntry = await provider.startScheduledWorkout(scheduledWorkoutId);

if (mounted) {
Navigator.pushNamed(
context,
'/workout-session',
arguments: historyEntry.id,
);
}
}

Future<void> _completeWorkout(String scheduledWorkoutId, String historyEntryId) async {
final provider = context.read<ProgramInstanceProvider>();
await provider.completeScheduledWorkout(scheduledWorkoutId, historyEntryId);

ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Workout completed! Program advanced.')),
);
}

Future<void> _skipWorkout(String scheduledWorkoutId) async {
final provider = context.read<ProgramInstanceProvider>();
await provider.skipScheduledWorkout(scheduledWorkoutId);

ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Workout skipped. Program advanced.')),
);
}
}

Program State Management UI

class ProgramControlsWidget extends StatelessWidget {
final ProgramUserInstance program;

const ProgramControlsWidget({super.key, required this.program});

@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
program.programPlan.name,
style: Theme.of(context).textTheme.titleLarge,
),
Text('Status: ${program.status.name}'),
Text('Current Week: ${program.currentPlanWeekNumber}'),
const SizedBox(height: 16),
Row(
children: [
if (program.status == UserProgramInstanceStatus.active)
ElevatedButton(
onPressed: () => _pauseProgram(context),
child: const Text('Pause'),
),
if (program.status == UserProgramInstanceStatus.paused)
ElevatedButton(
onPressed: () => _resumeProgram(context),
child: const Text('Resume'),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: () => _endProgram(context),
child: const Text('End Program'),
),
],
),
],
),
),
);
}

Future<void> _pauseProgram(BuildContext context) async {
final provider = context.read<ProgramInstanceProvider>();
await provider.pauseProgram(program.id);

ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Program paused')),
);
}

Future<void> _resumeProgram(BuildContext context) async {
final provider = context.read<ProgramInstanceProvider>();
await provider.resumeProgram(program.id);

ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Program resumed')),
);
}

Future<void> _endProgram(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('End Program'),
content: const Text('Are you sure you want to end this program? This cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('End Program'),
),
],
),
);

if (confirmed == true) {
final provider = context.read<ProgramInstanceProvider>();
await provider.endProgram(program.id);

ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Program ended')),
);
}
}
}

Error Handling

Common Error Scenarios

# User already enrolled in another program
{
"errors": [
{
"message": "User is already enrolled in an active program",
"extensions": {
"code": "ALREADY_ENROLLED",
"activeProgram": {
"id": "60f0cf0b2f8fb814a8a3d321",
"status": "ACTIVE"
}
}
}
]
}

# Program not found
{
"errors": [
{
"message": "Program plan not found or not accessible",
"extensions": {
"code": "PROGRAM_NOT_FOUND"
}
}
]
}

# Invalid training days configuration
{
"errors": [
{
"message": "Preferred training days exceed program requirements",
"extensions": {
"code": "INVALID_TRAINING_DAYS",
"maxDaysPerWeek": 3,
"providedDays": 5
}
}
]
}

# Workout already completed
{
"errors": [
{
"message": "Cannot complete workout that is already marked as completed",
"extensions": {
"code": "WORKOUT_ALREADY_COMPLETED",
"workoutStatus": "COMPLETED"
}
}
]
}

Flutter Error Handling

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

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

switch (code) {
case 'ALREADY_ENROLLED':
message = 'You are already enrolled in another program. Please complete or end your current program first.';
break;
case 'PROGRAM_NOT_FOUND':
message = 'This program is no longer available.';
break;
case 'INVALID_TRAINING_DAYS':
final maxDays = error.extensions?['maxDaysPerWeek'];
message = 'You can select a maximum of $maxDays training days for this program.';
break;
case 'WORKOUT_ALREADY_COMPLETED':
message = 'This workout has already been completed.';
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('Program User Instance Integration Tests', () {
testWidgets('complete program enrollment flow', (tester) async {
// Arrange: Mock GraphQL responses
when(mockClient.mutate<EnrollInProgram>(any)).thenAnswer((_) async =>
QueryResult(
data: {
'enrollInProgram': {
'id': 'test_pui_id',
'status': 'ACTIVE',
'startDate': DateTime.now().toIso8601String(),
'currentPlanWeekNumber': 1,
'preferredTrainingDays': [1, 3, 5],
}
},
source: QueryResultSource.network,
options: QueryOptions(document: gql('')),
));

// Act: Navigate through enrollment flow
await tester.pumpWidget(testApp);
await tester.tap(find.text('Enroll in Program'));
await tester.pumpAndSettle();

// Select training days
await tester.tap(find.text('Monday'));
await tester.tap(find.text('Wednesday'));
await tester.tap(find.text('Friday'));

await tester.tap(find.text('Start Program'));
await tester.pumpAndSettle();

// Assert: Program enrollment successful
expect(find.text('Program Dashboard'), findsOneWidget);
expect(find.text('Week 1'), findsOneWidget);
});

testWidgets('handle workout completion flow', (tester) async {
// Arrange: Mock active program and schedule
when(mockClient.query<ActiveProgram>(any)).thenAnswer((_) async =>
QueryResult(
data: {
'activeProgram': {
'id': 'test_pui_id',
'weeklySchedule': [
{
'id': 'scheduled_workout_id',
'dayOfWeek': 1,
'status': 'SCHEDULED',
'workoutTemplateName': 'Push Day',
}
]
}
},
source: QueryResultSource.network,
options: QueryOptions(document: gql('')),
));

// Mock workout start
when(mockClient.mutate<StartScheduledWorkout>(any)).thenAnswer((_) async =>
QueryResult(
data: {
'startScheduledWorkout': {
'id': 'workout_history_id',
'name': 'Push Day - Week 1',
'status': 'IN_PROGRESS',
}
},
source: QueryResultSource.network,
options: QueryOptions(document: gql('')),
));

// Act: Start and complete workout
await tester.pumpWidget(testApp);
await tester.tap(find.text('Start Workout'));
await tester.pumpAndSettle();

// Navigate to completion
await tester.tap(find.text('Complete Workout'));
await tester.pumpAndSettle();

// Assert: Workout completed successfully
expect(find.text('Workout Completed!'), findsOneWidget);
});
});
}

Unit Tests

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

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

test('enrollInProgram creates active program instance', () async {
// Arrange
when(mockClient.mutate<EnrollInProgram>(any)).thenAnswer((_) async =>
QueryResult(
data: {
'enrollInProgram': {
'id': 'test_pui_id',
'status': 'ACTIVE',
'currentPlanWeekNumber': 1,
}
},
source: QueryResultSource.network,
options: QueryOptions(document: gql('')),
));

// Act
await provider.enrollInProgram('program_id', [DayOfWeek.monday, DayOfWeek.wednesday, DayOfWeek.friday]);

// Assert
expect(provider.activeProgram, isNotNull);
expect(provider.activeProgram!.status, equals(UserProgramInstanceStatus.active));
expect(provider.activeProgram!.currentPlanWeekNumber, equals(1));
});

test('loadWeeklySchedule caches schedule data', () async {
// Arrange
when(mockClient.query<WeeklySchedule>(any)).thenAnswer((_) async =>
QueryResult(
data: {
'activeProgram': {
'weeklySchedule': [
{
'id': 'workout_1',
'dayOfWeek': 1,
'status': 'SCHEDULED',
},
{
'id': 'workout_2',
'dayOfWeek': 3,
'status': 'SCHEDULED',
}
]
}
},
source: QueryResultSource.network,
options: QueryOptions(document: gql('')),
));

provider.activeProgram = ProgramUserInstance(id: 'test_pui_id');

// Act
final schedule = await provider.loadWeeklySchedule(2023, 49);

// Assert
expect(schedule.length, equals(2));
expect(provider.getWeeklySchedule(2023, 49).length, equals(2));
});
});
}

Business Logic Summary

The Program User Instance API enables comprehensive user program management with:

⚠️ IMPLEMENTATION BOUNDARY

All scheduling logic, progression rules, and workout validation live server-side only. Flutter must NEVER implement program business logic locally.

Server-Side Intelligence:

  • Automatic Scheduling: Generates weekly workout schedules based on program type and user preferences
  • Progression Logic: Advances users through program weeks and workout sequences
  • Catch-up Management: Automatically handles missed workout recovery
  • Exercise Customization: Manages exercise-specific playbook assignments
  • State Validation: Enforces program status transitions and workflow rules

Client Responsibilities:

  • Display program enrollment forms and user preferences
  • Show weekly schedules and workout calendars
  • Handle workout start/completion user interactions
  • Present program progress and status updates
  • Cache program data for offline viewing