From a3e9dc36d85c25da209c1633fa6af06bbdf0fc85 Mon Sep 17 00:00:00 2001 From: DocNR Date: Thu, 13 Mar 2025 14:02:36 -0400 Subject: [PATCH] POWR Pack feature WIP - basic functionality, need to associate exercise events with workout template events. --- CHANGELOG.md | 31 + app/(packs)/_layout.tsx | 34 + app/(packs)/import.tsx | 413 ++++++++++++ app/(packs)/manage.tsx | 239 +++++++ app/(tabs)/library/templates.tsx | 140 ++-- app/(tabs)/social/powr.tsx | 4 + components/DatabaseProvider.tsx | 14 + components/SettingsDrawer.tsx | 11 +- .../exercises/SimplifiedExerciseCard.tsx | 102 --- components/social/POWRPackSection.tsx | 228 +++++++ docs/design/POWR Pack/POWRPack.md | 211 ++++++ .../POWR_Pack_Implementation_Plan.md | 616 ++++++++++++++++++ lib/db/schema.ts | 33 +- lib/db/services/NostrIntegration.ts | 389 +++++++++++ lib/db/services/POWRPackService.ts | 573 ++++++++++++++++ lib/db/services/TemplateService.ts | 35 +- .../{useExercises.tsx => useExercises.ts} | 0 lib/hooks/usePOWRpacks.ts | 94 +++ lib/hooks/{useProfile.tsx => useProfile.ts} | 0 types/powr-pack.ts | 48 ++ 20 files changed, 3067 insertions(+), 148 deletions(-) create mode 100644 app/(packs)/_layout.tsx create mode 100644 app/(packs)/import.tsx create mode 100644 app/(packs)/manage.tsx delete mode 100644 components/exercises/SimplifiedExerciseCard.tsx create mode 100644 components/social/POWRPackSection.tsx create mode 100644 docs/design/POWR Pack/POWRPack.md create mode 100644 docs/design/POWR Pack/POWR_Pack_Implementation_Plan.md create mode 100644 lib/db/services/NostrIntegration.ts create mode 100644 lib/db/services/POWRPackService.ts rename lib/hooks/{useExercises.tsx => useExercises.ts} (100%) create mode 100644 lib/hooks/usePOWRpacks.ts rename lib/hooks/{useProfile.tsx => useProfile.ts} (100%) create mode 100644 types/powr-pack.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 06fed1f..c5d9b33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/(packs)/_layout.tsx b/app/(packs)/_layout.tsx new file mode 100644 index 0000000..e420548 --- /dev/null +++ b/app/(packs)/_layout.tsx @@ -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 ( + + + + + ); +} \ No newline at end of file diff --git a/app/(packs)/import.tsx b/app/(packs)/import.tsx new file mode 100644 index 0000000..202ff7a --- /dev/null +++ b/app/(packs)/import.tsx @@ -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(null); + const [packData, setPackData] = useState(null); + const [selectedTemplates, setSelectedTemplates] = useState([]); + const [selectedExercises, setSelectedExercises] = useState([]); + const [dependencies, setDependencies] = useState>({}); + 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 ( + + + + + {/* Input section */} + + + + Enter POWR Pack Address + + + Paste a POWR Pack naddr to import + + + + {/* Helper text explaining naddr format */} + + + Paste a POWR Pack address (naddr1...) to import templates and exercises shared by the community. + + + + + + + + + + {/* Error message */} + {error && ( + + {error} + + )} + + {/* Success message */} + {importSuccess && ( + + Pack successfully imported! + + )} + + {/* Pack content */} + {packData && ( + + + + + {getPackTitle()} + + {getPackDescription() ? ( + + {getPackDescription()} + + ) : null} + + + Select items to import: + + + + {/* Templates section */} + {packData.templates && packData.templates.length > 0 ? ( + + + + Workout Templates + + + {packData.templates.length} templates available + + + + {packData.templates.map(template => { + const title = findTagValue(template.tags, 'title') || 'Unnamed Template'; + return ( + + + handleTemplateChange(template.id, checked === true) + } + id={`template-${template.id}`} + /> + + handleTemplateChange(template.id, !selectedTemplates.includes(template.id)) + }> + {title} + + + ); + })} + + + ) : ( + + + No templates available in this pack + + + )} + + {/* Exercises section */} + {packData.exercises && packData.exercises.length > 0 ? ( + + + + Exercises + + + {packData.exercises.length} exercises available + + + + {packData.exercises.map(exercise => { + const title = findTagValue(exercise.tags, 'title') || 'Unnamed Exercise'; + const isRequired = isRequiredByTemplate(exercise.id); + + return ( + + + handleExerciseChange(exercise.id, checked === true) + } + disabled={isRequired} + id={`exercise-${exercise.id}`} + /> + { + if (!isRequired) { + handleExerciseChange(exercise.id, !selectedExercises.includes(exercise.id)) + } + }} + > + {title} + + {isRequired && ( + + + Required + + )} + + ); + })} + + + ) : ( + + + No exercises available in this pack + + + )} + + {/* Import button */} + + + )} + + + ); +} + +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, + } +}); \ No newline at end of file diff --git a/app/(packs)/manage.tsx b/app/(packs)/manage.tsx new file mode 100644 index 0000000..035d00f --- /dev/null +++ b/app/(packs)/manage.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + const [selectedPackId, setSelectedPackId] = useState(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 ( + + + + + {/* Import button - fix icon usage */} + + + {/* No packs message */} + {!isLoading && packs.length === 0 && ( + + + + No POWR Packs Imported + + Import workout packs shared by the community to get started. + + + + + )} + + {/* Pack list */} + {packs.map((packWithContent) => { + const { pack, templates, exercises } = packWithContent; + + return ( + + + + + + {pack.title} + + {pack.description && ( + + {pack.description} + + )} + + handleDeleteClick(pack.id)} + style={styles.deleteButton} + > + + + + + + + + {templates.length} template{templates.length !== 1 ? 's' : ''} • {exercises.length} exercise{exercises.length !== 1 ? 's' : ''} + + + + + Imported {formatImportDate(pack.importDate)} + + + + ); + })} + + + {/* Delete confirmation dialog */} + + + + Delete Pack? + + + This will remove the POWR Pack from your library. Do you want to keep the imported exercises and templates? + + + + + + + + + + + + + + + + + + + + + ); +} + +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', + } +}); \ No newline at end of file diff --git a/app/(tabs)/library/templates.tsx b/app/(tabs)/library/templates.tsx index a765cc7..1d785c6 100644 --- a/app/(tabs)/library/templates.tsx +++ b/app/(tabs)/library/templates.tsx @@ -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(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 */} + + + + + {/* Debug info display */} + {debugInfo ? ( + + + {debugInfo} + + + ) : null} + {/* Templates list */} {/* Favorites Section */} @@ -288,7 +342,7 @@ export default function TemplatesScreen() { ) : ( - 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.'} )} diff --git a/app/(tabs)/social/powr.tsx b/app/(tabs)/social/powr.tsx index 295d2ba..948502d 100644 --- a/app/(tabs)/social/powr.tsx +++ b/app/(tabs)/social/powr.tsx @@ -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() { + {/* POWR Packs Section - Add this */} + + {/* Posts */} {posts.map(post => ( diff --git a/components/DatabaseProvider.tsx b/components/DatabaseProvider.tsx index 80f50ea..b8e30a4 100644 --- a/components/DatabaseProvider.tsx +++ b/components/DatabaseProvider.tsx @@ -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 { + closeDrawer(); + router.push("/(packs)/manage"); + }, + }, { id: 'device', icon: Smartphone, diff --git a/components/exercises/SimplifiedExerciseCard.tsx b/components/exercises/SimplifiedExerciseCard.tsx deleted file mode 100644 index c6bd372..0000000 --- a/components/exercises/SimplifiedExerciseCard.tsx +++ /dev/null @@ -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 ( - - {/* Image placeholder or first letter */} - - - {firstLetter} - - - - - {/* Title */} - - {title} - - - {/* Tags row */} - - {/* Category Badge */} - - {category} - - - {/* Equipment Badge (if available) */} - {equipment && ( - - {equipment} - - )} - - {/* Type Badge */} - {type && ( - - {type} - - )} - - {/* Source Badge - colored for 'powr' */} - {source && ( - - - {source} - - - )} - - - - {/* Weight/Reps information if available from sets */} - {workoutExercise?.sets?.[0] && ( - - - {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})`} - - - )} - - ); -} \ No newline at end of file diff --git a/components/social/POWRPackSection.tsx b/components/social/POWRPackSection.tsx new file mode 100644 index 0000000..4c4d3cd --- /dev/null +++ b/components/social/POWRPackSection.tsx @@ -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([]); + + // 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 ( + + + POWR Packs + + View All + + + + + + {isLoading ? ( + // Loading skeletons + Array.from({ length: 3 }).map((_, index) => ( + + + + + + + + + + + + + + )) + ) : 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 ( + handlePackClick(pack)} + activeOpacity={0.7} + > + + + {image ? ( + + ) : ( + + + + )} + {title} + + {templateCount} template{templateCount !== 1 ? 's' : ''} • {exerciseCount} exercise{exerciseCount !== 1 ? 's' : ''} + + + + + ); + }) + ) : ( + // No packs found + + + No packs found + + + )} + + + ); +} + +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, + } +}); \ No newline at end of file diff --git a/docs/design/POWR Pack/POWRPack.md b/docs/design/POWR Pack/POWRPack.md new file mode 100644 index 0000000..ef908d8 --- /dev/null +++ b/docs/design/POWR Pack/POWRPack.md @@ -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) \ No newline at end of file diff --git a/docs/design/POWR Pack/POWR_Pack_Implementation_Plan.md b/docs/design/POWR Pack/POWR_Pack_Implementation_Plan.md new file mode 100644 index 0000000..abcb0ff --- /dev/null +++ b/docs/design/POWR Pack/POWR_Pack_Implementation_Plan.md @@ -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 { + 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 { + await this.db.runAsync( + 'UPDATE templates SET is_archived = ? WHERE id = ?', + [archive ? 1 : 0, id] + ); +} + +async removeFromLibrary(id: string): Promise { + 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 { + // 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 { + const parameters: Record = {}; + + // 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, 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; + getDefaultParameters(): Record; + formatTemplateConfig(config: Record): string[][]; +} + +// Example implementation for EMOM workouts +class EMOMWorkoutProcessor implements WorkoutTypeProcessor { + parseTemplateConfig(tags: string[][]): Record { + const config: Record = { + 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 { + return { + rounds: 10, + interval: 60, + rest: 0 + }; + } + + formatTemplateConfig(config: Record): 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 { + // 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[]> = new Map(); + + // Register a processor for a specific kind + registerProcessor(kind: number, processor: NostrEventProcessor): void { + if (!this.processors.has(kind)) { + this.processors.set(kind, []); + } + + this.processors.get(kind)?.push(processor); + } + + // Get appropriate processor for an event + getProcessor(event: NostrEvent): NostrEventProcessor | 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; + } + } + + 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; + down(db: SQLiteDatabase): Promise; +} + +// 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 { + // 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 { + // Implementation + return 0; + } + + private async updateVersion(db: SQLiteDatabase, version: number): Promise { + // 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. \ No newline at end of file diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 0b8faeb..78118cf 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -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 { @@ -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) { diff --git a/lib/db/services/NostrIntegration.ts b/lib/db/services/NostrIntegration.ts new file mode 100644 index 0000000..c198dee --- /dev/null +++ b/lib/db/services/NostrIntegration.ts @@ -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 { + 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 { + 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 { + 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; + } + } +} \ No newline at end of file diff --git a/lib/db/services/POWRPackService.ts b/lib/db/services/POWRPackService.ts new file mode 100644 index 0000000..320f60f --- /dev/null +++ b/lib/db/services/POWRPackService.ts @@ -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 { + 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 { + 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>((_, 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 { + const dependencies: Record = {}; + const exerciseMap: Record = {}; + + 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 { + 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(); // 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 { + 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 { + try { + // 1. Get all packs + const packs = await this.db.getAllAsync( + `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( + `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( + `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 { + try { + // 1. Get pack info + const pack = await this.db.getFirstAsync( + `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( + `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( + `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 { + 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( + `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; \ No newline at end of file diff --git a/lib/db/services/TemplateService.ts b/lib/db/services/TemplateService.ts index 6458860..65becb3 100644 --- a/lib/db/services/TemplateService.ts +++ b/lib/db/services/TemplateService.ts @@ -20,6 +20,13 @@ export class TemplateService { */ async getAllTemplates(limit: number = 50, offset: number = 0): Promise { 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 { 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 { try { diff --git a/lib/hooks/useExercises.tsx b/lib/hooks/useExercises.ts similarity index 100% rename from lib/hooks/useExercises.tsx rename to lib/hooks/useExercises.ts diff --git a/lib/hooks/usePOWRpacks.ts b/lib/hooks/usePOWRpacks.ts new file mode 100644 index 0000000..3d44347 --- /dev/null +++ b/lib/hooks/usePOWRpacks.ts @@ -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([]); + 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 => { + 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 + }; +} \ No newline at end of file diff --git a/lib/hooks/useProfile.tsx b/lib/hooks/useProfile.ts similarity index 100% rename from lib/hooks/useProfile.tsx rename to lib/hooks/useProfile.ts diff --git a/types/powr-pack.ts b/types/powr-pack.ts new file mode 100644 index 0000000..3769207 --- /dev/null +++ b/types/powr-pack.ts @@ -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; +} \ No newline at end of file