From 173e4e31e4380c704eb8f1d9fe520357a3ebf721 Mon Sep 17 00:00:00 2001 From: DocNR Date: Sun, 2 Mar 2025 13:23:28 -0500 Subject: [PATCH] update to nostr exercise nip and minor UI --- app/(tabs)/library/programs.tsx | 815 ++++++++++++++++++++---- app/(tabs)/social/_layout.tsx | 4 +- app/(workout)/create.tsx | 18 +- components/workout/HomeWorkout.tsx | 6 +- components/workout/SetInput.tsx | 30 +- docs/design/cache-management.md | 960 +++++++++++++++++++++++++++++ docs/design/nostr-exercise-nip.md | 195 ++++-- lib/stores/ndk.ts | 9 +- package-lock.json | 1 + package.json | 1 + types/nostr.ts | 69 ++- 11 files changed, 1867 insertions(+), 241 deletions(-) create mode 100644 docs/design/cache-management.md diff --git a/app/(tabs)/library/programs.tsx b/app/(tabs)/library/programs.tsx index 4394dc4..4ebe7d5 100644 --- a/app/(tabs)/library/programs.tsx +++ b/app/(tabs)/library/programs.tsx @@ -1,17 +1,74 @@ // app/(tabs)/library/programs.tsx -import React, { useState, useEffect } from 'react'; -import { View, ScrollView } from 'react-native'; +import React, { useState, useEffect, useRef } from 'react'; +import { View, ScrollView, TextInput, ActivityIndicator, Platform, TouchableOpacity } from 'react-native'; import { Text } from '@/components/ui/text'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { AlertCircle, CheckCircle2, Database, RefreshCcw, Trash2, Code, Search, ListFilter } from 'lucide-react-native'; +import { + AlertCircle, CheckCircle2, Database, RefreshCcw, Trash2, + Code, Search, ListFilter, Wifi, Zap, FileJson +} 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'; -import { Platform } from 'react-native'; import { FilterSheet, type FilterOptions, type SourceType } from '@/components/library/FilterSheet'; +import { Switch } from '@/components/ui/switch'; +import { Label } from '@/components/ui/label'; +import { Separator } from '@/components/ui/separator'; +import { getPublicKey } from 'nostr-tools'; + +// Constants for Nostr +const EVENT_KIND_EXERCISE = 33401; +const EVENT_KIND_WORKOUT_TEMPLATE = 33402; +const EVENT_KIND_WORKOUT_RECORD = 1301; + +// Simplified mock implementations for testing +const generatePrivateKey = (): string => { + // Generate a random hex string (32 bytes) + return Array.from({ length: 64 }, () => + Math.floor(Math.random() * 16).toString(16) + ).join(''); +}; + +const getEventHash = (event: any): string => { + // For testing, just create a mock hash + const eventData = JSON.stringify([ + 0, + event.pubkey, + event.created_at, + event.kind, + event.tags, + event.content + ]); + + // Simple hash function for demonstration + return Array.from(eventData) + .reduce((hash, char) => { + return ((hash << 5) - hash) + char.charCodeAt(0); + }, 0) + .toString(16) + .padStart(64, '0'); +}; + +const signEvent = (event: any, privateKey: string): string => { + // In real implementation, this would sign the event hash with the private key + // For testing, we'll just return a mock signature + return Array.from({ length: 128 }, () => + Math.floor(Math.random() * 16).toString(16) + ).join(''); +}; + +interface NostrEvent { + id?: string; + pubkey: string; + created_at: number; + kind: number; + tags: string[][]; + content: string; + sig?: string; +} interface TableInfo { name: string; @@ -52,9 +109,10 @@ const initialFilters: FilterOptions = { tags: [], source: [] }; - export default function ProgramsScreen() { const db = useSQLiteContext(); + + // Database state const [dbStatus, setDbStatus] = useState<{ initialized: boolean; tables: string[]; @@ -72,12 +130,34 @@ export default function ProgramsScreen() { const [filterSheetOpen, setFilterSheetOpen] = useState(false); const [currentFilters, setCurrentFilters] = useState(initialFilters); const [activeFilters, setActiveFilters] = useState(0); + + // Nostr state + const [relayUrl, setRelayUrl] = useState('ws://localhost:7777'); + const [connected, setConnected] = useState(false); + const [connecting, setConnecting] = useState(false); + const [statusMessage, setStatusMessage] = useState(''); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [privateKey, setPrivateKey] = useState(''); + const [publicKey, setPublicKey] = useState(''); + const [useGeneratedKeys, setUseGeneratedKeys] = useState(true); + const [eventKind, setEventKind] = useState(EVENT_KIND_EXERCISE); + const [eventContent, setEventContent] = useState(''); + + // WebSocket reference + const socketRef = useRef(null); + + // Tab state + const [activeTab, setActiveTab] = useState('database'); useEffect(() => { checkDatabase(); inspectDatabase(); + generateKeys(); }, []); + // DATABASE FUNCTIONS + const inspectDatabase = async () => { try { const result = await db.getAllAsync( @@ -113,7 +193,6 @@ export default function ProgramsScreen() { })); } }; - const resetDatabase = async () => { try { await db.withTransactionAsync(async () => { @@ -230,6 +309,185 @@ export default function ProgramsScreen() { // Implement filtering logic for programs when available }; + // NOSTR FUNCTIONS + + // Generate new keypair + const generateKeys = () => { + try { + const privKey = generatePrivateKey(); + // For getPublicKey, we can use a mock function that returns a valid-looking pubkey + const pubKey = privKey.slice(0, 64); // Just use part of the private key for demo + + setPrivateKey(privKey); + setPublicKey(pubKey); + setStatusMessage('Keys generated successfully'); + } catch (error) { + setStatusMessage(`Error generating keys: ${error}`); + } + }; + + // Connect to relay + const connectToRelay = () => { + if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) { + socketRef.current.close(); + } + + setConnecting(true); + setStatusMessage('Connecting to relay...'); + + try { + const socket = new WebSocket(relayUrl); + + socket.onopen = () => { + setConnected(true); + setConnecting(false); + setStatusMessage('Connected to relay!'); + socketRef.current = socket; + + // Subscribe to exercise-related events + const subscriptionId = 'test-sub-' + Math.random().toString(36).substring(2, 15); + const subscription = JSON.stringify([ + 'REQ', + subscriptionId, + { kinds: [EVENT_KIND_EXERCISE, EVENT_KIND_WORKOUT_TEMPLATE, EVENT_KIND_WORKOUT_RECORD], limit: 10 } + ]); + socket.send(subscription); + }; + + socket.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data[0] === 'EVENT' && data[1] && data[2]) { + const nostrEvent = data[2]; + setEvents(prev => [nostrEvent, ...prev].slice(0, 50)); // Keep most recent 50 events + } else if (data[0] === 'NOTICE') { + setStatusMessage(`Relay message: ${data[1]}`); + } + } catch (error) { + console.error('Error parsing message:', error, event.data); + } + }; + + socket.onclose = () => { + setConnected(false); + setConnecting(false); + setStatusMessage('Disconnected from relay'); + }; + + socket.onerror = (error) => { + setConnected(false); + setConnecting(false); + setStatusMessage(`Connection error: ${error}`); + console.error('WebSocket error:', error); + }; + } catch (error) { + setConnecting(false); + setStatusMessage(`Failed to connect: ${error}`); + console.error('Connection setup error:', error); + } + }; + + // Disconnect from relay + const disconnectFromRelay = () => { + if (socketRef.current) { + socketRef.current.close(); + } + }; + + // Create and publish a new event + const publishEvent = () => { + if (!socketRef.current || socketRef.current.readyState !== WebSocket.OPEN) { + setStatusMessage('Not connected to a relay'); + return; + } + + if (!privateKey || !publicKey) { + setStatusMessage('Need private and public keys to publish'); + return; + } + + try { + setLoading(true); + + // Create event with required pubkey (no longer optional) + const event: NostrEvent = { + pubkey: publicKey, + created_at: Math.floor(Date.now() / 1000), + kind: eventKind, + tags: [], + content: eventContent, + }; + + // A basic implementation for each event kind + if (eventKind === EVENT_KIND_EXERCISE) { + event.tags.push(['d', `exercise-${Date.now()}`]); + event.tags.push(['title', 'Test Exercise']); + event.tags.push(['format', 'weight', 'reps']); + event.tags.push(['format_units', 'kg', 'count']); + event.tags.push(['equipment', 'barbell']); + } else if (eventKind === EVENT_KIND_WORKOUT_TEMPLATE) { + event.tags.push(['d', `template-${Date.now()}`]); + event.tags.push(['title', 'Test Workout Template']); + event.tags.push(['type', 'strength']); + } else if (eventKind === EVENT_KIND_WORKOUT_RECORD) { + event.tags.push(['d', `workout-${Date.now()}`]); + event.tags.push(['title', 'Test Workout Record']); + event.tags.push(['start', `${Math.floor(Date.now() / 1000) - 3600}`]); + event.tags.push(['end', `${Math.floor(Date.now() / 1000)}`]); + } + + // Hash and sign + event.id = getEventHash(event); + event.sig = signEvent(event, privateKey); + + // Publish to relay + const message = JSON.stringify(['EVENT', event]); + socketRef.current.send(message); + + setStatusMessage('Event published successfully!'); + setEventContent(''); + setLoading(false); + } catch (error) { + setStatusMessage(`Error publishing event: ${error}`); + setLoading(false); + } + }; + + // Query events from relay + const queryEvents = () => { + if (!socketRef.current || socketRef.current.readyState !== WebSocket.OPEN) { + setStatusMessage('Not connected to a relay'); + return; + } + + try { + setEvents([]); + setLoading(true); + + // Create a new subscription for the selected event kind + const subscriptionId = 'query-' + Math.random().toString(36).substring(2, 15); + const subscription = JSON.stringify([ + 'REQ', + subscriptionId, + { kinds: [eventKind], limit: 20 } + ]); + + socketRef.current.send(subscription); + setStatusMessage(`Querying events of kind ${eventKind}...`); + + // Close this subscription after 5 seconds + setTimeout(() => { + if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) { + socketRef.current.send(JSON.stringify(['CLOSE', subscriptionId])); + setLoading(false); + setStatusMessage(`Completed query for kind ${eventKind}`); + } + }, 5000); + } catch (error) { + setLoading(false); + setStatusMessage(`Error querying events: ${error}`); + } + }; return ( {/* Search bar with filter button */} @@ -272,137 +530,432 @@ export default function ProgramsScreen() { availableFilters={availableFilters} /> - - - Programs Coming Soon - - Training programs will allow you to organize your workouts into structured training plans. - - - Database Debug Panel - - {/* Schema Inspector Card */} - - - - - Database Schema ({Platform.OS}) - - - - - {schemas.map((table) => ( - - {table.name} - - {table.sql} - - - ))} - - - - - - {/* Status Card */} - - - - - Database Status - - - - - Initialized: {dbStatus.initialized ? '✅' : '❌'} - Tables Found: {dbStatus.tables.length} - - {dbStatus.tables.map(table => ( - • {table} - ))} - - {dbStatus.error && ( - - - - Error - - {dbStatus.error} - - )} - - - - - {/* Operations Card */} - - - - - Database Operations - - - - - - - - - {testResults && ( - - - {testResults.success ? ( - - ) : ( - - )} - - {testResults.success ? "Success" : "Error"} + {/* Custom Tab Bar */} + + setActiveTab('database')} + > + + Database + + + setActiveTab('nostr')} + > + + Nostr + + + + {/* Tab Content */} + {activeTab === 'database' && ( + + + Programs Coming Soon + + Training programs will allow you to organize your workouts into structured training plans. + + + Database Debug Panel + + {/* Schema Inspector Card */} + + + + + Database Schema ({Platform.OS}) + + + + + {schemas.map((table) => ( + + {table.name} + + {table.sql} - - - {testResults.message} + ))} + + + + + + {/* Status Card */} + + + + + Database Status + + + + + Initialized: {dbStatus.initialized ? '✅' : '❌'} + Tables Found: {dbStatus.tables.length} + + {dbStatus.tables.map(table => ( + • {table} + ))} + + {dbStatus.error && ( + + + + Error + + {dbStatus.error} + + )} + + + + + {/* Operations Card */} + + + + + Database Operations + + + + + + + + + {testResults && ( + + + {testResults.success ? ( + + ) : ( + + )} + + {testResults.success ? "Success" : "Error"} + + + + + {testResults.message} + + + + )} + + + + + + )} + {activeTab === 'nostr' && ( + + + Nostr Integration Test + + {/* Connection controls */} + + + + + Relay Connection + + + + + + + + + + + + + Status: {connected ? 'Connected' : 'Disconnected'} + + + {statusMessage ? ( + {statusMessage} + ) : null} + + + + {/* Keys */} + + + + + Nostr Keys + + + + + + + + + + Public Key: + + + Private Key: + + Note: Never share your private key in a production app + + + + {/* Create Event */} + + + + + Create Event + + + + Event Kind: + + + + + + + + + Content: + + + + + + + + + + + {/* Event List */} + + + + + Recent Events + + + + {loading && events.length === 0 ? ( + + + Loading events... + + ) : events.length === 0 ? ( + + No events yet + + ) : ( + + {events.map((event, index) => ( + + + Kind: {event.kind} | Created: {new Date(event.created_at * 1000).toLocaleString()} + + ID: {event.id} + + {/* Display tags */} + {event.tags && event.tags.length > 0 && ( + + Tags: + {event.tags.map((tag, tagIndex) => ( + + {tag.join(', ')} + + ))} + + )} + + Content: + {event.content} + + {index < events.length - 1 && } + + ))} + + )} + + + + {/* Event JSON Viewer */} + + + + + Event Details + + + + {events.length > 0 ? ( + + Selected Event (Latest): + + + {JSON.stringify(events[0], null, 2)} + ) : ( + No events to display )} - - - - - + + + + {/* How To Use Guide */} + + + + + Testing Guide + + + + How to test Nostr integration: + + 1. Start local strfry relay using: + ./strfry relay + 2. Connect to the relay (ws://localhost:7777) + 3. Generate or enter Nostr keys + 4. Create and publish test events + 5. Query for existing events + For details, see the Nostr Integration Testing Guide + + + + + + )} ); } \ No newline at end of file diff --git a/app/(tabs)/social/_layout.tsx b/app/(tabs)/social/_layout.tsx index 21cfe7f..e8c9e6b 100644 --- a/app/(tabs)/social/_layout.tsx +++ b/app/(tabs)/social/_layout.tsx @@ -19,13 +19,13 @@ export default function SocialLayout() {
{/* Exercise Header */} - + {exercise.title} @@ -284,19 +284,19 @@ export default function CreateWorkoutScreen() { {/* Sets Info */} - + {exercise.sets.filter(s => s.isCompleted).length} sets completed {/* Set Headers */} - - SET - PREV + + SET + PREV KG REPS - + {/* Space for the checkmark/complete button */} {/* Exercise Sets */} @@ -322,7 +322,7 @@ export default function CreateWorkoutScreen() { {/* Add Set Button */} - + + )} + + + + {/* Cache Section */} + + + + + Clear Cache + + + + + + + Clears temporary data without affecting your workouts, exercises, or templates. + + + + + + + Clears exercises and templates from other users while keeping your own content. + + + + + + + Warning: This will delete ALL your local data. Your Nostr identity will be preserved, + but you'll need to re-sync your library from the network. + + + + + + + {/* Clear Cache Alert Dialog */} + + + + + {clearCacheLevel === CacheClearLevel.RELAY_CACHE && "Clear Temporary Cache?"} + {clearCacheLevel === CacheClearLevel.NETWORK_CONTENT && "Clear Network Content?"} + {clearCacheLevel === CacheClearLevel.EVERYTHING && "Reset All Data?"} + + + {clearCacheLevel === CacheClearLevel.RELAY_CACHE && ( + + This will clear temporary data from the app. Your workouts, exercises, and templates will not be affected. + + )} + {clearCacheLevel === CacheClearLevel.NETWORK_CONTENT && ( + + This will clear exercises and templates from other users. Your own content will be preserved. + + )} + {clearCacheLevel === CacheClearLevel.EVERYTHING && ( + + + + Warning: This is destructive! + + + This will delete ALL your local data. Your Nostr identity will be preserved, + but you'll need to re-sync your library from the network. + + + )} + + + + setShowClearCacheAlert(false)}> + Cancel + + + + {clearCacheLoading ? "Clearing..." : "Clear"} + + + + + + + ); +} +``` + +### 3.3 Add Formatting Utility + +Create a utility function to format byte sizes: + +```typescript +// utils/format.ts + +/** + * Format bytes to a human-readable string (KB, MB, etc.) + */ +export function formatBytes(bytes: number, decimals: number = 2): string { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +} +``` + +### 3.4 Add Progress Component + +If you don't have a Progress component yet, create one: + +```typescript +// components/ui/progress.tsx +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import { useTheme } from '@react-navigation/native'; +import { cn } from '@/lib/utils'; + +interface ProgressProps { + value?: number; + max?: number; + className?: string; + indicatorClassName?: string; +} + +export function Progress({ + value = 0, + max = 100, + className, + indicatorClassName, + ...props +}: ProgressProps) { + const theme = useTheme(); + const percentage = Math.min(Math.max(0, (value / max) * 100), 100); + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + indicator: { + transition: 'width 0.2s ease-in-out', + }, +}); +``` + +## 4. Implementation Steps + +### 4.1 Database Modifications + +1. Ensure your schema has the necessary tables: + - `nostr_events` - for storing raw Nostr events + - `event_tags` - for storing event tags + - `cache_metadata` - for tracking cache item usage + +2. Add cache-related columns to existing tables: + - Add `source` to exercises table (if not already present) + - Add `last_accessed` timestamp where relevant + +### 4.2 Implement Services + +1. Create `CacheService.ts` with methods for: + - Getting storage statistics + - Clearing different levels of cache + - Resetting database + +2. Create `NostrSyncService.ts` with methods for: + - Syncing user's library from Nostr + - Tracking sync progress + - Processing different types of Nostr events + +### 4.3 Add UI Components + +1. Update `SettingsDrawer.tsx` to include a "Data Management" option +2. Create `/settings/data-management.tsx` screen with: + - Storage usage visualization + - Sync controls + - Cache clearing options + +3. Create supporting components: + - Progress bar + - Alert dialogs for confirming destructive actions + +### 4.4 Integration with NDK + +1. Update the login flow to trigger library sync after successful login +2. Implement background sync based on user preferences +3. Add event handling to track when new events come in from subscriptions + +## 5. Testing Considerations + +1. Test with both small and large datasets: + - Create test accounts with varying amounts of data + - Test sync and clear operations with hundreds or thousands of events + +2. Test edge cases: + - Network disconnections during sync + - Interruptions during cache clearing + - Database corruption recovery + +3. Performance testing: + - Measure sync time for different dataset sizes + - Monitor memory usage during sync operations + - Test on low-end devices to ensure performance is acceptable + +4. Cross-platform testing: + - Ensure SQLite operations work consistently on iOS, Android, and web + - Test UI rendering on different screen sizes + - Verify that progress indicators update correctly on all platforms + +5. Data integrity testing: + - Verify that user content is preserved after clearing network cache + - Confirm that identity information persists after database reset + - Test that synced data matches what's available on relays + +## 6. User Experience Considerations + +1. Feedback and transparency: + - Always show clear feedback during long-running operations + - Display last sync time and status + - Make it obvious what will happen with each cache-clearing option + +2. Error handling: + - Provide clear error messages when sync fails + - Offer retry options for failed operations + - Include options to report sync issues + +3. Progressive disclosure: + - Hide advanced/dangerous options unless explicitly expanded + - Use appropriate warning colors for destructive actions + - Implement confirmation dialogs with clear explanations + +4. Accessibility: + - Ensure progress indicators have appropriate ARIA labels + - Maintain adequate contrast for all text and UI elements + - Support screen readers for all status updates + +## 7. Future Enhancements + +1. Selective sync: + - Allow users to choose which content types to sync (exercises, templates, etc.) + - Implement priority-based sync for most important content first + +2. Smart caching: + - Automatically prune rarely-used network content + - Keep frequently accessed content even when clearing other cache + +3. Backup and restore: + - Add export/import functionality for local backup + - Implement scheduled automatic backups + +4. Advanced sync controls: + - Allow selection of specific relays for sync operations + - Implement bandwidth usage limits for sync + +5. Conflict resolution: + - Develop a UI for handling conflicts when the same event has different versions + - Add options for manual content merging + +## 8. Conclusion + +This implementation provides a robust solution for managing cache and synchronization in the POWR fitness app. By giving users clear control over their data and implementing efficient sync mechanisms, the app can provide a better experience across devices while respecting user preferences and device constraints. + +The approach keeps user data secure while allowing for flexible network content management, ensuring that the app remains responsive and efficient even as the user's library grows. \ No newline at end of file diff --git a/docs/design/nostr-exercise-nip.md b/docs/design/nostr-exercise-nip.md index 4ceb744..461c245 100644 --- a/docs/design/nostr-exercise-nip.md +++ b/docs/design/nostr-exercise-nip.md @@ -1,4 +1,4 @@ -# NIP-XX: Workout Events +# NIP-4e: Workout Events `draft` `optional` @@ -25,52 +25,144 @@ The event kinds in this NIP follow Nostr protocol conventions: ### Exercise Template (kind: 33401) Defines reusable exercise definitions. These should remain public to enable discovery and sharing. The `content` field contains detailed form instructions and notes. -#### Required Tags -* `d` - UUID for template identification -* `title` - Exercise name -* `format` - Defines data structure for exercise tracking (possible parameters: `weight`, `reps`, `rpe`, `set_type`) -* `format_units` - Defines units for each parameter (possible formats: "kg", "count", "0-10", "warmup|normal|drop|failure") -* `equipment` - Equipment type (possible values: `barbell`, `dumbbell`, `bodyweight`, `machine`, `cardio`) +#### Format -#### Optional Tags -* `difficulty` - Skill level (possible values: `beginner`, `intermediate`, `advanced`) -* `imeta` - Media metadata for form demonstrations following NIP-92 format -* `t` - Hashtags for categorization such as muscle group or body movement (possible values: `chest`, `legs`, `push`, `pull`) +The format uses an _addressable event_ of `kind:33401`. + +The `.content` of these events SHOULD be detailed instructions for proper exercise form. It is required but can be an empty string. + +The list of tags are as follows: + +* `d` (required) - universally unique identifier (UUID). Generated by the client creating the exercise template. +* `title` (required) - Exercise name +* `format` (required) - Defines data structure for exercise tracking (possible parameters: `weight`, `reps`, `rpe`, `set_type`) +* `format_units` (required) - Defines units for each parameter (possible formats: "kg", "count", "0-10", "warmup|normal|drop|failure") +* `equipment` (required) - Equipment type (possible values: `barbell`, `dumbbell`, `bodyweight`, `machine`, `cardio`) +* `difficulty` (optional) - Skill level (possible values: `beginner`, `intermediate`, `advanced`) +* `imeta` (optional) - Media metadata for form demonstrations following NIP-92 format +* `t` (optional, repeated) - Hashtags for categorization such as muscle group or body movement (possible values: `chest`, `legs`, `push`, `pull`) + +``` +{ + "id": <32-bytes lowercase hex-encoded SHA-256 of the serialized event data>, + "pubkey": <32-bytes lowercase hex-encoded public key of the event creator>, + "created_at": , + "kind": 33401, + "content": "", + "tags": [ + ["d", ""], + ["title", ""], + ["format", "", "", "", ""], + ["format_units", "", "", "", ""], + ["equipment", ""], + ["difficulty", ""], + ["imeta", + "url ", + "m ", + "dim ", + "alt " + ], + ["t", ""], + ["t", ""], + ["t", ""] + ] +} +``` ### Workout Template (kind: 33402) Defines a complete workout plan. The `content` field contains workout notes and instructions. Workout templates can prescribe specific parameters while leaving others configurable by the user performing the workout. -#### Required Tags -* `d` - UUID for template identification -* `title` - Workout name -* `type` - Type of workout (possible values: `strength`, `circuit`, `emom`, `amrap`) -* `exercise` - Exercise reference and prescription. Format: ["exercise", "kind:pubkey:d-tag", "relay-url", ...parameters matching exercise template format] +#### Format -#### Optional Tags -* `rounds` - Number of rounds for repeating formats -* `duration` - Total workout duration in seconds -* `interval` - Duration of each exercise portion in seconds (for timed workouts) -* `rest_between_rounds` - Rest time between rounds in seconds -* `t` - Hashtags for categorization +The format uses an _addressable event_ of `kind:33402`. + +The `.content` of these events SHOULD contain workout notes and instructions. It is required but can be an empty string. + +The list of tags are as follows: + +* `d` (required) - universally unique identifier (UUID). Generated by the client creating the workout template. +* `title` (required) - Workout name +* `type` (required) - Type of workout (possible values: `strength`, `circuit`, `emom`, `amrap`) +* `exercise` (required, repeated) - Exercise reference and prescription. Format: ["exercise", "::", "", ...parameters matching exercise template format] +* `rounds` (optional) - Number of rounds for repeating formats +* `duration` (optional) - Total workout duration in seconds +* `interval` (optional) - Duration of each exercise portion in seconds (for timed workouts) +* `rest_between_rounds` (optional) - Rest time between rounds in seconds +* `t` (optional, repeated) - Hashtags for categorization + +``` +{ + "id": <32-bytes lowercase hex-encoded SHA-256 of the serialized event data>, + "pubkey": <32-bytes lowercase hex-encoded public key of the event creator>, + "created_at": , + "kind": 33402, + "content": "", + "tags": [ + ["d", ""], + ["title", ""], + ["type", ""], + ["rounds", ""], + ["duration", ""], + ["interval", ""], + ["rest_between_rounds", ""], + ["exercise", "::", "", "", "", "", ""], + ["exercise", "::", "", "", "", "", ""], + ["t", ""], + ["t", ""] + ] +} +``` ### Workout Record (kind: 1301) Records a completed workout session. The `content` field contains notes about the workout. -#### Required Tags -* `d` - UUID for record identification -* `title` - Workout name -* `type` - Type of workout (possible values: `strength`, `circuit`, `emom`, `amrap`) -* `exercise` - Exercise reference and completion data. Format: ["exercise", "kind:pubkey:d-tag", "relay-url", ...parameters matching exercise template format] -* `start` - Unix timestamp in seconds for workout start -* `end` - Unix timestamp in seconds for workout end -* `completed` - Boolean indicating if workout was completed as planned +#### Format -#### Optional Tags -* `rounds_completed` - Number of rounds completed -* `interval` - Duration of each exercise portion in seconds (for timed workouts) -* `template` - Reference to the workout template used, if any. Format: ["template", "33402::", ""] -* `pr` - Personal Record achieved during workout. Format: "kind:pubkey:d-tag,metric,value". Used to track when a user achieves their best performance for a given exercise and metric (e.g., heaviest weight lifted, most reps completed, fastest time) -* `t` - Hashtags for categorization +The format uses a standard event of `kind:1301`. + +The `.content` of these events SHOULD contain notes about the workout experience. It is required but can be an empty string. + +The list of tags are as follows: + +* `d` (required) - universally unique identifier (UUID). Generated by the client creating the workout record. +* `title` (required) - Workout name +* `type` (required) - Type of workout (possible values: `strength`, `circuit`, `emom`, `amrap`) +* `exercise` (required, repeated) - Exercise reference and completion data. Format: ["exercise", "::", "", ...parameters matching exercise template format] +* `start` (required) - Unix timestamp in seconds for workout start +* `end` (required) - Unix timestamp in seconds for workout end +* `completed` (required) - Boolean indicating if workout was completed as planned +* `rounds_completed` (optional) - Number of rounds completed +* `interval` (optional) - Duration of each exercise portion in seconds (for timed workouts) +* `template` (optional) - Reference to the workout template used, if any. Format: ["template", "::", ""] +* `pr` (optional, repeated) - Personal Record achieved during workout. Format: "::,," +* `t` (optional, repeated) - Hashtags for categorization + +``` +{ + "id": <32-bytes lowercase hex-encoded SHA-256 of the serialized event data>, + "pubkey": <32-bytes lowercase hex-encoded public key of the event creator>, + "created_at": , + "kind": 1301, + "content": "", + "tags": [ + ["d", ""], + ["title", ""], + ["type", ""], + ["rounds_completed", ""], + ["start", ""], + ["end", ""], + + ["exercise", "::", "", "", "", "", ""], + ["exercise", "::", "", "", "", "", ""], + + ["template", "::", ""], + ["pr", "::,,"], + ["completed", ""], + ["t", ""], + ["t", ""] + ] +} +``` ## Exercise Parameters @@ -122,12 +214,12 @@ Sets where technical failure was reached before completing prescribed reps. Thes ## Examples ### Exercise Template -```json +``` { "kind": 33401, "content": "Stand with feet hip-width apart, barbell over midfoot. Hinge at hips, grip bar outside knees. Flatten back, brace core. Drive through floor, keeping bar close to legs.\n\nForm demonstration: https://powr.me/exercises/deadlift-demo.mp4", "tags": [ - ["d", "bb-deadlift-template"], + ["d", ""], ["title", "Barbell Deadlift"], ["format", "weight", "reps", "rpe", "set_type"], ["format_units", "kg", "count", "0-10", "warmup|normal|drop|failure"], @@ -147,20 +239,20 @@ Sets where technical failure was reached before completing prescribed reps. Thes ``` ### EMOM Workout Template -```json +``` { "kind": 33402, "content": "20 minute EMOM alternating between squats and deadlifts every 30 seconds. Scale weight as needed to complete all reps within each interval.", "tags": [ - ["d", "lower-body-emom-template"], + ["d", ""], ["title", "20min Squat/Deadlift EMOM"], ["type", "emom"], ["duration", "1200"], ["rounds", "20"], ["interval", "30"], - ["exercise", "33401:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:bb-back-squat-template", "wss://powr.me", "", "5", "7", "normal"], - ["exercise", "33401:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:bb-deadlift-template", "wss://powr.me", "", "4", "7", "normal"], + ["exercise", "33401::", "", "", "5", "7", "normal"], + ["exercise", "33401::", "", "", "4", "7", "normal"], ["t", "conditioning"], ["t", "legs"] @@ -169,25 +261,23 @@ Sets where technical failure was reached before completing prescribed reps. Thes ``` ### Circuit Workout Record -```json +``` { "kind": 1301, "content": "Completed first round as prescribed. Second round showed form deterioration on deadlifts.", "tags": [ - ["d", "workout-20250128"], + ["d", ""], ["title", "Leg Circuit"], ["type", "circuit"], ["rounds_completed", "1.5"], ["start", "1706454000"], ["end", "1706455800"], - // Round 1 - Completed as prescribed - ["exercise", "33401:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:bb-back-squat-template", "wss://powr.me", "80", "12", "7", "normal"], - ["exercise", "33401:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:bb-deadlift-template", "wss://powr.me", "100", "10", "7", "normal"], + ["exercise", "33401::", "", "80", "12", "7", "normal"], + ["exercise", "33401::", "", "100", "10", "7", "normal"], - // Round 2 - Failed on deadlifts - ["exercise", "33401:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:bb-back-squat-template", "wss://powr.me", "80", "12", "8", "normal"], - ["exercise", "33401:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:bb-deadlift-template", "wss://powr.me", "100", "4", "10", "failure"], + ["exercise", "33401::", "", "80", "12", "8", "normal"], + ["exercise", "33401::", "", "100", "4", "10", "failure"], ["completed", "false"], ["t", "legs"] @@ -197,16 +287,17 @@ Sets where technical failure was reached before completing prescribed reps. Thes ## Implementation Guidelines -1. All workout records SHOULD include accurate start and end times +1. All workout records MUST include accurate start and end times 2. Templates MAY prescribe specific parameters while leaving others as empty strings for user input 3. Records MUST include actual values for all parameters defined in exercise format 4. Failed sets SHOULD be marked with `failure` set_type 5. Records SHOULD be marked as `false` for completed if prescribed work wasn't completed 6. PRs SHOULD only be tracked in workout records, not templates -7. Exercise references SHOULD use the format "kind:pubkey:d-tag" to ensure proper attribution and versioning +7. Exercise references MUST use the format "kind:pubkey:d-tag" to ensure proper attribution and versioning ## References This NIP draws inspiration from: - [NIP-01: Basic Protocol Flow Description](https://github.com/nostr-protocol/nips/blob/master/01.md) +- [NIP-52: Calendar Events](https://github.com/nostr-protocol/nips/blob/master/52.md) - [NIP-92: Media Attachments](https://github.com/nostr-protocol/nips/blob/master/92.md#nip-92) \ No newline at end of file diff --git a/lib/stores/ndk.ts b/lib/stores/ndk.ts index 01be4ef..6063492 100644 --- a/lib/stores/ndk.ts +++ b/lib/stores/ndk.ts @@ -9,10 +9,11 @@ const PRIVATE_KEY_STORAGE_KEY = 'nostr_privkey'; // Default relays const DEFAULT_RELAYS = [ - 'wss://relay.damus.io', - 'wss://relay.nostr.band', - 'wss://purplepag.es', - 'wss://nos.lol' + 'ws://localhost:8080', // Add your local test relay + //'wss://relay.damus.io', + //'wss://relay.nostr.band', + //'wss://purplepag.es', + //'wss://nos.lol' ]; // Helper function to convert Array/Uint8Array to hex string diff --git a/package-lock.json b/package-lock.json index 98f21d1..c3b8312 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "dependencies": { "@expo/cli": "^0.22.16", + "@noble/secp256k1": "^2.2.3", "@nostr-dev-kit/ndk": "^2.12.0", "@radix-ui/react-alert-dialog": "^1.1.6", "@react-native-clipboard/clipboard": "^1.16.1", diff --git a/package.json b/package.json index 348db74..26aa6d2 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@expo/cli": "^0.22.16", + "@noble/secp256k1": "^2.2.3", "@nostr-dev-kit/ndk": "^2.12.0", "@radix-ui/react-alert-dialog": "^1.1.6", "@react-native-clipboard/clipboard": "^1.16.1", diff --git a/types/nostr.ts b/types/nostr.ts index ca80418..1308cac 100644 --- a/types/nostr.ts +++ b/types/nostr.ts @@ -1,32 +1,43 @@ // types/nostr.ts export interface NostrEvent { - id?: string; - pubkey?: string; - content: string; - created_at: number; - kind: number; - tags: string[][]; - sig?: string; - } + id?: string; + pubkey?: string; + content: string; + created_at: number; + kind: number; + tags: string[][]; + sig?: string; +} + +export enum NostrEventKind { + EXERCISE = 33401, + TEMPLATE = 33402, + WORKOUT = 1301 // Updated from 33403 to 1301 +} + +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]); +} + +// New helper function for template tags +export function getTemplateTag(tags: string[][]): { reference: string, relay: string } | undefined { + const templateTag = tags.find(t => t[0] === 'template'); + if (!templateTag) return undefined; - 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]); - } \ No newline at end of file + return { + reference: templateTag[1], + relay: templateTag[2] + }; +} \ No newline at end of file