mirror of
https://github.com/DocNR/POWR.git
synced 2025-06-23 16:05:31 +00:00
feat(database): Add workout and template persistence
Implements database tables and services for workout and template storage: - Updates schema to version 5 with new workout and template tables - Adds WorkoutService for CRUD operations on workouts - Enhances TemplateService for template management - Creates NostrWorkoutService for bidirectional Nostr event handling - Implements React hooks for database access - Connects workout store to database layer for persistence - Improves offline support with publication queue This change ensures workouts and templates are properly saved to SQLite and can be referenced across app sessions, while maintaining Nostr integration for social sharing.
This commit is contained in:
parent
001cb3078d
commit
29c4dd1675
49
CHANGELOG.md
49
CHANGELOG.md
@ -5,6 +5,55 @@ All notable changes to the POWR project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
# Changelog - March 8, 2025
|
||||||
|
|
||||||
|
## Added
|
||||||
|
- Database schema upgrade to version 5
|
||||||
|
- Added workouts, workout_exercises, and workout_sets tables
|
||||||
|
- Added templates and template_exercises tables
|
||||||
|
- Added publication_queue table for offline-first functionality
|
||||||
|
- Added app_status table for connectivity tracking
|
||||||
|
- New database services
|
||||||
|
- WorkoutService for managing workout data persistence
|
||||||
|
- Enhanced TemplateService for template management
|
||||||
|
- NostrWorkoutService for Nostr event conversion
|
||||||
|
- Updated PublicationQueueService for offline publishing
|
||||||
|
- React hooks for database access
|
||||||
|
- useWorkouts hook for workout operations
|
||||||
|
- useTemplates hook for template operations
|
||||||
|
- Improved workout completion flow
|
||||||
|
- Three-tier storage approach (Local Only, Publish Complete, Publish Limited)
|
||||||
|
- Template modification options (keep original, update, save as new)
|
||||||
|
- Enhanced social sharing capabilities
|
||||||
|
- Detailed workout summary with statistics
|
||||||
|
- Enhanced database debugging tools
|
||||||
|
- Added proper error handling and logging
|
||||||
|
- Improved transaction management
|
||||||
|
- Added connectivity status tracking
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
- Missing workout and template table errors
|
||||||
|
- Incomplete data storage issues
|
||||||
|
- Template management synchronization
|
||||||
|
- Nostr event conversion between app models and Nostr protocol
|
||||||
|
- Workout persistence across app sessions
|
||||||
|
- Database transaction handling in workout operations
|
||||||
|
- Template reference handling in workout records
|
||||||
|
|
||||||
|
## Improved
|
||||||
|
- Workout store persistence layer
|
||||||
|
- Enhanced integration with database services
|
||||||
|
- Better error handling for database operations
|
||||||
|
- Improved Nostr connectivity detection
|
||||||
|
- Template management workflow
|
||||||
|
- Proper versioning and attribution
|
||||||
|
- Enhanced modification tracking
|
||||||
|
- Better user control over template sharing
|
||||||
|
- Overall data persistence architecture
|
||||||
|
- Consistent service-based approach
|
||||||
|
- Improved type safety
|
||||||
|
- Enhanced error propagation
|
||||||
|
|
||||||
# Changelog - March 6, 2025
|
# Changelog - March 6, 2025
|
||||||
|
|
||||||
## Added
|
## Added
|
||||||
|
@ -6,12 +6,16 @@ import { ExerciseService } from '@/lib/db/services/ExerciseService';
|
|||||||
import { DevSeederService } from '@/lib/db/services/DevSeederService';
|
import { DevSeederService } from '@/lib/db/services/DevSeederService';
|
||||||
import { PublicationQueueService } from '@/lib/db/services/PublicationQueueService';
|
import { PublicationQueueService } from '@/lib/db/services/PublicationQueueService';
|
||||||
import { FavoritesService } from '@/lib/db/services/FavoritesService';
|
import { FavoritesService } from '@/lib/db/services/FavoritesService';
|
||||||
|
import { WorkoutService } from '@/lib/db/services/WorkoutService';
|
||||||
|
import { TemplateService } from '@/lib/db/services/TemplateService';
|
||||||
import { logDatabaseInfo } from '@/lib/db/debug';
|
import { logDatabaseInfo } from '@/lib/db/debug';
|
||||||
import { useNDKStore } from '@/lib/stores/ndk';
|
import { useNDKStore } from '@/lib/stores/ndk';
|
||||||
|
|
||||||
// Create context for services
|
// Create context for services
|
||||||
interface DatabaseServicesContextValue {
|
interface DatabaseServicesContextValue {
|
||||||
exerciseService: ExerciseService | null;
|
exerciseService: ExerciseService | null;
|
||||||
|
workoutService: WorkoutService | null;
|
||||||
|
templateService: TemplateService | null;
|
||||||
devSeeder: DevSeederService | null;
|
devSeeder: DevSeederService | null;
|
||||||
publicationQueue: PublicationQueueService | null;
|
publicationQueue: PublicationQueueService | null;
|
||||||
favoritesService: FavoritesService | null;
|
favoritesService: FavoritesService | null;
|
||||||
@ -20,6 +24,8 @@ interface DatabaseServicesContextValue {
|
|||||||
|
|
||||||
const DatabaseServicesContext = React.createContext<DatabaseServicesContextValue>({
|
const DatabaseServicesContext = React.createContext<DatabaseServicesContextValue>({
|
||||||
exerciseService: null,
|
exerciseService: null,
|
||||||
|
workoutService: null,
|
||||||
|
templateService: null,
|
||||||
devSeeder: null,
|
devSeeder: null,
|
||||||
publicationQueue: null,
|
publicationQueue: null,
|
||||||
favoritesService: null,
|
favoritesService: null,
|
||||||
@ -35,6 +41,8 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) {
|
|||||||
const [error, setError] = React.useState<string | null>(null);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [services, setServices] = React.useState<DatabaseServicesContextValue>({
|
const [services, setServices] = React.useState<DatabaseServicesContextValue>({
|
||||||
exerciseService: null,
|
exerciseService: null,
|
||||||
|
workoutService: null,
|
||||||
|
templateService: null,
|
||||||
devSeeder: null,
|
devSeeder: null,
|
||||||
publicationQueue: null,
|
publicationQueue: null,
|
||||||
favoritesService: null,
|
favoritesService: null,
|
||||||
@ -64,6 +72,8 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) {
|
|||||||
// Initialize services
|
// Initialize services
|
||||||
console.log('[DB] Initializing services...');
|
console.log('[DB] Initializing services...');
|
||||||
const exerciseService = new ExerciseService(db);
|
const exerciseService = new ExerciseService(db);
|
||||||
|
const workoutService = new WorkoutService(db);
|
||||||
|
const templateService = new TemplateService(db);
|
||||||
const devSeeder = new DevSeederService(db, exerciseService);
|
const devSeeder = new DevSeederService(db, exerciseService);
|
||||||
const publicationQueue = new PublicationQueueService(db);
|
const publicationQueue = new PublicationQueueService(db);
|
||||||
const favoritesService = new FavoritesService(db);
|
const favoritesService = new FavoritesService(db);
|
||||||
@ -80,6 +90,8 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) {
|
|||||||
// Set services
|
// Set services
|
||||||
setServices({
|
setServices({
|
||||||
exerciseService,
|
exerciseService,
|
||||||
|
workoutService,
|
||||||
|
templateService,
|
||||||
devSeeder,
|
devSeeder,
|
||||||
publicationQueue,
|
publicationQueue,
|
||||||
favoritesService,
|
favoritesService,
|
||||||
@ -140,6 +152,22 @@ export function useExerciseService() {
|
|||||||
return context.exerciseService;
|
return context.exerciseService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useWorkoutService() {
|
||||||
|
const context = React.useContext(DatabaseServicesContext);
|
||||||
|
if (!context.workoutService) {
|
||||||
|
throw new Error('Workout service not initialized');
|
||||||
|
}
|
||||||
|
return context.workoutService;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTemplateService() {
|
||||||
|
const context = React.useContext(DatabaseServicesContext);
|
||||||
|
if (!context.templateService) {
|
||||||
|
throw new Error('Template service not initialized');
|
||||||
|
}
|
||||||
|
return context.templateService;
|
||||||
|
}
|
||||||
|
|
||||||
export function useDevSeeder() {
|
export function useDevSeeder() {
|
||||||
const context = React.useContext(DatabaseServicesContext);
|
const context = React.useContext(DatabaseServicesContext);
|
||||||
if (!context.devSeeder) {
|
if (!context.devSeeder) {
|
||||||
|
@ -1,103 +0,0 @@
|
|||||||
// components/workout/WorkoutHeader.tsx
|
|
||||||
import React from 'react';
|
|
||||||
import { View } from 'react-native';
|
|
||||||
import { Text } from '@/components/ui/text';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Pause, Play, Square, ChevronLeft } from 'lucide-react-native';
|
|
||||||
import { useWorkoutStore } from '@/stores/workoutStore';
|
|
||||||
import { formatTime } from '@/utils/formatTime';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import EditableText from '@/components/EditableText';
|
|
||||||
|
|
||||||
interface WorkoutHeaderProps {
|
|
||||||
title?: string;
|
|
||||||
onBack?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function WorkoutHeader({ title, onBack }: WorkoutHeaderProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const status = useWorkoutStore.use.status();
|
|
||||||
const activeWorkout = useWorkoutStore.use.activeWorkout();
|
|
||||||
const elapsedTime = useWorkoutStore.use.elapsedTime();
|
|
||||||
const { pauseWorkout, resumeWorkout, completeWorkout, updateWorkoutTitle } = useWorkoutStore.getState();
|
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
if (onBack) {
|
|
||||||
onBack();
|
|
||||||
} else {
|
|
||||||
router.back();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!activeWorkout) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className={cn(
|
|
||||||
"px-4 py-2 border-b border-border",
|
|
||||||
status === 'paused' && "bg-muted/50"
|
|
||||||
)}>
|
|
||||||
{/* Header Row */}
|
|
||||||
<View className="flex-row items-center justify-between mb-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onPress={handleBack}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="text-foreground" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<View className="flex-1 px-4">
|
|
||||||
<EditableText
|
|
||||||
value={activeWorkout.title}
|
|
||||||
onChangeText={(newTitle) => updateWorkoutTitle(newTitle)}
|
|
||||||
style={{ alignItems: 'center' }}
|
|
||||||
placeholder="Workout Title"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex-row gap-2">
|
|
||||||
{status === 'active' ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onPress={pauseWorkout}
|
|
||||||
>
|
|
||||||
<Pause className="text-foreground" />
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onPress={resumeWorkout}
|
|
||||||
>
|
|
||||||
<Play className="text-foreground" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="icon"
|
|
||||||
onPress={completeWorkout}
|
|
||||||
>
|
|
||||||
<Square className="text-destructive-foreground" />
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Status Row */}
|
|
||||||
<View className="flex-row items-center justify-between">
|
|
||||||
<Text className={cn(
|
|
||||||
"text-2xl font-bold",
|
|
||||||
status === 'paused' ? "text-muted-foreground" : "text-foreground"
|
|
||||||
)}>
|
|
||||||
{formatTime(elapsedTime)}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Text className="text-sm text-muted-foreground capitalize">
|
|
||||||
{activeWorkout.type}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
203
lib/db/schema.ts
203
lib/db/schema.ts
@ -2,7 +2,7 @@
|
|||||||
import { SQLiteDatabase } from 'expo-sqlite';
|
import { SQLiteDatabase } from 'expo-sqlite';
|
||||||
import { Platform } from 'react-native';
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
export const SCHEMA_VERSION = 1;
|
export const SCHEMA_VERSION = 2; // Increment since we're adding new tables
|
||||||
|
|
||||||
class Schema {
|
class Schema {
|
||||||
private async getCurrentVersion(db: SQLiteDatabase): Promise<number> {
|
private async getCurrentVersion(db: SQLiteDatabase): Promise<number> {
|
||||||
@ -32,17 +32,8 @@ class Schema {
|
|||||||
async createTables(db: SQLiteDatabase): Promise<void> {
|
async createTables(db: SQLiteDatabase): Promise<void> {
|
||||||
try {
|
try {
|
||||||
console.log(`[Schema] Initializing database on ${Platform.OS}`);
|
console.log(`[Schema] Initializing database on ${Platform.OS}`);
|
||||||
const currentVersion = await this.getCurrentVersion(db);
|
|
||||||
|
|
||||||
// If we already have the current version, no need to recreate tables
|
// First, ensure schema_version table exists since we need it for version checking
|
||||||
if (currentVersion === SCHEMA_VERSION) {
|
|
||||||
console.log(`[Schema] Database already at version ${SCHEMA_VERSION}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[Schema] Creating tables for version ${SCHEMA_VERSION}`);
|
|
||||||
|
|
||||||
// Schema version tracking
|
|
||||||
await db.execAsync(`
|
await db.execAsync(`
|
||||||
CREATE TABLE IF NOT EXISTS schema_version (
|
CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
version INTEGER PRIMARY KEY,
|
version INTEGER PRIMARY KEY,
|
||||||
@ -50,16 +41,48 @@ class Schema {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const currentVersion = await this.getCurrentVersion(db);
|
||||||
|
console.log(`[Schema] Current version: ${currentVersion}, Target version: ${SCHEMA_VERSION}`);
|
||||||
|
|
||||||
|
// If we already have the current version, no need to recreate tables
|
||||||
|
if (currentVersion === SCHEMA_VERSION) {
|
||||||
|
console.log(`[Schema] Database already at version ${SCHEMA_VERSION}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Schema] Upgrading from version ${currentVersion} to ${SCHEMA_VERSION}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use a transaction to ensure all-or-nothing table creation
|
||||||
|
await db.withTransactionAsync(async () => {
|
||||||
// Drop all existing tables (except schema_version)
|
// Drop all existing tables (except schema_version)
|
||||||
await this.dropAllTables(db);
|
await this.dropAllTables(db);
|
||||||
|
|
||||||
// Create all tables in their latest form
|
// Create all tables in their latest form
|
||||||
await this.createAllTables(db);
|
await this.createAllTables(db);
|
||||||
|
|
||||||
|
// Update schema version at the end of the transaction
|
||||||
|
await this.updateSchemaVersion(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[Schema] Database successfully upgraded to version ${SCHEMA_VERSION}`);
|
||||||
|
} catch (txError) {
|
||||||
|
console.error('[Schema] Transaction error during schema upgrade:', txError);
|
||||||
|
|
||||||
|
// If transaction failed, try a non-transactional approach as fallback
|
||||||
|
console.log('[Schema] Attempting non-transactional schema upgrade...');
|
||||||
|
|
||||||
|
// Drop all existing tables except schema_version
|
||||||
|
await this.dropAllTables(db);
|
||||||
|
|
||||||
|
// Create all tables in their latest form
|
||||||
|
await this.createAllTables(db);
|
||||||
|
|
||||||
// Update schema version
|
// Update schema version
|
||||||
await this.updateSchemaVersion(db);
|
await this.updateSchemaVersion(db);
|
||||||
|
|
||||||
console.log(`[Schema] Database initialized at version ${SCHEMA_VERSION}`);
|
console.log('[Schema] Non-transactional schema upgrade completed');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Schema] Error creating tables:', error);
|
console.error('[Schema] Error creating tables:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@ -67,20 +90,38 @@ class Schema {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async dropAllTables(db: SQLiteDatabase): Promise<void> {
|
private async dropAllTables(db: SQLiteDatabase): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log('[Schema] Getting list of tables to drop...');
|
||||||
|
|
||||||
// Get list of all tables excluding schema_version
|
// Get list of all tables excluding schema_version
|
||||||
const tables = await db.getAllAsync<{ name: string }>(
|
const tables = await db.getAllAsync<{ name: string }>(
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name != 'schema_version'"
|
"SELECT name FROM sqlite_master WHERE type='table' AND name != 'schema_version'"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log(`[Schema] Found ${tables.length} tables to drop`);
|
||||||
|
|
||||||
// Drop each table
|
// Drop each table
|
||||||
for (const { name } of tables) {
|
for (const { name } of tables) {
|
||||||
|
try {
|
||||||
await db.execAsync(`DROP TABLE IF EXISTS ${name}`);
|
await db.execAsync(`DROP TABLE IF EXISTS ${name}`);
|
||||||
console.log(`[Schema] Dropped table: ${name}`);
|
console.log(`[Schema] Dropped table: ${name}`);
|
||||||
|
} catch (dropError) {
|
||||||
|
console.error(`[Schema] Error dropping table ${name}:`, dropError);
|
||||||
|
// Continue with other tables even if one fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Schema] Error in dropAllTables:', error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createAllTables(db: SQLiteDatabase): Promise<void> {
|
private async createAllTables(db: SQLiteDatabase): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log('[Schema] Creating all database tables...');
|
||||||
|
|
||||||
// Create exercises table
|
// Create exercises table
|
||||||
|
console.log('[Schema] Creating exercises table...');
|
||||||
await db.execAsync(`
|
await db.execAsync(`
|
||||||
CREATE TABLE exercises (
|
CREATE TABLE exercises (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@ -99,6 +140,7 @@ class Schema {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
// Create exercise_tags table
|
// Create exercise_tags table
|
||||||
|
console.log('[Schema] Creating exercise_tags table...');
|
||||||
await db.execAsync(`
|
await db.execAsync(`
|
||||||
CREATE TABLE exercise_tags (
|
CREATE TABLE exercise_tags (
|
||||||
exercise_id TEXT NOT NULL,
|
exercise_id TEXT NOT NULL,
|
||||||
@ -110,6 +152,7 @@ class Schema {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
// Create nostr_events table
|
// Create nostr_events table
|
||||||
|
console.log('[Schema] Creating nostr_events table...');
|
||||||
await db.execAsync(`
|
await db.execAsync(`
|
||||||
CREATE TABLE nostr_events (
|
CREATE TABLE nostr_events (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@ -124,6 +167,7 @@ class Schema {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
// Create event_tags table
|
// Create event_tags table
|
||||||
|
console.log('[Schema] Creating event_tags table...');
|
||||||
await db.execAsync(`
|
await db.execAsync(`
|
||||||
CREATE TABLE event_tags (
|
CREATE TABLE event_tags (
|
||||||
event_id TEXT NOT NULL,
|
event_id TEXT NOT NULL,
|
||||||
@ -136,6 +180,7 @@ class Schema {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
// Create cache metadata table
|
// Create cache metadata table
|
||||||
|
console.log('[Schema] Creating cache_metadata table...');
|
||||||
await db.execAsync(`
|
await db.execAsync(`
|
||||||
CREATE TABLE cache_metadata (
|
CREATE TABLE cache_metadata (
|
||||||
content_id TEXT PRIMARY KEY,
|
content_id TEXT PRIMARY KEY,
|
||||||
@ -147,6 +192,7 @@ class Schema {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
// Create media cache table
|
// Create media cache table
|
||||||
|
console.log('[Schema] Creating exercise_media table...');
|
||||||
await db.execAsync(`
|
await db.execAsync(`
|
||||||
CREATE TABLE exercise_media (
|
CREATE TABLE exercise_media (
|
||||||
exercise_id TEXT NOT NULL,
|
exercise_id TEXT NOT NULL,
|
||||||
@ -159,6 +205,7 @@ class Schema {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
// Create user profiles table
|
// Create user profiles table
|
||||||
|
console.log('[Schema] Creating user_profiles table...');
|
||||||
await db.execAsync(`
|
await db.execAsync(`
|
||||||
CREATE TABLE user_profiles (
|
CREATE TABLE user_profiles (
|
||||||
pubkey TEXT PRIMARY KEY,
|
pubkey TEXT PRIMARY KEY,
|
||||||
@ -175,6 +222,7 @@ class Schema {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
// Create user relays table
|
// Create user relays table
|
||||||
|
console.log('[Schema] Creating user_relays table...');
|
||||||
await db.execAsync(`
|
await db.execAsync(`
|
||||||
CREATE TABLE user_relays (
|
CREATE TABLE user_relays (
|
||||||
pubkey TEXT NOT NULL,
|
pubkey TEXT NOT NULL,
|
||||||
@ -188,6 +236,7 @@ class Schema {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
// Create publication queue table
|
// Create publication queue table
|
||||||
|
console.log('[Schema] Creating publication_queue table...');
|
||||||
await db.execAsync(`
|
await db.execAsync(`
|
||||||
CREATE TABLE publication_queue (
|
CREATE TABLE publication_queue (
|
||||||
event_id TEXT PRIMARY KEY,
|
event_id TEXT PRIMARY KEY,
|
||||||
@ -201,6 +250,7 @@ class Schema {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
// Create app status table
|
// Create app status table
|
||||||
|
console.log('[Schema] Creating app_status table...');
|
||||||
await db.execAsync(`
|
await db.execAsync(`
|
||||||
CREATE TABLE app_status (
|
CREATE TABLE app_status (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
@ -210,6 +260,7 @@ class Schema {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
// Create NDK cache table
|
// Create NDK cache table
|
||||||
|
console.log('[Schema] Creating ndk_cache table...');
|
||||||
await db.execAsync(`
|
await db.execAsync(`
|
||||||
CREATE TABLE ndk_cache (
|
CREATE TABLE ndk_cache (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@ -222,6 +273,7 @@ class Schema {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
// Create favorites table
|
// Create favorites table
|
||||||
|
console.log('[Schema] Creating favorites table...');
|
||||||
await db.execAsync(`
|
await db.execAsync(`
|
||||||
CREATE TABLE favorites (
|
CREATE TABLE favorites (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@ -235,9 +287,116 @@ class Schema {
|
|||||||
CREATE INDEX idx_favorites_content_type ON favorites(content_type);
|
CREATE INDEX idx_favorites_content_type ON favorites(content_type);
|
||||||
CREATE INDEX idx_favorites_content_id ON favorites(content_id);
|
CREATE INDEX idx_favorites_content_id ON favorites(content_id);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// === NEW TABLES === //
|
||||||
|
|
||||||
|
// Create workouts table
|
||||||
|
console.log('[Schema] Creating workouts table...');
|
||||||
|
await db.execAsync(`
|
||||||
|
CREATE TABLE workouts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
start_time INTEGER NOT NULL,
|
||||||
|
end_time INTEGER,
|
||||||
|
is_completed BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
template_id TEXT,
|
||||||
|
nostr_event_id TEXT,
|
||||||
|
share_status TEXT NOT NULL DEFAULT 'local',
|
||||||
|
source TEXT NOT NULL DEFAULT 'local',
|
||||||
|
total_volume REAL,
|
||||||
|
total_reps INTEGER,
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_workouts_start_time ON workouts(start_time);
|
||||||
|
CREATE INDEX idx_workouts_template_id ON workouts(template_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create workout_exercises table
|
||||||
|
console.log('[Schema] Creating workout_exercises table...');
|
||||||
|
await db.execAsync(`
|
||||||
|
CREATE TABLE workout_exercises (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
workout_id TEXT NOT NULL,
|
||||||
|
exercise_id TEXT NOT NULL,
|
||||||
|
display_order INTEGER NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY(workout_id) REFERENCES workouts(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_workout_exercises_workout_id ON workout_exercises(workout_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create workout_sets table
|
||||||
|
console.log('[Schema] Creating workout_sets table...');
|
||||||
|
await db.execAsync(`
|
||||||
|
CREATE TABLE workout_sets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
workout_exercise_id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL DEFAULT 'normal',
|
||||||
|
weight REAL,
|
||||||
|
reps INTEGER,
|
||||||
|
rpe REAL,
|
||||||
|
duration INTEGER,
|
||||||
|
is_completed BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
completed_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY(workout_exercise_id) REFERENCES workout_exercises(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_workout_sets_exercise_id ON workout_sets(workout_exercise_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create templates table
|
||||||
|
console.log('[Schema] Creating templates table...');
|
||||||
|
await db.execAsync(`
|
||||||
|
CREATE TABLE templates (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
nostr_event_id TEXT,
|
||||||
|
source TEXT NOT NULL DEFAULT 'local',
|
||||||
|
parent_id TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_templates_updated_at ON templates(updated_at);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create template_exercises table
|
||||||
|
console.log('[Schema] Creating template_exercises table...');
|
||||||
|
await db.execAsync(`
|
||||||
|
CREATE TABLE template_exercises (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
template_id TEXT NOT NULL,
|
||||||
|
exercise_id TEXT NOT NULL,
|
||||||
|
display_order INTEGER NOT NULL,
|
||||||
|
target_sets INTEGER,
|
||||||
|
target_reps INTEGER,
|
||||||
|
target_weight REAL,
|
||||||
|
notes TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY(template_id) REFERENCES templates(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_template_exercises_template_id ON template_exercises(template_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('[Schema] All tables created successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Schema] Error in createAllTables:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateSchemaVersion(db: SQLiteDatabase): Promise<void> {
|
private async updateSchemaVersion(db: SQLiteDatabase): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log(`[Schema] Updating schema version to ${SCHEMA_VERSION}`);
|
||||||
|
|
||||||
// Delete any existing schema version records
|
// Delete any existing schema version records
|
||||||
await db.runAsync('DELETE FROM schema_version');
|
await db.runAsync('DELETE FROM schema_version');
|
||||||
|
|
||||||
@ -246,17 +405,29 @@ class Schema {
|
|||||||
'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)',
|
'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)',
|
||||||
[SCHEMA_VERSION, Date.now()]
|
[SCHEMA_VERSION, Date.now()]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log('[Schema] Schema version updated successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Schema] Error updating schema version:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async resetDatabase(db: SQLiteDatabase): Promise<void> {
|
async resetDatabase(db: SQLiteDatabase): Promise<void> {
|
||||||
if (!__DEV__) return; // Only allow in development
|
if (!__DEV__) {
|
||||||
|
console.log('[Schema] Database reset is only available in development mode');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[Schema] Resetting database...');
|
console.log('[Schema] Resetting database...');
|
||||||
|
|
||||||
await this.dropAllTables(db);
|
// Clear schema_version to force recreation of all tables
|
||||||
await this.createAllTables(db);
|
await db.execAsync('DROP TABLE IF EXISTS schema_version');
|
||||||
await this.updateSchemaVersion(db);
|
console.log('[Schema] Dropped schema_version table');
|
||||||
|
|
||||||
|
// Now create tables from scratch
|
||||||
|
await this.createTables(db);
|
||||||
|
|
||||||
console.log('[Schema] Database reset complete');
|
console.log('[Schema] Database reset complete');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -1,13 +1,21 @@
|
|||||||
// lib/db/services/DevSeederService.ts
|
// lib/db/services/DevSeederService.ts
|
||||||
import { SQLiteDatabase } from 'expo-sqlite';
|
import { SQLiteDatabase } from 'expo-sqlite';
|
||||||
import { ExerciseService } from './ExerciseService';
|
import { ExerciseService } from './ExerciseService';
|
||||||
|
import { EventCache } from '@/lib/db/services/EventCache';
|
||||||
|
import { WorkoutService } from './WorkoutService';
|
||||||
|
import { TemplateService } from './TemplateService';
|
||||||
import { logDatabaseInfo } from '../debug';
|
import { logDatabaseInfo } from '../debug';
|
||||||
import { mockExerciseEvents, convertNostrToExercise } from '../../mocks/exercises';
|
import { mockExerciseEvents, convertNostrToExercise } from '../../mocks/exercises';
|
||||||
|
import { DbService } from '../db-service';
|
||||||
import NDK, { NDKEvent } from '@nostr-dev-kit/ndk-mobile';
|
import NDK, { NDKEvent } from '@nostr-dev-kit/ndk-mobile';
|
||||||
|
|
||||||
export class DevSeederService {
|
export class DevSeederService {
|
||||||
private db: SQLiteDatabase;
|
private db: SQLiteDatabase;
|
||||||
|
private dbService: DbService;
|
||||||
private exerciseService: ExerciseService;
|
private exerciseService: ExerciseService;
|
||||||
|
private workoutService: WorkoutService | null = null;
|
||||||
|
private templateService: TemplateService | null = null;
|
||||||
|
private eventCache: EventCache | null = null;
|
||||||
private ndk: NDK | null = null;
|
private ndk: NDK | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -15,7 +23,17 @@ export class DevSeederService {
|
|||||||
exerciseService: ExerciseService
|
exerciseService: ExerciseService
|
||||||
) {
|
) {
|
||||||
this.db = db;
|
this.db = db;
|
||||||
|
this.dbService = new DbService(db);
|
||||||
this.exerciseService = exerciseService;
|
this.exerciseService = exerciseService;
|
||||||
|
|
||||||
|
// Try to initialize other services if needed
|
||||||
|
try {
|
||||||
|
this.workoutService = new WorkoutService(db);
|
||||||
|
this.templateService = new TemplateService(db);
|
||||||
|
this.eventCache = new EventCache(db);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Some services not available yet:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setNDK(ndk: NDK) {
|
setNDK(ndk: NDK) {
|
||||||
@ -36,9 +54,7 @@ export class DevSeederService {
|
|||||||
|
|
||||||
if (existingCount > 0) {
|
if (existingCount > 0) {
|
||||||
console.log('Database already seeded with', existingCount, 'exercises');
|
console.log('Database already seeded with', existingCount, 'exercises');
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
|
|
||||||
// Start transaction for all seeding operations
|
// Start transaction for all seeding operations
|
||||||
await this.db.withTransactionAsync(async () => {
|
await this.db.withTransactionAsync(async () => {
|
||||||
console.log('Seeding mock exercises...');
|
console.log('Seeding mock exercises...');
|
||||||
@ -51,7 +67,6 @@ export class DevSeederService {
|
|||||||
Object.assign(event, eventData);
|
Object.assign(event, eventData);
|
||||||
|
|
||||||
// Cache the event in NDK
|
// Cache the event in NDK
|
||||||
if (this.ndk) {
|
|
||||||
const ndkEvent = new NDKEvent(this.ndk);
|
const ndkEvent = new NDKEvent(this.ndk);
|
||||||
|
|
||||||
// Copy event properties
|
// Copy event properties
|
||||||
@ -70,6 +85,14 @@ export class DevSeederService {
|
|||||||
await ndkEvent.sign();
|
await ndkEvent.sign();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache the event if possible
|
||||||
|
if (this.eventCache) {
|
||||||
|
try {
|
||||||
|
await this.eventCache.setEvent(eventData, true);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Error caching event:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create exercise from the mock data regardless of NDK availability
|
// Create exercise from the mock data regardless of NDK availability
|
||||||
@ -79,6 +102,11 @@ export class DevSeederService {
|
|||||||
|
|
||||||
console.log('Successfully seeded', mockExerciseEvents.length, 'exercises');
|
console.log('Successfully seeded', mockExerciseEvents.length, 'exercises');
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed workout and template tables
|
||||||
|
await this.seedWorkoutTables();
|
||||||
|
await this.seedTemplates();
|
||||||
|
|
||||||
// Log final database state
|
// Log final database state
|
||||||
await logDatabaseInfo();
|
await logDatabaseInfo();
|
||||||
@ -89,6 +117,70 @@ export class DevSeederService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async seedWorkoutTables() {
|
||||||
|
if (!__DEV__) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Checking workout tables seeding...');
|
||||||
|
|
||||||
|
// Check if we already have workout data
|
||||||
|
try {
|
||||||
|
const hasWorkouts = await this.dbService.getFirstAsync<{ count: number }>(
|
||||||
|
'SELECT COUNT(*) as count FROM workouts'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasWorkouts && hasWorkouts.count > 0) {
|
||||||
|
console.log('Workout tables already seeded with', hasWorkouts.count, 'workouts');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('No workout data found, but tables should be created');
|
||||||
|
|
||||||
|
// Optional: Add mock workout data here
|
||||||
|
// if (this.workoutService) {
|
||||||
|
// // Create mock workouts
|
||||||
|
// // await this.workoutService.saveWorkout(mockWorkout);
|
||||||
|
// }
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Workout tables may not exist yet - will be created in schema update');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking workout tables:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async seedTemplates() {
|
||||||
|
if (!__DEV__) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Checking template tables seeding...');
|
||||||
|
|
||||||
|
// Check if templates table exists and has data
|
||||||
|
try {
|
||||||
|
const hasTemplates = await this.dbService.getFirstAsync<{ count: number }>(
|
||||||
|
'SELECT COUNT(*) as count FROM templates'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasTemplates && hasTemplates.count > 0) {
|
||||||
|
console.log('Template tables already seeded with', hasTemplates.count, 'templates');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('No template data found, but tables should be created');
|
||||||
|
|
||||||
|
// Optional: Add mock template data here
|
||||||
|
// if (this.templateService) {
|
||||||
|
// // Create mock templates
|
||||||
|
// // await this.templateService.createTemplate(mockTemplate);
|
||||||
|
// }
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Template tables may not exist yet - will be created in schema update');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking template tables:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async clearDatabase() {
|
async clearDatabase() {
|
||||||
if (!__DEV__) return;
|
if (!__DEV__) return;
|
||||||
|
|
||||||
@ -97,16 +189,29 @@ export class DevSeederService {
|
|||||||
|
|
||||||
await this.db.withTransactionAsync(async () => {
|
await this.db.withTransactionAsync(async () => {
|
||||||
const tables = [
|
const tables = [
|
||||||
|
// Original tables
|
||||||
'exercises',
|
'exercises',
|
||||||
'exercise_tags',
|
'exercise_tags',
|
||||||
'nostr_events',
|
'nostr_events',
|
||||||
'event_tags',
|
'event_tags',
|
||||||
'cache_metadata',
|
'cache_metadata',
|
||||||
'ndk_cache' // Add the NDK Mobile cache table
|
'ndk_cache',
|
||||||
|
|
||||||
|
// New tables
|
||||||
|
'workouts',
|
||||||
|
'workout_exercises',
|
||||||
|
'workout_sets',
|
||||||
|
'templates',
|
||||||
|
'template_exercises'
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const table of tables) {
|
for (const table of tables) {
|
||||||
|
try {
|
||||||
await this.db.runAsync(`DELETE FROM ${table}`);
|
await this.db.runAsync(`DELETE FROM ${table}`);
|
||||||
|
console.log(`Cleared table: ${table}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Table ${table} might not exist yet, skipping`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
121
lib/db/services/EventCache.ts
Normal file
121
lib/db/services/EventCache.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
// lib/db/services/EventCache.ts
|
||||||
|
import { SQLiteDatabase } from 'expo-sqlite';
|
||||||
|
import { NostrEvent } from '@/types/nostr';
|
||||||
|
import { DbService } from '../db-service';
|
||||||
|
|
||||||
|
export class EventCache {
|
||||||
|
private db: DbService;
|
||||||
|
|
||||||
|
constructor(database: SQLiteDatabase) {
|
||||||
|
this.db = new DbService(database);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a Nostr event in the cache
|
||||||
|
*/
|
||||||
|
async setEvent(event: NostrEvent, skipExisting: boolean = false): Promise<void> {
|
||||||
|
if (!event.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if event already exists
|
||||||
|
if (skipExisting) {
|
||||||
|
const exists = await this.db.getFirstAsync<{ id: string }>(
|
||||||
|
'SELECT id FROM nostr_events WHERE id = ?',
|
||||||
|
[event.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (exists) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the event
|
||||||
|
await this.db.withTransactionAsync(async () => {
|
||||||
|
await this.db.runAsync(
|
||||||
|
`INSERT OR REPLACE INTO nostr_events
|
||||||
|
(id, pubkey, kind, created_at, content, sig, raw_event, received_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
event.id,
|
||||||
|
event.pubkey || '',
|
||||||
|
event.kind,
|
||||||
|
event.created_at,
|
||||||
|
event.content,
|
||||||
|
event.sig || '',
|
||||||
|
JSON.stringify(event),
|
||||||
|
Date.now()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store event tags
|
||||||
|
if (event.tags && event.tags.length > 0) {
|
||||||
|
// Delete existing tags first
|
||||||
|
await this.db.runAsync(
|
||||||
|
'DELETE FROM event_tags WHERE event_id = ?',
|
||||||
|
[event.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert new tags
|
||||||
|
for (let i = 0; i < event.tags.length; i++) {
|
||||||
|
const tag = event.tags[i];
|
||||||
|
if (tag.length >= 2) {
|
||||||
|
await this.db.runAsync(
|
||||||
|
'INSERT INTO event_tags (event_id, name, value, index_num) VALUES (?, ?, ?, ?)',
|
||||||
|
[event.id, tag[0], tag[1], i]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error caching event:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an event from the cache by ID
|
||||||
|
*/
|
||||||
|
async getEvent(id: string): Promise<NostrEvent | null> {
|
||||||
|
try {
|
||||||
|
const event = await this.db.getFirstAsync<{
|
||||||
|
id: string;
|
||||||
|
pubkey: string;
|
||||||
|
kind: number;
|
||||||
|
created_at: number;
|
||||||
|
content: string;
|
||||||
|
sig: string;
|
||||||
|
raw_event: string;
|
||||||
|
}>(
|
||||||
|
'SELECT * FROM nostr_events WHERE id = ?',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!event) return null;
|
||||||
|
|
||||||
|
// Get tags
|
||||||
|
const tags = await this.db.getAllAsync<{
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
index_num: number;
|
||||||
|
}>(
|
||||||
|
'SELECT name, value, index_num FROM event_tags WHERE event_id = ? ORDER BY index_num',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build the event object
|
||||||
|
const nostrEvent: NostrEvent = {
|
||||||
|
id: event.id,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
kind: event.kind,
|
||||||
|
created_at: event.created_at,
|
||||||
|
content: event.content,
|
||||||
|
sig: event.sig,
|
||||||
|
tags: tags.map(tag => [tag.name, tag.value])
|
||||||
|
};
|
||||||
|
|
||||||
|
return nostrEvent;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error retrieving event:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,10 @@
|
|||||||
// lib/services/NostrWorkoutService.ts - updated
|
// lib/services/NostrWorkoutService.ts
|
||||||
import { Workout } from '@/types/workout';
|
import { Workout, WorkoutExercise, WorkoutSet } from '@/types/workout';
|
||||||
|
import { WorkoutTemplate, TemplateExerciseConfig } from '@/types/templates';
|
||||||
import { NostrEvent } from '@/types/nostr';
|
import { NostrEvent } from '@/types/nostr';
|
||||||
|
import { generateId } from '@/utils/ids';
|
||||||
|
import { ExerciseCategory, ExerciseType } from '@/types/exercise';
|
||||||
|
import { TemplateType } from '@/types/templates';
|
||||||
/**
|
/**
|
||||||
* Service for creating and handling Nostr workout events
|
* Service for creating and handling Nostr workout events
|
||||||
*/
|
*/
|
||||||
@ -10,60 +13,14 @@ export class NostrWorkoutService {
|
|||||||
* Creates a complete Nostr workout event with all details
|
* Creates a complete Nostr workout event with all details
|
||||||
*/
|
*/
|
||||||
static createCompleteWorkoutEvent(workout: Workout): NostrEvent {
|
static createCompleteWorkoutEvent(workout: Workout): NostrEvent {
|
||||||
return {
|
return this.workoutToNostrEvent(workout, false);
|
||||||
kind: 1301, // As per NIP-4e spec
|
|
||||||
content: workout.notes || '',
|
|
||||||
tags: [
|
|
||||||
['d', workout.id],
|
|
||||||
['title', workout.title],
|
|
||||||
['type', workout.type],
|
|
||||||
['start', Math.floor(workout.startTime / 1000).toString()],
|
|
||||||
['end', workout.endTime ? Math.floor(workout.endTime / 1000).toString() : ''],
|
|
||||||
['completed', 'true'],
|
|
||||||
// Add all exercise data with complete metrics
|
|
||||||
...workout.exercises.flatMap(exercise =>
|
|
||||||
exercise.sets.map(set => [
|
|
||||||
'exercise',
|
|
||||||
`33401:${exercise.id}`,
|
|
||||||
set.weight?.toString() || '',
|
|
||||||
set.reps?.toString() || '',
|
|
||||||
set.rpe?.toString() || '',
|
|
||||||
set.type
|
|
||||||
])
|
|
||||||
),
|
|
||||||
// Include template reference if workout was based on template
|
|
||||||
...(workout.templateId ? [['template', `33402:${workout.templateId}`]] : []),
|
|
||||||
// Add any tags from the workout
|
|
||||||
...(workout.tags ? workout.tags.map(tag => ['t', tag]) : [])
|
|
||||||
],
|
|
||||||
created_at: Math.floor(Date.now() / 1000)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a limited Nostr workout event with reduced metrics for privacy
|
* Creates a limited Nostr workout event with reduced metrics for privacy
|
||||||
*/
|
*/
|
||||||
static createLimitedWorkoutEvent(workout: Workout): NostrEvent {
|
static createLimitedWorkoutEvent(workout: Workout): NostrEvent {
|
||||||
return {
|
return this.workoutToNostrEvent(workout, true);
|
||||||
kind: 1301,
|
|
||||||
content: workout.notes || '',
|
|
||||||
tags: [
|
|
||||||
['d', workout.id],
|
|
||||||
['title', workout.title],
|
|
||||||
['type', workout.type],
|
|
||||||
['start', Math.floor(workout.startTime / 1000).toString()],
|
|
||||||
['end', workout.endTime ? Math.floor(workout.endTime / 1000).toString() : ''],
|
|
||||||
['completed', 'true'],
|
|
||||||
// Add limited exercise data - just exercise names without metrics
|
|
||||||
...workout.exercises.map(exercise => [
|
|
||||||
'exercise',
|
|
||||||
`33401:${exercise.id}`
|
|
||||||
]),
|
|
||||||
...(workout.templateId ? [['template', `33402:${workout.templateId}`]] : []),
|
|
||||||
...(workout.tags ? workout.tags.map(tag => ['t', tag]) : [])
|
|
||||||
],
|
|
||||||
created_at: Math.floor(Date.now() / 1000)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -83,4 +40,371 @@ export class NostrWorkoutService {
|
|||||||
created_at: Math.floor(Date.now() / 1000)
|
created_at: Math.floor(Date.now() / 1000)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic method to convert a workout to a Nostr event
|
||||||
|
* @param workout The workout data
|
||||||
|
* @param isLimited Whether to include limited data only
|
||||||
|
* @returns A NostrEvent (kind 1301)
|
||||||
|
*/
|
||||||
|
static workoutToNostrEvent(workout: Workout, isLimited: boolean = false): NostrEvent {
|
||||||
|
// Format start and end dates as Unix timestamps in seconds
|
||||||
|
const startTime = Math.floor(workout.startTime / 1000);
|
||||||
|
const endTime = workout.endTime ? Math.floor(workout.endTime / 1000) : undefined;
|
||||||
|
|
||||||
|
// Prepare tags for Nostr event
|
||||||
|
const tags: string[][] = [
|
||||||
|
["d", workout.id],
|
||||||
|
["title", workout.title],
|
||||||
|
["type", workout.type],
|
||||||
|
["start", startTime.toString()],
|
||||||
|
["end", endTime ? endTime.toString() : ""],
|
||||||
|
["completed", workout.isCompleted ? "true" : "false"]
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add template reference if available
|
||||||
|
if (workout.templateId) {
|
||||||
|
tags.push(["template", `33402:${workout.templatePubkey || ''}:${workout.templateId}`, ""]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add exercise data
|
||||||
|
if (isLimited) {
|
||||||
|
// Limited data - just exercise names without metrics
|
||||||
|
workout.exercises.forEach(exercise => {
|
||||||
|
tags.push(["exercise", `33401:${exercise.exerciseId || exercise.id}`]);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Full data - include all metrics
|
||||||
|
workout.exercises.forEach(exercise =>
|
||||||
|
exercise.sets.forEach(set => {
|
||||||
|
const exerciseTag = [
|
||||||
|
"exercise",
|
||||||
|
`33401:${exercise.exerciseId || exercise.id}`,
|
||||||
|
""
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add set data
|
||||||
|
if (set.weight !== undefined) exerciseTag.push(set.weight.toString());
|
||||||
|
if (set.reps !== undefined) exerciseTag.push(set.reps.toString());
|
||||||
|
if (set.rpe !== undefined) exerciseTag.push(set.rpe.toString());
|
||||||
|
if (set.type) exerciseTag.push(set.type);
|
||||||
|
|
||||||
|
tags.push(exerciseTag);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any workout tags
|
||||||
|
workout.tags?.forEach(tag => {
|
||||||
|
tags.push(["t", tag]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 1301,
|
||||||
|
content: workout.notes || "",
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Nostr event to a workout
|
||||||
|
*/
|
||||||
|
static nostrEventToWorkout(event: NostrEvent): Workout {
|
||||||
|
if (event.kind !== 1301) {
|
||||||
|
throw new Error('Event is not a workout record (kind 1301)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find tag values
|
||||||
|
const findTagValue = (name: string): string | null => {
|
||||||
|
const tag = event.tags.find(t => t[0] === name);
|
||||||
|
return tag && tag.length > 1 ? tag[1] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse dates
|
||||||
|
const startTimeStr = findTagValue('start');
|
||||||
|
const endTimeStr = findTagValue('end');
|
||||||
|
|
||||||
|
const startTime = startTimeStr ? parseInt(startTimeStr) * 1000 : Date.now();
|
||||||
|
const endTime = endTimeStr && endTimeStr !== '' ? parseInt(endTimeStr) * 1000 : undefined;
|
||||||
|
|
||||||
|
// Create base workout object
|
||||||
|
const workout: Partial<Workout> = {
|
||||||
|
id: findTagValue('d') || generateId('nostr'),
|
||||||
|
title: findTagValue('title') || 'Untitled Workout',
|
||||||
|
type: (findTagValue('type') || 'strength') as TemplateType,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
isCompleted: findTagValue('completed') === 'true',
|
||||||
|
notes: event.content,
|
||||||
|
created_at: event.created_at * 1000,
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
nostrEventId: event.id,
|
||||||
|
availability: { source: ['nostr'] },
|
||||||
|
exercises: [],
|
||||||
|
shareStatus: 'public'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse template reference
|
||||||
|
const templateTag = event.tags.find(t => t[0] === 'template');
|
||||||
|
if (templateTag && templateTag.length > 1) {
|
||||||
|
const parts = templateTag[1].split(':');
|
||||||
|
if (parts.length === 3) {
|
||||||
|
workout.templateId = parts[2];
|
||||||
|
workout.templatePubkey = parts[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse exercises and sets
|
||||||
|
const exerciseTags = event.tags.filter(t => t[0] === 'exercise');
|
||||||
|
|
||||||
|
// Group exercise tags by exercise ID
|
||||||
|
const exerciseMap = new Map<string, string[][]>();
|
||||||
|
|
||||||
|
exerciseTags.forEach(tag => {
|
||||||
|
if (tag.length < 2) return;
|
||||||
|
|
||||||
|
const exerciseRef = tag[1];
|
||||||
|
let exerciseId = exerciseRef;
|
||||||
|
|
||||||
|
// Parse exercise ID from reference
|
||||||
|
if (exerciseRef.includes(':')) {
|
||||||
|
const parts = exerciseRef.split(':');
|
||||||
|
if (parts.length === 3) {
|
||||||
|
exerciseId = parts[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!exerciseMap.has(exerciseId)) {
|
||||||
|
exerciseMap.set(exerciseId, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
exerciseMap.get(exerciseId)?.push(tag);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert each unique exercise to a WorkoutExercise
|
||||||
|
workout.exercises = Array.from(exerciseMap.entries()).map(([exerciseId, tags]) => {
|
||||||
|
// Create a basic exercise
|
||||||
|
const exercise: Partial<WorkoutExercise> = {
|
||||||
|
id: generateId('nostr'),
|
||||||
|
exerciseId: exerciseId,
|
||||||
|
title: `Exercise ${exerciseId}`, // Placeholder - would be updated when loading full details
|
||||||
|
type: 'strength' as ExerciseType,
|
||||||
|
category: 'Other' as ExerciseCategory, // Default
|
||||||
|
created_at: workout.created_at || Date.now(),
|
||||||
|
lastUpdated: workout.lastUpdated,
|
||||||
|
isCompleted: true, // Default for past workouts
|
||||||
|
availability: { source: ['nostr'] },
|
||||||
|
tags: [], // Add empty tags array
|
||||||
|
sets: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse sets from tags
|
||||||
|
exercise.sets = tags.map(tag => {
|
||||||
|
const set: Partial<WorkoutSet> = {
|
||||||
|
id: generateId('nostr'),
|
||||||
|
type: 'normal',
|
||||||
|
isCompleted: true,
|
||||||
|
lastUpdated: workout.lastUpdated
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract set data if available (weight, reps, rpe, type)
|
||||||
|
if (tag.length > 3) set.weight = parseFloat(tag[3]) || undefined;
|
||||||
|
if (tag.length > 4) set.reps = parseInt(tag[4]) || undefined;
|
||||||
|
if (tag.length > 5) set.rpe = parseFloat(tag[5]) || undefined;
|
||||||
|
if (tag.length > 6) set.type = tag[6] as any;
|
||||||
|
|
||||||
|
return set as WorkoutSet;
|
||||||
|
});
|
||||||
|
|
||||||
|
// If no sets were found, add a default one
|
||||||
|
if (exercise.sets.length === 0) {
|
||||||
|
exercise.sets.push({
|
||||||
|
id: generateId('nostr'),
|
||||||
|
type: 'normal',
|
||||||
|
isCompleted: true,
|
||||||
|
lastUpdated: workout.lastUpdated
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return exercise as WorkoutExercise;
|
||||||
|
});
|
||||||
|
|
||||||
|
return workout as Workout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a template to a Nostr event (kind 33402)
|
||||||
|
*/
|
||||||
|
static templateToNostrEvent(template: WorkoutTemplate): NostrEvent {
|
||||||
|
// Prepare tags for Nostr event
|
||||||
|
const tags: string[][] = [
|
||||||
|
["d", template.id],
|
||||||
|
["title", template.title],
|
||||||
|
["type", template.type || "strength"]
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add optional tags
|
||||||
|
if (template.rounds) {
|
||||||
|
tags.push(["rounds", template.rounds.toString()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (template.duration) {
|
||||||
|
tags.push(["duration", template.duration.toString()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (template.interval) {
|
||||||
|
tags.push(["interval", template.interval.toString()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (template.restBetweenRounds) {
|
||||||
|
tags.push(["rest_between_rounds", template.restBetweenRounds.toString()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add exercise configurations
|
||||||
|
template.exercises.forEach(ex => {
|
||||||
|
const exerciseTag = [
|
||||||
|
"exercise",
|
||||||
|
`33401:${ex.exercise.id}`,
|
||||||
|
""
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add target parameters if available
|
||||||
|
if (ex.targetSets) exerciseTag.push(ex.targetSets.toString());
|
||||||
|
if (ex.targetReps) exerciseTag.push(ex.targetReps.toString());
|
||||||
|
if (ex.targetWeight) exerciseTag.push(ex.targetWeight.toString());
|
||||||
|
|
||||||
|
tags.push(exerciseTag);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add any template tags
|
||||||
|
template.tags?.forEach(tag => {
|
||||||
|
tags.push(["t", tag]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 33402,
|
||||||
|
content: template.description || "",
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Nostr event to a template
|
||||||
|
*/
|
||||||
|
static nostrEventToTemplate(event: NostrEvent): WorkoutTemplate {
|
||||||
|
if (event.kind !== 33402) {
|
||||||
|
throw new Error('Event is not a workout template (kind 33402)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find tag values
|
||||||
|
const findTagValue = (name: string): string | null => {
|
||||||
|
const tag = event.tags.find(t => t[0] === name);
|
||||||
|
return tag && tag.length > 1 ? tag[1] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create base template object
|
||||||
|
const template: Partial<WorkoutTemplate> = {
|
||||||
|
id: findTagValue('d') || generateId('nostr'),
|
||||||
|
title: findTagValue('title') || 'Untitled Template',
|
||||||
|
type: (findTagValue('type') || 'strength') as TemplateType,
|
||||||
|
description: event.content,
|
||||||
|
created_at: event.created_at * 1000,
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
nostrEventId: event.id,
|
||||||
|
availability: { source: ['nostr'] },
|
||||||
|
exercises: [],
|
||||||
|
isPublic: true,
|
||||||
|
version: 1,
|
||||||
|
tags: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse optional parameters
|
||||||
|
const roundsStr = findTagValue('rounds');
|
||||||
|
if (roundsStr) template.rounds = parseInt(roundsStr);
|
||||||
|
|
||||||
|
const durationStr = findTagValue('duration');
|
||||||
|
if (durationStr) template.duration = parseInt(durationStr);
|
||||||
|
|
||||||
|
const intervalStr = findTagValue('interval');
|
||||||
|
if (intervalStr) template.interval = parseInt(intervalStr);
|
||||||
|
|
||||||
|
const restStr = findTagValue('rest_between_rounds');
|
||||||
|
if (restStr) template.restBetweenRounds = parseInt(restStr);
|
||||||
|
|
||||||
|
// Parse exercises
|
||||||
|
const exerciseTags = event.tags.filter(t => t[0] === 'exercise');
|
||||||
|
|
||||||
|
template.exercises = exerciseTags.map(tag => {
|
||||||
|
if (tag.length < 2) {
|
||||||
|
// Skip invalid tags
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exerciseRef = tag[1];
|
||||||
|
let exerciseId = exerciseRef;
|
||||||
|
let exercisePubkey = '';
|
||||||
|
|
||||||
|
// Parse exercise ID from reference
|
||||||
|
if (exerciseRef.includes(':')) {
|
||||||
|
const parts = exerciseRef.split(':');
|
||||||
|
if (parts.length === 3) {
|
||||||
|
exerciseId = parts[2];
|
||||||
|
exercisePubkey = parts[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create exercise config
|
||||||
|
const config: Partial<TemplateExerciseConfig> = {
|
||||||
|
id: generateId('nostr'),
|
||||||
|
exercise: {
|
||||||
|
id: exerciseId,
|
||||||
|
title: `Exercise ${exerciseId}`, // Placeholder - would be updated when loading full details
|
||||||
|
type: 'strength',
|
||||||
|
category: 'Other' as ExerciseCategory,
|
||||||
|
tags: [], // Add empty tags array
|
||||||
|
availability: { source: ['nostr'] },
|
||||||
|
created_at: Date.now()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse target parameters if available
|
||||||
|
if (tag.length > 3) config.targetSets = parseInt(tag[3]) || undefined;
|
||||||
|
if (tag.length > 4) config.targetReps = parseInt(tag[4]) || undefined;
|
||||||
|
if (tag.length > 5) config.targetWeight = parseFloat(tag[5]) || undefined;
|
||||||
|
|
||||||
|
return config as TemplateExerciseConfig;
|
||||||
|
}).filter(Boolean) as TemplateExerciseConfig[]; // Filter out null values
|
||||||
|
|
||||||
|
return template as WorkoutTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to find a tag value in a Nostr event
|
||||||
|
*/
|
||||||
|
static findTagValue(tags: string[][], name: string): string | null {
|
||||||
|
const tag = tags.find(t => t[0] === name);
|
||||||
|
return tag && tag.length > 1 ? tag[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get all values for a specific tag name
|
||||||
|
*/
|
||||||
|
static getTagValues(tags: string[][], name: string): string[] {
|
||||||
|
return tags.filter(t => t[0] === name).map(t => t[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get template tag information
|
||||||
|
*/
|
||||||
|
static getTemplateTag(tags: string[][]): { reference: string, relay: string } | undefined {
|
||||||
|
const templateTag = tags.find(t => t[0] === 'template');
|
||||||
|
if (!templateTag || templateTag.length < 3) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
reference: templateTag[1],
|
||||||
|
relay: templateTag[2] || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
@ -1,50 +1,474 @@
|
|||||||
// lib/db/services/TemplateService.ts
|
// lib/db/services/TemplateService.ts
|
||||||
|
import { SQLiteDatabase, openDatabaseSync } from 'expo-sqlite';
|
||||||
|
import {
|
||||||
|
WorkoutTemplate,
|
||||||
|
TemplateExerciseConfig
|
||||||
|
} from '@/types/templates';
|
||||||
import { Workout } from '@/types/workout';
|
import { Workout } from '@/types/workout';
|
||||||
import { WorkoutTemplate } from '@/types/templates';
|
|
||||||
import { generateId } from '@/utils/ids';
|
import { generateId } from '@/utils/ids';
|
||||||
|
import { DbService } from '../db-service';
|
||||||
|
|
||||||
/**
|
|
||||||
* Service for managing workout templates
|
|
||||||
*/
|
|
||||||
export class TemplateService {
|
export class TemplateService {
|
||||||
|
private db: DbService;
|
||||||
|
|
||||||
|
constructor(database: SQLiteDatabase) {
|
||||||
|
this.db = new DbService(database);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates an existing template based on workout changes
|
* Get all templates
|
||||||
*/
|
*/
|
||||||
static async updateExistingTemplate(workout: Workout): Promise<boolean> {
|
async getAllTemplates(limit: number = 50, offset: number = 0): Promise<WorkoutTemplate[]> {
|
||||||
try {
|
try {
|
||||||
// This would require actual implementation with DB access
|
const templates = await this.db.getAllAsync<{
|
||||||
// For now, this is a placeholder
|
id: string;
|
||||||
console.log('Updating template from workout:', workout.id);
|
title: string;
|
||||||
// Future: Implement with your DB service
|
type: string;
|
||||||
return true;
|
description: string;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
nostr_event_id: string | null;
|
||||||
|
source: string;
|
||||||
|
parent_id: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT * FROM templates ORDER BY updated_at DESC LIMIT ? OFFSET ?`,
|
||||||
|
[limit, offset]
|
||||||
|
);
|
||||||
|
|
||||||
|
const result: WorkoutTemplate[] = [];
|
||||||
|
|
||||||
|
for (const template of templates) {
|
||||||
|
// Get exercises for this template
|
||||||
|
const exercises = await this.getTemplateExercises(template.id);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
id: template.id,
|
||||||
|
title: template.title,
|
||||||
|
type: template.type as any,
|
||||||
|
description: template.description,
|
||||||
|
category: 'Custom', // Add this line
|
||||||
|
created_at: template.created_at,
|
||||||
|
lastUpdated: template.updated_at,
|
||||||
|
nostrEventId: template.nostr_event_id || undefined,
|
||||||
|
parentId: template.parent_id || undefined,
|
||||||
|
exercises,
|
||||||
|
availability: {
|
||||||
|
source: [template.source as any]
|
||||||
|
},
|
||||||
|
isPublic: false,
|
||||||
|
version: 1,
|
||||||
|
tags: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating template:', error);
|
console.error('Error getting templates:', error);
|
||||||
return false;
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves a workout as a new template
|
* Get a template by ID
|
||||||
*/
|
*/
|
||||||
static async saveAsNewTemplate(workout: Workout, name: string): Promise<string | null> {
|
async getTemplate(id: string): Promise<WorkoutTemplate | null> {
|
||||||
try {
|
try {
|
||||||
// This would require actual implementation with DB access
|
const template = await this.db.getFirstAsync<{
|
||||||
// For now, this is a placeholder
|
id: string;
|
||||||
console.log('Creating new template from workout:', workout.id, 'with name:', name);
|
title: string;
|
||||||
// Future: Implement with your DB service
|
type: string;
|
||||||
return generateId();
|
description: string;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
nostr_event_id: string | null;
|
||||||
|
source: string;
|
||||||
|
parent_id: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT * FROM templates WHERE id = ?`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!template) return null;
|
||||||
|
|
||||||
|
// Get exercises for this template
|
||||||
|
const exercises = await this.getTemplateExercises(id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: template.id,
|
||||||
|
title: template.title,
|
||||||
|
type: template.type as any,
|
||||||
|
description: template.description,
|
||||||
|
category: 'Custom',
|
||||||
|
created_at: template.created_at,
|
||||||
|
lastUpdated: template.updated_at,
|
||||||
|
nostrEventId: template.nostr_event_id || undefined,
|
||||||
|
parentId: template.parent_id || undefined,
|
||||||
|
exercises,
|
||||||
|
availability: {
|
||||||
|
source: [template.source as any]
|
||||||
|
},
|
||||||
|
isPublic: false,
|
||||||
|
version: 1,
|
||||||
|
tags: []
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating template:', error);
|
console.error('Error getting template:', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detects if a workout has changes compared to its original template
|
* Create a new template
|
||||||
*/
|
*/
|
||||||
|
async createTemplate(template: Omit<WorkoutTemplate, 'id'>): Promise<string> {
|
||||||
|
try {
|
||||||
|
const id = generateId();
|
||||||
|
const timestamp = Date.now();
|
||||||
|
|
||||||
|
await this.db.withTransactionAsync(async () => {
|
||||||
|
// Insert template
|
||||||
|
await this.db.runAsync(
|
||||||
|
`INSERT INTO templates (
|
||||||
|
id, title, type, description, created_at, updated_at,
|
||||||
|
nostr_event_id, source, parent_id
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
template.title,
|
||||||
|
template.type || 'strength',
|
||||||
|
template.description || null,
|
||||||
|
timestamp,
|
||||||
|
timestamp,
|
||||||
|
template.nostrEventId || null,
|
||||||
|
template.availability?.source[0] || 'local',
|
||||||
|
template.parentId || null
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert exercises
|
||||||
|
if (template.exercises?.length) {
|
||||||
|
for (let i = 0; i < template.exercises.length; i++) {
|
||||||
|
const exercise = template.exercises[i];
|
||||||
|
|
||||||
|
await this.db.runAsync(
|
||||||
|
`INSERT INTO template_exercises (
|
||||||
|
id, template_id, exercise_id, display_order,
|
||||||
|
target_sets, target_reps, target_weight, notes,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
exercise.id || generateId(),
|
||||||
|
id,
|
||||||
|
exercise.exercise.id,
|
||||||
|
i,
|
||||||
|
exercise.targetSets || null,
|
||||||
|
exercise.targetReps || null,
|
||||||
|
exercise.targetWeight || null,
|
||||||
|
exercise.notes || null,
|
||||||
|
timestamp,
|
||||||
|
timestamp
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return id;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating template:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing template
|
||||||
|
*/
|
||||||
|
async updateTemplate(id: string, updates: Partial<WorkoutTemplate>): Promise<void> {
|
||||||
|
try {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
|
||||||
|
await this.db.withTransactionAsync(async () => {
|
||||||
|
// Update template record
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const updateValues: any[] = [];
|
||||||
|
|
||||||
|
if (updates.title !== undefined) {
|
||||||
|
updateFields.push('title = ?');
|
||||||
|
updateValues.push(updates.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.type !== undefined) {
|
||||||
|
updateFields.push('type = ?');
|
||||||
|
updateValues.push(updates.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.description !== undefined) {
|
||||||
|
updateFields.push('description = ?');
|
||||||
|
updateValues.push(updates.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.nostrEventId !== undefined) {
|
||||||
|
updateFields.push('nostr_event_id = ?');
|
||||||
|
updateValues.push(updates.nostrEventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always update the timestamp
|
||||||
|
updateFields.push('updated_at = ?');
|
||||||
|
updateValues.push(timestamp);
|
||||||
|
|
||||||
|
// Add the ID for the WHERE clause
|
||||||
|
updateValues.push(id);
|
||||||
|
|
||||||
|
if (updateFields.length > 0) {
|
||||||
|
await this.db.runAsync(
|
||||||
|
`UPDATE templates SET ${updateFields.join(', ')} WHERE id = ?`,
|
||||||
|
updateValues
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update exercises if provided
|
||||||
|
if (updates.exercises) {
|
||||||
|
// Delete existing exercises
|
||||||
|
await this.db.runAsync(
|
||||||
|
'DELETE FROM template_exercises WHERE template_id = ?',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert new exercises
|
||||||
|
if (updates.exercises.length > 0) {
|
||||||
|
for (let i = 0; i < updates.exercises.length; i++) {
|
||||||
|
const exercise = updates.exercises[i];
|
||||||
|
|
||||||
|
await this.db.runAsync(
|
||||||
|
`INSERT INTO template_exercises (
|
||||||
|
id, template_id, exercise_id, display_order,
|
||||||
|
target_sets, target_reps, target_weight, notes,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
exercise.id || generateId(),
|
||||||
|
id,
|
||||||
|
exercise.exercise.id,
|
||||||
|
i,
|
||||||
|
exercise.targetSets || null,
|
||||||
|
exercise.targetReps || null,
|
||||||
|
exercise.targetWeight || null,
|
||||||
|
exercise.notes || null,
|
||||||
|
timestamp,
|
||||||
|
timestamp
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating template:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a template
|
||||||
|
*/
|
||||||
|
async deleteTemplate(id: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.db.withTransactionAsync(async () => {
|
||||||
|
// Delete template exercises
|
||||||
|
await this.db.runAsync(
|
||||||
|
'DELETE FROM template_exercises WHERE template_id = ?',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete template
|
||||||
|
await this.db.runAsync(
|
||||||
|
'DELETE FROM templates WHERE id = ?',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting template:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update template with Nostr event ID
|
||||||
|
*/
|
||||||
|
async updateNostrEventId(templateId: string, eventId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.db.runAsync(
|
||||||
|
`UPDATE templates SET nostr_event_id = ? WHERE id = ?`,
|
||||||
|
[eventId, templateId]
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating template nostr event ID:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods
|
||||||
|
private async getTemplateExercises(templateId: string): Promise<TemplateExerciseConfig[]> {
|
||||||
|
try {
|
||||||
|
const exercises = await this.db.getAllAsync<{
|
||||||
|
id: string;
|
||||||
|
exercise_id: string;
|
||||||
|
target_sets: number | null;
|
||||||
|
target_reps: number | null;
|
||||||
|
target_weight: number | null;
|
||||||
|
notes: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
te.id, te.exercise_id, te.target_sets, te.target_reps,
|
||||||
|
te.target_weight, te.notes
|
||||||
|
FROM template_exercises te
|
||||||
|
WHERE te.template_id = ?
|
||||||
|
ORDER BY te.display_order`,
|
||||||
|
[templateId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const result: TemplateExerciseConfig[] = [];
|
||||||
|
|
||||||
|
for (const ex of exercises) {
|
||||||
|
// Get exercise details
|
||||||
|
const exercise = await this.db.getFirstAsync<{
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
category: string;
|
||||||
|
equipment: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT title, type, category, equipment FROM exercises WHERE id = ?`,
|
||||||
|
[ex.exercise_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
id: ex.id,
|
||||||
|
exercise: {
|
||||||
|
id: ex.exercise_id,
|
||||||
|
title: exercise?.title || 'Unknown Exercise',
|
||||||
|
type: exercise?.type as any || 'strength',
|
||||||
|
category: exercise?.category as any || 'Other',
|
||||||
|
equipment: exercise?.equipment as any || undefined,
|
||||||
|
tags: [], // Required property
|
||||||
|
availability: { source: ['local'] }, // Required property
|
||||||
|
created_at: Date.now() // Required property
|
||||||
|
},
|
||||||
|
targetSets: ex.target_sets || undefined,
|
||||||
|
targetReps: ex.target_reps || undefined,
|
||||||
|
targetWeight: ex.target_weight || undefined,
|
||||||
|
notes: ex.notes || undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting template exercises:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static helper methods used by the workout store
|
||||||
|
static async updateExistingTemplate(workout: Workout): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Make sure workout has a templateId
|
||||||
|
if (!workout.templateId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get database access
|
||||||
|
const db = openDatabaseSync('powr.db');
|
||||||
|
const service = new TemplateService(db);
|
||||||
|
|
||||||
|
// Get the existing template
|
||||||
|
const template = await service.getTemplate(workout.templateId);
|
||||||
|
if (!template) {
|
||||||
|
console.log('Template not found for updating:', workout.templateId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert workout exercises to template format
|
||||||
|
const exercises: TemplateExerciseConfig[] = workout.exercises.map(ex => ({
|
||||||
|
id: generateId(),
|
||||||
|
exercise: {
|
||||||
|
id: ex.id,
|
||||||
|
title: ex.title,
|
||||||
|
type: ex.type,
|
||||||
|
category: ex.category,
|
||||||
|
equipment: ex.equipment,
|
||||||
|
tags: ex.tags || [], // Required property
|
||||||
|
availability: { source: ['local'] }, // Required property
|
||||||
|
created_at: ex.created_at // Required property
|
||||||
|
},
|
||||||
|
targetSets: ex.sets.length,
|
||||||
|
targetReps: ex.sets[0]?.reps || 0,
|
||||||
|
targetWeight: ex.sets[0]?.weight || 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Update the template
|
||||||
|
await service.updateTemplate(template.id, {
|
||||||
|
lastUpdated: Date.now(),
|
||||||
|
exercises
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Template updated successfully:', template.id);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating template from workout:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async saveAsNewTemplate(workout: Workout, name: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
// Get database access
|
||||||
|
const db = openDatabaseSync('powr.db');
|
||||||
|
const service = new TemplateService(db);
|
||||||
|
|
||||||
|
// Convert workout exercises to template format
|
||||||
|
const exercises: TemplateExerciseConfig[] = workout.exercises.map(ex => ({
|
||||||
|
id: generateId(),
|
||||||
|
exercise: {
|
||||||
|
id: ex.id,
|
||||||
|
title: ex.title,
|
||||||
|
type: ex.type,
|
||||||
|
category: ex.category,
|
||||||
|
equipment: ex.equipment,
|
||||||
|
tags: ex.tags || [], // Required property
|
||||||
|
availability: { source: ['local'] }, // Required property
|
||||||
|
created_at: ex.created_at // Required property
|
||||||
|
},
|
||||||
|
targetSets: ex.sets.length,
|
||||||
|
targetReps: ex.sets[0]?.reps || 0,
|
||||||
|
targetWeight: ex.sets[0]?.weight || 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Create the new template
|
||||||
|
const templateId = await service.createTemplate({
|
||||||
|
title: name,
|
||||||
|
type: workout.type,
|
||||||
|
description: workout.notes,
|
||||||
|
category: 'Custom',
|
||||||
|
exercises,
|
||||||
|
created_at: Date.now(),
|
||||||
|
parentId: workout.templateId, // Link to original template if this was derived
|
||||||
|
availability: {
|
||||||
|
source: ['local']
|
||||||
|
},
|
||||||
|
isPublic: false,
|
||||||
|
version: 1,
|
||||||
|
tags: []
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('New template created from workout:', templateId);
|
||||||
|
return templateId;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating template from workout:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static hasTemplateChanges(workout: Workout): boolean {
|
static hasTemplateChanges(workout: Workout): boolean {
|
||||||
// This would require comparing with the original template
|
// Simple implementation - in a real app, you'd compare with the original template
|
||||||
// For now, assume changes if there's a templateId
|
return workout.templateId !== undefined;
|
||||||
return !!workout.templateId;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -3,6 +3,7 @@ import { SQLiteDatabase } from 'expo-sqlite';
|
|||||||
import { Workout } from '@/types/workout';
|
import { Workout } from '@/types/workout';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { DbService } from '../db-service';
|
import { DbService } from '../db-service';
|
||||||
|
import { WorkoutExercise } from '@/types/exercise'; // Add this import
|
||||||
|
|
||||||
export class WorkoutHistoryService {
|
export class WorkoutHistoryService {
|
||||||
private db: DbService;
|
private db: DbService;
|
||||||
@ -21,21 +22,47 @@ export class WorkoutHistoryService {
|
|||||||
title: string;
|
title: string;
|
||||||
type: string;
|
type: string;
|
||||||
start_time: number;
|
start_time: number;
|
||||||
end_time: number;
|
end_time: number | null;
|
||||||
is_completed: number;
|
is_completed: number;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
last_updated: number;
|
updated_at: number;
|
||||||
template_id: string | null;
|
template_id: string | null;
|
||||||
total_volume: number | null;
|
total_volume: number | null;
|
||||||
total_reps: number | null;
|
total_reps: number | null;
|
||||||
source: string;
|
source: string;
|
||||||
|
notes: string | null;
|
||||||
}>(
|
}>(
|
||||||
`SELECT * FROM workouts
|
`SELECT * FROM workouts
|
||||||
ORDER BY start_time DESC`
|
ORDER BY start_time DESC`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Transform database records to Workout objects
|
// Transform database records to Workout objects
|
||||||
return workouts.map(row => this.mapRowToWorkout(row));
|
const result: Workout[] = [];
|
||||||
|
|
||||||
|
for (const workout of workouts) {
|
||||||
|
const exercises = await this.getWorkoutExercises(workout.id);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
id: workout.id,
|
||||||
|
title: workout.title,
|
||||||
|
type: workout.type as any,
|
||||||
|
startTime: workout.start_time,
|
||||||
|
endTime: workout.end_time || undefined,
|
||||||
|
isCompleted: Boolean(workout.is_completed),
|
||||||
|
created_at: workout.created_at,
|
||||||
|
lastUpdated: workout.updated_at,
|
||||||
|
templateId: workout.template_id || undefined,
|
||||||
|
totalVolume: workout.total_volume || undefined,
|
||||||
|
totalReps: workout.total_reps || undefined,
|
||||||
|
notes: workout.notes || undefined,
|
||||||
|
exercises,
|
||||||
|
availability: {
|
||||||
|
source: [workout.source as any]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting workouts:', error);
|
console.error('Error getting workouts:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@ -55,14 +82,15 @@ export class WorkoutHistoryService {
|
|||||||
title: string;
|
title: string;
|
||||||
type: string;
|
type: string;
|
||||||
start_time: number;
|
start_time: number;
|
||||||
end_time: number;
|
end_time: number | null;
|
||||||
is_completed: number;
|
is_completed: number;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
last_updated: number;
|
updated_at: number;
|
||||||
template_id: string | null;
|
template_id: string | null;
|
||||||
total_volume: number | null;
|
total_volume: number | null;
|
||||||
total_reps: number | null;
|
total_reps: number | null;
|
||||||
source: string;
|
source: string;
|
||||||
|
notes: string | null;
|
||||||
}>(
|
}>(
|
||||||
`SELECT * FROM workouts
|
`SELECT * FROM workouts
|
||||||
WHERE start_time >= ? AND start_time <= ?
|
WHERE start_time >= ? AND start_time <= ?
|
||||||
@ -70,7 +98,32 @@ export class WorkoutHistoryService {
|
|||||||
[startOfDay, endOfDay]
|
[startOfDay, endOfDay]
|
||||||
);
|
);
|
||||||
|
|
||||||
return workouts.map(row => this.mapRowToWorkout(row));
|
const result: Workout[] = [];
|
||||||
|
|
||||||
|
for (const workout of workouts) {
|
||||||
|
const exercises = await this.getWorkoutExercises(workout.id);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
id: workout.id,
|
||||||
|
title: workout.title,
|
||||||
|
type: workout.type as any,
|
||||||
|
startTime: workout.start_time,
|
||||||
|
endTime: workout.end_time || undefined,
|
||||||
|
isCompleted: Boolean(workout.is_completed),
|
||||||
|
created_at: workout.created_at,
|
||||||
|
lastUpdated: workout.updated_at,
|
||||||
|
templateId: workout.template_id || undefined,
|
||||||
|
totalVolume: workout.total_volume || undefined,
|
||||||
|
totalReps: workout.total_reps || undefined,
|
||||||
|
notes: workout.notes || undefined,
|
||||||
|
exercises,
|
||||||
|
availability: {
|
||||||
|
source: [workout.source as any]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting workouts by date:', error);
|
console.error('Error getting workouts by date:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@ -88,7 +141,8 @@ export class WorkoutHistoryService {
|
|||||||
const result = await this.db.getAllAsync<{
|
const result = await this.db.getAllAsync<{
|
||||||
start_time: number;
|
start_time: number;
|
||||||
}>(
|
}>(
|
||||||
`SELECT DISTINCT start_time FROM workouts
|
`SELECT DISTINCT date(start_time/1000, 'unixepoch', 'localtime') * 1000 as start_time
|
||||||
|
FROM workouts
|
||||||
WHERE start_time >= ? AND start_time <= ?`,
|
WHERE start_time >= ? AND start_time <= ?`,
|
||||||
[startOfMonth, endOfMonth]
|
[startOfMonth, endOfMonth]
|
||||||
);
|
);
|
||||||
@ -110,14 +164,15 @@ export class WorkoutHistoryService {
|
|||||||
title: string;
|
title: string;
|
||||||
type: string;
|
type: string;
|
||||||
start_time: number;
|
start_time: number;
|
||||||
end_time: number;
|
end_time: number | null;
|
||||||
is_completed: number;
|
is_completed: number;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
last_updated: number;
|
updated_at: number;
|
||||||
template_id: string | null;
|
template_id: string | null;
|
||||||
total_volume: number | null;
|
total_volume: number | null;
|
||||||
total_reps: number | null;
|
total_reps: number | null;
|
||||||
source: string;
|
source: string;
|
||||||
|
notes: string | null;
|
||||||
}>(
|
}>(
|
||||||
`SELECT * FROM workouts WHERE id = ?`,
|
`SELECT * FROM workouts WHERE id = ?`,
|
||||||
[workoutId]
|
[workoutId]
|
||||||
@ -126,18 +181,26 @@ export class WorkoutHistoryService {
|
|||||||
if (!workout) return null;
|
if (!workout) return null;
|
||||||
|
|
||||||
// Get exercises for this workout
|
// Get exercises for this workout
|
||||||
// This is just a skeleton - you'll need to implement the actual query
|
const exercises = await this.getWorkoutExercises(workoutId);
|
||||||
// based on your database schema
|
|
||||||
const exercises = await this.db.getAllAsync(
|
|
||||||
`SELECT * FROM workout_exercises WHERE workout_id = ?`,
|
|
||||||
[workoutId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const workoutObj = this.mapRowToWorkout(workout);
|
return {
|
||||||
// You would set the exercises property here based on your schema
|
id: workout.id,
|
||||||
// workoutObj.exercises = exercises.map(...);
|
title: workout.title,
|
||||||
|
type: workout.type as any,
|
||||||
return workoutObj;
|
startTime: workout.start_time,
|
||||||
|
endTime: workout.end_time || undefined,
|
||||||
|
isCompleted: Boolean(workout.is_completed),
|
||||||
|
created_at: workout.created_at,
|
||||||
|
lastUpdated: workout.updated_at,
|
||||||
|
templateId: workout.template_id || undefined,
|
||||||
|
totalVolume: workout.total_volume || undefined,
|
||||||
|
totalReps: workout.total_reps || undefined,
|
||||||
|
notes: workout.notes || undefined,
|
||||||
|
exercises,
|
||||||
|
availability: {
|
||||||
|
source: [workout.source as any]
|
||||||
|
}
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting workout details:', error);
|
console.error('Error getting workout details:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@ -160,39 +223,96 @@ export class WorkoutHistoryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Helper method to load workout exercises and sets
|
||||||
* Helper method to map a database row to a Workout object
|
private async getWorkoutExercises(workoutId: string): Promise<WorkoutExercise[]> {
|
||||||
*/
|
try {
|
||||||
private mapRowToWorkout(row: {
|
const exercises = await this.db.getAllAsync<{
|
||||||
id: string;
|
id: string;
|
||||||
|
exercise_id: string;
|
||||||
|
display_order: number;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
}>(
|
||||||
|
`SELECT we.* FROM workout_exercises we
|
||||||
|
WHERE we.workout_id = ?
|
||||||
|
ORDER BY we.display_order`,
|
||||||
|
[workoutId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const result: WorkoutExercise[] = [];
|
||||||
|
|
||||||
|
for (const exercise of exercises) {
|
||||||
|
// Get the base exercise info
|
||||||
|
const baseExercise = await this.db.getFirstAsync<{
|
||||||
title: string;
|
title: string;
|
||||||
type: string;
|
type: string;
|
||||||
start_time: number;
|
category: string;
|
||||||
end_time: number;
|
equipment: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT title, type, category, equipment FROM exercises WHERE id = ?`,
|
||||||
|
[exercise.exercise_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the tags for this exercise
|
||||||
|
const tags = await this.db.getAllAsync<{ tag: string }>(
|
||||||
|
`SELECT tag FROM exercise_tags WHERE exercise_id = ?`,
|
||||||
|
[exercise.exercise_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the sets for this exercise
|
||||||
|
const sets = await this.db.getAllAsync<{
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
weight: number | null;
|
||||||
|
reps: number | null;
|
||||||
|
rpe: number | null;
|
||||||
|
duration: number | null;
|
||||||
is_completed: number;
|
is_completed: number;
|
||||||
|
completed_at: number | null;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
last_updated?: number;
|
updated_at: number;
|
||||||
template_id?: string | null;
|
}>(
|
||||||
total_volume?: number | null;
|
`SELECT * FROM workout_sets
|
||||||
total_reps?: number | null;
|
WHERE workout_exercise_id = ?
|
||||||
source: string;
|
ORDER BY id`,
|
||||||
}): Workout {
|
[exercise.id]
|
||||||
return {
|
);
|
||||||
id: row.id,
|
|
||||||
title: row.title,
|
// Map sets to the correct format
|
||||||
type: row.type as any, // Cast to TemplateType
|
const mappedSets = sets.map(set => ({
|
||||||
startTime: row.start_time,
|
id: set.id,
|
||||||
endTime: row.end_time,
|
type: set.type as any,
|
||||||
isCompleted: row.is_completed === 1,
|
weight: set.weight || undefined,
|
||||||
created_at: row.created_at,
|
reps: set.reps || undefined,
|
||||||
lastUpdated: row.last_updated,
|
rpe: set.rpe || undefined,
|
||||||
templateId: row.template_id || undefined,
|
duration: set.duration || undefined,
|
||||||
totalVolume: row.total_volume || undefined,
|
isCompleted: Boolean(set.is_completed),
|
||||||
totalReps: row.total_reps || undefined,
|
completedAt: set.completed_at || undefined,
|
||||||
availability: {
|
lastUpdated: set.updated_at
|
||||||
source: [row.source as any] // Cast to StorageSource
|
}));
|
||||||
},
|
|
||||||
exercises: [] // Exercises would be loaded separately
|
result.push({
|
||||||
};
|
id: exercise.id,
|
||||||
|
exerciseId: exercise.exercise_id,
|
||||||
|
title: baseExercise?.title || 'Unknown Exercise',
|
||||||
|
type: baseExercise?.type as any || 'strength',
|
||||||
|
category: baseExercise?.category as any || 'Other',
|
||||||
|
equipment: baseExercise?.equipment as any,
|
||||||
|
notes: exercise.notes || undefined,
|
||||||
|
tags: tags.map(t => t.tag), // Add the tags array here
|
||||||
|
sets: mappedSets,
|
||||||
|
created_at: exercise.created_at,
|
||||||
|
lastUpdated: exercise.updated_at,
|
||||||
|
isCompleted: mappedSets.every(set => set.isCompleted),
|
||||||
|
availability: { source: ['local'] }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting workout exercises:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
516
lib/db/services/WorkoutService.ts
Normal file
516
lib/db/services/WorkoutService.ts
Normal file
@ -0,0 +1,516 @@
|
|||||||
|
// lib/db/services/WorkoutService.ts
|
||||||
|
import { SQLiteDatabase } from 'expo-sqlite';
|
||||||
|
import { Workout, WorkoutExercise, WorkoutSet, WorkoutSummary } from '@/types/workout';
|
||||||
|
import { generateId } from '@/utils/ids';
|
||||||
|
import { DbService } from '../db-service';
|
||||||
|
|
||||||
|
export class WorkoutService {
|
||||||
|
private db: DbService;
|
||||||
|
|
||||||
|
constructor(database: SQLiteDatabase) {
|
||||||
|
this.db = new DbService(database);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a workout to the database
|
||||||
|
*/
|
||||||
|
async saveWorkout(workout: Workout): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.db.withTransactionAsync(async () => {
|
||||||
|
// Check if workout exists (for update vs insert)
|
||||||
|
const existingWorkout = await this.db.getFirstAsync<{ id: string }>(
|
||||||
|
'SELECT id FROM workouts WHERE id = ?',
|
||||||
|
[workout.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const timestamp = Date.now();
|
||||||
|
|
||||||
|
if (existingWorkout) {
|
||||||
|
// Update existing workout
|
||||||
|
await this.db.runAsync(
|
||||||
|
`UPDATE workouts SET
|
||||||
|
title = ?, type = ?, start_time = ?, end_time = ?,
|
||||||
|
is_completed = ?, updated_at = ?, template_id = ?,
|
||||||
|
share_status = ?, notes = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[
|
||||||
|
workout.title,
|
||||||
|
workout.type,
|
||||||
|
workout.startTime,
|
||||||
|
workout.endTime || null,
|
||||||
|
workout.isCompleted ? 1 : 0,
|
||||||
|
timestamp,
|
||||||
|
workout.templateId || null,
|
||||||
|
workout.shareStatus || 'local',
|
||||||
|
workout.notes || null,
|
||||||
|
workout.id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete existing exercises and sets to recreate them
|
||||||
|
await this.deleteWorkoutExercises(workout.id);
|
||||||
|
} else {
|
||||||
|
// Insert new workout
|
||||||
|
await this.db.runAsync(
|
||||||
|
`INSERT INTO workouts (
|
||||||
|
id, title, type, start_time, end_time, is_completed,
|
||||||
|
created_at, updated_at, template_id, source, share_status, notes
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
workout.id,
|
||||||
|
workout.title,
|
||||||
|
workout.type,
|
||||||
|
workout.startTime,
|
||||||
|
workout.endTime || null,
|
||||||
|
workout.isCompleted ? 1 : 0,
|
||||||
|
timestamp,
|
||||||
|
timestamp,
|
||||||
|
workout.templateId || null,
|
||||||
|
workout.availability?.source[0] || 'local',
|
||||||
|
workout.shareStatus || 'local',
|
||||||
|
workout.notes || null
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save exercises and sets
|
||||||
|
if (workout.exercises?.length) {
|
||||||
|
await this.saveWorkoutExercises(workout.id, workout.exercises);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Workout saved successfully:', workout.id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving workout:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a workout by ID
|
||||||
|
*/
|
||||||
|
async getWorkout(id: string): Promise<Workout | null> {
|
||||||
|
try {
|
||||||
|
const workout = await this.db.getFirstAsync<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
start_time: number;
|
||||||
|
end_time: number | null;
|
||||||
|
is_completed: number;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
template_id: string | null;
|
||||||
|
source: string;
|
||||||
|
share_status: string;
|
||||||
|
notes: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT * FROM workouts WHERE id = ?`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!workout) return null;
|
||||||
|
|
||||||
|
// Get exercises and sets
|
||||||
|
const exercises = await this.getWorkoutExercises(id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: workout.id,
|
||||||
|
title: workout.title,
|
||||||
|
type: workout.type as any,
|
||||||
|
startTime: workout.start_time,
|
||||||
|
endTime: workout.end_time || undefined,
|
||||||
|
isCompleted: Boolean(workout.is_completed),
|
||||||
|
created_at: workout.created_at,
|
||||||
|
lastUpdated: workout.updated_at,
|
||||||
|
templateId: workout.template_id || undefined,
|
||||||
|
shareStatus: workout.share_status as any,
|
||||||
|
notes: workout.notes || undefined,
|
||||||
|
exercises,
|
||||||
|
availability: {
|
||||||
|
source: [workout.source as any]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting workout:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all workouts
|
||||||
|
*/
|
||||||
|
async getAllWorkouts(limit: number = 50, offset: number = 0): Promise<Workout[]> {
|
||||||
|
try {
|
||||||
|
const workouts = await this.db.getAllAsync<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
start_time: number;
|
||||||
|
end_time: number | null;
|
||||||
|
is_completed: number;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
template_id: string | null;
|
||||||
|
source: string;
|
||||||
|
share_status: string;
|
||||||
|
notes: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT * FROM workouts ORDER BY start_time DESC LIMIT ? OFFSET ?`,
|
||||||
|
[limit, offset]
|
||||||
|
);
|
||||||
|
|
||||||
|
const result: Workout[] = [];
|
||||||
|
|
||||||
|
for (const workout of workouts) {
|
||||||
|
const exercises = await this.getWorkoutExercises(workout.id);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
id: workout.id,
|
||||||
|
title: workout.title,
|
||||||
|
type: workout.type as any,
|
||||||
|
startTime: workout.start_time,
|
||||||
|
endTime: workout.end_time || undefined,
|
||||||
|
isCompleted: Boolean(workout.is_completed),
|
||||||
|
created_at: workout.created_at,
|
||||||
|
lastUpdated: workout.updated_at,
|
||||||
|
templateId: workout.template_id || undefined,
|
||||||
|
shareStatus: workout.share_status as any,
|
||||||
|
notes: workout.notes || undefined,
|
||||||
|
exercises,
|
||||||
|
availability: {
|
||||||
|
source: [workout.source as any]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting all workouts:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get workouts for a specific date range
|
||||||
|
*/
|
||||||
|
async getWorkoutsByDateRange(startDate: number, endDate: number): Promise<Workout[]> {
|
||||||
|
try {
|
||||||
|
const workouts = await this.db.getAllAsync<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
start_time: number;
|
||||||
|
end_time: number | null;
|
||||||
|
is_completed: number;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
template_id: string | null;
|
||||||
|
source: string;
|
||||||
|
share_status: string;
|
||||||
|
notes: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT * FROM workouts
|
||||||
|
WHERE start_time >= ? AND start_time <= ?
|
||||||
|
ORDER BY start_time DESC`,
|
||||||
|
[startDate, endDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const result: Workout[] = [];
|
||||||
|
|
||||||
|
for (const workout of workouts) {
|
||||||
|
const exercises = await this.getWorkoutExercises(workout.id);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
id: workout.id,
|
||||||
|
title: workout.title,
|
||||||
|
type: workout.type as any,
|
||||||
|
startTime: workout.start_time,
|
||||||
|
endTime: workout.end_time || undefined,
|
||||||
|
isCompleted: Boolean(workout.is_completed),
|
||||||
|
created_at: workout.created_at,
|
||||||
|
lastUpdated: workout.updated_at,
|
||||||
|
templateId: workout.template_id || undefined,
|
||||||
|
shareStatus: workout.share_status as any,
|
||||||
|
notes: workout.notes || undefined,
|
||||||
|
exercises,
|
||||||
|
availability: {
|
||||||
|
source: [workout.source as any]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting workouts by date range:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a workout
|
||||||
|
*/
|
||||||
|
async deleteWorkout(id: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.db.withTransactionAsync(async () => {
|
||||||
|
// Delete exercises and sets first due to foreign key constraints
|
||||||
|
await this.deleteWorkoutExercises(id);
|
||||||
|
|
||||||
|
// Delete the workout
|
||||||
|
await this.db.runAsync(
|
||||||
|
'DELETE FROM workouts WHERE id = ?',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting workout:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save workout summary metrics
|
||||||
|
*/
|
||||||
|
async saveWorkoutSummary(workoutId: string, summary: WorkoutSummary): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.db.runAsync(
|
||||||
|
`UPDATE workouts SET
|
||||||
|
total_volume = ?,
|
||||||
|
total_reps = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[
|
||||||
|
summary.totalVolume || 0,
|
||||||
|
summary.totalReps || 0,
|
||||||
|
workoutId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving workout summary:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get dates that have workouts
|
||||||
|
*/
|
||||||
|
async getWorkoutDates(startDate: number, endDate: number): Promise<number[]> {
|
||||||
|
try {
|
||||||
|
const dates = await this.db.getAllAsync<{ start_time: number }>(
|
||||||
|
`SELECT DISTINCT date(start_time/1000, 'unixepoch', 'localtime') * 1000 as start_time
|
||||||
|
FROM workouts
|
||||||
|
WHERE start_time >= ? AND start_time <= ?
|
||||||
|
ORDER BY start_time`,
|
||||||
|
[startDate, endDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
return dates.map(d => d.start_time);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting workout dates:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Nostr event ID for a workout
|
||||||
|
*/
|
||||||
|
async updateNostrEventId(workoutId: string, eventId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.db.runAsync(
|
||||||
|
`UPDATE workouts SET nostr_event_id = ? WHERE id = ?`,
|
||||||
|
[eventId, workoutId]
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating Nostr event ID:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get exercises for a workout
|
||||||
|
*/
|
||||||
|
private async getWorkoutExercises(workoutId: string): Promise<WorkoutExercise[]> {
|
||||||
|
try {
|
||||||
|
const exercises = await this.db.getAllAsync<{
|
||||||
|
id: string;
|
||||||
|
exercise_id: string;
|
||||||
|
display_order: number;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
}>(
|
||||||
|
`SELECT we.* FROM workout_exercises we
|
||||||
|
WHERE we.workout_id = ?
|
||||||
|
ORDER BY we.display_order`,
|
||||||
|
[workoutId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const result: WorkoutExercise[] = [];
|
||||||
|
|
||||||
|
for (const exercise of exercises) {
|
||||||
|
// Get the base exercise info
|
||||||
|
const baseExercise = await this.db.getFirstAsync<{
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
category: string;
|
||||||
|
equipment: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT title, type, category, equipment FROM exercises WHERE id = ?`,
|
||||||
|
[exercise.exercise_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get the sets for this exercise
|
||||||
|
const sets = await this.getWorkoutSets(exercise.id);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
id: exercise.id,
|
||||||
|
exerciseId: exercise.exercise_id,
|
||||||
|
title: baseExercise?.title || 'Unknown Exercise',
|
||||||
|
type: baseExercise?.type as any || 'strength',
|
||||||
|
category: baseExercise?.category as any || 'Other',
|
||||||
|
equipment: baseExercise?.equipment as any,
|
||||||
|
notes: exercise.notes || undefined,
|
||||||
|
sets,
|
||||||
|
created_at: exercise.created_at,
|
||||||
|
lastUpdated: exercise.updated_at,
|
||||||
|
isCompleted: sets.every(set => set.isCompleted),
|
||||||
|
availability: { source: ['local'] },
|
||||||
|
tags: [] // Required property
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting workout exercises:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sets for a workout exercise
|
||||||
|
*/
|
||||||
|
private async getWorkoutSets(workoutExerciseId: string): Promise<WorkoutSet[]> {
|
||||||
|
try {
|
||||||
|
const sets = await this.db.getAllAsync<{
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
weight: number | null;
|
||||||
|
reps: number | null;
|
||||||
|
rpe: number | null;
|
||||||
|
duration: number | null;
|
||||||
|
is_completed: number;
|
||||||
|
completed_at: number | null;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
}>(
|
||||||
|
`SELECT * FROM workout_sets
|
||||||
|
WHERE workout_exercise_id = ?
|
||||||
|
ORDER BY id`,
|
||||||
|
[workoutExerciseId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return sets.map(set => ({
|
||||||
|
id: set.id,
|
||||||
|
type: set.type as any,
|
||||||
|
weight: set.weight || undefined,
|
||||||
|
reps: set.reps || undefined,
|
||||||
|
rpe: set.rpe || undefined,
|
||||||
|
duration: set.duration || undefined,
|
||||||
|
isCompleted: Boolean(set.is_completed),
|
||||||
|
completedAt: set.completed_at || undefined,
|
||||||
|
lastUpdated: set.updated_at
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting workout sets:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete exercises and sets for a workout
|
||||||
|
*/
|
||||||
|
private async deleteWorkoutExercises(workoutId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Get all workout exercise IDs
|
||||||
|
const exercises = await this.db.getAllAsync<{ id: string }>(
|
||||||
|
'SELECT id FROM workout_exercises WHERE workout_id = ?',
|
||||||
|
[workoutId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete sets for each exercise
|
||||||
|
for (const exercise of exercises) {
|
||||||
|
await this.db.runAsync(
|
||||||
|
'DELETE FROM workout_sets WHERE workout_exercise_id = ?',
|
||||||
|
[exercise.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the exercises
|
||||||
|
await this.db.runAsync(
|
||||||
|
'DELETE FROM workout_exercises WHERE workout_id = ?',
|
||||||
|
[workoutId]
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting workout exercises:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save exercises and sets for a workout
|
||||||
|
*/
|
||||||
|
private async saveWorkoutExercises(workoutId: string, exercises: WorkoutExercise[]): Promise<void> {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
|
||||||
|
for (let i = 0; i < exercises.length; i++) {
|
||||||
|
const exercise = exercises[i];
|
||||||
|
const exerciseId = exercise.id || generateId('local');
|
||||||
|
|
||||||
|
// Save exercise
|
||||||
|
await this.db.runAsync(
|
||||||
|
`INSERT INTO workout_exercises (
|
||||||
|
id, workout_id, exercise_id, display_order, notes,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
exerciseId,
|
||||||
|
workoutId,
|
||||||
|
exercise.exerciseId || exercise.id, // Use exerciseId if available
|
||||||
|
i, // Display order
|
||||||
|
exercise.notes || null,
|
||||||
|
timestamp,
|
||||||
|
timestamp
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save sets
|
||||||
|
if (exercise.sets?.length) {
|
||||||
|
for (const set of exercise.sets) {
|
||||||
|
const setId = set.id || generateId('local');
|
||||||
|
|
||||||
|
await this.db.runAsync(
|
||||||
|
`INSERT INTO workout_sets (
|
||||||
|
id, workout_exercise_id, type, weight, reps,
|
||||||
|
rpe, duration, is_completed, completed_at,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
setId,
|
||||||
|
exerciseId,
|
||||||
|
set.type || 'normal',
|
||||||
|
set.weight || null,
|
||||||
|
set.reps || null,
|
||||||
|
set.rpe || null,
|
||||||
|
set.duration || null,
|
||||||
|
set.isCompleted ? 1 : 0,
|
||||||
|
set.completedAt || null,
|
||||||
|
timestamp,
|
||||||
|
timestamp
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
84
lib/hooks/useTemplates.ts
Normal file
84
lib/hooks/useTemplates.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
// lib/hooks/useTemplates.ts
|
||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { WorkoutTemplate } from '@/types/templates';
|
||||||
|
import { useTemplateService } from '@/components/DatabaseProvider';
|
||||||
|
|
||||||
|
export function useTemplates() {
|
||||||
|
const templateService = useTemplateService();
|
||||||
|
const [templates, setTemplates] = useState<WorkoutTemplate[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const loadTemplates = useCallback(async (limit: number = 50, offset: number = 0) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await templateService.getAllTemplates(limit, offset);
|
||||||
|
setTemplates(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading templates:', err);
|
||||||
|
setError(err instanceof Error ? err : new Error('Failed to load templates'));
|
||||||
|
// Use empty array if database isn't ready
|
||||||
|
setTemplates([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [templateService]);
|
||||||
|
|
||||||
|
const getTemplate = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
return await templateService.getTemplate(id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error getting template:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [templateService]);
|
||||||
|
|
||||||
|
const createTemplate = useCallback(async (template: Omit<WorkoutTemplate, 'id'>) => {
|
||||||
|
try {
|
||||||
|
const id = await templateService.createTemplate(template);
|
||||||
|
await loadTemplates(); // Refresh the list
|
||||||
|
return id;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating template:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [templateService, loadTemplates]);
|
||||||
|
|
||||||
|
const updateTemplate = useCallback(async (id: string, updates: Partial<WorkoutTemplate>) => {
|
||||||
|
try {
|
||||||
|
await templateService.updateTemplate(id, updates);
|
||||||
|
await loadTemplates(); // Refresh the list
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating template:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [templateService, loadTemplates]);
|
||||||
|
|
||||||
|
const deleteTemplate = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
await templateService.deleteTemplate(id);
|
||||||
|
setTemplates(current => current.filter(t => t.id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting template:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [templateService]);
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => {
|
||||||
|
loadTemplates();
|
||||||
|
}, [loadTemplates]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
templates,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
loadTemplates,
|
||||||
|
getTemplate,
|
||||||
|
createTemplate,
|
||||||
|
updateTemplate,
|
||||||
|
deleteTemplate,
|
||||||
|
refreshTemplates: loadTemplates
|
||||||
|
};
|
||||||
|
}
|
108
lib/hooks/useWorkouts.ts
Normal file
108
lib/hooks/useWorkouts.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
// lib/hooks/useWorkouts.ts
|
||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { Workout } from '@/types/workout';
|
||||||
|
import { useWorkoutService } from '@/components/DatabaseProvider';
|
||||||
|
|
||||||
|
export function useWorkouts() {
|
||||||
|
const workoutService = useWorkoutService();
|
||||||
|
const [workouts, setWorkouts] = useState<Workout[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const loadWorkouts = useCallback(async (limit: number = 50, offset: number = 0) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await workoutService.getAllWorkouts(limit, offset);
|
||||||
|
setWorkouts(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading workouts:', err);
|
||||||
|
setError(err instanceof Error ? err : new Error('Failed to load workouts'));
|
||||||
|
// Use mock data in dev mode if database is not ready
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log('Using mock data because workout tables not yet created');
|
||||||
|
setWorkouts([]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [workoutService]);
|
||||||
|
|
||||||
|
const getWorkout = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
return await workoutService.getWorkout(id);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error getting workout:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [workoutService]);
|
||||||
|
|
||||||
|
const getWorkoutsByDate = useCallback(async (date: Date) => {
|
||||||
|
try {
|
||||||
|
// Create start and end of day timestamps
|
||||||
|
const startOfDay = new Date(date);
|
||||||
|
startOfDay.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const endOfDay = new Date(date);
|
||||||
|
endOfDay.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
return await workoutService.getWorkoutsByDateRange(
|
||||||
|
startOfDay.getTime(),
|
||||||
|
endOfDay.getTime()
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading workouts for date:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [workoutService]);
|
||||||
|
|
||||||
|
const getWorkoutDates = useCallback(async (startDate: Date, endDate: Date) => {
|
||||||
|
try {
|
||||||
|
return await workoutService.getWorkoutDates(
|
||||||
|
startDate.getTime(),
|
||||||
|
endDate.getTime()
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error getting workout dates:', err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [workoutService]);
|
||||||
|
|
||||||
|
const saveWorkout = useCallback(async (workout: Workout) => {
|
||||||
|
try {
|
||||||
|
await workoutService.saveWorkout(workout);
|
||||||
|
await loadWorkouts(); // Refresh the list
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving workout:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [workoutService, loadWorkouts]);
|
||||||
|
|
||||||
|
const deleteWorkout = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
await workoutService.deleteWorkout(id);
|
||||||
|
setWorkouts(current => current.filter(w => w.id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting workout:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [workoutService]);
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => {
|
||||||
|
loadWorkouts();
|
||||||
|
}, [loadWorkouts]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
workouts,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
loadWorkouts,
|
||||||
|
getWorkout,
|
||||||
|
getWorkoutsByDate,
|
||||||
|
getWorkoutDates,
|
||||||
|
saveWorkout,
|
||||||
|
deleteWorkout,
|
||||||
|
refreshWorkouts: loadWorkouts
|
||||||
|
};
|
||||||
|
}
|
@ -1,20 +1,4 @@
|
|||||||
// stores/workoutStore.ts
|
// stores/workoutStore.ts
|
||||||
|
|
||||||
/**
|
|
||||||
* Workout Store
|
|
||||||
*
|
|
||||||
* This store manages the state for active workouts including:
|
|
||||||
* - Starting, pausing, and completing workouts
|
|
||||||
* - Managing exercise sets and completion status
|
|
||||||
* - Handling workout timing and duration tracking
|
|
||||||
* - Publishing workout data to Nostr when requested
|
|
||||||
* - Tracking favorite templates
|
|
||||||
*
|
|
||||||
* The store uses a timestamp-based approach for duration tracking,
|
|
||||||
* capturing start and end times to accurately represent workout duration
|
|
||||||
* even when accounting for time spent in completion flow.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { createSelectors } from '@/utils/createSelectors';
|
import { createSelectors } from '@/utils/createSelectors';
|
||||||
import { generateId } from '@/utils/ids';
|
import { generateId } from '@/utils/ids';
|
||||||
@ -40,9 +24,26 @@ import { router } from 'expo-router';
|
|||||||
import { useNDKStore } from '@/lib/stores/ndk';
|
import { useNDKStore } from '@/lib/stores/ndk';
|
||||||
import { NostrWorkoutService } from '@/lib/db/services/NostrWorkoutService';
|
import { NostrWorkoutService } from '@/lib/db/services/NostrWorkoutService';
|
||||||
import { TemplateService } from '@/lib//db/services/TemplateService';
|
import { TemplateService } from '@/lib//db/services/TemplateService';
|
||||||
|
import { WorkoutService } from '@/lib/db/services/WorkoutService'; // Add this import
|
||||||
import { NostrEvent } from '@/types/nostr';
|
import { NostrEvent } from '@/types/nostr';
|
||||||
import { NDKEvent } from '@nostr-dev-kit/ndk-mobile';
|
import { NDKEvent } from '@nostr-dev-kit/ndk-mobile';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workout Store
|
||||||
|
*
|
||||||
|
* This store manages the state for active workouts including:
|
||||||
|
* - Starting, pausing, and completing workouts
|
||||||
|
* - Managing exercise sets and completion status
|
||||||
|
* - Handling workout timing and duration tracking
|
||||||
|
* - Publishing workout data to Nostr when requested
|
||||||
|
* - Tracking favorite templates
|
||||||
|
*
|
||||||
|
* The store uses a timestamp-based approach for duration tracking,
|
||||||
|
* capturing start and end times to accurately represent workout duration
|
||||||
|
* even when accounting for time spent in completion flow.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
const AUTO_SAVE_INTERVAL = 30000; // 30 seconds
|
const AUTO_SAVE_INTERVAL = 30000; // 30 seconds
|
||||||
|
|
||||||
// Define a module-level timer reference for the workout timer
|
// Define a module-level timer reference for the workout timer
|
||||||
@ -810,26 +811,23 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
|
|
||||||
async function getTemplate(templateId: string): Promise<WorkoutTemplate | null> {
|
async function getTemplate(templateId: string): Promise<WorkoutTemplate | null> {
|
||||||
try {
|
try {
|
||||||
// Try to get it from favorites in the database
|
// Try to get it from favorites in the database
|
||||||
const db = openDatabaseSync('powr.db');
|
const db = openDatabaseSync('powr.db');
|
||||||
|
const favoritesService = new FavoritesService(db);
|
||||||
|
const templateService = new TemplateService(db);
|
||||||
|
|
||||||
const result = await db.getFirstAsync<{
|
// First try to get from favorites
|
||||||
content: string
|
const favoriteResult = await favoritesService.getContentById<WorkoutTemplate>('template', templateId);
|
||||||
}>(
|
if (favoriteResult) {
|
||||||
`SELECT content FROM favorites WHERE content_type = 'template' AND content_id = ?`,
|
return favoriteResult;
|
||||||
[templateId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result && result.content) {
|
|
||||||
return JSON.parse(result.content);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not found in favorites, could implement fetching from template database
|
// If not in favorites, try the templates table
|
||||||
// Example: return await db.getTemplate(templateId);
|
const templateResult = await templateService.getTemplate(templateId);
|
||||||
console.log('Template not found in favorites:', templateId);
|
return templateResult;
|
||||||
return null;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching template:', error);
|
console.error('Error fetching template:', error);
|
||||||
return null;
|
return null;
|
||||||
@ -838,14 +836,31 @@ async function getTemplate(templateId: string): Promise<WorkoutTemplate | null>
|
|||||||
|
|
||||||
async function saveWorkout(workout: Workout): Promise<void> {
|
async function saveWorkout(workout: Workout): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Make sure we're capturing the duration properly in what's saved
|
|
||||||
console.log('Saving workout with endTime:', workout.endTime);
|
console.log('Saving workout with endTime:', workout.endTime);
|
||||||
// TODO: Implement actual save logic using our database service
|
|
||||||
|
// Use the workout service to save the workout
|
||||||
|
const db = openDatabaseSync('powr.db');
|
||||||
|
const workoutService = new WorkoutService(db);
|
||||||
|
|
||||||
|
await workoutService.saveWorkout(workout);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving workout:', error);
|
console.error('Error saving workout:', 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function calculateWorkoutSummary(workout: Workout): WorkoutSummary {
|
function calculateWorkoutSummary(workout: Workout): WorkoutSummary {
|
||||||
return {
|
return {
|
||||||
id: generateId('local'),
|
id: generateId('local'),
|
||||||
@ -891,11 +906,6 @@ function calculateAverageRpe(workout: Workout): number {
|
|||||||
return totalRpe / rpeSets.length;
|
return totalRpe / rpeSets.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSummary(summary: WorkoutSummary) {
|
|
||||||
// TODO: Implement summary saving
|
|
||||||
console.log('Saving summary:', summary);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create auto-generated selectors
|
// Create auto-generated selectors
|
||||||
export const useWorkoutStore = createSelectors(useWorkoutStoreBase);
|
export const useWorkoutStore = createSelectors(useWorkoutStoreBase);
|
||||||
|
|
||||||
|
@ -77,6 +77,9 @@ export interface WorkoutSet {
|
|||||||
isCompleted: boolean;
|
isCompleted: boolean;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
|
duration?: number; // Add this property
|
||||||
|
completedAt?: number;
|
||||||
|
lastUpdated?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -84,6 +87,7 @@ export interface WorkoutSet {
|
|||||||
*/
|
*/
|
||||||
export interface WorkoutExercise extends BaseExercise {
|
export interface WorkoutExercise extends BaseExercise {
|
||||||
sets: WorkoutSet[];
|
sets: WorkoutSet[];
|
||||||
|
exerciseId?: string;
|
||||||
targetSets?: number;
|
targetSets?: number;
|
||||||
targetReps?: number;
|
targetReps?: number;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
@ -31,9 +31,11 @@ export interface TemplateExerciseDisplay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TemplateExerciseConfig {
|
export interface TemplateExerciseConfig {
|
||||||
|
id?: string; // Add this line
|
||||||
exercise: BaseExercise;
|
exercise: BaseExercise;
|
||||||
targetSets: number;
|
targetSets?: number;
|
||||||
targetReps: number;
|
targetReps?: number;
|
||||||
|
targetWeight?: number;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
rpe?: number;
|
rpe?: number;
|
||||||
setType?: SetType;
|
setType?: SetType;
|
||||||
@ -122,6 +124,8 @@ export interface WorkoutTemplate extends TemplateBase, SyncableContent {
|
|||||||
exercises: TemplateExerciseConfig[];
|
exercises: TemplateExerciseConfig[];
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
version: number;
|
version: number;
|
||||||
|
lastUpdated?: number; // Add this line
|
||||||
|
parentId?: string; // Add this line
|
||||||
|
|
||||||
// Template configuration
|
// Template configuration
|
||||||
format?: {
|
format?: {
|
||||||
@ -184,8 +188,8 @@ export function toTemplateDisplay(template: WorkoutTemplate): Template {
|
|||||||
description: template.description,
|
description: template.description,
|
||||||
exercises: template.exercises.map(ex => ({
|
exercises: template.exercises.map(ex => ({
|
||||||
title: ex.exercise.title,
|
title: ex.exercise.title,
|
||||||
targetSets: ex.targetSets,
|
targetSets: ex.targetSets || 0, // Add default value
|
||||||
targetReps: ex.targetReps,
|
targetReps: ex.targetReps || 0, // Add default value
|
||||||
notes: ex.notes
|
notes: ex.notes
|
||||||
})),
|
})),
|
||||||
tags: template.tags,
|
tags: template.tags,
|
||||||
@ -261,8 +265,8 @@ export function createNostrTemplateEvent(template: WorkoutTemplate) {
|
|||||||
...template.exercises.map(ex => [
|
...template.exercises.map(ex => [
|
||||||
'exercise',
|
'exercise',
|
||||||
`33401:${ex.exercise.id}`,
|
`33401:${ex.exercise.id}`,
|
||||||
ex.targetSets.toString(),
|
(ex.targetSets || 0).toString(),
|
||||||
ex.targetReps.toString(),
|
(ex.targetReps || 0).toString(),
|
||||||
ex.setType || 'normal'
|
ex.setType || 'normal'
|
||||||
]),
|
]),
|
||||||
...template.tags.map(tag => ['t', tag])
|
...template.tags.map(tag => ['t', tag])
|
||||||
|
@ -23,12 +23,14 @@ export interface WorkoutSet {
|
|||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
lastUpdated?: number;
|
lastUpdated?: number;
|
||||||
completedAt?: number;
|
completedAt?: number;
|
||||||
|
duration?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exercise within a workout
|
* Exercise within a workout
|
||||||
*/
|
*/
|
||||||
export interface WorkoutExercise extends BaseExercise {
|
export interface WorkoutExercise extends BaseExercise {
|
||||||
|
exerciseId?: string;
|
||||||
sets: WorkoutSet[];
|
sets: WorkoutSet[];
|
||||||
targetSets?: number;
|
targetSets?: number;
|
||||||
targetReps?: number;
|
targetReps?: number;
|
||||||
@ -53,12 +55,16 @@ export interface Workout extends SyncableContent {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
|
||||||
// Template reference if workout was started from template
|
// Template reference if workout was started from template
|
||||||
templateId?: string;
|
templateId?: string; // Keep only one templateId property
|
||||||
|
templatePubkey?: string; // Add this for template references
|
||||||
|
|
||||||
|
// Add shareStatus property
|
||||||
|
shareStatus?: 'local' | 'public' | 'limited';
|
||||||
|
|
||||||
// Workout configuration
|
// Workout configuration
|
||||||
rounds?: number;
|
rounds?: number;
|
||||||
duration?: number; // Total duration in seconds
|
duration?: number;
|
||||||
interval?: number; // For EMOM/interval workouts
|
interval?: number;
|
||||||
restBetweenRounds?: number;
|
restBetweenRounds?: number;
|
||||||
|
|
||||||
// Workout metrics
|
// Workout metrics
|
||||||
|
65
utils/converter.ts
Normal file
65
utils/converter.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
// utils/converter.ts - Simplified to just forward to NostrWorkoutService
|
||||||
|
|
||||||
|
import { Workout } from '@/types/workout';
|
||||||
|
import { WorkoutTemplate } from '@/types/templates';
|
||||||
|
import { NostrEvent } from '@/types/nostr';
|
||||||
|
import { NostrWorkoutService } from '@/lib/db/services/NostrWorkoutService';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to find a tag value in a Nostr event
|
||||||
|
* @deprecated Use NostrWorkoutService.findTagValue instead
|
||||||
|
*/
|
||||||
|
export function findTagValue(tags: string[][], name: string): string | null {
|
||||||
|
return NostrWorkoutService.findTagValue(tags, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all values for a specific tag name
|
||||||
|
* @deprecated Use NostrWorkoutService.getTagValues instead
|
||||||
|
*/
|
||||||
|
export function getTagValues(tags: string[][], name: string): string[] {
|
||||||
|
return NostrWorkoutService.getTagValues(tags, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get template tag information
|
||||||
|
* @deprecated Use NostrWorkoutService.getTemplateTag instead
|
||||||
|
*/
|
||||||
|
export function getTagValueByName(tags: string[][], name: string): string | null {
|
||||||
|
return NostrWorkoutService.findTagValue(tags, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tag values matching a pattern
|
||||||
|
*/
|
||||||
|
export function getTemplateTag(tags: string[][]): { reference: string, relay: string } | undefined {
|
||||||
|
return NostrWorkoutService.getTemplateTag(tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a workout to a Nostr event
|
||||||
|
*/
|
||||||
|
export function workoutToNostrEvent(workout: Workout, isLimited: boolean = false): NostrEvent {
|
||||||
|
return NostrWorkoutService.workoutToNostrEvent(workout, isLimited);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Nostr event to a workout
|
||||||
|
*/
|
||||||
|
export function nostrEventToWorkout(event: NostrEvent): Workout {
|
||||||
|
return NostrWorkoutService.nostrEventToWorkout(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a template to a Nostr event
|
||||||
|
*/
|
||||||
|
export function templateToNostrEvent(template: WorkoutTemplate): NostrEvent {
|
||||||
|
return NostrWorkoutService.templateToNostrEvent(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Nostr event to a template
|
||||||
|
*/
|
||||||
|
export function nostrEventToTemplate(event: NostrEvent): WorkoutTemplate {
|
||||||
|
return NostrWorkoutService.nostrEventToTemplate(event);
|
||||||
|
}
|
30
utils/nostr-utils.ts
Normal file
30
utils/nostr-utils.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
// utils/nostr-utils.ts
|
||||||
|
import { NostrEvent } from '@/types/nostr';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to find a tag value in a Nostr event
|
||||||
|
*/
|
||||||
|
export function findTagValue(tags: string[][], name: string): string | null {
|
||||||
|
const tag = tags.find(t => t[0] === name);
|
||||||
|
return tag && tag.length > 1 ? tag[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all values for a specific tag name
|
||||||
|
*/
|
||||||
|
export function getTagValues(tags: string[][], name: string): string[] {
|
||||||
|
return tags.filter(t => t[0] === name).map(t => t[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get template tag information
|
||||||
|
*/
|
||||||
|
export function getTemplateTag(tags: string[][]): { reference: string, relay: string } | undefined {
|
||||||
|
const templateTag = tags.find(t => t[0] === 'template');
|
||||||
|
if (!templateTag || templateTag.length < 3) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
reference: templateTag[1],
|
||||||
|
relay: templateTag[2] || ''
|
||||||
|
};
|
||||||
|
}
|
@ -23,7 +23,7 @@ export function convertTemplateToWorkout(template: WorkoutTemplate) {
|
|||||||
},
|
},
|
||||||
created_at: now,
|
created_at: now,
|
||||||
// Create the specified number of sets from template
|
// Create the specified number of sets from template
|
||||||
sets: Array.from({ length: templateExercise.targetSets }, (): WorkoutSet => ({
|
sets: Array.from({ length: templateExercise.targetSets || 0 }, (): WorkoutSet => ({
|
||||||
id: generateId('local'),
|
id: generateId('local'),
|
||||||
weight: 0, // Start empty, but could use last workout weight
|
weight: 0, // Start empty, but could use last workout weight
|
||||||
reps: templateExercise.targetReps, // Use target reps from template
|
reps: templateExercise.targetReps, // Use target reps from template
|
||||||
|
Loading…
x
Reference in New Issue
Block a user