mirror of
https://github.com/DocNR/POWR.git
synced 2025-04-23 01:01:27 +00:00
848 lines
21 KiB
Markdown
848 lines
21 KiB
Markdown
![]() |
# 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
|
||
|
|
||
|
```mermaid
|
||
|
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
|
||
|
|
||
|
```typescript
|
||
|
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:
|
||
|
|
||
|
```typescript
|
||
|
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:
|
||
|
|
||
|
```typescript
|
||
|
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:
|
||
|
1. **Auto-save**: Every 30 seconds during active workout
|
||
|
2. **Exercise change**: When navigating between exercises
|
||
|
3. **Pause**: When workout is paused
|
||
|
4. **Completion**: Final save with additional metadata
|
||
|
5. **Manual save**: User-triggered save
|
||
|
6. **App background**: When app moves to background
|
||
|
|
||
|
### 4. Workout Completion Processing
|
||
|
|
||
|
```typescript
|
||
|
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
|
||
|
|
||
|
```typescript
|
||
|
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
|
||
|
|
||
|
```typescript
|
||
|
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
|
||
|
|
||
|
```typescript
|
||
|
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
|
||
|
|
||
|
```typescript
|
||
|
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:
|
||
|
|
||
|
```sql
|
||
|
-- 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:
|
||
|
|
||
|
```typescript
|
||
|
// 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:
|
||
|
|
||
|
```typescript
|
||
|
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
|
||
|
|
||
|
```typescript
|
||
|
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
|
||
|
|
||
|
```typescript
|
||
|
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
|
||
|
|
||
|
```typescript
|
||
|
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
|
||
|
|
||
|
```typescript
|
||
|
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
|
||
|
|
||
|
```typescript
|
||
|
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
|
||
|
|
||
|
```typescript
|
||
|
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
|
||
|
|
||
|
1. **Workout Initialization** (t=0)
|
||
|
- Template loaded or empty workout created
|
||
|
- Initial state populated
|
||
|
- Workout ID generated
|
||
|
- Database record created
|
||
|
|
||
|
2. **Active Tracking** (t=0 → completion)
|
||
|
- User inputs captured through reducers
|
||
|
- State updates trigger UI refreshes
|
||
|
- Dirty tracking flags changes
|
||
|
- Auto-save runs periodically
|
||
|
|
||
|
3. **Exercise Transitions**
|
||
|
- Current exercise state saved
|
||
|
- Next exercise loaded
|
||
|
- Progress indicators updated
|
||
|
|
||
|
4. **Completion Processing**
|
||
|
- Final state saving
|
||
|
- Statistics calculation
|
||
|
- PR detection
|
||
|
- Achievement unlocking
|
||
|
|
||
|
5. **Post-Workout**
|
||
|
- History update
|
||
|
- Nostr publishing (if enabled)
|
||
|
- Cleanup of temporary data
|
||
|
|
||
|
## Integration with Existing Architecture
|
||
|
|
||
|
The workout data flow integrates with existing systems:
|
||
|
|
||
|
1. **Library System** - Templates loaded from library
|
||
|
2. **User Profiles** - PRs and achievements tied to user
|
||
|
3. **Social Features** - Workout sharing via Nostr
|
||
|
4. **History Tab** - Completed workouts appear in history
|
||
|
5. **Exercise Database** - Exercise references maintained
|
||
|
|
||
|
## Future Extensibility
|
||
|
|
||
|
This design supports future enhancements:
|
||
|
|
||
|
1. **AI Recommendations** - Data structured for ML analysis
|
||
|
2. **External Device Integration** - Schema allows for sensor data
|
||
|
3. **Advanced Periodization** - Tracking supports long-term planning
|
||
|
4. **Video Analysis** - Form tracking integration points
|
||
|
5. **Multi-user Workouts** - Shared workout capabilities
|