feat(ios): Prepare app for TestFlight submission

UI enhancements and production optimizations:
- Added production flag in theme constants
- Hid development-only Programs tab in production builds
- Removed debug UI elements and debug logs from social feed
- Fixed workout completion flow UI issues (input styling, borders, spacing)
- Made improvements to exercise name resolution in feeds
- Standardized form element spacing and styling
- Enhanced multiline inputs with consistent design system

Note: Exercise name resolution in social feed still needs additional work
This commit is contained in:
DocNR 2025-04-06 23:26:55 -04:00
parent 9ad50956f8
commit 5ff311bc4a
15 changed files with 1183 additions and 351 deletions

View File

@ -5,6 +5,33 @@ 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Removed
- Removed "Use Template" button from the home screen for MVP
- Simplified UI to focus on Quick Start option for MVP
- Preserved underlying functionality for future re-implementation
### Fixed
- UI issues in workout completion flow
- Fixed weird line across input box in the 1301 event content box by adding subtle border styling
- Removed template options section that was causing bugs with workout templates
- Updated input styling to use consistent design across all multiline inputs
- Made input boxes clearly visible in light mode with thin borders
- Standardized spacing between input fields and buttons
### Fixed
- Exercise display improvements in social feeds and history
- Fixed exercise IDs showing instead of proper names in social feeds and history tabs
- Enhanced exercise name resolution with improved POWR format ID handling
- Enhanced exercise name resolution in workout history service
- Improved exercise name extraction for social feed posts
- Added comprehensive pattern matching for common exercises
- Added fallback naming strategies for unrecognized exercise IDs
- Implemented database lookup system for exercise titles
- Added name resolution for UUID-based exercise IDs in social feed
- Enhanced workout templates to display proper exercise names
- Resolved exercise titles in social feed posts via database integration
- Added specific handling for "local:" prefixed IDs in exercise naming
- Improved debug logging for exercise name resolution
- Enhanced EnhancedSocialPost components with multi-strategy name resolution
### Added ### Added
- Authentication persistence debugging tools - Authentication persistence debugging tools
- Created dedicated AuthPersistenceTest screen for diagnosing credential issues - Created dedicated AuthPersistenceTest screen for diagnosing credential issues
@ -26,6 +53,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved user experience during temporary API failures - Improved user experience during temporary API failures
### Improved ### Improved
- Workout completion flow and publishing process
- Added workout description field for better context in workout records
- Enhanced NostrWorkoutService with client tag for better Nostr client display
- Improved social message formatting with comprehensive workout details
- Added publication state tracking (saving, publishing, error states)
- Enhanced error handling with descriptive messages
- Added visual indicators for publishing progress
- Implemented navigation guards to prevent back navigation during publishing
- Added formatted workout summary in social posts (date, time, duration, volume)
- Improved accessibility with disabled UI elements during publishing
- Authentication initialization sequence - Authentication initialization sequence
- Added proper awaiting of NDK relay connections - Added proper awaiting of NDK relay connections
- Implemented credential migration before authentication starts - Implemented credential migration before authentication starts
@ -685,53 +723,4 @@ g
- Created POWRPackService for fetching, importing, and managing packs - Created POWRPackService for fetching, importing, and managing packs
- Built NostrIntegration helper for conversion between Nostr events and local models - Built NostrIntegration helper for conversion between Nostr events and local models
- Implemented interface to browse and import workout packs from the community - Implemented interface to browse and import workout packs from the community
- Added pack management screen with import/delete functionality - Added pack management screen
- 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
- Relay management system
- Added relays table to SQLite schema (version 3)
- Created RelayService for database operations
- Implemented RelayStore using Zustand for state management
- Added compatibility layer for NDK and NDK-mobile
- Added relay management UI in settings drawer
- Implemented relay connection status tracking
- Added support for read/write permissions
- Created relay initialization system with defaults
## Improved
- Enhanced NDK initialization
- Added proper relay configuration loading
- Improved connection status tracking
- Enhanced error handling for relay operations
- Settings drawer enhancements
- Added relay management option
- Improved navigation structure
- Enhanced user interface
- NDK compatibility
- Created universal interfaces for NDK implementations
- Added type safety for complex operations
- Improved error handling throughout relay management
# Changelog - March 8,

View File

@ -249,7 +249,7 @@ export default function WorkoutScreen() {
Begin a new workout or choose from one of your templates. Begin a new workout or choose from one of your templates.
</Text> </Text>
{/* Buttons from HomeWorkout but directly here */} {/* Quick Start button */}
<View className="gap-4"> <View className="gap-4">
<Button <Button
variant="default" // This ensures it uses the primary purple color variant="default" // This ensures it uses the primary purple color
@ -259,14 +259,6 @@ export default function WorkoutScreen() {
> >
<Text className="text-white font-medium">Quick Start</Text> <Text className="text-white font-medium">Quick Start</Text>
</Button> </Button>
<Button
variant="outline"
className="w-full"
onPress={handleSelectTemplate}
>
<Text>Use Template</Text>
</Button>
</View> </View>
</View> </View>
@ -393,4 +385,4 @@ export default function WorkoutScreen() {
</AlertDialog> </AlertDialog>
</TabScreen> </TabScreen>
); );
} }

View File

