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
import React, { useState } from 'react';
import { View, ScrollView } from 'react-native';
import React, { useState, useEffect } from 'react';
import { View, ScrollView, SectionList } from 'react-native';
import { Text } from '@/components/ui/text';
import { ExerciseCard } from '@/components/exercises/ExerciseCard';
import { FloatingActionButton } from '@/components/shared/FloatingActionButton';
import { NewExerciseSheet } from '@/components/library/NewExerciseSheet';
import { Dumbbell } from 'lucide-react-native';
import { Exercise } from '@/types/library';
import { generateId } from '@/utils/ids';
import DatabaseDebug from '@/components/DatabaseDebug'; // Add this import
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.',
},
];
import { Exercise, BaseExercise } from '@/types/exercise';
import { useSQLiteContext } from 'expo-sqlite';
import { ExerciseService } from '@/lib/db/services/ExerciseService';
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 handleAddExercise = (exerciseData: Omit<Exercise, 'id' | 'source'>) => {
const newExercise: Exercise = {
...exerciseData,
id: generateId(),
source: 'local',
};
setExercises(prev => [...prev, newExercise]);
setShowNewExercise(false);
useEffect(() => {
loadExercises();
}, []);
const loadExercises = async () => {
try {
const loadedExercises = await exerciseService.getAllExercises();
setExercises(loadedExercises);
} catch (error) {
console.error('Error loading exercises:', error);
}
};
// Get recent exercises
const recentExercises = exercises.slice(0, 2);
const handleAddExercise = async (exerciseData: BaseExercise) => {
try {
await exerciseService.createExercise({
...exerciseData,
created_at: Date.now(),
source: 'local'
});
await loadExercises();
setShowNewExercise(false);
} catch (error) {
console.error('Error adding exercise:', error);
}
};
const handleDelete = (id: string) => {
setExercises(current => current.filter(ex => ex.id !== id));
const handleDelete = async (id: string) => {
try {
await exerciseService.deleteExercise(id);
await loadExercises();
} catch (error) {
console.error('Error deleting exercise:', error);
}
};
const handleExercisePress = (exerciseId: string) => {
console.log('Selected exercise:', exerciseId);
};
const alphabet = '#ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
return (
<View className="flex-1 bg-background">
{__DEV__ && <DatabaseDebug />} {/* Only show in development */}
<ScrollView className="flex-1">
{/* Recent Exercises Section */}
<View className="py-4">
<Text className="text-lg font-semibold mb-4 px-4">Recent Exercises</Text>
<View className="gap-3">
{recentExercises.map(exercise => (
<ExerciseCard
key={exercise.id}
{...exercise}
onPress={() => handleExercisePress(exercise.id)}
onDelete={() => handleDelete(exercise.id)}
/>
))}
</View>
</View>
<View className="absolute right-0 top-0 bottom-0 w-6 z-10 justify-center bg-transparent">
{alphabet.map((letter) => (
<Text
key={letter}
className="text-xs text-muted-foreground text-center"
onPress={() => {
// TODO: Implement scroll to section
console.log('Scroll to:', letter);
}}
>
{letter}
</Text>
))}
</View>
{/* All Exercises Section */}
<ScrollView className="flex-1 mr-6">
<View className="py-4">
<Text className="text-lg font-semibold mb-4 px-4">All Exercises</Text>
<View className="gap-3">

View File

@ -1,11 +1,282 @@
// app/(tabs)/library/(tabs)/programs.tsx
import { View } from 'react-native';
// app/(tabs)/library/programs.tsx
import React, { useState, useEffect } from 'react';
import { View, ScrollView } from 'react-native';
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() {
const db = useSQLiteContext();
const [dbStatus, setDbStatus] = useState<{
initialized: boolean;
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 (
<View className="flex-1 items-center justify-center">
<Text>Programs (Coming Soon)</Text>
</View>
<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 { useSQLiteContext } from 'expo-sqlite';
import { ExerciseType, ExerciseCategory, Equipment } from '@/types/exercise';
import { SQLTransaction, SQLResultSet, SQLError } from '@/lib/db/types';
interface TableInfo {
name: string;
@ -15,6 +16,20 @@ 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;
source: string;
format_json: string;
format_units_json: string;
}
export default function DatabaseDebug() {
const db = useSQLiteContext();
const [dbStatus, setDbStatus] = useState<{
@ -52,6 +67,7 @@ export default function DatabaseDebug() {
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',
@ -78,17 +94,17 @@ export default function DatabaseDebug() {
reps: "count" as const
}
};
const timestamp = Date.now();
// Insert exercise using withTransactionAsync
await db.withTransactionAsync(async () => {
const timestamp = Date.now();
// Insert exercise
await db.runAsync(
`INSERT INTO exercises (
id, title, type, category, equipment, description,
created_at, updated_at, source,
format_json, format_units_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
format_json, format_units_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
'test-1',
testExercise.title,
@ -96,14 +112,13 @@ export default function DatabaseDebug() {
testExercise.category,
testExercise.equipment || null,
testExercise.description || null,
timestamp,
timestamp,
'local',
JSON.stringify(testExercise.format),
JSON.stringify(testExercise.format_units)
JSON.stringify(testExercise.format_units),
timestamp,
timestamp
]
);
// Insert tags
for (const tag of testExercise.tags) {
await db.runAsync(
@ -112,18 +127,20 @@ export default function DatabaseDebug() {
);
}
});
// Verify insert
const result = await db.getFirstAsync(
"SELECT * FROM exercises WHERE id = ?",
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'

View File

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

View File

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

View File

@ -1,11 +1,25 @@
// lib/db/schema.ts
import { SQLiteDatabase } from 'expo-sqlite';
export const SCHEMA_VERSION = 1;
export const SCHEMA_VERSION = 2;
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> {
try {
// Version tracking
const currentVersion = await this.getCurrentVersion(db);
// Schema version tracking
await db.execAsync(`
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
@ -13,43 +27,87 @@ class Schema {
);
`);
// Exercise Definitions
await db.execAsync(`
CREATE TABLE IF NOT EXISTS exercises (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
type TEXT NOT NULL,
category TEXT NOT NULL,
equipment TEXT,
description TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
`);
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(`
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'
);
`);
// Exercise Tags
await db.execAsync(`
CREATE TABLE IF NOT EXISTS 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)
);
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 INDEX IF NOT EXISTS idx_exercise_tags ON exercise_tags(tag);
`);
// Set initial schema version if not exists
const version = await db.getFirstAsync<{ version: number }>(
'SELECT version FROM schema_version ORDER BY version DESC LIMIT 1'
);
if (!version) {
// Set initial version
await db.runAsync(
'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) {
console.error('[Schema] Error creating tables:', 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'
| '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
export interface LibraryContent extends SyncableContent {
title: string;
@ -26,7 +35,7 @@ export interface LibraryContent extends SyncableContent {
};
category?: ExerciseCategory;
equipment?: Equipment;
source: 'local' | 'pow' | 'nostr';
source: 'local' | 'powr' | 'nostr';
tags: string[];
isPublic?: boolean;
}

View File

@ -2,7 +2,7 @@
/**
* Available storage sources for content
*/
export type StorageSource = 'local' | 'backup' | 'nostr';
export type StorageSource = 'local' | 'powr' | 'nostr';
/**
* Nostr sync metadata
@ -51,4 +51,4 @@ export interface ContentMetadata {
export interface SyncableContent extends ContentMetadata {
id: string;
availability: ContentAvailability;
}
}