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/),
|
||||
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
|
||||
|
||||
## Added
|
||||
|
@ -6,12 +6,16 @@ import { ExerciseService } from '@/lib/db/services/ExerciseService';
|
||||
import { DevSeederService } from '@/lib/db/services/DevSeederService';
|
||||
import { PublicationQueueService } from '@/lib/db/services/PublicationQueueService';
|
||||
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 { useNDKStore } from '@/lib/stores/ndk';
|
||||
|
||||
// Create context for services
|
||||
interface DatabaseServicesContextValue {
|
||||
exerciseService: ExerciseService | null;
|
||||
workoutService: WorkoutService | null;
|
||||
templateService: TemplateService | null;
|
||||
devSeeder: DevSeederService | null;
|
||||
publicationQueue: PublicationQueueService | null;
|
||||
favoritesService: FavoritesService | null;
|
||||
@ -20,6 +24,8 @@ interface DatabaseServicesContextValue {
|
||||
|
||||
const DatabaseServicesContext = React.createContext<DatabaseServicesContextValue>({
|
||||
exerciseService: null,
|
||||
workoutService: null,
|
||||
templateService: null,
|
||||
devSeeder: null,
|
||||
publicationQueue: null,
|
||||
favoritesService: null,
|
||||
@ -35,6 +41,8 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) {
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [services, setServices] = React.useState<DatabaseServicesContextValue>({
|
||||
exerciseService: null,
|
||||
workoutService: null,
|
||||
templateService: null,
|
||||
devSeeder: null,
|
||||
publicationQueue: null,
|
||||
favoritesService: null,
|
||||
@ -64,6 +72,8 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) {
|
||||
// Initialize services
|
||||
console.log('[DB] Initializing services...');
|
||||
const exerciseService = new ExerciseService(db);
|
||||
const workoutService = new WorkoutService(db);
|
||||
const templateService = new TemplateService(db);
|
||||
const devSeeder = new DevSeederService(db, exerciseService);
|
||||
const publicationQueue = new PublicationQueueService(db);
|
||||
const favoritesService = new FavoritesService(db);
|
||||
@ -80,6 +90,8 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) {
|
||||
// Set services
|
||||
setServices({
|
||||
exerciseService,
|
||||
workoutService,
|
||||
templateService,
|
||||
devSeeder,
|
||||
publicationQueue,
|
||||
favoritesService,
|
||||
@ -140,6 +152,22 @@ export function useExerciseService() {
|
||||
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() {
|
||||
const context = React.useContext(DatabaseServicesContext);
|
||||
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>
|
||||
);
|
||||
}
|
553
lib/db/schema.ts
553
lib/db/schema.ts
@ -2,7 +2,7 @@
|
||||
import { SQLiteDatabase } from 'expo-sqlite';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
export const SCHEMA_VERSION = 1;
|
||||
export const SCHEMA_VERSION = 2; // Increment since we're adding new tables
|
||||
|
||||
class Schema {
|
||||
private async getCurrentVersion(db: SQLiteDatabase): Promise<number> {
|
||||
@ -32,7 +32,17 @@ class Schema {
|
||||
async createTables(db: SQLiteDatabase): Promise<void> {
|
||||
try {
|
||||
console.log(`[Schema] Initializing database on ${Platform.OS}`);
|
||||
|
||||
// First, ensure schema_version table exists since we need it for version checking
|
||||
await db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
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) {
|
||||
@ -40,26 +50,39 @@ class Schema {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Schema] Creating tables for version ${SCHEMA_VERSION}`);
|
||||
console.log(`[Schema] Upgrading from version ${currentVersion} to ${SCHEMA_VERSION}`);
|
||||
|
||||
// Schema version tracking
|
||||
await db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// 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
|
||||
await this.updateSchemaVersion(db);
|
||||
|
||||
console.log(`[Schema] Database initialized at version ${SCHEMA_VERSION}`);
|
||||
try {
|
||||
// Use a transaction to ensure all-or-nothing table creation
|
||||
await db.withTransactionAsync(async () => {
|
||||
// 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 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
|
||||
await this.updateSchemaVersion(db);
|
||||
|
||||
console.log('[Schema] Non-transactional schema upgrade completed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Schema] Error creating tables:', error);
|
||||
throw error;
|
||||
@ -67,196 +90,344 @@ class Schema {
|
||||
}
|
||||
|
||||
private async dropAllTables(db: SQLiteDatabase): Promise<void> {
|
||||
// Get list of all tables excluding schema_version
|
||||
const tables = await db.getAllAsync<{ name: string }>(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name != 'schema_version'"
|
||||
);
|
||||
|
||||
// Drop each table
|
||||
for (const { name } of tables) {
|
||||
await db.execAsync(`DROP TABLE IF EXISTS ${name}`);
|
||||
console.log(`[Schema] Dropped table: ${name}`);
|
||||
try {
|
||||
console.log('[Schema] Getting list of tables to drop...');
|
||||
|
||||
// Get list of all tables excluding schema_version
|
||||
const tables = await db.getAllAsync<{ name: string }>(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name != 'schema_version'"
|
||||
);
|
||||
|
||||
console.log(`[Schema] Found ${tables.length} tables to drop`);
|
||||
|
||||
// Drop each table
|
||||
for (const { name } of tables) {
|
||||
try {
|
||||
await db.execAsync(`DROP TABLE IF EXISTS ${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> {
|
||||
// Create exercises table
|
||||
await db.execAsync(`
|
||||
CREATE TABLE exercises (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('strength', 'cardio', 'bodyweight')),
|
||||
category TEXT NOT NULL,
|
||||
equipment TEXT,
|
||||
description TEXT,
|
||||
format_json TEXT,
|
||||
format_units_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'local',
|
||||
nostr_event_id TEXT
|
||||
);
|
||||
`);
|
||||
try {
|
||||
console.log('[Schema] Creating all database tables...');
|
||||
|
||||
// Create exercises table
|
||||
console.log('[Schema] Creating exercises table...');
|
||||
await db.execAsync(`
|
||||
CREATE TABLE exercises (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('strength', 'cardio', 'bodyweight')),
|
||||
category TEXT NOT NULL,
|
||||
equipment TEXT,
|
||||
description TEXT,
|
||||
format_json TEXT,
|
||||
format_units_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'local',
|
||||
nostr_event_id TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
// Create exercise_tags table
|
||||
await db.execAsync(`
|
||||
CREATE TABLE exercise_tags (
|
||||
exercise_id TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE,
|
||||
UNIQUE(exercise_id, tag)
|
||||
);
|
||||
CREATE INDEX idx_exercise_tags ON exercise_tags(tag);
|
||||
`);
|
||||
|
||||
// Create nostr_events table
|
||||
await db.execAsync(`
|
||||
CREATE TABLE nostr_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
pubkey TEXT NOT NULL,
|
||||
kind INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
sig TEXT,
|
||||
raw_event TEXT NOT NULL,
|
||||
received_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
// Create exercise_tags table
|
||||
console.log('[Schema] Creating exercise_tags table...');
|
||||
await db.execAsync(`
|
||||
CREATE TABLE exercise_tags (
|
||||
exercise_id TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE,
|
||||
UNIQUE(exercise_id, tag)
|
||||
);
|
||||
CREATE INDEX idx_exercise_tags ON exercise_tags(tag);
|
||||
`);
|
||||
|
||||
// Create nostr_events table
|
||||
console.log('[Schema] Creating nostr_events table...');
|
||||
await db.execAsync(`
|
||||
CREATE TABLE nostr_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
pubkey TEXT NOT NULL,
|
||||
kind INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
sig TEXT,
|
||||
raw_event TEXT NOT NULL,
|
||||
received_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Create event_tags table
|
||||
await db.execAsync(`
|
||||
CREATE TABLE event_tags (
|
||||
event_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
index_num INTEGER NOT NULL,
|
||||
FOREIGN KEY(event_id) REFERENCES nostr_events(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_event_tags ON event_tags(name, value);
|
||||
`);
|
||||
|
||||
// Create cache metadata table
|
||||
await db.execAsync(`
|
||||
CREATE TABLE cache_metadata (
|
||||
content_id TEXT PRIMARY KEY,
|
||||
content_type TEXT NOT NULL,
|
||||
last_accessed INTEGER NOT NULL,
|
||||
access_count INTEGER NOT NULL DEFAULT 0,
|
||||
cache_priority INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
// Create event_tags table
|
||||
console.log('[Schema] Creating event_tags table...');
|
||||
await db.execAsync(`
|
||||
CREATE TABLE event_tags (
|
||||
event_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
index_num INTEGER NOT NULL,
|
||||
FOREIGN KEY(event_id) REFERENCES nostr_events(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_event_tags ON event_tags(name, value);
|
||||
`);
|
||||
|
||||
// Create cache metadata table
|
||||
console.log('[Schema] Creating cache_metadata table...');
|
||||
await db.execAsync(`
|
||||
CREATE TABLE cache_metadata (
|
||||
content_id TEXT PRIMARY KEY,
|
||||
content_type TEXT NOT NULL,
|
||||
last_accessed INTEGER NOT NULL,
|
||||
access_count INTEGER NOT NULL DEFAULT 0,
|
||||
cache_priority INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
|
||||
// Create media cache table
|
||||
await db.execAsync(`
|
||||
CREATE TABLE exercise_media (
|
||||
exercise_id TEXT NOT NULL,
|
||||
media_type TEXT NOT NULL,
|
||||
content BLOB NOT NULL,
|
||||
thumbnail BLOB,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Create user profiles table
|
||||
await db.execAsync(`
|
||||
CREATE TABLE user_profiles (
|
||||
pubkey TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
display_name TEXT,
|
||||
about TEXT,
|
||||
website TEXT,
|
||||
picture TEXT,
|
||||
nip05 TEXT,
|
||||
lud16 TEXT,
|
||||
last_updated INTEGER
|
||||
);
|
||||
CREATE INDEX idx_user_profiles_last_updated ON user_profiles(last_updated DESC);
|
||||
`);
|
||||
// Create media cache table
|
||||
console.log('[Schema] Creating exercise_media table...');
|
||||
await db.execAsync(`
|
||||
CREATE TABLE exercise_media (
|
||||
exercise_id TEXT NOT NULL,
|
||||
media_type TEXT NOT NULL,
|
||||
content BLOB NOT NULL,
|
||||
thumbnail BLOB,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Create user profiles table
|
||||
console.log('[Schema] Creating user_profiles table...');
|
||||
await db.execAsync(`
|
||||
CREATE TABLE user_profiles (
|
||||
pubkey TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
display_name TEXT,
|
||||
about TEXT,
|
||||
website TEXT,
|
||||
picture TEXT,
|
||||
nip05 TEXT,
|
||||
lud16 TEXT,
|
||||
last_updated INTEGER
|
||||
);
|
||||
CREATE INDEX idx_user_profiles_last_updated ON user_profiles(last_updated DESC);
|
||||
`);
|
||||
|
||||
// Create user relays table
|
||||
await db.execAsync(`
|
||||
CREATE TABLE user_relays (
|
||||
pubkey TEXT NOT NULL,
|
||||
relay_url TEXT NOT NULL,
|
||||
read BOOLEAN NOT NULL DEFAULT 1,
|
||||
write BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (pubkey, relay_url),
|
||||
FOREIGN KEY(pubkey) REFERENCES user_profiles(pubkey) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Create publication queue table
|
||||
await db.execAsync(`
|
||||
CREATE TABLE publication_queue (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_attempt INTEGER,
|
||||
payload TEXT NOT NULL,
|
||||
FOREIGN KEY(event_id) REFERENCES nostr_events(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_publication_queue_created ON publication_queue(created_at ASC);
|
||||
`);
|
||||
// Create user relays table
|
||||
console.log('[Schema] Creating user_relays table...');
|
||||
await db.execAsync(`
|
||||
CREATE TABLE user_relays (
|
||||
pubkey TEXT NOT NULL,
|
||||
relay_url TEXT NOT NULL,
|
||||
read BOOLEAN NOT NULL DEFAULT 1,
|
||||
write BOOLEAN NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (pubkey, relay_url),
|
||||
FOREIGN KEY(pubkey) REFERENCES user_profiles(pubkey) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Create publication queue table
|
||||
console.log('[Schema] Creating publication_queue table...');
|
||||
await db.execAsync(`
|
||||
CREATE TABLE publication_queue (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_attempt INTEGER,
|
||||
payload TEXT NOT NULL,
|
||||
FOREIGN KEY(event_id) REFERENCES nostr_events(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_publication_queue_created ON publication_queue(created_at ASC);
|
||||
`);
|
||||
|
||||
// Create app status table
|
||||
await db.execAsync(`
|
||||
CREATE TABLE app_status (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Create NDK cache table
|
||||
await db.execAsync(`
|
||||
CREATE TABLE ndk_cache (
|
||||
id TEXT PRIMARY KEY,
|
||||
event TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
kind INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_ndk_cache_kind ON ndk_cache(kind);
|
||||
CREATE INDEX idx_ndk_cache_created ON ndk_cache(created_at);
|
||||
`);
|
||||
|
||||
// Create favorites table
|
||||
await db.execAsync(`
|
||||
CREATE TABLE favorites (
|
||||
id TEXT PRIMARY KEY,
|
||||
content_type TEXT NOT NULL,
|
||||
content_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
pubkey TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(content_type, content_id)
|
||||
);
|
||||
CREATE INDEX idx_favorites_content_type ON favorites(content_type);
|
||||
CREATE INDEX idx_favorites_content_id ON favorites(content_id);
|
||||
`);
|
||||
// Create app status table
|
||||
console.log('[Schema] Creating app_status table...');
|
||||
await db.execAsync(`
|
||||
CREATE TABLE app_status (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Create NDK cache table
|
||||
console.log('[Schema] Creating ndk_cache table...');
|
||||
await db.execAsync(`
|
||||
CREATE TABLE ndk_cache (
|
||||
id TEXT PRIMARY KEY,
|
||||
event TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
kind INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_ndk_cache_kind ON ndk_cache(kind);
|
||||
CREATE INDEX idx_ndk_cache_created ON ndk_cache(created_at);
|
||||
`);
|
||||
|
||||
// Create favorites table
|
||||
console.log('[Schema] Creating favorites table...');
|
||||
await db.execAsync(`
|
||||
CREATE TABLE favorites (
|
||||
id TEXT PRIMARY KEY,
|
||||
content_type TEXT NOT NULL,
|
||||
content_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
pubkey TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(content_type, content_id)
|
||||
);
|
||||
CREATE INDEX idx_favorites_content_type ON favorites(content_type);
|
||||
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> {
|
||||
// Delete any existing schema version records
|
||||
await db.runAsync('DELETE FROM schema_version');
|
||||
|
||||
// Insert the current version
|
||||
await db.runAsync(
|
||||
'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)',
|
||||
[SCHEMA_VERSION, Date.now()]
|
||||
);
|
||||
try {
|
||||
console.log(`[Schema] Updating schema version to ${SCHEMA_VERSION}`);
|
||||
|
||||
// Delete any existing schema version records
|
||||
await db.runAsync('DELETE FROM schema_version');
|
||||
|
||||
// Insert the current version
|
||||
await db.runAsync(
|
||||
'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)',
|
||||
[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> {
|
||||
if (!__DEV__) return; // Only allow in development
|
||||
if (!__DEV__) {
|
||||
console.log('[Schema] Database reset is only available in development mode');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[Schema] Resetting database...');
|
||||
|
||||
await this.dropAllTables(db);
|
||||
await this.createAllTables(db);
|
||||
await this.updateSchemaVersion(db);
|
||||
// Clear schema_version to force recreation of all tables
|
||||
await db.execAsync('DROP TABLE IF EXISTS schema_version');
|
||||
console.log('[Schema] Dropped schema_version table');
|
||||
|
||||
// Now create tables from scratch
|
||||
await this.createTables(db);
|
||||
|
||||
console.log('[Schema] Database reset complete');
|
||||
} catch (error) {
|
||||
|
@ -1,13 +1,21 @@
|
||||
// lib/db/services/DevSeederService.ts
|
||||
import { SQLiteDatabase } from 'expo-sqlite';
|
||||
import { ExerciseService } from './ExerciseService';
|
||||
import { EventCache } from '@/lib/db/services/EventCache';
|
||||
import { WorkoutService } from './WorkoutService';
|
||||
import { TemplateService } from './TemplateService';
|
||||
import { logDatabaseInfo } from '../debug';
|
||||
import { mockExerciseEvents, convertNostrToExercise } from '../../mocks/exercises';
|
||||
import { DbService } from '../db-service';
|
||||
import NDK, { NDKEvent } from '@nostr-dev-kit/ndk-mobile';
|
||||
|
||||
export class DevSeederService {
|
||||
private db: SQLiteDatabase;
|
||||
private dbService: DbService;
|
||||
private exerciseService: ExerciseService;
|
||||
private workoutService: WorkoutService | null = null;
|
||||
private templateService: TemplateService | null = null;
|
||||
private eventCache: EventCache | null = null;
|
||||
private ndk: NDK | null = null;
|
||||
|
||||
constructor(
|
||||
@ -15,7 +23,17 @@ export class DevSeederService {
|
||||
exerciseService: ExerciseService
|
||||
) {
|
||||
this.db = db;
|
||||
this.dbService = new DbService(db);
|
||||
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) {
|
||||
@ -36,22 +54,19 @@ export class DevSeederService {
|
||||
|
||||
if (existingCount > 0) {
|
||||
console.log('Database already seeded with', existingCount, 'exercises');
|
||||
return;
|
||||
}
|
||||
|
||||
// Start transaction for all seeding operations
|
||||
await this.db.withTransactionAsync(async () => {
|
||||
console.log('Seeding mock exercises...');
|
||||
|
||||
// Process all events within the same transaction
|
||||
for (const eventData of mockExerciseEvents) {
|
||||
if (this.ndk) {
|
||||
// If NDK is available, use it to cache the event
|
||||
const event = new NDKEvent(this.ndk);
|
||||
Object.assign(event, eventData);
|
||||
|
||||
// Cache the event in NDK
|
||||
} else {
|
||||
// Start transaction for all seeding operations
|
||||
await this.db.withTransactionAsync(async () => {
|
||||
console.log('Seeding mock exercises...');
|
||||
|
||||
// Process all events within the same transaction
|
||||
for (const eventData of mockExerciseEvents) {
|
||||
if (this.ndk) {
|
||||
// If NDK is available, use it to cache the event
|
||||
const event = new NDKEvent(this.ndk);
|
||||
Object.assign(event, eventData);
|
||||
|
||||
// Cache the event in NDK
|
||||
const ndkEvent = new NDKEvent(this.ndk);
|
||||
|
||||
// Copy event properties
|
||||
@ -70,15 +85,28 @@ export class DevSeederService {
|
||||
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
|
||||
const exercise = convertNostrToExercise(eventData);
|
||||
await this.exerciseService.createExercise(exercise, true);
|
||||
}
|
||||
|
||||
// Create exercise from the mock data regardless of NDK availability
|
||||
const exercise = convertNostrToExercise(eventData);
|
||||
await this.exerciseService.createExercise(exercise, true);
|
||||
}
|
||||
|
||||
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
|
||||
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() {
|
||||
if (!__DEV__) return;
|
||||
|
||||
@ -97,16 +189,29 @@ export class DevSeederService {
|
||||
|
||||
await this.db.withTransactionAsync(async () => {
|
||||
const tables = [
|
||||
// Original tables
|
||||
'exercises',
|
||||
'exercise_tags',
|
||||
'nostr_events',
|
||||
'event_tags',
|
||||
'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) {
|
||||
await this.db.runAsync(`DELETE FROM ${table}`);
|
||||
try {
|
||||
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
|
||||
import { Workout } from '@/types/workout';
|
||||
// lib/services/NostrWorkoutService.ts
|
||||
import { Workout, WorkoutExercise, WorkoutSet } from '@/types/workout';
|
||||
import { WorkoutTemplate, TemplateExerciseConfig } from '@/types/templates';
|
||||
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
|
||||
*/
|
||||
@ -10,60 +13,14 @@ export class NostrWorkoutService {
|
||||
* Creates a complete Nostr workout event with all details
|
||||
*/
|
||||
static createCompleteWorkoutEvent(workout: Workout): NostrEvent {
|
||||
return {
|
||||
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)
|
||||
};
|
||||
return this.workoutToNostrEvent(workout, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a limited Nostr workout event with reduced metrics for privacy
|
||||
*/
|
||||
static createLimitedWorkoutEvent(workout: Workout): NostrEvent {
|
||||
return {
|
||||
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)
|
||||
};
|
||||
return this.workoutToNostrEvent(workout, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -83,4 +40,371 @@ export class NostrWorkoutService {
|
||||
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
|
||||
import { SQLiteDatabase, openDatabaseSync } from 'expo-sqlite';
|
||||
import {
|
||||
WorkoutTemplate,
|
||||
TemplateExerciseConfig
|
||||
} from '@/types/templates';
|
||||
import { Workout } from '@/types/workout';
|
||||
import { WorkoutTemplate } from '@/types/templates';
|
||||
import { generateId } from '@/utils/ids';
|
||||
import { DbService } from '../db-service';
|
||||
|
||||
/**
|
||||
* Service for managing workout templates
|
||||
*/
|
||||
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 {
|
||||
// This would require actual implementation with DB access
|
||||
// For now, this is a placeholder
|
||||
console.log('Updating template from workout:', workout.id);
|
||||
// Future: Implement with your DB service
|
||||
return true;
|
||||
const templates = await this.db.getAllAsync<{
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
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) {
|
||||
console.error('Error updating template:', error);
|
||||
return false;
|
||||
console.error('Error getting templates:', error);
|
||||
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 {
|
||||
// This would require actual implementation with DB access
|
||||
// For now, this is a placeholder
|
||||
console.log('Creating new template from workout:', workout.id, 'with name:', name);
|
||||
// Future: Implement with your DB service
|
||||
return generateId();
|
||||
const template = await this.db.getFirstAsync<{
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
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) {
|
||||
console.error('Error creating template:', error);
|
||||
console.error('Error getting template:', error);
|
||||
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 {
|
||||
// This would require comparing with the original template
|
||||
// For now, assume changes if there's a templateId
|
||||
return !!workout.templateId;
|
||||
// Simple implementation - in a real app, you'd compare with the original template
|
||||
return workout.templateId !== undefined;
|
||||
}
|
||||
}
|
@ -3,6 +3,7 @@ import { SQLiteDatabase } from 'expo-sqlite';
|
||||
import { Workout } from '@/types/workout';
|
||||
import { format } from 'date-fns';
|
||||
import { DbService } from '../db-service';
|
||||
import { WorkoutExercise } from '@/types/exercise'; // Add this import
|
||||
|
||||
export class WorkoutHistoryService {
|
||||
private db: DbService;
|
||||
@ -21,21 +22,47 @@ export class WorkoutHistoryService {
|
||||
title: string;
|
||||
type: string;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
end_time: number | null;
|
||||
is_completed: number;
|
||||
created_at: number;
|
||||
last_updated: number;
|
||||
updated_at: number;
|
||||
template_id: string | null;
|
||||
total_volume: number | null;
|
||||
total_reps: number | null;
|
||||
source: string;
|
||||
notes: string | null;
|
||||
}>(
|
||||
`SELECT * FROM workouts
|
||||
ORDER BY start_time DESC`
|
||||
);
|
||||
|
||||
// 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) {
|
||||
console.error('Error getting workouts:', error);
|
||||
throw error;
|
||||
@ -55,14 +82,15 @@ export class WorkoutHistoryService {
|
||||
title: string;
|
||||
type: string;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
end_time: number | null;
|
||||
is_completed: number;
|
||||
created_at: number;
|
||||
last_updated: number;
|
||||
updated_at: number;
|
||||
template_id: string | null;
|
||||
total_volume: number | null;
|
||||
total_reps: number | null;
|
||||
source: string;
|
||||
notes: string | null;
|
||||
}>(
|
||||
`SELECT * FROM workouts
|
||||
WHERE start_time >= ? AND start_time <= ?
|
||||
@ -70,7 +98,32 @@ export class WorkoutHistoryService {
|
||||
[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) {
|
||||
console.error('Error getting workouts by date:', error);
|
||||
throw error;
|
||||
@ -88,7 +141,8 @@ export class WorkoutHistoryService {
|
||||
const result = await this.db.getAllAsync<{
|
||||
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 <= ?`,
|
||||
[startOfMonth, endOfMonth]
|
||||
);
|
||||
@ -110,14 +164,15 @@ export class WorkoutHistoryService {
|
||||
title: string;
|
||||
type: string;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
end_time: number | null;
|
||||
is_completed: number;
|
||||
created_at: number;
|
||||
last_updated: number;
|
||||
updated_at: number;
|
||||
template_id: string | null;
|
||||
total_volume: number | null;
|
||||
total_reps: number | null;
|
||||
source: string;
|
||||
notes: string | null;
|
||||
}>(
|
||||
`SELECT * FROM workouts WHERE id = ?`,
|
||||
[workoutId]
|
||||
@ -126,18 +181,26 @@ export class WorkoutHistoryService {
|
||||
if (!workout) return null;
|
||||
|
||||
// Get exercises for this workout
|
||||
// This is just a skeleton - you'll need to implement the actual query
|
||||
// based on your database schema
|
||||
const exercises = await this.db.getAllAsync(
|
||||
`SELECT * FROM workout_exercises WHERE workout_id = ?`,
|
||||
[workoutId]
|
||||
);
|
||||
const exercises = await this.getWorkoutExercises(workoutId);
|
||||
|
||||
const workoutObj = this.mapRowToWorkout(workout);
|
||||
// You would set the exercises property here based on your schema
|
||||
// workoutObj.exercises = exercises.map(...);
|
||||
|
||||
return workoutObj;
|
||||
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,
|
||||
totalVolume: workout.total_volume || undefined,
|
||||
totalReps: workout.total_reps || undefined,
|
||||
notes: workout.notes || undefined,
|
||||
exercises,
|
||||
availability: {
|
||||
source: [workout.source as any]
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting workout details:', error);
|
||||
throw error;
|
||||
@ -160,39 +223,96 @@ export class WorkoutHistoryService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to map a database row to a Workout object
|
||||
*/
|
||||
private mapRowToWorkout(row: {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
is_completed: number;
|
||||
created_at: number;
|
||||
last_updated?: number;
|
||||
template_id?: string | null;
|
||||
total_volume?: number | null;
|
||||
total_reps?: number | null;
|
||||
source: string;
|
||||
}): Workout {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
type: row.type as any, // Cast to TemplateType
|
||||
startTime: row.start_time,
|
||||
endTime: row.end_time,
|
||||
isCompleted: row.is_completed === 1,
|
||||
created_at: row.created_at,
|
||||
lastUpdated: row.last_updated,
|
||||
templateId: row.template_id || undefined,
|
||||
totalVolume: row.total_volume || undefined,
|
||||
totalReps: row.total_reps || undefined,
|
||||
availability: {
|
||||
source: [row.source as any] // Cast to StorageSource
|
||||
},
|
||||
exercises: [] // Exercises would be loaded separately
|
||||
};
|
||||
}
|
||||
// Helper method to load workout exercises and sets
|
||||
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 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;
|
||||
completed_at: number | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}>(
|
||||
`SELECT * FROM workout_sets
|
||||
WHERE workout_exercise_id = ?
|
||||
ORDER BY id`,
|
||||
[exercise.id]
|
||||
);
|
||||
|
||||
// Map sets to the correct format
|
||||
const mappedSets = 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
|
||||
}));
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* 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 { createSelectors } from '@/utils/createSelectors';
|
||||
import { generateId } from '@/utils/ids';
|
||||
@ -40,9 +24,26 @@ import { router } from 'expo-router';
|
||||
import { useNDKStore } from '@/lib/stores/ndk';
|
||||
import { NostrWorkoutService } from '@/lib/db/services/NostrWorkoutService';
|
||||
import { TemplateService } from '@/lib//db/services/TemplateService';
|
||||
import { WorkoutService } from '@/lib/db/services/WorkoutService'; // Add this import
|
||||
import { NostrEvent } from '@/types/nostr';
|
||||
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
|
||||
|
||||
// Define a module-level timer reference for the workout timer
|
||||
@ -810,26 +811,23 @@ const useWorkoutStoreBase = create<ExtendedWorkoutState & ExtendedWorkoutActions
|
||||
}));
|
||||
|
||||
// Helper functions
|
||||
|
||||
async function getTemplate(templateId: string): Promise<WorkoutTemplate | null> {
|
||||
try {
|
||||
// Try to get it from favorites in the database
|
||||
const db = openDatabaseSync('powr.db');
|
||||
const favoritesService = new FavoritesService(db);
|
||||
const templateService = new TemplateService(db);
|
||||
|
||||
const result = await db.getFirstAsync<{
|
||||
content: string
|
||||
}>(
|
||||
`SELECT content FROM favorites WHERE content_type = 'template' AND content_id = ?`,
|
||||
[templateId]
|
||||
);
|
||||
|
||||
if (result && result.content) {
|
||||
return JSON.parse(result.content);
|
||||
// First try to get from favorites
|
||||
const favoriteResult = await favoritesService.getContentById<WorkoutTemplate>('template', templateId);
|
||||
if (favoriteResult) {
|
||||
return favoriteResult;
|
||||
}
|
||||
|
||||
// If not found in favorites, could implement fetching from template database
|
||||
// Example: return await db.getTemplate(templateId);
|
||||
console.log('Template not found in favorites:', templateId);
|
||||
return null;
|
||||
// If not in favorites, try the templates table
|
||||
const templateResult = await templateService.getTemplate(templateId);
|
||||
return templateResult;
|
||||
} catch (error) {
|
||||
console.error('Error fetching template:', error);
|
||||
return null;
|
||||
@ -838,14 +836,31 @@ async function getTemplate(templateId: string): Promise<WorkoutTemplate | null>
|
||||
|
||||
async function saveWorkout(workout: Workout): Promise<void> {
|
||||
try {
|
||||
// Make sure we're capturing the duration properly in what's saved
|
||||
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) {
|
||||
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 {
|
||||
return {
|
||||
id: generateId('local'),
|
||||
@ -891,11 +906,6 @@ function calculateAverageRpe(workout: Workout): number {
|
||||
return totalRpe / rpeSets.length;
|
||||
}
|
||||
|
||||
async function saveSummary(summary: WorkoutSummary) {
|
||||
// TODO: Implement summary saving
|
||||
console.log('Saving summary:', summary);
|
||||
}
|
||||
|
||||
// Create auto-generated selectors
|
||||
export const useWorkoutStore = createSelectors(useWorkoutStoreBase);
|
||||
|
||||
|
@ -77,6 +77,9 @@ export interface WorkoutSet {
|
||||
isCompleted: boolean;
|
||||
notes?: string;
|
||||
timestamp?: number;
|
||||
duration?: number; // Add this property
|
||||
completedAt?: number;
|
||||
lastUpdated?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -84,6 +87,7 @@ export interface WorkoutSet {
|
||||
*/
|
||||
export interface WorkoutExercise extends BaseExercise {
|
||||
sets: WorkoutSet[];
|
||||
exerciseId?: string;
|
||||
targetSets?: number;
|
||||
targetReps?: number;
|
||||
notes?: string;
|
||||
|
@ -31,9 +31,11 @@ export interface TemplateExerciseDisplay {
|
||||
}
|
||||
|
||||
export interface TemplateExerciseConfig {
|
||||
id?: string; // Add this line
|
||||
exercise: BaseExercise;
|
||||
targetSets: number;
|
||||
targetReps: number;
|
||||
targetSets?: number;
|
||||
targetReps?: number;
|
||||
targetWeight?: number;
|
||||
weight?: number;
|
||||
rpe?: number;
|
||||
setType?: SetType;
|
||||
@ -122,6 +124,8 @@ export interface WorkoutTemplate extends TemplateBase, SyncableContent {
|
||||
exercises: TemplateExerciseConfig[];
|
||||
isPublic: boolean;
|
||||
version: number;
|
||||
lastUpdated?: number; // Add this line
|
||||
parentId?: string; // Add this line
|
||||
|
||||
// Template configuration
|
||||
format?: {
|
||||
@ -184,8 +188,8 @@ export function toTemplateDisplay(template: WorkoutTemplate): Template {
|
||||
description: template.description,
|
||||
exercises: template.exercises.map(ex => ({
|
||||
title: ex.exercise.title,
|
||||
targetSets: ex.targetSets,
|
||||
targetReps: ex.targetReps,
|
||||
targetSets: ex.targetSets || 0, // Add default value
|
||||
targetReps: ex.targetReps || 0, // Add default value
|
||||
notes: ex.notes
|
||||
})),
|
||||
tags: template.tags,
|
||||
@ -261,8 +265,8 @@ export function createNostrTemplateEvent(template: WorkoutTemplate) {
|
||||
...template.exercises.map(ex => [
|
||||
'exercise',
|
||||
`33401:${ex.exercise.id}`,
|
||||
ex.targetSets.toString(),
|
||||
ex.targetReps.toString(),
|
||||
(ex.targetSets || 0).toString(),
|
||||
(ex.targetReps || 0).toString(),
|
||||
ex.setType || 'normal'
|
||||
]),
|
||||
...template.tags.map(tag => ['t', tag])
|
||||
|
@ -23,12 +23,14 @@ export interface WorkoutSet {
|
||||
timestamp?: number;
|
||||
lastUpdated?: number;
|
||||
completedAt?: number;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exercise within a workout
|
||||
*/
|
||||
export interface WorkoutExercise extends BaseExercise {
|
||||
exerciseId?: string;
|
||||
sets: WorkoutSet[];
|
||||
targetSets?: number;
|
||||
targetReps?: number;
|
||||
@ -53,12 +55,16 @@ export interface Workout extends SyncableContent {
|
||||
tags?: string[];
|
||||
|
||||
// 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
|
||||
rounds?: number;
|
||||
duration?: number; // Total duration in seconds
|
||||
interval?: number; // For EMOM/interval workouts
|
||||
duration?: number;
|
||||
interval?: number;
|
||||
restBetweenRounds?: number;
|
||||
|
||||
// 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,
|
||||
// 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'),
|
||||
weight: 0, // Start empty, but could use last workout weight
|
||||
reps: templateExercise.targetReps, // Use target reps from template
|
||||
|
Loading…
x
Reference in New Issue
Block a user