sqlite db implementation with development seeding and mock exercise library and more

This commit is contained in:
DocNR 2025-02-17 13:40:37 -05:00
parent 76433b93e6
commit 7fd62bce37
9 changed files with 672 additions and 19 deletions

View File

@ -8,6 +8,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### Added
- SQLite database implementation with development seeding
- Successfully integrated SQLite with proper transaction handling
- Added mock exercise library with 10 initial exercises
- Implemented development database seeder
- Added debug logging for database operations
- Event caching system for future Nostr integration
- Added EventCache service for Nostr event handling
- Implemented proper transaction management
- Added cache metadata tracking
- Database schema improvements
- Added nostr_events and event_tags tables
- Added cache_metadata table for performance optimization
- Added exercise_media table for future media support
- Alphabetical quick scroll in exercise library - Alphabetical quick scroll in exercise library
- Dynamic letter highlighting for available sections - Dynamic letter highlighting for available sections
- Smooth scrolling to selected sections - Smooth scrolling to selected sections
@ -40,7 +53,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Exercise deletion functionality - Exercise deletion functionality
- Keyboard overlap issues in exercise creation form - Keyboard overlap issues in exercise creation form
- SQLite transaction handling for exercise operations - SQLite transaction nesting issues
- TypeScript parameter typing in database services
- Null value handling in database operations
- Development seeding duplicate prevention
### Technical Details ### Technical Details
1. Database Schema Enforcement: 1. Database Schema Enforcement:
@ -61,6 +77,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved error propagation in LibraryService - Improved error propagation in LibraryService
- Added transaction rollback on constraint violations - Added transaction rollback on constraint violations
4. Database Services:
- Added EventCache service for Nostr events
- Improved ExerciseService with transaction awareness
- Added DevSeederService for development data
- Enhanced error handling and logging
### Migration Notes ### Migration Notes
- Exercise creation now enforces schema constraints - Exercise creation now enforces schema constraints
- Input validation prevents invalid data entry - Input validation prevents invalid data entry

View File

