21 KiB
POWR Workout Data Flow Specification
Overview
This document outlines the complete data flow for the workout feature, from initialization through completion and storage. The design prioritizes data integrity, performance, and future extensibility with Nostr integration.
Data Flow Diagram
flowchart TD
subgraph Initialization
A[Template Selection] -->|Load Template| B[Template-to-Workout Transformation]
C[Quick Start] -->|Create Empty| B
B -->|Initialize| D[Workout Context]
end
subgraph Active Tracking
D -->|Current State| E[UI Components]
E -->|User Input| F[Dispatch Actions]
F -->|State Updates| D
D -->|Timer Events| G[Auto-save]
end
subgraph Persistence
G -->|Incremental Writes| H[(SQLite)]
I[Workout Completion] -->|Final Write| H
J[Manual Save] -->|Checkpoint| H
end
subgraph Analysis
H -->|Load History| K[PR Detection]
H -->|Aggregate Data| L[Statistics Calculation]
K --> M[Achievements]
L --> M
end
subgraph Sync
H -->|Format Events| N[Nostr Event Creation]
N -->|Publish| O[Relays]
O -->|Subscribe| P[Other Devices]
end
Data Transformation Stages
1. Template to Workout Conversion
interface WorkoutTemplateToWorkoutParams {
template: Template;
workoutSettings?: {
skipExercises?: string[];
addExercises?: WorkoutExercise[];
adjustRestTimes?: boolean;
scaleWeights?: number; // Percentage multiplier
};
}
function convertTemplateToWorkout(
params: WorkoutTemplateToWorkoutParams
): Workout {
// 1. Deep clone template structure
// 2. Apply user customizations
// 3. Initialize tracking metadata
// 4. Generate unique IDs
// 5. Add timestamps
}
Key operations:
- Exercise copies maintain reference to source exercise
- Sets are initialized with default values from template
- Additional metadata fields added for tracking
- Timestamps initialized
- IDs generated for all entities
2. Workout State Management
The central reducer handles all state transitions and ensures data consistency:
function workoutReducer(
state: WorkoutState,
action: WorkoutAction
): WorkoutState {
switch (action.type) {
case 'START_WORKOUT':
return {
...state,
status: 'active',
activeWorkout: action.payload,
startTime: Date.now(),
elapsedTime: 0,
};
case 'UPDATE_SET':
const { exerciseIndex, setIndex, data } = action.payload;
const updatedExercises = [...state.activeWorkout.exercises];
const updatedSets = [...updatedExercises[exerciseIndex].sets];
updatedSets[setIndex] = {
...updatedSets[setIndex],
...data,
lastUpdated: Date.now(),
};
updatedExercises[exerciseIndex] = {
...updatedExercises[exerciseIndex],
sets: updatedSets,
};
return {
...state,
activeWorkout: {
...state.activeWorkout,
exercises: updatedExercises,
lastUpdated: Date.now(),
},
needsSave: true,
};
// Additional cases for all actions...
}
}
3. Persistence Layer
Data is saved incrementally with different strategies:
class WorkoutPersistence {
// Save entire workout
async saveWorkout(workout: Workout): Promise<void> {
return this.db.withTransactionAsync(async () => {
// 1. Save workout metadata
// 2. Save all exercises
// 3. Save all sets
// 4. Update related statistics
});
}
// Save only modified data
async saveIncrementalChanges(workout: Workout): Promise<void> {
const dirtyExercises = workout.exercises.filter(e => e.isDirty);
return this.db.withTransactionAsync(async () => {
// Only update changed exercises and sets
for (const exercise of dirtyExercises) {
// Update exercise
// Update dirty sets
exercise.isDirty = false;
for (const set of exercise.sets) {
set.isDirty = false;
}
}
});
}
}
Save triggers:
- Auto-save: Every 30 seconds during active workout
- Exercise change: When navigating between exercises
- Pause: When workout is paused
- Completion: Final save with additional metadata
- Manual save: User-triggered save
- App background: When app moves to background
4. Workout Completion Processing
async function processWorkoutCompletion(workout: Workout): Promise<WorkoutSummary> {
// 1. Mark workout as completed
const completedWorkout = {
...workout,
isCompleted: true,
endTime: Date.now(),
};
// 2. Calculate final statistics
const stats = calculateWorkoutStatistics(completedWorkout);
// 3. Detect personal records
const personalRecords = detectPersonalRecords(completedWorkout);
// 4. Save everything to database
await workoutPersistence.saveCompletedWorkout(
completedWorkout,
stats,
personalRecords
);
// 5. Return summary data
return {
workout: completedWorkout,
statistics: stats,
achievements: {
personalRecords,
streaks: detectStreaks(completedWorkout),
milestones: detectMilestones(completedWorkout),
},
};
}
5. Nostr Event Creation
function createNostrWorkoutEvent(workout: CompletedWorkout): NostrEvent {
return {
kind: 33403, // Workout Record
content: workout.notes || '',
tags: [
['d', workout.id],
['title', workout.title],
['type', workout.type],
['start', workout.startTime.toString()],
['end', workout.endTime.toString()],
['completed', workout.isCompleted.toString()],
// Exercise data
...workout.exercises.flatMap(exercise => {
const exerciseRef = `33401:${exercise.author || 'local'}:${exercise.sourceId}`;
return exercise.sets.map(set => [
'exercise',
exerciseRef,
set.weight?.toString() || '',
set.reps?.toString() || '',
set.rpe?.toString() || '',
set.type,
]);
}),
// PR tags if applicable
...workout.personalRecords.map(pr => [
'pr',
`${pr.exerciseId},${pr.metric},${pr.value}`
]),
// Categorization tags
...workout.tags.map(tag => ['t', tag])
],
created_at: Math.floor(workout.endTime / 1000),
};
}
Data Structures
Workout State
interface WorkoutState {
status: 'idle' | 'active' | 'paused' | 'completed';
activeWorkout: Workout | null;
currentExerciseIndex: number;
currentSetIndex: number;
startTime: number | null;
endTime: number | null;
elapsedTime: number;
restTimer: {
isActive: boolean;
duration: number;
remaining: number;
exerciseId?: string;
setIndex?: number;
};
needsSave: boolean;
lastSaved: number | null;
}
Active Workout
interface Workout {
id: string;
title: string;
type: WorkoutType;
startTime: number;
endTime?: number;
isCompleted: boolean;
templateId?: string;
exercises: WorkoutExercise[];
notes?: string;
tags: string[];
lastUpdated: number;
}
interface WorkoutExercise {
id: string;
sourceId: string; // Reference to exercise definition
title: string;
sets: WorkoutSet[];
notes?: string;
isDirty: boolean;
isCompleted: boolean;
order: number;
restTime?: number;
}
interface WorkoutSet {
id: string;
setNumber: number;
type: SetType;
weight?: number;
reps?: number;
rpe?: number;
isCompleted: boolean;
isDirty: boolean;
timestamp?: number;
notes?: string;
}
Workout Summary
interface WorkoutSummary {
id: string;
title: string;
type: WorkoutType;
duration: number; // In milliseconds
startTime: number;
endTime: number;
exerciseCount: number;
completedExercises: number;
totalVolume: number;
totalReps: number;
averageRpe?: number;
exerciseSummaries: ExerciseSummary[];
personalRecords: PersonalRecord[];
}
interface ExerciseSummary {
exerciseId: string;
title: string;
setCount: number;
completedSets: number;
volume: number;
peakWeight?: number;
totalReps: number;
averageRpe?: number;
}
SQLite Schema Integration
Building on the existing schema, these additional tables and relationships will be needed:
-- Workout-specific schema extensions
-- Active workout tracking
CREATE TABLE IF NOT EXISTS active_workouts (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
type TEXT NOT NULL,
start_time INTEGER NOT NULL,
last_updated INTEGER NOT NULL,
template_id TEXT,
metadata TEXT, -- JSON blob of additional data
FOREIGN KEY(template_id) REFERENCES templates(id)
);
-- Completed workouts
CREATE TABLE IF NOT EXISTS completed_workouts (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
type TEXT NOT NULL,
start_time INTEGER NOT NULL,
end_time INTEGER NOT NULL,
duration INTEGER NOT NULL, -- In milliseconds
total_volume REAL,
total_reps INTEGER,
average_rpe REAL,
notes TEXT,
template_id TEXT,
nostr_event_id TEXT,
FOREIGN KEY(template_id) REFERENCES templates(id)
);
-- Individual workout exercises
CREATE TABLE IF NOT EXISTS workout_exercises (
id TEXT PRIMARY KEY,
workout_id TEXT NOT NULL,
exercise_id TEXT NOT NULL,
position INTEGER NOT NULL,
is_completed BOOLEAN DEFAULT 0,
notes TEXT,
rest_time INTEGER, -- In seconds
FOREIGN KEY(workout_id) REFERENCES active_workouts(id) ON DELETE CASCADE,
FOREIGN KEY(exercise_id) REFERENCES exercises(id)
);
-- Set data
CREATE TABLE IF NOT EXISTS workout_sets (
id TEXT PRIMARY KEY,
workout_exercise_id TEXT NOT NULL,
set_number INTEGER NOT NULL,
weight REAL,
reps INTEGER,
rpe REAL,
completed BOOLEAN DEFAULT 0,
set_type TEXT NOT NULL,
timestamp INTEGER,
notes TEXT,
FOREIGN KEY(workout_exercise_id) REFERENCES workout_exercises(id) ON DELETE CASCADE
);
-- Personal records
CREATE TABLE IF NOT EXISTS personal_records (
id TEXT PRIMARY KEY,
exercise_id TEXT NOT NULL,
metric TEXT NOT NULL, -- 'weight', 'reps', 'volume', etc.
value REAL NOT NULL,
workout_id TEXT NOT NULL,
achieved_at INTEGER NOT NULL,
FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE,
FOREIGN KEY(workout_id) REFERENCES completed_workouts(id)
);
-- Workout tags
CREATE TABLE IF NOT EXISTS workout_tags (
workout_id TEXT NOT NULL,
tag TEXT NOT NULL,
FOREIGN KEY(workout_id) REFERENCES completed_workouts(id) ON DELETE CASCADE,
PRIMARY KEY(workout_id, tag)
);
-- Workout statistics
CREATE TABLE IF NOT EXISTS workout_statistics (
workout_id TEXT PRIMARY KEY,
stats_json TEXT NOT NULL, -- Flexible JSON storage for various metrics
calculated_at INTEGER NOT NULL,
FOREIGN KEY(workout_id) REFERENCES completed_workouts(id) ON DELETE CASCADE
);
Optimization Strategies
1. Batch Processing
For performance-critical operations, batch updates are used:
// Instead of individual operations
async function saveSetsIndividually(sets: WorkoutSet[]) {
for (const set of sets) {
await db.runAsync(
'UPDATE workout_sets SET weight = ?, reps = ?, completed = ? WHERE id = ?',
[set.weight, set.reps, set.isCompleted, set.id]
);
}
}
// Use batch operations
async function saveSetsInBatch(sets: WorkoutSet[]) {
if (sets.length === 0) return;
const placeholders = sets.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(', ');
const values = sets.flatMap(set => [
set.id,
set.workout_exercise_id,
set.set_number,
set.weight || null,
set.reps || null,
set.rpe || null,
set.isCompleted ? 1 : 0
]);
await db.runAsync(`
INSERT OR REPLACE INTO workout_sets
(id, workout_exercise_id, set_number, weight, reps, rpe, completed)
VALUES ${placeholders}
`, values);
}
2. Dirty Tracking
Optimize saves by only updating changed data:
function markDirty(entity: { isDirty?: boolean, lastUpdated?: number }) {
entity.isDirty = true;
entity.lastUpdated = Date.now();
}
// In reducer
case 'UPDATE_SET': {
const { exerciseIndex, setIndex, data } = action.payload;
const updatedExercises = [...state.activeWorkout.exercises];
const updatedSets = [...updatedExercises[exerciseIndex].sets];
// Only mark as dirty if actually changed
const currentSet = updatedSets[setIndex];
const hasChanged = Object.entries(data).some(
([key, value]) => currentSet[key] !== value
);
if (hasChanged) {
updatedSets[setIndex] = {
...updatedSets[setIndex],
...data,
isDirty: true,
lastUpdated: Date.now(),
};
updatedExercises[exerciseIndex] = {
...updatedExercises[exerciseIndex],
sets: updatedSets,
isDirty: true,
lastUpdated: Date.now(),
};
}
return {
...state,
activeWorkout: {
...state.activeWorkout,
exercises: updatedExercises,
lastUpdated: hasChanged ? Date.now() : state.activeWorkout.lastUpdated,
},
needsSave: hasChanged,
};
}
3. Incremental Auto-save
function useAutoSave(
workout: Workout | null,
needsSave: boolean,
saveWorkout: (workout: Workout) => Promise<void>
) {
const [lastSaveTime, setLastSaveTime] = useState<number | null>(null);
const saveIntervalRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (!workout) return;
// Set up interval for periodic saves
saveIntervalRef.current = setInterval(() => {
if (workout && needsSave) {
saveWorkout(workout)
.then(() => setLastSaveTime(Date.now()))
.catch(err => console.error('Auto-save failed:', err));
}
}, 30000); // 30 seconds
return () => {
if (saveIntervalRef.current) {
clearInterval(saveIntervalRef.current);
}
};
}, [workout, needsSave, saveWorkout]);
// Additional save on app state changes
useAppState(
(nextAppState) => {
if (nextAppState === 'background' && workout && needsSave) {
saveWorkout(workout)
.then(() => setLastSaveTime(Date.now()))
.catch(err => console.error('Background save failed:', err));
}
}
);
return lastSaveTime;
}
Error Handling and Recovery
1. Save Failure Recovery
async function saveWithRetry(
workout: Workout,
maxRetries = 3
): Promise<boolean> {
let attempts = 0;
while (attempts < maxRetries) {
try {
await workoutPersistence.saveWorkout(workout);
return true;
} catch (error) {
attempts++;
console.error(`Save failed (attempt ${attempts}):`, error);
if (attempts >= maxRetries) {
// Create emergency backup
await createEmergencyBackup(workout);
notifyUser('Workout save failed. Emergency backup created.');
return false;
}
// Exponential backoff
await new Promise(resolve =>
setTimeout(resolve, 1000 * Math.pow(2, attempts))
);
}
}
return false;
}
async function createEmergencyBackup(workout: Workout): Promise<void> {
try {
const backupJson = JSON.stringify(workout);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `workout-backup-${timestamp}.json`;
await FileSystem.writeAsStringAsync(
`${FileSystem.documentDirectory}backups/${filename}`,
backupJson
);
} catch (e) {
console.error('Emergency backup failed:', e);
}
}
2. Crash Recovery
async function checkForUnfinishedWorkouts(): Promise<Workout | null> {
try {
const activeWorkouts = await db.getAllAsync<ActiveWorkoutRow>(
'SELECT * FROM active_workouts WHERE end_time IS NULL'
);
if (activeWorkouts.length === 0) return null;
// Find most recent active workout
const mostRecent = activeWorkouts.reduce((latest, current) =>
current.last_updated > latest.last_updated ? current : latest
);
// Reconstruct full workout object
return reconstructWorkoutFromDatabase(mostRecent.id);
} catch (error) {
console.error('Error checking for unfinished workouts:', error);
return null;
}
}
function useWorkoutRecovery() {
const [recoveryWorkout, setRecoveryWorkout] = useState<Workout | null>(null);
const [showRecoveryDialog, setShowRecoveryDialog] = useState(false);
useEffect(() => {
const checkRecovery = async () => {
const unfinishedWorkout = await checkForUnfinishedWorkouts();
if (unfinishedWorkout) {
setRecoveryWorkout(unfinishedWorkout);
setShowRecoveryDialog(true);
}
};
checkRecovery();
}, []);
const handleRecovery = (shouldRecover: boolean) => {
if (shouldRecover && recoveryWorkout) {
// Resume workout
dispatch({
type: 'RECOVER_WORKOUT',
payload: recoveryWorkout
});
} else if (recoveryWorkout) {
// Discard unfinished workout
workoutPersistence.discardWorkout(recoveryWorkout.id);
}
setShowRecoveryDialog(false);
};
return {
showRecoveryDialog,
recoveryWorkout,
handleRecovery
};
}
Nostr Integration
1. Event Publishing
async function publishWorkoutToNostr(workout: CompletedWorkout): Promise<string> {
try {
// Convert to Nostr event format
const event = createNostrWorkoutEvent(workout);
// Sign event
const signedEvent = await ndk.signer.sign(event);
// Publish to relays
await ndk.publish(signedEvent);
// Update local record with event ID
await db.runAsync(
'UPDATE completed_workouts SET nostr_event_id = ? WHERE id = ?',
[signedEvent.id, workout.id]
);
return signedEvent.id;
} catch (error) {
console.error('Failed to publish workout to Nostr:', error);
throw error;
}
}
2. Subscription Integration
function subscribeToWorkoutEvents() {
// Subscribe to workout events from followed users
const filter = {
kinds: [33401, 33402, 33403],
authors: followedPubkeys,
since: lastSyncTimestamp
};
const subscription = ndk.subscribe(filter);
subscription.on('event', (event) => {
try {
processIncomingNostrEvent(event);
} catch (error) {
console.error('Error processing incoming event:', error);
}
});
return subscription;
}
async function processIncomingNostrEvent(event: NostrEvent) {
switch (event.kind) {
case 33401: // Exercise definition
await processExerciseDefinition(event);
break;
case 33402: // Workout template
await processWorkoutTemplate(event);
break;
case 33403: // Workout record
await processWorkoutRecord(event);
break;
}
}
Metrics and Analytics
interface WorkoutMetrics {
// Time metrics
totalDuration: number;
exerciseTime: number;
restTime: number;
averageSetDuration: number;
// Volume metrics
totalVolume: number;
volumeByExercise: Record<string, number>;
volumeByMuscleGroup: Record<string, number>;
// Intensity metrics
averageRpe: number;
peakRpe: number;
intensityDistribution: {
low: number; // Sets with RPE 1-4
medium: number; // Sets with RPE 5-7
high: number; // Sets with RPE 8-10
};
// Completion metrics
exerciseCompletionRate: number;
setCompletionRate: number;
plannedVsActualVolume: number;
}
function calculateWorkoutMetrics(workout: CompletedWorkout): WorkoutMetrics {
// Implementation of metric calculations
// ...
return metrics;
}
Data Flow Timeline
-
Workout Initialization (t=0)
- Template loaded or empty workout created
- Initial state populated
- Workout ID generated
- Database record created
-
Active Tracking (t=0 → completion)
- User inputs captured through reducers
- State updates trigger UI refreshes
- Dirty tracking flags changes
- Auto-save runs periodically
-
Exercise Transitions
- Current exercise state saved
- Next exercise loaded
- Progress indicators updated
-
Completion Processing
- Final state saving
- Statistics calculation
- PR detection
- Achievement unlocking
-
Post-Workout
- History update
- Nostr publishing (if enabled)
- Cleanup of temporary data
Integration with Existing Architecture
The workout data flow integrates with existing systems:
- Library System - Templates loaded from library
- User Profiles - PRs and achievements tied to user
- Social Features - Workout sharing via Nostr
- History Tab - Completed workouts appear in history
- Exercise Database - Exercise references maintained
Future Extensibility
This design supports future enhancements:
- AI Recommendations - Data structured for ML analysis
- External Device Integration - Schema allows for sensor data
- Advanced Periodization - Tracking supports long-term planning
- Video Analysis - Form tracking integration points
- Multi-user Workouts - Shared workout capabilities