mirror of
https://github.com/DocNR/POWR.git
synced 2025-06-06 01:02:09 +00:00
fixed infinite loop in local/all workout toggle in history view
This commit is contained in:
parent
e81012841f
commit
08bb9884bc
@ -139,6 +139,16 @@ export default function HistoryScreen() {
|
||||
const [includeNostr, setIncludeNostr] = useState(true);
|
||||
const [isLoginSheetOpen, setIsLoginSheetOpen] = useState(false);
|
||||
|
||||
// Create memoized filters to prevent recreation on every render
|
||||
const filters = React.useMemo(() => {
|
||||
if (includeNostr) {
|
||||
return undefined;
|
||||
} else {
|
||||
// Explicitly type the array to match WorkoutFilters interface
|
||||
return { source: ['local' as const] };
|
||||
}
|
||||
}, [includeNostr]);
|
||||
|
||||
// Use the unified workout history hook
|
||||
const {
|
||||
workouts: allWorkouts,
|
||||
@ -148,29 +158,30 @@ export default function HistoryScreen() {
|
||||
error
|
||||
} = useWorkoutHistory({
|
||||
includeNostr,
|
||||
filters: includeNostr ? undefined : { source: ['local'] },
|
||||
filters,
|
||||
realtime: true
|
||||
});
|
||||
|
||||
// Set workouts from the hook
|
||||
// Set workouts from the hook with more careful state updates
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
setIsLoading(true);
|
||||
return; // Exit early to avoid multiple state updates in one effect
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setRefreshing(false);
|
||||
|
||||
// Check if we need to use mock data (empty workouts)
|
||||
if (allWorkouts.length === 0 && !error) {
|
||||
console.log('No workouts found, using mock data');
|
||||
setWorkouts(mockWorkouts);
|
||||
setUseMockData(true);
|
||||
} else {
|
||||
setWorkouts(allWorkouts);
|
||||
setIsLoading(false);
|
||||
setRefreshing(false);
|
||||
|
||||
// Check if we need to use mock data (empty workouts)
|
||||
if (allWorkouts.length === 0 && !error) {
|
||||
console.log('No workouts found, using mock data');
|
||||
setWorkouts(mockWorkouts);
|
||||
setUseMockData(true);
|
||||
} else {
|
||||
setUseMockData(false);
|
||||
}
|
||||
setUseMockData(false);
|
||||
}
|
||||
}, [allWorkouts, loading, error]);
|
||||
}, [allWorkouts, loading, error, mockWorkouts]);
|
||||
|
||||
// Pull to refresh handler
|
||||
const onRefresh = React.useCallback(() => {
|
||||
@ -231,23 +242,54 @@ export default function HistoryScreen() {
|
||||
)}
|
||||
|
||||
{isAuthenticated && (
|
||||
<View className="flex-row justify-end mb-4">
|
||||
<Pressable
|
||||
onPress={() => setIncludeNostr(!includeNostr)}
|
||||
style={{
|
||||
backgroundColor: includeNostr ? primaryBgColor : mutedBgColor,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 9999,
|
||||
}}
|
||||
>
|
||||
<Text style={{
|
||||
color: includeNostr ? primaryTextColor : mutedTextColor,
|
||||
fontSize: 14,
|
||||
}}>
|
||||
{includeNostr ? 'Showing All Workouts' : 'Local Workouts Only'}
|
||||
<View className="mb-4">
|
||||
<View className="flex-row justify-between items-center">
|
||||
<Text className="text-foreground text-sm font-medium">
|
||||
Workout Source
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<View className="flex-row border border-border rounded-full overflow-hidden">
|
||||
<Pressable
|
||||
onPress={() => setIncludeNostr(true)}
|
||||
style={{
|
||||
backgroundColor: includeNostr ? primaryBgColor : 'transparent',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
}}
|
||||
>
|
||||
<Text style={{
|
||||
color: includeNostr ? primaryTextColor : mutedTextColor,
|
||||
fontSize: 14,
|
||||
fontWeight: includeNostr ? '600' : '400',
|
||||
}}>
|
||||
All Workouts
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={() => setIncludeNostr(false)}
|
||||
style={{
|
||||
backgroundColor: !includeNostr ? primaryBgColor : 'transparent',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
}}
|
||||
>
|
||||
<Text style={{
|
||||
color: !includeNostr ? primaryTextColor : mutedTextColor,
|
||||
fontSize: 14,
|
||||
fontWeight: !includeNostr ? '600' : '400',
|
||||
}}>
|
||||
Local Only
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text className="text-muted-foreground text-xs mt-1">
|
||||
{includeNostr
|
||||
? 'Showing all workouts from Nostr and your local device'
|
||||
: 'Only showing workouts saved on this device'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,261 +0,0 @@
|
||||
# NDK Functions for Hex Keys and NIP-19 Encoding/Decoding
|
||||
|
||||
When working with Nostr addresses (like naddr) and converting between hex and other formats, NDK provides several key functions. Here's a comprehensive overview of the main functions you'll need for handling hex keys and NIP-19 encoding/decoding in your POWR Pack implementation:
|
||||
|
||||
## Core NIP-19 Functions
|
||||
|
||||
NDK implements NIP-19 functionality in the `events/nip19.ts` file. The key functions you'll need are:
|
||||
|
||||
### 1. Decoding NIP-19 Entities
|
||||
|
||||
```typescript
|
||||
import { nip19 } from '@nostr-dev-kit/ndk';
|
||||
|
||||
// Decode any NIP-19 entity (naddr, npub, nsec, note, etc.)
|
||||
function decodeNaddr(naddrString: string) {
|
||||
try {
|
||||
const decoded = nip19.decode(naddrString);
|
||||
|
||||
// For naddr specifically, you'll get:
|
||||
if (decoded.type === 'naddr') {
|
||||
const { pubkey, kind, identifier } = decoded.data;
|
||||
|
||||
// pubkey is the hex public key of the author
|
||||
// kind is the event kind (30004 for lists)
|
||||
// identifier is the 'd' tag value
|
||||
|
||||
console.log('Hex pubkey:', pubkey);
|
||||
console.log('Event kind:', kind);
|
||||
console.log('Identifier:', identifier);
|
||||
|
||||
return decoded.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Invalid NIP-19 format:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Encoding to NIP-19 Formats
|
||||
|
||||
```typescript
|
||||
// Create an naddr from components
|
||||
function createNaddr(pubkey: string, kind: number, identifier: string) {
|
||||
return nip19.naddrEncode({
|
||||
pubkey, // Hex pubkey
|
||||
kind, // Event kind (number)
|
||||
identifier // The 'd' tag value
|
||||
});
|
||||
}
|
||||
|
||||
// Create an npub from a hex public key
|
||||
function hexToNpub(hexPubkey: string) {
|
||||
return nip19.npubEncode(hexPubkey);
|
||||
}
|
||||
|
||||
// Create a note (event reference) from event ID
|
||||
function eventIdToNote(eventId: string) {
|
||||
return nip19.noteEncode(eventId);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Utility Functions for Hex Keys
|
||||
|
||||
```typescript
|
||||
// Convert npub to hex pubkey
|
||||
function npubToHex(npub: string) {
|
||||
try {
|
||||
const decoded = nip19.decode(npub);
|
||||
if (decoded.type === 'npub') {
|
||||
return decoded.data as string; // This is the hex pubkey
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Invalid npub format:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a string is a valid hex key (pubkey or event id)
|
||||
function isValidHexKey(hexString: string) {
|
||||
return /^[0-9a-f]{64}$/i.test(hexString);
|
||||
}
|
||||
```
|
||||
|
||||
## Using NIP-19 Functions with NDK Filters
|
||||
|
||||
Here's how you would use these functions with NDK filters to fetch a POWR Pack from an naddr:
|
||||
|
||||
```typescript
|
||||
async function fetchPackFromNaddr(naddr: string) {
|
||||
try {
|
||||
// Decode the naddr to get event coordinates
|
||||
const decoded = nip19.decode(naddr);
|
||||
|
||||
if (decoded.type !== 'naddr') {
|
||||
throw new Error('Not an naddr');
|
||||
}
|
||||
|
||||
const { pubkey, kind, identifier } = decoded.data;
|
||||
|
||||
// Ensure it's a list (kind 30004)
|
||||
if (kind !== 30004) {
|
||||
throw new Error('Not a NIP-51 list');
|
||||
}
|
||||
|
||||
// Create a filter to fetch the specific list event
|
||||
const filter = {
|
||||
kinds: [kind],
|
||||
authors: [pubkey], // Using the hex pubkey from the naddr
|
||||
'#d': identifier ? [identifier] : undefined, // Using the d-tag if available
|
||||
};
|
||||
|
||||
// Fetch the event
|
||||
const events = await ndk.fetchEvents(filter);
|
||||
|
||||
if (events.size === 0) {
|
||||
throw new Error('Pack not found');
|
||||
}
|
||||
|
||||
// Get the first matching event
|
||||
return Array.from(events)[0];
|
||||
} catch (error) {
|
||||
console.error('Error fetching pack:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementing the Complete naddr Workflow for POWR Packs
|
||||
|
||||
Here's a complete example for fetching and processing a POWR Pack from an naddr:
|
||||
|
||||
```typescript
|
||||
import NDK, { NDKEvent, NDKFilter, nip19 } from '@nostr-dev-kit/ndk';
|
||||
|
||||
async function fetchAndProcessPOWRPack(naddr: string) {
|
||||
// 1. Initialize NDK
|
||||
const ndk = new NDK({
|
||||
explicitRelayUrls: [
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.nostr.band'
|
||||
]
|
||||
});
|
||||
await ndk.connect();
|
||||
|
||||
// 2. Decode the naddr
|
||||
const decoded = nip19.decode(naddr);
|
||||
if (decoded.type !== 'naddr') {
|
||||
throw new Error('Invalid naddr format');
|
||||
}
|
||||
|
||||
const { pubkey, kind, identifier } = decoded.data;
|
||||
|
||||
// 3. Create filter to fetch the pack event
|
||||
const packFilter: NDKFilter = {
|
||||
kinds: [kind],
|
||||
authors: [pubkey],
|
||||
'#d': identifier ? [identifier] : undefined
|
||||
};
|
||||
|
||||
// 4. Fetch the pack event
|
||||
const packEvents = await ndk.fetchEvents(packFilter);
|
||||
if (packEvents.size === 0) {
|
||||
throw new Error('Pack not found');
|
||||
}
|
||||
|
||||
const packEvent = Array.from(packEvents)[0];
|
||||
|
||||
// 5. Extract template and exercise references
|
||||
const templateRefs: string[] = [];
|
||||
const exerciseRefs: string[] = [];
|
||||
|
||||
for (const tag of packEvent.tags) {
|
||||
if (tag[0] === 'a') {
|
||||
const addressPointer = tag[1];
|
||||
// Format is kind:pubkey:d-tag
|
||||
if (addressPointer.startsWith('33402:')) { // Workout template
|
||||
templateRefs.push(addressPointer);
|
||||
} else if (addressPointer.startsWith('33401:')) { // Exercise
|
||||
exerciseRefs.push(addressPointer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Fetch templates and exercises
|
||||
const templates = await fetchReferencedEvents(ndk, templateRefs);
|
||||
const exercises = await fetchReferencedEvents(ndk, exerciseRefs);
|
||||
|
||||
// 7. Return the complete pack data
|
||||
return {
|
||||
pack: packEvent,
|
||||
templates,
|
||||
exercises
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to fetch events from address pointers
|
||||
async function fetchReferencedEvents(ndk: NDK, addressPointers: string[]) {
|
||||
const events: NDKEvent[] = [];
|
||||
|
||||
for (const pointer of addressPointers) {
|
||||
// Parse the pointer (kind:pubkey:d-tag)
|
||||
const [kindStr, hexPubkey, dTag] = pointer.split(':');
|
||||
const kind = parseInt(kindStr);
|
||||
|
||||
// Create a filter to find this specific event
|
||||
const filter: NDKFilter = {
|
||||
kinds: [kind],
|
||||
authors: [hexPubkey]
|
||||
};
|
||||
|
||||
if (dTag) {
|
||||
filter['#d'] = [dTag];
|
||||
}
|
||||
|
||||
// Fetch the events
|
||||
const fetchedEvents = await ndk.fetchEvents(filter);
|
||||
events.push(...Array.from(fetchedEvents));
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
```
|
||||
|
||||
## Creating naddr for Sharing Packs
|
||||
|
||||
If you want to generate an naddr that can be shared to allow others to import your POWR Pack:
|
||||
|
||||
```typescript
|
||||
function createShareableNaddr(packEvent: NDKEvent) {
|
||||
// Extract the d-tag (identifier)
|
||||
const dTags = packEvent.getMatchingTags('d');
|
||||
const identifier = dTags[0]?.[1] || '';
|
||||
|
||||
// Create the naddr
|
||||
const naddr = nip19.naddrEncode({
|
||||
pubkey: packEvent.pubkey,
|
||||
kind: packEvent.kind,
|
||||
identifier
|
||||
});
|
||||
|
||||
return naddr;
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices for Working with NIP-19 Formats
|
||||
|
||||
1. **Always validate decoded values**: Check that the decoded data is of the expected type and has the necessary properties.
|
||||
|
||||
2. **Handle encoding/decoding errors**: These functions can throw exceptions if the input is malformed.
|
||||
|
||||
3. **Normalize hex keys**: Convert to lowercase for consistency in filters and comparisons.
|
||||
|
||||
4. **Check event kinds**: Verify that the decoded event kind matches what you expect (30004 for NIP-51 lists).
|
||||
|
||||
5. **Use strong typing**: TypeScript's type system can help catch errors with NIP-19 data.
|
||||
|
||||
The main challenge when working with naddr and other NIP-19 formats is correctly translating between the human-readable encoded forms and the internal hex representations needed for Nostr protocol operations. NDK's nip19 module abstracts this complexity for you, allowing you to focus on the core business logic of your application.
|
@ -1,204 +0,0 @@
|
||||
# ARCHIVED: This document has moved
|
||||
|
||||
**NOTE:** This document has been migrated to [docs/project/mvp_and_rebuild.md](../project/mvp_and_rebuild.md) as part of the documentation reorganization.
|
||||
|
||||
# POWR App Roadmap - MVP and Social Rebuild
|
||||
|
||||
## MVP Definition
|
||||
|
||||
The Minimum Viable Product (MVP) will focus on core functionality while simplifying social features:
|
||||
|
||||
### Core Features (MVP Priority)
|
||||
- Complete workout tracking and history
|
||||
- Exercise library and template management
|
||||
- POWR Pack support
|
||||
- Basic Nostr integration:
|
||||
- Ability to publish kind 1301 workout records
|
||||
- Ability to share workouts with kind 1 notes (quoting 1301 records)
|
||||
- NIP-89 compliance for app identification
|
||||
|
||||
### Simplified Social Implementation
|
||||
- Social tab with "Coming Soon" placeholder or minimal POWR official feed
|
||||
- Profile tab with limited social activity display
|
||||
- Workout sharing from completion flow (with simplified UI)
|
||||
- Add workout sharing from history tab
|
||||
|
||||
## Current Technical Challenges
|
||||
|
||||
### Authentication Issues
|
||||
- Inconsistent auth state management causing cascading problems
|
||||
- Logout process triggering uncoordinated state changes
|
||||
|
||||
### Subscription Management Problems
|
||||
- Subscription lifecycle not properly managed
|
||||
- Resources not being cleaned up consistently
|
||||
|
||||
### React Hook Implementation
|
||||
- "Rendered fewer hooks than expected" errors
|
||||
- Component lifecycle hook management issues
|
||||
|
||||
### Component Coupling
|
||||
- Tight interdependencies between components
|
||||
- Difficulty isolating fixes for individual components
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: MVP Stabilization (Current Focus)
|
||||
- Implement fundamental architecture improvements:
|
||||
- Authentication state management with clear lifecycle hooks
|
||||
- Basic subscription management improvements
|
||||
- Simplify or disable problematic social features
|
||||
- Add workout sharing from history tab
|
||||
- Ensure stable workout tracking, history, and template management
|
||||
- Fix critical bugs in core functionality
|
||||
|
||||
### Phase 2: Social Foundation Rebuild (Post-MVP)
|
||||
- Complete targeted rebuild of authentication and subscription management
|
||||
- Implement proper data layer with caching
|
||||
- Create clear separation between data and UI layers
|
||||
- Develop and test in parallel with MVP branch
|
||||
|
||||
### Phase 3: Social Feature Re-implementation
|
||||
- Gradually re-enable social features using new architecture
|
||||
- Start with simplest screens (e.g., official POWR feed)
|
||||
- Progress to more complex screens (Following, Global)
|
||||
- Implement enhanced profile activity view
|
||||
|
||||
### Phase 4: Extended Features
|
||||
- Amber integration for Android users
|
||||
- Enhanced social features beyond original implementation
|
||||
- Additional Nostr integrations and social capabilities
|
||||
|
||||
## Architecture Design
|
||||
|
||||
### 1. Authentication State Management
|
||||
- Implementation of a proper state machine pattern
|
||||
- Clear transitions: unauthenticated → authenticating → authenticated → deauthenticating
|
||||
- Use of Zustand store (aligned with current workoutStore approach)
|
||||
- Event listeners/callbacks for components to respond to auth changes
|
||||
|
||||
### 2. Subscription Management
|
||||
- Centralized service for managing subscriptions
|
||||
- Automatic tracking and cleanup of subscriptions
|
||||
- Integration with component lifecycle
|
||||
- Rate limiting and cooldown mechanisms
|
||||
|
||||
### 3. Data Layer Design
|
||||
- Clean separation between data fetching and UI components
|
||||
- Proper caching with expiration policies
|
||||
- Offline support strategy
|
||||
- Clear interfaces for data services
|
||||
|
||||
### 4. UI Component Structure
|
||||
- Consistent component patterns across social features
|
||||
- Proper error boundaries and loading states
|
||||
- Better separation of concerns between components
|
||||
- Rebuilt social feed components with cleaner architecture
|
||||
|
||||
## Git and Release Strategy
|
||||
|
||||
### Branch Strategy
|
||||
- Create `mvp` branch from current state
|
||||
- Implement MVP simplifications and critical fixes in this branch
|
||||
- In parallel, start architecture rebuild in `social-rebuild` branch
|
||||
- Once MVP is released, gradually merge rebuilt components from `social-rebuild` to `main`
|
||||
|
||||
### Feature Flag Implementation
|
||||
- Add configuration system for feature toggling
|
||||
- Create conditional rendering for social features
|
||||
- Define clear interfaces between components to allow swapping implementations
|
||||
- Store feature flag state in persistent storage for consistency across app launches
|
||||
|
||||
### Release Plan
|
||||
1. iOS TestFlight (MVP)
|
||||
2. Implement Amber integration and final Android preparations
|
||||
3. Android Google Play / APK release
|
||||
4. Gradual social feature re-enablement through app updates
|
||||
|
||||
## Key Files to Modify
|
||||
|
||||
### MVP Initial Changes
|
||||
- `app/(tabs)/social/_layout.tsx` - Add "Coming Soon" placeholder or simplified view
|
||||
- `components/workout/WorkoutCompletionFlow.tsx` - Ensure sharing functionality is stable
|
||||
- `lib/db/services/NostrWorkoutService.ts` - Review for stability and proper NIP-89 implementation
|
||||
- `app/(tabs)/history/workoutHistory.tsx` - Add sharing capability
|
||||
|
||||
### Core Architecture Improvements
|
||||
- `lib/stores/ndk.ts` - Enhance with better auth management
|
||||
- `lib/hooks/useNDK.ts` - Refactor for more predictable state management
|
||||
- `components/RelayInitializer.tsx` - Review for subscription management issues
|
||||
- `lib/hooks/useSubscribe.ts` - Improve subscription lifecycle management
|
||||
|
||||
### Future Rebuild Targets (Post-MVP)
|
||||
- `lib/hooks/useSocialFeed.ts` - Replace with new service
|
||||
- `lib/social/socialFeedService.ts` - Refactor with cleaner architecture
|
||||
- `app/(tabs)/social/*` - Rebuild social feed screens with new architecture
|
||||
- `components/social/*` - Rebuild social components with consistent patterns
|
||||
|
||||
## Development Timeline
|
||||
|
||||
### 1. Architecture Design: 2-3 days
|
||||
- Create detailed service interfaces
|
||||
- Design state management approach
|
||||
- Document component lifecycle integration
|
||||
|
||||
### 2. Core Service Implementation: 3-5 days
|
||||
- Build authentication manager
|
||||
- Implement subscription manager
|
||||
- Create data fetching services
|
||||
|
||||
### 3. UI Component Rebuild: 5-7 days
|
||||
- Rebuild one screen at a time
|
||||
- Implement with new architectural patterns
|
||||
- Add comprehensive error handling
|
||||
|
||||
### 4. Testing and Integration: 2-3 days
|
||||
- Test with various network conditions
|
||||
- Verify authentication edge cases
|
||||
- Confirm subscription cleanup
|
||||
|
||||
### 5. Cleanup and Documentation: 1-2 days
|
||||
- Remove deprecated code
|
||||
- Document new architecture
|
||||
- Create developer onboarding guide
|
||||
|
||||
## Risk Mitigation
|
||||
- Implement feature flags to toggle between old and new implementations
|
||||
- Add enhanced logging during transition
|
||||
- Create robust error boundaries to prevent cascade failures
|
||||
- Maintain backward compatibility for core APIs during migration
|
||||
|
||||
## Original Requirements and Questions
|
||||
|
||||
### Simplified MVP Social Experience
|
||||
- Minimal or no social feed
|
||||
- Replace social tab with "Coming Soon" placeholder
|
||||
- Focus on core functionality:
|
||||
- Allow users to post kind 1 notes quoting 1301 workout records
|
||||
- Publishing workflow:
|
||||
1. User performs workout
|
||||
2. User completes workout and opts to share publicly
|
||||
3. User edits pre-populated kind 1 note and submits
|
||||
4. App publishes kind 1301 workout record, then publishes kind 1 note quoting the record
|
||||
5. Result: kind 1 note published to socials, kind 1301 record visible in workout history
|
||||
- Implement NIP-89 for app identification in published records
|
||||
|
||||
### Key Questions Addressed
|
||||
|
||||
#### Impact on Workout History Functionality
|
||||
The targeted rebuild approach preserves workout history functionality by focusing primarily on problematic social components. Core authentication and subscription management improvements will benefit the entire app without disrupting workflow.
|
||||
|
||||
#### MVP Architecture Requirements
|
||||
For a stable MVP with limited social features, we recommend implementing the fundamental Authentication state management and Subscription Management components. These are foundational and will improve stability across all features that use Nostr integration.
|
||||
|
||||
#### Caching Services
|
||||
Existing caching for user metadata can likely be preserved with clearer interfaces. For the MVP, we can simplify how these caches are used rather than fully rebuilding them.
|
||||
|
||||
#### Workout History Sharing
|
||||
Adding the ability to share workouts from the history tab would be valuable and consistent with the completion flow sharing functionality. This will require a review of local vs. Nostr event strategies.
|
||||
|
||||
#### Amber Integration
|
||||
Amber integration should be prioritized after the initial iOS TestFlight release but before wider Android distribution.
|
||||
|
||||
#### Git Strategy
|
||||
Creating an `mvp` branch from the current state makes sense for the MVP implementation. The feature flag approach will allow gradual introduction of rebuilt components without disrupting the user experience.
|
@ -1,211 +0,0 @@
|
||||
# POWR Pack Implementation Document
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the implementation plan for creating a "POWR Pack" feature in the POWR fitness app. POWR Packs are shareable collections of workout templates and exercises that users can import into their app. This feature leverages the Nostr protocol (NIP-51 lists) to enable decentralized sharing of fitness content.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
1. **POWR Pack**: A collection of workout templates and exercises stored as a NIP-51 list (kind 30004 "Curation set")
|
||||
2. **Pack Sharing**: Packs are shared via `naddr1` links that encode references to the collection
|
||||
3. **Selective Import**: Users can select which templates/exercises to import from a pack
|
||||
4. **Dependency Management**: When selecting a template, all required exercises are automatically selected
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Database Schema Extensions
|
||||
|
||||
Add new tables to track imported packs and their contents:
|
||||
|
||||
```sql
|
||||
-- POWR Packs table
|
||||
CREATE TABLE powr_packs (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
author_pubkey TEXT,
|
||||
nostr_event_id TEXT,
|
||||
import_date INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- POWR Pack items table
|
||||
CREATE TABLE powr_pack_items (
|
||||
pack_id TEXT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
item_type TEXT NOT NULL,
|
||||
item_order INTEGER,
|
||||
PRIMARY KEY (pack_id, item_id),
|
||||
FOREIGN KEY (pack_id) REFERENCES powr_packs(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
### 2. New Service: POWRPackService
|
||||
|
||||
Create a new service in `lib/db/services/POWRPackService.ts` with these key methods:
|
||||
|
||||
- `fetchPackFromNaddr(naddr: string)`: Fetch a pack and its content from Nostr
|
||||
- `importPack(pack, templates, exercises, selectedIds)`: Import selected items to local database
|
||||
- `getImportedPacks()`: List all imported packs with metadata
|
||||
- `deletePack(packId, keepItems)`: Remove a pack while optionally keeping its content
|
||||
|
||||
### 3. UI Components
|
||||
|
||||
#### Settings Integration
|
||||
|
||||
Add POWR Packs to the settings drawer:
|
||||
- "Import POWR Pack" item
|
||||
- "Manage POWR Packs" item
|
||||
|
||||
#### Import Flow
|
||||
|
||||
Create screen at `app/(packs)/import.tsx`:
|
||||
- Input field for naddr
|
||||
- Pack details display
|
||||
- Selectable list of templates
|
||||
- Selectable list of exercises with auto-selection based on template dependencies
|
||||
- Import button
|
||||
|
||||
#### Management Interface
|
||||
|
||||
Create screen at `app/(packs)/manage.tsx`:
|
||||
- List of imported packs
|
||||
- Pack details (templates/exercises count, import date)
|
||||
- Delete functionality
|
||||
|
||||
#### Social Discovery
|
||||
|
||||
Add a section to the social tab:
|
||||
- Horizontal scrolling list of available packs
|
||||
- Tap to view/import a pack
|
||||
|
||||
### 4. Routing
|
||||
|
||||
Configure routing in `app/(packs)/_layout.tsx`:
|
||||
- Import screen as modal
|
||||
- Management screen as standard page
|
||||
|
||||
## Technical Implementation Details
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **Pack Creation**: Exercise → Template → Pack (we've validated this flow works via NAK tests)
|
||||
2. **Pack Import**:
|
||||
- Decode naddr
|
||||
- Fetch pack event and referenced content
|
||||
- Parse Nostr events to POWR model objects
|
||||
- Save selected items to database
|
||||
|
||||
### Dependency Management
|
||||
|
||||
When users select a workout template, the system will:
|
||||
1. Identify all exercises referenced by the template
|
||||
2. Automatically select these exercises (shown as "required")
|
||||
3. Prevent deselection of required exercises
|
||||
|
||||
### Integration with Existing Services
|
||||
|
||||
- **NostrWorkoutService**: Use existing conversion methods between Nostr events and app models
|
||||
- **LibraryService**: Update to query content from imported packs
|
||||
- **NDK**: Use for fetching Nostr events and managing relay connections
|
||||
|
||||
## Sharing UI Mockups
|
||||
|
||||
### Import Screen
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ Import POWR Pack │
|
||||
├─────────────────────────────┤
|
||||
│ ┌───────────────────────┐ │
|
||||
│ │ naddr1... │ │
|
||||
│ └───────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────┐ │
|
||||
│ │ Fetch Pack │ │
|
||||
│ └─────────────┘ │
|
||||
│ │
|
||||
│ Pack Name │
|
||||
│ Description text here... │
|
||||
│ │
|
||||
│ Templates │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ ☑ Beginner Full Body │ │
|
||||
│ │ Strength workout │ │
|
||||
│ └─────────────────────────┘ │
|
||||
│ │
|
||||
│ Exercises │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ ☑ Squat │ │
|
||||
│ │ Required by template │ │
|
||||
│ └─────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────┐ │
|
||||
│ │ Import 3 items │ │
|
||||
│ └───────────────────────┘ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### Management Screen
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ Manage POWR Packs │
|
||||
├─────────────────────────────┤
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ POWR Test Pack [🗑]│ │
|
||||
│ │ A test collection... │ │
|
||||
│ │ │ │
|
||||
│ │ 2 templates • 2 exercises│
|
||||
│ │ Imported 2 days ago │ │
|
||||
│ └─────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ Beginner Pack [🗑]│ │
|
||||
│ │ For new users... │ │
|
||||
│ │ │ │
|
||||
│ │ 3 templates • 5 exercises│
|
||||
│ │ Imported 1 week ago │ │
|
||||
│ └─────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### Social Discovery
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ │
|
||||
│ POWR Packs │
|
||||
│ Discover workout collections│
|
||||
│ │
|
||||
│ ┌─────┐ ┌─────┐ ┌─────┐ │
|
||||
│ │Pack1│ │Pack2│ │Pack3│ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ └─────┘ └─────┘ └─────┘ │
|
||||
│ │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
## Testing and Validation
|
||||
|
||||
We've successfully tested the basic Nostr event publishing flow using NAK:
|
||||
1. Created exercise events (kind 33401)
|
||||
2. Created template events (kind 33402) that reference the exercises
|
||||
3. Created a pack event (kind 30004) that references both templates and exercises
|
||||
4. Verified that all events were published and can be fetched by ID
|
||||
|
||||
## Implementation Timeline
|
||||
|
||||
1. **Database Schema Updates**: Implement new tables
|
||||
2. **POWRPackService**: Create service for fetching and importing packs
|
||||
3. **Settings Integration**: Add menu items to settings drawer
|
||||
4. **Import UI**: Implement import screen with selection logic
|
||||
5. **Management UI**: Create pack management interface
|
||||
6. **Social Discovery**: Add pack discovery section to social tab
|
||||
7. **Testing**: Validate full import/management flow
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Implement the database schema changes
|
||||
2. Build POWRPackService
|
||||
3. Create the UI components
|
||||
4. Test the full feature flow
|
||||
5. Consider future enhancements (creating/publishing packs from within the app)
|
@ -1,616 +0,0 @@
|
||||
# Updated POWR Pack Integration Plan
|
||||
|
||||
## Current Status Assessment
|
||||
|
||||
Based on the current implementation of POWR Packs, we've identified several issues that need to be addressed:
|
||||
|
||||
1. **Missing Template-Exercise Relationships**: Templates are being imported but not properly linked to their associated exercises
|
||||
2. **Parameter Extraction Issues**: The system isn't correctly parsing parameters from exercise references
|
||||
3. **Lack of Future Extensibility**: The current approach doesn't adequately support future changes to the NIP-4e specification
|
||||
4. **Template Management**: Tools for template archiving and deletion are incomplete
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
This plan outlines both immediate fixes and longer-term improvements for a more extensible architecture.
|
||||
|
||||
### Phase 1: Critical Fixes (Immediate)
|
||||
|
||||
#### 1. Fix Template-Exercise Relationship
|
||||
|
||||
**Problem**: Templates are imported but show 0 exercises because the references aren't correctly matched.
|
||||
|
||||
**Solution**:
|
||||
|
||||
- Update `POWRPackService.ts` to correctly parse exercise references by d-tag
|
||||
- Improve the exercise matching logic to use the correct format (`33401:pubkey:d-tag`)
|
||||
- Add detailed logging for troubleshooting
|
||||
|
||||
```typescript
|
||||
// Find the corresponding imported exercise IDs
|
||||
const templateExerciseIds: string[] = [];
|
||||
const matchedRefs: string[] = [];
|
||||
|
||||
for (const ref of exerciseRefs) {
|
||||
// Extract the base reference (before any parameters)
|
||||
const refParts = ref.split('::');
|
||||
const baseRef = refParts[0];
|
||||
|
||||
console.log(`Looking for matching exercise for reference: ${baseRef}`);
|
||||
|
||||
// Parse the reference format: kind:pubkey:d-tag
|
||||
const refSegments = baseRef.split(':');
|
||||
if (refSegments.length < 3) {
|
||||
console.log(`Invalid reference format: ${baseRef}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const refKind = refSegments[0];
|
||||
const refPubkey = refSegments[1];
|
||||
const refDTag = refSegments[2];
|
||||
|
||||
// Find the event that matches by d-tag
|
||||
const matchingEvent = exercises.find(e => {
|
||||
const dTag = findTagValue(e.tags, 'd');
|
||||
if (!dTag || e.pubkey !== refPubkey) return false;
|
||||
|
||||
const match = dTag === refDTag;
|
||||
if (match) {
|
||||
console.log(`Found matching event: ${e.id} with d-tag: ${dTag}`);
|
||||
}
|
||||
|
||||
return match;
|
||||
});
|
||||
|
||||
if (matchingEvent && exerciseIdMap.has(matchingEvent.id)) {
|
||||
const localExerciseId = exerciseIdMap.get(matchingEvent.id) || '';
|
||||
templateExerciseIds.push(localExerciseId);
|
||||
matchedRefs.push(ref); // Keep the full reference including parameters
|
||||
|
||||
console.log(`Mapped Nostr event ${matchingEvent.id} to local exercise ID ${localExerciseId}`);
|
||||
} else {
|
||||
console.log(`No matching exercise found for reference: ${baseRef}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Fix Parameter Extraction in NostrIntegration.ts
|
||||
|
||||
**Problem**: Parameter values from exercise references aren't being properly extracted.
|
||||
|
||||
**Solution**:
|
||||
|
||||
```typescript
|
||||
async saveTemplateExercisesWithParams(
|
||||
templateId: string,
|
||||
exerciseIds: string[],
|
||||
exerciseRefs: string[]
|
||||
): Promise<void> {
|
||||
try {
|
||||
console.log(`Saving ${exerciseIds.length} exercise relationships for template ${templateId}`);
|
||||
|
||||
// Create template exercise records
|
||||
for (let i = 0; i < exerciseIds.length; i++) {
|
||||
const exerciseId = exerciseIds[i];
|
||||
const templateExerciseId = generateId();
|
||||
const now = Date.now();
|
||||
|
||||
// Get the corresponding exercise reference with parameters
|
||||
const exerciseRef = exerciseRefs[i] || '';
|
||||
|
||||
// Parse the reference format: kind:pubkey:d-tag::sets:reps:weight
|
||||
let targetSets = null;
|
||||
let targetReps = null;
|
||||
let targetWeight = null;
|
||||
|
||||
// Check if reference contains parameters
|
||||
if (exerciseRef.includes('::')) {
|
||||
const parts = exerciseRef.split('::');
|
||||
if (parts.length > 1) {
|
||||
const params = parts[1].split(':');
|
||||
if (params.length > 0 && params[0]) targetSets = parseInt(params[0]) || null;
|
||||
if (params.length > 1 && params[1]) targetReps = parseInt(params[1]) || null;
|
||||
if (params.length > 2 && params[2]) targetWeight = parseFloat(params[2]) || null;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Template exercise ${i}: ${exerciseId} with sets=${targetSets}, reps=${targetReps}, weight=${targetWeight}`);
|
||||
|
||||
await this.db.runAsync(
|
||||
`INSERT INTO template_exercises
|
||||
(id, template_id, exercise_id, display_order, target_sets, target_reps, target_weight, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
templateExerciseId,
|
||||
templateId,
|
||||
exerciseId,
|
||||
i,
|
||||
targetSets,
|
||||
targetReps,
|
||||
targetWeight,
|
||||
now,
|
||||
now
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Successfully saved all template-exercise relationships for template ${templateId}`);
|
||||
} catch (error) {
|
||||
console.error('Error saving template exercises with parameters:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Add Template Management Functions
|
||||
|
||||
**Problem**: Need better tools for template archiving and deletion.
|
||||
|
||||
**Solution**:
|
||||
|
||||
- Add an `is_archived` column to templates table
|
||||
- Create archive/unarchive functions
|
||||
- Implement safe template removal with dependency handling
|
||||
|
||||
```typescript
|
||||
// Schema update
|
||||
await db.execAsync(`
|
||||
ALTER TABLE templates ADD COLUMN is_archived BOOLEAN NOT NULL DEFAULT 0;
|
||||
ALTER TABLE templates ADD COLUMN author_pubkey TEXT;
|
||||
`);
|
||||
|
||||
// Template management functions
|
||||
async archiveTemplate(id: string, archive: boolean = true): Promise<void> {
|
||||
await this.db.runAsync(
|
||||
'UPDATE templates SET is_archived = ? WHERE id = ?',
|
||||
[archive ? 1 : 0, id]
|
||||
);
|
||||
}
|
||||
|
||||
async removeFromLibrary(id: string): Promise<void> {
|
||||
await this.db.withTransactionAsync(async () => {
|
||||
// Delete template-exercise relationships
|
||||
await this.db.runAsync(
|
||||
'DELETE FROM template_exercises WHERE template_id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
// Delete template
|
||||
await this.db.runAsync(
|
||||
'DELETE FROM templates WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
// Update powr_pack_items to mark as not imported
|
||||
await this.db.runAsync(
|
||||
'UPDATE powr_pack_items SET is_imported = 0 WHERE item_id = ? AND item_type = "template"',
|
||||
[id]
|
||||
);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 2: Extensibility Improvements (Short-term)
|
||||
|
||||
#### 1. Schema Updates for Extensibility
|
||||
|
||||
**Problem**: Schema is too rigid for future extensions to exercise parameters and workout types.
|
||||
|
||||
**Solution**:
|
||||
|
||||
```typescript
|
||||
// Add schema update in a migration file or update schema.ts
|
||||
async function addExtensibilityColumns(db: SQLiteDatabase): Promise<void> {
|
||||
// Add params_json to template_exercises for extensible parameters
|
||||
await db.execAsync(`
|
||||
ALTER TABLE template_exercises ADD COLUMN params_json TEXT;
|
||||
`);
|
||||
|
||||
// Add workout_type_config to templates for type-specific configurations
|
||||
await db.execAsync(`
|
||||
ALTER TABLE templates ADD COLUMN workout_type_config TEXT;
|
||||
`);
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Flexible Parameter Extraction
|
||||
|
||||
**Problem**: Current parameter extraction is hardcoded for a limited set of parameters.
|
||||
|
||||
**Solution**:
|
||||
|
||||
- Create a parameter mapper service
|
||||
- Implement dynamic parameter extraction based on exercise format
|
||||
|
||||
```typescript
|
||||
class ExerciseParameterMapper {
|
||||
// Extract parameters from a Nostr reference based on exercise format
|
||||
static extractParameters(exerciseRef: string, formatJson?: string): Record<string, any> {
|
||||
const parameters: Record<string, any> = {};
|
||||
|
||||
// If no reference with parameters, return empty object
|
||||
if (!exerciseRef || !exerciseRef.includes('::')) {
|
||||
return parameters;
|
||||
}
|
||||
|
||||
const [baseRef, paramString] = exerciseRef.split('::');
|
||||
if (!paramString) return parameters;
|
||||
|
||||
const paramValues = paramString.split(':');
|
||||
|
||||
// If we have format information, use it to map parameters
|
||||
if (formatJson) {
|
||||
try {
|
||||
const format = JSON.parse(formatJson);
|
||||
const formatKeys = Object.keys(format).filter(key => format[key] === true);
|
||||
|
||||
formatKeys.forEach((key, index) => {
|
||||
if (index < paramValues.length && paramValues[index]) {
|
||||
// Convert value to appropriate type based on parameter name
|
||||
if (key === 'weight') {
|
||||
parameters[key] = parseFloat(paramValues[index]) || null;
|
||||
} else if (['reps', 'sets', 'duration'].includes(key)) {
|
||||
parameters[key] = parseInt(paramValues[index]) || null;
|
||||
} else {
|
||||
// For other parameters, keep as string
|
||||
parameters[key] = paramValues[index];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return parameters;
|
||||
} catch (error) {
|
||||
console.warn('Error parsing format JSON:', error);
|
||||
// Fall back to default mapping below
|
||||
}
|
||||
}
|
||||
|
||||
// Default parameter mapping if no format or error parsing
|
||||
if (paramValues.length > 0) parameters.target_sets = parseInt(paramValues[0]) || null;
|
||||
if (paramValues.length > 1) parameters.target_reps = parseInt(paramValues[1]) || null;
|
||||
if (paramValues.length > 2) parameters.target_weight = parseFloat(paramValues[2]) || null;
|
||||
if (paramValues.length > 3) parameters.set_type = paramValues[3];
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
// Convert parameters back to Nostr reference format
|
||||
static formatParameters(parameters: Record<string, any>, formatJson?: string): string {
|
||||
if (!Object.keys(parameters).length) return '';
|
||||
|
||||
let paramArray: (string | number | null)[] = [];
|
||||
|
||||
// If we have format information, use it for parameter ordering
|
||||
if (formatJson) {
|
||||
try {
|
||||
const format = JSON.parse(formatJson);
|
||||
const formatKeys = Object.keys(format).filter(key => format[key] === true);
|
||||
|
||||
paramArray = formatKeys.map(key => parameters[key] ?? '');
|
||||
} catch (error) {
|
||||
console.warn('Error parsing format JSON:', error);
|
||||
// Fall back to default format below
|
||||
}
|
||||
}
|
||||
|
||||
// Default parameter format if no format JSON or error parsing
|
||||
if (!paramArray.length) {
|
||||
paramArray = [
|
||||
parameters.target_sets ?? parameters.sets ?? '',
|
||||
parameters.target_reps ?? parameters.reps ?? '',
|
||||
parameters.target_weight ?? parameters.weight ?? '',
|
||||
parameters.set_type ?? ''
|
||||
];
|
||||
}
|
||||
|
||||
// Trim trailing empty values
|
||||
while (paramArray.length > 0 &&
|
||||
(paramArray[paramArray.length - 1] === '' ||
|
||||
paramArray[paramArray.length - 1] === null)) {
|
||||
paramArray.pop();
|
||||
}
|
||||
|
||||
// If no parameters left, return empty string
|
||||
if (!paramArray.length) return '';
|
||||
|
||||
// Join parameters with colon
|
||||
return paramArray.join(':');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Workout Type-Specific Handling
|
||||
|
||||
**Problem**: Different workout types (AMRAP, EMOM, circuit, strength) have specific data needs.
|
||||
|
||||
**Solution**:
|
||||
|
||||
- Create workout type processors
|
||||
- Implement template service enhancements for type-specific configurations
|
||||
|
||||
```typescript
|
||||
// WorkoutTypesService.ts
|
||||
import { WorkoutTemplate, TemplateType } from '@/types/templates';
|
||||
|
||||
// Factory pattern for creating workout type processors
|
||||
export class WorkoutTypeFactory {
|
||||
static createProcessor(type: TemplateType): WorkoutTypeProcessor {
|
||||
switch (type) {
|
||||
case 'strength':
|
||||
return new StrengthWorkoutProcessor();
|
||||
case 'circuit':
|
||||
return new CircuitWorkoutProcessor();
|
||||
case 'emom':
|
||||
return new EMOMWorkoutProcessor();
|
||||
case 'amrap':
|
||||
return new AMRAPWorkoutProcessor();
|
||||
default:
|
||||
return new DefaultWorkoutProcessor();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Interface for workout type processors
|
||||
export interface WorkoutTypeProcessor {
|
||||
parseTemplateConfig(tags: string[][]): Record<string, any>;
|
||||
getDefaultParameters(): Record<string, any>;
|
||||
formatTemplateConfig(config: Record<string, any>): string[][];
|
||||
}
|
||||
|
||||
// Example implementation for EMOM workouts
|
||||
class EMOMWorkoutProcessor implements WorkoutTypeProcessor {
|
||||
parseTemplateConfig(tags: string[][]): Record<string, any> {
|
||||
const config: Record<string, any> = {
|
||||
type: 'emom',
|
||||
rounds: 0,
|
||||
interval: 60, // Default 60 seconds
|
||||
rest: 0
|
||||
};
|
||||
|
||||
// Extract rounds (total number of intervals)
|
||||
const roundsTag = tags.find(t => t[0] === 'rounds');
|
||||
if (roundsTag && roundsTag.length > 1) {
|
||||
config.rounds = parseInt(roundsTag[1]) || 0;
|
||||
}
|
||||
|
||||
// Extract interval duration
|
||||
const intervalTag = tags.find(t => t[0] === 'interval');
|
||||
if (intervalTag && intervalTag.length > 1) {
|
||||
config.interval = parseInt(intervalTag[1]) || 60;
|
||||
}
|
||||
|
||||
// Extract rest between rounds
|
||||
const restTag = tags.find(t => t[0] === 'rest_between_rounds');
|
||||
if (restTag && restTag.length > 1) {
|
||||
config.rest = parseInt(restTag[1]) || 0;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
getDefaultParameters(): Record<string, any> {
|
||||
return {
|
||||
rounds: 10,
|
||||
interval: 60,
|
||||
rest: 0
|
||||
};
|
||||
}
|
||||
|
||||
formatTemplateConfig(config: Record<string, any>): string[][] {
|
||||
const tags: string[][] = [];
|
||||
|
||||
if (config.rounds) {
|
||||
tags.push(['rounds', config.rounds.toString()]);
|
||||
}
|
||||
|
||||
if (config.interval) {
|
||||
tags.push(['interval', config.interval.toString()]);
|
||||
}
|
||||
|
||||
if (config.rest) {
|
||||
tags.push(['rest_between_rounds', config.rest.toString()]);
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: Long-Term Architecture (Future)
|
||||
|
||||
#### 1. Modular Event Processor Architecture
|
||||
|
||||
**Problem**: Need a more adaptable system for handling evolving Nostr event schemas.
|
||||
|
||||
**Solution**:
|
||||
|
||||
- Create a plugin-based architecture for event processors
|
||||
- Implement versioning for Nostr event handling
|
||||
- Design a flexible mapping system between Nostr events and local database schema
|
||||
|
||||
```typescript
|
||||
// Interface for event processors
|
||||
interface NostrEventProcessor<T> {
|
||||
// Check if processor can handle this event
|
||||
canProcess(event: NostrEvent): boolean;
|
||||
|
||||
// Process event to local model
|
||||
processEvent(event: NostrEvent): T;
|
||||
|
||||
// Convert local model to event
|
||||
createEvent(model: T): NostrEvent;
|
||||
|
||||
// Get processor version
|
||||
getVersion(): string;
|
||||
}
|
||||
|
||||
// Registry for event processors
|
||||
class EventProcessorRegistry {
|
||||
private processors: Map<number, NostrEventProcessor<any>[]> = new Map();
|
||||
|
||||
// Register a processor for a specific kind
|
||||
registerProcessor(kind: number, processor: NostrEventProcessor<any>): void {
|
||||
if (!this.processors.has(kind)) {
|
||||
this.processors.set(kind, []);
|
||||
}
|
||||
|
||||
this.processors.get(kind)?.push(processor);
|
||||
}
|
||||
|
||||
// Get appropriate processor for an event
|
||||
getProcessor<T>(event: NostrEvent): NostrEventProcessor<T> | null {
|
||||
const kindProcessors = this.processors.get(event.kind);
|
||||
if (!kindProcessors) return null;
|
||||
|
||||
// Find the first processor that can process this event
|
||||
for (const processor of kindProcessors) {
|
||||
if (processor.canProcess(event)) {
|
||||
return processor as NostrEventProcessor<T>;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Schema Migration System
|
||||
|
||||
**Problem**: Database schema needs to evolve with Nostr specification changes.
|
||||
|
||||
**Solution**:
|
||||
|
||||
- Create a versioned migration system
|
||||
- Implement automatic schema updates
|
||||
- Track schema versions
|
||||
|
||||
```typescript
|
||||
// Migration interface
|
||||
interface SchemaMigration {
|
||||
version: number;
|
||||
up(db: SQLiteDatabase): Promise<void>;
|
||||
down(db: SQLiteDatabase): Promise<void>;
|
||||
}
|
||||
|
||||
// Migration runner
|
||||
class MigrationRunner {
|
||||
private migrations: SchemaMigration[] = [];
|
||||
|
||||
// Register a migration
|
||||
registerMigration(migration: SchemaMigration): void {
|
||||
this.migrations.push(migration);
|
||||
// Sort migrations by version
|
||||
this.migrations.sort((a, b) => a.version - b.version);
|
||||
}
|
||||
|
||||
// Run migrations up to a specific version
|
||||
async migrate(db: SQLiteDatabase, targetVersion: number): Promise<void> {
|
||||
// Get current version
|
||||
const currentVersion = await this.getCurrentVersion(db);
|
||||
|
||||
if (currentVersion < targetVersion) {
|
||||
// Run UP migrations
|
||||
for (const migration of this.migrations) {
|
||||
if (migration.version > currentVersion && migration.version <= targetVersion) {
|
||||
await migration.up(db);
|
||||
await this.updateVersion(db, migration.version);
|
||||
}
|
||||
}
|
||||
} else if (currentVersion > targetVersion) {
|
||||
// Run DOWN migrations
|
||||
for (const migration of [...this.migrations].reverse()) {
|
||||
if (migration.version <= currentVersion && migration.version > targetVersion) {
|
||||
await migration.down(db);
|
||||
await this.updateVersion(db, migration.version - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private async getCurrentVersion(db: SQLiteDatabase): Promise<number> {
|
||||
// Implementation
|
||||
return 0;
|
||||
}
|
||||
|
||||
private async updateVersion(db: SQLiteDatabase, version: number): Promise<void> {
|
||||
// Implementation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Future-Proof Integration Patterns
|
||||
|
||||
**Problem**: Need to ensure the POWR app can adapt to future Nostr specification changes.
|
||||
|
||||
**Solution**:
|
||||
|
||||
- Implement adapter pattern for Nostr protocol
|
||||
- Create abstraction layers for data synchronization
|
||||
- Design entity mappers for different data versions
|
||||
|
||||
```typescript
|
||||
// Adapter for Nostr protocol versions
|
||||
interface NostrProtocolAdapter {
|
||||
// Get exercise from event
|
||||
getExerciseFromEvent(event: NostrEvent): BaseExercise;
|
||||
|
||||
// Get template from event
|
||||
getTemplateFromEvent(event: NostrEvent): WorkoutTemplate;
|
||||
|
||||
// Get workout record from event
|
||||
getWorkoutFromEvent(event: NostrEvent): Workout;
|
||||
|
||||
// Create events from local models
|
||||
createExerciseEvent(exercise: BaseExercise): NostrEvent;
|
||||
createTemplateEvent(template: WorkoutTemplate): NostrEvent;
|
||||
createWorkoutEvent(workout: Workout): NostrEvent;
|
||||
}
|
||||
|
||||
// Versioned adapter implementation
|
||||
class NostrProtocolAdapterV1 implements NostrProtocolAdapter {
|
||||
// Implementation for first version of NIP-4e
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Phase 1 (Immediate)
|
||||
|
||||
1. Create a test POWR Pack with variety of exercise types and templates
|
||||
2. Test importing the pack with the updated code
|
||||
3. Verify that templates contain the correct exercise relationships
|
||||
4. Validate parameter extraction works correctly
|
||||
|
||||
### Phase 2 (Short-term)
|
||||
|
||||
1. Create test cases for different workout types (strength, circuit, EMOM, AMRAP)
|
||||
2. Verify parameter mapping works as expected
|
||||
3. Test template management functions
|
||||
|
||||
### Phase 3 (Long-term)
|
||||
|
||||
1. Create comprehensive integration tests
|
||||
2. Design migration testing framework
|
||||
3. Implement automated testing for different Nostr protocol versions
|
||||
|
||||
## Implementation Timeline
|
||||
|
||||
### Phase 1: Critical Fixes
|
||||
- **Day 1**: Fix template-exercise relationship in `POWRPackService.ts`
|
||||
- **Day 2**: Fix parameter extraction in `NostrIntegration.ts`
|
||||
- **Day 3**: Implement template management functions and schema updates
|
||||
- **Day 4**: Testing and bug fixes
|
||||
|
||||
### Phase 2: Extensibility Improvements
|
||||
- **Week 2**: Implement schema updates and flexible parameter extraction
|
||||
- **Week 3**: Develop workout type-specific processing
|
||||
- **Week 4**: UI enhancements and testing
|
||||
|
||||
### Phase 3: Long-Term Architecture
|
||||
- **Future**: Implement as part of broader architectural improvements
|
||||
|
||||
## Conclusion
|
||||
|
||||
This updated plan addresses both the immediate issues with POWR Pack integration and lays out a path for future extensibility as the Nostr Exercise NIP evolves. By implementing these changes in phases, we can quickly fix the current template-exercise relationship problems while establishing a foundation for more sophisticated features in the future.
|
||||
|
||||
The proposed approach balances pragmatism with future-proofing, ensuring that users can immediately benefit from POWR Packs while the system remains adaptable to changes in workout types, exercise parameters, and Nostr protocol specifications.
|
@ -1,744 +0,0 @@
|
||||
# POWR Social Architecture
|
||||
|
||||
## Problem Statement
|
||||
POWR needs to integrate social features that leverage the Nostr protocol while maintaining a local-first architecture. The system must provide a seamless way for users to share workout content, receive feedback, and engage with the fitness community without compromising the standalone functionality of the application. Additionally, the implementation must support future integration with value-exchange mechanisms through Nostr Wallet Connect.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional Requirements
|
||||
- Custom Nostr event types for exercises, workout templates, and workout records
|
||||
- Social sharing of workout content via NIP-19 references
|
||||
- Content management including deletion requests
|
||||
- Comment system on exercises, templates, and workout records
|
||||
- Reactions and likes on shared content
|
||||
- App discovery through NIP-89 handlers
|
||||
- Support for zaps and Lightning payments via NWC
|
||||
- Ability to track template usage and popularity
|
||||
- User profile and content discovery
|
||||
- Local-first storage with Nostr sync capabilities
|
||||
|
||||
### Non-Functional Requirements
|
||||
- Performance: Social content loads within 500ms when online
|
||||
- Security: User private keys are never exposed to the application
|
||||
- Reliability: All created content must be usable offline
|
||||
- Usability: Social interactions should be intuitive and seamlessly integrated
|
||||
- Privacy: Users control what content gets shared publicly
|
||||
- Scalability: System handles thousands of exercise templates and workout records
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### 1. Custom Event Kinds vs. Standard Kinds
|
||||
**Approach**: Use custom event kinds (33401, 33402, 1301) for exercises, templates, and workout records rather than generic kind 1 events.
|
||||
|
||||
**Rationale**:
|
||||
- Custom kinds enable clear data separation and easier filtering
|
||||
- Avoids confusion between social posts and fitness data
|
||||
- Enables specialized app handlers via NIP-89
|
||||
- Aligns with best practices for specialized content types
|
||||
- Enables distinct validation rules for each content type
|
||||
|
||||
**Trade-offs**:
|
||||
- Requires implementing NIP-89 app handlers for client support
|
||||
- Less immediate visibility in generic Nostr clients
|
||||
- Needs additional social sharing mechanism for discovery
|
||||
|
||||
### 2. Template-Record Relationship
|
||||
**Approach**: Implement standalone workout records with explicit references to templates.
|
||||
|
||||
**Rationale**:
|
||||
- Cleaner separation between templates and completed workouts
|
||||
- More flexible for workouts that modify templates
|
||||
- Better support for offline-first usage
|
||||
- Simplifies synchronization logic
|
||||
- Easier to implement privacy controls
|
||||
|
||||
**Trade-offs**:
|
||||
- Requires custom queries to track template usage
|
||||
- Doesn't leverage built-in reply notification systems
|
||||
- Additional relationship management logic needed
|
||||
|
||||
### 3. Comments Implementation
|
||||
**Approach**: Use NIP-22 generic comments system with proper reference structure.
|
||||
|
||||
**Rationale**:
|
||||
- Standardized approach compatible with existing Nostr clients
|
||||
- Supports threaded conversations
|
||||
- Clear distinction between content and comments
|
||||
- Integrates with existing notification systems
|
||||
- Simple to implement using established patterns
|
||||
|
||||
**Trade-offs**:
|
||||
- Requires filtering to display relevant comments
|
||||
- Additional UI components for comment display
|
||||
- Need for moderation tools (client-side filtering)
|
||||
|
||||
### 4. Nostr Wallet Connect Integration
|
||||
**Approach**: Implement NIP-47 Nostr Wallet Connect for Lightning payments and zaps.
|
||||
|
||||
**Rationale**:
|
||||
- Secure payment capabilities without managing private keys
|
||||
- Enables zaps on workout content
|
||||
- Creates opportunities for creator compensation
|
||||
- Integrates with existing Nostr Lightning infrastructure
|
||||
- Future-proofs for monetization features
|
||||
|
||||
**Trade-offs**:
|
||||
- Additional complexity in wallet connection management
|
||||
- Dependency on external wallet implementations
|
||||
- Requires careful error handling for payment flows
|
||||
|
||||
### 5. Content Publishing and Deletion Workflow
|
||||
**Approach**: Implement a three-tier approach to content sharing with NIP-09 deletion requests.
|
||||
|
||||
**Rationale**:
|
||||
- Gives users control over content visibility
|
||||
- Maintains local-first philosophy
|
||||
- Provides clear separation between private and public data
|
||||
- Follows Nostr standards for content management
|
||||
- Enables social sharing while maintaining specialized data format
|
||||
|
||||
**Trade-offs**:
|
||||
- Deletion on Nostr is not guaranteed across all relays
|
||||
- Additional UI complexity to explain publishing/deletion states
|
||||
- Need to track content state across local storage and relays
|
||||
|
||||
## Technical Design
|
||||
|
||||
### Core Components
|
||||
|
||||
```typescript
|
||||
// Exercise Template Event (Kind 33401)
|
||||
interface ExerciseTemplate extends NostrEvent {
|
||||
kind: 33401;
|
||||
content: string; // Detailed instructions
|
||||
tags: [
|
||||
["d", string], // Unique identifier
|
||||
["title", string], // Exercise name
|
||||
["format", ...string[]], // Data structure parameters
|
||||
["format_units", ...string[]], // Units for parameters
|
||||
["equipment", string], // Equipment type
|
||||
["difficulty"?, string], // Optional skill level
|
||||
["imeta"?, ...string[]], // Optional media metadata
|
||||
["t"?, string][], // Optional hashtags
|
||||
]
|
||||
}
|
||||
|
||||
// Workout Template Event (Kind 33402)
|
||||
interface WorkoutTemplate extends NostrEvent {
|
||||
kind: 33402;
|
||||
content: string; // Workout notes and instructions
|
||||
tags: [
|
||||
["d", string], // Unique identifier
|
||||
["title", string], // Workout name
|
||||
["type", string], // Workout type (strength, circuit, etc.)
|
||||
["exercise", ...string[]][], // Exercise references with parameters
|
||||
["rounds"?, string], // Optional rounds count
|
||||
["duration"?, string], // Optional total duration
|
||||
["interval"?, string], // Optional interval duration
|
||||
["rest_between_rounds"?, string], // Optional rest time
|
||||
["t"?, string][], // Optional hashtags
|
||||
]
|
||||
}
|
||||
|
||||
// Workout Record Event (Kind 1301)
|
||||
interface WorkoutRecord extends NostrEvent {
|
||||
kind: 1301;
|
||||
content: string; // Workout notes
|
||||
tags: [
|
||||
["d", string], // Unique identifier
|
||||
["title", string], // Workout name
|
||||
["type", string], // Workout type
|
||||
["template", "33402:<pubkey>:<d-tag>", "<relay-url>"], // Explicit template reference
|
||||
["exercise", ...string[]][], // Exercises with actual values
|
||||
["start", string], // Start timestamp
|
||||
["end", string], // End timestamp
|
||||
["completed", string], // Completion status
|
||||
["rounds_completed"?, string], // Optional rounds completed
|
||||
["interval"?, string], // Optional interval duration
|
||||
["pr"?, string][], // Optional personal records
|
||||
["t"?, string][], // Optional hashtags
|
||||
]
|
||||
}
|
||||
|
||||
// Social Share (Kind 1)
|
||||
interface SocialShare extends NostrEvent {
|
||||
kind: 1;
|
||||
content: string; // Social post text
|
||||
tags: [
|
||||
// Quote reference to the exercise, template or workout
|
||||
["q", string, string, string], // event-id, relay-url, pubkey
|
||||
|
||||
// Kind tag to indicate what kind of event is being quoted
|
||||
["k", string], // The kind number of the quoted event (e.g., "1301")
|
||||
|
||||
// Mention author's pubkey
|
||||
["p", string] // pubkey of the event creator
|
||||
]
|
||||
}
|
||||
|
||||
// Deletion Request (Kind 5) - NIP-09
|
||||
interface DeletionRequest extends NostrEvent {
|
||||
kind: 5;
|
||||
content: string; // Reason for deletion (optional)
|
||||
tags: [
|
||||
// Event reference(s) to delete
|
||||
["e", string], // event-id(s) to delete
|
||||
// Or addressable event reference
|
||||
["a", string], // "<kind>:<pubkey>:<d-identifier>"
|
||||
// Kind of the event being deleted
|
||||
["k", string] // kind number as string
|
||||
]
|
||||
}
|
||||
|
||||
// Comment (Kind 1111 - as per NIP-22)
|
||||
interface WorkoutComment extends NostrEvent {
|
||||
kind: 1111;
|
||||
content: string; // Comment text
|
||||
tags: [
|
||||
// Root reference (exercise, template, or record)
|
||||
["e", string, string, string], // id, relay, marker "root"
|
||||
["K", string], // Root kind (33401, 33402, or 1301)
|
||||
["P", string, string], // Root pubkey, relay
|
||||
|
||||
// Parent comment (for replies)
|
||||
["e"?, string, string, string], // id, relay, marker "reply"
|
||||
["k"?, string], // Parent kind (1111)
|
||||
["p"?, string, string], // Parent pubkey, relay
|
||||
]
|
||||
}
|
||||
|
||||
// Reaction (Kind 7 - as per NIP-25)
|
||||
interface Reaction extends NostrEvent {
|
||||
kind: 7;
|
||||
content: "+" | "🔥" | "👍"; // Standard reaction symbols
|
||||
tags: [
|
||||
["e", string, string], // event-id, relay-url
|
||||
["p", string] // pubkey of the event creator
|
||||
]
|
||||
}
|
||||
|
||||
// App Handler Registration (Kind 31990)
|
||||
interface AppHandler extends NostrEvent {
|
||||
kind: 31990;
|
||||
content: string;
|
||||
tags: [
|
||||
["k", "33401", "exercise-template"],
|
||||
["k", "33402", "workout-template"],
|
||||
["k", "1301", "workout-record"],
|
||||
["web", string], // App URL
|
||||
["name", string], // App name
|
||||
["description", string] // App description
|
||||
]
|
||||
}
|
||||
|
||||
// Nostr Wallet Connection Manager
|
||||
class NWCManager {
|
||||
async connectWallet(nwcURI: string): Promise<{
|
||||
connected: boolean;
|
||||
pubkey?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
async getBalance(): Promise<number>;
|
||||
|
||||
async zapEvent(
|
||||
event: NostrEvent,
|
||||
amount: number,
|
||||
comment?: string
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
preimage?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Social Service
|
||||
class SocialService {
|
||||
// Share workout on social feeds
|
||||
async shareWorkoutSocially(
|
||||
workout: WorkoutTemplate | WorkoutRecord,
|
||||
message: string
|
||||
): Promise<NostrEvent>;
|
||||
|
||||
// Get comments for content
|
||||
async getComments(
|
||||
eventId: string,
|
||||
rootKind: number
|
||||
): Promise<WorkoutComment[]>;
|
||||
|
||||
// Post comment
|
||||
async postComment(
|
||||
rootEvent: NostrEvent,
|
||||
content: string,
|
||||
parentComment?: WorkoutComment
|
||||
): Promise<WorkoutComment>;
|
||||
|
||||
// Track template usage
|
||||
async getTemplateUsageCount(templateId: string): Promise<number>;
|
||||
|
||||
// React to content
|
||||
async reactToEvent(
|
||||
event: NostrEvent,
|
||||
reaction: "+" | "🔥" | "👍"
|
||||
): Promise<NostrEvent>;
|
||||
|
||||
// Request deletion of event
|
||||
async requestDeletion(
|
||||
eventId: string,
|
||||
eventKind: number,
|
||||
reason?: string
|
||||
): Promise<NostrEvent>;
|
||||
|
||||
// Request deletion of addressable event
|
||||
async requestAddressableDeletion(
|
||||
kind: number,
|
||||
pubkey: string,
|
||||
dTag: string,
|
||||
reason?: string
|
||||
): Promise<NostrEvent>;
|
||||
}
|
||||
```
|
||||
|
||||
### Content Publishing Workflow
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Create Content] --> B{Publish to Relays?}
|
||||
B -->|No| C[Local Storage Only]
|
||||
B -->|Yes| D[Save to Local Storage]
|
||||
D --> E[Publish to Relays]
|
||||
E --> F{Share Socially?}
|
||||
F -->|No| G[Done - Content on Relays]
|
||||
F -->|Yes| H[Create kind:1 Social Post]
|
||||
H --> I[Reference Original Event]
|
||||
I --> J[Done - Content Shared]
|
||||
|
||||
K[Delete Content] --> L{Delete from Relays?}
|
||||
L -->|No| M[Delete from Local Only]
|
||||
L -->|Yes| N[Create kind:5 Deletion Request]
|
||||
N --> O[Publish Deletion Request]
|
||||
O --> P{Delete Locally?}
|
||||
P -->|No| Q[Done - Deletion Requested]
|
||||
P -->|Yes| R[Delete from Local Storage]
|
||||
R --> S[Done - Content Deleted]
|
||||
```
|
||||
|
||||
### Data Flow Diagram
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph User
|
||||
A[Create Content] --> B[Local Storage]
|
||||
G[View Content] --> F[UI Components]
|
||||
T[Request Deletion] --> U[Deletion Manager]
|
||||
end
|
||||
|
||||
subgraph LocalStorage
|
||||
B --> C[SQLite Database]
|
||||
C --> D[Event Processor]
|
||||
end
|
||||
|
||||
subgraph NostrNetwork
|
||||
D -->|Publish| E[Relays]
|
||||
E -->|Subscribe| F
|
||||
U -->|Publish| E
|
||||
end
|
||||
|
||||
subgraph SocialInteractions
|
||||
H[Comments] --> I[Comment Processor]
|
||||
J[Reactions] --> K[Reaction Processor]
|
||||
L[Zaps] --> M[NWC Manager]
|
||||
end
|
||||
|
||||
I -->|Publish| E
|
||||
K -->|Publish| E
|
||||
M -->|Request| N[Lightning Wallet]
|
||||
N -->|Zap| E
|
||||
|
||||
E -->|Fetch Related| F
|
||||
C -->|Offline Data| F
|
||||
```
|
||||
|
||||
### Query Examples
|
||||
|
||||
```typescript
|
||||
// Find all exercise templates
|
||||
const exerciseTemplatesQuery = {
|
||||
kinds: [33401],
|
||||
limit: 50
|
||||
};
|
||||
|
||||
// Find workout templates that use a specific exercise
|
||||
const templatesWithExerciseQuery = {
|
||||
kinds: [33402],
|
||||
"#exercise": [`33401:${pubkey}:${exerciseId}`]
|
||||
};
|
||||
|
||||
// Find workout records for a specific template
|
||||
const workoutRecordsQuery = {
|
||||
kinds: [1301],
|
||||
"#template": [`33402:${pubkey}:${templateId}`]
|
||||
};
|
||||
|
||||
// Find comments on a workout record
|
||||
const commentsQuery = {
|
||||
kinds: [1111],
|
||||
"#e": [workoutEventId],
|
||||
"#K": ["1301"] // Root kind filter
|
||||
};
|
||||
|
||||
// Find all social posts specifically referencing workout records
|
||||
const workoutPostsQuery = {
|
||||
kinds: [1],
|
||||
"#k": ["1301"]
|
||||
};
|
||||
|
||||
// Find all social posts referencing any POWR content types
|
||||
const allPowrContentQuery = {
|
||||
kinds: [1],
|
||||
"#k": ["1301", "33401", "33402"]
|
||||
};
|
||||
|
||||
// Find all social posts referencing POWR content from a specific user
|
||||
const userPowrContentQuery = {
|
||||
kinds: [1],
|
||||
"#k": ["1301", "33401", "33402"],
|
||||
authors: [userPubkey]
|
||||
};
|
||||
|
||||
// Find posts with POWR hashtag
|
||||
const powrHashtagQuery = {
|
||||
kinds: [1],
|
||||
"#t": ["powrapp"]
|
||||
};
|
||||
|
||||
// Get reactions to a workout record
|
||||
const reactionsQuery = {
|
||||
kinds: [7],
|
||||
"#e": [workoutEventId]
|
||||
};
|
||||
|
||||
// Find deletion requests for an event
|
||||
const deletionRequestQuery = {
|
||||
kinds: [5],
|
||||
"#e": [eventId]
|
||||
};
|
||||
|
||||
// Find deletion requests for an addressable event
|
||||
const addressableDeletionRequestQuery = {
|
||||
kinds: [5],
|
||||
"#a": [`${kind}:${pubkey}:${dTag}`]
|
||||
};
|
||||
```
|
||||
|
||||
## Event Publishing and Deletion Implementation
|
||||
|
||||
### Publishing Workflow
|
||||
|
||||
POWR implements a three-tier approach to content publishing:
|
||||
|
||||
1. **Local Only**
|
||||
- Content is saved only to the device's local storage
|
||||
- No Nostr events are published
|
||||
- Content is completely private to the user
|
||||
|
||||
2. **Publish to Relays**
|
||||
- Content is saved locally and published to user-selected relays
|
||||
- Published as appropriate Nostr events (33401, 33402, 1301)
|
||||
- Content becomes discoverable by compatible apps via NIP-89
|
||||
- Local copy is marked as "published to relays"
|
||||
|
||||
3. **Social Sharing**
|
||||
- Content is published to relays as in step 2
|
||||
- Additionally, a kind:1 social post is created
|
||||
- The social post quotes the specialized content
|
||||
- Makes content visible in standard Nostr social clients
|
||||
- Links back to the specialized content via NIP-19 references
|
||||
|
||||
### Deletion Workflow
|
||||
|
||||
POWR implements NIP-09 for deletion requests:
|
||||
|
||||
1. **Local Deletion**
|
||||
- Content is removed from local storage only
|
||||
- No effect on previously published relay content
|
||||
- User maintains control over local data independent of relay status
|
||||
|
||||
2. **Relay Deletion Request**
|
||||
- Creates a kind:5 deletion request event
|
||||
- References the content to be deleted
|
||||
- Includes the kind of content being deleted
|
||||
- Published to relays that had the original content
|
||||
- Original content may remain in local storage if desired
|
||||
|
||||
3. **Complete Deletion**
|
||||
- Combination of local deletion and relay deletion request
|
||||
- Content is removed locally and requested for deletion from relays
|
||||
- Any social shares remain unless specifically deleted
|
||||
|
||||
### Example Implementation
|
||||
|
||||
```typescript
|
||||
// Publishing Content
|
||||
async function publishExerciseTemplate(exercise) {
|
||||
// Save locally first
|
||||
const localId = await localDb.saveExercise(exercise);
|
||||
|
||||
// If user wants to publish to relays
|
||||
if (exercise.publishToRelays) {
|
||||
// Create Nostr event
|
||||
const event = {
|
||||
kind: 33401,
|
||||
pubkey: userPubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
["d", localId],
|
||||
["title", exercise.title],
|
||||
["format", ...Object.keys(exercise.format)],
|
||||
["format_units", ...formatUnitsToArray(exercise.format_units)],
|
||||
["equipment", exercise.equipment],
|
||||
...exercise.tags.map(tag => ["t", tag])
|
||||
],
|
||||
content: exercise.description || ""
|
||||
};
|
||||
|
||||
// Sign and publish
|
||||
event.id = getEventHash(event);
|
||||
event.sig = signEvent(event, userPrivkey);
|
||||
await publishToRelays(event);
|
||||
|
||||
// Update local record to reflect published status
|
||||
await localDb.markAsPublished(localId, event.id);
|
||||
|
||||
// If user wants to share socially
|
||||
if (exercise.shareAsSocialPost) {
|
||||
await createSocialShare(event, exercise.socialShareText || "Check out this exercise!");
|
||||
}
|
||||
|
||||
return { localId, eventId: event.id };
|
||||
}
|
||||
|
||||
return { localId };
|
||||
}
|
||||
|
||||
// Requesting Deletion
|
||||
async function requestDeletion(eventId, eventKind, options = {}) {
|
||||
const { deleteLocally = false, reason = "" } = options;
|
||||
|
||||
// Create deletion request
|
||||
const deletionRequest = {
|
||||
kind: 5,
|
||||
pubkey: userPubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
["e", eventId],
|
||||
["k", eventKind.toString()]
|
||||
],
|
||||
content: reason
|
||||
};
|
||||
|
||||
// Sign and publish
|
||||
deletionRequest.id = getEventHash(deletionRequest);
|
||||
deletionRequest.sig = signEvent(deletionRequest, userPrivkey);
|
||||
await publishToRelays(deletionRequest);
|
||||
|
||||
// Update local storage
|
||||
await localDb.markAsDeletedFromRelays(eventId);
|
||||
|
||||
// Delete locally if requested
|
||||
if (deleteLocally) {
|
||||
await localDb.deleteContentLocally(eventId);
|
||||
}
|
||||
|
||||
return deletionRequest;
|
||||
}
|
||||
|
||||
// Request deletion of addressable event
|
||||
async function requestAddressableDeletion(kind, pubkey, dTag, options = {}) {
|
||||
const { deleteLocally = false, reason = "" } = options;
|
||||
|
||||
const deletionRequest = {
|
||||
kind: 5,
|
||||
pubkey: userPubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
["a", `${kind}:${pubkey}:${dTag}`],
|
||||
["k", kind.toString()]
|
||||
],
|
||||
content: reason
|
||||
};
|
||||
|
||||
// Sign and publish
|
||||
deletionRequest.id = getEventHash(deletionRequest);
|
||||
deletionRequest.sig = signEvent(deletionRequest, userPrivkey);
|
||||
await publishToRelays(deletionRequest);
|
||||
|
||||
// Update local storage
|
||||
await localDb.markAddressableEventAsDeletedFromRelays(kind, pubkey, dTag);
|
||||
|
||||
// Delete locally if requested
|
||||
if (deleteLocally) {
|
||||
await localDb.deleteAddressableContentLocally(kind, pubkey, dTag);
|
||||
}
|
||||
|
||||
return deletionRequest;
|
||||
}
|
||||
|
||||
// Check for deletion requests when viewing content
|
||||
async function checkDeletionStatus(eventId) {
|
||||
const deletionRequests = await ndk.fetchEvents({
|
||||
kinds: [5],
|
||||
"#e": [eventId]
|
||||
});
|
||||
|
||||
for (const request of deletionRequests) {
|
||||
// Verify the deletion request is from the original author
|
||||
if (request.pubkey === event.pubkey) {
|
||||
return { isDeleted: true, request };
|
||||
}
|
||||
}
|
||||
|
||||
return { isDeleted: false };
|
||||
}
|
||||
```
|
||||
|
||||
## User Interface Design
|
||||
|
||||
### Content Status Indicators
|
||||
|
||||
The UI should clearly indicate the status of fitness content:
|
||||
|
||||
1. **Local Only**
|
||||
- Visual indicator showing content is only on device
|
||||
- Options to publish to relays or share socially
|
||||
|
||||
2. **Published to Relays**
|
||||
- Indicator showing content is published
|
||||
- Display relay publishing status
|
||||
- Option to create social share
|
||||
|
||||
3. **Socially Shared**
|
||||
- Indicator showing content has been shared socially
|
||||
- Link to view social post
|
||||
- Stats on social engagement (comments, reactions)
|
||||
|
||||
4. **Deletion Requested**
|
||||
- Indicator showing deletion has been requested
|
||||
- Option to delete locally if not already done
|
||||
- Explanation that deletion from all relays cannot be guaranteed
|
||||
|
||||
### Deletion Interface
|
||||
|
||||
The UI for deletion should be clear and informative:
|
||||
|
||||
1. **Deletion Options**
|
||||
- "Delete Locally" - Removes from device only
|
||||
- "Request Deletion from Relays" - Issues NIP-09 deletion request
|
||||
- "Delete Completely" - Both local and relay deletion
|
||||
|
||||
2. **Confirmation Dialog**
|
||||
- Clear explanation of deletion scope
|
||||
- Warning that relay deletion is not guaranteed
|
||||
- Option to provide reason for deletion (for relay requests)
|
||||
|
||||
3. **Deletion Status**
|
||||
- Visual indicator for content with deletion requests
|
||||
- Option to view deletion request details
|
||||
- Ability to check status across relays
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Core Nostr Event Structure
|
||||
1. Implement custom event kinds (33401, 33402, 1301)
|
||||
2. Create local storage schema with publishing status tracking
|
||||
3. Build basic event publishing to relays
|
||||
4. Implement NIP-09 deletion requests
|
||||
|
||||
### Phase 2: Social Interaction Foundation
|
||||
1. Implement NIP-22 comments system
|
||||
2. Create NIP-25 reactions support
|
||||
3. Build NIP-19 social sharing functions
|
||||
4. Implement NIP-89 app handler registration
|
||||
5. Develop UI components for social interactions
|
||||
|
||||
### Phase 3: Nostr Wallet Connect
|
||||
1. Implement NWC connection management
|
||||
2. Create wallet interface in profile section
|
||||
3. Develop zap functionality for content
|
||||
4. Build UI for Lightning interactions
|
||||
5. Add tipping capability for creators
|
||||
|
||||
### Phase 4: Advanced Social Features
|
||||
1. Implement NIP-51 lists for workout collections
|
||||
2. Create user follows and discoveries
|
||||
3. Develop achievement sharing
|
||||
4. Build coaching and feedback tools
|
||||
5. Add paid content capabilities
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Event validation and processing tests
|
||||
- Deletion request handling tests
|
||||
- Comment threading logic tests
|
||||
- Wallet connection management tests
|
||||
- Relay communication tests
|
||||
- Social share URL generation tests
|
||||
|
||||
### Integration Tests
|
||||
- End-to-end publishing flow testing
|
||||
- Deletion request workflow testing
|
||||
- Comment and reaction functionality testing
|
||||
- Template usage tracking tests
|
||||
- Social sharing workflow tests
|
||||
- Zap flow testing
|
||||
|
||||
### User Testing
|
||||
- Usability of publishing and deletion workflows
|
||||
- Clarity of content status indicators
|
||||
- Wallet connection experience
|
||||
- Performance on different devices and connection speeds
|
||||
|
||||
## Security Considerations
|
||||
- Never store or request user private keys
|
||||
- Secure management of NWC connection secrets
|
||||
- Client-side validation of all incoming events
|
||||
- Content filtering for inappropriate material
|
||||
- User control over content visibility
|
||||
- Protection against spam and abuse
|
||||
|
||||
## Rollout Strategy
|
||||
|
||||
### Development Phase
|
||||
1. Implement custom event kinds and validation
|
||||
2. Create UI components for content publishing status
|
||||
3. Develop local-first storage with Nostr sync
|
||||
4. Build and test deletion request functionality
|
||||
5. Implement wallet connection interface
|
||||
6. Add documentation for Nostr integration
|
||||
|
||||
### Beta Testing
|
||||
1. Release to limited test group
|
||||
2. Monitor relay performance and sync issues
|
||||
3. Gather feedback on publishing and deletion flows
|
||||
4. Test cross-client compatibility
|
||||
5. Evaluate Lightning payment reliability
|
||||
|
||||
### Production Deployment
|
||||
1. Deploy app handler registration
|
||||
2. Roll out features progressively
|
||||
3. Monitor engagement and performance metrics
|
||||
4. Provide guides for feature usage
|
||||
5. Establish relay connection recommendations
|
||||
6. Create nostr:// URI scheme handlers
|
||||
|
||||
## References
|
||||
- [Nostr NIPs Repository](https://github.com/nostr-protocol/nips)
|
||||
- [NIP-09 Event Deletion](https://github.com/nostr-protocol/nips/blob/master/09.md)
|
||||
- [NIP-10 Text Notes and Threads](https://github.com/nostr-protocol/nips/blob/master/10.md)
|
||||
- [NIP-19 bech32-encoded entities](https://github.com/nostr-protocol/nips/blob/master/19.md)
|
||||
- [NIP-22 Comment](https://github.com/nostr-protocol/nips/blob/master/22.md)
|
||||
- [NIP-89 Recommended Application Handlers](https://github.com/nostr-protocol/nips/blob/master/89.md)
|
||||
- [NDK Documentation](https://github.com/nostr-dev-kit/ndk)
|
||||
- [NIP-47 Wallet Connect](https://github.com/nostr-protocol/nips/blob/master/47.md)
|
||||
- [NIP-57 Lightning Zaps](https://github.com/nostr-protocol/nips/blob/master/57.md)
|
File diff suppressed because it is too large
Load Diff
@ -1,447 +0,0 @@
|
||||
# Social Feed Cache Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the implementation of the Social Feed Cache system in the POWR app. The cache system is designed to provide offline access to social feed data, reduce network usage, and improve performance.
|
||||
|
||||
## Key Components
|
||||
|
||||
1. **SocialFeedCache**: The main service that handles caching of social feed events
|
||||
2. **EventCache**: A service for caching individual Nostr events
|
||||
3. **useSocialFeed**: A hook that provides access to the social feed data
|
||||
4. **RelayInitializer**: A component that initializes the cache system
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Write Buffer System
|
||||
|
||||
The SocialFeedCache uses a write buffer system to batch database operations and reduce transaction conflicts. This approach is inspired by the Olas NDK Mobile implementation.
|
||||
|
||||
```typescript
|
||||
private writeBuffer: { query: string; params: any[] }[] = [];
|
||||
private bufferFlushTimer: NodeJS.Timeout | null = null;
|
||||
private bufferFlushTimeout: number = 100; // milliseconds
|
||||
private processingTransaction: boolean = false;
|
||||
|
||||
private bufferWrite(query: string, params: any[]) {
|
||||
this.writeBuffer.push({ query, params });
|
||||
|
||||
if (!this.bufferFlushTimer) {
|
||||
this.bufferFlushTimer = setTimeout(() => this.flushWriteBuffer(), this.bufferFlushTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
private async flushWriteBuffer() {
|
||||
if (this.writeBuffer.length === 0 || this.processingTransaction) return;
|
||||
|
||||
const bufferCopy = [...this.writeBuffer];
|
||||
this.writeBuffer = [];
|
||||
|
||||
this.processingTransaction = true;
|
||||
|
||||
try {
|
||||
await this.db.withTransactionAsync(async () => {
|
||||
for (const { query, params } of bufferCopy) {
|
||||
await this.db.runAsync(query, params);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[SocialFeedCache] Error flushing write buffer:', error);
|
||||
// If there was an error, add the operations back to the buffer
|
||||
for (const op of bufferCopy) {
|
||||
if (!this.writeBuffer.some(item =>
|
||||
item.query === op.query &&
|
||||
JSON.stringify(item.params) === JSON.stringify(op.params)
|
||||
)) {
|
||||
this.writeBuffer.push(op);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.processingTransaction = false;
|
||||
}
|
||||
|
||||
this.bufferFlushTimer = null;
|
||||
|
||||
// If there are more operations, start a new timer
|
||||
if (this.writeBuffer.length > 0) {
|
||||
this.bufferFlushTimer = setTimeout(() => this.flushWriteBuffer(), this.bufferFlushTimeout);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### In-Memory Tracking with LRU Cache
|
||||
|
||||
To prevent redundant database operations, the SocialFeedCache uses an LRU (Least Recently Used) cache to track known events:
|
||||
|
||||
```typescript
|
||||
private knownEventIds: LRUCache<string, number>; // Event ID -> timestamp
|
||||
|
||||
constructor(database: SQLiteDatabase) {
|
||||
this.db = new DbService(database);
|
||||
this.eventCache = new EventCache(database);
|
||||
|
||||
// Initialize LRU cache for known events (limit to 1000 entries)
|
||||
this.knownEventIds = new LRUCache<string, number>({ maxSize: 1000 });
|
||||
|
||||
// Ensure feed_cache table exists
|
||||
this.initializeTable();
|
||||
}
|
||||
```
|
||||
|
||||
### Debounced Subscriptions
|
||||
|
||||
The `useSocialFeed` hook implements debouncing to prevent rapid resubscriptions:
|
||||
|
||||
```typescript
|
||||
// Subscription cooldown to prevent rapid resubscriptions
|
||||
const subscriptionCooldown = useRef<NodeJS.Timeout | null>(null);
|
||||
const cooldownPeriod = 2000; // 2 seconds
|
||||
const subscriptionAttempts = useRef(0);
|
||||
const maxSubscriptionAttempts = 3;
|
||||
|
||||
// In loadFeed function:
|
||||
// Prevent rapid resubscriptions
|
||||
if (subscriptionCooldown.current) {
|
||||
console.log('[useSocialFeed] Subscription on cooldown, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Track subscription attempts to prevent infinite loops
|
||||
subscriptionAttempts.current += 1;
|
||||
if (subscriptionAttempts.current > maxSubscriptionAttempts) {
|
||||
console.error(`[useSocialFeed] Too many subscription attempts (${subscriptionAttempts.current}), giving up`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set a cooldown to prevent rapid resubscriptions
|
||||
subscriptionCooldown.current = setTimeout(() => {
|
||||
subscriptionCooldown.current = null;
|
||||
// Reset attempt counter after cooldown period
|
||||
subscriptionAttempts.current = 0;
|
||||
}, cooldownPeriod);
|
||||
```
|
||||
|
||||
### Proper Initialization
|
||||
|
||||
The RelayInitializer component ensures that the SocialFeedCache is properly initialized with the NDK instance:
|
||||
|
||||
```typescript
|
||||
// Initialize ProfileImageCache and SocialFeedCache with NDK instance
|
||||
useEffect(() => {
|
||||
if (ndk) {
|
||||
console.log('[RelayInitializer] Setting NDK instance in ProfileImageCache');
|
||||
profileImageCache.setNDK(ndk);
|
||||
|
||||
// Initialize SocialFeedCache with NDK instance
|
||||
if (db) {
|
||||
try {
|
||||
const socialFeedCache = getSocialFeedCache(db);
|
||||
socialFeedCache.setNDK(ndk);
|
||||
console.log('[RelayInitializer] SocialFeedCache initialized with NDK');
|
||||
} catch (error) {
|
||||
console.error('[RelayInitializer] Error initializing SocialFeedCache:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [ndk, db]);
|
||||
```
|
||||
|
||||
### Global Transaction Lock Mechanism
|
||||
|
||||
To prevent transaction conflicts between different services (such as SocialFeedCache and ContactCacheService), we've implemented a global transaction lock mechanism in the SocialFeedCache class:
|
||||
|
||||
```typescript
|
||||
// Global transaction lock to prevent transaction conflicts across services
|
||||
private static transactionLock: boolean = false;
|
||||
private static transactionQueue: (() => Promise<void>)[] = [];
|
||||
private static processingQueue: boolean = false;
|
||||
|
||||
/**
|
||||
* Acquire the global transaction lock
|
||||
* @returns True if lock was acquired, false otherwise
|
||||
*/
|
||||
private static acquireTransactionLock(): boolean {
|
||||
if (SocialFeedCache.transactionLock) {
|
||||
return false;
|
||||
}
|
||||
SocialFeedCache.transactionLock = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the global transaction lock
|
||||
*/
|
||||
private static releaseTransactionLock(): void {
|
||||
SocialFeedCache.transactionLock = false;
|
||||
// Process the next transaction in queue if any
|
||||
if (SocialFeedCache.transactionQueue.length > 0 && !SocialFeedCache.processingQueue) {
|
||||
SocialFeedCache.processTransactionQueue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a transaction to the queue
|
||||
* @param transaction Function that performs the transaction
|
||||
*/
|
||||
private static enqueueTransaction(transaction: () => Promise<void>): void {
|
||||
SocialFeedCache.transactionQueue.push(transaction);
|
||||
// Start processing the queue if not already processing
|
||||
if (!SocialFeedCache.processingQueue) {
|
||||
SocialFeedCache.processTransactionQueue();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the transaction queue
|
||||
*/
|
||||
private static async processTransactionQueue(): Promise<void> {
|
||||
if (SocialFeedCache.processingQueue || SocialFeedCache.transactionQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
SocialFeedCache.processingQueue = true;
|
||||
|
||||
try {
|
||||
while (SocialFeedCache.transactionQueue.length > 0) {
|
||||
// Wait until we can acquire the lock
|
||||
if (!SocialFeedCache.acquireTransactionLock()) {
|
||||
// If we can't acquire the lock, wait and try again
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the next transaction
|
||||
const transaction = SocialFeedCache.transactionQueue.shift();
|
||||
if (!transaction) {
|
||||
SocialFeedCache.releaseTransactionLock();
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Execute the transaction
|
||||
await transaction();
|
||||
} catch (error) {
|
||||
console.error('[SocialFeedCache] Error executing queued transaction:', error);
|
||||
} finally {
|
||||
// Release the lock
|
||||
SocialFeedCache.releaseTransactionLock();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
SocialFeedCache.processingQueue = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a transaction with the global lock
|
||||
* @param transaction Function that performs the transaction
|
||||
*/
|
||||
public static async executeWithLock(transaction: () => Promise<void>): Promise<void> {
|
||||
// Add the transaction to the queue
|
||||
SocialFeedCache.enqueueTransaction(transaction);
|
||||
}
|
||||
```
|
||||
|
||||
This mechanism ensures that only one transaction is active at any given time, preventing the "cannot start a transaction within a transaction" error that can occur when two services try to start transactions simultaneously.
|
||||
|
||||
The `executeWithLock` method can be used by other services to coordinate their database transactions with SocialFeedCache:
|
||||
|
||||
```typescript
|
||||
// Example usage in ContactCacheService
|
||||
async cacheContacts(ownerPubkey: string, contacts: string[]): Promise<void> {
|
||||
if (!ownerPubkey || !contacts.length) return;
|
||||
|
||||
try {
|
||||
// Use the global transaction lock to prevent conflicts with other services
|
||||
await SocialFeedCache.executeWithLock(async () => {
|
||||
try {
|
||||
// Use a transaction for better performance
|
||||
await this.db.withTransactionAsync(async () => {
|
||||
// Database operations...
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ContactCacheService] Error in transaction:', error);
|
||||
throw error; // Rethrow to ensure the transaction is marked as failed
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ContactCacheService] Error caching contacts:', error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Enhanced Write Buffer System
|
||||
|
||||
The write buffer system has been enhanced with exponential backoff and improved error handling:
|
||||
|
||||
```typescript
|
||||
private async flushWriteBuffer() {
|
||||
if (this.writeBuffer.length === 0 || this.processingTransaction) return;
|
||||
|
||||
// Check if database is available
|
||||
if (!this.isDbAvailable()) {
|
||||
console.log('[SocialFeedCache] Database not available, delaying flush');
|
||||
this.scheduleNextFlush(true); // Schedule with backoff
|
||||
return;
|
||||
}
|
||||
|
||||
// Take only a batch of operations to process at once
|
||||
const bufferCopy = [...this.writeBuffer].slice(0, this.maxBatchSize);
|
||||
this.writeBuffer = this.writeBuffer.slice(bufferCopy.length);
|
||||
|
||||
this.processingTransaction = true;
|
||||
|
||||
// Use the transaction lock to prevent conflicts
|
||||
try {
|
||||
// Check if we've exceeded the maximum retry count
|
||||
if (this.retryCount > this.maxRetryCount) {
|
||||
console.warn(`[SocialFeedCache] Exceeded maximum retry count (${this.maxRetryCount}), dropping ${bufferCopy.length} operations`);
|
||||
// Reset retry count but don't retry these operations
|
||||
this.retryCount = 0;
|
||||
this.processingTransaction = false;
|
||||
this.scheduleNextFlush();
|
||||
return;
|
||||
}
|
||||
|
||||
// Increment retry count before attempting transaction
|
||||
this.retryCount++;
|
||||
|
||||
// Execute the transaction with the global lock
|
||||
await SocialFeedCache.executeWithLock(async () => {
|
||||
try {
|
||||
// Execute the transaction
|
||||
await this.db.withTransactionAsync(async () => {
|
||||
for (const { query, params } of bufferCopy) {
|
||||
try {
|
||||
await this.db.runAsync(query, params);
|
||||
} catch (innerError) {
|
||||
// Log individual query errors but continue with other queries
|
||||
console.error(`[SocialFeedCache] Error executing query: ${query}`, innerError);
|
||||
// Don't rethrow to allow other queries to proceed
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Success - reset retry count
|
||||
this.retryCount = 0;
|
||||
this.dbAvailable = true; // Mark database as available
|
||||
} catch (error) {
|
||||
console.error('[SocialFeedCache] Error in transaction:', error);
|
||||
|
||||
// Check for database connection errors
|
||||
if (error instanceof Error &&
|
||||
(error.message.includes('closed resource') ||
|
||||
error.message.includes('Database not available'))) {
|
||||
// Mark database as unavailable
|
||||
this.dbAvailable = false;
|
||||
console.warn('[SocialFeedCache] Database connection issue detected, marking as unavailable');
|
||||
|
||||
// Add all operations back to the buffer
|
||||
this.writeBuffer = [...bufferCopy, ...this.writeBuffer];
|
||||
} else {
|
||||
// For other errors, add operations back to the buffer
|
||||
// but only if they're not already there (avoid duplicates)
|
||||
for (const op of bufferCopy) {
|
||||
if (!this.writeBuffer.some(item =>
|
||||
item.query === op.query &&
|
||||
JSON.stringify(item.params) === JSON.stringify(op.params)
|
||||
)) {
|
||||
// Add back to the beginning of the buffer to retry sooner
|
||||
this.writeBuffer.unshift(op);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rethrow to ensure the transaction is marked as failed
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[SocialFeedCache] Error flushing write buffer:', error);
|
||||
} finally {
|
||||
this.processingTransaction = false;
|
||||
this.scheduleNextFlush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the next buffer flush with optional backoff
|
||||
*/
|
||||
private scheduleNextFlush(withBackoff: boolean = false) {
|
||||
if (this.bufferFlushTimer) {
|
||||
clearTimeout(this.bufferFlushTimer);
|
||||
this.bufferFlushTimer = null;
|
||||
}
|
||||
|
||||
if (this.writeBuffer.length > 0) {
|
||||
let delay = this.bufferFlushTimeout;
|
||||
|
||||
if (withBackoff) {
|
||||
// Use exponential backoff based on retry count
|
||||
delay = Math.min(
|
||||
this.bufferFlushTimeout * Math.pow(2, this.retryCount),
|
||||
this.maxBackoffTime
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[SocialFeedCache] Scheduling next flush in ${delay}ms (retry: ${this.retryCount})`);
|
||||
this.bufferFlushTimer = setTimeout(() => this.flushWriteBuffer(), delay);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Eliminated Transaction Conflicts**: The global transaction lock mechanism prevents transaction conflicts between different services.
|
||||
2. **Improved Reliability**: The transaction queue ensures that all transactions are processed even if they can't be executed immediately.
|
||||
3. **Enhanced Error Recovery**: The exponential backoff and retry mechanism improves recovery from temporary database errors.
|
||||
4. **Better Offline Stability**: The system handles database unavailability gracefully, enabling seamless offline operation.
|
||||
5. **Reduced Database Contention**: Coordinated transactions reduce contention on the database.
|
||||
6. **Improved Performance**: The LRU cache reduces redundant database operations.
|
||||
7. **Better Error Handling**: The system includes robust error handling to prevent cascading failures.
|
||||
8. **Offline Support**: The cache system provides offline access to social feed data.
|
||||
9. **Reduced Network Usage**: The system reduces network usage by caching events locally.
|
||||
|
||||
## Debugging
|
||||
|
||||
The Following screen includes debug information to help troubleshoot issues:
|
||||
|
||||
```typescript
|
||||
// Debug controls component - memoized
|
||||
const DebugControls = useCallback(() => (
|
||||
<View className="bg-gray-100 p-4 rounded-lg mx-4 mb-4">
|
||||
<Text className="font-bold mb-2">Debug Info:</Text>
|
||||
<Text>User: {currentUser?.pubkey?.substring(0, 8)}...</Text>
|
||||
<Text>Feed Items: {entries.length}</Text>
|
||||
<Text>Loading: {loading ? "Yes" : "No"}</Text>
|
||||
<Text>Offline: {isOffline ? "Yes" : "No"}</Text>
|
||||
<Text>Contacts: {contacts.length}</Text>
|
||||
<Text>Loading Contacts: {isLoadingContacts ? "Yes" : "No"}</Text>
|
||||
|
||||
<View className="flex-row mt-4 justify-between">
|
||||
<TouchableOpacity
|
||||
className="bg-blue-500 p-2 rounded flex-1 mr-2"
|
||||
onPress={checkRelayConnections}
|
||||
>
|
||||
<Text className="text-white text-center">Check Relays</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
className="bg-green-500 p-2 rounded flex-1"
|
||||
onPress={handleRefresh}
|
||||
>
|
||||
<Text className="text-white text-center">Force Refresh</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
), [currentUser?.pubkey, entries.length, loading, isOffline, contacts.length, isLoadingContacts, checkRelayConnections, handleRefresh]);
|
||||
```
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **Automatic Cache Cleanup**: Implement automatic cleanup of old cached events.
|
||||
2. **Cache Synchronization**: Implement synchronization between the cache and the server.
|
||||
3. **Cache Compression**: Implement compression of cached data to reduce storage usage.
|
||||
4. **Cache Encryption**: Implement encryption of cached data to improve security.
|
||||
5. **Cache Analytics**: Implement analytics to track cache usage and performance.
|
@ -1,220 +0,0 @@
|
||||
# Social Feed Filtering Rules
|
||||
|
||||
This document outlines the filtering rules for the different social feed tabs in the POWR app.
|
||||
|
||||
## Overview
|
||||
|
||||
The POWR app has three main social feed tabs:
|
||||
1. **POWR** - Official content from the POWR team
|
||||
2. **Following** - Content from users the current user follows
|
||||
3. **Community** (formerly Global) - Content from the broader Nostr community
|
||||
|
||||
Each feed has specific filtering rules to ensure users see relevant fitness-related content.
|
||||
|
||||
## Content Types
|
||||
|
||||
The app handles several types of Nostr events:
|
||||
- **Social Posts** (kind 1) - Regular text posts
|
||||
- **Articles** (kind 30023) - Long-form content
|
||||
- **Article Drafts** (kind 30024) - Unpublished long-form content
|
||||
- **Workout Records** (kind 1301) - Completed workouts
|
||||
- **Exercise Templates** (kind 33401) - Exercise definitions
|
||||
- **Workout Templates** (kind 33402) - Workout plans
|
||||
|
||||
## Filtering Rules
|
||||
|
||||
### POWR Feed
|
||||
- Shows content **only** from the official POWR account (`npub1p0wer69rpkraqs02l5v8rutagfh6g9wxn2dgytkv44ysz7avt8nsusvpjk`)
|
||||
- Includes:
|
||||
- Social posts (kind 1)
|
||||
- Published articles (kind 30023)
|
||||
- Workout records (kind 1301)
|
||||
- Exercise templates (kind 33401)
|
||||
- Workout templates (kind 33402)
|
||||
- **Excludes** article drafts (kind 30024)
|
||||
|
||||
### Following Feed
|
||||
- Shows content from users the current user follows
|
||||
- For social posts (kind 1) and articles (kind 30023), only shows content with fitness-related tags:
|
||||
- #workout
|
||||
- #fitness
|
||||
- #powr
|
||||
- #31days
|
||||
- #crossfit
|
||||
- #wod
|
||||
- #gym
|
||||
- #strength
|
||||
- #cardio
|
||||
- #training
|
||||
- #exercise
|
||||
- Always shows workout-specific content (kinds 1301, 33401, 33402) from followed users
|
||||
- **Excludes** article drafts (kind 30024)
|
||||
|
||||
### Community Feed
|
||||
- Shows content from all users
|
||||
- For social posts (kind 1) and articles (kind 30023), only shows content with fitness-related tags (same as Following Feed)
|
||||
- Always shows workout-specific content (kinds 1301, 33401, 33402)
|
||||
- **Excludes** article drafts (kind 30024)
|
||||
|
||||
### User Activity Feed
|
||||
- Shows only the current user's own content
|
||||
- For social posts (kind 1) and articles (kind 30023), only shows content with fitness-related tags (same as Following Feed)
|
||||
- Always shows the user's workout-specific content (kinds 1301, 33401, 33402)
|
||||
- **Excludes** article drafts (kind 30024)
|
||||
|
||||
## Implementation Details
|
||||
|
||||
The filtering is implemented in several key files:
|
||||
- `lib/social/socialFeedService.ts` - Core service that handles feed subscriptions
|
||||
- `lib/hooks/useFeedHooks.ts` - React hooks for the different feed types
|
||||
- `components/social/EnhancedSocialPost.tsx` - Component that renders feed items
|
||||
|
||||
### Tag-Based Filtering
|
||||
|
||||
For social posts and articles, we filter based on the presence of fitness-related tags. This ensures that users only see content relevant to fitness and workouts.
|
||||
|
||||
### Content Type Filtering
|
||||
|
||||
Workout-specific content (kinds 1301, 33401, 33402) is always included in the feeds, as these are inherently fitness-related.
|
||||
|
||||
### Draft Exclusion
|
||||
|
||||
Article drafts (kind 30024) are excluded from all feeds to ensure users only see published content.
|
||||
|
||||
## Modifying Feed Filtering
|
||||
|
||||
If you need to modify the event types or tags used for filtering, you'll need to update the following files:
|
||||
|
||||
### 1. To modify event kinds (content types):
|
||||
|
||||
#### a. `lib/social/socialFeedService.ts`:
|
||||
- The `subscribeFeed` method contains the core filtering logic
|
||||
- Modify the `workoutFilter` object to change workout-specific content kinds (1301, 33401, 33402)
|
||||
- Modify the `socialPostFilter` object to change social post kinds (1)
|
||||
- Modify the `articleFilter` object to change article kinds (30023)
|
||||
- The special case for draft articles (30024) has been removed, but you can add it back if needed
|
||||
|
||||
```typescript
|
||||
// Example: To add a new workout-related kind (e.g., 1302)
|
||||
const workoutFilter: NDKFilter = {
|
||||
kinds: [1301, 33401, 33402, 1302] as any[],
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
#### b. `lib/hooks/useFeedHooks.ts`:
|
||||
- Update the filter arrays in each hook function:
|
||||
- `useFollowingFeed`
|
||||
- `usePOWRFeed`
|
||||
- `useGlobalFeed`
|
||||
- `useUserActivityFeed`
|
||||
|
||||
```typescript
|
||||
// Example: Adding a new kind to the POWR feed
|
||||
const powrFilters = useMemo<NDKFilter[]>(() => {
|
||||
if (!POWR_PUBKEY_HEX) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
kinds: [1, 30023, 1302] as any[], // Added new kind 1302
|
||||
authors: [POWR_PUBKEY_HEX],
|
||||
limit: 25
|
||||
},
|
||||
// ...
|
||||
];
|
||||
}, []);
|
||||
```
|
||||
|
||||
### 2. To modify fitness-related tags:
|
||||
|
||||
#### a. `lib/social/socialFeedService.ts`:
|
||||
- Find the tag arrays in the `socialPostFilter` and `articleFilter` objects:
|
||||
|
||||
```typescript
|
||||
socialPostFilter['#t'] = [
|
||||
'workout', 'fitness', 'powr', '31days',
|
||||
'crossfit', 'wod', 'gym', 'strength',
|
||||
'cardio', 'training', 'exercise'
|
||||
// Add new tags here
|
||||
];
|
||||
```
|
||||
|
||||
#### b. `lib/hooks/useFeedHooks.ts`:
|
||||
- Update the tag arrays in each hook function:
|
||||
- `useFollowingFeed`
|
||||
- `useGlobalFeed`
|
||||
- `useUserActivityFeed`
|
||||
|
||||
```typescript
|
||||
'#t': [
|
||||
'workout', 'fitness', 'powr', '31days',
|
||||
'crossfit', 'wod', 'gym', 'strength',
|
||||
'cardio', 'training', 'exercise',
|
||||
'newTag1', 'newTag2' // Add new tags here
|
||||
]
|
||||
```
|
||||
|
||||
### 3. To modify content rendering:
|
||||
|
||||
#### a. `components/social/EnhancedSocialPost.tsx`:
|
||||
- The `renderContent` method determines how different content types are displayed
|
||||
- Modify this method if you add new event kinds or need to change how existing kinds are rendered
|
||||
|
||||
```typescript
|
||||
// Example: Adding support for a new kind
|
||||
case 'newContentType':
|
||||
return <NewContentTypeComponent data={item.parsedContent as NewContentType} />;
|
||||
```
|
||||
|
||||
### 4. To modify event parsing:
|
||||
|
||||
#### a. `lib/hooks/useSocialFeed.ts`:
|
||||
- The `processEvent` function parses events based on their kind
|
||||
- Update this function if you add new event kinds or change how existing kinds are processed
|
||||
|
||||
```typescript
|
||||
// Example: Adding support for a new kind
|
||||
case NEW_KIND:
|
||||
feedItem = {
|
||||
id: event.id,
|
||||
type: 'newType',
|
||||
originalEvent: event,
|
||||
parsedContent: parseNewContent(event),
|
||||
createdAt: timestamp
|
||||
};
|
||||
break;
|
||||
```
|
||||
|
||||
### 5. Event type definitions:
|
||||
|
||||
#### a. `types/nostr-workout.ts`:
|
||||
- Contains the `POWR_EVENT_KINDS` enum with all supported event kinds
|
||||
- Update this enum if you add new event kinds
|
||||
|
||||
```typescript
|
||||
// Example: Adding a new kind
|
||||
export enum POWR_EVENT_KINDS {
|
||||
// Existing kinds...
|
||||
NEW_KIND = 1302,
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Changes
|
||||
|
||||
After modifying the filtering rules, test the changes in all feed tabs:
|
||||
1. POWR feed
|
||||
2. Following feed
|
||||
3. Community feed
|
||||
4. User Activity feed (in the Profile tab)
|
||||
|
||||
Verify that:
|
||||
- Only the expected content types appear in each feed
|
||||
- Content with the specified tags is properly filtered
|
||||
- New event kinds are correctly rendered
|
||||
|
||||
## Future Improvements
|
||||
|
||||
Potential future improvements to the filtering system:
|
||||
- Add user-configurable filters for specific fitness interests
|
||||
- Implement AI-based content relevance scoring
|
||||
- Add support for more content types as the Nostr ecosystem evolves
|
@ -1,282 +0,0 @@
|
||||
### 1. Implement a Centralized Feed Management System
|
||||
|
||||
Based on both Olas and NDK's approaches:
|
||||
|
||||
- **Create a `FeedEntry` type** with clear support for different event kinds and content types
|
||||
- **Implement state management using references** following NDK's pattern of using `Map` and `Set` references for better performance
|
||||
- **Add proper event deduplication** using NDK's seen events tracking mechanism
|
||||
|
||||
### 2. Improve Event Subscription and Filtering
|
||||
|
||||
From NDK's implementation:
|
||||
|
||||
- **Utilize NDK's subscription options more effectively**:
|
||||
```typescript
|
||||
const subscription = ndk.subscribe(filters, {
|
||||
closeOnEose: true,
|
||||
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST,
|
||||
groupable: true,
|
||||
skipVerification: false
|
||||
});
|
||||
```
|
||||
|
||||
- **Implement caching strategies** using NDK's caching mechanisms:
|
||||
```typescript
|
||||
// Try cache first, then relays
|
||||
const cacheStrategy = NDKSubscriptionCacheUsage.CACHE_FIRST;
|
||||
```
|
||||
|
||||
- **Use subscription grouping** to reduce relay connections for similar queries:
|
||||
```typescript
|
||||
// Group similar subscriptions with a delay
|
||||
const groupableDelay = 100; // ms
|
||||
```
|
||||
|
||||
### 3. Enhance Contact List Fetching
|
||||
|
||||
NDK offers more sophisticated contact list handling:
|
||||
|
||||
- **Use NDK's contact list event fetching** with explicit validation:
|
||||
```typescript
|
||||
// Direct method to get follows for a user
|
||||
async followSet(opts?: NDKSubscriptionOptions): Promise<Set<Hexpubkey>> {
|
||||
const follows = await this.follows(opts);
|
||||
return new Set(Array.from(follows).map((f) => f.pubkey));
|
||||
}
|
||||
```
|
||||
|
||||
- **Implement loading states and retry logic** using NDK's pattern:
|
||||
```typescript
|
||||
// Track loading state with cleanup on unmount
|
||||
let isMounted = true;
|
||||
// ...
|
||||
return () => { isMounted = false; }
|
||||
```
|
||||
|
||||
### 4. Enhanced Subscription Management
|
||||
|
||||
From NDK's subscription implementation:
|
||||
|
||||
- **Proper lifecycle management** for subscriptions:
|
||||
```typescript
|
||||
// Keep track of subscriptions for cleanup
|
||||
const subscriptionRef = useRef<NDKSubscription | null>(null);
|
||||
|
||||
// Clean up subscription on component unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (subscriptionRef.current) {
|
||||
subscriptionRef.current.stop();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
- **Handle relay connection state** more effectively:
|
||||
```typescript
|
||||
// Monitor relay connections
|
||||
ndk.pool.on('relay:connect', (relay: NDKRelay) => {
|
||||
console.log(`Relay connected: ${relay.url}`);
|
||||
});
|
||||
```
|
||||
|
||||
### 5. Optimize Event Processing Pipeline
|
||||
|
||||
Based on NDK's efficient event handling:
|
||||
|
||||
- **Implement event processing with proper validation**:
|
||||
```typescript
|
||||
// Only process events that pass validation
|
||||
if (!this.skipValidation) {
|
||||
if (!ndkEvent.isValid) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Use NDK's event queuing and batch processing**:
|
||||
```typescript
|
||||
// Batch event processing for better performance
|
||||
const updateEntries = (reason: string) => {
|
||||
const newSlice = entriesFromIds(newEntriesRef.current);
|
||||
// ... process in batch rather than individually
|
||||
}
|
||||
```
|
||||
|
||||
- **Implement EOSE (End of Stored Events) handling** more effectively:
|
||||
```typescript
|
||||
// Handle EOSE with improved timing
|
||||
subscription.on('eose', () => {
|
||||
if (isMounted) {
|
||||
setLoading(false);
|
||||
setEose(true);
|
||||
// Process any accumulated events after EOSE
|
||||
updateEntries('eose');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 6. Implement Progressive Loading
|
||||
|
||||
From NDK's subscription approach:
|
||||
|
||||
- **Use cache-first loading with fallback to relays**:
|
||||
```typescript
|
||||
// Try cache first for immediate feedback
|
||||
if (ndk.cacheAdapter) {
|
||||
const cachedEvents = await ndk.cacheAdapter.query(subscription);
|
||||
if (cachedEvents.length > 0) {
|
||||
// Process cached events immediately
|
||||
processCachedEvents(cachedEvents);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Implement pagination with proper thresholds**:
|
||||
```typescript
|
||||
// Only fetch more when needed
|
||||
if (hasMore && !loading && !refreshing) {
|
||||
loadMore(oldestTimestamp - 1);
|
||||
}
|
||||
```
|
||||
|
||||
## Key Architectural Improvements
|
||||
|
||||
1. **More Robust Feed State Management**:
|
||||
- Use `useRef` for maintaining state references that don't trigger re-renders
|
||||
- Implement batch updates for feed state changes
|
||||
- Use proper cleanup and event deduplication
|
||||
|
||||
2. **Enhanced Subscription Management**:
|
||||
- Use `NDKSubscription` with appropriate options
|
||||
- Implement proper grouping and caching strategies
|
||||
- Handle subscription lifecycle with proper cleanup
|
||||
|
||||
3. **Contact List Reliability**:
|
||||
- Use multiple approaches to fetch and validate contacts
|
||||
- Implement caching and retry logic for reliable contact detection
|
||||
- Handle edge cases (like self-follows) appropriately
|
||||
|
||||
4. **Event Processing Optimization**:
|
||||
- Implement more efficient event filtering and processing
|
||||
- Use batch processing for events after EOSE
|
||||
- Add proper validation and verification
|
||||
|
||||
5. **Performance Enhancements**:
|
||||
- Use NDK's optimization patterns like subscription grouping
|
||||
- Implement proper memory management for large event sets
|
||||
- Use efficient data structures (Map/Set) for event tracking
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
1. **Start with rewriting the subscription layer** using NDK's patterns
|
||||
2. **Then enhance the contact list fetching** with multiple reliable approaches
|
||||
3. **Next, implement improved feed state management** with efficient data structures
|
||||
4. **Finally, optimize the UI components** for better rendering performance
|
||||
|
||||
This updated plan combines the best practices from both Olas's specialized feed implementation and NDK's efficient subscription management, providing a solid foundation for building a fast, reliable, and focused social feed for workout-related content in the POWR app.
|
||||
|
||||
Let me review your current subscription and feed code, and identify which files we'll need to delete, update, or create to implement the improved architecture.
|
||||
|
||||
## Current Code Structure
|
||||
|
||||
From what I can see in the shared code, you have:
|
||||
|
||||
1. **useFeedEvents.ts** - Core hook for handling feed events
|
||||
2. **useFeedHooks.ts** - Specialized feed hooks (Following, POWR, Global)
|
||||
3. **useFeedMonitor.ts** - For monitoring feed state and auto-refresh
|
||||
4. **powr.tsx** - The POWR tab screen component
|
||||
5. **following.tsx** - The Following tab screen component
|
||||
6. **global.tsx** - The Global tab screen component
|
||||
7. **EnhancedSocialPost.tsx** - Component for rendering feed posts
|
||||
8. **socialFeedService.ts** - Service for interacting with Nostr
|
||||
|
||||
## Plan for Implementation
|
||||
|
||||
### Files to Update (Modify):
|
||||
|
||||
1. **useFeedEvents.ts** - Update with NDK's subscription patterns and better event processing
|
||||
- Improve subscription lifecycle management
|
||||
- Implement better event deduplication
|
||||
- Add batch processing and performance optimizations
|
||||
|
||||
2. **useFeedHooks.ts** - Enhance contact list fetching and filter creation
|
||||
- Implement multiple approaches for contact list fetching
|
||||
- Improve filter creation for better relevance
|
||||
- Remove hardcoded fallbacks in favor of more reliable mechanisms
|
||||
|
||||
3. **useFeedMonitor.ts** - Minor updates for integration with new feed structure
|
||||
- Enhance refresh mechanisms
|
||||
- Improve state tracking
|
||||
|
||||
4. **following.tsx** - Update to use the new feed architecture
|
||||
- Remove debug code once implementation is stable
|
||||
- Improve UI for feed state feedback
|
||||
|
||||
5. **powr.tsx** - Update to fix infinite loop issues
|
||||
- Improve component lifecycle management
|
||||
- Enhance performance
|
||||
|
||||
6. **global.tsx** - Update for consistency with other feed implementations
|
||||
- Ensure consistent behavior across all feed tabs
|
||||
|
||||
7. **socialFeedService.ts** - Enhance with better relay interaction
|
||||
- Improve subscription creation
|
||||
- Add better event filtering
|
||||
|
||||
### Files to Create:
|
||||
|
||||
1. **useFeedState.ts** - New hook for centralized feed state management
|
||||
```typescript
|
||||
// Manage feed entries state with efficient updates
|
||||
export function useFeedState() {
|
||||
// Implementation here
|
||||
}
|
||||
```
|
||||
|
||||
2. **useFeedSubscription.ts** - New hook for subscription management
|
||||
```typescript
|
||||
// Handle NDK subscriptions with proper lifecycle
|
||||
export function useFeedSubscription(filters: NDKFilter[]) {
|
||||
// Implementation here
|
||||
}
|
||||
```
|
||||
|
||||
3. **types/feed.ts** - Enhanced type definitions for feed entries
|
||||
```typescript
|
||||
// More comprehensive feed entry types
|
||||
export interface FeedEntry {
|
||||
// Enhanced type definition
|
||||
}
|
||||
```
|
||||
|
||||
4. **utils/feedUtils.ts** - Utility functions for feed operations
|
||||
```typescript
|
||||
// Feed-related utility functions
|
||||
export function deduplicateEvents() {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
5. **components/feed/FeedList.tsx** - Shared component for feed rendering
|
||||
```typescript
|
||||
// Reusable feed list component with virtualization
|
||||
export function FeedList({ entries, onItemPress }) {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
### Files to Delete:
|
||||
|
||||
None of the files need to be deleted entirely. Instead, we'll refactor and enhance the existing codebase to implement the new architecture.
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. First, create the new type definitions in **types/feed.ts**
|
||||
2. Then, implement the new hooks in **useFeedSubscription.ts** and **useFeedState.ts**
|
||||
3. Update **useFeedEvents.ts** and **useFeedHooks.ts** with improved implementations
|
||||
4. Create utility functions in **utils/feedUtils.ts**
|
||||
5. Implement the shared component in **components/feed/FeedList.tsx**
|
||||
6. Finally, update the screen components to use the new architecture
|
||||
|
||||
This approach allows us to gradually refactor the codebase while maintaining functionality throughout the process. Each step builds on the previous one, ultimately resulting in a more robust and efficient feed implementation.
|
@ -1,275 +0,0 @@
|
||||
# Workout Completion Flow Design Document
|
||||
|
||||
## Problem Statement
|
||||
Users need a clear, privacy-respecting process for completing workouts, with options to save locally and/or publish to Nostr, update templates based on changes made during the workout, and optionally share their accomplishments socially. The current implementation lacks a structured flow for these decisions and doesn't address privacy concerns around workout metrics.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional Requirements
|
||||
- Allow users to complete workouts and save data locally
|
||||
- Provide options to publish workouts to Nostr with complete or limited data
|
||||
- Enable social sharing of workout accomplishments
|
||||
- Support template updates based on workout modifications
|
||||
- Maintain proper attribution for templates
|
||||
- Support offline completion with queued publishing
|
||||
- Present clear workout summary and celebration screens
|
||||
|
||||
### Non-Functional Requirements
|
||||
- Privacy: Control over what workout metrics are published
|
||||
- Performance: Completion flow should respond within 500ms
|
||||
- Reliability: Work offline with 100% data retention
|
||||
- Usability: Max 3 steps to complete a workout
|
||||
- Security: Secure handling of Nostr keys and signing
|
||||
- Consistency: Match Nostr protocol specifications (NIP-4e)
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### 1. Three-Tier Storage Approach
|
||||
Implement a tiered approach to workout data storage and sharing: Local Only, Publish to Nostr (Complete/Limited), and Social Sharing.
|
||||
|
||||
**Rationale**:
|
||||
- Provides users with clear control over their data privacy
|
||||
- Aligns with the Nostr protocol's decentralized nature
|
||||
- Balances social engagement with privacy concerns
|
||||
- Enables participation regardless of privacy preferences
|
||||
|
||||
**Trade-offs**:
|
||||
- Additional complexity in the UI
|
||||
- More complex data handling logic
|
||||
- Potential confusion around data visibility
|
||||
|
||||
### 2. Template Update Handling
|
||||
When users modify a workout during execution, offer options to: Keep Original Template, Update Existing Template, or Save as New Template.
|
||||
|
||||
**Rationale**:
|
||||
- Supports natural evolution of workout templates
|
||||
- Maintains history and attribution
|
||||
- Prevents accidental template modifications
|
||||
- Enables template personalization
|
||||
|
||||
**Trade-offs**:
|
||||
- Additional decision point for users
|
||||
- Version tracking complexity
|
||||
- Potential template proliferation
|
||||
|
||||
### 3. Conflict Resolution Strategy
|
||||
Implement a "Last Write Wins with Notification" approach for template conflicts, with options to keep local changes, accept remote changes, or create a fork.
|
||||
|
||||
**Rationale**:
|
||||
- Simple to implement and understand
|
||||
- Provides user awareness of conflicts
|
||||
- Maintains user control over conflict resolution
|
||||
- Avoids blocking workout completion flow
|
||||
|
||||
**Trade-offs**:
|
||||
- May occasionally result in lost updates
|
||||
- Requires additional UI for conflict resolution
|
||||
- Can create multiple versions of templates
|
||||
|
||||
## Technical Design
|
||||
|
||||
### Core Components
|
||||
|
||||
```typescript
|
||||
// Workout Completion Options
|
||||
interface WorkoutCompletionOptions {
|
||||
storageType: 'local_only' | 'publish_complete' | 'publish_limited';
|
||||
shareOnSocial: boolean;
|
||||
socialMessage?: string;
|
||||
templateAction: 'keep_original' | 'update_existing' | 'save_as_new';
|
||||
newTemplateName?: string;
|
||||
}
|
||||
|
||||
// Nostr Event Creation
|
||||
interface NostrEventCreator {
|
||||
createWorkoutRecord(
|
||||
workout: Workout,
|
||||
options: WorkoutCompletionOptions
|
||||
): NostrEvent;
|
||||
|
||||
createSocialShare(
|
||||
workoutRecord: NostrEvent,
|
||||
message: string
|
||||
): NostrEvent;
|
||||
|
||||
updateTemplate(
|
||||
originalTemplate: WorkoutTemplate,
|
||||
modifiedWorkout: Workout
|
||||
): NostrEvent;
|
||||
}
|
||||
|
||||
// Publishing Queue
|
||||
interface PublishingQueue {
|
||||
queueEvent(event: NostrEvent): Promise<void>;
|
||||
processQueue(): Promise<void>;
|
||||
getQueueStatus(): { pending: number, failed: number };
|
||||
}
|
||||
|
||||
// Conflict Resolution
|
||||
interface ConflictResolver {
|
||||
detectConflicts(localTemplate: WorkoutTemplate, remoteTemplate: WorkoutTemplate): boolean;
|
||||
resolveConflict(
|
||||
localTemplate: WorkoutTemplate,
|
||||
remoteTemplate: WorkoutTemplate,
|
||||
resolution: 'use_local' | 'use_remote' | 'create_fork'
|
||||
): WorkoutTemplate;
|
||||
}
|
||||
```
|
||||
|
||||
### Workout Completion Flow
|
||||
|
||||
```typescript
|
||||
async function completeWorkout(
|
||||
workout: Workout,
|
||||
options: WorkoutCompletionOptions
|
||||
): Promise<CompletionResult> {
|
||||
// 1. Save complete workout data locally
|
||||
await saveWorkoutLocally(workout);
|
||||
|
||||
// 2. Handle template updates if needed
|
||||
if (workout.templateId && workout.hasChanges) {
|
||||
await handleTemplateUpdate(workout, options.templateAction, options.newTemplateName);
|
||||
}
|
||||
|
||||
// 3. Publish to Nostr if selected
|
||||
let workoutEvent: NostrEvent | null = null;
|
||||
if (options.storageType !== 'local_only') {
|
||||
const isLimited = options.storageType === 'publish_limited';
|
||||
workoutEvent = await publishWorkoutToNostr(workout, isLimited);
|
||||
}
|
||||
|
||||
// 4. Create social share if selected
|
||||
if (options.shareOnSocial && workoutEvent) {
|
||||
await createSocialShare(workoutEvent, options.socialMessage || '');
|
||||
}
|
||||
|
||||
// 5. Return completion status
|
||||
return {
|
||||
success: true,
|
||||
localId: workout.id,
|
||||
nostrEventId: workoutEvent?.id,
|
||||
pendingSync: !navigator.onLine
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Core Completion Flow
|
||||
1. Implement workout completion confirmation dialog
|
||||
2. Create completion options screen with storage choices
|
||||
3. Build local storage functionality with workout summary
|
||||
4. Add workout celebration screen with achievements
|
||||
5. Implement template difference detection
|
||||
|
||||
### Phase 2: Nostr Integration
|
||||
1. Implement workout record (kind 1301) publishing
|
||||
2. Add support for limited metrics publishing
|
||||
3. Create template update/versioning system
|
||||
4. Implement social sharing via kind 1 posts
|
||||
5. Add offline queue with sync status indicators
|
||||
|
||||
### Phase 3: Refinement and Enhancement
|
||||
1. Add conflict detection and resolution
|
||||
2. Implement template attribution preservation
|
||||
3. Create version history browsing
|
||||
4. Add advanced privacy controls
|
||||
5. Implement achievement recognition system
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Template difference detection
|
||||
- Nostr event generation (complete and limited)
|
||||
- Social post creation
|
||||
- Conflict detection
|
||||
- Privacy filtering logic
|
||||
|
||||
### Integration Tests
|
||||
- End-to-end workout completion flow
|
||||
- Offline completion and sync
|
||||
- Template update scenarios
|
||||
- Cross-device template conflict resolution
|
||||
- Social sharing with quoted content
|
||||
|
||||
### User Testing
|
||||
- Template modification scenarios
|
||||
- Privacy control understanding
|
||||
- Conflict resolution UX
|
||||
- Workout completion satisfaction
|
||||
|
||||
## Observability
|
||||
|
||||
### Logging
|
||||
- Workout completion events
|
||||
- Publishing attempts and results
|
||||
- Template update operations
|
||||
- Conflict detection and resolution
|
||||
- Offline queue processing
|
||||
|
||||
### Metrics
|
||||
- Completion rates
|
||||
- Publishing success rates
|
||||
- Social sharing frequency
|
||||
- Template update frequency
|
||||
- Offline queue size and processing time
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Potential Enhancements
|
||||
- Collaborative template editing
|
||||
- Richer social sharing with images/graphics
|
||||
- Template popularity and trending metrics
|
||||
- Coach/trainee permission model
|
||||
- Interactive workout summary visualizations
|
||||
|
||||
### Known Limitations
|
||||
- Limited to Nostr protocol constraints
|
||||
- No guaranteed deletion of published content
|
||||
- Template conflicts require manual resolution
|
||||
- No cross-device real-time sync
|
||||
- Limited to supported NIP implementations
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Runtime Dependencies
|
||||
- Nostr NDK for event handling
|
||||
- SQLite for local storage
|
||||
- Expo SecureStore for key management
|
||||
- Connectivity detection for offline mode
|
||||
|
||||
### Development Dependencies
|
||||
- TypeScript for type safety
|
||||
- React Native testing tools
|
||||
- Mock Nostr relay for testing
|
||||
- UI/UX prototyping tools
|
||||
|
||||
## Security Considerations
|
||||
- Private keys never exposed to application code
|
||||
- Local workout data encrypted at rest
|
||||
- Clear indication of what data is being published
|
||||
- Template attribution verification
|
||||
- Rate limiting for publishing operations
|
||||
|
||||
## Rollout Strategy
|
||||
|
||||
### Development Phase
|
||||
1. Implement core completion flow with local storage
|
||||
2. Add Nostr publishing with complete/limited options
|
||||
3. Implement template handling and conflict resolution
|
||||
4. Add social sharing capabilities
|
||||
5. Implement comprehensive testing suite
|
||||
|
||||
### Production Deployment
|
||||
1. Release to limited beta testing group
|
||||
2. Monitor completion flow metrics and error rates
|
||||
3. Gather feedback on privacy controls and template handling
|
||||
4. Implement refinements based on user feedback
|
||||
5. Roll out to all users with clear documentation
|
||||
|
||||
## References
|
||||
- [NIP-4e: Workout Events](https://github.com/nostr-protocol/nips/blob/4e-draft/4e.md)
|
||||
- [POWR Social Features Design Document](https://github.com/docNR/powr/blob/main/docs/design/SocialDesignDocument.md)
|
||||
- [Nostr NDK Documentation](https://github.com/nostr-dev-kit/ndk)
|
||||
- [Offline-First Application Architecture](https://blog.flutter.io/offline-first-application-architecture-a2c4b2c61c8b)
|
||||
- [React Native Performance Optimization](https://reactnative.dev/docs/performance)
|
@ -1,848 +0,0 @@
|
||||
# POWR Workout Data Flow Specification
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the complete data flow for the workout feature, from initialization through completion and storage. The design prioritizes data integrity, performance, and future extensibility with Nostr integration.
|
||||
|
||||
## Data Flow Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Initialization
|
||||
A[Template Selection] -->|Load Template| B[Template-to-Workout Transformation]
|
||||
C[Quick Start] -->|Create Empty| B
|
||||
B -->|Initialize| D[Workout Context]
|
||||
end
|
||||
|
||||
subgraph Active Tracking
|
||||
D -->|Current State| E[UI Components]
|
||||
E -->|User Input| F[Dispatch Actions]
|
||||
F -->|State Updates| D
|
||||
D -->|Timer Events| G[Auto-save]
|
||||
end
|
||||
|
||||
subgraph Persistence
|
||||
G -->|Incremental Writes| H[(SQLite)]
|
||||
I[Workout Completion] -->|Final Write| H
|
||||
J[Manual Save] -->|Checkpoint| H
|
||||
end
|
||||
|
||||
subgraph Analysis
|
||||
H -->|Load History| K[PR Detection]
|
||||
H -->|Aggregate Data| L[Statistics Calculation]
|
||||
K --> M[Achievements]
|
||||
L --> M
|
||||
end
|
||||
|
||||
subgraph Sync
|
||||
H -->|Format Events| N[Nostr Event Creation]
|
||||
N -->|Publish| O[Relays]
|
||||
O -->|Subscribe| P[Other Devices]
|
||||
end
|
||||
```
|
||||
|
||||
## Data Transformation Stages
|
||||
|
||||
### 1. Template to Workout Conversion
|
||||
|
||||
```typescript
|
||||
interface WorkoutTemplateToWorkoutParams {
|
||||
template: Template;
|
||||
workoutSettings?: {
|
||||
skipExercises?: string[];
|
||||
addExercises?: WorkoutExercise[];
|
||||
adjustRestTimes?: boolean;
|
||||
scaleWeights?: number; // Percentage multiplier
|
||||
};
|
||||
}
|
||||
|
||||
function convertTemplateToWorkout(
|
||||
params: WorkoutTemplateToWorkoutParams
|
||||
): Workout {
|
||||
// 1. Deep clone template structure
|
||||
// 2. Apply user customizations
|
||||
// 3. Initialize tracking metadata
|
||||
// 4. Generate unique IDs
|
||||
// 5. Add timestamps
|
||||
}
|
||||
```
|
||||
|
||||
Key operations:
|
||||
- Exercise copies maintain reference to source exercise
|
||||
- Sets are initialized with default values from template
|
||||
- Additional metadata fields added for tracking
|
||||
- Timestamps initialized
|
||||
- IDs generated for all entities
|
||||
|
||||
### 2. Workout State Management
|
||||
|
||||
The central reducer handles all state transitions and ensures data consistency:
|
||||
|
||||
```typescript
|
||||
function workoutReducer(
|
||||
state: WorkoutState,
|
||||
action: WorkoutAction
|
||||
): WorkoutState {
|
||||
switch (action.type) {
|
||||
case 'START_WORKOUT':
|
||||
return {
|
||||
...state,
|
||||
status: 'active',
|
||||
activeWorkout: action.payload,
|
||||
startTime: Date.now(),
|
||||
elapsedTime: 0,
|
||||
};
|
||||
|
||||
case 'UPDATE_SET':
|
||||
const { exerciseIndex, setIndex, data } = action.payload;
|
||||
const updatedExercises = [...state.activeWorkout.exercises];
|
||||
const updatedSets = [...updatedExercises[exerciseIndex].sets];
|
||||
|
||||
updatedSets[setIndex] = {
|
||||
...updatedSets[setIndex],
|
||||
...data,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
|
||||
updatedExercises[exerciseIndex] = {
|
||||
...updatedExercises[exerciseIndex],
|
||||
sets: updatedSets,
|
||||
};
|
||||
|
||||
return {
|
||||
...state,
|
||||
activeWorkout: {
|
||||
...state.activeWorkout,
|
||||
exercises: updatedExercises,
|
||||
lastUpdated: Date.now(),
|
||||
},
|
||||
needsSave: true,
|
||||
};
|
||||
|
||||
// Additional cases for all actions...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Persistence Layer
|
||||
|
||||
Data is saved incrementally with different strategies:
|
||||
|
||||
```typescript
|
||||
class WorkoutPersistence {
|
||||
// Save entire workout
|
||||
async saveWorkout(workout: Workout): Promise<void> {
|
||||
return this.db.withTransactionAsync(async () => {
|
||||
// 1. Save workout metadata
|
||||
// 2. Save all exercises
|
||||
// 3. Save all sets
|
||||
// 4. Update related statistics
|
||||
});
|
||||
}
|
||||
|
||||
// Save only modified data
|
||||
async saveIncrementalChanges(workout: Workout): Promise<void> {
|
||||
const dirtyExercises = workout.exercises.filter(e => e.isDirty);
|
||||
|
||||
return this.db.withTransactionAsync(async () => {
|
||||
// Only update changed exercises and sets
|
||||
for (const exercise of dirtyExercises) {
|
||||
// Update exercise
|
||||
// Update dirty sets
|
||||
exercise.isDirty = false;
|
||||
for (const set of exercise.sets) {
|
||||
set.isDirty = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Save triggers:
|
||||
1. **Auto-save**: Every 30 seconds during active workout
|
||||
2. **Exercise change**: When navigating between exercises
|
||||
3. **Pause**: When workout is paused
|
||||
4. **Completion**: Final save with additional metadata
|
||||
5. **Manual save**: User-triggered save
|
||||
6. **App background**: When app moves to background
|
||||
|
||||
### 4. Workout Completion Processing
|
||||
|
||||
```typescript
|
||||
async function processWorkoutCompletion(workout: Workout): Promise<WorkoutSummary> {
|
||||
// 1. Mark workout as completed
|
||||
const completedWorkout = {
|
||||
...workout,
|
||||
isCompleted: true,
|
||||
endTime: Date.now(),
|
||||
};
|
||||
|
||||
// 2. Calculate final statistics
|
||||
const stats = calculateWorkoutStatistics(completedWorkout);
|
||||
|
||||
// 3. Detect personal records
|
||||
const personalRecords = detectPersonalRecords(completedWorkout);
|
||||
|
||||
// 4. Save everything to database
|
||||
await workoutPersistence.saveCompletedWorkout(
|
||||
completedWorkout,
|
||||
stats,
|
||||
personalRecords
|
||||
);
|
||||
|
||||
// 5. Return summary data
|
||||
return {
|
||||
workout: completedWorkout,
|
||||
statistics: stats,
|
||||
achievements: {
|
||||
personalRecords,
|
||||
streaks: detectStreaks(completedWorkout),
|
||||
milestones: detectMilestones(completedWorkout),
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Nostr Event Creation
|
||||
|
||||
```typescript
|
||||
function createNostrWorkoutEvent(workout: CompletedWorkout): NostrEvent {
|
||||
return {
|
||||
kind: 33403, // Workout Record
|
||||
content: workout.notes || '',
|
||||
tags: [
|
||||
['d', workout.id],
|
||||
['title', workout.title],
|
||||
['type', workout.type],
|
||||
['start', workout.startTime.toString()],
|
||||
['end', workout.endTime.toString()],
|
||||
['completed', workout.isCompleted.toString()],
|
||||
|
||||
// Exercise data
|
||||
...workout.exercises.flatMap(exercise => {
|
||||
const exerciseRef = `33401:${exercise.author || 'local'}:${exercise.sourceId}`;
|
||||
|
||||
return exercise.sets.map(set => [
|
||||
'exercise',
|
||||
exerciseRef,
|
||||
set.weight?.toString() || '',
|
||||
set.reps?.toString() || '',
|
||||
set.rpe?.toString() || '',
|
||||
set.type,
|
||||
]);
|
||||
}),
|
||||
|
||||
// PR tags if applicable
|
||||
...workout.personalRecords.map(pr => [
|
||||
'pr',
|
||||
`${pr.exerciseId},${pr.metric},${pr.value}`
|
||||
]),
|
||||
|
||||
// Categorization tags
|
||||
...workout.tags.map(tag => ['t', tag])
|
||||
],
|
||||
created_at: Math.floor(workout.endTime / 1000),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Data Structures
|
||||
|
||||
### Workout State
|
||||
|
||||
```typescript
|
||||
interface WorkoutState {
|
||||
status: 'idle' | 'active' | 'paused' | 'completed';
|
||||
activeWorkout: Workout | null;
|
||||
currentExerciseIndex: number;
|
||||
currentSetIndex: number;
|
||||
startTime: number | null;
|
||||
endTime: number | null;
|
||||
elapsedTime: number;
|
||||
restTimer: {
|
||||
isActive: boolean;
|
||||
duration: number;
|
||||
remaining: number;
|
||||
exerciseId?: string;
|
||||
setIndex?: number;
|
||||
};
|
||||
needsSave: boolean;
|
||||
lastSaved: number | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Active Workout
|
||||
|
||||
```typescript
|
||||
interface Workout {
|
||||
id: string;
|
||||
title: string;
|
||||
type: WorkoutType;
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
isCompleted: boolean;
|
||||
templateId?: string;
|
||||
exercises: WorkoutExercise[];
|
||||
notes?: string;
|
||||
tags: string[];
|
||||
lastUpdated: number;
|
||||
}
|
||||
|
||||
interface WorkoutExercise {
|
||||
id: string;
|
||||
sourceId: string; // Reference to exercise definition
|
||||
title: string;
|
||||
sets: WorkoutSet[];
|
||||
notes?: string;
|
||||
isDirty: boolean;
|
||||
isCompleted: boolean;
|
||||
order: number;
|
||||
restTime?: number;
|
||||
}
|
||||
|
||||
interface WorkoutSet {
|
||||
id: string;
|
||||
setNumber: number;
|
||||
type: SetType;
|
||||
weight?: number;
|
||||
reps?: number;
|
||||
rpe?: number;
|
||||
isCompleted: boolean;
|
||||
isDirty: boolean;
|
||||
timestamp?: number;
|
||||
notes?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Workout Summary
|
||||
|
||||
```typescript
|
||||
interface WorkoutSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
type: WorkoutType;
|
||||
duration: number; // In milliseconds
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
exerciseCount: number;
|
||||
completedExercises: number;
|
||||
totalVolume: number;
|
||||
totalReps: number;
|
||||
averageRpe?: number;
|
||||
exerciseSummaries: ExerciseSummary[];
|
||||
personalRecords: PersonalRecord[];
|
||||
}
|
||||
|
||||
interface ExerciseSummary {
|
||||
exerciseId: string;
|
||||
title: string;
|
||||
setCount: number;
|
||||
completedSets: number;
|
||||
volume: number;
|
||||
peakWeight?: number;
|
||||
totalReps: number;
|
||||
averageRpe?: number;
|
||||
}
|
||||
```
|
||||
|
||||
## SQLite Schema Integration
|
||||
|
||||
Building on the existing schema, these additional tables and relationships will be needed:
|
||||
|
||||
```sql
|
||||
-- Workout-specific schema extensions
|
||||
|
||||
-- Active workout tracking
|
||||
CREATE TABLE IF NOT EXISTS active_workouts (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
start_time INTEGER NOT NULL,
|
||||
last_updated INTEGER NOT NULL,
|
||||
template_id TEXT,
|
||||
metadata TEXT, -- JSON blob of additional data
|
||||
FOREIGN KEY(template_id) REFERENCES templates(id)
|
||||
);
|
||||
|
||||
-- Completed workouts
|
||||
CREATE TABLE IF NOT EXISTS completed_workouts (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
start_time INTEGER NOT NULL,
|
||||
end_time INTEGER NOT NULL,
|
||||
duration INTEGER NOT NULL, -- In milliseconds
|
||||
total_volume REAL,
|
||||
total_reps INTEGER,
|
||||
average_rpe REAL,
|
||||
notes TEXT,
|
||||
template_id TEXT,
|
||||
nostr_event_id TEXT,
|
||||
FOREIGN KEY(template_id) REFERENCES templates(id)
|
||||
);
|
||||
|
||||
-- Individual workout exercises
|
||||
CREATE TABLE IF NOT EXISTS workout_exercises (
|
||||
id TEXT PRIMARY KEY,
|
||||
workout_id TEXT NOT NULL,
|
||||
exercise_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
is_completed BOOLEAN DEFAULT 0,
|
||||
notes TEXT,
|
||||
rest_time INTEGER, -- In seconds
|
||||
FOREIGN KEY(workout_id) REFERENCES active_workouts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(exercise_id) REFERENCES exercises(id)
|
||||
);
|
||||
|
||||
-- Set data
|
||||
CREATE TABLE IF NOT EXISTS workout_sets (
|
||||
id TEXT PRIMARY KEY,
|
||||
workout_exercise_id TEXT NOT NULL,
|
||||
set_number INTEGER NOT NULL,
|
||||
weight REAL,
|
||||
reps INTEGER,
|
||||
rpe REAL,
|
||||
completed BOOLEAN DEFAULT 0,
|
||||
set_type TEXT NOT NULL,
|
||||
timestamp INTEGER,
|
||||
notes TEXT,
|
||||
FOREIGN KEY(workout_exercise_id) REFERENCES workout_exercises(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Personal records
|
||||
CREATE TABLE IF NOT EXISTS personal_records (
|
||||
id TEXT PRIMARY KEY,
|
||||
exercise_id TEXT NOT NULL,
|
||||
metric TEXT NOT NULL, -- 'weight', 'reps', 'volume', etc.
|
||||
value REAL NOT NULL,
|
||||
workout_id TEXT NOT NULL,
|
||||
achieved_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(workout_id) REFERENCES completed_workouts(id)
|
||||
);
|
||||
|
||||
-- Workout tags
|
||||
CREATE TABLE IF NOT EXISTS workout_tags (
|
||||
workout_id TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
FOREIGN KEY(workout_id) REFERENCES completed_workouts(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY(workout_id, tag)
|
||||
);
|
||||
|
||||
-- Workout statistics
|
||||
CREATE TABLE IF NOT EXISTS workout_statistics (
|
||||
workout_id TEXT PRIMARY KEY,
|
||||
stats_json TEXT NOT NULL, -- Flexible JSON storage for various metrics
|
||||
calculated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(workout_id) REFERENCES completed_workouts(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
## Optimization Strategies
|
||||
|
||||
### 1. Batch Processing
|
||||
|
||||
For performance-critical operations, batch updates are used:
|
||||
|
||||
```typescript
|
||||
// Instead of individual operations
|
||||
async function saveSetsIndividually(sets: WorkoutSet[]) {
|
||||
for (const set of sets) {
|
||||
await db.runAsync(
|
||||
'UPDATE workout_sets SET weight = ?, reps = ?, completed = ? WHERE id = ?',
|
||||
[set.weight, set.reps, set.isCompleted, set.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Use batch operations
|
||||
async function saveSetsInBatch(sets: WorkoutSet[]) {
|
||||
if (sets.length === 0) return;
|
||||
|
||||
const placeholders = sets.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(', ');
|
||||
const values = sets.flatMap(set => [
|
||||
set.id,
|
||||
set.workout_exercise_id,
|
||||
set.set_number,
|
||||
set.weight || null,
|
||||
set.reps || null,
|
||||
set.rpe || null,
|
||||
set.isCompleted ? 1 : 0
|
||||
]);
|
||||
|
||||
await db.runAsync(`
|
||||
INSERT OR REPLACE INTO workout_sets
|
||||
(id, workout_exercise_id, set_number, weight, reps, rpe, completed)
|
||||
VALUES ${placeholders}
|
||||
`, values);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Dirty Tracking
|
||||
|
||||
Optimize saves by only updating changed data:
|
||||
|
||||
```typescript
|
||||
function markDirty(entity: { isDirty?: boolean, lastUpdated?: number }) {
|
||||
entity.isDirty = true;
|
||||
entity.lastUpdated = Date.now();
|
||||
}
|
||||
|
||||
// In reducer
|
||||
case 'UPDATE_SET': {
|
||||
const { exerciseIndex, setIndex, data } = action.payload;
|
||||
const updatedExercises = [...state.activeWorkout.exercises];
|
||||
const updatedSets = [...updatedExercises[exerciseIndex].sets];
|
||||
|
||||
// Only mark as dirty if actually changed
|
||||
const currentSet = updatedSets[setIndex];
|
||||
const hasChanged = Object.entries(data).some(
|
||||
([key, value]) => currentSet[key] !== value
|
||||
);
|
||||
|
||||
if (hasChanged) {
|
||||
updatedSets[setIndex] = {
|
||||
...updatedSets[setIndex],
|
||||
...data,
|
||||
isDirty: true,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
|
||||
updatedExercises[exerciseIndex] = {
|
||||
...updatedExercises[exerciseIndex],
|
||||
sets: updatedSets,
|
||||
isDirty: true,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
activeWorkout: {
|
||||
...state.activeWorkout,
|
||||
exercises: updatedExercises,
|
||||
lastUpdated: hasChanged ? Date.now() : state.activeWorkout.lastUpdated,
|
||||
},
|
||||
needsSave: hasChanged,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Incremental Auto-save
|
||||
|
||||
```typescript
|
||||
function useAutoSave(
|
||||
workout: Workout | null,
|
||||
needsSave: boolean,
|
||||
saveWorkout: (workout: Workout) => Promise<void>
|
||||
) {
|
||||
const [lastSaveTime, setLastSaveTime] = useState<number | null>(null);
|
||||
const saveIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workout) return;
|
||||
|
||||
// Set up interval for periodic saves
|
||||
saveIntervalRef.current = setInterval(() => {
|
||||
if (workout && needsSave) {
|
||||
saveWorkout(workout)
|
||||
.then(() => setLastSaveTime(Date.now()))
|
||||
.catch(err => console.error('Auto-save failed:', err));
|
||||
}
|
||||
}, 30000); // 30 seconds
|
||||
|
||||
return () => {
|
||||
if (saveIntervalRef.current) {
|
||||
clearInterval(saveIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [workout, needsSave, saveWorkout]);
|
||||
|
||||
// Additional save on app state changes
|
||||
useAppState(
|
||||
(nextAppState) => {
|
||||
if (nextAppState === 'background' && workout && needsSave) {
|
||||
saveWorkout(workout)
|
||||
.then(() => setLastSaveTime(Date.now()))
|
||||
.catch(err => console.error('Background save failed:', err));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return lastSaveTime;
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling and Recovery
|
||||
|
||||
### 1. Save Failure Recovery
|
||||
|
||||
```typescript
|
||||
async function saveWithRetry(
|
||||
workout: Workout,
|
||||
maxRetries = 3
|
||||
): Promise<boolean> {
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < maxRetries) {
|
||||
try {
|
||||
await workoutPersistence.saveWorkout(workout);
|
||||
return true;
|
||||
} catch (error) {
|
||||
attempts++;
|
||||
console.error(`Save failed (attempt ${attempts}):`, error);
|
||||
|
||||
if (attempts >= maxRetries) {
|
||||
// Create emergency backup
|
||||
await createEmergencyBackup(workout);
|
||||
notifyUser('Workout save failed. Emergency backup created.');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exponential backoff
|
||||
await new Promise(resolve =>
|
||||
setTimeout(resolve, 1000 * Math.pow(2, attempts))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function createEmergencyBackup(workout: Workout): Promise<void> {
|
||||
try {
|
||||
const backupJson = JSON.stringify(workout);
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filename = `workout-backup-${timestamp}.json`;
|
||||
await FileSystem.writeAsStringAsync(
|
||||
`${FileSystem.documentDirectory}backups/${filename}`,
|
||||
backupJson
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Emergency backup failed:', e);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Crash Recovery
|
||||
|
||||
```typescript
|
||||
async function checkForUnfinishedWorkouts(): Promise<Workout | null> {
|
||||
try {
|
||||
const activeWorkouts = await db.getAllAsync<ActiveWorkoutRow>(
|
||||
'SELECT * FROM active_workouts WHERE end_time IS NULL'
|
||||
);
|
||||
|
||||
if (activeWorkouts.length === 0) return null;
|
||||
|
||||
// Find most recent active workout
|
||||
const mostRecent = activeWorkouts.reduce((latest, current) =>
|
||||
current.last_updated > latest.last_updated ? current : latest
|
||||
);
|
||||
|
||||
// Reconstruct full workout object
|
||||
return reconstructWorkoutFromDatabase(mostRecent.id);
|
||||
} catch (error) {
|
||||
console.error('Error checking for unfinished workouts:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function useWorkoutRecovery() {
|
||||
const [recoveryWorkout, setRecoveryWorkout] = useState<Workout | null>(null);
|
||||
const [showRecoveryDialog, setShowRecoveryDialog] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkRecovery = async () => {
|
||||
const unfinishedWorkout = await checkForUnfinishedWorkouts();
|
||||
if (unfinishedWorkout) {
|
||||
setRecoveryWorkout(unfinishedWorkout);
|
||||
setShowRecoveryDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
checkRecovery();
|
||||
}, []);
|
||||
|
||||
const handleRecovery = (shouldRecover: boolean) => {
|
||||
if (shouldRecover && recoveryWorkout) {
|
||||
// Resume workout
|
||||
dispatch({
|
||||
type: 'RECOVER_WORKOUT',
|
||||
payload: recoveryWorkout
|
||||
});
|
||||
} else if (recoveryWorkout) {
|
||||
// Discard unfinished workout
|
||||
workoutPersistence.discardWorkout(recoveryWorkout.id);
|
||||
}
|
||||
|
||||
setShowRecoveryDialog(false);
|
||||
};
|
||||
|
||||
return {
|
||||
showRecoveryDialog,
|
||||
recoveryWorkout,
|
||||
handleRecovery
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Nostr Integration
|
||||
|
||||
### 1. Event Publishing
|
||||
|
||||
```typescript
|
||||
async function publishWorkoutToNostr(workout: CompletedWorkout): Promise<string> {
|
||||
try {
|
||||
// Convert to Nostr event format
|
||||
const event = createNostrWorkoutEvent(workout);
|
||||
|
||||
// Sign event
|
||||
const signedEvent = await ndk.signer.sign(event);
|
||||
|
||||
// Publish to relays
|
||||
await ndk.publish(signedEvent);
|
||||
|
||||
// Update local record with event ID
|
||||
await db.runAsync(
|
||||
'UPDATE completed_workouts SET nostr_event_id = ? WHERE id = ?',
|
||||
[signedEvent.id, workout.id]
|
||||
);
|
||||
|
||||
return signedEvent.id;
|
||||
} catch (error) {
|
||||
console.error('Failed to publish workout to Nostr:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Subscription Integration
|
||||
|
||||
```typescript
|
||||
function subscribeToWorkoutEvents() {
|
||||
// Subscribe to workout events from followed users
|
||||
const filter = {
|
||||
kinds: [33401, 33402, 33403],
|
||||
authors: followedPubkeys,
|
||||
since: lastSyncTimestamp
|
||||
};
|
||||
|
||||
const subscription = ndk.subscribe(filter);
|
||||
|
||||
subscription.on('event', (event) => {
|
||||
try {
|
||||
processIncomingNostrEvent(event);
|
||||
} catch (error) {
|
||||
console.error('Error processing incoming event:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async function processIncomingNostrEvent(event: NostrEvent) {
|
||||
switch (event.kind) {
|
||||
case 33401: // Exercise definition
|
||||
await processExerciseDefinition(event);
|
||||
break;
|
||||
|
||||
case 33402: // Workout template
|
||||
await processWorkoutTemplate(event);
|
||||
break;
|
||||
|
||||
case 33403: // Workout record
|
||||
await processWorkoutRecord(event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Metrics and Analytics
|
||||
|
||||
```typescript
|
||||
interface WorkoutMetrics {
|
||||
// Time metrics
|
||||
totalDuration: number;
|
||||
exerciseTime: number;
|
||||
restTime: number;
|
||||
averageSetDuration: number;
|
||||
|
||||
// Volume metrics
|
||||
totalVolume: number;
|
||||
volumeByExercise: Record<string, number>;
|
||||
volumeByMuscleGroup: Record<string, number>;
|
||||
|
||||
// Intensity metrics
|
||||
averageRpe: number;
|
||||
peakRpe: number;
|
||||
intensityDistribution: {
|
||||
low: number; // Sets with RPE 1-4
|
||||
medium: number; // Sets with RPE 5-7
|
||||
high: number; // Sets with RPE 8-10
|
||||
};
|
||||
|
||||
// Completion metrics
|
||||
exerciseCompletionRate: number;
|
||||
setCompletionRate: number;
|
||||
plannedVsActualVolume: number;
|
||||
}
|
||||
|
||||
function calculateWorkoutMetrics(workout: CompletedWorkout): WorkoutMetrics {
|
||||
// Implementation of metric calculations
|
||||
// ...
|
||||
|
||||
return metrics;
|
||||
}
|
||||
```
|
||||
|
||||
## Data Flow Timeline
|
||||
|
||||
1. **Workout Initialization** (t=0)
|
||||
- Template loaded or empty workout created
|
||||
- Initial state populated
|
||||
- Workout ID generated
|
||||
- Database record created
|
||||
|
||||
2. **Active Tracking** (t=0 → completion)
|
||||
- User inputs captured through reducers
|
||||
- State updates trigger UI refreshes
|
||||
- Dirty tracking flags changes
|
||||
- Auto-save runs periodically
|
||||
|
||||
3. **Exercise Transitions**
|
||||
- Current exercise state saved
|
||||
- Next exercise loaded
|
||||
- Progress indicators updated
|
||||
|
||||
4. **Completion Processing**
|
||||
- Final state saving
|
||||
- Statistics calculation
|
||||
- PR detection
|
||||
- Achievement unlocking
|
||||
|
||||
5. **Post-Workout**
|
||||
- History update
|
||||
- Nostr publishing (if enabled)
|
||||
- Cleanup of temporary data
|
||||
|
||||
## Integration with Existing Architecture
|
||||
|
||||
The workout data flow integrates with existing systems:
|
||||
|
||||
1. **Library System** - Templates loaded from library
|
||||
2. **User Profiles** - PRs and achievements tied to user
|
||||
3. **Social Features** - Workout sharing via Nostr
|
||||
4. **History Tab** - Completed workouts appear in history
|
||||
5. **Exercise Database** - Exercise references maintained
|
||||
|
||||
## Future Extensibility
|
||||
|
||||
This design supports future enhancements:
|
||||
|
||||
1. **AI Recommendations** - Data structured for ML analysis
|
||||
2. **External Device Integration** - Schema allows for sensor data
|
||||
3. **Advanced Periodization** - Tracking supports long-term planning
|
||||
4. **Video Analysis** - Form tracking integration points
|
||||
5. **Multi-user Workouts** - Shared workout capabilities
|
@ -1,376 +0,0 @@
|
||||
# POWR Workout Tab Design Document
|
||||
|
||||
## Problem Statement
|
||||
Users need a dedicated interface for tracking workout sessions in real-time, including starting new workouts from templates or creating custom workouts on the fly. The workout experience must support various workout types (strength, circuit, EMOM, AMRAP), maintain offline functionality, and prepare for future Nostr integration.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional Requirements
|
||||
- Start new workouts from templates or create custom workouts
|
||||
- Track sets, reps, weight, and other exercise metrics in real-time
|
||||
- Support rest timers between sets and exercises
|
||||
- Allow recording of RPE (Rate of Perceived Exertion)
|
||||
- Enable workout notes and feedback
|
||||
- Save completed workout history
|
||||
- Track PRs (Personal Records)
|
||||
- Support various workout structures (traditional, circuit, EMOM, AMRAP)
|
||||
- Provide workout summary statistics
|
||||
|
||||
### Non-Functional Requirements
|
||||
- Performant timer implementation (accurate to within 100ms)
|
||||
- Smooth UI experience during workout tracking
|
||||
- Reliable offline functionality
|
||||
- Data persistence during app crashes
|
||||
- Battery-efficient implementation
|
||||
- Support for future Nostr event publishing (kinds 33401-33403)
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### 1. Workout State Management
|
||||
**Approach:** Context-based state management with reducers
|
||||
|
||||
**Rationale:**
|
||||
- Workouts require complex state that needs to be accessed by multiple components
|
||||
- Reducer pattern provides predictable state transitions
|
||||
- Context allows state sharing without prop drilling
|
||||
- Enables isolation of workout logic from UI components
|
||||
|
||||
### 2. Timer Implementation
|
||||
**Approach:** Custom hook-based timer with requestAnimationFrame
|
||||
|
||||
**Rationale:**
|
||||
- More accurate than setInterval for visual timing
|
||||
- Better battery performance than interval-based approaches
|
||||
- Handles background/foreground transitions gracefully
|
||||
- Can be paused/resumed without drift
|
||||
|
||||
### 3. Offline Data Persistence
|
||||
**Approach:** Incremental SQLite saves with optimistic UI updates
|
||||
|
||||
**Rationale:**
|
||||
- Balances performance with data safety
|
||||
- Prevents data loss during crashes
|
||||
- Maintains responsive UI during saves
|
||||
- Supports future sync capabilities
|
||||
|
||||
### 4. Template-to-Workout Transformation
|
||||
**Approach:** Deep copy with runtime customization
|
||||
|
||||
**Rationale:**
|
||||
- Preserves template integrity
|
||||
- Allows workout-specific modifications
|
||||
- Maintains type safety
|
||||
- Supports progression tracking
|
||||
|
||||
## Technical Design
|
||||
|
||||
### Core Components
|
||||
|
||||
#### WorkoutProvider (Context)
|
||||
```typescript
|
||||
interface WorkoutContextState {
|
||||
status: 'idle' | 'active' | 'paused' | 'completed';
|
||||
activeWorkout: Workout | null;
|
||||
currentExerciseIndex: number;
|
||||
currentSetIndex: number;
|
||||
elapsedTime: number;
|
||||
restTimers: {
|
||||
isActive: boolean;
|
||||
duration: number;
|
||||
remaining: number;
|
||||
};
|
||||
}
|
||||
|
||||
type WorkoutAction =
|
||||
| { type: 'START_WORKOUT', payload: Workout }
|
||||
| { type: 'PAUSE_WORKOUT' }
|
||||
| { type: 'RESUME_WORKOUT' }
|
||||
| { type: 'COMPLETE_WORKOUT' }
|
||||
| { type: 'UPDATE_SET', payload: { exerciseIndex: number, setIndex: number, data: Partial<WorkoutSet> } }
|
||||
| { type: 'NEXT_EXERCISE' }
|
||||
| { type: 'PREVIOUS_EXERCISE' }
|
||||
| { type: 'START_REST_TIMER', payload: number }
|
||||
| { type: 'TICK_TIMER', payload: number };
|
||||
|
||||
function workoutReducer(state: WorkoutContextState, action: WorkoutAction): WorkoutContextState {
|
||||
// State transitions and logic
|
||||
}
|
||||
```
|
||||
|
||||
#### Workout Screen Structure
|
||||
```typescript
|
||||
// Main layout components
|
||||
function WorkoutScreen() {
|
||||
// Handles routing between idle/active states
|
||||
}
|
||||
|
||||
function ActiveWorkoutScreen() {
|
||||
// Active workout tracking UI
|
||||
}
|
||||
|
||||
function WorkoutSetupScreen() {
|
||||
// Template selection or custom workout creation
|
||||
}
|
||||
|
||||
function WorkoutSummaryScreen() {
|
||||
// Post-workout summary and stats
|
||||
}
|
||||
```
|
||||
|
||||
#### Timer Hook
|
||||
```typescript
|
||||
function useWorkoutTimer({
|
||||
isActive,
|
||||
onTick,
|
||||
}: {
|
||||
isActive: boolean;
|
||||
onTick: (elapsedMs: number) => void;
|
||||
}) {
|
||||
// Timer implementation using requestAnimationFrame
|
||||
// Handles background/foreground transitions
|
||||
}
|
||||
```
|
||||
|
||||
### Database Schema Extensions
|
||||
|
||||
Building on the existing schema, we'll add:
|
||||
|
||||
```sql
|
||||
-- Workout tracking
|
||||
CREATE TABLE IF NOT EXISTS workouts (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
start_time INTEGER NOT NULL,
|
||||
end_time INTEGER,
|
||||
completed BOOLEAN DEFAULT 0,
|
||||
notes TEXT,
|
||||
total_volume REAL,
|
||||
template_id TEXT,
|
||||
nostr_event_id TEXT,
|
||||
FOREIGN KEY(template_id) REFERENCES templates(id)
|
||||
);
|
||||
|
||||
-- Individual workout exercises
|
||||
CREATE TABLE IF NOT EXISTS workout_exercises (
|
||||
id TEXT PRIMARY KEY,
|
||||
workout_id TEXT NOT NULL,
|
||||
exercise_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
notes TEXT,
|
||||
FOREIGN KEY(workout_id) REFERENCES workouts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(exercise_id) REFERENCES exercises(id)
|
||||
);
|
||||
|
||||
-- Set data
|
||||
CREATE TABLE IF NOT EXISTS workout_sets (
|
||||
id TEXT PRIMARY KEY,
|
||||
workout_exercise_id TEXT NOT NULL,
|
||||
set_number INTEGER NOT NULL,
|
||||
weight REAL,
|
||||
reps INTEGER,
|
||||
rpe REAL,
|
||||
completed BOOLEAN DEFAULT 0,
|
||||
set_type TEXT NOT NULL,
|
||||
timestamp INTEGER,
|
||||
FOREIGN KEY(workout_exercise_id) REFERENCES workout_exercises(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Personal records
|
||||
CREATE TABLE IF NOT EXISTS personal_records (
|
||||
id TEXT PRIMARY KEY,
|
||||
exercise_id TEXT NOT NULL,
|
||||
metric TEXT NOT NULL,
|
||||
value REAL NOT NULL,
|
||||
workout_id TEXT NOT NULL,
|
||||
achieved_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(exercise_id) REFERENCES exercises(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(workout_id) REFERENCES workouts(id)
|
||||
);
|
||||
```
|
||||
|
||||
### TypeScript Definitions
|
||||
|
||||
```typescript
|
||||
// Workout Types
|
||||
export interface Workout {
|
||||
id: string;
|
||||
title: string;
|
||||
type: WorkoutType;
|
||||
exercises: WorkoutExercise[];
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
isCompleted: boolean;
|
||||
notes?: string;
|
||||
templateId?: string;
|
||||
totalVolume?: number;
|
||||
}
|
||||
|
||||
export type WorkoutType = 'strength' | 'circuit' | 'emom' | 'amrap';
|
||||
|
||||
// PR Tracking
|
||||
export interface PersonalRecord {
|
||||
id: string;
|
||||
exerciseId: string;
|
||||
metric: 'weight' | 'reps' | 'volume';
|
||||
value: number;
|
||||
workoutId: string;
|
||||
achievedAt: number;
|
||||
}
|
||||
```
|
||||
|
||||
## User Interface Design
|
||||
|
||||
### Workout Flow
|
||||
|
||||
#### 1. Idle State / Setup
|
||||
- Option to start from template
|
||||
- Option to create custom workout
|
||||
- Quick-start recent workouts
|
||||
- Template browsing/filtering
|
||||
|
||||
#### 2. Active Workout Screens
|
||||
- **Header:** Workout title, timer, complete button
|
||||
- **Exercise Navigation:** Current exercise, navigation controls
|
||||
- **Set Tracking:** Weight/reps input, rest timer, RPE selection
|
||||
- **Notes:** Exercise-specific notes field
|
||||
- **Progress:** Visual indicators of completion status
|
||||
|
||||
#### 3. Summary Screen
|
||||
- Workout duration
|
||||
- Total volume
|
||||
- PR achievements
|
||||
- Exercise completion rates
|
||||
- Option to add notes
|
||||
- Share capabilities (future Nostr publishing)
|
||||
|
||||
### UI Components
|
||||
|
||||
#### WorkoutHeader
|
||||
Displays current status, elapsed time, and workout controls
|
||||
|
||||
#### ExerciseTracker
|
||||
Primary interface for tracking sets of current exercise
|
||||
|
||||
#### RestTimer
|
||||
Visual countdown with sound/vibration alerts
|
||||
|
||||
#### WorkoutSummary
|
||||
Post-workout statistics and achievements
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Core Workout Flow (2 weeks)
|
||||
1. Implement WorkoutContext and reducer
|
||||
2. Build workout setup screen
|
||||
3. Create basic exercise tracking UI
|
||||
4. Implement timer functionality
|
||||
5. Add SQLite persistence for workouts
|
||||
|
||||
### Phase 2: Advanced Features (2 weeks)
|
||||
1. Implement rest timers with alerts
|
||||
2. Add PR tracking and detection
|
||||
3. Create workout summary screen
|
||||
4. Support for different workout types
|
||||
5. Add notes and feedback options
|
||||
|
||||
### Phase 3: Polish & Integration (1 week)
|
||||
1. UI refinements and animations
|
||||
2. Performance optimization
|
||||
3. Integration with Nostr publishing
|
||||
4. Add sharing capabilities
|
||||
5. Final testing and bug fixes
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Timer accuracy and pause/resume functionality
|
||||
- State transitions in workout reducer
|
||||
- Template transformation logic
|
||||
- PR detection algorithms
|
||||
- Exercise progression calculations
|
||||
|
||||
### Integration Tests
|
||||
- Workout flow from setup to completion
|
||||
- Data persistence during app lifecycle events
|
||||
- Template-to-workout conversion
|
||||
- History recording accuracy
|
||||
|
||||
### Performance Tests
|
||||
- Timer precision under load
|
||||
- UI responsiveness during data saving
|
||||
- Battery usage monitoring
|
||||
- Memory profiling during long workouts
|
||||
|
||||
## Observability
|
||||
|
||||
### Logging
|
||||
- Workout state transitions
|
||||
- Timer accuracy metrics
|
||||
- Data save operations
|
||||
- Error conditions
|
||||
|
||||
### Analytics (Future)
|
||||
- Workout completion rates
|
||||
- Average workout duration
|
||||
- Most used templates
|
||||
- Feature usage statistics
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Potential Enhancements
|
||||
- Voice feedback during workouts
|
||||
- Video form checking integration
|
||||
- Social sharing via Nostr
|
||||
- Workout streaks and achievements
|
||||
- AI-powered workout recommendations
|
||||
- Heart rate monitor integration
|
||||
- Barcode scanner for gym equipment
|
||||
|
||||
### Known Limitations
|
||||
- Timer may drift slightly in background
|
||||
- Workout types limited to predefined structures
|
||||
- No direct hardware integrations in MVP
|
||||
- Offline-only in initial implementation
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Runtime Dependencies
|
||||
- SQLite for data persistence
|
||||
- Timer implementation libraries
|
||||
- Chart visualization for summary
|
||||
- Haptic feedback for timers
|
||||
|
||||
### Development Dependencies
|
||||
- Testing framework for timer accuracy
|
||||
- Mock data generators
|
||||
- Performance monitoring tools
|
||||
|
||||
## Security Considerations
|
||||
- Personal fitness data privacy
|
||||
- Optional anonymization for shared workouts
|
||||
- Secure storage of personal records
|
||||
- Permission handling for notifications
|
||||
|
||||
## Rollout Strategy
|
||||
|
||||
### Development Phase
|
||||
1. Implement core workout tracking
|
||||
2. Add template integration
|
||||
3. Build timer functionality
|
||||
4. Create persistence layer
|
||||
5. Add summary statistics
|
||||
|
||||
### Production Deployment
|
||||
1. Internal testing with sample workouts
|
||||
2. Beta testing with power users
|
||||
3. Phased rollout to all users
|
||||
4. Monitoring for performance issues
|
||||
5. Iterative improvements based on feedback
|
||||
|
||||
## References
|
||||
- Nostr NIP-33401: Exercise Templates
|
||||
- Nostr NIP-33402: Workout Templates
|
||||
- Nostr NIP-33403: Workout Records
|
||||
- React Native Animation Performance Guide
|
||||
- SQLite Transaction Best Practices
|
@ -1,348 +0,0 @@
|
||||
# Workout UI Component Specification
|
||||
|
||||
## Overview
|
||||
This document outlines the key UI components needed for the POWR workout tracking experience. The interface prioritizes readability during exercise, quick data entry, and clear visualization of progress.
|
||||
|
||||
## Core UI Components
|
||||
|
||||
### 1. Workout Header
|
||||

|
||||
|
||||
```tsx
|
||||
interface WorkoutHeaderProps {
|
||||
title: string;
|
||||
type: WorkoutType;
|
||||
elapsedTime: number;
|
||||
isActive: boolean;
|
||||
onPause: () => void;
|
||||
onResume: () => void;
|
||||
onComplete: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
#### Features
|
||||
- Sticky header with minimized height
|
||||
- Elapsed time with large, readable timer
|
||||
- Workout title and type indicator
|
||||
- Status indicators (active/paused)
|
||||
- Action buttons (pause/resume/complete)
|
||||
- Optional: calorie/heart rate display
|
||||
|
||||
#### Behavior
|
||||
- Time updates every second
|
||||
- Color changes based on active/paused state
|
||||
- Confirm dialog appears before completing workout
|
||||
|
||||
---
|
||||
|
||||
### 2. Exercise Navigation
|
||||

|
||||
|
||||
```tsx
|
||||
interface ExerciseNavigationProps {
|
||||
exercises: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
isCompleted: boolean;
|
||||
}>;
|
||||
currentIndex: number;
|
||||
onSelectExercise: (index: number) => void;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
#### Features
|
||||
- Horizontal scroll for exercise list
|
||||
- Current exercise highlighted
|
||||
- Progress indicators showing completion status
|
||||
- Previous/next navigation controls
|
||||
- Jump-to capability for non-linear workouts
|
||||
|
||||
#### Behavior
|
||||
- Swipe gestures to change exercises
|
||||
- Auto-scrolls to keep current exercise visible
|
||||
- Vibration feedback on exercise change
|
||||
- Optional confirmation when leaving incomplete exercise
|
||||
|
||||
---
|
||||
|
||||
### 3. Set Tracker
|
||||

|
||||
|
||||
```tsx
|
||||
interface SetTrackerProps {
|
||||
sets: WorkoutSet[];
|
||||
exercise: WorkoutExercise;
|
||||
onUpdateSet: (setIndex: number, data: Partial<WorkoutSet>) => void;
|
||||
onAddSet: () => void;
|
||||
onRemoveSet: (setIndex: number) => void;
|
||||
showRestTimer: boolean;
|
||||
onStartRest: (duration: number) => void;
|
||||
}
|
||||
```
|
||||
|
||||
#### Features
|
||||
- Individual set cards with weight/reps/RPE inputs
|
||||
- Completion toggle for each set
|
||||
- Previous set data for reference
|
||||
- Support for different input types based on exercise format
|
||||
- "Add Set" button for additional sets
|
||||
- Rest timer trigger
|
||||
|
||||
#### Behavior
|
||||
- Auto-focuses appropriate input field
|
||||
- Supports quick incrementing/decrementing of values
|
||||
- Auto-suggests rest time based on set intensity
|
||||
- Remembers input patterns within workout
|
||||
- Validates inputs against exercise constraints
|
||||
|
||||
---
|
||||
|
||||
### 4. Rest Timer
|
||||

|
||||
|
||||
```tsx
|
||||
interface RestTimerProps {
|
||||
duration: number;
|
||||
remaining: number;
|
||||
isActive: boolean;
|
||||
onPause: () => void;
|
||||
onResume: () => void;
|
||||
onSkip: () => void;
|
||||
onExtend: (seconds: number) => void;
|
||||
}
|
||||
```
|
||||
|
||||
#### Features
|
||||
- Large circular countdown display
|
||||
- Visual progress indicator
|
||||
- Time remaining in large font
|
||||
- Control buttons (pause/resume/skip)
|
||||
- Quick-extend buttons (+30s, +1m)
|
||||
- Next exercise preview
|
||||
|
||||
#### Behavior
|
||||
- Full-screen takeover when active
|
||||
- Haptic feedback at 50% and 10% remaining
|
||||
- Sound alert at completion (if enabled)
|
||||
- Auto-dismisses after completion
|
||||
- Background timer continues running
|
||||
- Screen prevents sleep during active timer
|
||||
|
||||
---
|
||||
|
||||
### 5. Exercise Details Panel
|
||||

|
||||
|
||||
```tsx
|
||||
interface ExerciseDetailsPanelProps {
|
||||
exercise: WorkoutExercise;
|
||||
previousPerformance?: {
|
||||
date: number;
|
||||
sets: WorkoutSet[];
|
||||
personalBests: Record<string, number>;
|
||||
};
|
||||
onAddNote: (note: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
#### Features
|
||||
- Collapsible panel for exercise details
|
||||
- Form instructions and tips
|
||||
- Previous performance metrics
|
||||
- Personal best indicators
|
||||
- Notes field for technique reminders
|
||||
- Optional media previews (images/video)
|
||||
|
||||
#### Behavior
|
||||
- Expandable/collapsible with smooth animation
|
||||
- Auto-collapses during timer to maximize screen space
|
||||
- Persists notes between workout sessions
|
||||
- Highlights personal records
|
||||
|
||||
---
|
||||
|
||||
### 6. Workout Controls
|
||||

|
||||
|
||||
```tsx
|
||||
interface WorkoutControlsProps {
|
||||
canComplete: boolean;
|
||||
onPause: () => void;
|
||||
onResume: () => void;
|
||||
onCancel: () => void;
|
||||
onComplete: () => void;
|
||||
isActive: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
#### Features
|
||||
- Fixed position at screen bottom
|
||||
- Primary action button (Complete Workout)
|
||||
- Secondary actions (pause/resume)
|
||||
- Cancel workout option
|
||||
- Status indicators
|
||||
|
||||
#### Behavior
|
||||
- Complete button enables when minimum criteria met
|
||||
- Confirmation dialog for cancel action
|
||||
- Smooth transition animations between states
|
||||
- Haptic feedback on major actions
|
||||
|
||||
---
|
||||
|
||||
### 7. Workout Summary
|
||||

|
||||
|
||||
```tsx
|
||||
interface WorkoutSummaryProps {
|
||||
workout: CompletedWorkout;
|
||||
achievements: {
|
||||
personalRecords: PersonalRecord[];
|
||||
streaks: Streak[];
|
||||
milestones: Milestone[];
|
||||
};
|
||||
onSave: (notes: string) => void;
|
||||
onShare: () => void;
|
||||
onDiscard: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
#### Features
|
||||
- Comprehensive workout statistics
|
||||
- Total volume, duration, and intensity metrics
|
||||
- Exercise completion breakdown
|
||||
- Personal records achieved
|
||||
- Notes field for workout reflection
|
||||
- Visual charts of performance
|
||||
- Share and save controls
|
||||
|
||||
#### Behavior
|
||||
- Scrollable container for all summary data
|
||||
- Expandable sections for detailed stats
|
||||
- Animated entry of achievement cards
|
||||
- Pre-populates notes from during-workout entries
|
||||
- Save confirmation with preview
|
||||
|
||||
## Layout Variations
|
||||
|
||||
### 1. Strength Workout Layout
|
||||
Optimized for tracking weight, reps and rest periods.
|
||||
|
||||
- Prominent weight/rep inputs
|
||||
- Set-rest-set pattern flow
|
||||
- Previous lift stats readily visible
|
||||
- PR tracking indicators
|
||||
- Weight plate calculator
|
||||
|
||||
### 2. Circuit Workout Layout
|
||||
Designed for quick transitions between exercises.
|
||||
|
||||
- Minimized input fields
|
||||
- Prominent exercise timer
|
||||
- Next exercise preview
|
||||
- Round counter
|
||||
- Overall circuit progress
|
||||
|
||||
### 3. EMOM/AMRAP Layout
|
||||
Focused on timed intervals and rep counting.
|
||||
|
||||
- Large interval timer
|
||||
- Quick rep counter
|
||||
- Round progression
|
||||
- Work/rest indicators
|
||||
- Audio cues for intervals
|
||||
|
||||
## Interaction Patterns
|
||||
|
||||
### 1. Data Entry
|
||||
- Single-tap to select input field
|
||||
- Long-press for quick increment/decrement
|
||||
- Swipe to mark set complete
|
||||
- Shake to undo last action
|
||||
- Double-tap to copy previous set values
|
||||
|
||||
### 2. Navigation
|
||||
- Swipe between exercises
|
||||
- Pull down to reveal workout overview
|
||||
- Pull up for exercise details
|
||||
- Pinch to zoom workout timeline
|
||||
- Double-tap header to toggle timer visibility
|
||||
|
||||
### 3. Timers
|
||||
- Tap timer to pause/resume
|
||||
- Swipe up on timer for fullscreen mode
|
||||
- Rotate device for alternative timer view
|
||||
- Shake to skip timer
|
||||
- Volume buttons as quick controls
|
||||
|
||||
## Accessibility Considerations
|
||||
|
||||
### 1. Visual
|
||||
- High contrast mode for gym environments
|
||||
- Large text option for all metrics
|
||||
- Color-blind friendly progress indicators
|
||||
- Screen rotation lock option
|
||||
- Auto-brightness adjustment
|
||||
|
||||
### 2. Motor
|
||||
- Large touch targets for sweaty hands
|
||||
- Voice control for hands-free operation
|
||||
- Simplified layout option with fewer controls
|
||||
- Adjustable button sensitivity
|
||||
- Support for external Bluetooth controls
|
||||
|
||||
### 3. Auditory
|
||||
- Vibration patterns as alternative to sound
|
||||
- Visual countdown alternatives
|
||||
- Adjustable volume levels
|
||||
- Custom sound selection
|
||||
- Background noise filtering for voice features
|
||||
|
||||
## State Transitions
|
||||
|
||||
### 1. Idle → Active
|
||||
- Template selection or quick start
|
||||
- Exercise preview animation
|
||||
- Timer initialization
|
||||
- Welcome guidance (configurable)
|
||||
|
||||
### 2. Active → Paused
|
||||
- Dim UI elements
|
||||
- Prominent resume button
|
||||
- Elapsed time continues but visually distinguished
|
||||
- Quick access to notes and adjustments
|
||||
|
||||
### 3. Active → Complete
|
||||
- Celebration animation
|
||||
- Stats calculation overlay
|
||||
- Achievement unlocks
|
||||
- Social share prompts (optional)
|
||||
- Return to home or next workout suggestion
|
||||
|
||||
## Theme Integration
|
||||
|
||||
All components should support both light and dark themes with special considerations:
|
||||
|
||||
1. **Dark gym mode** - Ultra-dark background with high contrast elements for poorly lit environments
|
||||
2. **Outdoor mode** - High contrast, glare-resistant design for outdoor workouts
|
||||
3. **Night mode** - Red-shifted colors to minimize blue light during evening workouts
|
||||
4. **Energy saver** - Minimalist UI with reduced animations to maximize battery life
|
||||
|
||||
## Component Integration
|
||||
|
||||
These components will integrate with the existing POWR architecture:
|
||||
|
||||
1. **Component Library** - Extends existing UI components with workout-specific variants
|
||||
2. **Theme System** - Utilizes current theme tokens with workout-specific additions
|
||||
3. **Navigation** - Embeds within the tab navigation as a modal flow when active
|
||||
4. **Context** - Consumes the WorkoutContext for state management
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Create low-fidelity wireframes for each component
|
||||
2. Develop component prototypes using existing design system
|
||||
3. Test input patterns with sample workout data
|
||||
4. Validate timer accuracy across devices
|
||||
5. Create component documentation in Storybook
|
@ -1,303 +0,0 @@
|
||||
# NIP-4e: Workout Events
|
||||
|
||||
`draft` `optional`
|
||||
|
||||
This specification defines workout events for fitness tracking. These workout events support both planning (templates) and recording (completed activities).
|
||||
|
||||
## Event Kinds
|
||||
|
||||
### Event Kind Selection Rationale
|
||||
|
||||
The event kinds in this NIP follow Nostr protocol conventions:
|
||||
|
||||
- **Exercise and Workout Templates** (33401, 33402) use parameterized replaceable event kinds (30000+) because:
|
||||
- They represent content that may be updated or improved over time
|
||||
- The author may want to replace previous versions with improved ones
|
||||
- They need the `d` parameter to distinguish between different templates by the same author
|
||||
- Multiple versions shouldn't accumulate in clients' storage
|
||||
|
||||
- **Workout Records** (1301) use a standard event kind (0-9999) because:
|
||||
- They represent a chronological feed of activity that shouldn't replace previous records
|
||||
- Each workout is a unique occurrence that adds to a user's history
|
||||
- Users publish multiple records over time, creating a timeline
|
||||
- They're conceptually similar to notes (kind 1) but with structured fitness data
|
||||
|
||||
### 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.
|
||||
|
||||
#### Format
|
||||
|
||||
The format uses an _addressable event_ of `kind:33401`.
|
||||
|
||||
The `.content` of these events SHOULD be detailed instructions for proper exercise form. It is required but can be an empty string.
|
||||
|
||||
The list of tags are as follows:
|
||||
|
||||
* `d` (required) - universally unique identifier (UUID). Generated by the client creating the exercise template.
|
||||
* `title` (required) - Exercise name
|
||||
* `format` (required) - Defines data structure for exercise tracking (possible parameters: `weight`, `reps`, `rpe`, `set_type`)
|
||||
* `format_units` (required) - Defines units for each parameter (possible formats: "kg", "count", "0-10", "warmup|normal|drop|failure")
|
||||
* `equipment` (required) - Equipment type (possible values: `barbell`, `dumbbell`, `bodyweight`, `machine`, `cardio`)
|
||||
* `difficulty` (optional) - Skill level (possible values: `beginner`, `intermediate`, `advanced`)
|
||||
* `imeta` (optional) - Media metadata for form demonstrations following NIP-92 format
|
||||
* `t` (optional, repeated) - Hashtags for categorization such as muscle group or body movement (possible values: `chest`, `legs`, `push`, `pull`)
|
||||
|
||||
```
|
||||
{
|
||||
"id": <32-bytes lowercase hex-encoded SHA-256 of the serialized event data>,
|
||||
"pubkey": <32-bytes lowercase hex-encoded public key of the event creator>,
|
||||
"created_at": <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)
|
||||
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.
|
||||
|
||||
#### Format
|
||||
|
||||
The format uses an _addressable event_ of `kind:33402`.
|
||||
|
||||
The `.content` of these events SHOULD contain workout notes and instructions. It is required but can be an empty string.
|
||||
|
||||
The list of tags are as follows:
|
||||
|
||||
* `d` (required) - universally unique identifier (UUID). Generated by the client creating the workout template.
|
||||
* `title` (required) - Workout name
|
||||
* `type` (required) - Type of workout (possible values: `strength`, `circuit`, `emom`, `amrap`)
|
||||
* `exercise` (required, repeated) - Exercise reference and prescription. Format: ["exercise", "<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)
|
||||
Records a completed workout session. The `content` field contains notes about the workout.
|
||||
|
||||
#### Format
|
||||
|
||||
The format uses a standard event of `kind:1301`.
|
||||
|
||||
The `.content` of these events SHOULD contain notes about the workout experience. It is required but can be an empty string.
|
||||
|
||||
The list of tags are as follows:
|
||||
|
||||
* `d` (required) - universally unique identifier (UUID). Generated by the client creating the workout record.
|
||||
* `title` (required) - Workout name
|
||||
* `type` (required) - Type of workout (possible values: `strength`, `circuit`, `emom`, `amrap`)
|
||||
* `exercise` (required, repeated) - Exercise reference and completion data. Format: ["exercise", "<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
|
||||
|
||||
### Standard Parameters and Units
|
||||
* `weight` - Load in kilograms (kg). Empty string for bodyweight exercises, negative values for assisted exercises
|
||||
* `reps` - Number of repetitions (count)
|
||||
* `rpe` - Rate of Perceived Exertion (0-10):
|
||||
- RPE 10: Could not do any more reps, technical failure
|
||||
- RPE 9: Could maybe do 1 more rep
|
||||
- RPE 8: Could definitely do 1 more rep, maybe 2
|
||||
- RPE 7: Could do 2-3 more reps
|
||||
* `duration` - Time in seconds
|
||||
* `set_type` - Set classification (possible values: `warmup`, `normal`, `drop`, `failure`)
|
||||
|
||||
Additional parameters can be defined in exercise templates in the `format_units` tag as needed for specific activities (e.g., distance, heartrate, intensity).
|
||||
|
||||
## Workout Types and Terminology
|
||||
|
||||
This specification provides examples of common workout structures but is not limited to these types. The format is extensible to support various training methodologies while maintaining consistent data structure.
|
||||
|
||||
### Common Workout Types
|
||||
|
||||
#### Strength
|
||||
Traditional strength training focusing on sets and reps with defined weights. Typically includes warm-up sets, working sets, and may include techniques like drop sets or failure sets.
|
||||
|
||||
#### Circuit
|
||||
Multiple exercises performed in sequence with minimal rest between exercises and defined rest periods between rounds. Focuses on maintaining work rate through prescribed exercises.
|
||||
|
||||
#### EMOM (Every Minute On the Minute)
|
||||
Time-based workout where specific exercises are performed at the start of each minute. Rest time is whatever remains in the minute after completing prescribed work.
|
||||
|
||||
#### AMRAP (As Many Rounds/Reps As Possible)
|
||||
Time-capped workout where the goal is to complete as many rounds or repetitions as possible of prescribed exercises while maintaining proper form.
|
||||
|
||||
## Set Types
|
||||
|
||||
### Normal Sets
|
||||
Standard working sets that count toward volume and progress tracking.
|
||||
|
||||
### Warm-up Sets
|
||||
Preparatory sets using submaximal weights. These sets are not counted in metrics or progress tracking.
|
||||
|
||||
### Drop Sets
|
||||
Sets performed immediately after a working set with reduced weight. These are counted in volume calculations but tracked separately for progress analysis.
|
||||
|
||||
### Failure Sets
|
||||
Sets where technical failure was reached before completing prescribed reps. These sets are counted in metrics but marked to indicate intensity/failure was reached.
|
||||
|
||||
## Examples
|
||||
|
||||
### Exercise Template
|
||||
```
|
||||
{
|
||||
"kind": 33401,
|
||||
"content": "Stand with feet hip-width apart, barbell over midfoot. Hinge at hips, grip bar outside knees. Flatten back, brace core. Drive through floor, keeping bar close to legs.\n\nForm demonstration: https://powr.me/exercises/deadlift-demo.mp4",
|
||||
"tags": [
|
||||
["d", "<UUID-deadlift>"],
|
||||
["title", "Barbell Deadlift"],
|
||||
["format", "weight", "reps", "rpe", "set_type"],
|
||||
["format_units", "kg", "count", "0-10", "warmup|normal|drop|failure"],
|
||||
["equipment", "barbell"],
|
||||
["difficulty", "intermediate"],
|
||||
["imeta",
|
||||
"url https://powr.me/exercises/deadlift-demo.mp4",
|
||||
"m video/mp4",
|
||||
"dim 1920x1080",
|
||||
"alt Demonstration of proper barbell deadlift form"
|
||||
],
|
||||
["t", "compound"],
|
||||
["t", "legs"],
|
||||
["t", "posterior"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### EMOM Workout Template
|
||||
```
|
||||
{
|
||||
"kind": 33402,
|
||||
"content": "20 minute EMOM alternating between squats and deadlifts every 30 seconds. Scale weight as needed to complete all reps within each interval.",
|
||||
"tags": [
|
||||
["d", "<UUID-emom-template>"],
|
||||
["title", "20min Squat/Deadlift EMOM"],
|
||||
["type", "emom"],
|
||||
["duration", "1200"],
|
||||
["rounds", "20"],
|
||||
["interval", "30"],
|
||||
|
||||
["exercise", "33401:<pubkey>:<UUID-squat>", "<relay-url>", "", "5", "7", "normal"],
|
||||
["exercise", "33401:<pubkey>:<UUID-deadlift>", "<relay-url>", "", "4", "7", "normal"],
|
||||
|
||||
["t", "conditioning"],
|
||||
["t", "legs"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Circuit Workout Record
|
||||
```
|
||||
{
|
||||
"kind": 1301,
|
||||
"content": "Completed first round as prescribed. Second round showed form deterioration on deadlifts.",
|
||||
"tags": [
|
||||
["d", "<UUID-workout-record>"],
|
||||
["title", "Leg Circuit"],
|
||||
["type", "circuit"],
|
||||
["rounds_completed", "1.5"],
|
||||
["start", "1706454000"],
|
||||
["end", "1706455800"],
|
||||
|
||||
["exercise", "33401:<pubkey>:<UUID-squat>", "<relay-url>", "80", "12", "7", "normal"],
|
||||
["exercise", "33401:<pubkey>:<UUID-deadlift>", "<relay-url>", "100", "10", "7", "normal"],
|
||||
|
||||
["exercise", "33401:<pubkey>:<UUID-squat>", "<relay-url>", "80", "12", "8", "normal"],
|
||||
["exercise", "33401:<pubkey>:<UUID-deadlift>", "<relay-url>", "100", "4", "10", "failure"],
|
||||
|
||||
["completed", "false"],
|
||||
["t", "legs"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Guidelines
|
||||
|
||||
1. All workout records MUST include accurate start and end times
|
||||
2. Templates MAY prescribe specific parameters while leaving others as empty strings for user input
|
||||
3. Records MUST include actual values for all parameters defined in exercise format
|
||||
4. Failed sets SHOULD be marked with `failure` set_type
|
||||
5. Records SHOULD be marked as `false` for completed if prescribed work wasn't completed
|
||||
6. PRs SHOULD only be tracked in workout records, not templates
|
||||
7. Exercise references MUST use the format "kind:pubkey:d-tag" to ensure proper attribution and versioning
|
||||
|
||||
## References
|
||||
|
||||
This NIP draws inspiration from:
|
||||
- [NIP-01: Basic Protocol Flow Description](https://github.com/nostr-protocol/nips/blob/master/01.md)
|
||||
- [NIP-52: Calendar Events](https://github.com/nostr-protocol/nips/blob/master/52.md)
|
||||
- [NIP-92: Media Attachments](https://github.com/nostr-protocol/nips/blob/master/92.md#nip-92)
|
@ -71,25 +71,28 @@ export function useWorkoutHistory(options: UseWorkoutHistoryOptions = {}) {
|
||||
|
||||
// Set up real-time subscription if enabled
|
||||
useEffect(() => {
|
||||
if (!realtime || !isAuthenticated || !currentUser?.pubkey || !includeNostr) {
|
||||
if (!realtime || !isAuthenticated || !currentUser?.pubkey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Subscribe to real-time updates
|
||||
// Only create the subscription when we need it (not dependent on includeNostr to prevent re-subs)
|
||||
const subId = workoutHistoryService.subscribeToNostrWorkouts(
|
||||
currentUser.pubkey,
|
||||
(newWorkout) => {
|
||||
setWorkouts(prev => {
|
||||
// Check if workout already exists
|
||||
const exists = prev.some(w => w.id === newWorkout.id);
|
||||
if (exists) {
|
||||
// Update existing workout
|
||||
return prev.map(w => w.id === newWorkout.id ? newWorkout : w);
|
||||
} else {
|
||||
// Add new workout
|
||||
return [newWorkout, ...prev];
|
||||
}
|
||||
});
|
||||
// Only update state if we're including Nostr workouts
|
||||
if (includeNostr) {
|
||||
setWorkouts(prev => {
|
||||
// Check if workout already exists
|
||||
const exists = prev.some(w => w.id === newWorkout.id);
|
||||
if (exists) {
|
||||
// Update existing workout
|
||||
return prev.map(w => w.id === newWorkout.id ? newWorkout : w);
|
||||
} else {
|
||||
// Add new workout
|
||||
return [newWorkout, ...prev];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@ -97,7 +100,9 @@ export function useWorkoutHistory(options: UseWorkoutHistoryOptions = {}) {
|
||||
return () => {
|
||||
workoutHistoryService.unsubscribeFromNostrWorkouts(subId);
|
||||
};
|
||||
}, [workoutHistoryService, currentUser?.pubkey, isAuthenticated, realtime, includeNostr]);
|
||||
// Remove includeNostr from dependencies to prevent re-subs when it changes
|
||||
// We handle the includeNostr state inside the callback instead
|
||||
}, [workoutHistoryService, currentUser?.pubkey, isAuthenticated, realtime]);
|
||||
|
||||
// Refresh function for pull-to-refresh
|
||||
const refresh = useCallback(() => {
|
||||
|
Loading…
x
Reference in New Issue
Block a user