diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2feb80d..4af150e 100644
--- a/CHANGELOG.md
+++ b/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
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index bc01506..abdfbd6 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -249,7 +249,7 @@ export default function WorkoutScreen() {
Begin a new workout or choose from one of your templates.
- {/* Buttons from HomeWorkout but directly here */}
+ {/* Quick Start button */}
-
-
@@ -393,4 +385,4 @@ export default function WorkoutScreen() {
);
-}
\ No newline at end of file
+}
diff --git a/app/(workout)/complete.tsx b/app/(workout)/complete.tsx
index 557346e..fb3cd6d 100644
--- a/app/(workout)/complete.tsx
+++ b/app/(workout)/complete.tsx
@@ -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 */}
Complete Workout
-
+
diff --git a/components/DatabaseProvider.tsx b/components/DatabaseProvider.tsx
index 3ee1315..14f7ee0 100644
--- a/components/DatabaseProvider.tsx
+++ b/components/DatabaseProvider.tsx
@@ -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();
diff --git a/components/social/EnhancedSocialPost.tsx b/components/social/EnhancedSocialPost.tsx
index 54f2d23..91654c6 100644
--- a/components/social/EnhancedSocialPost.tsx
+++ b/components/social/EnhancedSocialPost.tsx
@@ -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 (
{workout.title}
@@ -262,14 +290,53 @@ function WorkoutContent({ workout }: { workout: ParsedWorkoutRecord }) {
{workout.exercises.length > 0 && (
Exercises:
- {workout.exercises.slice(0, 3).map((exercise, index) => (
-
- • {exercise.name}
- {exercise.weight ? ` - ${exercise.weight}kg` : ''}
- {exercise.reps ? ` × ${exercise.reps}` : ''}
- {exercise.rpe ? ` @ RPE ${exercise.rpe}` : ''}
-
- ))}
+ {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 (
+
+ • {displayName}
+ {exercise.weight ? ` - ${exercise.weight}kg` : ''}
+ {exercise.reps ? ` × ${exercise.reps}` : ''}
+ {exercise.rpe ? ` @ RPE ${exercise.rpe}` : ''}
+
+ );
+ })}
{workout.exercises.length > 3 && (
+{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 (
{template.title}
@@ -368,11 +457,36 @@ function TemplateContent({ template }: { template: ParsedWorkoutTemplate }) {
{template.exercises.length > 0 && (
Exercises:
- {template.exercises.slice(0, 3).map((exercise, index) => (
-
- • {exercise.name || 'Exercise ' + (index + 1)}
-
- ))}
+ {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 (
+
+ • {displayName}
+
+ );
+ })}
{template.exercises.length > 3 && (
+{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 (
{workout.title}
{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 ? '...' : ''}
+ {workout.startTime && workout.endTime && (
+
+ Duration: {formatDuration(workout.endTime - workout.startTime)}
+
+ )}
);
}
diff --git a/components/workout/WorkoutCard.tsx b/components/workout/WorkoutCard.tsx
index 0c8902f..b0f864d 100644
--- a/components/workout/WorkoutCard.tsx
+++ b/components/workout/WorkoutCard.tsx
@@ -215,11 +215,16 @@ export const WorkoutCard: React.FC = ({
Exercise
{/* In a real implementation, you would map through actual exercises */}
{workout.exercises && workout.exercises.length > 0 ? (
- workout.exercises.slice(0, 3).map((exercise, idx) => (
-
- {exercise.title}
-
- ))
+ workout.exercises.slice(0, 3).map((exercise, idx) => {
+ // Use the exercise title directly
+ const exerciseTitle = exercise.title || 'Exercise';
+
+ return (
+
+ {exerciseTitle}
+
+ );
+ })
) : (
No exercises recorded
)}
diff --git a/components/workout/WorkoutCompletionFlow.tsx b/components/workout/WorkoutCompletionFlow.tsx
index 01decff..9479c8b 100644
--- a/components/workout/WorkoutCompletionFlow.tsx
+++ b/components/workout/WorkoutCompletionFlow.tsx
@@ -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({
)}
- {/* Template options section - only if needed */}
- {hasTemplateChanges && (
- <>
-
-
- Template Options
-
- Your workout includes modifications to the original template
-
-
- {/* Keep original option */}
- handleTemplateAction('keep_original')}
- activeOpacity={0.7}
- >
-
-
-
- Keep Original
-
- Don't update the template
-
-
-
-
-
-
- {/* Update existing option */}
- handleTemplateAction('update_existing')}
- activeOpacity={0.7}
- >
-
-
-
- Update Template
-
- Save these changes to the original template
-
-
-
-
-
-
- {/* Save as new option */}
- handleTemplateAction('save_as_new')}
- activeOpacity={0.7}
- >
-
-
-
- Save as New
-
- Create a new template from this workout
-
-
-
-
-
-
- {/* Template name input if save as new is selected */}
- {options.templateAction === 'save_as_new' && (
-
- New template name:
- setOptions({
- ...options,
- newTemplateName: text
- })}
- />
-
- )}
- >
- )}
+ {/* Workout Description field and Next button */}
+
+ Workout Notes
+
+ Add context or details about your workout
+
+ 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 }}
+ />
+
- {/* Next button */}
+ {/* Next button with direct styling */}
+
+ {/* Template options section removed as it was causing bugs */}
);
@@ -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({
+ {/* Show publishing status */}
+ {isPublishing && (
+
+
+ {publishingStatus === 'saving' && 'Saving workout...'}
+ {publishingStatus === 'publishing-workout' && 'Publishing workout record...'}
+ {publishingStatus === 'publishing-social' && 'Sharing to Nostr...'}
+
+
+
+ )}
+
+ {/* Show error if any */}
+ {publishError && (
+
+ {publishError}
+
+ )}
+
{/* 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}
/>
);
-}
\ No newline at end of file
+}
diff --git a/lib/db/services/NostrWorkoutService.ts b/lib/db/services/NostrWorkoutService.ts
index 1ba58cc..23825ed 100644
--- a/lib/db/services/NostrWorkoutService.ts
+++ b/lib/db/services/NostrWorkoutService.ts
@@ -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
@@ -439,4 +512,4 @@ export class NostrWorkoutService {
relay: templateTag[2] || ''
};
}
-}
\ No newline at end of file
+}
diff --git a/lib/db/services/UnifiedWorkoutHistoryService.ts b/lib/db/services/UnifiedWorkoutHistoryService.ts
index 8a3fc07..af07441 100644
--- a/lib/db/services/UnifiedWorkoutHistoryService.ts
+++ b/lib/db/services/UnifiedWorkoutHistoryService.ts
@@ -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 {
+ // 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 {
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 = {
'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
});
}
diff --git a/lib/hooks/useAuthQuery.ts b/lib/hooks/useAuthQuery.ts
index 6649f42..2981d6d 100644
--- a/lib/hooks/useAuthQuery.ts
+++ b/lib/hooks/useAuthQuery.ts
@@ -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');
}
},
diff --git a/lib/hooks/useExerciseNames.ts b/lib/hooks/useExerciseNames.ts
new file mode 100644
index 0000000..522ab22
--- /dev/null
+++ b/lib/hooks/useExerciseNames.ts
@@ -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;
+
+/**
+ * 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 => {
+ 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 => {
+ 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 => {
+ 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 {
+ 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;
+}
diff --git a/lib/queryKeys.ts b/lib/queryKeys.ts
index 94b22cb..492d0ec 100644
--- a/lib/queryKeys.ts
+++ b/lib/queryKeys.ts
@@ -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
diff --git a/stores/workoutStore.ts b/stores/workoutStore.ts
index e9bb661..7a19ae3 100644
--- a/stores/workoutStore.ts
+++ b/stores/workoutStore.ts
@@ -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;
// New template management
- startWorkoutFromTemplate: (templateId: string) => Promise;
+ startWorkoutFromTemplate: (templateId: string, templateData?: WorkoutTemplate) => Promise;
// Additional workout actions
endWorkout: () => Promise;
@@ -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()((set, get) => ({
...initialState,
- // Core Workout Flow
+ // Core Workflow Flow
startWorkout: (workoutData: Partial = {}) => {
// First stop any existing timer to avoid duplicate timers
get().stopWorkoutTimer();
@@ -225,10 +230,18 @@ const useWorkoutStoreBase = create ({
+ const exercises: WorkoutExercise[] = template.exercises.map((templateExercise: any) => ({
id: generateId('local'),
title: templateExercise.exercise.title,
type: templateExercise.exercise.type,
@@ -868,7 +931,6 @@ async function getTemplate(templateId: string): Promise
const favoritesService = new FavoritesService(db);
const exerciseService = new ExerciseService(db);
const templateService = new TemplateService(db, new ExerciseService(db));
-
// First try to get from favorites
const favoriteResult = await favoritesService.getContentById('template', templateId);
@@ -885,89 +947,112 @@ async function getTemplate(templateId: string): Promise
}
}
+/**
+ * Save a workout to the database
+ */
async function saveWorkout(workout: Workout): Promise {
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 {
+ 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
-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');
- }
- });
-}
+// Create a version with selectors for easier state access
+export const useWorkoutStore = createSelectors(useWorkoutStoreBase);
\ No newline at end of file
diff --git a/types/nostr-workout.ts b/types/nostr-workout.ts
index b654110..9b71c72 100644
--- a/types/nostr-workout.ts
+++ b/types/nostr-workout.ts
@@ -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)}`;
+ }
+ }
}
- return 'Unknown Exercise';
-}
\ No newline at end of file
+
+ // 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 {
+ 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;
+ }
+}
diff --git a/types/workout.ts b/types/workout.ts
index 02574c8..a1e9ae5 100644
--- a/types/workout.ts
+++ b/types/workout.ts
@@ -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;
}
/**