add new exercises to sqlite db

This commit is contained in:
DocNR 2025-02-16 23:53:28 -05:00
parent df91cbd9bb
commit 90ea708e9b
10 changed files with 956 additions and 146 deletions

View File

@ -1,90 +1,82 @@
// app/(tabs)/library/exercises.tsx // app/(tabs)/library/exercises.tsx
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { View, ScrollView } from 'react-native'; import { View, ScrollView, SectionList } from 'react-native';
import { Text } from '@/components/ui/text'; import { Text } from '@/components/ui/text';
import { ExerciseCard } from '@/components/exercises/ExerciseCard'; import { ExerciseCard } from '@/components/exercises/ExerciseCard';
import { FloatingActionButton } from '@/components/shared/FloatingActionButton'; import { FloatingActionButton } from '@/components/shared/FloatingActionButton';
import { NewExerciseSheet } from '@/components/library/NewExerciseSheet'; import { NewExerciseSheet } from '@/components/library/NewExerciseSheet';
import { Dumbbell } from 'lucide-react-native'; import { Dumbbell } from 'lucide-react-native';
import { Exercise } from '@/types/library'; import { Exercise, BaseExercise } from '@/types/exercise';
import { generateId } from '@/utils/ids'; import { useSQLiteContext } from 'expo-sqlite';
import DatabaseDebug from '@/components/DatabaseDebug'; // Add this import import { ExerciseService } from '@/lib/db/services/ExerciseService';
const initialExercises: Exercise[] = [
{
id: '1',
title: 'Barbell Back Squat',
category: 'Legs',
equipment: 'barbell',
tags: ['compound', 'strength'],
source: 'local',
description: 'A compound exercise that primarily targets the quadriceps, hamstrings, and glutes.',
},
{
id: '2',
title: 'Pull-ups',
category: 'Pull',
equipment: 'bodyweight',
tags: ['upper-body', 'compound'],
source: 'local',
description: 'An upper body pulling exercise that targets the latissimus dorsi and biceps.',
},
{
id: '3',
title: 'Bench Press',
category: 'Push',
equipment: 'barbell',
tags: ['push', 'strength'],
source: 'nostr',
description: 'A compound pushing exercise that targets the chest, shoulders, and triceps.',
},
];
export default function ExercisesScreen() { export default function ExercisesScreen() {
const [exercises, setExercises] = useState<Exercise[]>(initialExercises); const db = useSQLiteContext();
const exerciseService = React.useMemo(() => new ExerciseService(db), [db]);
const [exercises, setExercises] = useState<Exercise[]>([]);
const [showNewExercise, setShowNewExercise] = useState(false); const [showNewExercise, setShowNewExercise] = useState(false);
const handleAddExercise = (exerciseData: Omit<Exercise, 'id' | 'source'>) => { useEffect(() => {
const newExercise: Exercise = { loadExercises();
}, []);
const loadExercises = async () => {
try {
const loadedExercises = await exerciseService.getAllExercises();
setExercises(loadedExercises);
} catch (error) {
console.error('Error loading exercises:', error);
}
};
const handleAddExercise = async (exerciseData: BaseExercise) => {
try {
await exerciseService.createExercise({
...exerciseData, ...exerciseData,
id: generateId(), created_at: Date.now(),
source: 'local', source: 'local'
}; });
setExercises(prev => [...prev, newExercise]); await loadExercises();
setShowNewExercise(false); setShowNewExercise(false);
} catch (error) {
console.error('Error adding exercise:', error);
}
}; };
// Get recent exercises const handleDelete = async (id: string) => {
const recentExercises = exercises.slice(0, 2); try {
await exerciseService.deleteExercise(id);
const handleDelete = (id: string) => { await loadExercises();
setExercises(current => current.filter(ex => ex.id !== id)); } catch (error) {
console.error('Error deleting exercise:', error);
}
}; };
const handleExercisePress = (exerciseId: string) => { const handleExercisePress = (exerciseId: string) => {
console.log('Selected exercise:', exerciseId); console.log('Selected exercise:', exerciseId);
}; };
const alphabet = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
return ( return (
<View className="flex-1 bg-background"> <View className="flex-1 bg-background">
{__DEV__ && <DatabaseDebug />} {/* Only show in development */} <View className="absolute right-0 top-0 bottom-0 w-6 z-10 justify-center bg-transparent">
<ScrollView className="flex-1"> {alphabet.map((letter) => (
{/* Recent Exercises Section */} <Text
<View className="py-4"> key={letter}
<Text className="text-lg font-semibold mb-4 px-4">Recent Exercises</Text> className="text-xs text-muted-foreground text-center"
<View className="gap-3"> onPress={() => {
{recentExercises.map(exercise => ( // TODO: Implement scroll to section
<ExerciseCard console.log('Scroll to:', letter);
key={exercise.id} }}
{...exercise} >
onPress={() => handleExercisePress(exercise.id)} {letter}
onDelete={() => handleDelete(exercise.id)} </Text>
/>
))} ))}
</View> </View>
</View>
{/* All Exercises Section */} <ScrollView className="flex-1 mr-6">
<View className="py-4"> <View className="py-4">
<Text className="text-lg font-semibold mb-4 px-4">All Exercises</Text> <Text className="text-lg font-semibold mb-4 px-4">All Exercises</Text>
<View className="gap-3"> <View className="gap-3">

View File

@ -1,11 +1,282 @@
// app/(tabs)/library/(tabs)/programs.tsx // app/(tabs)/library/programs.tsx
import { View } from 'react-native'; import React, { useState, useEffect } from 'react';
import { View, ScrollView } from 'react-native';
import { Text } from '@/components/ui/text'; import { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { AlertCircle, CheckCircle2, Database, RefreshCcw, Trash2 } from 'lucide-react-native';
import { useSQLiteContext } from 'expo-sqlite';
import { ExerciseType, ExerciseCategory, Equipment } from '@/types/exercise';
import { SQLTransaction, SQLResultSet, SQLError } from '@/lib/db/types';
import { schema } from '@/lib/db/schema';
interface TableInfo {
name: string;
}
interface SchemaVersion {
version: number;
}
interface ExerciseRow {
id: string;
title: string;
type: string;
category: string;
equipment: string | null;
description: string | null;
created_at: number;
updated_at: number;
format_json: string;
format_units_json: string;
}
export default function ProgramsScreen() { export default function ProgramsScreen() {
return ( const db = useSQLiteContext();
<View className="flex-1 items-center justify-center"> const [dbStatus, setDbStatus] = useState<{
<Text>Programs (Coming Soon)</Text> initialized: boolean;
</View> tables: string[];
error?: string;
}>({
initialized: false,
tables: [],
});
const [testResults, setTestResults] = useState<{
success: boolean;
message: string;
} | null>(null);
useEffect(() => {
checkDatabase();
}, []);
const checkDatabase = async () => {
try {
// Check schema_version table
const version = await db.getFirstAsync<SchemaVersion>(
'SELECT version FROM schema_version ORDER BY version DESC LIMIT 1'
);
// Get all tables
const tables = await db.getAllAsync<TableInfo>(
"SELECT name FROM sqlite_master WHERE type='table'"
);
setDbStatus({
initialized: !!version,
tables: tables.map(t => t.name),
});
} catch (error) {
console.error('Error checking database:', error);
setDbStatus(prev => ({
...prev,
error: error instanceof Error ? error.message : 'Unknown error occurred',
}));
}
};
const resetDatabase = async () => {
try {
await db.withTransactionAsync(async () => {
// Drop all tables
const tables = await db.getAllAsync<{ name: string }>(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
);
for (const { name } of tables) {
await db.execAsync(`DROP TABLE IF EXISTS ${name}`);
}
// Recreate schema
await schema.createTables(db);
});
setTestResults({
success: true,
message: 'Database reset successfully'
});
// Refresh database status
checkDatabase();
} catch (error) {
console.error('Error resetting database:', error);
setTestResults({
success: false,
message: error instanceof Error ? error.message : 'Unknown error during reset'
});
}
};
const runTestInsert = async () => {
try {
// Test exercise
const testExercise = {
title: "Test Squat",
type: "strength" as ExerciseType,
category: "Legs" as ExerciseCategory,
equipment: "barbell" as Equipment,
description: "Test exercise",
tags: ["test", "legs"],
format: {
weight: true,
reps: true
},
format_units: {
weight: "kg" as const,
reps: "count" as const
}
};
const timestamp = Date.now();
// Insert exercise using withTransactionAsync
await db.withTransactionAsync(async () => {
// Insert exercise
await db.runAsync(
`INSERT INTO exercises (
id, title, type, category, equipment, description,
format_json, format_units_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
'test-1',
testExercise.title,
testExercise.type,
testExercise.category,
testExercise.equipment || null,
testExercise.description || null,
JSON.stringify(testExercise.format),
JSON.stringify(testExercise.format_units),
timestamp,
timestamp
]
);
// Insert tags
for (const tag of testExercise.tags) {
await db.runAsync(
"INSERT INTO exercise_tags (exercise_id, tag) VALUES (?, ?)",
['test-1', tag]
);
}
});
// Verify insert
const result = await db.getFirstAsync<ExerciseRow>(
"SELECT * FROM exercises WHERE id = ?",
['test-1']
);
setTestResults({
success: true,
message: `Successfully inserted and verified test exercise: ${JSON.stringify(result, null, 2)}`
});
} catch (error) {
console.error('Test insert error:', error);
setTestResults({
success: false,
message: error instanceof Error ? error.message : 'Unknown error occurred'
});
}
};
return (
<ScrollView className="flex-1 bg-background p-4">
<View className="py-4 space-y-4">
<Text className="text-lg font-semibold text-center mb-4">Database Debug Panel</Text>
<Card>
<CardHeader>
<CardTitle className="flex-row items-center gap-2">
<Database size={20} className="text-foreground" />
<Text className="text-lg font-semibold">Database Status</Text>
</CardTitle>
</CardHeader>
<CardContent>
<View className="space-y-2">
<Text>Initialized: {dbStatus.initialized ? '✅' : '❌'}</Text>
<Text>Tables Found: {dbStatus.tables.length}</Text>
<View className="pl-4">
{dbStatus.tables.map(table => (
<Text key={table} className="text-muted-foreground"> {table}</Text>
))}
</View>
{dbStatus.error && (
<View className="mt-4 p-4 bg-destructive/10 rounded-lg border border-destructive">
<View className="flex-row items-center gap-2">
<AlertCircle className="text-destructive" size={20} />
<Text className="font-semibold text-destructive">Error</Text>
</View>
<Text className="mt-2 text-destructive">{dbStatus.error}</Text>
</View>
)}
</View>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex-row items-center gap-2">
<RefreshCcw size={20} className="text-foreground" />
<Text className="text-lg font-semibold">Database Operations</Text>
</CardTitle>
</CardHeader>
<CardContent>
<View className="space-y-4">
<Button
onPress={runTestInsert}
className="w-full"
>
<Text className="text-primary-foreground">Run Test Insert</Text>
</Button>
<Button
onPress={resetDatabase}
variant="destructive"
className="w-full"
>
<Trash2 size={18} className="mr-2" />
<Text className="text-destructive-foreground">Reset Database</Text>
</Button>
{testResults && (
<View className={`mt-4 p-4 rounded-lg border ${
testResults.success
? 'bg-primary/10 border-primary'
: 'bg-destructive/10 border-destructive'
}`}>
<View className="flex-row items-center gap-2">
{testResults.success ? (
<CheckCircle2
className="text-primary"
size={20}
/>
) : (
<AlertCircle
className="text-destructive"
size={20}
/>
)}
<Text className={`font-semibold ${
testResults.success ? 'text-primary' : 'text-destructive'
}`}>
{testResults.success ? "Success" : "Error"}
</Text>
</View>
<ScrollView className="mt-2">
<Text className={`${
testResults.success ? 'text-foreground' : 'text-destructive'
}`}>
{testResults.message}
</Text>
</ScrollView>
</View>
)}
</View>
</CardContent>
</Card>
</View>
</ScrollView>
); );
} }

View File

@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { AlertCircle, CheckCircle2 } from 'lucide-react-native'; import { AlertCircle, CheckCircle2 } from 'lucide-react-native';
import { useSQLiteContext } from 'expo-sqlite'; import { useSQLiteContext } from 'expo-sqlite';
import { ExerciseType, ExerciseCategory, Equipment } from '@/types/exercise'; import { ExerciseType, ExerciseCategory, Equipment } from '@/types/exercise';
import { SQLTransaction, SQLResultSet, SQLError } from '@/lib/db/types';
interface TableInfo { interface TableInfo {
name: string; name: string;
@ -15,6 +16,20 @@ interface SchemaVersion {
version: number; version: number;
} }
interface ExerciseRow {
id: string;
title: string;
type: string;
category: string;
equipment: string | null;
description: string | null;
created_at: number;
updated_at: number;
source: string;
format_json: string;
format_units_json: string;
}
export default function DatabaseDebug() { export default function DatabaseDebug() {
const db = useSQLiteContext(); const db = useSQLiteContext();
const [dbStatus, setDbStatus] = useState<{ const [dbStatus, setDbStatus] = useState<{
@ -52,6 +67,7 @@ export default function DatabaseDebug() {
tables: tables.map(t => t.name), tables: tables.map(t => t.name),
}); });
} catch (error) { } catch (error) {
console.error('Error checking database:', error);
setDbStatus(prev => ({ setDbStatus(prev => ({
...prev, ...prev,
error: error instanceof Error ? error.message : 'Unknown error occurred', error: error instanceof Error ? error.message : 'Unknown error occurred',
@ -79,16 +95,16 @@ export default function DatabaseDebug() {
} }
}; };
await db.withTransactionAsync(async () => {
const timestamp = Date.now(); const timestamp = Date.now();
// Insert exercise using withTransactionAsync
await db.withTransactionAsync(async () => {
// Insert exercise // Insert exercise
await db.runAsync( await db.runAsync(
`INSERT INTO exercises ( `INSERT INTO exercises (
id, title, type, category, equipment, description, id, title, type, category, equipment, description,
created_at, updated_at, source, format_json, format_units_json, created_at, updated_at
format_json, format_units_json ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
'test-1', 'test-1',
testExercise.title, testExercise.title,
@ -96,11 +112,10 @@ export default function DatabaseDebug() {
testExercise.category, testExercise.category,
testExercise.equipment || null, testExercise.equipment || null,
testExercise.description || null, testExercise.description || null,
timestamp,
timestamp,
'local',
JSON.stringify(testExercise.format), JSON.stringify(testExercise.format),
JSON.stringify(testExercise.format_units) JSON.stringify(testExercise.format_units),
timestamp,
timestamp
] ]
); );
@ -114,7 +129,7 @@ export default function DatabaseDebug() {
}); });
// Verify insert // Verify insert
const result = await db.getFirstAsync( const result = await db.getFirstAsync<ExerciseRow>(
"SELECT * FROM exercises WHERE id = ?", "SELECT * FROM exercises WHERE id = ?",
['test-1'] ['test-1']
); );
@ -123,7 +138,9 @@ export default function DatabaseDebug() {
success: true, success: true,
message: `Successfully inserted and verified test exercise: ${JSON.stringify(result, null, 2)}` message: `Successfully inserted and verified test exercise: ${JSON.stringify(result, null, 2)}`
}); });
} catch (error) { } catch (error) {
console.error('Test insert error:', error);
setTestResults({ setTestResults({
success: false, success: false,
message: error instanceof Error ? error.message : 'Unknown error occurred' message: error instanceof Error ? error.message : 'Unknown error occurred'

View File

@ -23,7 +23,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { Exercise } from '@/types/library'; import { Exercise } from '@/types/exercise';
interface ExerciseCardProps extends Exercise { interface ExerciseCardProps extends Exercise {
onPress: () => void; onPress: () => void;
@ -48,23 +48,17 @@ export function ExerciseCard({
const [showSheet, setShowSheet] = React.useState(false); const [showSheet, setShowSheet] = React.useState(false);
const [showDeleteAlert, setShowDeleteAlert] = React.useState(false); const [showDeleteAlert, setShowDeleteAlert] = React.useState(false);
const handleDeletePress = () => {
setShowDeleteAlert(true);
};
const handleConfirmDelete = () => { const handleConfirmDelete = () => {
onDelete(id); onDelete(id);
setShowDeleteAlert(false); setShowDeleteAlert(false);
}; if (showSheet) {
setShowSheet(false); // Close detail sheet if open
const handleCardPress = () => { }
setShowSheet(true);
onPress();
}; };
return ( return (
<> <>
<TouchableOpacity onPress={handleCardPress} activeOpacity={0.7}> <TouchableOpacity onPress={() => setShowSheet(true)} activeOpacity={0.7}>
<Card className="mx-4"> <Card className="mx-4">
<CardContent className="p-4"> <CardContent className="p-4">
<View className="flex-row justify-between items-start"> <View className="flex-row justify-between items-start">
@ -143,8 +137,8 @@ export function ExerciseCard({
<Trash2 <Trash2
size={20} size={20}
color={Platform.select({ color={Platform.select({
ios: undefined, // Let className handle it ios: undefined,
android: '#8B5CF6' // Explicit color for Android android: '#dc2626'
})} })}
className="text-destructive" className="text-destructive"
/> />
@ -175,7 +169,6 @@ export function ExerciseCard({
</Card> </Card>
</TouchableOpacity> </TouchableOpacity>
{/* Bottom sheet section */}
<Sheet isOpen={showSheet} onClose={() => setShowSheet(false)}> <Sheet isOpen={showSheet} onClose={() => setShowSheet(false)}>
<SheetHeader> <SheetHeader>
<SheetTitle> <SheetTitle>

View File

@ -52,18 +52,8 @@ export function NewExerciseSheet({ isOpen, onClose, onSubmit }: NewExerciseSheet
const handleSubmit = () => { const handleSubmit = () => {
if (!formData.title || !formData.equipment) return; if (!formData.title || !formData.equipment) return;
// Cast to any as a temporary workaround for the TypeScript error
const exercise = { const exercise = {
// BaseExercise properties ...formData,
title: formData.title,
type: formData.type,
category: formData.category,
equipment: formData.equipment,
description: formData.description,
tags: formData.tags,
format: formData.format,
format_units: formData.format_units,
// SyncableContent properties
id: generateId('local'), id: generateId('local'),
created_at: Date.now(), created_at: Date.now(),
availability: { availability: {

View File

@ -1,11 +1,25 @@
// lib/db/schema.ts
import { SQLiteDatabase } from 'expo-sqlite'; import { SQLiteDatabase } from 'expo-sqlite';
export const SCHEMA_VERSION = 1; export const SCHEMA_VERSION = 2;
class Schema { class Schema {
private async getCurrentVersion(db: SQLiteDatabase): Promise<number> {
try {
const version = await db.getFirstAsync<{ version: number }>(
'SELECT version FROM schema_version ORDER BY version DESC LIMIT 1'
);
return version?.version ?? 0;
} catch (error) {
return 0; // If table doesn't exist yet
}
}
async createTables(db: SQLiteDatabase): Promise<void> { async createTables(db: SQLiteDatabase): Promise<void> {
try { try {
// Version tracking const currentVersion = await this.getCurrentVersion(db);
// Schema version tracking
await db.execAsync(` await db.execAsync(`
CREATE TABLE IF NOT EXISTS schema_version ( CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY, version INTEGER PRIMARY KEY,
@ -13,43 +27,87 @@ class Schema {
); );
`); `);
// Exercise Definitions if (currentVersion === 0) {
// Drop existing tables if they exist
await db.execAsync(`DROP TABLE IF EXISTS exercise_tags`);
await db.execAsync(`DROP TABLE IF EXISTS exercises`);
await db.execAsync(`DROP TABLE IF EXISTS event_tags`);
await db.execAsync(`DROP TABLE IF EXISTS nostr_events`);
// Create base tables
await db.execAsync(` await db.execAsync(`
CREATE TABLE IF NOT EXISTS exercises ( CREATE TABLE exercises (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
title TEXT NOT NULL, title TEXT NOT NULL,
type TEXT NOT NULL, type TEXT NOT NULL CHECK(type IN ('strength', 'cardio', 'bodyweight')),
category TEXT NOT NULL, category TEXT NOT NULL,
equipment TEXT, equipment TEXT,
description TEXT, description TEXT,
format_json TEXT,
format_units_json TEXT,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT 'local'
); );
`); `);
// Exercise Tags
await db.execAsync(` await db.execAsync(`
CREATE TABLE IF NOT EXISTS exercise_tags ( CREATE TABLE exercise_tags (
exercise_id TEXT NOT NULL, exercise_id TEXT NOT NULL,
tag TEXT NOT NULL, tag TEXT NOT NULL,
FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE, FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE,
UNIQUE(exercise_id, tag) UNIQUE(exercise_id, tag)
); );
CREATE INDEX idx_exercise_tags ON exercise_tags(tag);
CREATE INDEX IF NOT EXISTS idx_exercise_tags ON exercise_tags(tag);
`); `);
// Set initial schema version if not exists // Set initial version
const version = await db.getFirstAsync<{ version: number }>(
'SELECT version FROM schema_version ORDER BY version DESC LIMIT 1'
);
if (!version) {
await db.runAsync( await db.runAsync(
'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)', 'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)',
[SCHEMA_VERSION, Date.now()] [1, Date.now()]
); );
} }
// Update to version 2 if needed
if (currentVersion < 2) {
await db.execAsync(`
CREATE TABLE IF NOT EXISTS 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
);
`);
await db.execAsync(`
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_event_tags ON event_tags(name, value);
`);
// Add Nostr reference to exercises if not exists
try {
await db.execAsync(`ALTER TABLE exercises ADD COLUMN nostr_event_id TEXT REFERENCES nostr_events(id)`);
} catch (e) {
// Column might already exist
}
await db.runAsync(
'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)',
[2, Date.now()]
);
}
console.log(`[Schema] Database initialized at version ${await this.getCurrentVersion(db)}`);
} catch (error) { } catch (error) {
console.error('[Schema] Error creating tables:', error); console.error('[Schema] Error creating tables:', error);
throw error; throw error;

View File

@ -0,0 +1,444 @@
// lib/db/services/ExerciseService.ts
import { SQLiteDatabase } from 'expo-sqlite';
import { Exercise, ExerciseType, ExerciseCategory, Equipment } from '@/types/exercise';
import { generateId } from '@/utils/ids';
export class ExerciseService {
constructor(private db: SQLiteDatabase) {}
// Add this new method
async getAllExercises(): Promise<Exercise[]> {
try {
const exercises = await this.db.getAllAsync<any>(`
SELECT * FROM exercises ORDER BY created_at DESC
`);
const exerciseIds = exercises.map(e => e.id);
if (exerciseIds.length === 0) return [];
const tags = await this.db.getAllAsync<{ exercise_id: string; tag: string }>(
`SELECT exercise_id, tag
FROM exercise_tags
WHERE exercise_id IN (${exerciseIds.map(() => '?').join(',')})`,
exerciseIds
);
const tagsByExercise = tags.reduce((acc, { exercise_id, tag }) => {
acc[exercise_id] = acc[exercise_id] || [];
acc[exercise_id].push(tag);
return acc;
}, {} as Record<string, string[]>);
return exercises.map(exercise => ({
...exercise,
tags: tagsByExercise[exercise.id] || [],
format: exercise.format_json ? JSON.parse(exercise.format_json) : undefined,
format_units: exercise.format_units_json ? JSON.parse(exercise.format_units_json) : undefined,
availability: { source: [exercise.source] }
}));
} catch (error) {
console.error('Error getting all exercises:', error);
throw error;
}
}
// Update createExercise to handle all required fields
async createExercise(exercise: Omit<Exercise, 'id' | 'availability'>): Promise<string> {
const id = generateId();
const timestamp = Date.now();
try {
await this.db.withTransactionAsync(async () => {
await this.db.runAsync(
`INSERT INTO exercises (
id, title, type, category, equipment, description,
format_json, format_units_json, created_at, updated_at, source
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
id,
exercise.title,
exercise.type,
exercise.category,
exercise.equipment || null,
exercise.description || null,
exercise.format ? JSON.stringify(exercise.format) : null,
exercise.format_units ? JSON.stringify(exercise.format_units) : null,
timestamp,
timestamp,
exercise.source || 'local'
]
);
if (exercise.tags?.length) {
for (const tag of exercise.tags) {
await this.db.runAsync(
'INSERT INTO exercise_tags (exercise_id, tag) VALUES (?, ?)',
[id, tag]
);
}
}
});
return id;
} catch (error) {
console.error('Error creating exercise:', error);
throw error;
}
}
async getExercise(id: string): Promise<Exercise | null> {
try {
// Get exercise data
const exercise = await this.db.getFirstAsync<Exercise>(
`SELECT * FROM exercises WHERE id = ?`,
[id]
);
if (!exercise) return null;
// Get tags
const tags = await this.db.getAllAsync<{ tag: string }>(
'SELECT tag FROM exercise_tags WHERE exercise_id = ?',
[id]
);
return {
...exercise,
format: exercise.format_json ? JSON.parse(exercise.format_json) : undefined,
format_units: exercise.format_units_json ? JSON.parse(exercise.format_units_json) : undefined,
tags: tags.map(t => t.tag)
};
} catch (error) {
console.error('Error getting exercise:', error);
throw error;
}
}
async updateExercise(id: string, exercise: Partial<Exercise>): Promise<void> {
const timestamp = Date.now();
try {
await this.db.withTransactionAsync(async () => {
// Build update query dynamically based on provided fields
const updates: string[] = [];
const values: any[] = [];
if (exercise.title !== undefined) {
updates.push('title = ?');
values.push(exercise.title);
}
if (exercise.type !== undefined) {
updates.push('type = ?');
values.push(exercise.type);
}
if (exercise.category !== undefined) {
updates.push('category = ?');
values.push(exercise.category);
}
if (exercise.equipment !== undefined) {
updates.push('equipment = ?');
values.push(exercise.equipment);
}
if (exercise.description !== undefined) {
updates.push('description = ?');
values.push(exercise.description);
}
if (exercise.format !== undefined) {
updates.push('format_json = ?');
values.push(JSON.stringify(exercise.format));
}
if (exercise.format_units !== undefined) {
updates.push('format_units_json = ?');
values.push(JSON.stringify(exercise.format_units));
}
if (updates.length > 0) {
updates.push('updated_at = ?');
values.push(timestamp);
// Add id to values array
values.push(id);
await this.db.runAsync(
`UPDATE exercises SET ${updates.join(', ')} WHERE id = ?`,
values
);
}
// Update tags if provided
if (exercise.tags !== undefined) {
// Delete existing tags
await this.db.runAsync(
'DELETE FROM exercise_tags WHERE exercise_id = ?',
[id]
);
// Insert new tags
for (const tag of exercise.tags) {
await this.db.runAsync(
'INSERT INTO exercise_tags (exercise_id, tag) VALUES (?, ?)',
[id, tag]
);
}
}
});
} catch (error) {
console.error('Error updating exercise:', error);
throw error;
}
}
async deleteExercise(id: string): Promise<void> {
try {
console.log('Deleting exercise:', id);
await this.db.withTransactionAsync(async () => {
// Due to ON DELETE CASCADE, we only need to delete from exercises
const result = await this.db.runAsync('DELETE FROM exercises WHERE id = ?', [id]);
console.log('Delete result:', result);
});
} catch (error) {
console.error('Error deleting exercise:', error);
throw error;
}
}
async searchExercises(query?: string, filters?: {
types?: ExerciseType[];
categories?: ExerciseCategory[];
equipment?: Equipment[];
tags?: string[];
source?: 'local' | 'powr' | 'nostr';
}): Promise<Exercise[]> {
try {
let sql = `
SELECT DISTINCT e.*
FROM exercises e
LEFT JOIN exercise_tags et ON e.id = et.exercise_id
WHERE 1=1
`;
const params: any[] = [];
// Add search condition
if (query) {
sql += ' AND (e.title LIKE ? OR e.description LIKE ?)';
params.push(`%${query}%`, `%${query}%`);
}
// Add filter conditions
if (filters?.types?.length) {
sql += ` AND e.type IN (${filters.types.map(() => '?').join(',')})`;
params.push(...filters.types);
}
if (filters?.categories?.length) {
sql += ` AND e.category IN (${filters.categories.map(() => '?').join(',')})`;
params.push(...filters.categories);
}
if (filters?.equipment?.length) {
sql += ` AND e.equipment IN (${filters.equipment.map(() => '?').join(',')})`;
params.push(...filters.equipment);
}
if (filters?.source) {
sql += ' AND e.source = ?';
params.push(filters.source);
}
// Handle tag filtering
if (filters?.tags?.length) {
sql += `
AND e.id IN (
SELECT exercise_id
FROM exercise_tags
WHERE tag IN (${filters.tags.map(() => '?').join(',')})
GROUP BY exercise_id
HAVING COUNT(DISTINCT tag) = ?
)
`;
params.push(...filters.tags, filters.tags.length);
}
// Add ordering
sql += ' ORDER BY e.title ASC';
// Get exercises
const exercises = await this.db.getAllAsync<Exercise>(sql, params);
// Get tags for all exercises
const exerciseIds = exercises.map(e => e.id);
if (exerciseIds.length) {
const tags = await this.db.getAllAsync<{ exercise_id: string; tag: string }>(
`SELECT exercise_id, tag
FROM exercise_tags
WHERE exercise_id IN (${exerciseIds.map(() => '?').join(',')})`,
exerciseIds
);
// Group tags by exercise
const tagsByExercise = tags.reduce((acc, { exercise_id, tag }) => {
acc[exercise_id] = acc[exercise_id] || [];
acc[exercise_id].push(tag);
return acc;
}, {} as Record<string, string[]>);
// Add tags to exercises
return exercises.map(exercise => ({
...exercise,
format: exercise.format_json ? JSON.parse(exercise.format_json) : undefined,
format_units: exercise.format_units_json ? JSON.parse(exercise.format_units_json) : undefined,
tags: tagsByExercise[exercise.id] || []
}));
}
return exercises;
} catch (error) {
console.error('Error searching exercises:', error);
throw error;
}
}
async getRecentExercises(limit: number = 10): Promise<Exercise[]> {
try {
const sql = `
SELECT e.*
FROM exercises e
ORDER BY e.updated_at DESC
LIMIT ?
`;
const exercises = await this.db.getAllAsync<Exercise>(sql, [limit]);
// Get tags for these exercises
const exerciseIds = exercises.map(e => e.id);
if (exerciseIds.length) {
const tags = await this.db.getAllAsync<{ exercise_id: string; tag: string }>(
`SELECT exercise_id, tag
FROM exercise_tags
WHERE exercise_id IN (${exerciseIds.map(() => '?').join(',')})`,
exerciseIds
);
// Group tags by exercise
const tagsByExercise = tags.reduce((acc, { exercise_id, tag }) => {
acc[exercise_id] = acc[exercise_id] || [];
acc[exercise_id].push(tag);
return acc;
}, {} as Record<string, string[]>);
return exercises.map(exercise => ({
...exercise,
format: exercise.format_json ? JSON.parse(exercise.format_json) : undefined,
format_units: exercise.format_units_json ? JSON.parse(exercise.format_units_json) : undefined,
tags: tagsByExercise[exercise.id] || []
}));
}
return exercises;
} catch (error) {
console.error('Error getting recent exercises:', error);
throw error;
}
}
async getExerciseTags(): Promise<{ tag: string; count: number }[]> {
try {
return await this.db.getAllAsync<{ tag: string; count: number }>(
`SELECT tag, COUNT(*) as count
FROM exercise_tags
GROUP BY tag
ORDER BY count DESC, tag ASC`
);
} catch (error) {
console.error('Error getting exercise tags:', error);
throw error;
}
}
async bulkImport(exercises: Omit<Exercise, 'id'>[]): Promise<string[]> {
const ids: string[] = [];
try {
await this.db.withTransactionAsync(async () => {
for (const exercise of exercises) {
const id = await this.createExercise(exercise);
ids.push(id);
}
});
return ids;
} catch (error) {
console.error('Error bulk importing exercises:', error);
throw error;
}
}
// Helper method to sync with Nostr events
async syncWithNostrEvent(eventId: string, exercise: Omit<Exercise, 'id'>): Promise<string> {
try {
// Check if we already have this exercise
const existing = await this.db.getFirstAsync<{ id: string }>(
'SELECT id FROM exercises WHERE nostr_event_id = ?',
[eventId]
);
if (existing) {
// Update existing exercise
await this.updateExercise(existing.id, exercise);
return existing.id;
} else {
// Create new exercise with Nostr reference
const id = generateId();
const timestamp = Date.now();
await this.db.withTransactionAsync(async () => {
await this.db.runAsync(
`INSERT INTO exercises (
id, nostr_event_id, title, type, category, equipment, description,
format_json, format_units_json, created_at, updated_at, source
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
id,
eventId,
exercise.title,
exercise.type,
exercise.category,
exercise.equipment || null,
exercise.description || null,
exercise.format ? JSON.stringify(exercise.format) : null,
exercise.format_units ? JSON.stringify(exercise.format_units) : null,
timestamp,
timestamp,
'nostr'
]
);
if (exercise.tags?.length) {
for (const tag of exercise.tags) {
await this.db.runAsync(
'INSERT INTO exercise_tags (exercise_id, tag) VALUES (?, ?)',
[id, tag]
);
}
}
});
return id;
}
} catch (error) {
console.error('Error syncing exercise with Nostr event:', error);
throw error;
}
}
}
// Helper function to create an instance
export const createExerciseService = (db: SQLiteDatabase) => new ExerciseService(db);
// Also export a type for the service if needed
export type ExerciseServiceType = ExerciseService;

36
lib/db/types.ts Normal file
View File

@ -0,0 +1,36 @@
import { SQLiteDatabase } from 'expo-sqlite';
export interface SQLTransaction {
executeSql: (
sqlStatement: string,
args?: any[],
callback?: (transaction: SQLTransaction, resultSet: SQLResultSet) => void,
errorCallback?: (transaction: SQLTransaction, error: SQLError) => boolean
) => void;
}
export interface SQLResultSet {
insertId?: number;
rowsAffected: number;
rows: {
length: number;
item: (index: number) => any;
_array: any[];
};
}
export interface SQLError {
code: number;
message: string;
}
// Extend the SQLiteDatabase type to include transaction methods
declare module 'expo-sqlite' {
interface SQLiteDatabase {
transaction(
callback: (transaction: SQLTransaction) => void,
errorCallback?: (error: SQLError) => void,
successCallback?: () => void
): void;
}
}

View File

@ -15,6 +15,15 @@ export type Equipment =
| 'cable' | 'cable'
| 'other'; | 'other';
export interface Exercise extends BaseExercise {
source: 'local' | 'powr' | 'nostr';
usageCount?: number;
lastUsed?: Date;
format_json?: string; // For database storage
format_units_json?: string; // For database storage
nostr_event_id?: string; // For Nostr integration
}
// Base library content interface // Base library content interface
export interface LibraryContent extends SyncableContent { export interface LibraryContent extends SyncableContent {
title: string; title: string;
@ -26,7 +35,7 @@ export interface LibraryContent extends SyncableContent {
}; };
category?: ExerciseCategory; category?: ExerciseCategory;
equipment?: Equipment; equipment?: Equipment;
source: 'local' | 'pow' | 'nostr'; source: 'local' | 'powr' | 'nostr';
tags: string[]; tags: string[];
isPublic?: boolean; isPublic?: boolean;
} }

View File

@ -2,7 +2,7 @@
/** /**
* Available storage sources for content * Available storage sources for content
*/ */
export type StorageSource = 'local' | 'backup' | 'nostr'; export type StorageSource = 'local' | 'powr' | 'nostr';
/** /**
* Nostr sync metadata * Nostr sync metadata