mirror of
https://github.com/DocNR/POWR.git
synced 2025-04-19 10:51:19 +00:00
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:
parent
9ad50956f8
commit
5ff311bc4a
89
CHANGELOG.md
89
CHANGELOG.md
@ -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).
|
||||
|
||||
## [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
|
||||
- Authentication persistence debugging tools
|
||||
- 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
|
||||
- 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
|
||||
- Added proper awaiting of NDK relay connections
|
||||
- Implemented credential migration before authentication starts
|
||||
@ -685,53 +723,4 @@ g
|
||||
- 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
|
||||
- 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,
|
||||
- Added pack management screen
|
||||
|
@ -249,7 +249,7 @@ export default function WorkoutScreen() {
|
||||
Begin a new workout or choose from one of your templates.
|
||||
</Text>
|
||||
|
||||
{/* Buttons from HomeWorkout but directly here */}
|
||||
{/* Quick Start button */}
|
||||
<View className="gap-4">
|
||||
<Button
|
||||
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>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onPress={handleSelectTemplate}
|
||||
>
|
||||
<Text>Use Template</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
// app/(workout)/complete.tsx
|
||||
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 { Text } from '@/components/ui/text';
|
||||
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.
|
||||
*/
|
||||
export default function CompleteWorkoutScreen() {
|
||||
const { resumeWorkout, activeWorkout } = useWorkoutStore();
|
||||
const { resumeWorkout, activeWorkout, isPublishing, publishingStatus } = useWorkoutStore();
|
||||
const { isDarkColorScheme } = useColorScheme();
|
||||
|
||||
// Check if we have a workout to complete
|
||||
@ -44,8 +44,21 @@ export default function CompleteWorkoutScreen() {
|
||||
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 = () => {
|
||||
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
|
||||
router.back();
|
||||
};
|
||||
@ -65,7 +78,12 @@ export default function CompleteWorkoutScreen() {
|
||||
{/* Header */}
|
||||
<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>
|
||||
<TouchableOpacity onPress={handleCancel} className="p-1">
|
||||
<TouchableOpacity
|
||||
onPress={handleCancel}
|
||||
className="p-1"
|
||||
disabled={!canClose}
|
||||
style={{ opacity: canClose ? 1 : 0.5 }}
|
||||
>
|
||||
<X size={24} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
@ -13,6 +13,7 @@ import { logDatabaseInfo } from '@/lib/db/debug';
|
||||
import { useNDKStore } from '@/lib/stores/ndk';
|
||||
import { useLibraryStore } from '@/lib/stores/libraryStore';
|
||||
import { createLogger, setQuietMode } from '@/lib/utils/logger';
|
||||
import { setDatabaseConnection } from '@/types/nostr-workout';
|
||||
|
||||
// Create database-specific logger
|
||||
const logger = createLogger('DatabaseProvider');
|
||||
@ -107,6 +108,14 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) {
|
||||
React.useEffect(() => {
|
||||
if (isReady && services.db) {
|
||||
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
|
||||
useLibraryStore.getState().refreshAll();
|
||||
}
|
||||
@ -234,6 +243,10 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) {
|
||||
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
|
||||
if (__DEV__) {
|
||||
await logDatabaseInfo();
|
||||
|
@ -18,6 +18,7 @@ import {
|
||||
} from '@/types/nostr-workout';
|
||||
import { formatDistance } from 'date-fns';
|
||||
import Markdown from 'react-native-markdown-display';
|
||||
import { useExerciseNames, useTemplateExerciseNames } from '@/lib/hooks/useExerciseNames';
|
||||
|
||||
// Helper functions for all components to use
|
||||
// Format timestamp
|
||||
@ -236,6 +237,33 @@ export default function EnhancedSocialPost({ item, onPress }: SocialPostProps) {
|
||||
|
||||
// Component for workout records
|
||||
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 (
|
||||
<View>
|
||||
<Text className="text-lg font-semibold mb-2">{workout.title}</Text>
|
||||
@ -262,14 +290,53 @@ function WorkoutContent({ workout }: { workout: ParsedWorkoutRecord }) {
|
||||
{workout.exercises.length > 0 && (
|
||||
<View>
|
||||
<Text className="font-medium mb-1">Exercises:</Text>
|
||||
{workout.exercises.slice(0, 3).map((exercise, index) => (
|
||||
<Text key={index} className="text-sm">
|
||||
• {exercise.name}
|
||||
{exercise.weight ? ` - ${exercise.weight}kg` : ''}
|
||||
{exercise.reps ? ` × ${exercise.reps}` : ''}
|
||||
{exercise.rpe ? ` @ RPE ${exercise.rpe}` : ''}
|
||||
</Text>
|
||||
))}
|
||||
{workout.exercises.slice(0, 3).map((exercise, index) => {
|
||||
// Get the exercise ID
|
||||
const exerciseId = exercise.id;
|
||||
|
||||
// Enhanced name resolution with multiple strategies
|
||||
let displayName;
|
||||
|
||||
// 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 && (
|
||||
<Text className="text-xs text-muted-foreground mt-1">
|
||||
+{workout.exercises.length - 3} more exercises
|
||||
@ -336,6 +403,28 @@ function ExerciseContent({ exercise }: { exercise: ParsedExerciseTemplate }) {
|
||||
|
||||
// Component for workout templates
|
||||
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 (
|
||||
<View>
|
||||
<Text className="text-lg font-semibold mb-2">{template.title}</Text>
|
||||
@ -368,11 +457,36 @@ function TemplateContent({ template }: { template: ParsedWorkoutTemplate }) {
|
||||
{template.exercises.length > 0 && (
|
||||
<View>
|
||||
<Text className="font-medium mb-1">Exercises:</Text>
|
||||
{template.exercises.slice(0, 3).map((exercise, index) => (
|
||||
<Text key={index} className="text-sm">
|
||||
• {exercise.name || 'Exercise ' + (index + 1)}
|
||||
</Text>
|
||||
))}
|
||||
{template.exercises.slice(0, 3).map((exercise, index) => {
|
||||
// Get the exercise ID for better debugging
|
||||
const exerciseId = exercise.reference;
|
||||
|
||||
// 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 && (
|
||||
<Text className="text-xs text-muted-foreground mt-1">
|
||||
+{template.exercises.length - 3} more exercises
|
||||
@ -504,16 +618,58 @@ function ArticleQuote({ article }: { article: ParsedLongformContent }) {
|
||||
// Simplified versions of content for quoted posts
|
||||
|
||||
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 (
|
||||
<View>
|
||||
<Text className="font-medium">{workout.title}</Text>
|
||||
<Text className="text-sm text-muted-foreground">
|
||||
{workout.exercises.length} exercises • {
|
||||
workout.startTime && workout.endTime ?
|
||||
formatDuration(workout.endTime - workout.startTime) :
|
||||
'Duration N/A'
|
||||
}
|
||||
namedExerciseCount.length > 0 ? namedExerciseCount.join(', ') : ''
|
||||
} {namedExerciseCount.length < workout.exercises.length && workout.exercises.length > 2 ? '...' : ''}
|
||||
</Text>
|
||||
{workout.startTime && workout.endTime && (
|
||||
<Text className="text-xs text-muted-foreground">
|
||||
Duration: {formatDuration(workout.endTime - workout.startTime)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
@ -215,11 +215,16 @@ export const WorkoutCard: React.FC<EnhancedWorkoutCardProps> = ({
|
||||
<Text className="text-foreground font-semibold mb-1">Exercise</Text>
|
||||
{/* In a real implementation, you would map through actual exercises */}
|
||||
{workout.exercises && workout.exercises.length > 0 ? (
|
||||
workout.exercises.slice(0, 3).map((exercise, idx) => (
|
||||
<Text key={idx} className="text-foreground mb-1">
|
||||
{exercise.title}
|
||||
</Text>
|
||||
))
|
||||
workout.exercises.slice(0, 3).map((exercise, idx) => {
|
||||
// Use the exercise title directly
|
||||
const exerciseTitle = exercise.title || 'Exercise';
|
||||
|
||||
return (
|
||||
<Text key={idx} className="text-foreground mb-1">
|
||||
{exerciseTitle}
|
||||
</Text>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Text className="text-muted-foreground">No exercises recorded</Text>
|
||||
)}
|
||||
|
@ -1,6 +1,6 @@
|
||||
// components/workout/WorkoutCompletionFlow.tsx
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@ -20,6 +20,7 @@ import {
|
||||
Cog
|
||||
} from 'lucide-react-native';
|
||||
import { TemplateService } from '@/lib/db/services/TemplateService';
|
||||
import { NostrWorkoutService } from '@/lib/db/services/NostrWorkoutService';
|
||||
import Confetti from '@/components/Confetti';
|
||||
|
||||
/**
|
||||
@ -178,109 +179,41 @@ function StorageOptionsTab({
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Template options section - only if needed */}
|
||||
{hasTemplateChanges && (
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
{/* Workout Description field and Next button */}
|
||||
<View>
|
||||
<Text className="text-lg font-semibold text-foreground">Workout Notes</Text>
|
||||
<Text className="text-muted-foreground mb-2">
|
||||
Add context or details about your workout
|
||||
</Text>
|
||||
<Input
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
placeholder="Tough upper body workout today. Feeling stronger!"
|
||||
value={options.workoutDescription || ''}
|
||||
onChangeText={(text) => setOptions({
|
||||
...options,
|
||||
workoutDescription: text
|
||||
})}
|
||||
className="min-h-[100px] p-3 bg-background dark:bg-muted border-[0.5px] border-input dark:border-0"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text className="text-lg font-semibold text-foreground">Template Options</Text>
|
||||
<Text className="text-muted-foreground">
|
||||
Your workout includes modifications to the original template
|
||||
</Text>
|
||||
|
||||
{/* Keep original option */}
|
||||
<TouchableOpacity
|
||||
onPress={() => handleTemplateAction('keep_original')}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Card
|
||||
style={options.templateAction === 'keep_original' ? {
|
||||
borderColor: purpleColor,
|
||||
borderWidth: 1.5,
|
||||
} : {}}
|
||||
>
|
||||
<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
|
||||
onPress={onNext}
|
||||
className="w-full mb-6"
|
||||
style={{ backgroundColor: purpleColor }}
|
||||
className="w-full"
|
||||
style={{
|
||||
backgroundColor: purpleColor,
|
||||
marginTop: 16
|
||||
}}
|
||||
>
|
||||
<Text className="text-white font-medium">
|
||||
Next
|
||||
</Text>
|
||||
</Button>
|
||||
|
||||
{/* Template options section removed as it was causing bugs */}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
@ -507,34 +440,28 @@ function CelebrationTab({
|
||||
onComplete: (options: WorkoutCompletionOptions) => void
|
||||
}) {
|
||||
const { isAuthenticated } = useNDKCurrentUser();
|
||||
const { activeWorkout } = useWorkoutStore();
|
||||
const { activeWorkout, isPublishing, publishingStatus, publishError } = useWorkoutStore();
|
||||
const [showConfetti, setShowConfetti] = useState(true);
|
||||
const [shareMessage, setShareMessage] = useState('');
|
||||
|
||||
// Purple color used throughout the app
|
||||
const purpleColor = 'hsl(261, 90%, 66%)';
|
||||
|
||||
// Generate default share message
|
||||
// Disable buttons during publishing
|
||||
const isDisabled = isPublishing;
|
||||
|
||||
// Generate default share message on load
|
||||
useEffect(() => {
|
||||
// Create default message based on workout data
|
||||
let message = "Just completed a workout! 💪";
|
||||
|
||||
if (activeWorkout) {
|
||||
const exerciseCount = activeWorkout.exercises.length;
|
||||
const completedSets = activeWorkout.exercises.reduce(
|
||||
(total, exercise) => total + exercise.sets.filter(set => set.isCompleted).length, 0
|
||||
// Use the enhanced formatter from NostrWorkoutService
|
||||
const formattedMessage = NostrWorkoutService.createFormattedSocialMessage(
|
||||
activeWorkout,
|
||||
"Just completed a workout! 💪"
|
||||
);
|
||||
|
||||
// Add workout details
|
||||
message = `Just completed a workout with ${exerciseCount} exercises and ${completedSets} sets! 💪`;
|
||||
|
||||
// Add mock PR info
|
||||
if (Math.random() > 0.5) {
|
||||
message += " Hit some new PRs today! 🏆";
|
||||
}
|
||||
setShareMessage(formattedMessage);
|
||||
} else {
|
||||
setShareMessage("Just completed a workout! 💪 #powr #nostr");
|
||||
}
|
||||
|
||||
setShareMessage(message);
|
||||
}, [activeWorkout]);
|
||||
|
||||
const handleShare = () => {
|
||||
@ -573,6 +500,25 @@ function CelebrationTab({
|
||||
</Text>
|
||||
</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 */}
|
||||
{isAuthenticated && options.storageType !== 'local_only' && (
|
||||
<>
|
||||
@ -592,13 +538,18 @@ function CelebrationTab({
|
||||
numberOfLines={4}
|
||||
value={shareMessage}
|
||||
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
|
||||
className="w-full mb-3"
|
||||
style={{ backgroundColor: purpleColor }}
|
||||
style={{
|
||||
backgroundColor: purpleColor,
|
||||
opacity: isDisabled ? 0.5 : 1
|
||||
}}
|
||||
onPress={handleShare}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Text className="text-white font-medium">
|
||||
Share to Nostr
|
||||
@ -609,6 +560,8 @@ function CelebrationTab({
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onPress={handleSkip}
|
||||
disabled={isDisabled}
|
||||
style={{ opacity: isDisabled ? 0.5 : 1 }}
|
||||
>
|
||||
<Text>
|
||||
Skip Sharing
|
||||
@ -622,7 +575,11 @@ function CelebrationTab({
|
||||
{(options.storageType === 'local_only' || !isAuthenticated) && (
|
||||
<Button
|
||||
className="w-full mt-4"
|
||||
style={{ backgroundColor: purpleColor }}
|
||||
style={{
|
||||
backgroundColor: purpleColor,
|
||||
opacity: isDisabled ? 0.5 : 1
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
onPress={handleSkip}
|
||||
>
|
||||
<Text className="text-white font-medium">
|
||||
|
@ -27,7 +27,25 @@ export class NostrWorkoutService {
|
||||
* Creates a social share event that quotes the workout record
|
||||
*/
|
||||
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'],
|
||||
// Add content-specific tags
|
||||
...contentTypeTags,
|
||||
// Add client tag
|
||||
['client', 'POWR App'],
|
||||
// Add any additional tags
|
||||
...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
|
||||
* @param workout The workout data
|
||||
|
@ -557,8 +557,8 @@ export class UnifiedWorkoutHistoryService {
|
||||
|
||||
if (!parsedWorkout) continue;
|
||||
|
||||
// Convert to Workout type
|
||||
const workout = this.convertParsedWorkoutToWorkout(parsedWorkout, event);
|
||||
// Convert to Workout type - make sure to await it
|
||||
const workout = await this.convertParsedWorkoutToWorkout(parsedWorkout, event);
|
||||
workouts.push(workout);
|
||||
} catch (error) {
|
||||
console.error('Error parsing workout event:', error);
|
||||
@ -575,7 +575,41 @@ export class UnifiedWorkoutHistoryService {
|
||||
/**
|
||||
* 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 {
|
||||
id: parsedWorkout.id,
|
||||
title: parsedWorkout.title,
|
||||
@ -586,38 +620,7 @@ export class UnifiedWorkoutHistoryService {
|
||||
notes: parsedWorkout.notes,
|
||||
created_at: parsedWorkout.createdAt,
|
||||
lastUpdated: parsedWorkout.createdAt,
|
||||
|
||||
// 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: []
|
||||
};
|
||||
}),
|
||||
exercises,
|
||||
|
||||
// Add Nostr-specific metadata
|
||||
availability: {
|
||||
@ -633,14 +636,25 @@ export class UnifiedWorkoutHistoryService {
|
||||
* This method attempts to look up the exercise in the local database
|
||||
* or use a mapping of common exercise IDs to names
|
||||
*/
|
||||
private getExerciseNameFromId(exerciseId: string): string | null {
|
||||
private async getExerciseNameFromId(exerciseId: string): Promise<string> {
|
||||
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> = {
|
||||
'bench-press': 'Bench Press',
|
||||
'squat': 'Squat',
|
||||
'deadlift': 'Deadlift',
|
||||
'shoulder-press': 'Shoulder Press',
|
||||
'overhead-press': 'Overhead Press',
|
||||
'pull-up': 'Pull Up',
|
||||
'push-up': 'Push Up',
|
||||
'barbell-row': 'Barbell Row',
|
||||
@ -656,21 +670,100 @@ export class UnifiedWorkoutHistoryService {
|
||||
'lunge': 'Lunge',
|
||||
'dip': 'Dip',
|
||||
'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
|
||||
for (const [key, name] of Object.entries(commonExercises)) {
|
||||
if (exerciseId.includes(key)) {
|
||||
if (exerciseId.toLowerCase().includes(key.toLowerCase())) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle specific format seen in logs: "Exercise m8l4pk"
|
||||
if (exerciseId.match(/^m[0-9a-z]{5,6}$/)) {
|
||||
// This appears to be a short ID, return a generic name with a number
|
||||
const shortId = exerciseId.substring(1, 4).toUpperCase();
|
||||
return `Exercise ${shortId}`;
|
||||
// Handle specific format seen in logs: "Exercise m8l4pk" or other ID-like patterns
|
||||
if (exerciseId.match(/^[a-z][0-9a-z]{4,6}$/i) || exerciseId.match(/^[0-9a-f]{8,}$/i)) {
|
||||
// Look in the database again, but with a more flexible search
|
||||
const fuzzyMatch = await this.db.getFirstAsync<{ title: string }>(
|
||||
`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
|
||||
@ -999,8 +1092,8 @@ export class UnifiedWorkoutHistoryService {
|
||||
throw new Error(`Failed to parse workout from event ${eventId}`);
|
||||
}
|
||||
|
||||
// Convert to Workout type
|
||||
const workout = this.convertParsedWorkoutToWorkout(parsedWorkout, event);
|
||||
// Convert to Workout type - make sure to await it
|
||||
const workout = await this.convertParsedWorkoutToWorkout(parsedWorkout, event);
|
||||
|
||||
// Update the source to include both local and Nostr
|
||||
workout.availability.source = ['nostr', 'local'];
|
||||
@ -1106,11 +1199,12 @@ export class UnifiedWorkoutHistoryService {
|
||||
}, { closeOnEose: false });
|
||||
|
||||
// Handle events
|
||||
sub.on('event', (event: NDKEvent) => {
|
||||
sub.on('event', async (event: NDKEvent) => {
|
||||
try {
|
||||
const parsedWorkout = parseWorkoutRecord(event);
|
||||
if (parsedWorkout) {
|
||||
const workout = this.convertParsedWorkoutToWorkout(parsedWorkout, event);
|
||||
// Make sure to await the async conversion
|
||||
const workout = await this.convertParsedWorkoutToWorkout(parsedWorkout, event);
|
||||
callback(workout);
|
||||
}
|
||||
} catch (error) {
|
||||
@ -1220,7 +1314,7 @@ export class UnifiedWorkoutHistoryService {
|
||||
created_at: exercise.created_at,
|
||||
lastUpdated: exercise.updated_at,
|
||||
isCompleted: mappedSets.every(set => set.isCompleted),
|
||||
availability: { source: ['local'] }
|
||||
availability: { source: ['local' as 'local'] } // Explicitly cast to StorageSource
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -202,7 +202,7 @@ export function useAuthQuery() {
|
||||
case 'ephemeral':
|
||||
return authService.createEphemeralKey();
|
||||
default:
|
||||
logger.error("Invalid login method:", params.method);
|
||||
logger.error("Invalid login method:", (params as any).method);
|
||||
throw new Error('Invalid login method');
|
||||
}
|
||||
},
|
||||
|
294
lib/hooks/useExerciseNames.ts
Normal file
294
lib/hooks/useExerciseNames.ts
Normal 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;
|
||||
}
|
@ -50,6 +50,8 @@ export const QUERY_KEYS = {
|
||||
all: ['exercises'] as const,
|
||||
detail: (id: string) => [...QUERY_KEYS.exercises.all, 'detail', id] 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
|
||||
|
@ -44,7 +44,6 @@ import { ExerciseService } from '@/lib/db/services/ExerciseService';
|
||||
* even when accounting for time spent in completion flow.
|
||||
*/
|
||||
|
||||
|
||||
const AUTO_SAVE_INTERVAL = 30000; // 30 seconds
|
||||
|
||||
// Define a module-level timer reference for the workout timer
|
||||
@ -62,6 +61,9 @@ interface ExtendedWorkoutState extends WorkoutState {
|
||||
isMinimized: boolean;
|
||||
favoriteIds: string[]; // Only store IDs in memory
|
||||
favoritesLoaded: boolean;
|
||||
isPublishing: boolean;
|
||||
publishingStatus?: 'saving' | 'publishing-workout' | 'publishing-social' | 'error';
|
||||
publishError?: string;
|
||||
}
|
||||
|
||||
interface WorkoutActions {
|
||||
@ -121,7 +123,7 @@ interface ExtendedWorkoutActions extends WorkoutActions {
|
||||
loadFavorites: () => Promise<void>;
|
||||
|
||||
// New template management
|
||||
startWorkoutFromTemplate: (templateId: string) => Promise<void>;
|
||||
startWorkoutFromTemplate: (templateId: string, templateData?: WorkoutTemplate) => Promise<void>;
|
||||
|
||||
// Additional workout actions
|
||||
endWorkout: () => Promise<void>;
|
||||
@ -151,13 +153,16 @@ const initialState: ExtendedWorkoutState = {
|
||||
isActive: false,
|
||||
isMinimized: false,
|
||||
favoriteIds: [],
|
||||
favoritesLoaded: false
|
||||
favoritesLoaded: false,
|
||||
isPublishing: false,
|
||||
publishingStatus: undefined,
|
||||
publishError: undefined
|
||||
};
|
||||
|
||||
const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions>()((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
// Core Workout Flow
|
||||
// Core Workflow Flow
|
||||
startWorkout: (workoutData: Partial<Workout> = {}) => {
|
||||
// First stop any existing timer to avoid duplicate timers
|
||||
get().stopWorkoutTimer();
|
||||
@ -226,9 +231,17 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
return;
|
||||
}
|
||||
|
||||
// Set publishing state
|
||||
set({
|
||||
isPublishing: true,
|
||||
publishingStatus: 'saving',
|
||||
publishError: undefined
|
||||
});
|
||||
|
||||
const completedWorkout = {
|
||||
...activeWorkout,
|
||||
isCompleted: true,
|
||||
notes: options.workoutDescription || activeWorkout.notes, // Use workoutDescription for notes
|
||||
lastUpdated: Date.now()
|
||||
};
|
||||
|
||||
@ -246,6 +259,11 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
const { ndk, isAuthenticated } = useNDKStore.getState();
|
||||
|
||||
if (ndk && isAuthenticated) {
|
||||
// Update publishing status
|
||||
set({
|
||||
publishingStatus: 'publishing-workout'
|
||||
});
|
||||
|
||||
// Create appropriate Nostr event data
|
||||
const eventData = options.storageType === 'publish_complete'
|
||||
? NostrWorkoutService.createCompleteWorkoutEvent(completedWorkout)
|
||||
@ -288,6 +306,11 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
// Handle social share if selected
|
||||
if (options.shareOnSocial && options.socialMessage) {
|
||||
try {
|
||||
// Update publishing status for social
|
||||
set({
|
||||
publishingStatus: 'publishing-social'
|
||||
});
|
||||
|
||||
const socialEventData = NostrWorkoutService.createSocialShareEvent(
|
||||
event.id,
|
||||
options.socialMessage
|
||||
@ -316,8 +339,13 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
|
||||
await Promise.race([socialPublishPromise, socialPublishTimeout]);
|
||||
console.log('Successfully published social share');
|
||||
} catch (socialError) {
|
||||
} catch (error) {
|
||||
const socialError = error as Error;
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -325,24 +353,48 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
const signError = error as Error;
|
||||
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
|
||||
if (signError.message?.includes('timeout')) {
|
||||
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
|
||||
}
|
||||
} catch (eventCreationError) {
|
||||
} catch (error) {
|
||||
const eventCreationError = error as Error;
|
||||
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);
|
||||
// Update error state
|
||||
set({
|
||||
publishError: `Error publishing to Nostr: ${publishError.message || 'Unknown error'}`,
|
||||
publishingStatus: '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
|
||||
set({
|
||||
publishError: `Error preparing events: ${prepError.message || 'Unknown error'}`,
|
||||
publishingStatus: 'error'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -358,7 +410,8 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
);
|
||||
}
|
||||
} 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
|
||||
}
|
||||
}
|
||||
@ -371,15 +424,24 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
status: 'completed',
|
||||
activeWorkout: null, // Set to null to fully clear the workout
|
||||
isActive: false,
|
||||
isMinimized: false
|
||||
isMinimized: false,
|
||||
isPublishing: false, // Reset publishing state
|
||||
publishingStatus: undefined
|
||||
});
|
||||
|
||||
// Ensure we fully reset the state
|
||||
get().reset();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error completing workout:', error);
|
||||
// Consider showing an error message to the user
|
||||
const completeError = error as Error;
|
||||
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;
|
||||
} catch (error) {
|
||||
console.error('Failed to publish event:', error);
|
||||
throw error;
|
||||
const publishError = error as Error;
|
||||
console.error('Failed to publish event:', publishError);
|
||||
throw publishError;
|
||||
}
|
||||
},
|
||||
|
||||
@ -659,7 +722,7 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
if (!template) return;
|
||||
|
||||
// Convert template exercises to workout exercises
|
||||
const exercises: WorkoutExercise[] = template.exercises.map(templateExercise => ({
|
||||
const exercises: WorkoutExercise[] = template.exercises.map((templateExercise: any) => ({
|
||||
id: generateId('local'),
|
||||
title: templateExercise.exercise.title,
|
||||
type: templateExercise.exercise.type,
|
||||
@ -869,7 +932,6 @@ async function getTemplate(templateId: string): Promise<WorkoutTemplate | null>
|
||||
const exerciseService = new ExerciseService(db);
|
||||
const templateService = new TemplateService(db, new ExerciseService(db));
|
||||
|
||||
|
||||
// First try to get from favorites
|
||||
const favoriteResult = await favoritesService.getContentById<WorkoutTemplate>('template', templateId);
|
||||
if (favoriteResult) {
|
||||
@ -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> {
|
||||
try {
|
||||
console.log('Saving workout with endTime:', workout.endTime);
|
||||
|
||||
// Use the workout service to save the workout
|
||||
const db = openDatabaseSync('powr.db');
|
||||
const workoutService = new WorkoutService(db);
|
||||
|
||||
await workoutService.saveWorkout(workout);
|
||||
} catch (error) {
|
||||
console.error('Error saving workout:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSummary(summary: WorkoutSummary) {
|
||||
try {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate summary statistics for a workout
|
||||
*/
|
||||
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 {
|
||||
id: generateId('local'),
|
||||
id: workout.id,
|
||||
title: workout.title,
|
||||
type: workout.type,
|
||||
duration: workout.endTime ? workout.endTime - workout.startTime : 0,
|
||||
startTime: workout.startTime,
|
||||
endTime: workout.endTime || Date.now(),
|
||||
duration,
|
||||
startTime,
|
||||
endTime,
|
||||
exerciseCount: workout.exercises.length,
|
||||
completedExercises: workout.exercises.filter(e => e.isCompleted).length,
|
||||
totalVolume: calculateTotalVolume(workout),
|
||||
totalReps: calculateTotalReps(workout),
|
||||
averageRpe: calculateAverageRpe(workout),
|
||||
exerciseSummaries: [],
|
||||
personalRecords: []
|
||||
completedExercises: workout.exercises.filter(ex => ex.isCompleted).length,
|
||||
totalVolume,
|
||||
totalReps,
|
||||
averageRpe: rpeCount > 0 ? averageRpe / rpeCount : undefined,
|
||||
exerciseSummaries,
|
||||
personalRecords: [] // Personal records would be calculated separately
|
||||
};
|
||||
}
|
||||
|
||||
function calculateTotalVolume(workout: Workout): number {
|
||||
return workout.exercises.reduce((total, exercise) => {
|
||||
return total + exercise.sets.reduce((setTotal, set) => {
|
||||
return setTotal + (set.weight || 0) * (set.reps || 0);
|
||||
}, 0);
|
||||
}, 0);
|
||||
/**
|
||||
* Save workout summary to the database
|
||||
*/
|
||||
async function saveSummary(summary: WorkoutSummary): Promise<void> {
|
||||
try {
|
||||
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 {
|
||||
return workout.exercises.reduce((total, exercise) => {
|
||||
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
|
||||
// Create a version with selectors for easier state access
|
||||
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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -129,9 +129,13 @@ export function parseWorkoutRecord(event: NDKEvent): ParsedWorkoutRecord {
|
||||
const reference = tag[1] || '';
|
||||
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 {
|
||||
id: parts.length > 2 ? parts[2] : reference,
|
||||
name: extractExerciseName(reference),
|
||||
id,
|
||||
name,
|
||||
weight: tag[3] ? parseFloat(tag[3]) : undefined,
|
||||
reps: tag[4] ? parseInt(tag[4]) : 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
|
||||
function extractExerciseName(reference: string): string {
|
||||
// This is a placeholder function
|
||||
// In production, you would look up the exercise name from your database
|
||||
// For now, just return a formatted version of the reference
|
||||
const parts = reference.split(':');
|
||||
if (parts.length > 2) {
|
||||
return `Exercise ${parts[2].substring(0, 6)}`;
|
||||
// Extract exercise name from reference
|
||||
export function extractExerciseName(reference: string): string {
|
||||
if (!reference) return 'Exercise';
|
||||
|
||||
// Handle the "local:" prefix pattern seen in logs (e.g., "local:m94c2cm7-mrhvwgfcr0b")
|
||||
if (reference.startsWith('local:')) {
|
||||
const localIdentifier = reference.substring(6); // Remove "local:" prefix
|
||||
|
||||
// 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)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
return 'Unknown Exercise';
|
||||
}
|
@ -96,6 +96,9 @@ export interface WorkoutCompletionOptions {
|
||||
// Template update options
|
||||
templateAction: 'keep_original' | 'update_existing' | 'save_as_new';
|
||||
newTemplateName?: string;
|
||||
|
||||
// Workout description - added to the workout record content
|
||||
workoutDescription?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
Loading…
x
Reference in New Issue
Block a user