POWR Pack feature WIP - basic functionality, need to associate exercise events with workout template events.

This commit is contained in:
DocNR 2025-03-13 14:02:36 -04:00
parent ea5dde32f4
commit a3e9dc36d8
20 changed files with 3067 additions and 148 deletions

View File

@ -5,6 +5,37 @@ All notable changes to the POWR project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
# Changelog - March 12, 2025
## Added
- POWR Packs - Shareable template and exercise collections
- Implemented import/export system for workout content using Nostr protocol
- Added database schema support for packs (tables: powr_packs, powr_pack_items)
- Created POWRPackService for fetching, importing, and managing packs
- Built NostrIntegration helper for conversion between Nostr events and local models
- Implemented interface to browse and import workout packs from the community
- Added pack management screen with import/delete functionality
- Created pack discovery in POWR Community tab
- Added dependency tracking for exercises required by templates
- Implemented selective import with smart dependency management
- Added clipboard support for sharing pack addresses
## Improved
- Enhanced Social experience
- Added POWR Pack discovery to POWR Community tab
- Implemented horizontal scrolling gallery for featured packs
- Added loading states with skeleton UI
- Improved visual presentation of shared content
- Settings drawer enhancements
- Added POWR Packs management option
- Improved navigation structure
- Nostr integration
- Added support for NIP-51 lists (kind 30004)
- Enhanced compatibility between app models and Nostr events
- Improved type safety for Nostr operations
- Better error handling for network operations
- Expanded event type support for templates and exercises
# Changelog - March 9, 2025
## Added

34
app/(packs)/_layout.tsx Normal file
View File

@ -0,0 +1,34 @@
// app/(packs)/_layout.tsx
import { Stack } from 'expo-router';
import { useColorScheme } from '@/lib/theme/useColorScheme';
export default function PacksLayout() {
const { isDarkColorScheme } = useColorScheme();
return (
<Stack
screenOptions={{
headerStyle: {
backgroundColor: isDarkColorScheme ? '#18181b' : '#ffffff',
},
headerTintColor: isDarkColorScheme ? '#ffffff' : '#18181b',
headerShadowVisible: false,
headerBackTitle: 'Back',
}}
>
<Stack.Screen
name="import"
options={{
presentation: 'modal',
title: 'Import POWR Pack',
}}
/>
<Stack.Screen
name="manage"
options={{
title: 'Manage POWR Packs',
}}
/>
</Stack>
);
}

413
app/(packs)/import.tsx Normal file
View File

@ -0,0 +1,413 @@
// app/(packs)/import.tsx
import React, { useState, useEffect } from 'react';
import { View, ScrollView, StyleSheet, ActivityIndicator, Platform } from 'react-native';
import { router, Stack } from 'expo-router';
import { useNDK } from '@/lib/hooks/useNDK';
import { Text } from '@/components/ui/text';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { nip19 } from 'nostr-tools'; // Fix import from nostr-tools
import { findTagValue } from '@/utils/nostr-utils';
import POWRPackService from '@/lib/db/services/POWRPackService';
import { usePOWRPackService } from '@/components/DatabaseProvider'; // Use the proper hook
import { POWRPackImport, POWRPackSelection } from '@/types/powr-pack';
import { InfoIcon } from 'lucide-react-native';
export default function ImportPOWRPackScreen() {
const { ndk } = useNDK();
const powrPackService = usePOWRPackService();
const [naddrInput, setNaddrInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [packData, setPackData] = useState<POWRPackImport | null>(null);
const [selectedTemplates, setSelectedTemplates] = useState<string[]>([]);
const [selectedExercises, setSelectedExercises] = useState<string[]>([]);
const [dependencies, setDependencies] = useState<Record<string, string[]>>({});
const [isImporting, setIsImporting] = useState(false);
const [importSuccess, setImportSuccess] = useState(false);
// Handle fetch button click
const handleFetchPack = async () => {
if (!naddrInput.trim()) {
setError('Please enter a valid naddr');
return;
}
if (!ndk) {
setError('NDK is not initialized');
return;
}
setIsLoading(true);
setError(null);
setPackData(null);
setSelectedTemplates([]);
setSelectedExercises([]);
setDependencies({});
try {
// Validate naddr format
const isValid = naddrInput.startsWith('naddr1');
if (!isValid) {
throw new Error('Invalid naddr format. Should start with "naddr1"');
}
// Fetch pack data
const packImport = await powrPackService.fetchPackFromNaddr(naddrInput, ndk);
// Debug logging
console.log("Fetched pack event:", packImport.packEvent.id);
console.log("Templates count:", packImport.templates.length);
console.log("Exercises count:", packImport.exercises.length);
setPackData(packImport);
// Analyze dependencies
const deps = powrPackService.analyzeDependencies(packImport.templates, packImport.exercises);
setDependencies(deps);
// Pre-select all items
setSelectedTemplates(packImport.templates.map(t => t.id));
setSelectedExercises(packImport.exercises.map(e => e.id));
} catch (err) {
console.error('Error fetching pack:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch pack');
} finally {
setIsLoading(false);
}
};
// Handle template selection change
const handleTemplateChange = (templateId: string, isSelected: boolean) => {
setSelectedTemplates(prev => {
const updated = isSelected
? [...prev, templateId]
: prev.filter(id => id !== templateId);
// Update required exercises
updateRequiredExercises(updated);
return updated;
});
};
// Handle exercise selection change
const handleExerciseChange = (exerciseId: string, isSelected: boolean) => {
// Don't allow deselecting if it's required by a selected template
if (!isSelected && isRequiredByTemplate(exerciseId)) {
return;
}
setSelectedExercises(prev =>
isSelected
? [...prev, exerciseId]
: prev.filter(id => id !== exerciseId)
);
};
// Check if an exercise is required by any selected template
const isRequiredByTemplate = (exerciseId: string): boolean => {
return selectedTemplates.some(templateId =>
dependencies[templateId]?.includes(exerciseId)
);
};
// Update exercise selection based on template dependencies
const updateRequiredExercises = (selectedTemplateIds: string[]) => {
// Start with currently manually selected exercises
const manuallySelected = selectedExercises.filter(id =>
!Object.values(dependencies).flat().includes(id)
);
// Add all exercises required by selected templates
const requiredExercises = selectedTemplateIds.flatMap(templateId =>
dependencies[templateId] || []
);
// Combine manual selections with required ones, removing duplicates
const allExercises = [...new Set([...manuallySelected, ...requiredExercises])];
setSelectedExercises(allExercises);
};
// Handle import button click
const handleImport = async () => {
if (!packData) return;
setIsImporting(true);
setError(null);
try {
const packId = generatePackId();
const selection: POWRPackSelection = {
packId,
selectedTemplates,
selectedExercises,
templateDependencies: dependencies
};
await powrPackService.importPack(packData, selection);
setImportSuccess(true);
// Navigate back after a short delay
setTimeout(() => {
router.back();
}, 2000);
} catch (err) {
console.error('Error importing pack:', err);
setError(err instanceof Error ? err.message : 'Failed to import pack');
} finally {
setIsImporting(false);
}
};
// Generate a unique pack ID
const generatePackId = (): string => {
return 'pack_' + Date.now().toString(36) + Math.random().toString(36).substr(2);
};
// Get pack title from event
const getPackTitle = (): string => {
if (!packData?.packEvent) return 'Unknown Pack';
return findTagValue(packData.packEvent.tags, 'title') || 'Unnamed Pack';
};
// Get pack description from event
const getPackDescription = (): string => {
if (!packData?.packEvent) return '';
return findTagValue(packData.packEvent.tags, 'description') || packData.packEvent.content || '';
};
return (
<View style={styles.container}>
<Stack.Screen
options={{
title: 'Import POWR Pack',
headerShown: true,
}}
/>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
{/* Input section */}
<Card>
<CardHeader>
<CardTitle>
<Text className="text-xl font-semibold">Enter POWR Pack Address</Text>
</CardTitle>
<CardDescription>
<Text className="text-muted-foreground">Paste a POWR Pack naddr to import</Text>
</CardDescription>
</CardHeader>
<CardContent>
{/* Helper text explaining naddr format */}
<View className="mb-4">
<Text className="text-sm text-muted-foreground">
Paste a POWR Pack address (naddr1...) to import templates and exercises shared by the community.
</Text>
</View>
<Input
placeholder="naddr1..."
value={naddrInput}
onChangeText={setNaddrInput}
style={styles.input}
/>
</CardContent>
<CardFooter>
<Button
onPress={handleFetchPack}
disabled={isLoading || !naddrInput.trim()}
className="w-full"
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text className="text-primary-foreground">Fetch Pack</Text>
)}
</Button>
</CardFooter>
</Card>
{/* Error message */}
{error && (
<View className="mb-4 mt-4 p-4 bg-destructive/10 border border-destructive rounded-md flex-row items-center">
<Text className="text-destructive ml-2">{error}</Text>
</View>
)}
{/* Success message */}
{importSuccess && (
<View className="mb-4 mt-4 p-4 bg-green-50 border border-green-200 rounded-md flex-row items-center">
<Text className="ml-2 text-green-800">Pack successfully imported!</Text>
</View>
)}
{/* Pack content */}
{packData && (
<View style={styles.packContent}>
<Card className="mb-4">
<CardHeader>
<CardTitle>
<Text className="text-xl font-semibold">{getPackTitle()}</Text>
</CardTitle>
{getPackDescription() ? (
<CardDescription>
<Text className="text-muted-foreground">{getPackDescription()}</Text>
</CardDescription>
) : null}
</CardHeader>
<CardContent>
<Text className="mb-2">Select items to import:</Text>
</CardContent>
</Card>
{/* Templates section */}
{packData.templates && packData.templates.length > 0 ? (
<Card className="mb-4">
<CardHeader>
<CardTitle>
<Text className="text-lg font-semibold">Workout Templates</Text>
</CardTitle>
<CardDescription>
<Text className="text-muted-foreground">{packData.templates.length} templates available</Text>
</CardDescription>
</CardHeader>
<CardContent>
{packData.templates.map(template => {
const title = findTagValue(template.tags, 'title') || 'Unnamed Template';
return (
<View key={template.id} style={styles.itemRow}>
<Checkbox
checked={selectedTemplates.includes(template.id)}
onCheckedChange={(checked) =>
handleTemplateChange(template.id, checked === true)
}
id={`template-${template.id}`}
/>
<Text className="ml-2 flex-1" onPress={() =>
handleTemplateChange(template.id, !selectedTemplates.includes(template.id))
}>
{title}
</Text>
</View>
);
})}
</CardContent>
</Card>
) : (
<Card className="mb-4">
<CardContent>
<Text className="text-center text-muted-foreground py-4">No templates available in this pack</Text>
</CardContent>
</Card>
)}
{/* Exercises section */}
{packData.exercises && packData.exercises.length > 0 ? (
<Card className="mb-4">
<CardHeader>
<CardTitle>
<Text className="text-lg font-semibold">Exercises</Text>
</CardTitle>
<CardDescription>
<Text className="text-muted-foreground">{packData.exercises.length} exercises available</Text>
</CardDescription>
</CardHeader>
<CardContent>
{packData.exercises.map(exercise => {
const title = findTagValue(exercise.tags, 'title') || 'Unnamed Exercise';
const isRequired = isRequiredByTemplate(exercise.id);
return (
<View key={exercise.id} style={styles.itemRow}>
<Checkbox
checked={selectedExercises.includes(exercise.id)}
onCheckedChange={(checked) =>
handleExerciseChange(exercise.id, checked === true)
}
disabled={isRequired}
id={`exercise-${exercise.id}`}
/>
<Text
className={`ml-2 flex-1 ${isRequired ? 'font-medium' : ''}`}
onPress={() => {
if (!isRequired) {
handleExerciseChange(exercise.id, !selectedExercises.includes(exercise.id))
}
}}
>
{title}
</Text>
{isRequired && (
<View style={styles.requiredBadge}>
<InfoIcon size={14} color="#6b7280" />
<Text className="text-xs text-gray-500 ml-1">Required</Text>
</View>
)}
</View>
);
})}
</CardContent>
</Card>
) : (
<Card className="mb-4">
<CardContent>
<Text className="text-center text-muted-foreground py-4">No exercises available in this pack</Text>
</CardContent>
</Card>
)}
{/* Import button */}
<Button
onPress={handleImport}
disabled={isImporting || (selectedTemplates.length === 0 && selectedExercises.length === 0)}
className="w-full"
>
{isImporting ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text className="text-primary-foreground">
Import {selectedTemplates.length + selectedExercises.length} Items
</Text>
)}
</Button>
</View>
)}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
},
scrollContent: {
paddingBottom: 80,
},
input: {
marginBottom: 16,
},
packContent: {
marginTop: 16,
},
itemRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: '#f0f0f0',
},
requiredBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#f9fafb',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 12,
marginLeft: 8,
}
});

