update to nostr exercise nip and minor UI

This commit is contained in:
DocNR 2025-03-02 13:23:28 -05:00
parent dca4ef5b33
commit 173e4e31e4
11 changed files with 1867 additions and 241 deletions

View File

@ -1,17 +1,74 @@
// app/(tabs)/library/programs.tsx // app/(tabs)/library/programs.tsx
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { View, ScrollView } from 'react-native'; import { View, ScrollView, TextInput, ActivityIndicator, Platform, TouchableOpacity } from 'react-native';
import { Text } from '@/components/ui/text'; import { Text } from '@/components/ui/text';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; 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 { useSQLiteContext } from 'expo-sqlite';
import { ExerciseType, ExerciseCategory, Equipment } from '@/types/exercise'; import { ExerciseType, ExerciseCategory, Equipment } from '@/types/exercise';
import { SQLTransaction, SQLResultSet, SQLError } from '@/lib/db/types'; import { SQLTransaction, SQLResultSet, SQLError } from '@/lib/db/types';
import { schema } from '@/lib/db/schema'; import { schema } from '@/lib/db/schema';
import { Platform } from 'react-native';
import { FilterSheet, type FilterOptions, type SourceType } from '@/components/library/FilterSheet'; 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 { interface TableInfo {
name: string; name: string;
@ -52,9 +109,10 @@ const initialFilters: FilterOptions = {
tags: [], tags: [],
source: [] source: []
}; };
export default function ProgramsScreen() { export default function ProgramsScreen() {
const db = useSQLiteContext(); const db = useSQLiteContext();
// Database state
const [dbStatus, setDbStatus] = useState<{ const [dbStatus, setDbStatus] = useState<{
initialized: boolean; initialized: boolean;
tables: string[]; tables: string[];
@ -72,12 +130,34 @@ export default function ProgramsScreen() {
const [filterSheetOpen, setFilterSheetOpen] = useState(false); const [filterSheetOpen, setFilterSheetOpen] = useState(false);
const [currentFilters, setCurrentFilters] = useState<FilterOptions>(initialFilters); const [currentFilters, setCurrentFilters] = useState<FilterOptions>(initialFilters);
const [activeFilters, setActiveFilters] = useState(0); 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<NostrEvent[]>([]);
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<WebSocket | null>(null);
// Tab state
const [activeTab, setActiveTab] = useState('database');
useEffect(() => { useEffect(() => {
checkDatabase(); checkDatabase();
inspectDatabase(); inspectDatabase();
generateKeys();
}, []); }, []);
// DATABASE FUNCTIONS
const inspectDatabase = async () => { const inspectDatabase = async () => {
try { try {
const result = await db.getAllAsync<TableSchema>( const result = await db.getAllAsync<TableSchema>(
@ -113,7 +193,6 @@ export default function ProgramsScreen() {
})); }));
} }
}; };
const resetDatabase = async () => { const resetDatabase = async () => {
try { try {
await db.withTransactionAsync(async () => { await db.withTransactionAsync(async () => {
@ -230,6 +309,185 @@ export default function ProgramsScreen() {
// Implement filtering logic for programs when available // 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 ( return (
<View className="flex-1 bg-background"> <View className="flex-1 bg-background">
{/* Search bar with filter button */} {/* Search bar with filter button */}
@ -272,137 +530,432 @@ export default function ProgramsScreen() {
availableFilters={availableFilters} availableFilters={availableFilters}
/> />
<ScrollView className="flex-1 p-4"> {/* Custom Tab Bar */}
<View className="py-4 space-y-4"> <View className="flex-row mx-4 mt-4 bg-card rounded-lg overflow-hidden">
<Text className="text-lg font-semibold text-center mb-4">Programs Coming Soon</Text> <TouchableOpacity
<Text className="text-center text-muted-foreground mb-6"> className={`flex-1 flex-row items-center justify-center py-3 ${activeTab === 'database' ? 'bg-primary' : ''}`}
Training programs will allow you to organize your workouts into structured training plans. onPress={() => setActiveTab('database')}
</Text> >
<Database size={18} className={`mr-2 ${activeTab === 'database' ? 'text-white' : 'text-foreground'}`} />
<Text className="text-lg font-semibold text-center mb-4">Database Debug Panel</Text> <Text className={activeTab === 'database' ? 'text-white' : 'text-foreground'}>Database</Text>
</TouchableOpacity>
{/* Schema Inspector Card */}
<Card> <TouchableOpacity
<CardHeader> className={`flex-1 flex-row items-center justify-center py-3 ${activeTab === 'nostr' ? 'bg-primary' : ''}`}
<CardTitle className="flex-row items-center gap-2"> onPress={() => setActiveTab('nostr')}
<Code size={20} className="text-foreground" /> >
<Text className="text-lg font-semibold">Database Schema ({Platform.OS})</Text> <Zap size={18} className={`mr-2 ${activeTab === 'nostr' ? 'text-white' : 'text-foreground'}`} />
</CardTitle> <Text className={activeTab === 'nostr' ? 'text-white' : 'text-foreground'}>Nostr</Text>
</CardHeader> </TouchableOpacity>
<CardContent> </View>
<View className="space-y-4">
{schemas.map((table) => ( {/* Tab Content */}
<View key={table.name} className="space-y-2"> {activeTab === 'database' && (
<Text className="font-semibold">{table.name}</Text> <ScrollView className="flex-1 p-4">
<Text className="text-muted-foreground text-sm"> <View className="py-4 space-y-4">
{table.sql} <Text className="text-lg font-semibold text-center mb-4">Programs Coming Soon</Text>
</Text> <Text className="text-center text-muted-foreground mb-6">
</View> Training programs will allow you to organize your workouts into structured training plans.
))} </Text>
</View>
<Button <Text className="text-lg font-semibold text-center mb-4">Database Debug Panel</Text>
className="mt-4"
onPress={inspectDatabase} {/* Schema Inspector Card */}
> <Card>
<Text className="text-primary-foreground">Refresh Schema</Text> <CardHeader>
</Button> <CardTitle className="flex-row items-center gap-2">
</CardContent> <Code size={20} className="text-foreground" />
</Card> <Text className="text-lg font-semibold">Database Schema ({Platform.OS})</Text>
</CardTitle>
{/* Status Card */} </CardHeader>
<Card> <CardContent>
<CardHeader> <View className="space-y-4">
<CardTitle className="flex-row items-center gap-2"> {schemas.map((table) => (
<Database size={20} className="text-foreground" /> <View key={table.name} className="space-y-2">
<Text className="text-lg font-semibold">Database Status</Text> <Text className="font-semibold">{table.name}</Text>
</CardTitle> <Text className="text-muted-foreground text-sm">
</CardHeader> {table.sql}
<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>
{/* Operations 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> </Text>
</View> </View>
<ScrollView className="mt-2"> ))}
<Text className={`${ </View>
testResults.success ? 'text-foreground' : 'text-destructive' <Button
}`}> className="mt-4"
{testResults.message} onPress={inspectDatabase}
>
<Text className="text-primary-foreground">Refresh Schema</Text>
</Button>
</CardContent>
</Card>
{/* Status Card */}
<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>
{/* Operations 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>
)}
{activeTab === 'nostr' && (
<ScrollView className="flex-1 p-4">
<View className="py-4 space-y-4">
<Text className="text-lg font-semibold text-center mb-4">Nostr Integration Test</Text>
{/* Connection controls */}
<Card className="mb-4">
<CardHeader>
<CardTitle className="flex-row items-center gap-2">
<Wifi size={20} className="text-foreground" />
<Text className="text-lg font-semibold">Relay Connection</Text>
</CardTitle>
</CardHeader>
<CardContent>
<Input
value={relayUrl}
onChangeText={setRelayUrl}
placeholder="wss://relay.example.com"
className="mb-4"
/>
<View className="flex-row gap-4">
<Button
onPress={connectToRelay}
disabled={connecting || connected}
className="flex-1"
>
{connecting ? (
<><ActivityIndicator size="small" color="#fff" /><Text className="text-white ml-2">Connecting...</Text></>
) : (
<Text className="text-white">Connect</Text>
)}
</Button>
<Button
onPress={disconnectFromRelay}
disabled={!connected}
variant="destructive"
className="flex-1"
>
<Text className="text-white">Disconnect</Text>
</Button>
</View>
<Text className={`mt-2 ${connected ? 'text-green-500' : 'text-red-500'}`}>
Status: {connected ? 'Connected' : 'Disconnected'}
</Text>
{statusMessage ? (
<Text className="mt-2 text-gray-500">{statusMessage}</Text>
) : null}
</CardContent>
</Card>
{/* Keys */}
<Card className="mb-4">
<CardHeader>
<CardTitle className="flex-row items-center gap-2">
<Code size={20} className="text-foreground" />
<Text className="text-lg font-semibold">Nostr Keys</Text>
</CardTitle>
</CardHeader>
<CardContent>
<View className="flex-row items-center mb-4">
<Switch
checked={useGeneratedKeys}
onCheckedChange={setUseGeneratedKeys}
id="use-generated-keys"
/>
<Label htmlFor="use-generated-keys" className="ml-2">Use generated keys</Label>
<Button
onPress={generateKeys}
className="ml-auto"
variant="outline"
size="sm"
>
<Text>Generate New Keys</Text>
</Button>
</View>
<Text className="mb-1 font-medium">Public Key:</Text>
<Input
value={publicKey}
onChangeText={setPublicKey}
placeholder="Public key (hex)"
editable={!useGeneratedKeys}
className={`mb-4 ${useGeneratedKeys ? 'opacity-70' : ''}`}
/>
<Text className="mb-1 font-medium">Private Key:</Text>
<Input
value={privateKey}
onChangeText={setPrivateKey}
placeholder="Private key (hex)"
editable={!useGeneratedKeys}
className={`mb-2 ${useGeneratedKeys ? 'opacity-70' : ''}`}
/>
<Text className="text-xs text-muted-foreground">Note: Never share your private key in a production app</Text>
</CardContent>
</Card>
{/* Create Event */}
<Card className="mb-4">
<CardHeader>
<CardTitle className="flex-row items-center gap-2">
<Zap size={20} className="text-foreground" />
<Text className="text-lg font-semibold">Create Event</Text>
</CardTitle>
</CardHeader>
<CardContent>
<Text className="mb-1 font-medium">Event Kind:</Text>
<View className="flex-row gap-2 mb-4">
<Button
variant={eventKind === EVENT_KIND_EXERCISE ? "default" : "outline"}
onPress={() => setEventKind(EVENT_KIND_EXERCISE)}
size="sm"
>
<Text className={eventKind === EVENT_KIND_EXERCISE ? "text-white" : ""}>Exercise</Text>
</Button>
<Button
variant={eventKind === EVENT_KIND_WORKOUT_TEMPLATE ? "default" : "outline"}
onPress={() => setEventKind(EVENT_KIND_WORKOUT_TEMPLATE)}
size="sm"
>
<Text className={eventKind === EVENT_KIND_WORKOUT_TEMPLATE ? "text-white" : ""}>Template</Text>
</Button>
<Button
variant={eventKind === EVENT_KIND_WORKOUT_RECORD ? "default" : "outline"}
onPress={() => setEventKind(EVENT_KIND_WORKOUT_RECORD)}
size="sm"
>
<Text className={eventKind === EVENT_KIND_WORKOUT_RECORD ? "text-white" : ""}>Workout</Text>
</Button>
</View>
<Text className="mb-1 font-medium">Content:</Text>
<TextInput
value={eventContent}
onChangeText={setEventContent}
placeholder="Event content"
multiline
numberOfLines={4}
className="border border-gray-300 dark:border-gray-700 rounded-md p-2 mb-4 min-h-24"
/>
<View className="flex-row gap-4">
<Button
onPress={publishEvent}
disabled={!connected || loading}
className="flex-1"
>
{loading ? (
<><ActivityIndicator size="small" color="#fff" /><Text className="text-white ml-2">Publishing...</Text></>
) : (
<Text className="text-white">Publish Event</Text>
)}
</Button>
<Button
onPress={queryEvents}
disabled={!connected || loading}
variant="outline"
className="flex-1"
>
<Text>Query Events</Text>
</Button>
</View>
</CardContent>
</Card>
{/* Event List */}
<Card className="mb-4">
<CardHeader>
<CardTitle className="flex-row items-center gap-2">
<Database size={20} className="text-foreground" />
<Text className="text-lg font-semibold">Recent Events</Text>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{loading && events.length === 0 ? (
<View className="items-center justify-center p-4">
<ActivityIndicator size="large" />
<Text className="mt-2">Loading events...</Text>
</View>
) : events.length === 0 ? (
<View className="items-center justify-center p-4">
<Text className="text-muted-foreground">No events yet</Text>
</View>
) : (
<ScrollView className="p-4" style={{ maxHeight: 200 }}>
{events.map((event, index) => (
<View key={event.id || index} className="mb-4">
<Text className="font-bold">
Kind: {event.kind} | Created: {new Date(event.created_at * 1000).toLocaleString()}
</Text>
<Text className="mb-1">ID: {event.id}</Text>
{/* Display tags */}
{event.tags && event.tags.length > 0 && (
<View className="mb-1">
<Text className="font-medium">Tags:</Text>
{event.tags.map((tag, tagIndex) => (
<Text key={tagIndex} className="ml-2">
{tag.join(', ')}
</Text>
))}
</View>
)}
<Text className="font-medium">Content:</Text>
<Text className="ml-2 mb-2">{event.content}</Text>
{index < events.length - 1 && <Separator className="my-2" />}
</View>
))}
</ScrollView>
)}
</CardContent>
</Card>
{/* Event JSON Viewer */}
<Card className="mb-4">
<CardHeader>
<CardTitle className="flex-row items-center gap-2">
<FileJson size={20} className="text-foreground" />
<Text className="text-lg font-semibold">Event Details</Text>
</CardTitle>
</CardHeader>
<CardContent>
{events.length > 0 ? (
<View>
<Text className="font-medium mb-2">Selected Event (Latest):</Text>
<ScrollView
className="border border-border p-2 rounded-md"
style={{ maxHeight: 200 }}
>
<Text className="font-mono">
{JSON.stringify(events[0], null, 2)}
</Text> </Text>
</ScrollView> </ScrollView>
</View> </View>
) : (
<Text className="text-muted-foreground">No events to display</Text>
)} )}
</View> </CardContent>
</CardContent> </Card>
</Card>
</View> {/* How To Use Guide */}
</ScrollView> <Card className="mb-4">
<CardHeader>
<CardTitle className="flex-row items-center gap-2">
<AlertCircle size={20} className="text-foreground" />
<Text className="text-lg font-semibold">Testing Guide</Text>
</CardTitle>
</CardHeader>
<CardContent>
<Text className="font-medium mb-2">How to test Nostr integration:</Text>
<View className="space-y-2">
<Text>1. Start local strfry relay using:</Text>
<Text className="ml-4 font-mono bg-muted p-2 rounded">./strfry relay</Text>
<Text>2. Connect to the relay (ws://localhost:7777)</Text>
<Text>3. Generate or enter Nostr keys</Text>
<Text>4. Create and publish test events</Text>
<Text>5. Query for existing events</Text>
<Text className="mt-2 text-muted-foreground">For details, see the Nostr Integration Testing Guide</Text>
</View>
</CardContent>
</Card>
</View>
</ScrollView>
)}
</View> </View>
); );
} }

View File

@ -19,13 +19,13 @@ export default function SocialLayout() {
<Header useLogo={true} /> <Header useLogo={true} />
<Tab.Navigator <Tab.Navigator
initialRouteName="following" initialRouteName="powr"
screenOptions={{ screenOptions={{
tabBarActiveTintColor: theme.colors.tabIndicator, tabBarActiveTintColor: theme.colors.tabIndicator,
tabBarInactiveTintColor: theme.colors.tabInactive, tabBarInactiveTintColor: theme.colors.tabInactive,
tabBarLabelStyle: { tabBarLabelStyle: {
fontSize: 14, fontSize: 14,
textTransform: 'capitalize', textTransform: 'none',
fontWeight: 'bold', fontWeight: 'bold',
}, },
tabBarIndicatorStyle: { tabBarIndicatorStyle: {

View File

@ -267,7 +267,7 @@ export default function CreateWorkoutScreen() {
className="mb-6 overflow-hidden border border-border bg-card" className="mb-6 overflow-hidden border border-border bg-card"
> >
{/* Exercise Header */} {/* Exercise Header */}
<View className="flex-row justify-between items-center px-4 py-3 border-b border-border"> <View className="flex-row justify-between items-center px-4 py-1 border-b border-border">
<Text className="text-lg font-semibold text-[#8B5CF6]"> <Text className="text-lg font-semibold text-[#8B5CF6]">
{exercise.title} {exercise.title}
</Text> </Text>
@ -284,19 +284,19 @@ export default function CreateWorkoutScreen() {
</View> </View>
{/* Sets Info */} {/* Sets Info */}
<View className="px-4 py-2"> <View className="px-4 py-1">
<Text className="text-sm text-muted-foreground"> <Text className="text-sm text-muted-foreground">
{exercise.sets.filter(s => s.isCompleted).length} sets completed {exercise.sets.filter(s => s.isCompleted).length} sets completed
</Text> </Text>
</View> </View>
{/* Set Headers */} {/* Set Headers */}
<View className="flex-row px-4 py-2 border-t border-border bg-muted/30"> <View className="flex-row px-4 py-1 border-t border-border bg-muted/30">
<Text className="w-16 text-sm font-medium text-muted-foreground">SET</Text> <Text className="w-8 text-sm font-medium text-muted-foreground text-center">SET</Text>
<Text className="w-20 text-sm font-medium text-muted-foreground">PREV</Text> <Text className="w-20 text-sm font-medium text-muted-foreground text-center">PREV</Text>
<Text className="flex-1 text-sm font-medium text-center text-muted-foreground">KG</Text> <Text className="flex-1 text-sm font-medium text-center text-muted-foreground">KG</Text>
<Text className="flex-1 text-sm font-medium text-center text-muted-foreground">REPS</Text> <Text className="flex-1 text-sm font-medium text-center text-muted-foreground">REPS</Text>
<View style={{ width: 44 }} /> <View style={{ width: 44 }} /> {/* Space for the checkmark/complete button */}
</View> </View>
{/* Exercise Sets */} {/* Exercise Sets */}
@ -322,7 +322,7 @@ export default function CreateWorkoutScreen() {
{/* Add Set Button */} {/* Add Set Button */}
<Button <Button
variant="ghost" variant="ghost"
className="flex-row justify-center items-center py-3 border-t border-border" className="flex-row justify-center items-center py-2 border-t border-border"
onPress={() => handleAddSet(exerciseIndex)} onPress={() => handleAddSet(exerciseIndex)}
> >
<Plus size={18} className="text-foreground mr-2" /> <Plus size={18} className="text-foreground mr-2" />
@ -386,7 +386,9 @@ export default function CreateWorkoutScreen() {
<AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}> <AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Cancel Workout</AlertDialogTitle> <AlertDialogTitle>
<Text>Cancel Workout</Text>
</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
<Text>Are you sure you want to cancel this workout? All progress will be lost.</Text> <Text>Are you sure you want to cancel this workout? All progress will be lost.</Text>
</AlertDialogDescription> </AlertDialogDescription>

View File

@ -18,14 +18,14 @@ export default function HomeWorkout({ onStartBlank, onSelectTemplate }: HomeWork
</CardHeader> </CardHeader>
<CardContent className="flex-col gap-4"> <CardContent className="flex-col gap-4">
<Button <Button
variant="purple"
size="lg" size="lg"
className="w-full flex-row items-center justify-center gap-2" className="w-full flex-row items-center justify-center gap-2"
onPress={onStartBlank} onPress={onStartBlank}
> >
<Play className="h-5 w-5" /> <Play className="h-5 w-5" color="white" />
<Text className="text-primary-foreground">Quick Start</Text> <Text className="text-white">Quick Start</Text>
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="lg" size="lg"

View File

@ -2,7 +2,7 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { View, TextInput, TouchableOpacity } from 'react-native'; import { View, TextInput, TouchableOpacity } from 'react-native';
import { Text } from '@/components/ui/text'; import { Text } from '@/components/ui/text';
import { Feather } from '@expo/vector-icons'; import { Circle, CheckCircle } from 'lucide-react-native'; // Lucide React icons
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useWorkoutStore } from '@/stores/workoutStore'; import { useWorkoutStore } from '@/stores/workoutStore';
import { useColorScheme } from '@/lib/useColorScheme'; import { useColorScheme } from '@/lib/useColorScheme';
@ -90,7 +90,6 @@ export default function SetInput({
}, [previousSet]); }, [previousSet]);
// Get the appropriate colors based on theme variables // Get the appropriate colors based on theme variables
// Using the --purple and --muted-foreground from your theme
const purpleColor = 'hsl(261, 90%, 66%)'; // --purple from your constants const purpleColor = 'hsl(261, 90%, 66%)'; // --purple from your constants
const mutedForegroundColor = isDarkColorScheme const mutedForegroundColor = isDarkColorScheme
? 'hsl(240, 5%, 64.9%)' // --muted-foreground dark ? 'hsl(240, 5%, 64.9%)' // --muted-foreground dark
@ -98,11 +97,11 @@ export default function SetInput({
return ( return (
<View className={cn( <View className={cn(
"flex-row items-center px-4 py-2 border-b border-border", "flex-row items-center px-4 py-1 border-b border-border",
isCompleted && "bg-primary/5" isCompleted && "bg-primary/5"
)}> )}>
{/* Set Number */} {/* Set Number */}
<Text className="w-8 text-sm font-medium text-muted-foreground"> <Text className="w-8 text-sm font-medium text-muted-foreground text-center">
{setNumber} {setNumber}
</Text> </Text>
@ -119,7 +118,7 @@ export default function SetInput({
> >
<TextInput <TextInput
className={cn( className={cn(
"h-10 px-3 rounded-md text-center text-foreground", "h-8 px-3 rounded-md text-center text-foreground",
"bg-secondary border border-border", "bg-secondary border border-border",
isCompleted && "bg-primary/10 border-primary/20" isCompleted && "bg-primary/10 border-primary/20"
)} )}
@ -141,7 +140,7 @@ export default function SetInput({
> >
<TextInput <TextInput
className={cn( className={cn(
"h-10 px-3 rounded-md text-center text-foreground", "h-8 px-3 rounded-md text-center text-foreground",
"bg-secondary border border-border", "bg-secondary border border-border",
isCompleted && "bg-primary/10 border-primary/20" isCompleted && "bg-primary/10 border-primary/20"
)} )}
@ -155,16 +154,23 @@ export default function SetInput({
/> />
</TouchableOpacity> </TouchableOpacity>
{/* Complete Button using Feather icons with appropriate theme colors */} {/* Complete Button using Lucide React icons - without fill */}
<TouchableOpacity <TouchableOpacity
className="w-10 h-10 items-center justify-center" className="w-10 h-10 items-center justify-center"
onPress={handleCompleteSet} onPress={handleCompleteSet}
> >
<Feather {isCompleted ? (
name={isCompleted ? "check-circle" : "circle"} <CheckCircle
size={24} size={24}
color={isCompleted ? purpleColor : mutedForegroundColor} color={purpleColor}
/> // Removed fill and fillOpacity properties
/>
) : (
<Circle
size={24}
color={mutedForegroundColor}
/>
)}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
); );

View File

@ -0,0 +1,960 @@
# POWR App: Cache Management Implementation Guide
This document outlines the implementation of cache management features in the POWR fitness app, including data synchronization options and cache clearing functions.
## 1. Overview
The cache management system will allow users to:
1. Sync their library data from Nostr on demand
2. Clear different levels of cached data
3. View storage usage information
4. Configure automatic sync behavior
## 2. Data Services Implementation
### 2.1 CacheService Class
Create a new service to handle cache management operations:
```typescript
// lib/services/CacheService.ts
import { SQLiteDatabase } from 'expo-sqlite';
import { schema } from '@/lib/db/schema';
export enum CacheClearLevel {
RELAY_CACHE = 'relay_cache', // Just temporary relay data
NETWORK_CONTENT = 'network', // Other users' content
EVERYTHING = 'everything' // Reset the entire database (except user credentials)
}
export class CacheService {
private db: SQLiteDatabase;
constructor(db: SQLiteDatabase) {
this.db = db;
}
/**
* Get storage usage statistics by category
*/
async getStorageStats(): Promise<{
userContent: number; // bytes used by user's content
networkContent: number; // bytes used by other users' content
temporaryCache: number; // bytes used by temporary cache
total: number; // total bytes used
}> {
// Implementation to calculate database size by category
// This is a placeholder - actual implementation would depend on platform-specific APIs
// For SQLite, you'd typically query the page_count and page_size
// from sqlite_master to estimate database size
try {
const dbSize = await this.db.getFirstAsync<{ size: number }>(
"SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()"
);
// For a more detailed breakdown, you'd need to query each table size
// This is simplified
const userContentSize = dbSize?.size ? Math.floor(dbSize.size * 0.4) : 0;
const networkContentSize = dbSize?.size ? Math.floor(dbSize.size * 0.4) : 0;
const tempCacheSize = dbSize?.size ? Math.floor(dbSize.size * 0.2) : 0;
return {
userContent: userContentSize,
networkContent: networkContentSize,
temporaryCache: tempCacheSize,
total: dbSize?.size || 0
};
} catch (error) {
console.error('Error getting storage stats:', error);
return {
userContent: 0,
networkContent: 0,
temporaryCache: 0,
total: 0
};
}
}
/**
* Clears cache based on the specified level
*/
async clearCache(level: CacheClearLevel, currentUserPubkey?: string): Promise<void> {
switch(level) {
case CacheClearLevel.RELAY_CACHE:
// Clear temporary relay cache but keep all local content
await this.clearRelayCache();
break;
case CacheClearLevel.NETWORK_CONTENT:
// Clear other users' content but keep user's own content
if (!currentUserPubkey) throw new Error('User pubkey required for this operation');
await this.clearNetworkContent(currentUserPubkey);
break;
case CacheClearLevel.EVERYTHING:
// Reset everything except user credentials
await this.resetDatabase();
break;
}
}
/**
* Clears only temporary cache entries
*/
private async clearRelayCache(): Promise<void> {
await this.db.withTransactionAsync(async () => {
// Clear cache_metadata table
await this.db.runAsync('DELETE FROM cache_metadata');
});
}
/**
* Clears network content from other users
*/
private async clearNetworkContent(userPubkey: string): Promise<void> {
await this.db.withTransactionAsync(async () => {
// Delete events from other users
await this.db.runAsync(
'DELETE FROM nostr_events WHERE pubkey != ?',
[userPubkey]
);
// Delete references to those events
await this.db.runAsync(
`DELETE FROM event_tags
WHERE event_id NOT IN (
SELECT id FROM nostr_events
)`
);
// Delete exercises that reference deleted events
await this.db.runAsync(
`DELETE FROM exercises
WHERE source = 'nostr'
AND nostr_event_id NOT IN (
SELECT id FROM nostr_events
)`
);
// Delete tags for those exercises
await this.db.runAsync(
`DELETE FROM exercise_tags
WHERE exercise_id NOT IN (
SELECT id FROM exercises
)`
);
});
}
/**
* Resets the entire database but preserves user credentials
*/
private async resetDatabase(): Promise<void> {
// Save user credentials before reset
const userProfiles = await this.db.getAllAsync(
'SELECT * FROM user_profiles'
);
const userRelays = await this.db.getAllAsync(
'SELECT * FROM user_relays'
);
// Reset schema (keeping user credentials)
await this.db.withTransactionAsync(async () => {
// Drop all content tables
await this.db.execAsync('DROP TABLE IF EXISTS exercises');
await this.db.execAsync('DROP TABLE IF EXISTS exercise_tags');
await this.db.execAsync('DROP TABLE IF EXISTS nostr_events');
await this.db.execAsync('DROP TABLE IF EXISTS event_tags');
await this.db.execAsync('DROP TABLE IF EXISTS cache_metadata');
// Recreate schema
await schema.createTables(this.db);
});
// Restore user profiles and relays
if (userProfiles.length > 0) {
await this.db.withTransactionAsync(async () => {
for (const profile of userProfiles) {
await this.db.runAsync(
`INSERT INTO user_profiles (
pubkey, name, display_name, about, website,
picture, nip05, lud16, last_updated
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
profile.pubkey,
profile.name,
profile.display_name,
profile.about,
profile.website,
profile.picture,
profile.nip05,
profile.lud16,
profile.last_updated
]
);
}
for (const relay of userRelays) {
await this.db.runAsync(
`INSERT INTO user_relays (
pubkey, relay_url, read, write, created_at
) VALUES (?, ?, ?, ?, ?)`,
[
relay.pubkey,
relay.relay_url,
relay.read,
relay.write,
relay.created_at
]
);
}
});
}
}
}
```
### 2.2 NostrSyncService Class
Create a service for syncing content from Nostr:
```typescript
// lib/services/NostrSyncService.ts
import { SQLiteDatabase } from 'expo-sqlite';
import { EventCache } from '@/lib/db/services/EventCache';
import { ExerciseService } from '@/lib/db/services/ExerciseService';
import { NostrEvent } from '@/types/nostr';
import { convertNostrToExercise } from '@/utils/converters';
export interface SyncProgress {
total: number;
processed: number;
status: 'idle' | 'syncing' | 'complete' | 'error';
message?: string;
}
export class NostrSyncService {
private db: SQLiteDatabase;
private eventCache: EventCache;
private exerciseService: ExerciseService;
private syncStatus: SyncProgress = {
total: 0,
processed: 0,
status: 'idle'
};
constructor(db: SQLiteDatabase) {
this.db = db;
this.eventCache = new EventCache(db);
this.exerciseService = new ExerciseService(db);
}
/**
* Get current sync status
*/
getSyncStatus(): SyncProgress {
return { ...this.syncStatus };
}
/**
* Synchronize user's library from Nostr
*/
async syncUserLibrary(
pubkey: string,
ndk: any, // Replace with NDK type
progressCallback?: (progress: SyncProgress) => void
): Promise<void> {
try {
this.syncStatus = {
total: 0,
processed: 0,
status: 'syncing',
message: 'Starting sync...'
};
if (progressCallback) progressCallback(this.syncStatus);
// 1. Fetch exercise events (kind 33401)
this.syncStatus.message = 'Fetching exercises...';
if (progressCallback) progressCallback(this.syncStatus);
const exercises = await this.fetchUserExercises(pubkey, ndk);
this.syncStatus.total = exercises.length;
this.syncStatus.message = `Processing ${exercises.length} exercises...`;
if (progressCallback) progressCallback(this.syncStatus);
// 2. Process each exercise
for (const exercise of exercises) {
await this.processExercise(exercise);
this.syncStatus.processed++;
if (progressCallback) progressCallback(this.syncStatus);
}
// 3. Update final status
this.syncStatus.status = 'complete';
this.syncStatus.message = 'Sync completed successfully';
if (progressCallback) progressCallback(this.syncStatus);
} catch (error) {
this.syncStatus.status = 'error';
this.syncStatus.message = `Sync error: ${error instanceof Error ? error.message : 'Unknown error'}`;
if (progressCallback) progressCallback(this.syncStatus);
throw error;
}
}
/**
* Fetch user's exercise events from Nostr
*/
private async fetchUserExercises(pubkey: string, ndk: any): Promise<NostrEvent[]> {
// Use NDK subscription to fetch exercise events (kind 33401)
return new Promise((resolve) => {
const exercises: NostrEvent[] = [];
const filter = { kinds: [33401], authors: [pubkey] };
const subscription = ndk.subscribe(filter);
subscription.on('event', (event: NostrEvent) => {
exercises.push(event);
});
subscription.on('eose', () => {
resolve(exercises);
});
});
}
/**
* Process and store an exercise event
*/
private async processExercise(event: NostrEvent): Promise<void> {
// 1. Check if we already have this event
const existingEvent = await this.eventCache.getEvent(event.id);
if (existingEvent) return;
// 2. Store the event
await this.eventCache.setEvent(event);
// 3. Convert to Exercise and store in exercises table
const exercise = convertNostrToExercise(event);
await this.exerciseService.createExercise(exercise);
}
}
```
## 3. UI Components
### 3.1 Modify SettingsDrawer.tsx
Update the existing SettingsDrawer component to include the new cache-related menu items:
```typescript
// Add these imports
import { useSQLiteContext } from 'expo-sqlite';
import { CacheService, CacheClearLevel } from '@/lib/services/CacheService';
import { NostrSyncService } from '@/lib/services/NostrSyncService';
import { formatBytes } from '@/utils/format';
// Update the menuItems array to include Data Management options:
const menuItems: MenuItem[] = [
// ... existing menu items
// Replace the "Data Sync" item with this:
{
id: 'data-management',
icon: Database,
label: 'Data Management',
onPress: () => {
closeDrawer();
router.push('/settings/data-management');
},
},
// ... other menu items
];
```
### 3.2 Create DataManagementScreen Component
Create a new screen for data management:
```typescript
// app/settings/data-management.tsx
import React, { useState, useEffect } from 'react';
import { View, ScrollView, ActivityIndicator } from 'react-native';
import { Text } from '@/components/ui/text';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useSQLiteContext } from 'expo-sqlite';
import { useNDK, useNDKCurrentUser } from '@/lib/hooks/useNDK';
import { CacheService, CacheClearLevel } from '@/lib/services/CacheService';
import { NostrSyncService, SyncProgress } from '@/lib/services/NostrSyncService';
import { formatBytes } from '@/utils/format';
import {
RefreshCw, Trash2, Database, AlertTriangle, CheckCircle, AlertCircle
} from 'lucide-react-native';
import { Progress } from '@/components/ui/progress';
import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
export default function DataManagementScreen() {
const db = useSQLiteContext();
const { ndk } = useNDK();
const { currentUser, isAuthenticated } = useNDKCurrentUser();
const [storageStats, setStorageStats] = useState({
userContent: 0,
networkContent: 0,
temporaryCache: 0,
total: 0
});
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [syncProgress, setSyncProgress] = useState<SyncProgress>({
total: 0,
processed: 0,
status: 'idle'
});
const [showClearCacheAlert, setShowClearCacheAlert] = useState(false);
const [clearCacheLevel, setClearCacheLevel] = useState<CacheClearLevel>(CacheClearLevel.RELAY_CACHE);
const [clearCacheLoading, setClearCacheLoading] = useState(false);
// Auto-sync settings
const [autoSyncEnabled, setAutoSyncEnabled] = useState(true);
// Load storage stats
useEffect(() => {
const loadStats = async () => {
try {
setLoading(true);
const cacheService = new CacheService(db);
const stats = await cacheService.getStorageStats();
setStorageStats(stats);
} catch (error) {
console.error('Error loading storage stats:', error);
} finally {
setLoading(false);
}
};
loadStats();
}, [db]);
// Handle manual sync
const handleSync = async () => {
if (!isAuthenticated || !currentUser?.pubkey || !ndk) return;
try {
setSyncing(true);
const syncService = new NostrSyncService(db);
await syncService.syncUserLibrary(
currentUser.pubkey,
ndk,
(progress) => {
setSyncProgress(progress);
}
);
} catch (error) {
console.error('Sync error:', error);
} finally {
setSyncing(false);
}
};
// Trigger clear cache alert
const handleClearCacheClick = (level: CacheClearLevel) => {
setClearCacheLevel(level);
setShowClearCacheAlert(true);
};
// Handle clear cache action
const handleClearCache = async () => {
if (!isAuthenticated && clearCacheLevel !== CacheClearLevel.RELAY_CACHE) {
return; // Only allow clearing relay cache if not authenticated
}
try {
setClearCacheLoading(true);
const cacheService = new CacheService(db);
await cacheService.clearCache(
clearCacheLevel,
isAuthenticated ? currentUser?.pubkey : undefined
);
// Refresh stats
const stats = await cacheService.getStorageStats();
setStorageStats(stats);
setShowClearCacheAlert(false);
} catch (error) {
console.error('Error clearing cache:', error);
} finally {
setClearCacheLoading(false);
}
};
// Calculate sync progress percentage
const syncPercentage = syncProgress.total > 0
? Math.round((syncProgress.processed / syncProgress.total) * 100)
: 0;
return (
<ScrollView className="flex-1 bg-background p-4">
<Text className="text-xl font-semibold mb-4">Data Management</Text>
{/* Storage Usage Section */}
<Card className="mb-6">
<CardContent className="pt-6">
<View className="flex-row items-center mb-4">
<Database size={20} className="text-primary mr-2" />
<Text className="text-lg font-semibold">Storage Usage</Text>
</View>
{loading ? (
<View className="items-center py-4">
<ActivityIndicator size="small" className="mb-2" />
<Text className="text-muted-foreground">Loading storage statistics...</Text>
</View>
) : (
<>
<View className="mb-4">
<Text className="text-sm mb-1">User Content</Text>
<View className="flex-row justify-between mb-2">
<Progress value={storageStats.userContent / storageStats.total * 100} className="flex-1 mr-2" />
<Text className="text-sm text-muted-foreground w-20 text-right">
{formatBytes(storageStats.userContent)}
</Text>
</View>
<Text className="text-sm mb-1">Network Content</Text>
<View className="flex-row justify-between mb-2">
<Progress value={storageStats.networkContent / storageStats.total * 100} className="flex-1 mr-2" />
<Text className="text-sm text-muted-foreground w-20 text-right">
{formatBytes(storageStats.networkContent)}
</Text>
</View>
<Text className="text-sm mb-1">Temporary Cache</Text>
<View className="flex-row justify-between mb-2">
<Progress value={storageStats.temporaryCache / storageStats.total * 100} className="flex-1 mr-2" />
<Text className="text-sm text-muted-foreground w-20 text-right">
{formatBytes(storageStats.temporaryCache)}
</Text>
</View>
</View>
<Separator className="mb-4" />
<View className="flex-row justify-between items-center">
<Text className="font-medium">Total Storage</Text>
<Text className="font-medium">{formatBytes(storageStats.total)}</Text>
</View>
</>
)}
</CardContent>
</Card>
{/* Sync Section */}
<Card className="mb-6">
<CardContent className="pt-6">
<View className="flex-row items-center mb-4">
<RefreshCw size={20} className="text-primary mr-2" />
<Text className="text-lg font-semibold">Sync</Text>
</View>
{!isAuthenticated ? (
<Text className="text-muted-foreground mb-4">
Login with Nostr to sync your library across devices.
</Text>
) : (
<>
{/* Auto-sync settings */}
<View className="flex-row justify-between items-center mb-4">
<View>
<Text className="font-medium">Auto-sync on startup</Text>
<Text className="text-sm text-muted-foreground">
Automatically sync data when you open the app
</Text>
</View>
<Switch
checked={autoSyncEnabled}
onCheckedChange={setAutoSyncEnabled}
/>
</View>
<Separator className="mb-4" />
{/* Sync status and controls */}
{syncing ? (
<View className="mb-4">
<View className="flex-row justify-between mb-2">
<Text className="text-sm">
{syncProgress.message || 'Syncing...'}
</Text>
<Text className="text-sm text-muted-foreground">
{syncProgress.processed}/{syncProgress.total}
</Text>
</View>
<Progress value={syncPercentage} className="mb-2" />
<Text className="text-xs text-muted-foreground text-center">
{syncPercentage}% complete
</Text>
</View>
) : (
<>
{syncProgress.status === 'complete' && (
<View className="flex-row items-center mb-4">
<CheckCircle size={16} className="text-primary mr-2" />
<Text className="text-sm">Last sync completed successfully</Text>
</View>
)}
{syncProgress.status === 'error' && (
<View className="flex-row items-center mb-4">
<AlertCircle size={16} className="text-destructive mr-2" />
<Text className="text-sm">{syncProgress.message}</Text>
</View>
)}
</>
)}
<Button
className="w-full"
onPress={handleSync}
disabled={syncing}
>
{syncing ? (
<>
<ActivityIndicator size="small" color="#fff" className="mr-2" />
<Text className="text-primary-foreground">Syncing...</Text>
</>
) : (
<>
<RefreshCw size={16} className="mr-2 text-primary-foreground" />
<Text className="text-primary-foreground">Sync Now</Text>
</>
)}
</Button>
</>
)}
</CardContent>
</Card>
{/* Cache Section */}
<Card className="mb-6">
<CardContent className="pt-6">
<View className="flex-row items-center mb-4">
<Trash2 size={20} className="text-primary mr-2" />
<Text className="text-lg font-semibold">Clear Cache</Text>
</View>
<View className="space-y-4">
<View>
<Button
variant="outline"
className="w-full mb-2"
onPress={() => handleClearCacheClick(CacheClearLevel.RELAY_CACHE)}
>
<Text>Clear Temporary Cache</Text>
</Button>
<Text className="text-xs text-muted-foreground">
Clears temporary data without affecting your workouts, exercises, or templates.
</Text>
</View>
<View>
<Button
variant="outline"
className="w-full mb-2"
onPress={() => handleClearCacheClick(CacheClearLevel.NETWORK_CONTENT)}
disabled={!isAuthenticated}
>
<Text>Clear Network Content</Text>
</Button>
<Text className="text-xs text-muted-foreground">
Clears exercises and templates from other users while keeping your own content.
</Text>
</View>
<View>
<Button
variant="destructive"
className="w-full mb-2"
onPress={() => handleClearCacheClick(CacheClearLevel.EVERYTHING)}
disabled={!isAuthenticated}
>
<Text className="text-destructive-foreground">Reset All Data</Text>
</Button>
<Text className="text-xs text-muted-foreground">
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.
</Text>
</View>
</View>
</CardContent>
</Card>
{/* Clear Cache Alert Dialog */}
<AlertDialog open={showClearCacheAlert} onOpenChange={setShowClearCacheAlert}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{clearCacheLevel === CacheClearLevel.RELAY_CACHE && "Clear Temporary Cache?"}
{clearCacheLevel === CacheClearLevel.NETWORK_CONTENT && "Clear Network Content?"}
{clearCacheLevel === CacheClearLevel.EVERYTHING && "Reset All Data?"}
</AlertDialogTitle>
<AlertDialogDescription>
{clearCacheLevel === CacheClearLevel.RELAY_CACHE && (
<Text>
This will clear temporary data from the app. Your workouts, exercises, and templates will not be affected.
</Text>
)}
{clearCacheLevel === CacheClearLevel.NETWORK_CONTENT && (
<Text>
This will clear exercises and templates from other users. Your own content will be preserved.
</Text>
)}
{clearCacheLevel === CacheClearLevel.EVERYTHING && (
<View className="space-y-2">
<View className="flex-row items-center">
<AlertTriangle size={16} className="text-destructive mr-2" />
<Text className="text-destructive font-semibold">Warning: This is destructive!</Text>
</View>
<Text>
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.
</Text>
</View>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onPress={() => setShowClearCacheAlert(false)}>
<Text>Cancel</Text>
</AlertDialogCancel>
<AlertDialogAction
onPress={handleClearCache}
className={clearCacheLevel === CacheClearLevel.EVERYTHING ? "bg-destructive" : ""}
>
<Text className={clearCacheLevel === CacheClearLevel.EVERYTHING ? "text-destructive-foreground" : ""}>
{clearCacheLoading ? "Clearing..." : "Clear"}
</Text>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ScrollView>
);
}
```
### 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 (
<View
className={cn("h-2 w-full overflow-hidden rounded-full bg-secondary", className)}
{...props}
>
<View
className={cn("h-full bg-primary", indicatorClassName)}
style={[
styles.indicator,
{ width: `${percentage}%` }
]}
/>
</View>
);
}
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.

View File

@ -1,4 +1,4 @@
# NIP-XX: Workout Events # NIP-4e: Workout Events
`draft` `optional` `draft` `optional`
@ -25,52 +25,144 @@ The event kinds in this NIP follow Nostr protocol conventions:
### Exercise Template (kind: 33401) ### 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. Defines reusable exercise definitions. These should remain public to enable discovery and sharing. The `content` field contains detailed form instructions and notes.
#### Required Tags #### Format
* `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`)
#### Optional Tags The format uses an _addressable event_ of `kind:33401`.
* `difficulty` - Skill level (possible values: `beginner`, `intermediate`, `advanced`)
* `imeta` - Media metadata for form demonstrations following NIP-92 format The `.content` of these events SHOULD be detailed instructions for proper exercise form. It is required but can be an empty string.
* `t` - Hashtags for categorization such as muscle group or body movement (possible values: `chest`, `legs`, `push`, `pull`)
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": <Unix timestamp in seconds>,
"kind": 33401,
"content": "<detailed form instructions and notes>",
"tags": [
["d", "<UUID>"],
["title", "<exercise name>"],
["format", "<parameter>", "<parameter>", "<parameter>", "<parameter>"],
["format_units", "<unit>", "<unit>", "<unit>", "<unit>"],
["equipment", "<equipment type>"],
["difficulty", "<skill level>"],
["imeta",
"url <url to demonstration media>",
"m <media type>",
"dim <dimensions>",
"alt <alt text>"
],
["t", "<hashtag>"],
["t", "<hashtag>"],
["t", "<hashtag>"]
]
}
```
### Workout Template (kind: 33402) ### 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. 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 #### Format
* `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]
#### Optional Tags The format uses an _addressable event_ of `kind:33402`.
* `rounds` - Number of rounds for repeating formats
* `duration` - Total workout duration in seconds The `.content` of these events SHOULD contain workout notes and instructions. It is required but can be an empty string.
* `interval` - Duration of each exercise portion in seconds (for timed workouts)
* `rest_between_rounds` - Rest time between rounds in seconds The list of tags are as follows:
* `t` - Hashtags for categorization
* `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", "<kind>:<pubkey>:<d-tag>", "<relay-url>", ...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": <Unix timestamp in seconds>,
"kind": 33402,
"content": "<workout notes and instructions>",
"tags": [
["d", "<UUID>"],
["title", "<workout name>"],
["type", "<workout type>"],
["rounds", "<number of rounds>"],
["duration", "<duration in seconds>"],
["interval", "<interval in seconds>"],
["rest_between_rounds", "<rest time in seconds>"],
["exercise", "<kind>:<pubkey>:<d-tag>", "<relay-url>", "<param1>", "<param2>", "<param3>", "<param4>"],
["exercise", "<kind>:<pubkey>:<d-tag>", "<relay-url>", "<param1>", "<param2>", "<param3>", "<param4>"],
["t", "<hashtag>"],
["t", "<hashtag>"]
]
}
```
### Workout Record (kind: 1301) ### Workout Record (kind: 1301)
Records a completed workout session. The `content` field contains notes about the workout. Records a completed workout session. The `content` field contains notes about the workout.
#### Required Tags #### Format
* `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
#### Optional Tags The format uses a standard event of `kind:1301`.
* `rounds_completed` - Number of rounds completed
* `interval` - Duration of each exercise portion in seconds (for timed workouts) The `.content` of these events SHOULD contain notes about the workout experience. It is required but can be an empty string.
* `template` - Reference to the workout template used, if any. Format: ["template", "33402:<pubkey>:<d-tag>", "<relay-url>"]
* `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) The list of tags are as follows:
* `t` - Hashtags for categorization
* `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", "<kind>:<pubkey>:<d-tag>", "<relay-url>", ...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", "<kind>:<pubkey>:<d-tag>", "<relay-url>"]
* `pr` (optional, repeated) - Personal Record achieved during workout. Format: "<kind>:<pubkey>:<d-tag>,<metric>,<value>"
* `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": <Unix timestamp in seconds>,
"kind": 1301,
"content": "<workout notes>",
"tags": [
["d", "<UUID>"],
["title", "<workout name>"],
["type", "<workout type>"],
["rounds_completed", "<number of rounds completed>"],
["start", "<Unix timestamp in seconds>"],
["end", "<Unix timestamp in seconds>"],
["exercise", "<kind>:<pubkey>:<d-tag>", "<relay-url>", "<weight>", "<reps>", "<rpe>", "<set_type>"],
["exercise", "<kind>:<pubkey>:<d-tag>", "<relay-url>", "<weight>", "<reps>", "<rpe>", "<set_type>"],
["template", "<kind>:<pubkey>:<d-tag>", "<relay-url>"],
["pr", "<kind>:<pubkey>:<d-tag>,<metric>,<value>"],
["completed", "<true/false>"],
["t", "<hashtag>"],
["t", "<hashtag>"]
]
}
```
## Exercise Parameters ## Exercise Parameters
@ -122,12 +214,12 @@ Sets where technical failure was reached before completing prescribed reps. Thes
## Examples ## Examples
### Exercise Template ### Exercise Template
```json ```
{ {
"kind": 33401, "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", "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": [ "tags": [
["d", "bb-deadlift-template"], ["d", "<UUID-deadlift>"],
["title", "Barbell Deadlift"], ["title", "Barbell Deadlift"],
["format", "weight", "reps", "rpe", "set_type"], ["format", "weight", "reps", "rpe", "set_type"],
["format_units", "kg", "count", "0-10", "warmup|normal|drop|failure"], ["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 ### EMOM Workout Template
```json ```
{ {
"kind": 33402, "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.", "content": "20 minute EMOM alternating between squats and deadlifts every 30 seconds. Scale weight as needed to complete all reps within each interval.",
"tags": [ "tags": [
["d", "lower-body-emom-template"], ["d", "<UUID-emom-template>"],
["title", "20min Squat/Deadlift EMOM"], ["title", "20min Squat/Deadlift EMOM"],
["type", "emom"], ["type", "emom"],
["duration", "1200"], ["duration", "1200"],
["rounds", "20"], ["rounds", "20"],
["interval", "30"], ["interval", "30"],
["exercise", "33401:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:bb-back-squat-template", "wss://powr.me", "", "5", "7", "normal"], ["exercise", "33401:<pubkey>:<UUID-squat>", "<relay-url>", "", "5", "7", "normal"],
["exercise", "33401:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:bb-deadlift-template", "wss://powr.me", "", "4", "7", "normal"], ["exercise", "33401:<pubkey>:<UUID-deadlift>", "<relay-url>", "", "4", "7", "normal"],
["t", "conditioning"], ["t", "conditioning"],
["t", "legs"] ["t", "legs"]
@ -169,25 +261,23 @@ Sets where technical failure was reached before completing prescribed reps. Thes
``` ```
### Circuit Workout Record ### Circuit Workout Record
```json ```
{ {
"kind": 1301, "kind": 1301,
"content": "Completed first round as prescribed. Second round showed form deterioration on deadlifts.", "content": "Completed first round as prescribed. Second round showed form deterioration on deadlifts.",
"tags": [ "tags": [
["d", "workout-20250128"], ["d", "<UUID-workout-record>"],
["title", "Leg Circuit"], ["title", "Leg Circuit"],
["type", "circuit"], ["type", "circuit"],
["rounds_completed", "1.5"], ["rounds_completed", "1.5"],
["start", "1706454000"], ["start", "1706454000"],
["end", "1706455800"], ["end", "1706455800"],
// Round 1 - Completed as prescribed ["exercise", "33401:<pubkey>:<UUID-squat>", "<relay-url>", "80", "12", "7", "normal"],
["exercise", "33401:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:bb-back-squat-template", "wss://powr.me", "80", "12", "7", "normal"], ["exercise", "33401:<pubkey>:<UUID-deadlift>", "<relay-url>", "100", "10", "7", "normal"],
["exercise", "33401:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:bb-deadlift-template", "wss://powr.me", "100", "10", "7", "normal"],
// Round 2 - Failed on deadlifts ["exercise", "33401:<pubkey>:<UUID-squat>", "<relay-url>", "80", "12", "8", "normal"],
["exercise", "33401:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:bb-back-squat-template", "wss://powr.me", "80", "12", "8", "normal"], ["exercise", "33401:<pubkey>:<UUID-deadlift>", "<relay-url>", "100", "4", "10", "failure"],
["exercise", "33401:9947f9659dd80c3682402b612f5447e28249997fb3709500c32a585eb0977340:bb-deadlift-template", "wss://powr.me", "100", "4", "10", "failure"],
["completed", "false"], ["completed", "false"],
["t", "legs"] ["t", "legs"]
@ -197,16 +287,17 @@ Sets where technical failure was reached before completing prescribed reps. Thes
## Implementation Guidelines ## 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 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 3. Records MUST include actual values for all parameters defined in exercise format
4. Failed sets SHOULD be marked with `failure` set_type 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 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 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 ## References
This NIP draws inspiration from: This NIP draws inspiration from:
- [NIP-01: Basic Protocol Flow Description](https://github.com/nostr-protocol/nips/blob/master/01.md) - [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) - [NIP-92: Media Attachments](https://github.com/nostr-protocol/nips/blob/master/92.md#nip-92)

View File

@ -9,10 +9,11 @@ const PRIVATE_KEY_STORAGE_KEY = 'nostr_privkey';
// Default relays // Default relays
const DEFAULT_RELAYS = [ const DEFAULT_RELAYS = [
'wss://relay.damus.io', 'ws://localhost:8080', // Add your local test relay
'wss://relay.nostr.band', //'wss://relay.damus.io',
'wss://purplepag.es', //'wss://relay.nostr.band',
'wss://nos.lol' //'wss://purplepag.es',
//'wss://nos.lol'
]; ];
// Helper function to convert Array/Uint8Array to hex string // Helper function to convert Array/Uint8Array to hex string

1
package-lock.json generated
View File

@ -10,6 +10,7 @@
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@expo/cli": "^0.22.16", "@expo/cli": "^0.22.16",
"@noble/secp256k1": "^2.2.3",
"@nostr-dev-kit/ndk": "^2.12.0", "@nostr-dev-kit/ndk": "^2.12.0",
"@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-alert-dialog": "^1.1.6",
"@react-native-clipboard/clipboard": "^1.16.1", "@react-native-clipboard/clipboard": "^1.16.1",

View File

@ -24,6 +24,7 @@
}, },
"dependencies": { "dependencies": {
"@expo/cli": "^0.22.16", "@expo/cli": "^0.22.16",
"@noble/secp256k1": "^2.2.3",
"@nostr-dev-kit/ndk": "^2.12.0", "@nostr-dev-kit/ndk": "^2.12.0",
"@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-alert-dialog": "^1.1.6",
"@react-native-clipboard/clipboard": "^1.16.1", "@react-native-clipboard/clipboard": "^1.16.1",

View File

@ -1,32 +1,43 @@
// types/nostr.ts // types/nostr.ts
export interface NostrEvent { export interface NostrEvent {
id?: string; id?: string;
pubkey?: string; pubkey?: string;
content: string; content: string;
created_at: number; created_at: number;
kind: number; kind: number;
tags: string[][]; tags: string[][];
sig?: 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 { return {
EXERCISE = 33401, reference: templateTag[1],
TEMPLATE = 33402, relay: templateTag[2]
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]);
}