@ -1,6 +1,6 @@
// app/(workout)/complete.tsx // app/(workout)/complete.tsx
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { View, Modal, TouchableOpacity } from 'react-native'; import { View, Modal, TouchableOpacity, Alert } from 'react-native';
import { router } from 'expo-router'; import { router } from 'expo-router';
import { Text } from '@/components/ui/text'; import { Text } from '@/components/ui/text';
import { X } from 'lucide-react-native'; import { X } from 'lucide-react-native';
@ -25,7 +25,7 @@ import { useColorScheme } from '@/lib/theme/useColorScheme';
* was set when the user confirmed finishing in the create screen. * was set when the user confirmed finishing in the create screen.
*/ */
export default function CompleteWorkoutScreen() { export default function CompleteWorkoutScreen() {
const { resumeWorkout, activeWorkout } = useWorkoutStore(); const { resumeWorkout, activeWorkout, isPublishing, publishingStatus } = useWorkoutStore();
const { isDarkColorScheme } = useColorScheme(); const { isDarkColorScheme } = useColorScheme();
// Check if we have a workout to complete // Check if we have a workout to complete
@ -44,8 +44,21 @@ export default function CompleteWorkoutScreen() {
await completeWorkout(options); await completeWorkout(options);
}; };
// Handle cancellation (go back to workout) // Check if we can safely close the modal
const canClose = !isPublishing;
// Handle cancellation (go back to workout) with safety check
const handleCancel = () => { const handleCancel = () => {
if (!canClose) {
// Show alert about publishing in progress
Alert.alert(
"Publishing in Progress",
"Please wait for publishing to complete before going back.",
[{ text: "OK" }]
);
return;
}
// Go back to the workout screen // Go back to the workout screen
router.back(); router.back();
}; };
@ -65,7 +78,12 @@ export default function CompleteWorkoutScreen() {
{/* Header */} {/* Header */}
<View className="flex-row justify-between items-center p-4 border-b border-border"> <View className="flex-row justify-between items-center p-4 border-b border-border">
<Text className="text-xl font-bold text-foreground">Complete Workout</Text> <Text className="text-xl font-bold text-foreground">Complete Workout</Text>
<TouchableOpacity onPress={handleCancel} className="p-1"> <TouchableOpacity
onPress={handleCancel}
className="p-1"
disabled={!canClose}
style={{ opacity: canClose ? 1 : 0.5 }}
>
<X size={24} /> <X size={24} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>

View File

@ -13,6 +13,7 @@ import { logDatabaseInfo } from '@/lib/db/debug';
import { useNDKStore } from '@/lib/stores/ndk'; import { useNDKStore } from '@/lib/stores/ndk';
import { useLibraryStore } from '@/lib/stores/libraryStore'; import { useLibraryStore } from '@/lib/stores/libraryStore';
import { createLogger, setQuietMode } from '@/lib/utils/logger'; import { createLogger, setQuietMode } from '@/lib/utils/logger';
import { setDatabaseConnection } from '@/types/nostr-workout';
// Create database-specific logger // Create database-specific logger
const logger = createLogger('DatabaseProvider'); const logger = createLogger('DatabaseProvider');
@ -107,6 +108,14 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) {
React.useEffect(() => { React.useEffect(() => {
if (isReady && services.db) { if (isReady && services.db) {
console.log('[DB] Database ready - triggering initial library refresh'); console.log('[DB] Database ready - triggering initial library refresh');
// Set database for exercise title lookups
try {
const { setDatabaseConnection } = require('@/types/nostr-workout');
setDatabaseConnection(services.db);
console.log('[DB] Database connection set for exercise title lookups');
} catch (error) {
console.error('[DB] Failed to set database for exercise title lookups:', error);
}
// Refresh all library data // Refresh all library data
useLibraryStore.getState().refreshAll(); useLibraryStore.getState().refreshAll();
} }
@ -234,6 +243,10 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) {
db, db,
}); });
// Set database connection for exercise title lookups
setDatabaseConnection(db);
console.log('[DB] Database connection set for exercise title lookups');
// Display database info in development mode // Display database info in development mode
if (__DEV__) { if (__DEV__) {
await logDatabaseInfo(); await logDatabaseInfo();

View File

@ -18,6 +18,7 @@ import {
} from '@/types/nostr-workout'; } from '@/types/nostr-workout';
import { formatDistance } from 'date-fns'; import { formatDistance } from 'date-fns';
import Markdown from 'react-native-markdown-display'; import Markdown from 'react-native-markdown-display';
import { useExerciseNames, useTemplateExerciseNames } from '@/lib/hooks/useExerciseNames';
// Helper functions for all components to use // Helper functions for all components to use
// Format timestamp // Format timestamp
@ -236,6 +237,33 @@ export default function EnhancedSocialPost({ item, onPress }: SocialPostProps) {
// Component for workout records // Component for workout records
function WorkoutContent({ workout }: { workout: ParsedWorkoutRecord }) { function WorkoutContent({ workout }: { workout: ParsedWorkoutRecord }) {
// Use our React Query hook for resolving exercise names
const {
data: exerciseNames = {},
isLoading,
isError
} = useExerciseNames(workout);
// Add enhanced logging for debugging
useEffect(() => {
console.log('[DEBUG] Original workout exercises:', workout.exercises);
console.log('[DEBUG] Resolved exercise names:', exerciseNames);
}, [workout.exercises, exerciseNames]);
// Log status for debugging
useEffect(() => {
if (isLoading) {
console.log('[WorkoutContent] Loading exercise names...');
} else if (isError) {
console.error('[WorkoutContent] Error loading exercise names');
} else if (exerciseNames) {
// Log more details about each exercise ID for debugging
Object.entries(exerciseNames).forEach(([id, name]) => {
console.log(`[WorkoutContent] Exercise ${id} resolved to: ${name}`);
});
}
}, [isLoading, isError, exerciseNames]);
return ( return (
<View> <View>
<Text className="text-lg font-semibold mb-2">{workout.title}</Text> <Text className="text-lg font-semibold mb-2">{workout.title}</Text>
@ -262,14 +290,53 @@ function WorkoutContent({ workout }: { workout: ParsedWorkoutRecord }) {
{workout.exercises.length > 0 && ( {workout.exercises.length > 0 && (
<View> <View>
<Text className="font-medium mb-1">Exercises:</Text> <Text className="font-medium mb-1">Exercises:</Text>
{workout.exercises.slice(0, 3).map((exercise, index) => ( {workout.exercises.slice(0, 3).map((exercise, index) => {
<Text key={index} className="text-sm"> // Get the exercise ID
{exercise.name} const exerciseId = exercise.id;
{exercise.weight ? ` - ${exercise.weight}kg` : ''}
{exercise.reps ? ` × ${exercise.reps}` : ''} // Enhanced name resolution with multiple strategies
{exercise.rpe ? ` @ RPE ${exercise.rpe}` : ''} let displayName;
</Text>
))} // Strategy 1: Check exerciseNames from useExerciseNames hook
if (exerciseNames[exerciseId]) {
displayName = exerciseNames[exerciseId];
console.log(`[SocialFeed] Using resolved name for ${exerciseId}: ${displayName}`);
}
// Strategy 2: Check existing name if it's meaningful
else if (exercise.name && exercise.name !== 'Exercise' && exercise.name !== 'Unknown Exercise') {
displayName = exercise.name;
console.log(`[SocialFeed] Using original name for ${exerciseId}: ${displayName}`);
}
// Strategy 3: Format POWR-specific IDs nicely
else if (exerciseId && exerciseId.match(/^m[a-z0-9]{7}-[a-z0-9]{10}$/i)) {
displayName = `Exercise ${exerciseId.substring(1, 5).toUpperCase()}`;
console.log(`[SocialFeed] Formatting POWR ID ${exerciseId} to: ${displayName}`);
}
// Strategy 4: Handle "local:" prefix
else if (exerciseId && exerciseId.startsWith('local:')) {
const localId = exerciseId.substring(6);
if (localId.match(/^m[a-z0-9]{7}-[a-z0-9]{10}$/i)) {
displayName = `Exercise ${localId.substring(1, 5).toUpperCase()}`;
console.log(`[SocialFeed] Formatting local POWR ID ${exerciseId} to: ${displayName}`);
} else {
displayName = `Exercise ${index + 1}`;
}
}
// Strategy 5: Last resort fallback
else {
displayName = `Exercise ${index + 1}`;
console.log(`[SocialFeed] Using fallback name for ${exerciseId}: ${displayName}`);
}
return (
<Text key={index} className="text-sm">
{displayName}
{exercise.weight ? ` - ${exercise.weight}kg` : ''}
{exercise.reps ? ` × ${exercise.reps}` : ''}
{exercise.rpe ? ` @ RPE ${exercise.rpe}` : ''}
</Text>
);
})}
{workout.exercises.length > 3 && ( {workout.exercises.length > 3 && (
<Text className="text-xs text-muted-foreground mt-1"> <Text className="text-xs text-muted-foreground mt-1">
+{workout.exercises.length - 3} more exercises +{workout.exercises.length - 3} more exercises
@ -336,6 +403,28 @@ function ExerciseContent({ exercise }: { exercise: ParsedExerciseTemplate }) {
// Component for workout templates // Component for workout templates
function TemplateContent({ template }: { template: ParsedWorkoutTemplate }) { function TemplateContent({ template }: { template: ParsedWorkoutTemplate }) {
// Use our React Query hook for resolving template exercise names
const {
data: exerciseNames = {},
isLoading,
isError
} = useTemplateExerciseNames(template.id, template.exercises);
// Log status for debugging with more detailed information
useEffect(() => {
if (isLoading) {
console.log('[TemplateContent] Loading exercise names...');
} else if (isError) {
console.error('[TemplateContent] Error loading exercise names');
} else if (exerciseNames) {
// Log more details about each exercise ID for debugging
Object.entries(exerciseNames).forEach(([id, name]) => {
console.log(`[TemplateContent] Exercise ${id} resolved to: ${name}`);
});
console.log('[TemplateContent] Exercise names loaded:', exerciseNames);
}
}, [isLoading, isError, exerciseNames]);
return ( return (
<View> <View>
<Text className="text-lg font-semibold mb-2">{template.title}</Text> <Text className="text-lg font-semibold mb-2">{template.title}</Text>
@ -368,11 +457,36 @@ function TemplateContent({ template }: { template: ParsedWorkoutTemplate }) {
{template.exercises.length > 0 && ( {template.exercises.length > 0 && (
<View> <View>
<Text className="font-medium mb-1">Exercises:</Text> <Text className="font-medium mb-1">Exercises:</Text>
{template.exercises.slice(0, 3).map((exercise, index) => ( {template.exercises.slice(0, 3).map((exercise, index) => {
<Text key={index} className="text-sm"> // Get the exercise ID for better debugging
{exercise.name || 'Exercise ' + (index + 1)} const exerciseId = exercise.reference;
</Text>
))} // First try to get the name from exerciseNames
let displayName = exerciseNames[exerciseId];
// If no name found, try to use the existing name
if (!displayName && exercise.name && exercise.name !== 'Exercise') {
displayName = exercise.name;
}
// Special handling for POWR-specific ID format (Mxxxxxxx-xxxxxxxxxx)
if (!displayName && exerciseId && exerciseId.match(/^m[a-z0-9]{7}-[a-z0-9]{10}$/i)) {
// Create a better-looking name from the ID: first 4 chars after the 'm'
displayName = `Exercise ${exerciseId.substring(1, 5).toUpperCase()}`;
console.log(`[TemplateContent] Formatted POWR ID ${exerciseId} to ${displayName}`);
}
// Final fallback
if (!displayName) {
displayName = `Exercise ${index + 1}`;
}
return (
<Text key={index} className="text-sm">
{displayName}
</Text>
);
})}
{template.exercises.length > 3 && ( {template.exercises.length > 3 && (
<Text className="text-xs text-muted-foreground mt-1"> <Text className="text-xs text-muted-foreground mt-1">
+{template.exercises.length - 3} more exercises +{template.exercises.length - 3} more exercises
@ -504,16 +618,58 @@ function ArticleQuote({ article }: { article: ParsedLongformContent }) {
// Simplified versions of content for quoted posts // Simplified versions of content for quoted posts
function WorkoutQuote({ workout }: { workout: ParsedWorkoutRecord }) { function WorkoutQuote({ workout }: { workout: ParsedWorkoutRecord }) {
// Use our hook for resolving exercise names
const { data: exerciseNames = {} } = useExerciseNames(workout);
// Count properly named exercises for better display
const namedExerciseCount = workout.exercises.slice(0, 2).map(ex => {
const exerciseId = ex.id;
// Enhanced name resolution with multiple strategies
let displayName;
// Strategy 1: Check exerciseNames from hook
if (exerciseNames[exerciseId]) {
displayName = exerciseNames[exerciseId];
}
// Strategy 2: Check existing name if it's meaningful
else if (ex.name && ex.name !== 'Exercise' && ex.name !== 'Unknown Exercise') {
displayName = ex.name;
}
// Strategy 3: Format POWR-specific IDs nicely
else if (exerciseId && exerciseId.match(/^m[a-z0-9]{7}-[a-z0-9]{10}$/i)) {
displayName = `Exercise ${exerciseId.substring(1, 5).toUpperCase()}`;
}
// Strategy 4: Handle "local:" prefix
else if (exerciseId && exerciseId.startsWith('local:')) {
const localId = exerciseId.substring(6);
if (localId.match(/^m[a-z0-9]{7}-[a-z0-9]{10}$/i)) {
displayName = `Exercise ${localId.substring(1, 5).toUpperCase()}`;
} else {
displayName = `Exercise`;
}
}
// Final fallback
else {
displayName = `Exercise`;
}
return displayName;
});
return ( return (
<View> <View>
<Text className="font-medium">{workout.title}</Text> <Text className="font-medium">{workout.title}</Text>
<Text className="text-sm text-muted-foreground"> <Text className="text-sm text-muted-foreground">
{workout.exercises.length} exercises { {workout.exercises.length} exercises {
workout.startTime && workout.endTime ? namedExerciseCount.length > 0 ? namedExerciseCount.join(', ') : ''
formatDuration(workout.endTime - workout.startTime) : } {namedExerciseCount.length < workout.exercises.length && workout.exercises.length > 2 ? '...' : ''}
'Duration N/A'
}
</Text> </Text>
{workout.startTime && workout.endTime && (
<Text className="text-xs text-muted-foreground">
Duration: {formatDuration(workout.endTime - workout.startTime)}
</Text>
)}
</View> </View>
); );
} }

View File

@ -215,11 +215,16 @@ export const WorkoutCard: React.FC<EnhancedWorkoutCardProps> = ({
<Text className="text-foreground font-semibold mb-1">Exercise</Text> <Text className="text-foreground font-semibold mb-1">Exercise</Text>
{/* In a real implementation, you would map through actual exercises */} {/* In a real implementation, you would map through actual exercises */}
{workout.exercises && workout.exercises.length > 0 ? ( {workout.exercises && workout.exercises.length > 0 ? (
workout.exercises.slice(0, 3).map((exercise, idx) => ( workout.exercises.slice(0, 3).map((exercise, idx) => {
<Text key={idx} className="text-foreground mb-1"> // Use the exercise title directly
{exercise.title} const exerciseTitle = exercise.title || 'Exercise';
</Text>
)) return (
<Text key={idx} className="text-foreground mb-1">
{exerciseTitle}
</Text>
);
})
) : ( ) : (
<Text className="text-muted-foreground">No exercises recorded</Text> <Text className="text-muted-foreground">No exercises recorded</Text>
)} )}

View File

@ -1,6 +1,6 @@
// components/workout/WorkoutCompletionFlow.tsx // components/workout/WorkoutCompletionFlow.tsx
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { View, TouchableOpacity, ScrollView } from 'react-native'; import { View, TouchableOpacity, ScrollView, ActivityIndicator } from 'react-native';
import { Text } from '@/components/ui/text'; import { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
@ -20,6 +20,7 @@ import {
Cog Cog
} from 'lucide-react-native'; } from 'lucide-react-native';
import { TemplateService } from '@/lib/db/services/TemplateService'; import { TemplateService } from '@/lib/db/services/TemplateService';
import { NostrWorkoutService } from '@/lib/db/services/NostrWorkoutService';
import Confetti from '@/components/Confetti'; import Confetti from '@/components/Confetti';
/** /**
@ -178,109 +179,41 @@ function StorageOptionsTab({
</TouchableOpacity> </TouchableOpacity>
)} )}
{/* Template options section - only if needed */} {/* Workout Description field and Next button */}
{hasTemplateChanges && ( <View>
<> <Text className="text-lg font-semibold text-foreground">Workout Notes</Text>
<Separator className="my-2" /> <Text className="text-muted-foreground mb-2">
Add context or details about your workout
<Text className="text-lg font-semibold text-foreground">Template Options</Text> </Text>
<Text className="text-muted-foreground"> <Input
Your workout includes modifications to the original template multiline
</Text> numberOfLines={4}
placeholder="Tough upper body workout today. Feeling stronger!"
{/* Keep original option */} value={options.workoutDescription || ''}
<TouchableOpacity onChangeText={(text) => setOptions({
onPress={() => handleTemplateAction('keep_original')} ...options,
activeOpacity={0.7} workoutDescription: text
> })}
<Card className="min-h-[100px] p-3 bg-background dark:bg-muted border-[0.5px] border-input dark:border-0"
style={options.templateAction === 'keep_original' ? { style={{ marginBottom: 16 }}
borderColor: purpleColor, />
borderWidth: 1.5, </View>
} : {}}
>
<CardContent className="p-4">
<View>
<Text className="font-medium text-foreground">Keep Original</Text>
<Text className="text-sm text-muted-foreground">
Don't update the template
</Text>
</View>
</CardContent>
</Card>
</TouchableOpacity>
{/* Update existing option */}
<TouchableOpacity
onPress={() => handleTemplateAction('update_existing')}
activeOpacity={0.7}
>
<Card
style={options.templateAction === 'update_existing' ? {
borderColor: purpleColor,
borderWidth: 1.5,
} : {}}
>
<CardContent className="p-4">
<View>
<Text className="font-medium text-foreground">Update Template</Text>
<Text className="text-sm text-muted-foreground">
Save these changes to the original template
</Text>
</View>
</CardContent>
</Card>
</TouchableOpacity>
{/* Save as new option */}
<TouchableOpacity
onPress={() => handleTemplateAction('save_as_new')}
activeOpacity={0.7}
>
<Card
style={options.templateAction === 'save_as_new' ? {
borderColor: purpleColor,
borderWidth: 1.5,
} : {}}
>
<CardContent className="p-4">
<View>
<Text className="font-medium text-foreground">Save as New</Text>
<Text className="text-sm text-muted-foreground">
Create a new template from this workout
</Text>
</View>
</CardContent>
</Card>
</TouchableOpacity>
{/* Template name input if save as new is selected */}
{options.templateAction === 'save_as_new' && (
<View className="mt-2 mb-4">
<Text className="text-sm mb-2">New template name:</Text>
<Input
placeholder="My Custom Template"
value={options.newTemplateName || activeWorkout?.title || ''}
onChangeText={(text) => setOptions({
...options,
newTemplateName: text
})}
/>
</View>
)}
</>
)}
{/* Next button */} {/* Next button with direct styling */}
<Button <Button
onPress={onNext} onPress={onNext}
className="w-full mb-6" className="w-full"
style={{ backgroundColor: purpleColor }} style={{
backgroundColor: purpleColor,
marginTop: 16
}}
> >
<Text className="text-white font-medium"> <Text className="text-white font-medium">
Next Next
</Text> </Text>
</Button> </Button>
{/* Template options section removed as it was causing bugs */}
</View> </View>
</ScrollView> </ScrollView>
); );
@ -507,34 +440,28 @@ function CelebrationTab({
onComplete: (options: WorkoutCompletionOptions) => void onComplete: (options: WorkoutCompletionOptions) => void
}) { }) {
const { isAuthenticated } = useNDKCurrentUser(); const { isAuthenticated } = useNDKCurrentUser();
const { activeWorkout } = useWorkoutStore(); const { activeWorkout, isPublishing, publishingStatus, publishError } = useWorkoutStore();
const [showConfetti, setShowConfetti] = useState(true); const [showConfetti, setShowConfetti] = useState(true);
const [shareMessage, setShareMessage] = useState(''); const [shareMessage, setShareMessage] = useState('');
// Purple color used throughout the app // Purple color used throughout the app
const purpleColor = 'hsl(261, 90%, 66%)'; const purpleColor = 'hsl(261, 90%, 66%)';
// Generate default share message // Disable buttons during publishing
const isDisabled = isPublishing;
// Generate default share message on load
useEffect(() => { useEffect(() => {
// Create default message based on workout data
let message = "Just completed a workout! 💪";
if (activeWorkout) { if (activeWorkout) {
const exerciseCount = activeWorkout.exercises.length; // Use the enhanced formatter from NostrWorkoutService
const completedSets = activeWorkout.exercises.reduce( const formattedMessage = NostrWorkoutService.createFormattedSocialMessage(
(total, exercise) => total + exercise.sets.filter(set => set.isCompleted).length, 0 activeWorkout,
"Just completed a workout! 💪"
); );
setShareMessage(formattedMessage);
// Add workout details } else {
message = `Just completed a workout with ${exerciseCount} exercises and ${completedSets} sets! 💪`; setShareMessage("Just completed a workout! 💪 #powr #nostr");
// Add mock PR info
if (Math.random() > 0.5) {
message += " Hit some new PRs today! 🏆";
}
} }
setShareMessage(message);
}, [activeWorkout]); }, [activeWorkout]);
const handleShare = () => { const handleShare = () => {
@ -573,6 +500,25 @@ function CelebrationTab({
</Text> </Text>
</View> </View>
{/* Show publishing status */}
{isPublishing && (
<View className="mb-4 p-4 bg-primary/10 rounded-lg w-full">
<Text className="text-foreground font-medium mb-2 text-center">
{publishingStatus === 'saving' && 'Saving workout...'}
{publishingStatus === 'publishing-workout' && 'Publishing workout record...'}
{publishingStatus === 'publishing-social' && 'Sharing to Nostr...'}
</Text>
<ActivityIndicator size="small" className="my-2" />
</View>
)}
{/* Show error if any */}
{publishError && (
<View className="mb-4 p-4 bg-destructive/10 rounded-lg w-full">
<Text className="text-destructive font-medium">{publishError}</Text>
</View>
)}
{/* Show sharing options for Nostr if appropriate */} {/* Show sharing options for Nostr if appropriate */}
{isAuthenticated && options.storageType !== 'local_only' && ( {isAuthenticated && options.storageType !== 'local_only' && (
<> <>
@ -592,13 +538,18 @@ function CelebrationTab({
numberOfLines={4} numberOfLines={4}
value={shareMessage} value={shareMessage}
onChangeText={setShareMessage} onChangeText={setShareMessage}
className="min-h-[120px] p-3 mb-4" className="min-h-[120px] p-3 mb-4 bg-background dark:bg-muted border-[0.5px] border-input dark:border-0"
editable={!isDisabled}
/> />
<Button <Button
className="w-full mb-3" className="w-full mb-3"
style={{ backgroundColor: purpleColor }} style={{
backgroundColor: purpleColor,
opacity: isDisabled ? 0.5 : 1
}}
onPress={handleShare} onPress={handleShare}
disabled={isDisabled}
> >
<Text className="text-white font-medium"> <Text className="text-white font-medium">
Share to Nostr Share to Nostr
@ -609,6 +560,8 @@ function CelebrationTab({
variant="outline" variant="outline"
className="w-full" className="w-full"
onPress={handleSkip} onPress={handleSkip}
disabled={isDisabled}
style={{ opacity: isDisabled ? 0.5 : 1 }}
> >
<Text> <Text>
Skip Sharing Skip Sharing
@ -622,7 +575,11 @@ function CelebrationTab({
{(options.storageType === 'local_only' || !isAuthenticated) && ( {(options.storageType === 'local_only' || !isAuthenticated) && (
<Button <Button
className="w-full mt-4" className="w-full mt-4"
style={{ backgroundColor: purpleColor }} style={{
backgroundColor: purpleColor,
opacity: isDisabled ? 0.5 : 1
}}
disabled={isDisabled}
onPress={handleSkip} onPress={handleSkip}
> >
<Text className="text-white font-medium"> <Text className="text-white font-medium">
@ -706,4 +663,4 @@ export function WorkoutCompletionFlow({
{renderStep()} {renderStep()}
</View> </View>
); );
} }

View File

@ -27,7 +27,25 @@ export class NostrWorkoutService {
* Creates a social share event that quotes the workout record * Creates a social share event that quotes the workout record
*/ */
static createSocialShareEvent(workoutEventId: string, message: string): NostrEvent { static createSocialShareEvent(workoutEventId: string, message: string): NostrEvent {
return this.createShareEvent(workoutEventId, '1301', message); return {
kind: 1,
content: message,
tags: [
// Quote the event
['q', workoutEventId],
// Add kind tag
['k', '1301'],
// Add standard fitness tag
['t', 'fitness'],
// Add workout tag
['t', 'workout'],
// Add powr tag
['t', 'powr'],
// Add client tag for Nostr client display
['client', 'POWR App']
],
created_at: Math.floor(Date.now() / 1000)
};
} }
/** /**
@ -66,6 +84,8 @@ export class NostrWorkoutService {
['t', 'fitness'], ['t', 'fitness'],
// Add content-specific tags // Add content-specific tags
...contentTypeTags, ...contentTypeTags,
// Add client tag
['client', 'POWR App'],
// Add any additional tags // Add any additional tags
...additionalTags ...additionalTags
], ],
@ -73,6 +93,59 @@ export class NostrWorkoutService {
}; };
} }
/**
* Creates a formatted social message for workout sharing
*/
static createFormattedSocialMessage(workout: Workout, customMessage?: string): string {
// Format date and time
const startDate = new Date(workout.startTime);
const endDate = workout.endTime ? new Date(workout.endTime) : new Date();
const formattedDate = startDate.toLocaleDateString();
const startTime = startDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
const endTime = endDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
// Calculate duration
const durationMs = endDate.getTime() - startDate.getTime();
const hours = Math.floor(durationMs / (1000 * 60 * 60));
const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60));
// Calculate total volume
const totalVolume = workout.exercises.reduce((total, ex) =>
total + ex.sets.reduce((sum, set) => sum + ((set.weight || 0) * (set.reps || 0)), 0), 0);
// Format the message
let formattedMessage =
`${customMessage || ''}\n\n` +
`#workout #${workout.type} #powr\n\n` +
`🏋️ ${workout.title}\n` +
`📅 ${formattedDate} ${startTime}-${endTime}\n` +
`⏱️ Duration: ${hours > 0 ? `${hours}h ` : ''}${minutes}m\n` +
`💪 Total Volume: ${totalVolume.toLocaleString()} kg\n\n`;
// Add exercise details
workout.exercises.forEach(exercise => {
// Just use one of a few basic emojis
let exerciseEmoji = "⚡"; // Zap as requested
// Or alternatively use a small set based on broad categories
if (exercise.type === "cardio") {
exerciseEmoji = "🏃";
} else if (exercise.type === "strength") {
exerciseEmoji = "🏋️";
}
formattedMessage += `${exerciseEmoji} ${exercise.title}\n`;
exercise.sets.filter(set => set.isCompleted).forEach((set, index) => {
formattedMessage += `${index + 1}. ${set.weight || 0} Kg x ${set.reps || 0} Reps\n`;
});
formattedMessage += '\n';
});
return formattedMessage;
}
/** /**
* Generic method to convert a workout to a Nostr event * Generic method to convert a workout to a Nostr event
* @param workout The workout data * @param workout The workout data
@ -439,4 +512,4 @@ export class NostrWorkoutService {
relay: templateTag[2] || '' relay: templateTag[2] || ''
}; };
} }
} }

View File

@ -557,8 +557,8 @@ export class UnifiedWorkoutHistoryService {
if (!parsedWorkout) continue; if (!parsedWorkout) continue;
// Convert to Workout type // Convert to Workout type - make sure to await it
const workout = this.convertParsedWorkoutToWorkout(parsedWorkout, event); const workout = await this.convertParsedWorkoutToWorkout(parsedWorkout, event);
workouts.push(workout); workouts.push(workout);
} catch (error) { } catch (error) {
console.error('Error parsing workout event:', error); console.error('Error parsing workout event:', error);
@ -575,7 +575,41 @@ export class UnifiedWorkoutHistoryService {
/** /**
* Convert a parsed workout record to a Workout object * Convert a parsed workout record to a Workout object
*/ */
private convertParsedWorkoutToWorkout(parsedWorkout: ParsedWorkoutRecord, event: NDKEvent): Workout { private async convertParsedWorkoutToWorkout(parsedWorkout: ParsedWorkoutRecord, event: NDKEvent): Promise<Workout> {
// First, resolve all exercise names before constructing the workout object
const exercises = await Promise.all(parsedWorkout.exercises.map(async (ex) => {
// Get a human-readable name for the exercise
// If ex.name is an ID (typically starts with numbers or contains special characters),
// use a more descriptive name based on the exercise type
const isLikelyId = /^[0-9]|:|\//.test(ex.name);
const exerciseName = isLikelyId
? await this.getExerciseNameFromId(ex.id)
: ex.name;
return {
id: ex.id,
title: exerciseName,
exerciseId: ex.id,
type: 'strength' as const,
category: 'Core' as const,
sets: [{
id: generateId('nostr'),
weight: ex.weight,
reps: ex.reps,
rpe: ex.rpe,
type: (ex.setType as any) || 'normal',
isCompleted: true
}],
isCompleted: true,
created_at: parsedWorkout.createdAt,
lastUpdated: parsedWorkout.createdAt,
availability: {
source: ['nostr' as 'nostr'] // Explicitly cast to StorageSource
},
tags: []
};
}));
return { return {
id: parsedWorkout.id, id: parsedWorkout.id,
title: parsedWorkout.title, title: parsedWorkout.title,
@ -586,38 +620,7 @@ export class UnifiedWorkoutHistoryService {
notes: parsedWorkout.notes, notes: parsedWorkout.notes,
created_at: parsedWorkout.createdAt, created_at: parsedWorkout.createdAt,
lastUpdated: parsedWorkout.createdAt, lastUpdated: parsedWorkout.createdAt,
exercises,
// Convert exercises
exercises: parsedWorkout.exercises.map(ex => {
// Get a human-readable name for the exercise
// If ex.name is an ID (typically starts with numbers or contains special characters),
// use a more descriptive name based on the exercise type
const isLikelyId = /^[0-9]|:|\//.test(ex.name);
const exerciseName = isLikelyId
? this.getExerciseNameFromId(ex.id) || `Exercise ${ex.id.substring(0, 4)}`
: ex.name;
return {
id: ex.id,
title: exerciseName,
exerciseId: ex.id,
type: 'strength',
category: 'Core',
sets: [{
id: generateId('nostr'),
weight: ex.weight,
reps: ex.reps,
rpe: ex.rpe,
type: (ex.setType as any) || 'normal',
isCompleted: true
}],
isCompleted: true,
created_at: parsedWorkout.createdAt,
lastUpdated: parsedWorkout.createdAt,
availability: { source: ['nostr'] },
tags: []
};
}),
// Add Nostr-specific metadata // Add Nostr-specific metadata
availability: { availability: {
@ -633,14 +636,25 @@ export class UnifiedWorkoutHistoryService {
* This method attempts to look up the exercise in the local database * This method attempts to look up the exercise in the local database
* or use a mapping of common exercise IDs to names * or use a mapping of common exercise IDs to names
*/ */
private getExerciseNameFromId(exerciseId: string): string | null { private async getExerciseNameFromId(exerciseId: string): Promise<string> {
try { try {
// Common exercise name mappings // First, try to get the exercise name from the database
const exercise = await this.db.getFirstAsync<{ title: string }>(
`SELECT title FROM exercises WHERE id = ?`,
[exerciseId]
);
if (exercise && exercise.title) {
return exercise.title;
}
// Expanded common exercise name mappings
const commonExercises: Record<string, string> = { const commonExercises: Record<string, string> = {
'bench-press': 'Bench Press', 'bench-press': 'Bench Press',
'squat': 'Squat', 'squat': 'Squat',
'deadlift': 'Deadlift', 'deadlift': 'Deadlift',
'shoulder-press': 'Shoulder Press', 'shoulder-press': 'Shoulder Press',
'overhead-press': 'Overhead Press',
'pull-up': 'Pull Up', 'pull-up': 'Pull Up',
'push-up': 'Push Up', 'push-up': 'Push Up',
'barbell-row': 'Barbell Row', 'barbell-row': 'Barbell Row',
@ -656,21 +670,100 @@ export class UnifiedWorkoutHistoryService {
'lunge': 'Lunge', 'lunge': 'Lunge',
'dip': 'Dip', 'dip': 'Dip',
'chin-up': 'Chin Up', 'chin-up': 'Chin Up',
'military-press': 'Military Press' 'military-press': 'Military Press',
// Add more common exercises
'chest-press': 'Chest Press',
'chest-fly': 'Chest Fly',
'row': 'Row',
'push-down': 'Push Down',
'lateral-raise': 'Lateral Raise',
'front-raise': 'Front Raise',
'rear-delt-fly': 'Rear Delt Fly',
'face-pull': 'Face Pull',
'shrug': 'Shrug',
'crunch': 'Crunch',
'russian-twist': 'Russian Twist',
'leg-raise': 'Leg Raise',
'glute-bridge': 'Glute Bridge',
'hip-thrust': 'Hip Thrust',
'back-extension': 'Back Extension',
'good-morning': 'Good Morning',
'rdl': 'Romanian Deadlift',
'romanian-deadlift': 'Romanian Deadlift',
'hack-squat': 'Hack Squat',
'front-squat': 'Front Squat',
'goblet-squat': 'Goblet Squat',
'bulgarian-split-squat': 'Bulgarian Split Squat',
'split-squat': 'Split Squat',
'step-up': 'Step Up',
'calf-press': 'Calf Press',
'seated-calf-raise': 'Seated Calf Raise',
'standing-calf-raise': 'Standing Calf Raise',
'preacher-curl': 'Preacher Curl',
'hammer-curl': 'Hammer Curl',
'concentration-curl': 'Concentration Curl',
'skull-crusher': 'Skull Crusher',
'tricep-pushdown': 'Tricep Pushdown',
'tricep-kickback': 'Tricep Kickback',
'cable-row': 'Cable Row',
'cable-fly': 'Cable Fly',
'cable-curl': 'Cable Curl',
'cable-extension': 'Cable Extension',
'cable-lateral-raise': 'Cable Lateral Raise',
'cable-face-pull': 'Cable Face Pull',
'machine-chest-press': 'Machine Chest Press',
'machine-shoulder-press': 'Machine Shoulder Press',
'machine-row': 'Machine Row',
'machine-lat-pulldown': 'Machine Lat Pulldown',
'machine-bicep-curl': 'Machine Bicep Curl',
'machine-tricep-extension': 'Machine Tricep Extension',
'machine-leg-press': 'Machine Leg Press',
'machine-leg-extension': 'Machine Leg Extension',
'machine-leg-curl': 'Machine Leg Curl',
'machine-calf-raise': 'Machine Calf Raise',
'smith-machine-squat': 'Smith Machine Squat',
'smith-machine-bench-press': 'Smith Machine Bench Press',
'smith-machine-shoulder-press': 'Smith Machine Shoulder Press',
'smith-machine-row': 'Smith Machine Row',
'smith-machine-calf-raise': 'Smith Machine Calf Raise',
'incline-bench-press': 'Incline Bench Press',
'incline-dumbbell-press': 'Incline Dumbbell Press',
'decline-bench-press': 'Decline Bench Press',
'decline-dumbbell-press': 'Decline Dumbbell Press',
'dumbbell-fly': 'Dumbbell Fly',
'dumbbell-row': 'Dumbbell Row',
'dumbbell-press': 'Dumbbell Press',
'dumbbell-curl': 'Dumbbell Curl',
'dumbbell-lateral-raise': 'Dumbbell Lateral Raise',
'dumbbell-front-raise': 'Dumbbell Front Raise',
'dumbbell-rear-delt-fly': 'Dumbbell Rear Delt Fly',
'dumbbell-shrug': 'Dumbbell Shrug',
'ez-bar-curl': 'EZ Bar Curl',
'ez-bar-skull-crusher': 'EZ Bar Skull Crusher',
'ez-bar-preacher-curl': 'EZ Bar Preacher Curl'
}; };
// Check if it's a common exercise // Check if it's a common exercise
for (const [key, name] of Object.entries(commonExercises)) { for (const [key, name] of Object.entries(commonExercises)) {
if (exerciseId.includes(key)) { if (exerciseId.toLowerCase().includes(key.toLowerCase())) {
return name; return name;
} }
} }
// Handle specific format seen in logs: "Exercise m8l4pk" // Handle specific format seen in logs: "Exercise m8l4pk" or other ID-like patterns
if (exerciseId.match(/^m[0-9a-z]{5,6}$/)) { if (exerciseId.match(/^[a-z][0-9a-z]{4,6}$/i) || exerciseId.match(/^[0-9a-f]{8,}$/i)) {
// This appears to be a short ID, return a generic name with a number // Look in the database again, but with a more flexible search
const shortId = exerciseId.substring(1, 4).toUpperCase(); const fuzzyMatch = await this.db.getFirstAsync<{ title: string }>(
return `Exercise ${shortId}`; `SELECT title FROM exercises WHERE id LIKE ? LIMIT 1`,
[`%${exerciseId.substring(0, 4)}%`]
);
if (fuzzyMatch && fuzzyMatch.title) {
return fuzzyMatch.title;
}
// If all else fails, convert the ID to a nicer format
return `Exercise ${exerciseId.substring(0, 4).toUpperCase()}`;
} }
// If not found in common exercises, try to extract a name from the ID // If not found in common exercises, try to extract a name from the ID
@ -999,8 +1092,8 @@ export class UnifiedWorkoutHistoryService {
throw new Error(`Failed to parse workout from event ${eventId}`); throw new Error(`Failed to parse workout from event ${eventId}`);
} }
// Convert to Workout type // Convert to Workout type - make sure to await it
const workout = this.convertParsedWorkoutToWorkout(parsedWorkout, event); const workout = await this.convertParsedWorkoutToWorkout(parsedWorkout, event);
// Update the source to include both local and Nostr // Update the source to include both local and Nostr
workout.availability.source = ['nostr', 'local']; workout.availability.source = ['nostr', 'local'];
@ -1106,11 +1199,12 @@ export class UnifiedWorkoutHistoryService {
}, { closeOnEose: false }); }, { closeOnEose: false });
// Handle events // Handle events
sub.on('event', (event: NDKEvent) => { sub.on('event', async (event: NDKEvent) => {
try { try {
const parsedWorkout = parseWorkoutRecord(event); const parsedWorkout = parseWorkoutRecord(event);
if (parsedWorkout) { if (parsedWorkout) {
const workout = this.convertParsedWorkoutToWorkout(parsedWorkout, event); // Make sure to await the async conversion
const workout = await this.convertParsedWorkoutToWorkout(parsedWorkout, event);
callback(workout); callback(workout);
} }
} catch (error) { } catch (error) {
@ -1220,7 +1314,7 @@ export class UnifiedWorkoutHistoryService {
created_at: exercise.created_at, created_at: exercise.created_at,
lastUpdated: exercise.updated_at, lastUpdated: exercise.updated_at,
isCompleted: mappedSets.every(set => set.isCompleted), isCompleted: mappedSets.every(set => set.isCompleted),
availability: { source: ['local'] } availability: { source: ['local' as 'local'] } // Explicitly cast to StorageSource
}); });
} }

View File

@ -202,7 +202,7 @@ export function useAuthQuery() {
case 'ephemeral': case 'ephemeral':
return authService.createEphemeralKey(); return authService.createEphemeralKey();
default: default:
logger.error("Invalid login method:", params.method); logger.error("Invalid login method:", (params as any).method);
throw new Error('Invalid login method'); throw new Error('Invalid login method');
} }
}, },

View File

@ -0,0 +1,294 @@
/**
* useExerciseNames - React Query hook to resolve exercise names from various formats
*
* This hook provides a standardized way to resolve exercise names from:
* 1. Local database IDs (format: "local:id-hash")
* 2. Global identifiers (format: "kind:pubkey:id")
* 3. UUIDs and other unique identifiers
*
* It implements a multi-step resolution process:
* 1. First tries using any existing name from the exercise object
* 2. Then attempts to parse the ID using pattern recognition
* 3. Finally falls back to database lookup if possible
*/
import { useQuery } from '@tanstack/react-query';
import { QUERY_KEYS } from '../queryKeys';
import { NDKEvent } from '@nostr-dev-kit/ndk-mobile';
import { createLogger } from '@/lib/utils/logger';
import { useNDK } from '@/lib/hooks/useNDK';
import {
ParsedWorkoutRecord,
ParsedTemplateExercise,
parseWorkoutRecord,
extractExerciseName,
lookupExerciseTitle
} from '@/types/nostr-workout';
// Create a module-specific logger that follows the app's logging system
const logger = createLogger('ExerciseNames');
// Type alias for the exercise resolution format
export type ExerciseNamesMap = Record<string, string>;
/**
* Hook for resolving exercise names from a Nostr workout event
*/
export function useExerciseNamesFromEvent(event: NDKEvent | null | undefined) {
const { ndk } = useNDK();
return useQuery({
queryKey: QUERY_KEYS.exercises.namesByEvent(event?.id || ''),
queryFn: async (): Promise<ExerciseNamesMap> => {
if (!event) return {};
try {
// Parse the workout record
const workout = parseWorkoutRecord(event);
return resolveExerciseNames(workout.exercises, ndk);
} catch (error) {
logger.error('Error resolving exercise names from event:', error);
return {};
}
},
// Cache for 15 minutes - exercise names don't change often
staleTime: 15 * 60 * 1000,
// Don't refetch on window focus - this data is stable
refetchOnWindowFocus: false,
// Enable only if we have an event
enabled: !!event,
});
}
/**
* Hook for resolving exercise names from an already parsed workout record
*/
export function useExerciseNames(workout: ParsedWorkoutRecord | null | undefined) {
const { ndk } = useNDK();
return useQuery({
queryKey: QUERY_KEYS.exercises.namesByWorkout(workout?.id || ''),
queryFn: async (): Promise<ExerciseNamesMap> => {
if (!workout || !workout.exercises) return {};
try {
return resolveExerciseNames(workout.exercises, ndk);
} catch (error) {
logger.error('Error resolving exercise names from workout:', error);
return {};
}
},
// Cache for 15 minutes - exercise names don't change often
staleTime: 15 * 60 * 1000,
// Don't refetch on window focus - this data is stable
refetchOnWindowFocus: false,
// Enable only if we have a workout with exercises
enabled: !!(workout?.exercises && workout.exercises.length > 0),
});
}
/**
* Hook for resolving exercise names from template exercises
*
* Enhanced to use the same comprehensive resolution strategy
* as regular exercise name resolution
*/
export function useTemplateExerciseNames(
templateId: string,
exercises: ParsedTemplateExercise[] | null | undefined
) {
const { ndk } = useNDK();
return useQuery({
queryKey: [...QUERY_KEYS.exercises.all, 'templateExercises', templateId],
queryFn: async (): Promise<ExerciseNamesMap> => {
if (!exercises || exercises.length === 0) return {};
// Convert template exercises to format compatible with resolveExerciseNames
const adaptedExercises = exercises.map(exercise => ({
id: exercise.reference,
name: exercise.name
}));
// Use the same resolution logic as for workout exercises
return resolveExerciseNames(adaptedExercises, ndk);
},
// Cache for 15 minutes
staleTime: 15 * 60 * 1000,
refetchOnWindowFocus: false,
enabled: !!(exercises && exercises.length > 0),
});
}
/**
* Helper function to resolve exercise names from an array of exercises
*/
/**
* Enhanced version of resolveExerciseNames that implements multiple resolution strategies
* to ensure we get readable exercise names in all contexts
*
* @param exercises Array of exercise objects to resolve names for
* @param ndk Optional NDK instance for direct event fetching
* @returns Map of exercise IDs to display names
*/
async function resolveExerciseNames(exercises: any[], ndk?: any): Promise<ExerciseNamesMap> {
if (!exercises || exercises.length === 0) return {};
logger.debug(`Resolving names for ${exercises.length} exercises`);
if (exercises[0]) {
logger.debug(`Exercise data sample:`, exercises[0]);
}
const names: ExerciseNamesMap = {};
// RESOLUTION STRATEGY 0: Use NDK to fetch exercise events directly
if (ndk) {
// Get all exercise IDs that might be Nostr references (kind 33401)
const nostrRefs = exercises
.map(ex => {
const id = typeof ex === 'string' ? ex :
'id' in ex ? ex.id :
'reference' in ex ? ex.reference :
'exerciseId' in ex ? ex.exerciseId : null;
if (!id) return null;
// Check for explicit Nostr kind:pubkey:id format
if (id.includes(':')) {
const parts = id.split(':');
if (parts[0] === '33401') {
return {
exerciseId: id,
eventId: parts.length > 2 ? parts[2] : parts[parts.length - 1]
};
}
}
return null;
})
.filter(Boolean);
// If we have Nostr references, fetch them all at once
if (nostrRefs.length > 0) {
logger.debug(`Fetching ${nostrRefs.length} exercise events via NDK`);
try {
const eventIds = nostrRefs.map(ref => ref!.eventId);
const events = await ndk.fetchEvents({ ids: eventIds });
// Process each event to extract title
events.forEach((event: any) => {
// Find the title tag in the event
const titleTag = event.tags.find((tag: any[]) => tag[0] === 'title');
if (titleTag && titleTag.length > 1) {
// Find which exercise this corresponds to
const matchingRef = nostrRefs.find(ref => ref!.eventId === event.id);
if (matchingRef) {
names[matchingRef.exerciseId] = titleTag[1];
logger.debug(`Found title for ${matchingRef.exerciseId}: ${titleTag[1]}`);
}
}
});
} catch (error) {
logger.error('Error fetching exercise events:', error);
}
}
}
// Process each exercise in parallel for better performance
await Promise.all(exercises.map(async (exercise, index) => {
// Enhanced ID extraction with more formats supported
const exerciseId = typeof exercise === 'string' ? exercise :
'id' in exercise ? exercise.id :
'reference' in exercise ? exercise.reference :
'exerciseId' in exercise ? exercise.exerciseId :
`unknown-${index}`;
// For debugging the exact format of IDs we're receiving
logger.debug(`Processing exercise with ID: "${exerciseId}" (${typeof exerciseId})`);
// RESOLUTION STRATEGY 1: Check for meaningful name in the exercise object
if (exercise.name && exercise.name !== 'Exercise' && exercise.name !== 'Unknown Exercise') {
logger.debug(`Found exercise name in object: ${exercise.name}`);
names[exerciseId] = exercise.name;
return;
}
// RESOLUTION STRATEGY 2: Handle POWR's specific ID format, with or without local: prefix
let idToProcess = exerciseId;
let localPrefix = false;
// Check for and handle local: prefix
if (exerciseId.startsWith('local:')) {
idToProcess = exerciseId.substring(6);
localPrefix = true;
logger.debug(`Stripped local: prefix, processing ID: ${idToProcess}`);
}
// Check for POWR specific format
const powrFormatMatch = idToProcess.match(/^(m[a-z0-9]{7}-[a-z0-9]{10})$/i);
if (powrFormatMatch) {
const idWithoutPrefix = powrFormatMatch[1];
const betterName = `Exercise ${idWithoutPrefix.substring(1, 5).toUpperCase()}`;
logger.debug(`Created better name for POWR format ID: ${betterName}`);
names[exerciseId] = betterName;
// Still try database lookup for a proper name
try {
// Try different variations of the ID for lookup
const idVariations = [
idToProcess,
// If we already removed the local: prefix, don't need to try again
localPrefix ? [] : (exerciseId.startsWith('local:') ? [exerciseId.substring(6)] : []),
].flat();
for (const id of idVariations) {
const dbTitle = await lookupExerciseTitle(id);
if (dbTitle) {
logger.debug(`Found in database: ${id}${dbTitle}`);
names[exerciseId] = dbTitle;
break;
}
}
} catch (error) {
logger.error(`Database lookup failed for ${exerciseId}:`, error);
// Keep the better name we created earlier
}
return;
}
// RESOLUTION STRATEGY 3: Try extracting a name from the ID pattern using the helper
const extractedName = extractExerciseName(idToProcess);
logger.debug(`Extracted name for ${idToProcess}: ${extractedName}`);
if (extractedName !== 'Exercise') {
names[exerciseId] = extractedName;
return;
}
// RESOLUTION STRATEGY 4: Database lookup as last resort
try {
// Try with and without "local:" prefix if we haven't already handled it
const lookupIds = localPrefix ? [idToProcess] : [exerciseId, idToProcess];
for (const id of lookupIds) {
const dbTitle = await lookupExerciseTitle(id);
if (dbTitle) {
logger.debug(`Database lookup successful for ${id}: ${dbTitle}`);
names[exerciseId] = dbTitle;
return;
}
}
// If we get here, nothing worked, use a generic name
logger.debug(`No resolution found for ${exerciseId}, using generic name`);
names[exerciseId] = `Exercise ${index + 1}`;
} catch (error) {
logger.error(`Error in name resolution process for ${exerciseId}:`, error);
names[exerciseId] = `Exercise ${index + 1}`;
}
}));
logger.debug('Final resolved names:', names);
return names;
}

View File

@ -50,6 +50,8 @@ export const QUERY_KEYS = {
all: ['exercises'] as const, all: ['exercises'] as const,
detail: (id: string) => [...QUERY_KEYS.exercises.all, 'detail', id] as const, detail: (id: string) => [...QUERY_KEYS.exercises.all, 'detail', id] as const,
list: (filters?: any) => [...QUERY_KEYS.exercises.all, 'list', filters] as const, list: (filters?: any) => [...QUERY_KEYS.exercises.all, 'list', filters] as const,
namesByEvent: (eventId: string) => [...QUERY_KEYS.exercises.all, 'namesByEvent', eventId] as const,
namesByWorkout: (workoutId: string) => [...QUERY_KEYS.exercises.all, 'namesByWorkout', workoutId] as const,
}, },
// Social feed related queries // Social feed related queries

View File

@ -44,7 +44,6 @@ import { ExerciseService } from '@/lib/db/services/ExerciseService';
* even when accounting for time spent in completion flow. * even when accounting for time spent in completion flow.
*/ */
const AUTO_SAVE_INTERVAL = 30000; // 30 seconds const AUTO_SAVE_INTERVAL = 30000; // 30 seconds
// Define a module-level timer reference for the workout timer // Define a module-level timer reference for the workout timer
@ -62,6 +61,9 @@ interface ExtendedWorkoutState extends WorkoutState {
isMinimized: boolean; isMinimized: boolean;
favoriteIds: string[]; // Only store IDs in memory favoriteIds: string[]; // Only store IDs in memory
favoritesLoaded: boolean; favoritesLoaded: boolean;
isPublishing: boolean;
publishingStatus?: 'saving' | 'publishing-workout' | 'publishing-social' | 'error';
publishError?: string;
} }
interface WorkoutActions { interface WorkoutActions {
@ -121,7 +123,7 @@ interface ExtendedWorkoutActions extends WorkoutActions {
loadFavorites: () => Promise<void>; loadFavorites: () => Promise<void>;
// New template management // New template management
startWorkoutFromTemplate: (templateId: string) => Promise<void>; startWorkoutFromTemplate: (templateId: string, templateData?: WorkoutTemplate) => Promise<void>;
// Additional workout actions // Additional workout actions
endWorkout: () => Promise<void>; endWorkout: () => Promise<void>;
@ -151,13 +153,16 @@ const initialState: ExtendedWorkoutState = {
isActive: false, isActive: false,
isMinimized: false, isMinimized: false,
favoriteIds: [], favoriteIds: [],
favoritesLoaded: false favoritesLoaded: false,
isPublishing: false,
publishingStatus: undefined,
publishError: undefined
}; };
const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions>()((set, get) => ({ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions>()((set, get) => ({
...initialState, ...initialState,
// Core Workout Flow // Core Workflow Flow
startWorkout: (workoutData: Partial<Workout> = {}) => { startWorkout: (workoutData: Partial<Workout> = {}) => {
// First stop any existing timer to avoid duplicate timers // First stop any existing timer to avoid duplicate timers
get().stopWorkoutTimer(); get().stopWorkoutTimer();
@ -225,10 +230,18 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
router.push('/(workout)/complete'); router.push('/(workout)/complete');
return; return;
} }
// Set publishing state
set({
isPublishing: true,
publishingStatus: 'saving',
publishError: undefined
});
const completedWorkout = { const completedWorkout = {
...activeWorkout, ...activeWorkout,
isCompleted: true, isCompleted: true,
notes: options.workoutDescription || activeWorkout.notes, // Use workoutDescription for notes
lastUpdated: Date.now() lastUpdated: Date.now()
}; };
@ -246,6 +259,11 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
const { ndk, isAuthenticated } = useNDKStore.getState(); const { ndk, isAuthenticated } = useNDKStore.getState();
if (ndk && isAuthenticated) { if (ndk && isAuthenticated) {
// Update publishing status
set({
publishingStatus: 'publishing-workout'
});
// Create appropriate Nostr event data // Create appropriate Nostr event data
const eventData = options.storageType === 'publish_complete' const eventData = options.storageType === 'publish_complete'
? NostrWorkoutService.createCompleteWorkoutEvent(completedWorkout) ? NostrWorkoutService.createCompleteWorkoutEvent(completedWorkout)
@ -288,6 +306,11 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
// Handle social share if selected // Handle social share if selected
if (options.shareOnSocial && options.socialMessage) { if (options.shareOnSocial && options.socialMessage) {
try { try {
// Update publishing status for social
set({
publishingStatus: 'publishing-social'
});
const socialEventData = NostrWorkoutService.createSocialShareEvent( const socialEventData = NostrWorkoutService.createSocialShareEvent(
event.id, event.id,
options.socialMessage options.socialMessage
@ -316,8 +339,13 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
await Promise.race([socialPublishPromise, socialPublishTimeout]); await Promise.race([socialPublishPromise, socialPublishTimeout]);
console.log('Successfully published social share'); console.log('Successfully published social share');
} catch (socialError) { } catch (error) {
const socialError = error as Error;
console.error('Error publishing social share:', socialError); console.error('Error publishing social share:', socialError);
// Update error status but still continue
set({
publishError: `Error sharing to Nostr: ${socialError.message || 'Unknown error'}`
});
// Continue with workout completion even if social sharing fails // Continue with workout completion even if social sharing fails
} }
} }
@ -325,24 +353,48 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
const signError = error as Error; const signError = error as Error;
console.error('Error signing or publishing event:', signError); console.error('Error signing or publishing event:', signError);
// Update error state
set({
publishError: `Error publishing: ${signError.message || 'Unknown error'}`
});
// Specific handling for timeout errors to give user better feedback // Specific handling for timeout errors to give user better feedback
if (signError.message?.includes('timeout')) { if (signError.message?.includes('timeout')) {
console.warn('The signing operation timed out. This may be due to an issue with the external signer.'); console.warn('The signing operation timed out. This may be due to an issue with the external signer.');
set({
publishError: 'The signing operation timed out. Please try again or check your signer app.'
});
} }
// Continue with workout completion even though publishing failed // Continue with workout completion even though publishing failed
} }
} catch (eventCreationError) { } catch (error) {
const eventCreationError = error as Error;
console.error('Error creating event:', eventCreationError); console.error('Error creating event:', eventCreationError);
// Continue with workout completion, but log the error // Update error state
set({
publishError: `Error creating event: ${eventCreationError.message || 'Unknown error'}`,
publishingStatus: 'error'
});
} }
} catch (publishError) { } catch (error) {
const publishError = error as Error;
console.error('Error publishing to Nostr:', publishError); console.error('Error publishing to Nostr:', publishError);
// Update error state
set({
publishError: `Error publishing to Nostr: ${publishError.message || 'Unknown error'}`,
publishingStatus: 'error'
});
} }
} }
} catch (error) { } catch (error) {
console.error('Error preparing Nostr events:', error); const prepError = error as Error;
console.error('Error preparing Nostr events:', prepError);
// Continue anyway to preserve local data // Continue anyway to preserve local data
set({
publishError: `Error preparing events: ${prepError.message || 'Unknown error'}`,
publishingStatus: 'error'
});
} }
} }
@ -358,7 +410,8 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
); );
} }
} catch (error) { } catch (error) {
console.error('Error updating template:', error); const templateError = error as Error;
console.error('Error updating template:', templateError);
// Continue anyway to preserve workout data // Continue anyway to preserve workout data
} }
} }
@ -371,15 +424,24 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
status: 'completed', status: 'completed',
activeWorkout: null, // Set to null to fully clear the workout activeWorkout: null, // Set to null to fully clear the workout
isActive: false, isActive: false,
isMinimized: false isMinimized: false,
isPublishing: false, // Reset publishing state
publishingStatus: undefined
}); });
// Ensure we fully reset the state // Ensure we fully reset the state
get().reset(); get().reset();
} catch (error) { } catch (error) {
console.error('Error completing workout:', error); const completeError = error as Error;
// Consider showing an error message to the user console.error('Error completing workout:', completeError);
// Update error state
set({
publishError: `Error completing workout: ${completeError.message || 'Unknown error'}`,
publishingStatus: 'error',
isPublishing: false
});
} }
}, },
@ -406,8 +468,9 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
return ndkEvent; return ndkEvent;
} catch (error) { } catch (error) {
console.error('Failed to publish event:', error); const publishError = error as Error;
throw error; console.error('Failed to publish event:', publishError);
throw publishError;
} }
}, },
@ -659,7 +722,7 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
if (!template) return; if (!template) return;
// Convert template exercises to workout exercises // Convert template exercises to workout exercises
const exercises: WorkoutExercise[] = template.exercises.map(templateExercise => ({ const exercises: WorkoutExercise[] = template.exercises.map((templateExercise: any) => ({
id: generateId('local'), id: generateId('local'),
title: templateExercise.exercise.title, title: templateExercise.exercise.title,
type: templateExercise.exercise.type, type: templateExercise.exercise.type,
@ -868,7 +931,6 @@ async function getTemplate(templateId: string): Promise<WorkoutTemplate | null>
const favoritesService = new FavoritesService(db); const favoritesService = new FavoritesService(db);
const exerciseService = new ExerciseService(db); const exerciseService = new ExerciseService(db);
const templateService = new TemplateService(db, new ExerciseService(db)); const templateService = new TemplateService(db, new ExerciseService(db));
// First try to get from favorites // First try to get from favorites
const favoriteResult = await favoritesService.getContentById<WorkoutTemplate>('template', templateId); const favoriteResult = await favoritesService.getContentById<WorkoutTemplate>('template', templateId);
@ -885,89 +947,112 @@ async function getTemplate(templateId: string): Promise<WorkoutTemplate | null>
} }
} }
/**
* Save a workout to the database
*/
async function saveWorkout(workout: Workout): Promise<void> { async function saveWorkout(workout: Workout): Promise<void> {
try { try {
console.log('Saving workout with endTime:', workout.endTime);
// Use the workout service to save the workout
const db = openDatabaseSync('powr.db'); const db = openDatabaseSync('powr.db');
const workoutService = new WorkoutService(db); const workoutService = new WorkoutService(db);
await workoutService.saveWorkout(workout); await workoutService.saveWorkout(workout);
} catch (error) { } catch (error) {
console.error('Error saving workout:', error); console.error('Error saving workout:', error);
throw error;
} }
} }
async function saveSummary(summary: WorkoutSummary) { /**
try { * Calculate summary statistics for a workout
// Use the workout service to save summary metrics */
const db = openDatabaseSync('powr.db');
const workoutService = new WorkoutService(db);
await workoutService.saveWorkoutSummary(summary.id, summary);
console.log('Workout summary saved successfully:', summary.id);
} catch (error) {
console.error('Error saving workout summary:', error);
}
}
function calculateWorkoutSummary(workout: Workout): WorkoutSummary { function calculateWorkoutSummary(workout: Workout): WorkoutSummary {
const startTime = workout.startTime;
const endTime = workout.endTime || Date.now();
const duration = endTime - startTime;
let totalVolume = 0;
let totalReps = 0;
let averageRpe = 0;
let rpeCount = 0;
const exerciseSummaries = workout.exercises.map(exercise => {
let exerciseVolume = 0;
let exerciseReps = 0;
let exerciseRpe = 0;
let rpeCount = 0;
let peakWeight = 0;
const completedSets = exercise.sets.filter(set => set.isCompleted).length;
exercise.sets.forEach(set => {
if (set.isCompleted) {
const weight = set.weight || 0;
const reps = set.reps || 0;
exerciseVolume += weight * reps;
exerciseReps += reps;
if (weight > peakWeight) {
peakWeight = weight;
}
if (set.rpe) {
exerciseRpe += set.rpe;
rpeCount++;
}
}
});
totalVolume += exerciseVolume;
totalReps += exerciseReps;
if (rpeCount > 0) {
const avgRpe = exerciseRpe / rpeCount;
averageRpe += avgRpe;
rpeCount++;
}
return {
exerciseId: exercise.id,
title: exercise.title,
setCount: exercise.sets.length,
completedSets,
volume: exerciseVolume,
peakWeight: peakWeight > 0 ? peakWeight : undefined,
totalReps: exerciseReps,
averageRpe: rpeCount > 0 ? exerciseRpe / rpeCount : undefined
};
});
return { return {
id: generateId('local'), id: workout.id,
title: workout.title, title: workout.title,
type: workout.type, type: workout.type,
duration: workout.endTime ? workout.endTime - workout.startTime : 0, duration,
startTime: workout.startTime, startTime,
endTime: workout.endTime || Date.now(), endTime,
exerciseCount: workout.exercises.length, exerciseCount: workout.exercises.length,
completedExercises: workout.exercises.filter(e => e.isCompleted).length, completedExercises: workout.exercises.filter(ex => ex.isCompleted).length,
totalVolume: calculateTotalVolume(workout), totalVolume,
totalReps: calculateTotalReps(workout), totalReps,
averageRpe: calculateAverageRpe(workout), averageRpe: rpeCount > 0 ? averageRpe / rpeCount : undefined,
exerciseSummaries: [], exerciseSummaries,
personalRecords: [] personalRecords: [] // Personal records would be calculated separately
}; };
} }
function calculateTotalVolume(workout: Workout): number { /**
return workout.exercises.reduce((total, exercise) => { * Save workout summary to the database
return total + exercise.sets.reduce((setTotal, set) => { */
return setTotal + (set.weight || 0) * (set.reps || 0); async function saveSummary(summary: WorkoutSummary): Promise<void> {
}, 0); try {
}, 0); const db = openDatabaseSync('powr.db');
const workoutService = new WorkoutService(db);
await workoutService.saveWorkoutSummary(summary.id, summary);
} catch (error) {
console.error('Error saving workout summary:', error);
// Non-fatal error, can continue
}
} }
function calculateTotalReps(workout: Workout): number { // Create a version with selectors for easier state access
return workout.exercises.reduce((total, exercise) => { export const useWorkoutStore = createSelectors(useWorkoutStoreBase);
return total + exercise.sets.reduce((setTotal, set) => {
return setTotal + (set.reps || 0);
}, 0);
}, 0);
}
function calculateAverageRpe(workout: Workout): number {
const rpeSets = workout.exercises.reduce((sets, exercise) => {
return sets.concat(exercise.sets.filter(set => set.rpe !== undefined));
}, [] as WorkoutSet[]);
if (rpeSets.length === 0) return 0;
const totalRpe = rpeSets.reduce((total, set) => total + (set.rpe || 0), 0);
return totalRpe / rpeSets.length;
}
// Create auto-generated selectors
export const useWorkoutStore = createSelectors(useWorkoutStoreBase);
// Clean up interval on hot reload in development
if (typeof module !== 'undefined' && 'hot' in module) {
// @ts-ignore - 'hot' exists at runtime but TypeScript doesn't know about it
module.hot?.dispose(() => {
if (workoutTimerInterval) {
clearInterval(workoutTimerInterval);
workoutTimerInterval = null;
console.log('Workout timer cleared on hot reload');
}
});
}

View File

@ -129,9 +129,13 @@ export function parseWorkoutRecord(event: NDKEvent): ParsedWorkoutRecord {
const reference = tag[1] || ''; const reference = tag[1] || '';
const parts = reference.split(':'); const parts = reference.split(':');
const id = parts.length > 2 ? parts[2] : reference;
// Get a basic name, will be improved by lookupExerciseTitle in UI components
const name = extractExerciseName(reference);
return { return {
id: parts.length > 2 ? parts[2] : reference, id,
name: extractExerciseName(reference), name,
weight: tag[3] ? parseFloat(tag[3]) : undefined, weight: tag[3] ? parseFloat(tag[3]) : undefined,
reps: tag[4] ? parseInt(tag[4]) : undefined, reps: tag[4] ? parseInt(tag[4]) : undefined,
rpe: tag[5] ? parseFloat(tag[5]) : undefined, rpe: tag[5] ? parseFloat(tag[5]) : undefined,
@ -386,14 +390,161 @@ export function parseLongformContent(event: NDKEvent): ParsedLongformContent {
}; };
} }
// Extract exercise name from reference - this should be replaced with lookup from your database // Extract exercise name from reference
function extractExerciseName(reference: string): string { export function extractExerciseName(reference: string): string {
// This is a placeholder function if (!reference) return 'Exercise';
// In production, you would look up the exercise name from your database
// For now, just return a formatted version of the reference // Handle the "local:" prefix pattern seen in logs (e.g., "local:m94c2cm7-mrhvwgfcr0b")
const parts = reference.split(':'); if (reference.startsWith('local:')) {
if (parts.length > 2) { const localIdentifier = reference.substring(6); // Remove "local:" prefix
return `Exercise ${parts[2].substring(0, 6)}`;
// Use the UUID pattern for local IDs
if (localIdentifier.includes('-')) {
const parts = localIdentifier.split('-');
if (parts.length === 2) {
// Format might be something like "m94c2cm7-mrhvwgfcr0b"
return `Exercise ${parts[0].substring(0, 4)}`;
}
}
} }
return 'Unknown Exercise';
} // Split the reference to get the parts for standard format
const parts = reference.split(':');
// Get the identifier part (usually the last part)
let identifier = '';
if (parts.length > 2) {
identifier = parts[2];
} else if (reference) {
identifier = reference;
}
// Enhanced naming detection for common exercises
// Bench variations
if (identifier.match(/bench[-_]?press/i) ||
identifier.match(/chest[-_]?press/i)) return 'Bench Press';
// Squat variations
if (identifier.match(/squat/i)) {
if (identifier.match(/front/i)) return 'Front Squat';
if (identifier.match(/back/i)) return 'Back Squat';
if (identifier.match(/goblet/i)) return 'Goblet Squat';
return 'Squat';
}
// Deadlift variations
if (identifier.match(/deadlift/i)) {
if (identifier.match(/romanian/i) || identifier.match(/rdl/i)) return 'Romanian Deadlift';
if (identifier.match(/stiff[-_]?leg/i) || identifier.match(/sldl/i)) return 'Stiff-Leg Deadlift';
if (identifier.match(/sumo/i)) return 'Sumo Deadlift';
return 'Deadlift';
}
// Press variations
if (identifier.match(/shoulder[-_]?press/i) ||
identifier.match(/overhead[-_]?press/i) ||
identifier.match(/ohp/i)) return 'Shoulder Press';
// Pull variations
if (identifier.match(/pull[-_]?up/i)) return 'Pull Up';
if (identifier.match(/chin[-_]?up/i)) return 'Chin Up';
// Push variations
if (identifier.match(/push[-_]?up/i)) return 'Push Up';
if (identifier.match(/dip/i)) return 'Dip';
// Row variations
if (identifier.match(/row/i)) {
if (identifier.match(/barbell/i)) return 'Barbell Row';
if (identifier.match(/dumbbell/i) || identifier.match(/db/i)) return 'Dumbbell Row';
return 'Row';
}
// Back exercises
if (identifier.match(/lat[-_]?pulldown/i) || identifier.match(/lat[-_]?pull/i)) return 'Lat Pulldown';
// Arm exercises
if (identifier.match(/bicep[-_]?curl/i) || identifier.match(/curl/i)) return 'Bicep Curl';
if (identifier.match(/tricep/i)) {
if (identifier.match(/extension/i)) return 'Tricep Extension';
if (identifier.match(/pushdown/i)) return 'Tricep Pushdown';
return 'Tricep Exercise';
}
// Leg exercises
if (identifier.match(/leg[-_]?press/i)) return 'Leg Press';
if (identifier.match(/leg[-_]?curl/i)) return 'Leg Curl';
if (identifier.match(/leg[-_]?ext/i)) return 'Leg Extension';
if (identifier.match(/calf[-_]?raise/i)) return 'Calf Raise';
if (identifier.match(/lunge/i)) return 'Lunge';
// Core exercises
if (identifier.match(/plank/i)) return 'Plank';
if (identifier.match(/crunch/i)) return 'Crunch';
if (identifier.match(/sit[-_]?up/i)) return 'Sit Up';
// If the ID appears to contain a name with dashes or underscores
if (identifier.includes('-') || identifier.includes('_')) {
// Extract name parts and create a more readable name
const words = identifier.split(/[-_]/).filter(word => /^[a-zA-Z]+$/.test(word));
if (words.length > 0) {
return words.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join(' ');
}
}
// For POWR's specific ID format: mXXXXXXX-XXXXXXXXXX
if (identifier.match(/^m[a-z0-9]{7}-[a-z0-9]{10}$/i)) {
// This matches patterns like "m8l428e1-3yedofspbpy"
// Get the first 4 characters after 'm' and capitalize
return `Exercise ${identifier.substring(1, 5).toUpperCase()}`;
}
// For UUIDs and other random IDs
if (identifier.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
return 'Exercise'; // Will be resolved by lookupExerciseTitle later
}
// For any unrecognized format with alphabetic characters, try to make it readable
if (/[a-zA-Z]/.test(identifier)) {
// Break camelCase
const nameParts = identifier.replace(/([a-z])([A-Z])/g, '$1 $2').split(' ');
return nameParts.map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()).join(' ');
}
// Last resort fallback
return 'Exercise';
}
// Global variable to store a database connection that can be set from outside
let _dbConnection: any = null;
// Function to set the database connection from outside this module
export function setDatabaseConnection(db: any) {
_dbConnection = db;
}
// Function to look up an exercise title from the database
export async function lookupExerciseTitle(exerciseId: string): Promise<string | null> {
try {
if (!_dbConnection) {
console.warn('No DB connection available for exercise title lookup');
return null;
}
// Query the database for the exercise title
const exercise = await _dbConnection.getFirstAsync(
`SELECT title FROM exercises WHERE id = ?`,
[exerciseId]
);
if (exercise && typeof exercise.title === 'string') {
return exercise.title;
}
return null;
} catch (error) {
console.error('Error looking up exercise title:', error);
return null;
}
}

View File

@ -96,6 +96,9 @@ export interface WorkoutCompletionOptions {
// Template update options // Template update options
templateAction: 'keep_original' | 'update_existing' | 'save_as_new'; templateAction: 'keep_original' | 'update_existing' | 'save_as_new';
newTemplateName?: string; newTemplateName?: string;
// Workout description - added to the workout record content
workoutDescription?: string;
} }
/** /**