@ -1,7 +1,25 @@
// components/DatabaseProvider.tsx
import React from 'react'; import React from 'react';
import { View, ActivityIndicator, Text } from 'react-native'; import { View, ActivityIndicator, Text } from 'react-native';
import { SQLiteProvider, openDatabaseSync } from 'expo-sqlite'; import { SQLiteProvider, openDatabaseSync, SQLiteDatabase } from 'expo-sqlite';
import { schema } from '@/lib/db/schema'; import { schema } from '@/lib/db/schema';
import { ExerciseService } from '@/lib/db/services/ExerciseService';
import { EventCache } from '@/lib/db/services/EventCache';
import { DevSeederService } from '@/lib/db/services/DevSeederService';
import { logDatabaseInfo } from '@/lib/db/debug';
// Create context for services
interface DatabaseServicesContextValue {
exerciseService: ExerciseService | null;
eventCache: EventCache | null;
devSeeder: DevSeederService | null;
}
const DatabaseServicesContext = React.createContext<DatabaseServicesContextValue>({
exerciseService: null,
eventCache: null,
devSeeder: null,
});
interface DatabaseProviderProps { interface DatabaseProviderProps {
children: React.ReactNode; children: React.ReactNode;
@ -10,6 +28,11 @@ interface DatabaseProviderProps {
export function DatabaseProvider({ children }: DatabaseProviderProps) { export function DatabaseProvider({ children }: DatabaseProviderProps) {
const [isReady, setIsReady] = React.useState(false); const [isReady, setIsReady] = React.useState(false);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [services, setServices] = React.useState<DatabaseServicesContextValue>({
exerciseService: null,
eventCache: null,
devSeeder: null,
});
React.useEffect(() => { React.useEffect(() => {
async function initDatabase() { async function initDatabase() {
@ -20,6 +43,26 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) {
console.log('[DB] Creating schema...'); console.log('[DB] Creating schema...');
await schema.createTables(db); await schema.createTables(db);
// Initialize services
console.log('[DB] Initializing services...');
const eventCache = new EventCache(db);
const exerciseService = new ExerciseService(db);
const devSeeder = new DevSeederService(db, exerciseService, eventCache);
// Set services
setServices({
exerciseService,
eventCache,
devSeeder,
});
// Seed development database
if (__DEV__) {
console.log('[DB] Seeding development database...');
await devSeeder.seedDatabase();
await logDatabaseInfo();
}
console.log('[DB] Database initialized successfully'); console.log('[DB] Database initialized successfully');
setIsReady(true); setIsReady(true);
} catch (e) { } catch (e) {
@ -51,7 +94,34 @@ export function DatabaseProvider({ children }: DatabaseProviderProps) {
return ( return (
<SQLiteProvider databaseName="powr.db"> <SQLiteProvider databaseName="powr.db">
{children} <DatabaseServicesContext.Provider value={services}>
{children}
</DatabaseServicesContext.Provider>
</SQLiteProvider> </SQLiteProvider>
); );
} }
// Hooks for accessing services
export function useExerciseService() {
const context = React.useContext(DatabaseServicesContext);
if (!context.exerciseService) {
throw new Error('Exercise service not initialized');
}
return context.exerciseService;
}
export function useEventCache() {
const context = React.useContext(DatabaseServicesContext);
if (!context.eventCache) {
throw new Error('Event cache not initialized');
}
return context.eventCache;
}
export function useDevSeeder() {
const context = React.useContext(DatabaseServicesContext);
if (!context.devSeeder) {
throw new Error('Dev seeder not initialized');
}
return context.devSeeder;
}

View File

@ -2,12 +2,11 @@
import { SQLiteDatabase } from 'expo-sqlite'; import { SQLiteDatabase } from 'expo-sqlite';
import { Platform } from 'react-native'; import { Platform } from 'react-native';
export const SCHEMA_VERSION = 2; export const SCHEMA_VERSION = 3; // Incrementing version for new tables
class Schema { class Schema {
private async getCurrentVersion(db: SQLiteDatabase): Promise<number> { private async getCurrentVersion(db: SQLiteDatabase): Promise<number> {
try { try {
// First check if the table exists
const tableExists = await db.getFirstAsync<{ count: number }>( const tableExists = await db.getFirstAsync<{ count: number }>(
`SELECT count(*) as count FROM sqlite_master `SELECT count(*) as count FROM sqlite_master
WHERE type='table' AND name='schema_version'` WHERE type='table' AND name='schema_version'`
@ -26,7 +25,7 @@ class Schema {
return version?.version ?? 0; return version?.version ?? 0;
} catch (error) { } catch (error) {
console.log('[Schema] Error getting version:', error); console.log('[Schema] Error getting version:', error);
return 0; // If table doesn't exist yet return 0;
} }
} }
@ -43,7 +42,7 @@ class Schema {
); );
`); `);
if (currentVersion === 0) { if (currentVersion < 1) {
console.log('[Schema] Performing fresh install'); console.log('[Schema] Performing fresh install');
// Drop existing tables if they exist // Drop existing tables if they exist
@ -79,7 +78,6 @@ class Schema {
CREATE INDEX idx_exercise_tags ON exercise_tags(tag); CREATE INDEX idx_exercise_tags ON exercise_tags(tag);
`); `);
// Set initial version
await db.runAsync( await db.runAsync(
'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)', 'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)',
[1, Date.now()] [1, Date.now()]
@ -88,7 +86,7 @@ class Schema {
console.log('[Schema] Base tables created successfully'); console.log('[Schema] Base tables created successfully');
} }
// Update to version 2 if needed // Update to version 2 if needed - Nostr support
if (currentVersion < 2) { if (currentVersion < 2) {
console.log('[Schema] Upgrading to version 2'); console.log('[Schema] Upgrading to version 2');
@ -116,7 +114,7 @@ class Schema {
CREATE INDEX IF NOT EXISTS idx_event_tags ON event_tags(name, value); CREATE INDEX IF NOT EXISTS idx_event_tags ON event_tags(name, value);
`); `);
// Add Nostr reference to exercises if not exists // Add Nostr reference to exercises
try { try {
await db.execAsync(`ALTER TABLE exercises ADD COLUMN nostr_event_id TEXT REFERENCES nostr_events(id)`); await db.execAsync(`ALTER TABLE exercises ADD COLUMN nostr_event_id TEXT REFERENCES nostr_events(id)`);
} catch (e) { } catch (e) {
@ -131,6 +129,41 @@ class Schema {
console.log('[Schema] Version 2 upgrade completed'); console.log('[Schema] Version 2 upgrade completed');
} }
// Update to version 3 if needed - Event Cache
if (currentVersion < 3) {
console.log('[Schema] Upgrading to version 3');
// Create cache metadata table
await db.execAsync(`
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS 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
);
`);
await db.runAsync(
'INSERT INTO schema_version (version, updated_at) VALUES (?, ?)',
[3, Date.now()]
);
console.log('[Schema] Version 3 upgrade completed');
}
// Verify final schema // Verify final schema
const tables = await db.getAllAsync<{ name: string }>( const tables = await db.getAllAsync<{ name: string }>(
"SELECT name FROM sqlite_master WHERE type='table'" "SELECT name FROM sqlite_master WHERE type='table'"

View File

@ -0,0 +1,97 @@
// lib/db/services/DevSeederService.ts
import { SQLiteDatabase } from 'expo-sqlite';
import { ExerciseService } from './ExerciseService';
import { EventCache } from './EventCache';
import { logDatabaseInfo } from '../debug';
import { mockExerciseEvents, convertNostrToExercise } from '../../mocks/exercises';
export class DevSeederService {
private db: SQLiteDatabase;
private exerciseService: ExerciseService;
private eventCache: EventCache;
constructor(
db: SQLiteDatabase,
exerciseService: ExerciseService,
eventCache: EventCache
) {
this.db = db;
this.exerciseService = exerciseService;
this.eventCache = eventCache;
}
async seedDatabase() {
if (!__DEV__) return;
try {
console.log('Starting development database seeding...');
// Log initial database state
await logDatabaseInfo();
// Check if we already have exercises
const existingCount = (await this.exerciseService.getAllExercises()).length;
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 event of mockExerciseEvents) {
// Pass true to indicate we're in a transaction
await this.eventCache.setEvent(event, true);
const exercise = convertNostrToExercise(event);
await this.exerciseService.createExercise(exercise, true);
}
console.log('Successfully seeded', mockExerciseEvents.length, 'exercises');
});
// Log final database state
await logDatabaseInfo();
} catch (error) {
console.error('Error seeding database:', error);
throw error;
}
}
async clearDatabase() {
if (!__DEV__) return;
try {
console.log('Clearing development database...');
await this.db.withTransactionAsync(async () => {
const tables = [
'exercises',
'exercise_tags',
'nostr_events',
'event_tags',
'cache_metadata'
];
for (const table of tables) {
await this.db.runAsync(`DELETE FROM ${table}`);
}
});
console.log('Successfully cleared database');
} catch (error) {
console.error('Error clearing database:', error);
throw error;
}
}
async resetDatabase() {
if (!__DEV__) return;
await this.clearDatabase();
await this.seedDatabase();
}
}

View File

@ -0,0 +1,115 @@
// lib/db/services/EventCache.ts
import { SQLiteDatabase } from 'expo-sqlite';
import { NostrEvent } from '@/types/nostr';
export class EventCache {
private db: SQLiteDatabase;
private writeBuffer: { query: string; params: any[] }[] = [];
constructor(db: SQLiteDatabase) {
this.db = db;
}
async setEvent(event: NostrEvent, inTransaction: boolean = false): Promise<void> {
try {
// Store queries to execute
const queries = [
{
query: `INSERT OR REPLACE INTO nostr_events
(id, pubkey, kind, created_at, content, sig, raw_event, received_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
params: [
event.id || '', // Convert undefined to empty string
event.pubkey || '',
event.kind,
event.created_at,
event.content,
event.sig || '',
JSON.stringify(event),
Date.now()
]
},
// Add metadata query
{
query: `INSERT OR REPLACE INTO cache_metadata
(content_id, content_type, last_accessed, access_count)
VALUES (?, ?, ?, 1)
ON CONFLICT(content_id) DO UPDATE SET
last_accessed = ?,
access_count = access_count + 1`,
params: [
event.id || '',
'event',
Date.now(),
Date.now()
]
}
];
// Add tag queries
event.tags.forEach((tag, index) => {
queries.push({
query: `INSERT OR REPLACE INTO event_tags
(event_id, name, value, index_num)
VALUES (?, ?, ?, ?)`,
params: [
event.id || '',
tag[0] || '',
tag[1] || '',
index
]
});
});
// If we're already in a transaction, just execute the queries
if (inTransaction) {
for (const { query, params } of queries) {
await this.db.runAsync(query, params);
}
} else {
// Otherwise, wrap in our own transaction
await this.db.withTransactionAsync(async () => {
for (const { query, params } of queries) {
await this.db.runAsync(query, params);
}
});
}
} catch (error) {
console.error('Error caching event:', error);
throw error;
}
}
async getEvent(id: string): Promise<NostrEvent | null> {
try {
const event = await this.db.getFirstAsync<any>(
`SELECT * FROM nostr_events WHERE id = ?`,
[id]
);
if (!event) return null;
// Get tags
const tags = await this.db.getAllAsync<{ name: string; value: string }>(
`SELECT name, value FROM event_tags WHERE event_id = ? ORDER BY index_num`,
[id]
);
// Update access metadata
await this.db.runAsync(
`UPDATE cache_metadata
SET last_accessed = ?, access_count = access_count + 1
WHERE content_id = ?`,
[Date.now(), id]
);
return {
...event,
tags: tags.map(tag => [tag.name, tag.value])
};
} catch (error) {
console.error('Error getting event from cache:', error);
return null;
}
}
}

View File

@ -43,12 +43,15 @@ export class ExerciseService {
} }
// Update createExercise to handle all required fields // Update createExercise to handle all required fields
async createExercise(exercise: Omit<Exercise, 'id' | 'availability'>): Promise<string> { async createExercise(
exercise: Omit<Exercise, 'id' | 'availability'>,
inTransaction: boolean = false
): Promise<string> {
const id = generateId(); const id = generateId();
const timestamp = Date.now(); const timestamp = Date.now();
try { try {
await this.db.withTransactionAsync(async () => { const runQueries = async () => {
await this.db.runAsync( await this.db.runAsync(
`INSERT INTO exercises ( `INSERT INTO exercises (
id, title, type, category, equipment, description, id, title, type, category, equipment, description,
@ -77,7 +80,13 @@ export class ExerciseService {
); );
} }
} }
}); };
if (inTransaction) {
await runQueries();
} else {
await this.db.withTransactionAsync(runQueries);
}
return id; return id;
} catch (error) { } catch (error) {

277
lib/mocks/exercises.ts Normal file
View File

@ -0,0 +1,277 @@
// lib/mocks/exercises.ts
import { NostrEvent } from '@/types/nostr';
import { Exercise, ExerciseType, ExerciseCategory, Equipment } from '@/types/exercise';
import { generateId } from '@/utils/ids';
// Mock exercise definitions that will become our initial POWR library
export const mockExerciseEvents: NostrEvent[] = [
{
kind: 33401,
content: "Stand with feet hip-width apart, barbell racked on shoulders. Bend knees and hips to squat down, keeping chest up. Drive through heels to stand.",
tags: [
["d", "bb-back-squat"],
["title", "Barbell Back Squat"],
["format", "weight", "reps", "rpe", "set_type"],
["format_units", "kg", "count", "0-10", "warmup|normal|drop|failure"],
["equipment", "barbell"],
["difficulty", "intermediate"],
["category", "legs"],
["t", "compound"],
["t", "squat"],
["t", "legs"],
["t", "quadriceps"]
],
created_at: 1708300800, // Feb 19, 2024
id: generateId('nostr'),
pubkey: "powr", // We'll update this when we create the POWR relay
sig: undefined
},
{
kind: 33401,
content: "Stand with feet shoulder-width apart, barbell on floor. Hinge at hips, grip bar outside knees. Keep back flat, drive through heels to lift.",
tags: [
["d", "bb-deadlift"],
["title", "Barbell Deadlift"],
["format", "weight", "reps", "rpe", "set_type"],
["format_units", "kg", "count", "0-10", "warmup|normal|drop|failure"],
["equipment", "barbell"],
["difficulty", "intermediate"],
["category", "legs"],
["t", "compound"],
["t", "hinge"],
["t", "legs"],
["t", "posterior"]
],
created_at: 1708300800,
id: generateId('nostr'),
pubkey: "powr",
sig: undefined
},
{
kind: 33401,
content: "Lie on bench, feet flat on floor. Grip barbell slightly wider than shoulders. Lower bar to chest, press back up to start.",
tags: [
["d", "bb-bench-press"],
["title", "Barbell Bench Press"],
["format", "weight", "reps", "rpe", "set_type"],
["format_units", "kg", "count", "0-10", "warmup|normal|drop|failure"],
["equipment", "barbell"],
["difficulty", "intermediate"],
["category", "push"],
["t", "compound"],
["t", "push"],
["t", "chest"],
["t", "triceps"]
],
created_at: 1708300800,
id: generateId('nostr'),
pubkey: "powr",
sig: undefined
},
{
kind: 33401,
content: "Start in plank position. Lower body by bending elbows, keeping body straight. Push back up to start position.",
tags: [
["d", "pushup"],
["title", "Push-Up"],
["format", "reps", "set_type"],
["format_units", "count", "warmup|normal|drop|failure"],
["equipment", "bodyweight"],
["difficulty", "beginner"],
["category", "push"],
["t", "bodyweight"],
["t", "push"],
["t", "chest"],
["t", "triceps"]
],
created_at: 1708300800,
id: generateId('nostr'),
pubkey: "powr",
sig: undefined
},
{
kind: 33401,
content: "Hang from pull-up bar with overhand grip. Pull body up until chin clears bar, lower back to start.",
tags: [
["d", "pullup"],
["title", "Pull-Up"],
["format", "reps", "set_type"],
["format_units", "count", "warmup|normal|drop|failure"],
["equipment", "bodyweight"],
["difficulty", "intermediate"],
["category", "pull"],
["t", "bodyweight"],
["t", "pull"],
["t", "back"],
["t", "biceps"]
],
created_at: 1708300800,
id: generateId('nostr'),
pubkey: "powr",
sig: undefined
},
{
kind: 33401,
content: "Sit at machine, grip handles at shoulder height. Press handles up overhead, return to start position.",
tags: [
["d", "shoulder-press-machine"],
["title", "Shoulder Press Machine"],
["format", "weight", "reps", "set_type"],
["format_units", "kg", "count", "warmup|normal|drop|failure"],
["equipment", "machine"],
["difficulty", "beginner"],
["category", "push"],
["t", "machine"],
["t", "push"],
["t", "shoulders"],
["t", "triceps"]
],
created_at: 1708300800,
id: generateId('nostr'),
pubkey: "powr",
sig: undefined
},
{
kind: 33401,
content: "Stand with dumbbell in each hand at sides. Curl weights toward shoulders, keeping elbows close to body. Lower back down.",
tags: [
["d", "db-bicep-curl"],
["title", "Dumbbell Bicep Curl"],
["format", "weight", "reps", "set_type"],
["format_units", "kg", "count", "warmup|normal|drop|failure"],
["equipment", "dumbbell"],
["difficulty", "beginner"],
["category", "pull"],
["t", "isolation"],
["t", "pull"],
["t", "biceps"]
],
created_at: 1708300800,
id: generateId('nostr'),
pubkey: "powr",
sig: undefined
},
{
kind: 33401,
content: "Attach rope to cable machine at top. Grip ends, pull down to chest level keeping elbows close. Control return.",
tags: [
["d", "cable-tricep-pushdown"],
["title", "Cable Tricep Pushdown"],
["format", "weight", "reps", "set_type"],
["format_units", "kg", "count", "warmup|normal|drop|failure"],
["equipment", "cable"],
["difficulty", "beginner"],
["category", "push"],
["t", "isolation"],
["t", "push"],
["t", "triceps"]
],
created_at: 1708300800,
id: generateId('nostr'),
pubkey: "powr",
sig: undefined
},
{
kind: 33401,
content: "Kneel before cable machine, rope attachment at bottom. Pull rope toward forehead, keeping upper arms still. Lower with control.",
tags: [
["d", "cable-face-pull"],
["title", "Cable Face Pull"],
["format", "weight", "reps", "set_type"],
["format_units", "kg", "count", "warmup|normal|drop|failure"],
["equipment", "cable"],
["difficulty", "intermediate"],
["category", "pull"],
["t", "isolation"],
["t", "pull"],
["t", "rear-deltoids"],
["t", "upper-back"]
],
created_at: 1708300800,
id: generateId('nostr'),
pubkey: "powr",
sig: undefined
},
{
kind: 33401,
content: "Stand with feet hip-width, holding kettlebell by horns at chest. Squat down keeping chest up, stand back up.",
tags: [
["d", "kb-goblet-squat"],
["title", "Kettlebell Goblet Squat"],
["format", "weight", "reps", "set_type"],
["format_units", "kg", "count", "warmup|normal|drop|failure"],
["equipment", "kettlebell"],
["difficulty", "beginner"],
["category", "legs"],
["t", "compound"],
["t", "squat"],
["t", "legs"],
["t", "quadriceps"]
],
created_at: 1708300800,
id: generateId('nostr'),
pubkey: "powr",
sig: undefined
}
];
function getTagValue(tags: string[][], name: string): string | undefined {
const tag = tags.find((tag: string[]) => tag[0] === name);
return tag ? tag[1] : undefined;
}
function getTags(tags: string[][]): string[] {
return tags
.filter((tag: string[]) => tag[0] === 't')
.map((tag: string[]) => tag[1]);
}
export function convertNostrToExercise(event: NostrEvent): Exercise {
return {
id: event.id || '',
title: getTagValue(event.tags, 'title') || '',
type: getTagValue(event.tags, 'equipment') === 'bodyweight'
? 'bodyweight'
: 'strength' as ExerciseType,
category: getTagValue(event.tags, 'category') as ExerciseCategory,
equipment: getTagValue(event.tags, 'equipment') as Equipment,
description: event.content,
format: getTagValue(event.tags, 'format')
?.split(',')
.reduce((acc: Record<string, boolean>, curr: string) => ({
...acc,
[curr]: true
}), {}),
format_units: getTagValue(event.tags, 'format_units')
?.split(',')
.reduce((acc: Record<string, string>, curr: string, i: number) => {
const format = getTagValue(event.tags, 'format')?.split(',')[i];
return format ? { ...acc, [format]: curr } : acc;
}, {}),
tags: getTags(event.tags),
availability: {
source: ['powr']
},
created_at: event.created_at * 1000,
source: 'powr'
};
}
// Export pre-converted exercises for easy testing
export const mockExercises = mockExerciseEvents.map(convertNostrToExercise);
// Helper to seed the database
export async function seedExercises(exerciseService: any) {
try {
const existingCount = (await exerciseService.getAllExercises()).length;
if (existingCount === 0) {
console.log('Seeding database with mock exercises...');
for (const exercise of mockExercises) {
await exerciseService.createExercise(exercise);
}
console.log('Successfully seeded database');
}
} catch (error) {
console.error('Error seeding database:', error);
}
}

View File

@ -4,9 +4,7 @@
"strict": true, "strict": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": [ "@/*": ["./*"]
"*"
]
} }
}, },
"include": [ "include": [

32
types/nostr.ts Normal file
View File

@ -0,0 +1,32 @@
// types/nostr.ts
export interface NostrEvent {
id?: string;
pubkey?: string;
content: string;
created_at: number;
kind: number;
tags: string[][];
sig?: string;
}
export enum NostrEventKind {
EXERCISE = 33401,
TEMPLATE = 33402,
WORKOUT = 33403
}
export interface NostrTag {
name: string;
value: string;
index?: number;
}
// Helper functions
export function getTagValue(tags: string[][], name: string): string | undefined {
const tag = tags.find(t => t[0] === name);
return tag ? tag[1] : undefined;
}
export function getTagValues(tags: string[][], name: string): string[] {
return tags.filter(t => t[0] === name).map(t => t[1]);
}