239
app/(packs)/manage.tsx Normal file
View File

@ -0,0 +1,239 @@
// app/(packs)/manage.tsx
import React, { useState, useEffect } from 'react';
import { View, ScrollView, StyleSheet, TouchableOpacity } from 'react-native';
import { router, Stack } from 'expo-router';
import { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
import { POWRPackWithContent } from '@/types/powr-pack';
// Fix database context import
import { usePOWRPackService } from '@/components/DatabaseProvider';
import { formatDistanceToNow } from 'date-fns';
import { Trash2, PackageOpen, Plus } from 'lucide-react-native';
export default function ManagePOWRPacksScreen() {
const powrPackService = usePOWRPackService();
const [packs, setPacks] = useState<POWRPackWithContent[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedPackId, setSelectedPackId] = useState<string | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [keepItems, setKeepItems] = useState(true);
// Load imported packs
useEffect(() => {
loadPacks();
}, []);
// Function to load imported packs
const loadPacks = async () => {
setIsLoading(true);
try {
const importedPacks = await powrPackService.getImportedPacks();
setPacks(importedPacks);
} catch (error) {
console.error('Error loading packs:', error);
} finally {
setIsLoading(false);
}
};
// Handle import button click
const handleImport = () => {
router.push('/(packs)/import');
};
// Handle delete button click
const handleDeleteClick = (packId: string) => {
setSelectedPackId(packId);
setShowDeleteDialog(true);
};
// Handle delete confirmation
const handleDeleteConfirm = async () => {
if (!selectedPackId) return;
try {
await powrPackService.deletePack(selectedPackId, keepItems);
// Refresh the list
loadPacks();
} catch (error) {
console.error('Error deleting pack:', error);
} finally {
setShowDeleteDialog(false);
setSelectedPackId(null);
}
};
// Format import date
const formatImportDate = (timestamp: number): string => {
return formatDistanceToNow(new Date(timestamp), { addSuffix: true });
};
return (
<View style={styles.container}>
<Stack.Screen
options={{
title: 'Manage POWR Packs',
headerShown: true,
}}
/>
<ScrollView contentContainerStyle={styles.scrollContent}>
{/* Import button - fix icon usage */}
<Button
onPress={handleImport}
className="mb-4"
>
<Plus size={18} color="#fff" className="mr-2" />
<Text className="text-primary-foreground">Import New Pack</Text>
</Button>
{/* No packs message */}
{!isLoading && packs.length === 0 && (
<Card>
<CardContent className="py-8 items-center">
<PackageOpen size={48} color="#6b7280" />
<Text className="text-lg font-medium mt-4 text-center">No POWR Packs Imported</Text>
<Text className="text-center mt-2 text-gray-500">
Import workout packs shared by the community to get started.
</Text>
<Button
onPress={handleImport}
className="mt-4"
variant="outline"
>
<Text>Import Your First Pack</Text>
</Button>
</CardContent>
</Card>
)}
{/* Pack list */}
{packs.map((packWithContent) => {
const { pack, templates, exercises } = packWithContent;
return (
<Card key={pack.id} className="mb-4">
<CardHeader>
<View style={styles.cardHeaderContent}>
<View style={styles.cardHeaderText}>
<CardTitle>
<Text className="text-lg font-semibold">{pack.title}</Text>
</CardTitle>
{pack.description && (
<CardDescription>
<Text className="text-muted-foreground">{pack.description}</Text>
</CardDescription>
)}
</View>
<TouchableOpacity
onPress={() => handleDeleteClick(pack.id)}
style={styles.deleteButton}
>
<Trash2 size={20} color="#ef4444" />
</TouchableOpacity>
</View>
</CardHeader>
<CardContent>
<View style={styles.statsRow}>
<Text className="text-gray-500">
{templates.length} template{templates.length !== 1 ? 's' : ''} {exercises.length} exercise{exercises.length !== 1 ? 's' : ''}
</Text>
</View>
<Separator className="my-2" />
<Text className="text-sm text-gray-500">
Imported {formatImportDate(pack.importDate)}
</Text>
</CardContent>
</Card>
);
})}
</ScrollView>
{/* Delete confirmation dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Pack?</AlertDialogTitle>
<AlertDialogDescription>
<Text>
This will remove the POWR Pack from your library. Do you want to keep the imported exercises and templates?
</Text>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<View style={styles.dialogOptions}>
<Button
variant={keepItems ? "default" : "outline"}
onPress={() => setKeepItems(true)}
className="flex-1 mr-2"
>
<Text className={keepItems ? "text-primary-foreground" : ""}>
Keep Items
</Text>
</Button>
<Button
variant={!keepItems ? "default" : "outline"}
onPress={() => setKeepItems(false)}
className="flex-1"
>
<Text className={!keepItems ? "text-primary-foreground" : ""}>
Delete All
</Text>
</Button>
</View>
<View style={styles.dialogActions}>
<AlertDialogCancel asChild>
<Button variant="outline" className="mr-2">
<Text>Cancel</Text>
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button variant="destructive" onPress={handleDeleteConfirm}>
<Text className="text-destructive-foreground">Confirm</Text>
</Button>
</AlertDialogAction>
</View>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
},
scrollContent: {
paddingBottom: 80,
},
cardHeaderContent: {
flexDirection: 'row',
justifyContent: 'space-between',
width: '100%',
},
cardHeaderText: {
flex: 1,
},
deleteButton: {
padding: 8,
},
statsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
},
dialogOptions: {
flexDirection: 'row',
marginBottom: 16,
},
dialogActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
}
});

View File

@ -18,6 +18,7 @@ import {
toWorkoutTemplate
} from '@/types/templates';
import { useWorkoutStore } from '@/stores/workoutStore';
import { useTemplateService } from '@/components/DatabaseProvider';
// Default available filters
const availableFilters = {
@ -33,39 +34,11 @@ const initialFilters: FilterOptions = {
source: []
};
// Mock data - move to a separate file later
const initialTemplates: Template[] = [
{
id: '1',
title: 'Full Body Strength',
type: 'strength',
category: 'Full Body',
exercises: [
{ title: 'Barbell Squat', targetSets: 3, targetReps: 8 },
{ title: 'Bench Press', targetSets: 3, targetReps: 8 },
{ title: 'Bent Over Row', targetSets: 3, targetReps: 8 }
],
tags: ['strength', 'compound'],
source: 'local',
isFavorite: true
},
{
id: '2',
title: '20min EMOM',
type: 'emom',
category: 'Conditioning',
exercises: [
{ title: 'Kettlebell Swings', targetSets: 1, targetReps: 15 },
{ title: 'Push-ups', targetSets: 1, targetReps: 10 },
{ title: 'Air Squats', targetSets: 1, targetReps: 20 }
],
tags: ['conditioning', 'kettlebell'],
source: 'powr',
isFavorite: false
}
];
// Initial templates - empty array
const initialTemplates: Template[] = [];
export default function TemplatesScreen() {
const templateService = useTemplateService(); // Get the template service
const [showNewTemplate, setShowNewTemplate] = useState(false);
const [templates, setTemplates] = useState(initialTemplates);
const [searchQuery, setSearchQuery] = useState('');
@ -74,6 +47,7 @@ export default function TemplatesScreen() {
const [activeFilters, setActiveFilters] = useState(0);
const { isActive, isMinimized } = useWorkoutStore();
const shouldShowFAB = !isActive || !isMinimized;
const [debugInfo, setDebugInfo] = useState('');
// State for the modal template details
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
@ -87,9 +61,6 @@ export default function TemplatesScreen() {
// Just open the modal without navigating to a route
setSelectedTemplateId(template.id);
setShowTemplateModal(true);
// We're no longer using this:
// router.push(`/template/${template.id}`);
};
const handleStartWorkout = async (template: Template) => {
@ -97,8 +68,8 @@ export default function TemplatesScreen() {
// Convert to WorkoutTemplate format
const workoutTemplate = toWorkoutTemplate(template);
// Start the workout
await useWorkoutStore.getState().startWorkoutFromTemplate(template.id, workoutTemplate);
// Start the workout - use the template ID
await useWorkoutStore.getState().startWorkoutFromTemplate(template.id);
// Navigate to the active workout screen
router.push('/(workout)/create');
@ -157,13 +128,76 @@ export default function TemplatesScreen() {
);
};
const handleDebugDB = async () => {
try {
// Get all templates directly
const allTemplates = await templateService.getAllTemplates(100);
let info = "Database Stats:\n";
info += "--------------\n";
info += `Total templates: ${allTemplates.length}\n\n`;
// Template list
info += "Templates:\n";
allTemplates.forEach(template => {
info += `- ${template.title} (${template.id}) [${template.availability?.source[0] || 'unknown'}]\n`;
info += ` Exercises: ${template.exercises.length}\n`;
});
setDebugInfo(info);
console.log(info);
} catch (error) {
console.error('Debug error:', error);
setDebugInfo(`Error: ${String(error)}`);
}
};
// Load templates when the screen is focused
useFocusEffect(
React.useCallback(() => {
// Refresh template favorite status when tab gains focus
setTemplates(current => current.map(template => ({
...template,
isFavorite: useWorkoutStore.getState().checkFavoriteStatus(template.id)
})));
async function loadTemplates() {
try {
console.log('[TemplateScreen] Loading templates...');
// Load templates from the database
const data = await templateService.getAllTemplates(100);
console.log(`[TemplateScreen] Loaded ${data.length} templates from database`);
// Convert to Template[] format that the screen expects
const formattedTemplates: Template[] = [];
for (const template of data) {
// Get favorite status
const isFavorite = useWorkoutStore.getState().checkFavoriteStatus(template.id);
// Convert to Template format
formattedTemplates.push({
id: template.id,
title: template.title,
type: template.type,
category: template.category,
exercises: template.exercises.map(ex => ({
title: ex.exercise.title,
targetSets: ex.targetSets || 0,
targetReps: ex.targetReps || 0,
equipment: ex.exercise.equipment
})),
tags: template.tags || [],
source: template.availability?.source[0] || 'local',
isFavorite
});
}
// Update the templates state
setTemplates(formattedTemplates);
} catch (error) {
console.error('[TemplateScreen] Error loading templates:', error);
}
}
loadTemplates();
return () => {};
}, [])
);
@ -181,7 +215,7 @@ export default function TemplatesScreen() {
// Filter by equipment if any selected
const matchesEquipment = currentFilters.equipment.length === 0 ||
(template.exercises.some(ex =>
(template.exercises && template.exercises.some(ex =>
currentFilters.equipment.includes(ex.equipment || '')
));
@ -244,6 +278,26 @@ export default function TemplatesScreen() {
availableFilters={availableFilters}
/>
{/* Debug button */}
<View className="p-2">
<Button
variant="outline"
size="sm"
onPress={handleDebugDB}
>
<Text className="text-xs">Debug Templates</Text>
</Button>
</View>
{/* Debug info display */}
{debugInfo ? (
<View className="px-4 py-2 mx-4 mb-2 bg-primary/10 rounded-md">
<ScrollView style={{ maxHeight: 150 }}>
<Text className="font-mono text-xs">{debugInfo}</Text>
</ScrollView>
</View>
) : null}
{/* Templates list */}
<ScrollView className="flex-1">
{/* Favorites Section */}
@ -288,7 +342,7 @@ export default function TemplatesScreen() {
) : (
<View className="px-4">
<Text className="text-muted-foreground">
So empty! Create a new workout template by clicking the + button.
No templates found. {templates.length > 0 ? 'Try changing your filters.' : 'Create a new workout template by clicking the + button.'}
</Text>
</View>
)}

View File

@ -4,6 +4,7 @@ import { View, ScrollView, RefreshControl } from 'react-native';
import { Text } from '@/components/ui/text';
import SocialPost from '@/components/social/SocialPost';
import { Zap } from 'lucide-react-native';
import POWRPackSection from '@/components/social/POWRPackSection'; // Add this import
// Sample mock data for posts from POWR team/recommendations
const POWR_POSTS = [
@ -96,6 +97,9 @@ export default function PowerScreen() {
</Text>
</View>
{/* POWR Packs Section - Add this */}
<POWRPackSection />
{/* Posts */}
{posts.map(post => (
<SocialPost key={post.id} post={post} />

View File

@ -8,6 +8,7 @@ import { PublicationQueueService } from '@/lib/db/services/PublicationQueueServi
import { FavoritesService } from '@/lib/db/services/FavoritesService';
import { WorkoutService } from '@/lib/db/services/WorkoutService';
import { TemplateService } from '@/lib/db/services/TemplateService';
import POWRPackService from '@/lib/db/services/POWRPackService';
import { logDatabaseInfo } from '@/lib/db/debug';
import { useNDKStore } from '@/lib/stores/ndk';
@ -19,6 +20,7 @@ interface DatabaseServicesContextValue {
devSeeder: DevSeederService | null;
publicationQueue: PublicationQueueService | null;
favoritesService: FavoritesService | null;
powrPackService: POWRPackService | null;
db: SQLiteDatabase | null;
}
@ -29,6 +31,7 @@ const DatabaseServicesContext = React.createContext<DatabaseServicesContextValue
devSeeder: null,
publicationQueue: null,
favoritesService: null,
powrPackService: null,
db: null,
});
@ -72,6 +75,7 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) {
devSeeder: null,
publicationQueue: null,
favoritesService: null,
powrPackService: null,
db: null,
});
@ -111,6 +115,7 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) {
const devSeeder = new DevSeederService(db, exerciseService);
const publicationQueue = new PublicationQueueService(db);
const favoritesService = new FavoritesService(db);
const powrPackService = new POWRPackService(db);
// Initialize the favorites service
await favoritesService.initialize();
@ -129,6 +134,7 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) {
devSeeder,
publicationQueue,
favoritesService,
powrPackService,
db,
});
@ -233,6 +239,14 @@ export function useFavoritesService() {
return context.favoritesService;
}
export function usePOWRPackService() {
const context = React.useContext(DatabaseServicesContext);
if (!context.powrPackService) {
throw new Error('POWR Pack service not initialized');
}
return context.powrPackService;
}
export function useDatabase() {
const context = React.useContext(DatabaseServicesContext);
if (!context.db) {

View File

@ -7,7 +7,7 @@ import { useRouter } from 'expo-router';
import { useSettingsDrawer } from '@/lib/contexts/SettingsDrawerContext';
import {
Moon, Sun, LogOut, User, ChevronRight, X, Bell, HelpCircle,
Smartphone, Database, Zap, RefreshCw, AlertTriangle, Globe
Smartphone, Database, Zap, RefreshCw, AlertTriangle, Globe, PackageOpen
} from 'lucide-react-native';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
@ -182,6 +182,15 @@ export default function SettingsDrawer() {
label: 'Manage Relays',
onPress: handleRelayManagement,
},
{
id: 'powr-packs',
icon: PackageOpen,
label: 'POWR Packs',
onPress: () => {
closeDrawer();
router.push("/(packs)/manage");
},
},
{
id: 'device',
icon: Smartphone,

View File

@ -1,102 +0,0 @@
// components/exercises/SimplifiedExerciseCard.tsx
import React from 'react';
import { View, TouchableOpacity, Image } from 'react-native';
import { Text } from '@/components/ui/text';
import { Badge } from '@/components/ui/badge';
import { ExerciseDisplay } from '@/types/exercise';
interface SimplifiedExerciseCardProps {
exercise: ExerciseDisplay;
onPress: () => void;
}
export function SimplifiedExerciseCard({ exercise, onPress }: SimplifiedExerciseCardProps) {
const {
title,
category,
equipment,
type,
source,
} = exercise;
const firstLetter = title.charAt(0).toUpperCase();
// Helper to check if exercise has workout-specific properties
const isWorkoutExercise = 'sets' in exercise && Array.isArray((exercise as any).sets);
// Access sets safely if available
const workoutExercise = isWorkoutExercise ?
(exercise as ExerciseDisplay & { sets: Array<{weight?: number, reps?: number}> }) :
null;
return (
<TouchableOpacity
activeOpacity={0.7}
onPress={onPress}
className="flex-row items-center py-3 border-b border-border"
>
{/* Image placeholder or first letter */}
<View className="w-12 h-12 rounded-full bg-card flex items-center justify-center mr-3 overflow-hidden">
<Text className="text-2xl font-bold text-foreground">
{firstLetter}
</Text>
</View>
<View className="flex-1">
{/* Title */}
<Text className="text-base font-semibold text-foreground mb-1">
{title}
</Text>
{/* Tags row */}
<View className="flex-row flex-wrap gap-1">
{/* Category Badge */}
<Badge variant="outline" className="rounded-full py-0.5">
<Text className="text-xs">{category}</Text>
</Badge>
{/* Equipment Badge (if available) */}
{equipment && (
<Badge variant="outline" className="rounded-full py-0.5">
<Text className="text-xs">{equipment}</Text>
</Badge>
)}
{/* Type Badge */}
{type && (
<Badge variant="outline" className="rounded-full py-0.5">
<Text className="text-xs">{type}</Text>
</Badge>
)}
{/* Source Badge - colored for 'powr' */}
{source && (
<Badge
variant={source === 'powr' ? 'default' : 'secondary'}
className={`rounded-full py-0.5 ${
source === 'powr' ? 'bg-violet-500' : ''
}`}
>
<Text className={`text-xs ${
source === 'powr' ? 'text-white' : ''
}`}>
{source}
</Text>
</Badge>
)}
</View>
</View>
{/* Weight/Reps information if available from sets */}
{workoutExercise?.sets?.[0] && (
<View className="items-end">
<Text className="text-muted-foreground text-sm">
{workoutExercise.sets[0].weight && `${workoutExercise.sets[0].weight} lb`}
{workoutExercise.sets[0].weight && workoutExercise.sets[0].reps && ' '}
{workoutExercise.sets[0].reps && `(×${workoutExercise.sets[0].reps})`}
</Text>
</View>
)}
</TouchableOpacity>
);
}

View File

@ -0,0 +1,228 @@
// components/social/POWRPackSection.tsx
import React, { useState, useEffect } from 'react';
import { View, ScrollView, StyleSheet, TouchableOpacity, Image } from 'react-native';
import { router } from 'expo-router';
import { useNDK } from '@/lib/hooks/useNDK';
import { useSubscribe } from '@/lib/hooks/useSubscribe';
import { findTagValue } from '@/utils/nostr-utils';
import { Text } from '@/components/ui/text';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { PackageOpen, ArrowRight } from 'lucide-react-native';
import { NDKEvent } from '@nostr-dev-kit/ndk-mobile';
import { usePOWRPackService } from '@/components/DatabaseProvider';
export default function POWRPackSection() {
const { ndk } = useNDK();
const powrPackService = usePOWRPackService();
const [featuredPacks, setFeaturedPacks] = useState<NDKEvent[]>([]);
// Subscribe to POWR packs (kind 30004 with powrpack hashtag)
const { events, isLoading } = useSubscribe(
ndk ? [{ kinds: [30004], '#t': ['powrpack'], limit: 10 }] : false,
{ enabled: !!ndk }
);
// Update featured packs when events change
useEffect(() => {
if (events.length > 0) {
setFeaturedPacks(events);
}
}, [events]);
// Handle pack click
const handlePackClick = (packEvent: NDKEvent) => {
// Use the service from context
const naddr = powrPackService.createShareableNaddr(packEvent);
// Navigate to import screen
router.push('/(packs)/import');
// We could also implement copy to clipboard functionality here
// Clipboard.setString(naddr);
// Alert.alert('Pack address copied', 'Paste the address in the import screen to add this pack.');
};
// View all packs
const handleViewAll = () => {
// For future implementation - could navigate to a dedicated packs discovery screen
router.push('/(packs)/manage');
};
// If no packs are available and not loading, don't show the section
if (featuredPacks.length === 0 && !isLoading) {
return null;
}
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>POWR Packs</Text>
<TouchableOpacity onPress={handleViewAll} style={styles.viewAll}>
<Text style={styles.viewAllText}>View All</Text>
<ArrowRight size={16} color="#6b7280" />
</TouchableOpacity>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
>
{isLoading ? (
// Loading skeletons
Array.from({ length: 3 }).map((_, index) => (
<Card key={`skeleton-${index}`} style={styles.packCard}>
<CardContent style={styles.cardContent}>
<View style={styles.packImage}>
<Skeleton className="w-full h-full rounded-lg" />
</View>
<View style={styles.titleSkeleton}>
<Skeleton className="w-full h-full rounded" />
</View>
<View style={styles.subtitleSkeleton}>
<Skeleton className="w-full h-full rounded" />
</View>
</CardContent>
</Card>
))
) : featuredPacks.length > 0 ? (
// Pack cards
featuredPacks.map(pack => {
const title = findTagValue(pack.tags, 'title') || 'Unnamed Pack';
const description = findTagValue(pack.tags, 'description') || '';
const image = findTagValue(pack.tags, 'image') || null;
const exerciseCount = pack.tags.filter(t => t[0] === 'a' && t[1].startsWith('33401')).length;
const templateCount = pack.tags.filter(t => t[0] === 'a' && t[1].startsWith('33402')).length;
return (
<TouchableOpacity
key={pack.id}
onPress={() => handlePackClick(pack)}
activeOpacity={0.7}
>
<Card style={styles.packCard}>
<CardContent style={styles.cardContent}>
{image ? (
<Image source={{ uri: image }} style={styles.packImage} />
) : (
<View style={styles.placeholderImage}>
<PackageOpen size={32} color="#6b7280" />
</View>
)}
<Text style={styles.packTitle} numberOfLines={1}>{title}</Text>
<Text style={styles.packSubtitle} numberOfLines={2}>
{templateCount} template{templateCount !== 1 ? 's' : ''} {exerciseCount} exercise{exerciseCount !== 1 ? 's' : ''}
</Text>
</CardContent>
</Card>
</TouchableOpacity>
);
})
) : (
// No packs found
<View style={styles.emptyState}>
<PackageOpen size={32} color="#6b7280" />
<Text style={styles.emptyText}>No packs found</Text>
<Button
onPress={() => router.push('/(packs)/manage')}
size="sm"
variant="outline"
style={styles.emptyButton}
>
<Text>Import Pack</Text>
</Button>
</View>
)}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
marginVertical: 16,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
marginBottom: 12,
},
title: {
fontSize: 18,
fontWeight: '600',
},
viewAll: {
flexDirection: 'row',
alignItems: 'center',
},
viewAllText: {
fontSize: 14,
color: '#6b7280',
marginRight: 4,
},
scrollContent: {
paddingLeft: 16,
paddingRight: 8,
},
packCard: {
width: 160,
marginRight: 8,
borderRadius: 12,
},
cardContent: {
padding: 8,
},
packImage: {
width: '100%',
height: 90,
borderRadius: 8,
marginBottom: 8,
},
placeholderImage: {
width: '100%',
height: 90,
borderRadius: 8,
backgroundColor: '#f3f4f6',
marginBottom: 8,
justifyContent: 'center',
alignItems: 'center',
},
packTitle: {
fontSize: 14,
fontWeight: '600',
marginBottom: 4,
},
packSubtitle: {
fontSize: 12,
color: '#6b7280',
},
titleSkeleton: {
height: 16,
width: '80%',
borderRadius: 4,
marginBottom: 8,
},
subtitleSkeleton: {
height: 12,
width: '60%',
borderRadius: 4,
},
emptyState: {
width: '100%',
padding: 24,
alignItems: 'center',
justifyContent: 'center',
},
emptyText: {
marginTop: 8,
marginBottom: 16,
color: '#6b7280',
},
emptyButton: {
marginTop: 8,
}
});

View File

@ -0,0 +1,211 @@
# POWR Pack Implementation Document
## Overview
This document outlines the implementation plan for creating a "POWR Pack" feature in the POWR fitness app. POWR Packs are shareable collections of workout templates and exercises that users can import into their app. This feature leverages the Nostr protocol (NIP-51 lists) to enable decentralized sharing of fitness content.
## Key Concepts
1. **POWR Pack**: A collection of workout templates and exercises stored as a NIP-51 list (kind 30004 "Curation set")
2. **Pack Sharing**: Packs are shared via `naddr1` links that encode references to the collection
3. **Selective Import**: Users can select which templates/exercises to import from a pack
4. **Dependency Management**: When selecting a template, all required exercises are automatically selected
## Implementation Steps
### 1. Database Schema Extensions
Add new tables to track imported packs and their contents:
```sql
-- POWR Packs table
CREATE TABLE powr_packs (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
author_pubkey TEXT,
nostr_event_id TEXT,
import_date INTEGER NOT NULL
);
-- POWR Pack items table
CREATE TABLE powr_pack_items (
pack_id TEXT NOT NULL,
item_id TEXT NOT NULL,
item_type TEXT NOT NULL,
item_order INTEGER,
PRIMARY KEY (pack_id, item_id),
FOREIGN KEY (pack_id) REFERENCES powr_packs(id) ON DELETE CASCADE
);
```
### 2. New Service: POWRPackService
Create a new service in `lib/db/services/POWRPackService.ts` with these key methods:
- `fetchPackFromNaddr(naddr: string)`: Fetch a pack and its content from Nostr
- `importPack(pack, templates, exercises, selectedIds)`: Import selected items to local database
- `getImportedPacks()`: List all imported packs with metadata
- `deletePack(packId, keepItems)`: Remove a pack while optionally keeping its content
### 3. UI Components
#### Settings Integration
Add POWR Packs to the settings drawer:
- "Import POWR Pack" item
- "Manage POWR Packs" item
#### Import Flow
Create screen at `app/(packs)/import.tsx`:
- Input field for naddr
- Pack details display
- Selectable list of templates
- Selectable list of exercises with auto-selection based on template dependencies
- Import button
#### Management Interface
Create screen at `app/(packs)/manage.tsx`:
- List of imported packs
- Pack details (templates/exercises count, import date)
- Delete functionality
#### Social Discovery
Add a section to the social tab:
- Horizontal scrolling list of available packs
- Tap to view/import a pack
### 4. Routing
Configure routing in `app/(packs)/_layout.tsx`:
- Import screen as modal
- Management screen as standard page
## Technical Implementation Details
### Data Flow
1. **Pack Creation**: Exercise → Template → Pack (we've validated this flow works via NAK tests)
2. **Pack Import**:
- Decode naddr
- Fetch pack event and referenced content
- Parse Nostr events to POWR model objects
- Save selected items to database
### Dependency Management
When users select a workout template, the system will:
1. Identify all exercises referenced by the template
2. Automatically select these exercises (shown as "required")
3. Prevent deselection of required exercises
### Integration with Existing Services
- **NostrWorkoutService**: Use existing conversion methods between Nostr events and app models
- **LibraryService**: Update to query content from imported packs
- **NDK**: Use for fetching Nostr events and managing relay connections
## Sharing UI Mockups
### Import Screen
```
┌─────────────────────────────┐
│ Import POWR Pack │
├─────────────────────────────┤
│ ┌───────────────────────┐ │
│ │ naddr1... │ │
│ └───────────────────────┘ │
│ │
│ ┌─────────────┐ │
│ │ Fetch Pack │ │
│ └─────────────┘ │
│ │
│ Pack Name │
│ Description text here... │
│ │
│ Templates │
│ ┌─────────────────────────┐ │
│ │ ☑ Beginner Full Body │ │
│ │ Strength workout │ │
│ └─────────────────────────┘ │
│ │
│ Exercises │
│ ┌─────────────────────────┐ │
│ │ ☑ Squat │ │
│ │ Required by template │ │
│ └─────────────────────────┘ │
│ │
│ ┌───────────────────────┐ │
│ │ Import 3 items │ │
│ └───────────────────────┘ │
└─────────────────────────────┘
```
### Management Screen
```
┌─────────────────────────────┐
│ Manage POWR Packs │
├─────────────────────────────┤
│ ┌─────────────────────────┐ │
│ │ POWR Test Pack [🗑]│ │
│ │ A test collection... │ │
│ │ │ │
│ │ 2 templates • 2 exercises│
│ │ Imported 2 days ago │ │
│ └─────────────────────────┘ │
│ │
│ ┌─────────────────────────┐ │
│ │ Beginner Pack [🗑]│ │
│ │ For new users... │ │
│ │ │ │
│ │ 3 templates • 5 exercises│
│ │ Imported 1 week ago │ │
│ └─────────────────────────┘ │
│ │
└─────────────────────────────┘
```
### Social Discovery
```
┌─────────────────────────────┐
│ │
│ POWR Packs │
│ Discover workout collections│
│ │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │Pack1│ │Pack2│ │Pack3│ │
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ │
│ └─────┘ └─────┘ └─────┘ │
│ │
└─────────────────────────────┘
```
## Testing and Validation
We've successfully tested the basic Nostr event publishing flow using NAK:
1. Created exercise events (kind 33401)
2. Created template events (kind 33402) that reference the exercises
3. Created a pack event (kind 30004) that references both templates and exercises
4. Verified that all events were published and can be fetched by ID
## Implementation Timeline
1. **Database Schema Updates**: Implement new tables
2. **POWRPackService**: Create service for fetching and importing packs
3. **Settings Integration**: Add menu items to settings drawer
4. **Import UI**: Implement import screen with selection logic
5. **Management UI**: Create pack management interface
6. **Social Discovery**: Add pack discovery section to social tab
7. **Testing**: Validate full import/management flow
## Next Steps
1. Implement the database schema changes
2. Build POWRPackService
3. Create the UI components
4. Test the full feature flow
5. Consider future enhancements (creating/publishing packs from within the app)

View File

@ -0,0 +1,616 @@
# Updated POWR Pack Integration Plan
## Current Status Assessment
Based on the current implementation of POWR Packs, we've identified several issues that need to be addressed:
1. **Missing Template-Exercise Relationships**: Templates are being imported but not properly linked to their associated exercises
2. **Parameter Extraction Issues**: The system isn't correctly parsing parameters from exercise references
3. **Lack of Future Extensibility**: The current approach doesn't adequately support future changes to the NIP-4e specification
4. **Template Management**: Tools for template archiving and deletion are incomplete
## Implementation Plan
This plan outlines both immediate fixes and longer-term improvements for a more extensible architecture.
### Phase 1: Critical Fixes (Immediate)
#### 1. Fix Template-Exercise Relationship
**Problem**: Templates are imported but show 0 exercises because the references aren't correctly matched.
**Solution**:
- Update `POWRPackService.ts` to correctly parse exercise references by d-tag
- Improve the exercise matching logic to use the correct format (`33401:pubkey:d-tag`)
- Add detailed logging for troubleshooting
```typescript
// Find the corresponding imported exercise IDs
const templateExerciseIds: string[] = [];
const matchedRefs: string[] = [];
for (const ref of exerciseRefs) {
// Extract the base reference (before any parameters)
const refParts = ref.split('::');
const baseRef = refParts[0];
console.log(`Looking for matching exercise for reference: ${baseRef}`);
// Parse the reference format: kind:pubkey:d-tag
const refSegments = baseRef.split(':');
if (refSegments.length < 3) {
console.log(`Invalid reference format: ${baseRef}`);
continue;
}
const refKind = refSegments[0];
const refPubkey = refSegments[1];
const refDTag = refSegments[2];
// Find the event that matches by d-tag
const matchingEvent = exercises.find(e => {
const dTag = findTagValue(e.tags, 'd');
if (!dTag || e.pubkey !== refPubkey) return false;
const match = dTag === refDTag;
if (match) {
console.log(`Found matching event: ${e.id} with d-tag: ${dTag}`);
}
return match;
});
if (matchingEvent && exerciseIdMap.has(matchingEvent.id)) {
const localExerciseId = exerciseIdMap.get(matchingEvent.id) || '';
templateExerciseIds.push(localExerciseId);
matchedRefs.push(ref); // Keep the full reference including parameters
console.log(`Mapped Nostr event ${matchingEvent.id} to local exercise ID ${localExerciseId}`);
} else {
console.log(`No matching exercise found for reference: ${baseRef}`);
}
}
```
#### 2. Fix Parameter Extraction in NostrIntegration.ts
**Problem**: Parameter values from exercise references aren't being properly extracted.
**Solution**:
```typescript
async saveTemplateExercisesWithParams(
templateId: string,
exerciseIds: string[],
exerciseRefs: string[]
): Promise<void> {
try {
console.log(`Saving ${exerciseIds.length} exercise relationships for template ${templateId}`);
// Create template exercise records
for (let i = 0; i < exerciseIds.length; i++) {
const exerciseId = exerciseIds[i];
const templateExerciseId = generateId();
const now = Date.now();
// Get the corresponding exercise reference with parameters
const exerciseRef = exerciseRefs[i] || '';
// Parse the reference format: kind:pubkey:d-tag::sets:reps:weight
let targetSets = null;
let targetReps = null;
let targetWeight = null;
// Check if reference contains parameters
if (exerciseRef.includes('::')) {
const parts = exerciseRef.split('::');
if (parts.length > 1) {
const params = parts[1].split(':');
if (params.length > 0 && params[0]) targetSets = parseInt(params[0]) || null;
if (params.length > 1 && params[1]) targetReps = parseInt(params[1]) || null;
if (params.length > 2 && params[2]) targetWeight = parseFloat(params[2]) || null;
}
}
console.log(`Template exercise ${i}: ${exerciseId} with sets=${targetSets}, reps=${targetReps}, weight=${targetWeight}`);
await this.db.runAsync(
`INSERT INTO template_exercises
(id, template_id, exercise_id, display_order, target_sets, target_reps, target_weight, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
templateExerciseId,
templateId,
exerciseId,
i,
targetSets,
targetReps,
targetWeight,
now,
now
]
);
}
console.log(`Successfully saved all template-exercise relationships for template ${templateId}`);
} catch (error) {
console.error('Error saving template exercises with parameters:', error);
throw error;
}
}
```
#### 3. Add Template Management Functions
**Problem**: Need better tools for template archiving and deletion.
**Solution**:
- Add an `is_archived` column to templates table
- Create archive/unarchive functions
- Implement safe template removal with dependency handling
```typescript
// Schema update
await db.execAsync(`
ALTER TABLE templates ADD COLUMN is_archived BOOLEAN NOT NULL DEFAULT 0;
ALTER TABLE templates ADD COLUMN author_pubkey TEXT;
`);
// Template management functions
async archiveTemplate(id: string, archive: boolean = true): Promise<void> {
await this.db.runAsync(
'UPDATE templates SET is_archived = ? WHERE id = ?',
[archive ? 1 : 0, id]
);
}
async removeFromLibrary(id: string): Promise<void> {
await this.db.withTransactionAsync(async () => {
// Delete template-exercise relationships
await this.db.runAsync(
'DELETE FROM template_exercises WHERE template_id = ?',
[id]
);
// Delete template
await this.db.runAsync(
'DELETE FROM templates WHERE id = ?',
[id]
);
// Update powr_pack_items to mark as not imported
await this.db.runAsync(
'UPDATE powr_pack_items SET is_imported = 0 WHERE item_id = ? AND item_type = "template"',
[id]
);
});
}
```
### Phase 2: Extensibility Improvements (Short-term)
#### 1. Schema Updates for Extensibility
**Problem**: Schema is too rigid for future extensions to exercise parameters and workout types.
**Solution**:
```typescript
// Add schema update in a migration file or update schema.ts
async function addExtensibilityColumns(db: SQLiteDatabase): Promise<void> {
// Add params_json to template_exercises for extensible parameters
await db.execAsync(`
ALTER TABLE template_exercises ADD COLUMN params_json TEXT;
`);
// Add workout_type_config to templates for type-specific configurations
await db.execAsync(`
ALTER TABLE templates ADD COLUMN workout_type_config TEXT;
`);
}
```
#### 2. Flexible Parameter Extraction
**Problem**: Current parameter extraction is hardcoded for a limited set of parameters.
**Solution**:
- Create a parameter mapper service
- Implement dynamic parameter extraction based on exercise format
```typescript
class ExerciseParameterMapper {
// Extract parameters from a Nostr reference based on exercise format
static extractParameters(exerciseRef: string, formatJson?: string): Record<string, any> {
const parameters: Record<string, any> = {};
// If no reference with parameters, return empty object
if (!exerciseRef || !exerciseRef.includes('::')) {
return parameters;
}
const [baseRef, paramString] = exerciseRef.split('::');
if (!paramString) return parameters;
const paramValues = paramString.split(':');
// If we have format information, use it to map parameters
if (formatJson) {
try {
const format = JSON.parse(formatJson);
const formatKeys = Object.keys(format).filter(key => format[key] === true);
formatKeys.forEach((key, index) => {
if (index < paramValues.length && paramValues[index]) {
// Convert value to appropriate type based on parameter name
if (key === 'weight') {
parameters[key] = parseFloat(paramValues[index]) || null;
} else if (['reps', 'sets', 'duration'].includes(key)) {
parameters[key] = parseInt(paramValues[index]) || null;
} else {
// For other parameters, keep as string
parameters[key] = paramValues[index];
}
}
});
return parameters;
} catch (error) {
console.warn('Error parsing format JSON:', error);
// Fall back to default mapping below
}
}
// Default parameter mapping if no format or error parsing
if (paramValues.length > 0) parameters.target_sets = parseInt(paramValues[0]) || null;
if (paramValues.length > 1) parameters.target_reps = parseInt(paramValues[1]) || null;
if (paramValues.length > 2) parameters.target_weight = parseFloat(paramValues[2]) || null;
if (paramValues.length > 3) parameters.set_type = paramValues[3];
return parameters;
}
// Convert parameters back to Nostr reference format
static formatParameters(parameters: Record<string, any>, formatJson?: string): string {
if (!Object.keys(parameters).length) return '';
let paramArray: (string | number | null)[] = [];
// If we have format information, use it for parameter ordering
if (formatJson) {
try {
const format = JSON.parse(formatJson);
const formatKeys = Object.keys(format).filter(key => format[key] === true);
paramArray = formatKeys.map(key => parameters[key] ?? '');
} catch (error) {
console.warn('Error parsing format JSON:', error);
// Fall back to default format below
}
}
// Default parameter format if no format JSON or error parsing
if (!paramArray.length) {
paramArray = [
parameters.target_sets ?? parameters.sets ?? '',
parameters.target_reps ?? parameters.reps ?? '',
parameters.target_weight ?? parameters.weight ?? '',
parameters.set_type ?? ''
];
}
// Trim trailing empty values
while (paramArray.length > 0 &&
(paramArray[paramArray.length - 1] === '' ||
paramArray[paramArray.length - 1] === null)) {
paramArray.pop();
}
// If no parameters left, return empty string
if (!paramArray.length) return '';
// Join parameters with colon
return paramArray.join(':');
}
}
```
#### 3. Workout Type-Specific Handling
**Problem**: Different workout types (AMRAP, EMOM, circuit, strength) have specific data needs.
**Solution**:
- Create workout type processors
- Implement template service enhancements for type-specific configurations
```typescript
// WorkoutTypesService.ts
import { WorkoutTemplate, TemplateType } from '@/types/templates';
// Factory pattern for creating workout type processors
export class WorkoutTypeFactory {
static createProcessor(type: TemplateType): WorkoutTypeProcessor {
switch (type) {
case 'strength':
return new StrengthWorkoutProcessor();
case 'circuit':
return new CircuitWorkoutProcessor();
case 'emom':
return new EMOMWorkoutProcessor();
case 'amrap':
return new AMRAPWorkoutProcessor();
default:
return new DefaultWorkoutProcessor();
}
}
}
// Interface for workout type processors
export interface WorkoutTypeProcessor {
parseTemplateConfig(tags: string[][]): Record<string, any>;
getDefaultParameters(): Record<string, any>;
formatTemplateConfig(config: Record<string, any>): string[][];
}
// Example implementation for EMOM workouts
class EMOMWorkoutProcessor implements WorkoutTypeProcessor {
parseTemplateConfig(tags: string[][]): Record<string, any> {
const config: Record<string, any> = {
type: 'emom',
rounds: 0,
interval: 60, // Default 60 seconds
rest: 0
};
// Extract rounds (total number of intervals)
const roundsTag = tags.find(t => t[0] === 'rounds');
if (roundsTag && roundsTag.length > 1) {
config.rounds = parseInt(roundsTag[1]) || 0;
}
// Extract interval duration
const intervalTag = tags.find(t => t[0] === 'interval');
if (intervalTag && intervalTag.length > 1) {
config.interval = parseInt(intervalTag[1]) || 60;
}
// Extract rest between rounds
const restTag = tags.find(t => t[0] === 'rest_between_rounds');
if (restTag && restTag.length > 1) {
config.rest = parseInt(restTag[1]) || 0;
}
return config;
}
getDefaultParameters(): Record<string, any> {
return {
rounds: 10,
interval: 60,
rest: 0
};
}
formatTemplateConfig(config: Record<string, any>): string[][] {
const tags: string[][] = [];
if (config.rounds) {
tags.push(['rounds', config.rounds.toString()]);
}
if (config.interval) {
tags.push(['interval', config.interval.toString()]);
}
if (config.rest) {
tags.push(['rest_between_rounds', config.rest.toString()]);
}
return tags;
}
}
```
### Phase 3: Long-Term Architecture (Future)
#### 1. Modular Event Processor Architecture
**Problem**: Need a more adaptable system for handling evolving Nostr event schemas.
**Solution**:
- Create a plugin-based architecture for event processors
- Implement versioning for Nostr event handling
- Design a flexible mapping system between Nostr events and local database schema
```typescript
// Interface for event processors
interface NostrEventProcessor<T> {
// Check if processor can handle this event
canProcess(event: NostrEvent): boolean;
// Process event to local model
processEvent(event: NostrEvent): T;
// Convert local model to event
createEvent(model: T): NostrEvent;
// Get processor version
getVersion(): string;
}
// Registry for event processors
class EventProcessorRegistry {
private processors: Map<number, NostrEventProcessor<any>[]> = new Map();
// Register a processor for a specific kind
registerProcessor(kind: number, processor: NostrEventProcessor<any>): void {
if (!this.processors.has(kind)) {
this.processors.set(kind, []);
}
this.processors.get(kind)?.push(processor);
}
// Get appropriate processor for an event
getProcessor<T>(event: NostrEvent): NostrEventProcessor<T> | null {
const kindProcessors = this.processors.get(event.kind);
if (!kindProcessors) return null;
// Find the first processor that can process this event
for (const processor of kindProcessors) {
if (processor.canProcess(event)) {
return processor as NostrEventProcessor<T>;
}
}
return null;
}
}
```
#### 2. Schema Migration System
**Problem**: Database schema needs to evolve with Nostr specification changes.
**Solution**:
- Create a versioned migration system
- Implement automatic schema updates
- Track schema versions
```typescript
// Migration interface
interface SchemaMigration {
version: number;
up(db: SQLiteDatabase): Promise<void>;
down(db: SQLiteDatabase): Promise<void>;
}
// Migration runner
class MigrationRunner {
private migrations: SchemaMigration[] = [];
// Register a migration
registerMigration(migration: SchemaMigration): void {
this.migrations.push(migration);
// Sort migrations by version
this.migrations.sort((a, b) => a.version - b.version);
}
// Run migrations up to a specific version
async migrate(db: SQLiteDatabase, targetVersion: number): Promise<void> {
// Get current version
const currentVersion = await this.getCurrentVersion(db);
if (currentVersion < targetVersion) {
// Run UP migrations
for (const migration of this.migrations) {
if (migration.version > currentVersion && migration.version <= targetVersion) {
await migration.up(db);
await this.updateVersion(db, migration.version);
}
}
} else if (currentVersion > targetVersion) {
// Run DOWN migrations
for (const migration of [...this.migrations].reverse()) {
if (migration.version <= currentVersion && migration.version > targetVersion) {
await migration.down(db);
await this.updateVersion(db, migration.version - 1);
}
}
}
}
// Helper methods
private async getCurrentVersion(db: SQLiteDatabase): Promise<number> {
// Implementation
return 0;
}
private async updateVersion(db: SQLiteDatabase, version: number): Promise<void> {
// Implementation
}
}
```
#### 3. Future-Proof Integration Patterns
**Problem**: Need to ensure the POWR app can adapt to future Nostr specification changes.
**Solution**:
- Implement adapter pattern for Nostr protocol
- Create abstraction layers for data synchronization
- Design entity mappers for different data versions
```typescript
// Adapter for Nostr protocol versions
interface NostrProtocolAdapter {
// Get exercise from event
getExerciseFromEvent(event: NostrEvent): BaseExercise;
// Get template from event
getTemplateFromEvent(event: NostrEvent): WorkoutTemplate;
// Get workout record from event
getWorkoutFromEvent(event: NostrEvent): Workout;
// Create events from local models
createExerciseEvent(exercise: BaseExercise): NostrEvent;
createTemplateEvent(template: WorkoutTemplate): NostrEvent;
createWorkoutEvent(workout: Workout): NostrEvent;
}
// Versioned adapter implementation
class NostrProtocolAdapterV1 implements NostrProtocolAdapter {
// Implementation for first version of NIP-4e
}
```
## Testing Strategy
### Phase 1 (Immediate)
1. Create a test POWR Pack with variety of exercise types and templates
2. Test importing the pack with the updated code
3. Verify that templates contain the correct exercise relationships
4. Validate parameter extraction works correctly
### Phase 2 (Short-term)
1. Create test cases for different workout types (strength, circuit, EMOM, AMRAP)
2. Verify parameter mapping works as expected
3. Test template management functions
### Phase 3 (Long-term)
1. Create comprehensive integration tests
2. Design migration testing framework
3. Implement automated testing for different Nostr protocol versions
## Implementation Timeline
### Phase 1: Critical Fixes
- **Day 1**: Fix template-exercise relationship in `POWRPackService.ts`
- **Day 2**: Fix parameter extraction in `NostrIntegration.ts`
- **Day 3**: Implement template management functions and schema updates
- **Day 4**: Testing and bug fixes
### Phase 2: Extensibility Improvements
- **Week 2**: Implement schema updates and flexible parameter extraction
- **Week 3**: Develop workout type-specific processing
- **Week 4**: UI enhancements and testing
### Phase 3: Long-Term Architecture
- **Future**: Implement as part of broader architectural improvements
## Conclusion
This updated plan addresses both the immediate issues with POWR Pack integration and lays out a path for future extensibility as the Nostr Exercise NIP evolves. By implementing these changes in phases, we can quickly fix the current template-exercise relationship problems while establishing a foundation for more sophisticated features in the future.
The proposed approach balances pragmatism with future-proofing, ensuring that users can immediately benefit from POWR Packs while the system remains adaptable to changes in workout types, exercise parameters, and Nostr protocol specifications.

View File

@ -2,7 +2,7 @@
import { SQLiteDatabase } from 'expo-sqlite';
import { Platform } from 'react-native';
export const SCHEMA_VERSION = 6; // Incremented from 5 to 6 for relay table removal
export const SCHEMA_VERSION = 7; // Incremented from 6 to 7 for POWR Pack addition
class Schema {
private async getCurrentVersion(db: SQLiteDatabase): Promise<number> {
@ -516,6 +516,37 @@ class Schema {
);
CREATE INDEX idx_template_exercises_template_id ON template_exercises(template_id);
`);
// Create powr_packs table
console.log('[Schema] Creating powr_packs table...');
await db.execAsync(`
CREATE TABLE powr_packs (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
author_pubkey TEXT,
nostr_event_id TEXT,
import_date INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX idx_powr_packs_import_date ON powr_packs(import_date DESC);
`);
// Create powr_pack_items table
console.log('[Schema] Creating powr_pack_items table...');
await db.execAsync(`
CREATE TABLE powr_pack_items (
pack_id TEXT NOT NULL,
item_id TEXT NOT NULL,
item_type TEXT NOT NULL CHECK(item_type IN ('exercise', 'template')),
item_order INTEGER,
is_imported BOOLEAN NOT NULL DEFAULT 0,
nostr_event_id TEXT,
PRIMARY KEY (pack_id, item_id),
FOREIGN KEY (pack_id) REFERENCES powr_packs(id) ON DELETE CASCADE
);
CREATE INDEX idx_powr_pack_items_type ON powr_pack_items(item_type);
`);
console.log('[Schema] All tables created successfully');
} catch (error) {

View File

@ -0,0 +1,389 @@
// lib/db/services/NostrIntegration.ts
import { SQLiteDatabase } from 'expo-sqlite';
import { NDKEvent } from '@nostr-dev-kit/ndk-mobile';
import { findTagValue, getTagValues } from '@/utils/nostr-utils';
import {
BaseExercise,
ExerciseType,
ExerciseCategory,
Equipment,
ExerciseFormat,
ExerciseFormatUnits
} from '@/types/exercise';
import {
WorkoutTemplate,
TemplateType,
TemplateCategory,
TemplateExerciseConfig
} from '@/types/templates';
import { generateId } from '@/utils/ids';
/**
* Helper class for converting between Nostr events and local models
*/
export class NostrIntegration {
private db: SQLiteDatabase;
constructor(db: SQLiteDatabase) {
this.db = db;
}
/**
* Convert a Nostr exercise event to a local Exercise model
*/
convertNostrExerciseToLocal(exerciseEvent: NDKEvent): BaseExercise {
const id = generateId();
const title = findTagValue(exerciseEvent.tags, 'title') || 'Unnamed Exercise';
const equipmentTag = findTagValue(exerciseEvent.tags, 'equipment') || 'barbell';
const difficultyTag = findTagValue(exerciseEvent.tags, 'difficulty') || '';
const formatTag = exerciseEvent.tags.find(t => t[0] === 'format');
const formatUnitsTag = exerciseEvent.tags.find(t => t[0] === 'format_units');
// Get tags
const tags = getTagValues(exerciseEvent.tags, 't');
// Map equipment to valid type
const equipment: Equipment = this.mapToValidEquipment(equipmentTag);
// Map to valid exercise type
const type: ExerciseType = this.mapEquipmentToType(equipment);
// Map to valid category (using first tag if available)
const category: ExerciseCategory = this.mapToCategory(tags[0] || '');
// Parse format and format_units
const format: ExerciseFormat = {};
const formatUnits: ExerciseFormatUnits = {};
if (formatTag && formatUnitsTag && formatTag.length > 1 && formatUnitsTag.length > 1) {
// Process format parameters
for (let i = 1; i < formatTag.length; i++) {
const param = formatTag[i];
const unit = formatUnitsTag[i] || '';
if (param === 'weight') {
format.weight = true;
formatUnits.weight = (unit === 'kg' || unit === 'lbs') ? unit : 'kg';
} else if (param === 'reps') {
format.reps = true;
formatUnits.reps = 'count';
} else if (param === 'rpe') {
format.rpe = true;
formatUnits.rpe = '0-10';
} else if (param === 'set_type') {
format.set_type = true;
formatUnits.set_type = 'warmup|normal|drop|failure';
}
}
} else {
// Set default format if none provided
format.weight = true;
format.reps = true;
format.rpe = true;
format.set_type = true;
formatUnits.weight = 'kg';
formatUnits.reps = 'count';
formatUnits.rpe = '0-10';
formatUnits.set_type = 'warmup|normal|drop|failure';
}
// Create the exercise object
const exercise: BaseExercise = {
id,
title,
type,
category,
equipment,
description: exerciseEvent.content,
tags,
format,
format_units: formatUnits,
availability: {
source: ['nostr'],
},
created_at: exerciseEvent.created_at ? exerciseEvent.created_at * 1000 : Date.now()
};
return exercise;
}
/**
* Map string to valid Equipment type
*/
private mapToValidEquipment(equipment: string): Equipment {
switch (equipment.toLowerCase()) {
case 'barbell':
return 'barbell';
case 'dumbbell':
return 'dumbbell';
case 'kettlebell':
return 'kettlebell';
case 'machine':
return 'machine';
case 'cable':
return 'cable';
case 'bodyweight':
return 'bodyweight';
default:
return 'other';
}
}
/**
* Map Equipment value to exercise type
*/
private mapEquipmentToType(equipment: Equipment): ExerciseType {
switch (equipment) {
case 'barbell':
case 'dumbbell':
case 'kettlebell':
case 'machine':
case 'cable':
case 'other':
return 'strength';
case 'bodyweight':
return 'bodyweight';
default:
return 'strength';
}
}
/**
* Map string to valid category
*/
private mapToCategory(category: string): ExerciseCategory {
const normalized = category.toLowerCase();
if (normalized.includes('push')) return 'Push';
if (normalized.includes('pull')) return 'Pull';
if (normalized.includes('leg')) return 'Legs';
if (normalized.includes('core') || normalized.includes('abs')) return 'Core';
// Default to Push if no match
return 'Push';
}
/**
* Convert a Nostr template event to a local Template model
*/
convertNostrTemplateToLocal(templateEvent: NDKEvent): WorkoutTemplate {
const id = generateId();
const title = findTagValue(templateEvent.tags, 'title') || 'Unnamed Template';
const typeTag = findTagValue(templateEvent.tags, 'type') || 'strength';
// Convert string to valid TemplateType
const type: TemplateType =
(typeTag === 'strength' || typeTag === 'circuit' ||
typeTag === 'emom' || typeTag === 'amrap') ?
typeTag as TemplateType : 'strength';
// Get rounds, duration, interval if available
const rounds = parseInt(findTagValue(templateEvent.tags, 'rounds') || '0') || undefined;
const duration = parseInt(findTagValue(templateEvent.tags, 'duration') || '0') || undefined;
const interval = parseInt(findTagValue(templateEvent.tags, 'interval') || '0') || undefined;
// Get tags
const tags = getTagValues(templateEvent.tags, 't');
// Map to valid category
const category: TemplateCategory = this.mapToTemplateCategory(tags[0] || '');
// Create exercises placeholder (will be populated later)
const exercises: TemplateExerciseConfig[] = [];
// Create the template object
const template: WorkoutTemplate = {
id,
title,
type,
category,
description: templateEvent.content,
tags,
rounds,
duration,
interval,
exercises,
isPublic: true,
version: 1,
availability: {
source: ['nostr']
},
created_at: templateEvent.created_at ? templateEvent.created_at * 1000 : Date.now(),
lastUpdated: Date.now(),
nostrEventId: templateEvent.id
};
return template;
}
/**
* Map string to valid template category
*/
private mapToTemplateCategory(category: string): TemplateCategory {
const normalized = category.toLowerCase();
if (normalized.includes('full') && normalized.includes('body')) return 'Full Body';
if (normalized.includes('push') || normalized.includes('pull') || normalized.includes('leg')) return 'Push/Pull/Legs';
if (normalized.includes('upper') || normalized.includes('lower')) return 'Upper/Lower';
if (normalized.includes('cardio')) return 'Cardio';
if (normalized.includes('crossfit')) return 'CrossFit';
if (normalized.includes('strength')) return 'Strength';
if (normalized.includes('condition')) return 'Conditioning';
// Default if no match
return 'Custom';
}
/**
* Get exercise references from a template event
*/
getTemplateExerciseRefs(templateEvent: NDKEvent): string[] {
const exerciseRefs: string[] = [];
for (const tag of templateEvent.tags) {
if (tag[0] === 'exercise' && tag.length > 1) {
exerciseRefs.push(tag[1]);
}
}
return exerciseRefs;
}
/**
* Save an imported exercise to the database
*/
async saveImportedExercise(exercise: BaseExercise): Promise<string> {
try {
// Convert format objects to JSON strings
const formatJson = JSON.stringify(exercise.format || {});
const formatUnitsJson = JSON.stringify(exercise.format_units || {});
await this.db.runAsync(
`INSERT INTO exercises
(id, title, type, category, equipment, description, format_json, format_units_json,
created_at, updated_at, source, nostr_event_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
exercise.id,
exercise.title,
exercise.type,
exercise.category,
exercise.equipment || 'other',
exercise.description || '',
formatJson,
formatUnitsJson,
exercise.created_at,
Date.now(),
'nostr',
exercise.id // Using exercise ID as nostr_event_id since we don't have the actual event ID
]
);
// Save tags
if (exercise.tags && exercise.tags.length > 0) {
for (const tag of exercise.tags) {
await this.db.runAsync(
`INSERT INTO exercise_tags (exercise_id, tag) VALUES (?, ?)`,
[exercise.id, tag]
);
}
}
return exercise.id;
} catch (error) {
console.error('Error saving imported exercise:', error);
throw error;
}
}
/**
* Save an imported template to the database
*/
async saveImportedTemplate(template: WorkoutTemplate): Promise<string> {
try {
await this.db.runAsync(
`INSERT INTO templates
(id, title, type, description, created_at, updated_at, source, nostr_event_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
template.id,
template.title,
template.type,
template.description || '',
template.created_at,
template.lastUpdated || Date.now(),
'nostr',
template.nostrEventId || null
]
);
return template.id;
} catch (error) {
console.error('Error saving imported template:', error);
throw error;
}
}
/**
* Save template exercise relationships
*/
async saveTemplateExercisesWithParams(
templateId: string,
exerciseIds: string[],
exerciseRefs: string[]
): Promise<void> {
try {
console.log(`Saving ${exerciseIds.length} exercise relationships for template ${templateId}`);
// Create template exercise records
for (const [index, exerciseId] of exerciseIds.entries()) {
const templateExerciseId = generateId();
const now = Date.now();
// Get the corresponding exercise reference with parameters
const exerciseRef = exerciseRefs[index] || '';
// Parse the reference format: kind:pubkey:d-tag::sets:reps:weight
let targetSets = null;
let targetReps = null;
let targetWeight = null;
// Check if reference contains parameters
if (exerciseRef.includes('::')) {
const parts = exerciseRef.split('::');
if (parts.length > 1) {
const params = parts[1].split(':');
if (params.length > 0) targetSets = parseInt(params[0]) || null;
if (params.length > 1) targetReps = parseInt(params[1]) || null;
if (params.length > 2) targetWeight = parseFloat(params[2]) || null;
}
}
console.log(`Template exercise ${index}: ${exerciseId} with sets=${targetSets}, reps=${targetReps}, weight=${targetWeight}`);
await this.db.runAsync(
`INSERT INTO template_exercises
(id, template_id, exercise_id, display_order, target_sets, target_reps, target_weight, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
templateExerciseId,
templateId,
exerciseId,
index,
targetSets,
targetReps,
targetWeight,
now,
now
]
);
}
console.log(`Successfully saved all template-exercise relationships for template ${templateId}`);
} catch (error) {
console.error('Error saving template exercises with parameters:', error);
throw error;
}
}
}

View File

@ -0,0 +1,573 @@
// lib/db/services/POWRPackService.ts
import { SQLiteDatabase } from 'expo-sqlite';
import { generateId } from '@/utils/ids';
import { POWRPack, POWRPackItem, POWRPackWithContent, POWRPackImport, POWRPackSelection } from '@/types/powr-pack';
import { BaseExercise } from '@/types/exercise';
import { WorkoutTemplate } from '@/types/templates';
import NDK, { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk-mobile';
import { nip19 } from 'nostr-tools';
import { findTagValue, getTagValues } from '@/utils/nostr-utils';
import { NostrIntegration } from './NostrIntegration';
class POWRPackService {
private db: SQLiteDatabase;
constructor(db: SQLiteDatabase) {
this.db = db;
}
/**
* Fetches a POWR Pack from a nostr address (naddr)
* @param naddr The naddr string pointing to a NIP-51 list
* @param ndk The NDK instance to use for fetching
* @returns Promise with the pack data and its contents
*/
async fetchPackFromNaddr(naddr: string, ndk: NDK): Promise<POWRPackImport> {
try {
console.log(`Fetching POWR Pack from naddr: ${naddr}`);
// 1. Decode the naddr
const decoded = nip19.decode(naddr);
if (decoded.type !== 'naddr') {
throw new Error('Invalid naddr format');
}
const { pubkey, kind, identifier } = decoded.data as { pubkey: string, kind: number, identifier?: string };
console.log(`Decoded naddr: pubkey=${pubkey}, kind=${kind}, identifier=${identifier}`);
// 2. Check that it's a curation list (kind 30004)
if (kind !== 30004) {
throw new Error('Not a valid NIP-51 curation list');
}
// 3. Create a filter to fetch the pack event
const packFilter: NDKFilter = {
kinds: [kind],
authors: [pubkey],
'#d': identifier ? [identifier] : undefined
};
console.log(`Fetching pack with filter: ${JSON.stringify(packFilter)}`);
// 4. Fetch the pack event
const packEvents = await ndk.fetchEvents(packFilter);
if (packEvents.size === 0) {
throw new Error('Pack not found');
}
const packEvent = Array.from(packEvents)[0];
console.log(`Fetched pack event: ${packEvent.id}`);
console.log(`Pack tags: ${JSON.stringify(packEvent.tags)}`);
// 5. Extract template and exercise references
const templateRefs: string[] = [];
const exerciseRefs: string[] = [];
for (const tag of packEvent.tags) {
if (tag[0] === 'a' && tag.length > 1) {
const addressPointer = tag[1];
// Format is kind:pubkey:d-tag
if (addressPointer.startsWith('33402:')) {
// Workout template
templateRefs.push(addressPointer);
console.log(`Found template reference: ${addressPointer}`);
} else if (addressPointer.startsWith('33401:')) {
// Exercise
exerciseRefs.push(addressPointer);
console.log(`Found exercise reference: ${addressPointer}`);
}
}
}
console.log(`Found ${templateRefs.length} template refs and ${exerciseRefs.length} exercise refs`);
// 6. Fetch templates and exercises
console.log('Fetching referenced templates...');
const templates = await this.fetchReferencedEvents(ndk, templateRefs);
console.log('Fetching referenced exercises...');
const exercises = await this.fetchReferencedEvents(ndk, exerciseRefs);
console.log(`Fetched ${templates.length} templates and ${exercises.length} exercises`);
// 7. Return the complete pack data
return {
packEvent,
templates,
exercises
};
} catch (error) {
console.error('Error fetching pack from naddr:', error);
throw error;
}
}
/**
* Helper function to fetch events from address pointers
*/
async fetchReferencedEvents(ndk: NDK, addressPointers: string[]): Promise<NDKEvent[]> {
const events: NDKEvent[] = [];
console.log("Fetching references:", addressPointers);
for (const pointer of addressPointers) {
try {
// Parse the pointer (kind:pubkey:d-tag)
const parts = pointer.split(':');
if (parts.length < 3) {
console.error(`Invalid address pointer format: ${pointer}`);
continue;
}
// Extract the components
const kindStr = parts[0];
const hexPubkey = parts[1];
const dTagOrEventId = parts[2];
const kind = parseInt(kindStr);
if (isNaN(kind)) {
console.error(`Invalid kind in pointer: ${kindStr}`);
continue;
}
console.log(`Fetching ${kind} event with d-tag ${dTagOrEventId} from author ${hexPubkey}`);
// Try direct event ID fetching first
try {
console.log(`Trying to fetch event directly by ID: ${dTagOrEventId}`);
const directEvent = await ndk.fetchEvent({ids: [dTagOrEventId]});
if (directEvent) {
console.log(`Successfully fetched event by ID: ${dTagOrEventId}`);
events.push(directEvent);
continue; // Skip to next loop iteration
}
} catch (directFetchError) {
console.log(`Direct fetch failed, falling back to filters: ${directFetchError}`);
}
// Create a filter as fallback
const filter: NDKFilter = {
kinds: [kind],
authors: [hexPubkey],
};
if (dTagOrEventId && dTagOrEventId.length > 0) {
// For parameterized replaceable events, use d-tag
filter['#d'] = [dTagOrEventId];
}
console.log("Using filter:", JSON.stringify(filter));
// Fetch the events with a timeout
const fetchPromise = ndk.fetchEvents(filter);
const timeoutPromise = new Promise<Set<NDKEvent>>((_, reject) =>
setTimeout(() => reject(new Error('Fetch timeout')), 10000)
);
const fetchedEvents = await Promise.race([fetchPromise, timeoutPromise]);
console.log(`Found ${fetchedEvents.size} events for ${pointer}`);
if (fetchedEvents.size > 0) {
events.push(...Array.from(fetchedEvents));
}
} catch (error) {
console.error(`Error fetching event with pointer ${pointer}:`, error);
// Continue with other events even if one fails
}
}
console.log(`Total fetched referenced events: ${events.length}`);
return events;
}
/**
* Analyzes templates and identifies their exercise dependencies
*/
analyzeDependencies(templates: NDKEvent[], exercises: NDKEvent[]): Record<string, string[]> {
const dependencies: Record<string, string[]> = {};
const exerciseMap: Record<string, string> = {};
console.log(`Analyzing dependencies for ${templates.length} templates and ${exercises.length} exercises`);
// Create lookup map for exercises by reference
exercises.forEach(exercise => {
const dTag = findTagValue(exercise.tags, 'd');
if (dTag) {
const exerciseRef = `33401:${exercise.pubkey}:${dTag}`;
exerciseMap[exerciseRef] = exercise.id;
console.log(`Mapped exercise ${exercise.id} to reference ${exerciseRef}`);
} else {
console.log(`Exercise ${exercise.id} has no d-tag`);
}
});
// Analyze each template for exercise references
templates.forEach(template => {
const requiredExercises: string[] = [];
const templateName = findTagValue(template.tags, 'title') || template.id.substring(0, 8);
console.log(`Analyzing template ${templateName} (${template.id})`);
// Find exercise references in template tags
template.tags.forEach(tag => {
if (tag[0] === 'exercise' && tag.length > 1) {
const exerciseRefFull = tag[1];
// Split the reference to get the base part (without parameters)
const refParts = exerciseRefFull.split('::');
const baseRef = refParts[0];
const exerciseId = exerciseMap[baseRef];
if (exerciseId) {
requiredExercises.push(exerciseId);
console.log(`Template ${templateName} requires exercise ${exerciseId} via ref ${baseRef}`);
} else {
console.log(`Template ${templateName} references unknown exercise ${exerciseRefFull}`);
}
}
});
dependencies[template.id] = requiredExercises;
console.log(`Template ${templateName} has ${requiredExercises.length} dependencies`);
});
return dependencies;
}
/**
* Import a POWR Pack and selected items into the database
*/
async importPack(
packImport: POWRPackImport,
selection: POWRPackSelection
): Promise<string> {
try {
const { packEvent, templates, exercises } = packImport;
const { selectedTemplates, selectedExercises } = selection;
// Create integration helper
const nostrIntegration = new NostrIntegration(this.db);
// 1. Extract pack metadata
const title = findTagValue(packEvent.tags, 'name') || 'Unnamed Pack';
const description = findTagValue(packEvent.tags, 'about') || packEvent.content;
// 2. Create pack record
const packId = generateId();
const now = Date.now();
await this.db.withTransactionAsync(async () => {
// Insert pack record
await this.db.runAsync(
`INSERT INTO powr_packs (id, title, description, author_pubkey, nostr_event_id, import_date, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[packId, title, description, packEvent.pubkey, packEvent.id, now, now]
);
// 3. Process and import selected exercises
const exercisesToImport = exercises.filter((e: NDKEvent) => selectedExercises.includes(e.id));
const importedExerciseIds: string[] = [];
const exerciseIdMap = new Map<string, string>(); // Map Nostr event ID to local ID
console.log(`Importing ${exercisesToImport.length} exercises...`);
for (const exerciseEvent of exercisesToImport) {
// Convert to local model
const exercise = nostrIntegration.convertNostrExerciseToLocal(exerciseEvent);
// Save to database
await nostrIntegration.saveImportedExercise(exercise);
// Track imported exercise
importedExerciseIds.push(exercise.id);
exerciseIdMap.set(exerciseEvent.id, exercise.id);
console.log(`Imported exercise: ${exercise.title} (${exercise.id}) from Nostr event ${exerciseEvent.id}`);
// Create pack item record
await this.createPackItemRecord(packId, exercise.id, 'exercise', exerciseEvent.id);
}
// 4. Process and import selected templates
const templatesToImport = templates.filter((t: NDKEvent) => selectedTemplates.includes(t.id));
console.log(`Importing ${templatesToImport.length} templates...`);
for (const templateEvent of templatesToImport) {
// Convert to local model
const templateModel = nostrIntegration.convertNostrTemplateToLocal(templateEvent);
// Save to database
await nostrIntegration.saveImportedTemplate(templateModel);
console.log(`Imported template: ${templateModel.title} (${templateModel.id}) from Nostr event ${templateEvent.id}`);
// Get exercise references from this template
const exerciseRefs = nostrIntegration.getTemplateExerciseRefs(templateEvent);
console.log(`Template has ${exerciseRefs.length} exercise references:`);
exerciseRefs.forEach(ref => console.log(` - ${ref}`));
// Find the corresponding imported exercise IDs
const templateExerciseIds: string[] = [];
const matchedRefs: string[] = [];
for (const ref of exerciseRefs) {
// Extract the base reference (before any parameters)
const refParts = ref.split('::');
const baseRef = refParts[0];
console.log(`Looking for matching exercise for reference: ${baseRef}`);
// Find the event that matches this reference
const matchingEvent = exercises.find(e => {
const dTag = findTagValue(e.tags, 'd');
if (!dTag) return false;
const fullRef = `33401:${e.pubkey}:${dTag}`;
const match = baseRef === fullRef;
if (match) {
console.log(`Found matching event: ${e.id} with d-tag: ${dTag}`);
}
return match;
});
if (matchingEvent && exerciseIdMap.has(matchingEvent.id)) {
const localExerciseId = exerciseIdMap.get(matchingEvent.id) || '';
templateExerciseIds.push(localExerciseId);
matchedRefs.push(ref); // Keep the full reference including parameters
console.log(`Mapped Nostr event ${matchingEvent.id} to local exercise ID ${localExerciseId}`);
} else {
console.log(`No matching exercise found for reference: ${baseRef}`);
}
}
// Save template-exercise relationships with parameters
if (templateExerciseIds.length > 0) {
await nostrIntegration.saveTemplateExercisesWithParams(templateModel.id, templateExerciseIds, matchedRefs);
} else {
console.log(`No exercise relationships to save for template ${templateModel.id}`);
}
// Create pack item record
await this.createPackItemRecord(packId, templateModel.id, 'template', templateEvent.id);
// Add diagnostic logging
console.log(`Checking saved template: ${templateModel.id}`);
const exerciseCount = await this.db.getFirstAsync<{count: number}>(
'SELECT COUNT(*) as count FROM template_exercises WHERE template_id = ?',
[templateModel.id]
);
console.log(`Template ${templateModel.title} has ${exerciseCount?.count || 0} exercises associated`);
}
// Final diagnostic check
const templateCount = await this.db.getFirstAsync<{count: number}>(
'SELECT COUNT(*) as count FROM templates WHERE source = "nostr"'
);
console.log(`Total nostr templates in database: ${templateCount?.count || 0}`);
const templateIds = await this.db.getAllAsync<{id: string, title: string}>(
'SELECT id, title FROM templates WHERE source = "nostr"'
);
console.log(`Template IDs:`);
templateIds.forEach(t => console.log(` - ${t.title}: ${t.id}`));
});
return packId;
} catch (error) {
console.error('Error importing POWR pack:', error);
throw error;
}
}
/**
* Create a record of a pack item
*/
private async createPackItemRecord(
packId: string,
itemId: string,
itemType: 'exercise' | 'template',
nostrEventId?: string,
itemOrder?: number
): Promise<void> {
await this.db.runAsync(
`INSERT INTO powr_pack_items (pack_id, item_id, item_type, item_order, is_imported, nostr_event_id)
VALUES (?, ?, ?, ?, ?, ?)`,
[packId, itemId, itemType, itemOrder || 0, 1, nostrEventId || null]
);
}
/**
* Get all imported packs
*/
async getImportedPacks(): Promise<POWRPackWithContent[]> {
try {
// 1. Get all packs
const packs = await this.db.getAllAsync<POWRPack>(
`SELECT id, title, description, author_pubkey as authorPubkey,
nostr_event_id as nostrEventId, import_date as importDate, updated_at as updatedAt
FROM powr_packs
ORDER BY import_date DESC`
);
// 2. Get content for each pack
const result: POWRPackWithContent[] = [];
for (const pack of packs) {
// Get exercises
const exercises = await this.db.getAllAsync<BaseExercise>(
`SELECT e.*
FROM exercises e
JOIN powr_pack_items ppi ON e.id = ppi.item_id
WHERE ppi.pack_id = ? AND ppi.item_type = 'exercise' AND ppi.is_imported = 1`,
[pack.id]
);
// Get templates
const templates = await this.db.getAllAsync<WorkoutTemplate>(
`SELECT t.*
FROM templates t
JOIN powr_pack_items ppi ON t.id = ppi.item_id
WHERE ppi.pack_id = ? AND ppi.item_type = 'template' AND ppi.is_imported = 1`,
[pack.id]
);
result.push({
pack,
exercises,
templates
});
}
return result;
} catch (error) {
console.error('Error getting imported packs:', error);
throw error;
}
}
/**
* Get a specific pack by ID
*/
async getPackById(packId: string): Promise<POWRPackWithContent | null> {
try {
// 1. Get pack info
const pack = await this.db.getFirstAsync<POWRPack>(
`SELECT id, title, description, author_pubkey as authorPubkey,
nostr_event_id as nostrEventId, import_date as importDate, updated_at as updatedAt
FROM powr_packs
WHERE id = ?`,
[packId]
);
if (!pack) {
return null;
}
// 2. Get exercises
const exercises = await this.db.getAllAsync<BaseExercise>(
`SELECT e.*
FROM exercises e
JOIN powr_pack_items ppi ON e.id = ppi.item_id
WHERE ppi.pack_id = ? AND ppi.item_type = 'exercise' AND ppi.is_imported = 1`,
[packId]
);
// 3. Get templates
const templates = await this.db.getAllAsync<WorkoutTemplate>(
`SELECT t.*
FROM templates t
JOIN powr_pack_items ppi ON t.id = ppi.item_id
WHERE ppi.pack_id = ? AND ppi.item_type = 'template' AND ppi.is_imported = 1`,
[packId]
);
return {
pack,
exercises,
templates
};
} catch (error) {
console.error('Error getting pack by ID:', error);
throw error;
}
}
/**
* Delete a pack and optionally its contents
*/
async deletePack(packId: string, keepItems: boolean = false): Promise<void> {
try {
await this.db.withTransactionAsync(async () => {
if (!keepItems) {
// Get the items first so we can delete them from their respective tables
const items = await this.db.getAllAsync<POWRPackItem>(
`SELECT * FROM powr_pack_items WHERE pack_id = ? AND is_imported = 1`,
[packId]
);
// Delete each exercise and template
for (const item of items as POWRPackItem[]) {
if (item.itemType === 'exercise') {
// Delete exercise
await this.db.runAsync(`DELETE FROM exercises WHERE id = ?`, [item.itemId]);
} else if (item.itemType === 'template') {
// Delete template and its relationships
await this.db.runAsync(`DELETE FROM template_exercises WHERE template_id = ?`, [item.itemId]);
await this.db.runAsync(`DELETE FROM templates WHERE id = ?`, [item.itemId]);
}
}
}
// Delete the pack items
await this.db.runAsync(
`DELETE FROM powr_pack_items WHERE pack_id = ?`,
[packId]
);
// Delete the pack
await this.db.runAsync(
`DELETE FROM powr_packs WHERE id = ?`,
[packId]
);
});
} catch (error) {
console.error('Error deleting pack:', error);
throw error;
}
}
/**
* Create an naddr for sharing a pack
*/
createShareableNaddr(packEvent: NDKEvent): string {
try {
// Extract the d-tag (identifier)
const dTags = packEvent.getMatchingTags('d');
const identifier = dTags[0]?.[1] || '';
// Ensure kind is a definite number (use 30004 as default if undefined)
const kind = packEvent.kind !== undefined ? packEvent.kind : 30004;
// Create the naddr
const naddr = nip19.naddrEncode({
pubkey: packEvent.pubkey,
kind: kind, // Now this is always a number
identifier
});
return naddr;
} catch (error) {
console.error('Error creating shareable naddr:', error);
throw error;
}
}
}
export default POWRPackService;

View File

@ -20,6 +20,13 @@ export class TemplateService {
*/
async getAllTemplates(limit: number = 50, offset: number = 0): Promise<WorkoutTemplate[]> {
try {
// Add source logging
const sourceCount = await this.db.getAllAsync<{source: string, count: number}>(
'SELECT source, COUNT(*) as count FROM templates GROUP BY source'
);
console.log('[TemplateService] Template sources:');
sourceCount.forEach(s => console.log(` - ${s.source}: ${s.count}`));
const templates = await this.db.getAllAsync<{
id: string;
title: string;
@ -35,6 +42,10 @@ export class TemplateService {
[limit, offset]
);
console.log(`[TemplateService] Found ${templates.length} templates`);
// Log each template for debugging
templates.forEach(t => console.log(` - ${t.title} (${t.id}) [source: ${t.source}]`));
const result: WorkoutTemplate[] = [];
for (const template of templates) {
@ -310,6 +321,9 @@ export class TemplateService {
// Helper methods
private async getTemplateExercises(templateId: string): Promise<TemplateExerciseConfig[]> {
try {
// Add additional logging for diagnostic purposes
console.log(`Fetching exercises for template ${templateId}`);
const exercises = await this.db.getAllAsync<{
id: string;
exercise_id: string;
@ -327,6 +341,13 @@ export class TemplateService {
[templateId]
);
console.log(`Found ${exercises.length} template exercises in database`);
// Log exercise IDs for debugging
if (exercises.length > 0) {
exercises.forEach(ex => console.log(` - Exercise ID: ${ex.exercise_id}`));
}
const result: TemplateExerciseConfig[] = [];
for (const ex of exercises) {
@ -341,6 +362,17 @@ export class TemplateService {
[ex.exercise_id]
);
// Log if exercise is found
if (exercise) {
console.log(`Found exercise: ${exercise.title} (${ex.exercise_id})`);
} else {
console.log(`Exercise not found for ID: ${ex.exercise_id}`);
// Important: Skip exercises that don't exist in the database
// We don't want to include placeholder exercises
continue;
}
result.push({
id: ex.id,
exercise: {
@ -360,13 +392,14 @@ export class TemplateService {
});
}
console.log(`Returning ${result.length} template exercises`);
return result;
} catch (error) {
console.error('Error getting template exercises:', error);
return [];
}
}
// Static helper methods used by the workout store
static async updateExistingTemplate(workout: Workout): Promise<boolean> {
try {

94
lib/hooks/usePOWRpacks.ts Normal file
View File

@ -0,0 +1,94 @@
// lib/hooks/usePOWRPacks.ts
import { useState, useEffect, useCallback } from 'react';
import { Alert } from 'react-native';
import { usePOWRPackService } from '@/components/DatabaseProvider';
import { useNDK } from '@/lib/hooks/useNDK';
import { POWRPackWithContent, POWRPackImport, POWRPackSelection } from '@/types/powr-pack';
import { router } from 'expo-router';
export function usePOWRPacks() {
const powrPackService = usePOWRPackService();
const { ndk } = useNDK();
const [packs, setPacks] = useState<POWRPackWithContent[]>([]);
const [isLoading, setIsLoading] = useState(false);
// Load all packs
const loadPacks = useCallback(async () => {
setIsLoading(true);
try {
const importedPacks = await powrPackService.getImportedPacks();
setPacks(importedPacks);
return importedPacks;
} catch (error) {
console.error('Error loading POWR packs:', error);
return [];
} finally {
setIsLoading(false);
}
}, [powrPackService]);
// Load packs on mount
useEffect(() => {
loadPacks();
}, [loadPacks]);
// Fetch a pack from an naddr
const fetchPack = useCallback(async (naddr: string): Promise<POWRPackImport | null> => {
if (!ndk) {
Alert.alert('Error', 'NDK is not initialized');
return null;
}
try {
return await powrPackService.fetchPackFromNaddr(naddr, ndk);
} catch (error) {
console.error('Error fetching pack:', error);
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to fetch pack');
return null;
}
}, [ndk, powrPackService]);
// Import a pack with selected items
const importPack = useCallback(async (packImport: POWRPackImport, selection: POWRPackSelection) => {
try {
await powrPackService.importPack(packImport, selection);
await loadPacks(); // Refresh the list
return true;
} catch (error) {
console.error('Error importing pack:', error);
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to import pack');
return false;
}
}, [powrPackService, loadPacks]);
// Delete a pack
const deletePack = useCallback(async (packId: string, keepItems: boolean = false) => {
try {
await powrPackService.deletePack(packId, keepItems);
await loadPacks(); // Refresh the list
return true;
} catch (error) {
console.error('Error deleting pack:', error);
Alert.alert('Error', error instanceof Error ? error.message : 'Failed to delete pack');
return false;
}
}, [powrPackService, loadPacks]);
// Helper to copy pack address to clipboard (for future implementation)
const copyPackAddress = useCallback((naddr: string) => {
// We would implement clipboard functionality here
// For now, this is a placeholder for future enhancement
console.log('Would copy to clipboard:', naddr);
return true;
}, []);
return {
packs,
isLoading,
loadPacks,
fetchPack,
importPack,
deletePack,
copyPackAddress
};
}

48
types/powr-pack.ts Normal file
View File

@ -0,0 +1,48 @@
// types/powr-pack.ts
import { WorkoutTemplate } from './templates';
import { BaseExercise } from './exercise';
import { NDKEvent } from '@nostr-dev-kit/ndk-mobile';
// Basic POWR Pack structure
export interface POWRPack {
id: string;
title: string;
description?: string;
authorPubkey: string;
nostrEventId?: string;
importDate: number;
updatedAt: number;
}
// Pack item reference
export interface POWRPackItem {
packId: string;
itemId: string;
itemType: 'exercise' | 'template';
itemOrder?: number;
isImported: boolean;
nostrEventId?: string;
}
// Combined pack with content for display
export interface POWRPackWithContent {
pack: POWRPack;
exercises: BaseExercise[];
templates: WorkoutTemplate[];
}
// Structure for importing packs
export interface POWRPackImport {
packEvent: NDKEvent;
exercises: NDKEvent[];
templates: NDKEvent[];
}
// Selected items during import process
export interface POWRPackSelection {
packId: string;
selectedExercises: string[]; // Exercise IDs
selectedTemplates: string[]; // Template IDs
// Mapping of template ID to required exercise IDs
templateDependencies: Record<string, string[]>;
}