mirror of
https://github.com/DocNR/POWR.git
synced 2025-04-23 01:01:27 +00:00
update to nostr exercise nip and minor UI
This commit is contained in:
parent
dca4ef5b33
commit
173e4e31e4
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -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: {
|
||||||
|
@ -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>
|
||||||
|
@ -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"
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
960
docs/design/cache-management.md
Normal file
960
docs/design/cache-management.md
Normal 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.
|
@ -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)
|
@ -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
1
package-lock.json
generated
@ -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",
|
||||||
|
@ -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",
|
||||||
|
@ -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]);
|
|
||||||
}
|
|
Loading…
x
Reference in New Issue
Block a user