diff --git a/app/(tabs)/history/workoutHistory.tsx b/app/(tabs)/history/workoutHistory.tsx index e9afb9a..ba7d8fb 100644 --- a/app/(tabs)/history/workoutHistory.tsx +++ b/app/(tabs)/history/workoutHistory.tsx @@ -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 && ( - - setIncludeNostr(!includeNostr)} - style={{ - backgroundColor: includeNostr ? primaryBgColor : mutedBgColor, - paddingHorizontal: 12, - paddingVertical: 4, - borderRadius: 9999, - }} - > - - {includeNostr ? 'Showing All Workouts' : 'Local Workouts Only'} + + + + Workout Source - + + + setIncludeNostr(true)} + style={{ + backgroundColor: includeNostr ? primaryBgColor : 'transparent', + paddingHorizontal: 12, + paddingVertical: 6, + }} + > + + All Workouts + + + + setIncludeNostr(false)} + style={{ + backgroundColor: !includeNostr ? primaryBgColor : 'transparent', + paddingHorizontal: 12, + paddingVertical: 6, + }} + > + + Local Only + + + + + + + {includeNostr + ? 'Showing all workouts from Nostr and your local device' + : 'Only showing workouts saved on this device'} + )} diff --git a/docs/design/Analysis/NDKSubscriptionAnalysis.md b/docs/design/Analysis/NDKSubscriptionAnalysis.md deleted file mode 100644 index 17d182b..0000000 --- a/docs/design/Analysis/NDKSubscriptionAnalysis.md +++ /dev/null @@ -1,1397 +0,0 @@ -Let me examine some of the key files related to subscriptions and contact lists. First, let's look at the subscription implementation: -─── text_editor | developer ────────────────────────── -path: ~/ndk/ndk/src/subscription/index.ts -command: view - - -### /Users/danielwyler/ndk/ndk/src/subscription/index.ts -```typescript -import { EventEmitter } from "tseep"; - -import type { NDKEventId, NostrEvent } from "../events/index.js"; -import { NDKEvent } from "../events/index.js"; -import type { NDK } from "../ndk/index.js"; -import type { NDKRelay } from "../relay"; -import type { NDKPool } from "../relay/pool/index.js"; -import { calculateRelaySetsFromFilters } from "../relay/sets/calculate"; -import type { NDKRelaySet } from "../relay/sets/index.js"; -import { queryFullyFilled } from "./utils.js"; -import type { NDKKind } from "../events/kinds/index.js"; -import { verifiedSignatures } from "../events/validation.js"; -import { wrapEvent } from "../events/wrap.js"; - -export type NDKSubscriptionInternalId = string; - -export type NDKSubscriptionDelayedType = "at-least" | "at-most"; - -export type NDKFilter = { - ids?: string[]; - kinds?: K[]; - authors?: string[]; - since?: number; - until?: number; - limit?: number; - search?: string; - [key: `#${string}`]: string[] | undefined; -}; - -export enum NDKSubscriptionCacheUsage { - // Only use cache, don't subscribe to relays - ONLY_CACHE = "ONLY_CACHE", - - // Use cache, if no matches, use relays - CACHE_FIRST = "CACHE_FIRST", - - // Use cache in addition to relays - PARALLEL = "PARALLEL", - - // Skip cache, don't query it - ONLY_RELAY = "ONLY_RELAY", -} - -export interface NDKSubscriptionOptions { - /** - * Whether to close the subscription when all relays have reached the end of the event stream. - * @default false - */ - closeOnEose?: boolean; - cacheUsage?: NDKSubscriptionCacheUsage; - - /** - * Whether to skip caching events coming from this subscription - **/ - dontSaveToCache?: boolean; - - /** - * Groupable subscriptions are created with a slight time - * delayed to allow similar filters to be grouped together. - */ - groupable?: boolean; - - /** - * The delay to use when grouping subscriptions, specified in milliseconds. - * @default 100 - * @example - * const sub1 = ndk.subscribe({ kinds: [1], authors: ["alice"] }, { groupableDelay: 100 }); - * const sub2 = ndk.subscribe({ kinds: [0], authors: ["alice"] }, { groupableDelay: 1000 }); - * // sub1 and sub2 will be grouped together and executed 100ms after sub1 was created - */ - groupableDelay?: number; - - /** - * Specifies how this delay should be interpreted. - * "at-least" means "wait at least this long before sending the subscription" - * "at-most" means "wait at most this long before sending the subscription" - * @default "at-most" - * @example - * const sub1 = ndk.subscribe({ kinds: [1], authors: ["alice"] }, { groupableDelay: 100, groupableDelayType: "at-least" }); - * const sub2 = ndk.subscribe({ kinds: [0], authors: ["alice"] }, { groupableDelay: 1000, groupableDelayType: "at-most" }); - * // sub1 and sub2 will be grouped together and executed 1000ms after sub1 was created - */ - groupableDelayType?: NDKSubscriptionDelayedType; - - /** - * The subscription ID to use for the subscription. - */ - subId?: string; - - /** - * Pool to use - */ - pool?: NDKPool; - - /** - * Skip signature verification - * @default false - */ - skipVerification?: boolean; - - /** - * Skip event validation. Event validation, checks whether received - * kinds conform to what the expected schema of that kind should look like.rtwle - * @default false - */ - skipValidation?: boolean; - - /** - * Skip emitting on events before they are received from a relay. (skip optimistic publish) - * @default false - */ - skipOptimisticPublishEvent?: boolean; - - /** - * Remove filter constraints when querying the cache. - * - * This allows setting more aggressive filters that will be removed when hitting the cache. - * - * Useful uses of this include removing `since` or `until` constraints or `limit` filters. - * - * @example - * ndk.subscribe({ kinds: [1], since: 1710000000, limit: 10 }, { cacheUnconstrainFilter: ['since', 'limit'] }); - * - * This will hit relays with the since and limit constraints, while loading from the cache without them. - */ - cacheUnconstrainFilter?: (keyof NDKFilter)[]; - - /** - * Whether to wrap events in kind-specific classes when possible. - * @default false - */ - wrap?: boolean; -} - -/** - * Default subscription options. - */ -export const defaultOpts: NDKSubscriptionOptions = { - closeOnEose: false, - cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST, - dontSaveToCache: false, - groupable: true, - groupableDelay: 100, - groupableDelayType: "at-most", - cacheUnconstrainFilter: ['limit', 'since', 'until'] -}; - -/** - * Represents a subscription to an NDK event stream. - * - * @emits event - * Emitted when an event is received by the subscription. - * * ({NDKEvent} event - The event received by the subscription, - * * {NDKRelay} relay - The relay that received the event, - * * {NDKSubscription} subscription - The subscription that received the event.) - * - * @emits event:dup - * Emitted when a duplicate event is received by the subscription. - * * {NDKEvent} event - The duplicate event received by the subscription. - * * {NDKRelay} relay - The relay that received the event. - * * {number} timeSinceFirstSeen - The time elapsed since the first time the event was seen. - * * {NDKSubscription} subscription - The subscription that received the event. - * - * @emits cacheEose - Emitted when the cache adapter has reached the end of the events it had. - * - * @emits eose - Emitted when all relays have reached the end of the event stream. - * * {NDKSubscription} subscription - The subscription that received EOSE. - * - * @emits close - Emitted when the subscription is closed. - * * {NDKSubscription} subscription - The subscription that was closed. - * - * @example - * const sub = ndk.subscribe({ kinds: [1] }); // Get all kind:1s - * sub.on("event", (event) => console.log(event.content); // Show the content - * sub.on("eose", () => console.log("All relays have reached the end of the event stream")); - * sub.on("close", () => console.log("Subscription closed")); - * setTimeout(() => sub.stop(), 10000); // Stop the subscription after 10 seconds - * - * @description - * Subscriptions are created using {@link NDK.subscribe}. - * - * # Event validation - * By defaults, subscriptions will validate events to comply with the minimal requirement - * of each known NIP. - * This can be disabled by setting the `skipValidation` option to `true`. - * - * @example - * const sub = ndk.subscribe({ kinds: [1] }, { skipValidation: false }); - * sub.on("event", (event) => console.log(event.content); // Only valid events will be received - */ -export class NDKSubscription extends EventEmitter<{ - cacheEose: () => void; - eose: (sub: NDKSubscription) => void; - close: (sub: NDKSubscription) => void; - - /** - * Emitted when a duplicate event is received by the subscription. - * @param event - The duplicate event received by the subscription. - * @param relay - The relay that received the event. - * @param timeSinceFirstSeen - The time elapsed since the first time the event was seen. - * @param sub - The subscription that received the event. - */ - "event:dup": ( - event: NDKEvent | NostrEvent, - relay: NDKRelay | undefined, - timeSinceFirstSeen: number, - sub: NDKSubscription, - fromCache: boolean, - optimisticPublish: boolean - ) => void; - - /** - * Emitted when an event is received by the subscription. - * @param event - The event received by the subscription. - * @param relay - The relay that received the event. - * @param sub - The subscription that received the event. - * @param fromCache - Whether the event was received from the cache. - * @param optimisticPublish - Whether the event was received from an optimistic publish. - */ - event: (event: NDKEvent, relay: NDKRelay | undefined, sub: NDKSubscription, fromCache: boolean, optimisticPublish: boolean) => void; - - /** - * Emitted when a relay unilaterally closes the subscription. - * @param relay - * @param reason - * @returns - */ - closed: (relay: NDKRelay, reason: string) => void; -}> { - readonly subId?: string; - readonly filters: NDKFilter[]; - readonly opts: NDKSubscriptionOptions; - readonly pool: NDKPool; - readonly skipVerification: boolean = false; - readonly skipValidation: boolean = false; - - /** - * Tracks the filters as they are executed on each relay - */ - public relayFilters?: Map; - public relaySet?: NDKRelaySet; - public ndk: NDK; - public debug: debug.Debugger; - - /** - * Events that have been seen by the subscription, with the time they were first seen. - */ - public eventFirstSeen = new Map(); - - /** - * Relays that have sent an EOSE. - */ - public eosesSeen = new Set(); - - /** - * The time the last event was received by the subscription. - * This is used to calculate when EOSE should be emitted. - */ - private lastEventReceivedAt: number | undefined; - - public internalId: NDKSubscriptionInternalId; - - /** - * Whether the subscription should close when all relays have reached the end of the event stream. - */ - public closeOnEose: boolean; - - /** - * Pool monitor callback - */ - private poolMonitor: ((relay: NDKRelay) => void) | undefined; - - public skipOptimisticPublishEvent: boolean = false; - - /** - * Filters to remove when querying the cache. - */ - public cacheUnconstrainFilter?: Array<(keyof NDKFilter)>; - - public constructor( - ndk: NDK, - filters: NDKFilter | NDKFilter[], - opts?: NDKSubscriptionOptions, - relaySet?: NDKRelaySet, - subId?: string - ) { - super(); - this.ndk = ndk; - this.pool = opts?.pool || ndk.pool; - this.opts = { ...defaultOpts, ...(opts || {}) }; - this.filters = filters instanceof Array ? filters : [filters]; - this.subId = subId || this.opts.subId; - this.internalId = Math.random().toString(36).substring(7); - this.relaySet = relaySet; - this.debug = ndk.debug.extend(`subscription[${this.opts.subId ?? this.internalId}]`); - this.skipVerification = this.opts.skipVerification || false; - this.skipValidation = this.opts.skipValidation || false; - this.closeOnEose = this.opts.closeOnEose || false; - this.skipOptimisticPublishEvent = this.opts.skipOptimisticPublishEvent || false; - this.cacheUnconstrainFilter = this.opts.cacheUnconstrainFilter; - } - - /** - * Returns the relays that have not yet sent an EOSE. - */ - public relaysMissingEose(): WebSocket["url"][] { - if (!this.relayFilters) return []; - - const relaysMissingEose = Array.from(this.relayFilters!.keys()).filter( - (url) => !this.eosesSeen.has(this.pool.getRelay(url, false, false)) - ); - - return relaysMissingEose; - } - - /** - * Provides access to the first filter of the subscription for - * backwards compatibility. - */ - get filter(): NDKFilter { - return this.filters[0]; - } - - get groupableDelay(): number | undefined { - if (!this.isGroupable()) return undefined; - return this.opts?.groupableDelay; - } - - get groupableDelayType(): NDKSubscriptionDelayedType { - return this.opts?.groupableDelayType || "at-most"; - } - - public isGroupable(): boolean { - return this.opts?.groupable || false; - } - - private shouldQueryCache(): boolean { - // explicitly told to not query the cache - if (this.opts?.cacheUsage === NDKSubscriptionCacheUsage.ONLY_RELAY) return false; - - const hasNonEphemeralKind = this.filters.some((f) => f.kinds?.some((k) => kindIsEphemeral(k))); - if (hasNonEphemeralKind) return true; - - return true; - } - - private shouldQueryRelays(): boolean { - return this.opts?.cacheUsage !== NDKSubscriptionCacheUsage.ONLY_CACHE; - } - - private shouldWaitForCache(): boolean { - return ( - // Must want to close on EOSE; subscriptions - // that want to receive further updates must - // always hit the relay - this.opts.closeOnEose! && - // Cache adapter must claim to be fast - !!this.ndk.cacheAdapter?.locking && - // If explicitly told to run in parallel, then - // we should not wait for the cache - this.opts.cacheUsage !== NDKSubscriptionCacheUsage.PARALLEL - ); - } - - /** - * Start the subscription. This is the main method that should be called - * after creating a subscription. - * - * @param emitCachedEvents - Whether to emit events coming from a synchronous cache - * - * When using a synchronous cache, the events will be returned immediately - * by this function. If you will use those returned events, you should - * set emitCachedEvents to false to prevent seeing them as duplicate events. - */ - public start(emitCachedEvents: boolean = true): NDKEvent[] | null { - let cacheResult: NDKEvent[] | Promise; - - const updateStateFromCacheResults = (events: NDKEvent[]) => { - if (emitCachedEvents) { - for (const event of events) { - this.eventReceived(event, undefined, true, false); - } - } else { - cacheResult = []; - events.forEach((event) => { - event.ndk = this.ndk; - const e = this.opts.wrap ? wrapEvent(event) : event; - if (!e) return; - if (e instanceof Promise) { - // if we get a promise, we emit it - e.then((wrappedEvent) => { - this.emitEvent(false, wrappedEvent, undefined, true, false); - }); - return; - } - this.eventFirstSeen.set(e.id, Date.now()); - (cacheResult as NDKEvent[]).push(e); - }); - } - } - - const loadFromRelays = () => { - if (this.shouldQueryRelays()) { - this.startWithRelays(); - this.startPoolMonitor(); - } else { - this.emit("eose", this); - } - } - - if (this.shouldQueryCache()) { - cacheResult = this.startWithCache(); - - if (cacheResult instanceof Promise) { - // The cache is asynchronous - if (this.shouldWaitForCache()) { - // If we need to wait for it - cacheResult.then((events) => { - // load the results into the subscription state - updateStateFromCacheResults(events); - // if the cache has a hit, return early - if (queryFullyFilled(this)) { - this.emit("eose", this); - return; - } else { - loadFromRelays(); - } - }); - return null; - } else { - cacheResult.then((events) => { - updateStateFromCacheResults(events); - }); - } - - return null; - } else { - updateStateFromCacheResults(cacheResult); - - if (queryFullyFilled(this)) { - this.emit("eose", this); - } else { - loadFromRelays(); - } - - return cacheResult; - } - } else { - loadFromRelays(); - return null; - } - } - - /** - * We want to monitor for new relays that are coming online, in case - * they should be part of this subscription. - */ - private startPoolMonitor(): void { - const d = this.debug.extend("pool-monitor"); - - this.poolMonitor = (relay: NDKRelay) => { - // check if the pool monitor is already in the relayFilters - if (this.relayFilters?.has(relay.url)) return; - - const calc = calculateRelaySetsFromFilters(this.ndk, this.filters, this.pool); - - // check if the new relay is included - if (calc.get(relay.url)) { - // add it to the relayFilters - this.relayFilters?.set(relay.url, this.filters); - - // d("New relay connected -- adding to subscription", relay.url); - relay.subscribe(this, this.filters); - } - }; - - this.pool.on("relay:connect", this.poolMonitor); - } - - public onStopped?: () => void; - - public stop(): void { - this.emit("close", this); - this.poolMonitor && this.pool.off("relay:connect", this.poolMonitor); - this.removeAllListeners(); - this.onStopped?.(); - } - - /** - * @returns Whether the subscription has an authors filter. - */ - public hasAuthorsFilter(): boolean { - return this.filters.some((f) => f.authors?.length); - } - - private startWithCache(): NDKEvent[] | Promise { - if (this.ndk.cacheAdapter?.query) { - return this.ndk.cacheAdapter.query(this); - } else { - return []; - } - } - - /** - * Send REQ to relays - */ - private startWithRelays(): void { - if (!this.relaySet || this.relaySet.relays.size === 0) { - this.relayFilters = calculateRelaySetsFromFilters(this.ndk, this.filters, this.pool); - } else { - this.relayFilters = new Map(); - for (const relay of this.relaySet.relays) { - this.relayFilters.set(relay.url, this.filters); - } - } - - if (!this.relayFilters || this.relayFilters.size === 0) return; - - // iterate through the this.relayFilters - for (const [relayUrl, filters] of this.relayFilters) { - const relay = this.pool.getRelay(relayUrl, true, true, filters); - relay.subscribe(this, filters); - } - } - - // EVENT handling - - /** - * Called when an event is received from a relay or the cache - * @param event - * @param relay - * @param fromCache Whether the event was received from the cache - * @param optimisticPublish Whether this event is coming from an optimistic publish - */ - public eventReceived( - event: NDKEvent | NostrEvent, - relay: NDKRelay | undefined, - fromCache: boolean = false, - optimisticPublish: boolean = false - ) { - const eventId = event.id! as NDKEventId; - - const eventAlreadySeen = this.eventFirstSeen.has(eventId); - let ndkEvent: NDKEvent; - - if (event instanceof NDKEvent) ndkEvent = event; - - if (!eventAlreadySeen) { - // generate the ndkEvent - ndkEvent ??= new NDKEvent(this.ndk, event); - ndkEvent.ndk = this.ndk; - ndkEvent.relay = relay; - - // we don't want to validate/verify events that are either - // coming from the cache or have been published by us from within - // the client - if (!fromCache && !optimisticPublish) { - // validate it - if (!this.skipValidation) { - if (!ndkEvent.isValid) { - this.debug(`Event failed validation %s from relay %s`, eventId, relay?.url); - return; - } - } - - // verify it - if (relay) { - if (relay?.shouldValidateEvent() !== false) { - if (!this.skipVerification) { - if (!ndkEvent.verifySignature(true) && !this.ndk.asyncSigVerification) { - this.debug(`Event failed signature validation`, event); - return; - } else if (relay) { - relay.addValidatedEvent(); - } - } - } else { - relay.addNonValidatedEvent(); - } - } - - if (this.ndk.cacheAdapter && !this.opts.dontSaveToCache) { - this.ndk.cacheAdapter.setEvent(ndkEvent, this.filters, relay); - } - } - - // emit it - if (!optimisticPublish || this.skipOptimisticPublishEvent !== true) { - this.emitEvent(this.opts?.wrap, ndkEvent, relay, fromCache, optimisticPublish); - // mark the eventId as seen - this.eventFirstSeen.set(eventId, Date.now()); - } - } else { - const timeSinceFirstSeen = Date.now() - (this.eventFirstSeen.get(eventId) || 0); - this.emit("event:dup", event, relay, timeSinceFirstSeen, this, fromCache, optimisticPublish); - - if (relay) { - // Let's see if we have already verified this event id's signature - const signature = verifiedSignatures.get(eventId); - if (signature && typeof signature === "string") { - // If it matches then we can increase the relay verification count - if (event.sig === signature) { - relay.addValidatedEvent(); - } - } - } - } - - this.lastEventReceivedAt = Date.now(); - } - - /** - * Optionally wraps, sync or async, and emits the event (if one comes back from the wrapper) - */ - private emitEvent(wrap = false, evt: NDKEvent, relay: NDKRelay | undefined, fromCache: boolean, optimisticPublish: boolean) { - const wrapped = wrap ? wrapEvent(evt) : evt; - if (wrapped instanceof Promise) { - wrapped.then((e) => this.emitEvent(false, e, relay, fromCache, optimisticPublish)) - } else if (wrapped) { - this.emit("event", wrapped, relay, this, fromCache, optimisticPublish); - } - } - - public closedReceived(relay: NDKRelay, reason: string): void { - this.emit("closed", relay, reason); - } - - // EOSE handling - private eoseTimeout: ReturnType | undefined; - private eosed = false; - - public eoseReceived(relay: NDKRelay): void { - this.debug("EOSE received from %s", relay.url); - this.eosesSeen.add(relay); - - let lastEventSeen = this.lastEventReceivedAt - ? Date.now() - this.lastEventReceivedAt - : undefined; - - const hasSeenAllEoses = this.eosesSeen.size === this.relayFilters?.size; - const queryFilled = queryFullyFilled(this); - - const performEose = (reason: string) => { - this.debug("Performing EOSE: %s %d", reason, this.eosed); - if (this.eosed) return; - if (this.eoseTimeout) clearTimeout(this.eoseTimeout); - this.emit("eose", this); - this.eosed = true; - - // if (this.opts?.closeOnEose) this.stop(); - }; - - if (queryFilled || hasSeenAllEoses) { - performEose("query filled or seen all"); - } else if (this.relayFilters) { - let timeToWaitForNextEose = 1000; - - const connectedRelays = new Set(this.pool.connectedRelays().map((r) => r.url)); - - const connectedRelaysWithFilters = Array.from(this.relayFilters.keys()).filter((url) => - connectedRelays.has(url) - ); - - // if we have no connected relays, wait for all relays to connect - if (connectedRelaysWithFilters.length === 0) { - return; - } - - // Reduce the number of ms to wait based on the percentage of relays - // that have already sent an EOSE, the more - // relays that have sent an EOSE, the less time we should wait - // for the next one - const percentageOfRelaysThatHaveSentEose = - this.eosesSeen.size / connectedRelaysWithFilters.length; - - this.debug("Percentage of relays that have sent EOSE", { subId: this.subId, percentageOfRelaysThatHaveSentEose, seen: this.eosesSeen.size, total: connectedRelaysWithFilters.length }); - - // If less than 2 and 50% of relays have EOSEd don't add a timeout yet - if (this.eosesSeen.size >= 2 && percentageOfRelaysThatHaveSentEose >= 0.5) { - timeToWaitForNextEose = - timeToWaitForNextEose * (1 - percentageOfRelaysThatHaveSentEose); - - if (timeToWaitForNextEose === 0) { - performEose("time to wait was 0"); - return; - } - - if (this.eoseTimeout) clearTimeout(this.eoseTimeout); - - const sendEoseTimeout = () => { - lastEventSeen = this.lastEventReceivedAt - ? Date.now() - this.lastEventReceivedAt - : undefined; - - // If we have seen an event in the past 20ms don't emit an EOSE due to a timeout, events - // are still being received - if (lastEventSeen !== undefined && lastEventSeen < 20) { - this.eoseTimeout = setTimeout(sendEoseTimeout, timeToWaitForNextEose); - } else { - performEose("send eose timeout: " + timeToWaitForNextEose); - } - }; - - this.eoseTimeout = setTimeout(sendEoseTimeout, timeToWaitForNextEose); - } - } - } -} - -const kindIsEphemeral = (kind: NDKKind) => kind >= 20000 && kind < 30000; -``` - -Let me also check the implementation for contact list handling: -─── text_editor | developer ────────────────────────── -path: ~/ndk/ndk/src/user/index.ts -command: view - - -### /Users/danielwyler/ndk/ndk/src/user/index.ts -```typescript -import { nip19 } from "nostr-tools"; - -import { NDKEvent, type NDKTag, type NostrEvent } from "../events/index.js"; -import { NDKKind } from "../events/kinds/index.js"; -import type { NDK } from "../ndk/index.js"; -import { NDKSubscriptionCacheUsage, type NDKSubscriptionOptions } from "../subscription/index.js"; -import { follows } from "./follows.js"; -import { type NDKUserProfile, profileFromEvent, serializeProfile } from "./profile.js"; -import { getNip05For } from "./nip05.js"; -import type { - NDKFilter, - NDKRelay, - NDKZapMethod, - NDKZapMethodInfo, -} from "../index.js"; -import { NDKCashuMintList } from "../events/kinds/nutzap/mint-list.js"; - -export type Hexpubkey = string; - -export type Npub = string; - -// @ignore -export type ProfilePointer = { - pubkey: string; - relays?: string[]; - nip46?: string[]; -}; - -// @ignore -export type EventPointer = { - id: string; - relays?: string[]; - author?: string; - kind?: number; -}; - -export interface NDKUserParams { - npub?: Npub; - hexpubkey?: Hexpubkey; - pubkey?: Hexpubkey; - nip05?: string; - relayUrls?: string[]; - nip46Urls?: string[]; -} - -/** - * Represents a pubkey. - */ -export class NDKUser { - public ndk: NDK | undefined; - public profile?: NDKUserProfile; - public profileEvent?: NDKEvent; - private _npub?: Npub; - private _pubkey?: Hexpubkey; - readonly relayUrls: string[] = []; - readonly nip46Urls: string[] = []; - - public constructor(opts: NDKUserParams) { - if (opts.npub) this._npub = opts.npub; - - if (opts.hexpubkey) this._pubkey = opts.hexpubkey; - if (opts.pubkey) this._pubkey = opts.pubkey; - - if (opts.relayUrls) this.relayUrls = opts.relayUrls; - if (opts.nip46Urls) this.nip46Urls = opts.nip46Urls; - } - - get npub(): string { - if (!this._npub) { - if (!this._pubkey) throw new Error("pubkey not set"); - this._npub = nip19.npubEncode(this.pubkey); - } - - return this._npub; - } - - get nprofile(): string { - const relays = this.profileEvent?.onRelays?.map((r) => r.url); - return nip19.nprofileEncode({ - pubkey: this.pubkey, - relays - }); - } - - set npub(npub: Npub) { - this._npub = npub; - } - - /** - * Get the user's pubkey - * @returns {string} The user's pubkey - */ - get pubkey(): string { - if (!this._pubkey) { - if (!this._npub) throw new Error("npub not set"); - this._pubkey = nip19.decode(this.npub).data as Hexpubkey; - } - - return this._pubkey; - } - - /** - * Set the user's pubkey - * @param pubkey {string} The user's pubkey - */ - set pubkey(pubkey: string) { - this._pubkey = pubkey; - } - - /** - * Equivalent to NDKEvent.filters(). - * @returns {NDKFilter} - */ - public filter(): NDKFilter { - return {"#p": [this.pubkey]} - } - - /** - * Gets NIP-57 and NIP-61 information that this user has signaled - * - * @param getAll {boolean} Whether to get all zap info or just the first one - */ - async getZapInfo(timeoutMs?: number): Promise> { - if (!this.ndk) throw new Error("No NDK instance found"); - - const promiseWithTimeout = async (promise: Promise): Promise => { - if (!timeoutMs) return promise; - try { - return await Promise.race([ - promise, - new Promise((_, reject) => setTimeout(() => reject(), timeoutMs)) - ]); - } catch { - return undefined; - } - }; - - const [ userProfile, mintListEvent ] = await Promise.all([ - promiseWithTimeout(this.fetchProfile()), - promiseWithTimeout(this.ndk.fetchEvent({ kinds: [NDKKind.CashuMintList], authors: [this.pubkey] })) - ]); - - const res: Map = new Map(); - - if (mintListEvent) { - const mintList = NDKCashuMintList.from(mintListEvent); - - if (mintList.mints.length > 0) { - res.set("nip61", { - mints: mintList.mints, - relays: mintList.relays, - p2pk: mintList.p2pk, - }); - } - } - - if (userProfile) { - const { lud06, lud16 } = userProfile; - res.set("nip57", { lud06, lud16 }); - } - - return res; - } - - /** - * Instantiate an NDKUser from a NIP-05 string - * @param nip05Id {string} The user's NIP-05 - * @param ndk {NDK} An NDK instance - * @param skipCache {boolean} Whether to skip the cache or not - * @returns {NDKUser | undefined} An NDKUser if one is found for the given NIP-05, undefined otherwise. - */ - static async fromNip05( - nip05Id: string, - ndk: NDK, - skipCache = false - ): Promise { - if (!ndk) throw new Error("No NDK instance found"); - - const opts: RequestInit = {}; - - if (skipCache) opts.cache = "no-cache"; - const profile = await getNip05For(ndk, nip05Id, ndk?.httpFetch, opts); - - if (profile) { - const user = new NDKUser({ - pubkey: profile.pubkey, - relayUrls: profile.relays, - nip46Urls: profile.nip46, - }); - user.ndk = ndk; - return user; - } - } - - /** - * Fetch a user's profile - * @param opts {NDKSubscriptionOptions} A set of NDKSubscriptionOptions - * @param storeProfileEvent {boolean} Whether to store the profile event or not - * @returns User Profile - */ - public async fetchProfile( - opts?: NDKSubscriptionOptions, - storeProfileEvent: boolean = false - ): Promise { - if (!this.ndk) throw new Error("NDK not set"); - - if (!this.profile) this.profile = {}; - - let setMetadataEvent: NDKEvent | null = null; - - if ( - this.ndk.cacheAdapter && - this.ndk.cacheAdapter.fetchProfile && - opts?.cacheUsage !== NDKSubscriptionCacheUsage.ONLY_RELAY - ) { - const profile = await this.ndk.cacheAdapter.fetchProfile(this.pubkey); - - if (profile) { - this.profile = profile; - return profile; - } - } - - // if no options have been set and we have a cache, try to load from cache with no grouping - // This is done in favour of simply using NDKSubscriptionCacheUsage.CACHE_FIRST since - // we want to avoid depending on the grouping, arguably, all queries should go through this - // type of behavior when we have a locking cache - if ( - !opts && // if no options have been set - this.ndk.cacheAdapter && // and we have a cache - this.ndk.cacheAdapter.locking // and the cache identifies itself as fast 😂 - ) { - setMetadataEvent = await this.ndk.fetchEvent( - { - kinds: [0], - authors: [this.pubkey], - }, - { - cacheUsage: NDKSubscriptionCacheUsage.ONLY_CACHE, - closeOnEose: true, - groupable: false, - } - ); - - opts = { - cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, - closeOnEose: true, - groupable: true, - groupableDelay: 250, - }; - } - - if (!setMetadataEvent) { - setMetadataEvent = await this.ndk.fetchEvent( - { - kinds: [0], - authors: [this.pubkey], - }, - opts - ); - } - - if (!setMetadataEvent) return null; - - // return the most recent profile - this.profile = profileFromEvent(setMetadataEvent); - - if (storeProfileEvent) { - // Store the event as a stringified JSON - this.profile.profileEvent = JSON.stringify(setMetadataEvent); - } - - if (this.profile && this.ndk.cacheAdapter && this.ndk.cacheAdapter.saveProfile) { - this.ndk.cacheAdapter.saveProfile(this.pubkey, this.profile); - } - - return this.profile; - } - - /** - * Returns a set of users that this user follows. - * - * @deprecated Use followSet instead - */ - public follows = follows.bind(this); - - /** - * Returns a set of pubkeys that this user follows. - * - * @param opts - NDKSubscriptionOptions - * @param outbox - boolean - * @param kind - number - */ - public async followSet( - opts?: NDKSubscriptionOptions, - outbox?: boolean, - kind: number = NDKKind.Contacts - ): Promise> { - const follows = await this.follows(opts, outbox, kind); - return new Set(Array.from(follows).map((f) => f.pubkey)); - } - - /** @deprecated Use referenceTags instead. */ - /** - * Get the tag that can be used to reference this user in an event - * @returns {NDKTag} an NDKTag - */ - public tagReference(): NDKTag { - return ["p", this.pubkey]; - } - - /** - * Get the tags that can be used to reference this user in an event - * @returns {NDKTag[]} an array of NDKTag - */ - public referenceTags(marker?: string): NDKTag[] { - const tag = [["p", this.pubkey]]; - if (!marker) return tag; - - // TODO: Locate this pubkey's relay - tag[0].push("", marker); - return tag; - } - - /** - * Publishes the current profile. - */ - public async publish() { - if (!this.ndk) throw new Error("No NDK instance found"); - if (!this.profile) throw new Error("No profile available"); - - this.ndk.assertSigner(); - - const event = new NDKEvent(this.ndk, { - kind: 0, - content: serializeProfile(this.profile), - } as NostrEvent); - await event.publish(); - } - - /** - * Add a follow to this user's contact list - * - * @param newFollow {NDKUser} The user to follow - * @param currentFollowList {Set} The current follow list - * @param kind {NDKKind} The kind to use for this contact list (defaults to `3`) - * @returns {Promise} True if the follow was added, false if the follow already exists - */ - public async follow( - newFollow: NDKUser, - currentFollowList?: Set, - kind = NDKKind.Contacts - ): Promise { - if (!this.ndk) throw new Error("No NDK instance found"); - - this.ndk.assertSigner(); - - if (!currentFollowList) { - currentFollowList = await this.follows(undefined, undefined, kind); - } - - if (currentFollowList.has(newFollow)) { - return false; - } - - currentFollowList.add(newFollow); - - const event = new NDKEvent(this.ndk, { kind } as NostrEvent); - - // This is a horrible hack and I need to fix it - for (const follow of currentFollowList) { - event.tag(follow); - } - - await event.publish(); - return true; - } - - /** - * Remove a follow from this user's contact list - * - * @param user {NDKUser} The user to unfollow - * @param currentFollowList {Set} The current follow list - * @param kind {NDKKind} The kind to use for this contact list (defaults to `3`) - * @returns The relays were the follow list was published or false if the user wasn't found - */ - public async unfollow( - user: NDKUser, - currentFollowList?: Set, - kind = NDKKind.Contacts - ): Promise | boolean> { - if (!this.ndk) throw new Error("No NDK instance found"); - - this.ndk.assertSigner(); - - if (!currentFollowList) { - currentFollowList = await this.follows(undefined, undefined, kind); - } - - // find the user that has the same pubkey - const newUserFollowList = new Set(); - let foundUser = false; - for (const follow of currentFollowList) { - if (follow.pubkey !== user.pubkey) { - newUserFollowList.add(follow); - } else { - foundUser = true; - } - } - - if (!foundUser) return false; - - const event = new NDKEvent(this.ndk, { kind } as NostrEvent); - - // Tag users from the new follow list - for (const follow of newUserFollowList) { - event.tag(follow); - } - - return await event.publish(); - } - - /** - * Validate a user's NIP-05 identifier (usually fetched from their kind:0 profile data) - * - * @param nip05Id The NIP-05 string to validate - * @returns {Promise} True if the NIP-05 is found and matches this user's pubkey, - * False if the NIP-05 is found but doesn't match this user's pubkey, - * null if the NIP-05 isn't found on the domain or we're unable to verify (because of network issues, etc.) - */ - public async validateNip05(nip05Id: string): Promise { - if (!this.ndk) throw new Error("No NDK instance found"); - - const profilePointer: ProfilePointer | null = await getNip05For(this.ndk, nip05Id); - - if (profilePointer === null) return null; - return profilePointer.pubkey === this.pubkey; - } -} - -``` - -Based on the code analysis, I can provide some best practices and recommendations for handling subscriptions and contact lists in NDK: - -### Best Practices for Subscriptions - -1. **Use Subscription Options Effectively** -```typescript -const subscriptionOptions = { - closeOnEose: true, // Close when all relays send EOSE - cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST, // Try cache before hitting relays - groupable: true, // Allow grouping similar subscriptions - groupableDelay: 100, // Wait 100ms to group subscriptions - skipVerification: false, // Verify signatures - skipValidation: false // Validate event structure -}; -``` - -2. **Cache Management** -- NDK supports different cache strategies through `cacheUsage`: - - `CACHE_FIRST`: Check cache before hitting relays - - `PARALLEL`: Query both cache and relays simultaneously - - `ONLY_CACHE`: Only use cached data - - `ONLY_RELAY`: Skip cache entirely - -3. **Efficient Subscription Handling** -```typescript -// Create a subscription with filters -const sub = ndk.subscribe( - { kinds: [3], // Contact lists - authors: [userPubkey] }, - subscriptionOptions -); - -// Handle events and cleanup -sub.on('event', (event) => { - // Process contact list updates -}); -sub.on('eose', () => { - // Handle end of stored events -}); - -// Start the subscription -sub.start(); - -// Clean up when done -sub.stop(); -``` - -### Best Practices for Contact Lists (Kind 3) - -1. **Fetching Contact Lists** -```typescript -// Using NDKUser class -const user = new NDKUser({ pubkey: userPubkey }); -user.ndk = ndk; - -// Get follows as NDKUser objects -const follows = await user.follows(); - -// Get just the pubkeys -const followSet = await user.followSet(); -``` - -2. **Managing Follows** -```typescript -// Add a new follow -const newFollow = new NDKUser({ pubkey: followPubkey }); -await user.follow(newFollow); - -// Remove a follow -await user.unfollow(userToUnfollow); -``` - -3. **Efficient Contact List Updates** -```typescript -// Get current follow list first -const currentFollows = await user.follows(); - -// Batch multiple follow operations -await user.follow(newFollow, currentFollows); -await user.follow(anotherFollow, currentFollows); -``` - -### Improved Reliability Patterns - -1. **Subscription Error Handling** -```typescript -const sub = ndk.subscribe({ kinds: [3] }); - -sub.on('error', (error, relay) => { - console.error(`Subscription error from ${relay.url}:`, error); -}); - -sub.on('closed', (relay, reason) => { - console.log(`Relay ${relay.url} closed connection: ${reason}`); -}); -``` - -2. **Relay Management** -```typescript -// Specify reliable relays for important queries -const relaySet = new NDKRelaySet(ndk, [ - 'wss://relay1.example.com', - 'wss://relay2.example.com' -]); - -const sub = ndk.subscribe( - { kinds: [3] }, - { closeOnEose: true }, - relaySet -); -``` - -3. **Cache Implementation** -```typescript -// Implement a cache adapter for better reliability -const cacheAdapter = { - async query(subscription) { - // Return cached events matching subscription filters - }, - async setEvent(event, filters, relay) { - // Store event in cache - } -}; - -ndk.cacheAdapter = cacheAdapter; -``` - -4. **Subscription Grouping** -```typescript -// Group similar subscriptions to reduce relay connections -const sub1 = ndk.subscribe( - { kinds: [3], authors: [pubkey1] }, - { groupable: true, groupableDelay: 100 } -); - -const sub2 = ndk.subscribe( - { kinds: [3], authors: [pubkey2] }, - { groupable: true, groupableDelay: 100 } -); - -// These will be grouped into a single subscription to the relay -``` - -### Recommendations for Your Use Case - -1. **Implement Progressive Loading** -```typescript -// First load from cache -const sub = ndk.subscribe( - { kinds: [3], authors: [userPubkey] }, - { cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST } -); - -// Handle immediate cache results -let contactList = new Set(); -sub.on('event', (event, relay, subscription, fromCache) => { - if (fromCache) { - // Handle cached contacts immediately - } else { - // Update with fresh data from relays - } -}); -``` - -2. **Keep Contact Lists Updated** -```typescript -// Subscribe to contact list updates -const followsSubscription = ndk.subscribe({ - kinds: [3], - authors: [userPubkey], -}); - -followsSubscription.on('event', (event) => { - // Update local contact list state - updateContactList(event); -}); -``` - -3. **Implement Retry Logic** -```typescript -async function fetchContactList(attempts = 3) { - for (let i = 0; i < attempts; i++) { - try { - const user = new NDKUser({ pubkey: userPubkey }); - user.ndk = ndk; - return await user.followSet(); - } catch (e) { - if (i === attempts - 1) throw e; - await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i))); - } - } -} -``` - -4. **Maintain Local State** -```typescript -class ContactListManager { - private contactList = new Set(); - private subscription; - - constructor(ndk, userPubkey) { - this.subscription = ndk.subscribe({ - kinds: [3], - authors: [userPubkey] - }); - - this.subscription.on('event', this.handleContactListUpdate.bind(this)); - } - - private handleContactListUpdate(event) { - // Update local state with new contact list - this.contactList = new Set( - event.tags - .filter(tag => tag[0] === 'p') - .map(tag => tag[1]) - ); - } - - getContacts() { - return this.contactList; - } -} -``` - -These practices should provide a more reliable way to handle contact lists and subscriptions in your NDK implementation. The key is to use the built-in caching mechanisms, implement proper error handling, and maintain local state while keeping it in sync with relay updates. - -Would you like me to expand on any of these aspects or provide more specific examples for your use case? \ No newline at end of file diff --git a/docs/design/Analysis/NDKandNip19.md b/docs/design/Analysis/NDKandNip19.md deleted file mode 100644 index 27fc1c4..0000000 --- a/docs/design/Analysis/NDKandNip19.md +++ /dev/null @@ -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. \ No newline at end of file diff --git a/docs/design/MVPandTargetedRebuild.md b/docs/design/MVPandTargetedRebuild.md deleted file mode 100644 index 9f1422e..0000000 --- a/docs/design/MVPandTargetedRebuild.md +++ /dev/null @@ -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. diff --git a/docs/design/POWR Pack/POWRPack.md b/docs/design/POWR Pack/POWRPack.md deleted file mode 100644 index ef908d8..0000000 --- a/docs/design/POWR Pack/POWRPack.md +++ /dev/null @@ -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) \ No newline at end of file diff --git a/docs/design/POWR Pack/POWR_Pack_Implementation_Plan.md b/docs/design/POWR Pack/POWR_Pack_Implementation_Plan.md deleted file mode 100644 index abcb0ff..0000000 --- a/docs/design/POWR Pack/POWR_Pack_Implementation_Plan.md +++ /dev/null @@ -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 { - 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 { - await this.db.runAsync( - 'UPDATE templates SET is_archived = ? WHERE id = ?', - [archive ? 1 : 0, id] - ); -} - -async removeFromLibrary(id: string): Promise { - 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 { - // 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 { - const parameters: Record = {}; - - // 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, 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; - getDefaultParameters(): Record; - formatTemplateConfig(config: Record): string[][]; -} - -// Example implementation for EMOM workouts -class EMOMWorkoutProcessor implements WorkoutTypeProcessor { - parseTemplateConfig(tags: string[][]): Record { - const config: Record = { - 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 { - return { - rounds: 10, - interval: 60, - rest: 0 - }; - } - - formatTemplateConfig(config: Record): 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 { - // 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[]> = new Map(); - - // Register a processor for a specific kind - registerProcessor(kind: number, processor: NostrEventProcessor): void { - if (!this.processors.has(kind)) { - this.processors.set(kind, []); - } - - this.processors.get(kind)?.push(processor); - } - - // Get appropriate processor for an event - getProcessor(event: NostrEvent): NostrEventProcessor | 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; - } - } - - 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; - down(db: SQLiteDatabase): Promise; -} - -// 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 { - // 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 { - // Implementation - return 0; - } - - private async updateVersion(db: SQLiteDatabase, version: number): Promise { - // 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. \ No newline at end of file diff --git a/docs/design/Social/POWRSocialArchitecture.md b/docs/design/Social/POWRSocialArchitecture.md deleted file mode 100644 index c91d254..0000000 --- a/docs/design/Social/POWRSocialArchitecture.md +++ /dev/null @@ -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::", ""], // 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 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; - - 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; - - // Get comments for content - async getComments( - eventId: string, - rootKind: number - ): Promise; - - // Post comment - async postComment( - rootEvent: NostrEvent, - content: string, - parentComment?: WorkoutComment - ): Promise; - - // Track template usage - async getTemplateUsageCount(templateId: string): Promise; - - // React to content - async reactToEvent( - event: NostrEvent, - reaction: "+" | "🔥" | "👍" - ): Promise; - - // Request deletion of event - async requestDeletion( - eventId: string, - eventKind: number, - reason?: string - ): Promise; - - // Request deletion of addressable event - async requestAddressableDeletion( - kind: number, - pubkey: string, - dTag: string, - reason?: string - ): Promise; -} -``` - -### 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) \ No newline at end of file diff --git a/docs/design/Social/POWRSocialFeedImplementationPlan.md b/docs/design/Social/POWRSocialFeedImplementationPlan.md deleted file mode 100644 index 17e5f13..0000000 --- a/docs/design/Social/POWRSocialFeedImplementationPlan.md +++ /dev/null @@ -1,1112 +0,0 @@ -# POWR Social Feed Implementation Plan - -## Technical Considerations - -### Data Flow - -The data flow for the social feed will leverage your existing NDK integration: - -1. **Subscription Management** - - Use your existing `useSubscribe` hook with appropriate filters for different feed types - - Implement efficient subscription handling to minimize relay connections - - Use NDK's subscription grouping for better relay performance - -2. **Event Processing** - - Parse Nostr events into app-specific data structures using the parser functions - - Integrate with your existing workout data models - - Handle event validation and error cases - -3. **UI Rendering** - - Use React Native's `FlatList` for efficient rendering of feed items - - Implement proper list virtualization for performance - - Use memoization to prevent unnecessary re-renders - -### NDK Outbox Model - -Your `initNDK.ts` already configures the outbox model. This is important for optimizing event delivery: - -1. **Relay Management** - - The outbox model helps route events to the most appropriate relays - - Configure relay preferences based on event types and content - - Optimize relay selection for event delivery - -2. **Event Publishing** - - Use the outbox model to ensure events reach appropriate relays - - Configure fallback relays for critical events - - Monitor delivery status using NDK's built-in mechanisms - -### Offline Support - -For true offline support: - -1. **Local Event Storage** - - Implement a pending events table in your SQLite database - - Store unsent events when offline - - Provide retry logic for failed publications - -2. **UI Indicators** - - Show visual indicators for pending publications - - Implement status tracking for shared content - - Allow users to manually retry failed shares - -### Performance Optimizations - -1. **Feed Rendering** - - Use windowed lists for performance - - Implement paged loading for long feeds - - Cache rendered components to minimize re-renders - -2. **Profile Caching** - - Cache user profiles to reduce relay requests - - Implement TTL-based caching for profile data - - Prefetch profiles for visible feed items - -3. **Media Handling** - - Implement lazy loading for images - - Use proper caching for media content - - Consider progressive loading for large media - -## Event Type Structures - -### Workout Record (kind: 1301) -```json -{ - "kind": 1301, - "content": "", - "tags": [ - ["d", ""], - ["title", ""], - ["type", ""], - ["rounds_completed", ""], - ["start", ""], - ["end", ""], - ["exercise", "::", "", "", "", "", ""], - ["exercise", "::", "", "", "", "", ""], - ["template", "::", ""], - ["pr", "::,,"], - ["completed", ""], - ["t", ""], - ["t", ""] - ] -} -``` - -### Exercise Template (kind: 33401) -```json -{ - "kind": 33401, - "content": "", - "tags": [ - ["d", ""], - ["title", ""], - ["format", "", "", "", ""], - ["format_units", "", "", "", ""], - ["equipment", ""], - ["difficulty", ""], - ["imeta", - "url ", - "m ", - "dim ", - "alt " - ], - ["t", ""], - ["t", ""] - ] -} -``` - -### Workout Template (kind: 33402) -```json -{ - "kind": 33402, - "content": "", - "tags": [ - ["d", ""], - ["title", ""], - ["type", ""], - ["rounds", ""], - ["duration", ""], - ["interval", ""], - ["rest_between_rounds", ""], - ["exercise", "::", "", "", "", "", ""], - ["exercise", "::", "", "", "", "", ""], - ["t", ""], - ["t", ""] - ] -} -``` - -## Integration with Existing Types - -To ensure compatibility with your existing codebase, we'll need to extend your current types: - -```typescript -// Add to types/nostr.ts -export interface NostrWorkoutRecord extends NostrEvent { - kind: NostrEventKind.WORKOUT; - tags: string[][]; -} - -export interface NostrExerciseTemplate extends NostrEvent { - kind: NostrEventKind.EXERCISE; - tags: string[][]; -} - -export interface NostrWorkoutTemplate extends NostrEvent { - kind: NostrEventKind.TEMPLATE; - tags: string[][]; -} - -// Interface for parsed workout record -export interface ParsedWorkoutRecord { - id: string; - title: string; - type: string; - startTime: number; - endTime: number; - completed: boolean; - exercises: ParsedExercise[]; - templateReference?: string; - notes: string; -} - -// Interface for parsed exercise -export interface ParsedExercise { - id: string; - name: string; - weight?: number; - reps?: number; - rpe?: number; - setType: string; -} -``` - -## Security Considerations - -1. **Data Privacy** - - Allow users to control which workouts are shared - - Provide clear privacy indicators - - Support event deletion (NIP-09) - -2. **Content Moderation** - - Implement user blocking - - Add reporting mechanisms - - Support muting functionality - -3. **User Safety** - - Protect sensitive health data - - Allow selective sharing - - Provide education on privacy implications - -## Conclusion - -This implementation plan provides a comprehensive roadmap for building the POWR social feed, leveraging the Nostr protocol and your existing NDK integration. By focusing on workout-specific events and integrating with POWR's existing fitness tracking capabilities, the social feed will enhance the app's value and create a vibrant fitness community. - -The phased approach allows for incremental development and testing, while the technical architecture ensures performance and user experience remain optimal across different network conditions. By leveraging your existing NDK integration and extending it for workout-specific events, the implementation can focus on fitness-specific features and user experience. - -## Next Steps - -1. Begin with enhancing the NDK integration and implementing workout event parsers -2. Extend the social feed components to handle different event types -3. Integrate with the workout completion flow for sharing functionality -4. Create detail screens for different content types -5. Implement performance optimizations and polish Overview - -This plan outlines the implementation strategy for a fitness-focused social feed within the POWR workout app, leveraging the Nostr protocol. The social feed will allow users to share workouts, discover exercise templates, follow other fitness enthusiasts, and engage with the fitness community, building upon the existing POWR application architecture. - -## Current Implementation Assessment - -### Strengths to Maintain -- Tab-based social feed structure (Following/POWR/Global) -- Basic component architecture (`SocialPost`, `EmptyFeed`, `NostrLoginPrompt`) -- NDK integration hooks (`useNDK`, `useSubscribe`, `useNDKAuth`) -- React Native with Expo structure - -### Areas for Enhancement -- Implement proper Nostr event handling for workout-related events -- Develop more robust UI components for various event types -- Complete the subscription mechanism for different feed types -- Integrate with existing workout tracking functionality - -## Core Protocol Concepts - -### Essential Nostr Event Kinds -- **Kind 0**: User Metadata/Profile information -- **Kind 1**: Standard text posts -- **Kind 1301**: Workout Records (activity data) -- **Kind 33401**: Exercise Templates (reusable definitions) -- **Kind 33402**: Workout Templates (workout plans) -- **Kind 7**: Reactions (emoji responses to workouts) -- **Kind 1111**: Comments (threaded discussions) -- **Kind 16**: Generic reposts (sharing workouts) -- **Kind 5**: Event Deletion (NIP-09) -- **Kind 23**: Long-form content (articles, guides) - -### Key NIPs for Implementation -- **NIP-01**: Basic protocol flow -- **NIP-4e**: Workout Events specification (draft) -- **NIP-18**: Reposts for sharing workouts -- **NIP-22**: Comments for workout discussions -- **NIP-25**: Reactions to workouts -- **NIP-09**: Event Deletion (crucial for allowing users to remove workout data) -- **NIP-89**: App Identification (for becoming the default handler for workout events) -- **NIP-92**: Media Attachments - -## Enhanced NDK Integration - -Building on the existing NDK integration in POWR, we'll enhance it to support workout-specific event kinds and social interactions: - -### Enhanced Subscription Management - -```typescript -// Enhancement to useSubscribe.ts to support workout-specific events -export function useWorkoutFeed( - feedType: 'following' | 'powr' | 'global' -) { - const { ndk } = useNDK(); - const { currentUser } = useNDKCurrentUser(); - const [feedItems, setFeedItems] = useState([]); - - // Define filters based on your existing pattern but with workout event kinds - const getFilters = useCallback(() => { - const baseFilters = [{ - kinds: [1, 1301, 33401, 33402], // Notes, Workouts, Exercise Templates, Workout Templates - limit: 20 - }]; - - // Customize based on feed type - switch (feedType) { - case 'following': - // Use your existing pattern for following users - return baseFilters.map(filter => ({ - ...filter, - // Add authors filter based on who the user follows - '#p': currentUser?.follows || [] - })); - case 'powr': - return baseFilters.map(filter => ({ - ...filter, - authors: ['npub1p0wer69rpkraqs02l5v8rutagfh6g9wxn2dgytkv44ysz7avt8nsusvpjk'] - })); - default: - return baseFilters; - } - }, [feedType, currentUser]); - - // Use your existing useSubscribe hook - const { events, isLoading, eose, resubscribe } = useSubscribe( - getFilters(), - { enabled: !!ndk } - ); - - // Process events into feed items - useEffect(() => { - if (events.length) { - // Convert NDK events to feed items with proper parsing - const processedItems = events.map(event => { - switch (event.kind) { - case 1301: // Workout records - return processWorkoutEvent(event); - case 33401: // Exercise templates - return processExerciseTemplateEvent(event); - case 33402: // Workout templates - return processWorkoutTemplateEvent(event); - default: // Standard posts - return processStandardPost(event); - } - }); - - setFeedItems(processedItems); - } - }, [events]); - - return { - feedItems, - isLoading, - refreshFeed: resubscribe - }; -} -``` - -### Event Type Parsers - -```typescript -// Add to utils/nostr-utils.ts -export function processWorkoutEvent(event: NDKEvent): WorkoutFeedItem { - const title = findTagValue(event.tags, 'title') || 'Workout'; - const type = findTagValue(event.tags, 'type') || 'strength'; - const startTime = parseInt(findTagValue(event.tags, 'start') || '0'); - const endTime = parseInt(findTagValue(event.tags, 'end') || '0'); - - // Extract exercises from tags - const exercises = event.tags - .filter(tag => tag[0] === 'exercise') - .map(tag => parseExerciseTag(tag)); - - // Map to your existing feed item structure - return { - id: event.id, - type: 'workout', - author: event.pubkey, - createdAt: event.created_at, - content: event.content, - title, - workoutType: type, - duration: endTime - startTime, - exercises, - // Map other properties as needed - }; -} - -export function parseExerciseTag(tag: string[]): ExerciseData { - // Format: ['exercise', '::', '', '', '', '', ''] - if (tag.length < 7) { - // Handle incomplete tags - return { - id: tag[1] || '', - name: 'Unknown Exercise', - weight: null, - reps: null, - rpe: null, - setType: 'normal' - }; - } - - // Extract exercise ID parts (kind:pubkey:d-tag) - const idParts = tag[1].split(':'); - const exerciseId = idParts.length > 2 ? idParts[2] : tag[1]; - - return { - id: exerciseId, - name: exerciseId, // Placeholder - should be resolved from your exercise database - weight: tag[3] ? parseFloat(tag[3]) : null, - reps: tag[4] ? parseInt(tag[4]) : null, - rpe: tag[5] ? parseFloat(tag[5]) : null, - setType: tag[6] || 'normal' - }; -} - -export function processExerciseTemplateEvent(event: NDKEvent): ExerciseFeedItem { - const title = findTagValue(event.tags, 'title') || 'Exercise'; - const equipment = findTagValue(event.tags, 'equipment'); - const difficulty = findTagValue(event.tags, 'difficulty'); - - // Parse format data - const formatTag = event.tags.find(tag => tag[0] === 'format'); - const formatUnitsTag = event.tags.find(tag => tag[0] === 'format_units'); - - // Get tags for categorization - const categories = event.tags - .filter(tag => tag[0] === 't') - .map(tag => tag[1]); - - // Extract media if present - const mediaTag = event.tags.find(tag => tag[0] === 'imeta'); - let media = null; - - if (mediaTag && mediaTag.length > 1) { - const urlPart = mediaTag[1].split(' '); - if (urlPart.length > 1) { - media = { - url: urlPart[1], - mimeType: mediaTag[2]?.split(' ')[1] || '', - altText: mediaTag[4]?.substring(4) || '' - }; - } - } - - return { - id: event.id, - type: 'exerciseTemplate', - author: event.pubkey, - createdAt: event.created_at, - content: event.content, - title, - equipment, - difficulty, - categories, - media, - // Map other properties as needed - }; -} - -export function processWorkoutTemplateEvent(event: NDKEvent): WorkoutTemplateFeedItem { - const title = findTagValue(event.tags, 'title') || 'Workout Template'; - const type = findTagValue(event.tags, 'type') || 'strength'; - const rounds = findTagValue(event.tags, 'rounds'); - const duration = findTagValue(event.tags, 'duration'); - - // Extract exercises - const exercises = event.tags - .filter(tag => tag[0] === 'exercise') - .map(tag => parseExerciseTag(tag)); - - // Get tags for categorization - const categories = event.tags - .filter(tag => tag[0] === 't') - .map(tag => tag[1]); - - return { - id: event.id, - type: 'workoutTemplate', - author: event.pubkey, - createdAt: event.created_at, - content: event.content, - title, - workoutType: type, - rounds: rounds ? parseInt(rounds) : null, - duration: duration ? parseInt(duration) : null, - exercises, - categories, - // Map other properties as needed - }; -} - -export function processStandardPost(event: NDKEvent): StandardPostFeedItem { - // Get tags for categorization - const categories = event.tags - .filter(tag => tag[0] === 't') - .map(tag => tag[1]); - - return { - id: event.id, - type: 'post', - author: event.pubkey, - createdAt: event.created_at, - content: event.content, - categories, - // Map other properties as needed - }; -} -``` - -## Enhanced Component Architecture - -### Enhanced SocialPost Component - -```tsx -// Enhancement to components/social/SocialPost.tsx -interface WorkoutPostProps { - event: NDKEvent; - parsed: WorkoutFeedItem; -} - -function WorkoutPost({ event, parsed }: WorkoutPostProps) { - return ( - - - - - {parsed.displayName || 'Athlete'} - - completed a {parsed.workoutType} workout • {timeAgo(parsed.createdAt)} - - - - - - {parsed.title} - - {parsed.exercises.length > 0 && ( - - Exercises: - {parsed.exercises.slice(0, 3).map((exercise, index) => ( - - • {exercise.name} {exercise.weight ? `${exercise.weight}kg` : ''} - {exercise.reps ? ` × ${exercise.reps}` : ''} - - ))} - {parsed.exercises.length > 3 && ( - - +{parsed.exercises.length - 3} more exercises - - )} - - )} - - {parsed.content && ( - {parsed.content} - )} - - - - - - - ); -} - -function ExerciseTemplatePost({ event, parsed }: { event: NDKEvent, parsed: ExerciseFeedItem }) { - return ( - - - - - {parsed.displayName || 'Athlete'} - - shared an exercise template • {timeAgo(parsed.createdAt)} - - - - - - {parsed.title} - - - {parsed.equipment && ( - {parsed.equipment} - )} - {parsed.difficulty && ( - {parsed.difficulty} - )} - {parsed.categories.map((category, index) => ( - #{category} - ))} - - - {parsed.media && ( - - - - )} - - {parsed.content && ( - {parsed.content} - )} - - - - - - - ); -} - -function WorkoutTemplatePost({ event, parsed }: { event: NDKEvent, parsed: WorkoutTemplateFeedItem }) { - return ( - - - - - {parsed.displayName || 'Athlete'} - - shared a workout template • {timeAgo(parsed.createdAt)} - - - - - - {parsed.title} - - - {parsed.workoutType} - {parsed.rounds && ( - {parsed.rounds} rounds - )} - {parsed.duration && ( - {formatDuration(parsed.duration)} - )} - - - {parsed.exercises.length > 0 && ( - - Exercises: - {parsed.exercises.slice(0, 3).map((exercise, index) => ( - - • {exercise.name} - - ))} - {parsed.exercises.length > 3 && ( - - +{parsed.exercises.length - 3} more exercises - - )} - - )} - - {parsed.content && ( - {parsed.content} - )} - - - - - - - ); -} - -function StandardPost({ event, parsed }: { event: NDKEvent, parsed: StandardPostFeedItem }) { - return ( - - - - - {parsed.displayName || 'User'} - - {timeAgo(parsed.createdAt)} - - - - - - {parsed.content} - - {parsed.categories.length > 0 && ( - - {parsed.categories.map((category, index) => ( - #{category} - ))} - - )} - - - - - - - ); -} - -// Enhanced SocialPost component -export default function SocialPost({ event }: { event: NDKEvent }) { - // Parse event based on kind - const parsed = useMemo(() => { - switch (event.kind) { - case 1301: - return processWorkoutEvent(event); - case 33401: - return processExerciseTemplateEvent(event); - case 33402: - return processWorkoutTemplateEvent(event); - default: - return processStandardPost(event); - } - }, [event]); - - // Render different components based on event kind - switch (event.kind) { - case 1301: - return ; - case 33401: - return ; - case 33402: - return ; - default: - return ; - } -} -``` - -### Interaction Components - -```tsx -// New component: components/social/InteractionButtons.tsx -export default function InteractionButtons({ event }: { event: NDKEvent }) { - const { ndk } = useNDK(); - const { currentUser } = useNDKCurrentUser(); - const [hasLiked, setHasLiked] = useState(false); - const [likeCount, setLikeCount] = useState(0); - - // Check if user has liked the event - useEffect(() => { - if (!ndk || !currentUser) return; - - // Use your existing subscription mechanism - const sub = ndk.subscribe({ - kinds: [7], // Reactions - '#e': [event.id], - authors: [currentUser.pubkey] - }); - - sub.on('event', () => { - setHasLiked(true); - }); - - return () => { - sub.close(); - }; - }, [ndk, currentUser, event.id]); - - // Get like count - useEffect(() => { - if (!ndk) return; - - const sub = ndk.subscribe({ - kinds: [7], - '#e': [event.id] - }); - - let count = 0; - - sub.on('event', () => { - count++; - setLikeCount(count); - }); - - return () => { - sub.close(); - }; - }, [ndk, event.id]); - - // Like handler - const handleLike = async () => { - if (!ndk || !currentUser) return; - - const reaction = new NDKEvent(ndk); - reaction.kind = 7; - reaction.content = '❤️'; - reaction.tags = [ - ['e', event.id], - ['p', event.pubkey] - ]; - - await reaction.publish(); - setHasLiked(true); - setLikeCount(prev => prev + 1); - }; - - return ( - - - - {likeCount > 0 ? likeCount : ''} - - - - - Comment - - - - - Repost - - - ); -} -``` - -## Integration with Existing Functionality - -### Workout Sharing - -```tsx -// Enhancement to components/workout/WorkoutCompletionFlow.tsx -// Add to your existing implementation -const handleShareWorkout = async () => { - if (!ndk || !currentUser || !workout) return; - - // Create a workout record event - const workoutEvent = new NDKEvent(ndk); - workoutEvent.kind = 1301; // Workout Record - workoutEvent.content = notes || ''; - - // Add tags based on completed workout - const tags = [ - ['d', workout.id], // Use your UUID - ['title', workout.title], - ['type', workout.type], - ['start', workout.startTime.toString()], - ['end', workout.endTime.toString()], - ['completed', 'true'] - ]; - - // Add exercise tags - workout.exercises.forEach(exercise => { - // Format: exercise, reference, relay, weight, reps, rpe, set_type - tags.push([ - 'exercise', - `33401:${exercise.id}`, - '', - exercise.weight?.toString() || '', - exercise.reps?.toString() || '', - exercise.rpe?.toString() || '', - 'normal' - ]); - }); - - // Add template reference if used - if (workout.templateId) { - tags.push(['template', `33402:${workout.templateId}`, '']); - } - - // Add hashtags - tags.push(['t', 'workout']); - tags.push(['t', 'powrapp']); - - workoutEvent.tags = tags; - - try { - await workoutEvent.publish(); - // Show success message - showToast('Workout shared successfully!'); - } catch (error) { - console.error('Error sharing workout:', error); - showToast('Failed to share workout'); - } -}; -``` - -### Sync with Local Workouts - -```tsx -// New hook: lib/hooks/useSyncWorkouts.ts -export function useSyncWorkouts() { - const { ndk } = useNDK(); - const { currentUser } = useNDKCurrentUser(); - const { addWorkoutToDb } = useWorkouts(); - - // Sync from Nostr to local database - const syncFromNostr = useCallback(async () => { - if (!ndk || !currentUser) return; - - // Fetch user's workout records - const events = await ndk.fetchEvents({ - kinds: [1301], - authors: [currentUser.pubkey], - limit: 50 - }); - - // Process and add to local database - for (const event of events) { - try { - const workout = parseWorkoutRecord(event); - // Check if already exists - // If not, add to local database - await addWorkoutToDb({ - ...workout, - nostrEventId: event.id, - source: 'nostr' - }); - } catch (error) { - console.error('Error syncing workout:', error); - } - } - }, [ndk, currentUser, addWorkoutToDb]); - - // Sync from local to Nostr - const syncToNostr = useCallback(async (workoutId: string) => { - if (!ndk || !currentUser) return; - - // Implement based on your local database structure - // This will publish local workouts that haven't been shared yet - }, [ndk, currentUser]); - - return { - syncFromNostr, - syncToNostr - }; -} -``` - -## New Screens - -### Workout Detail Screen - -```tsx -// New screen: app/(social)/workout/[id].tsx -export default function WorkoutDetailScreen() { - const { id } = useLocalSearchParams(); - const { ndk } = useNDK(); - const [event, setEvent] = useState(null); - const [loading, setLoading] = useState(true); - - // Fetch the workout event - useEffect(() => { - if (!ndk || !id) return; - - const fetchEvent = async () => { - try { - const events = await ndk.fetchEvents({ - ids: [id as string] - }); - - if (events.size > 0) { - setEvent(Array.from(events)[0]); - } - setLoading(false); - } catch (error) { - console.error('Error fetching workout:', error); - setLoading(false); - } - }; - - fetchEvent(); - }, [ndk, id]); - - // Handle loading state - if (loading) { - return ( - - - - ); - } - - // Handle not found - if (!event) { - return ( - - Workout not found - - ); - } - - // Parse workout data - const workout = processWorkoutEvent(event); - - return ( - - - {workout.title} - - {new Date(workout.createdAt * 1000).toLocaleString()} - - - {/* Workout details */} - - - Workout Details - - - - Type: - {workout.workoutType} - - - Duration: - {formatDuration(workout.duration)} - - {/* Add more workout details */} - - - - {/* Exercise list */} - - - Exercises - - - {workout.exercises.map((exercise, index) => ( - - {exercise.name} - - {exercise.weight && ( - {exercise.weight}kg - )} - {exercise.reps && ( - {exercise.reps} reps - )} - {exercise.rpe && ( - RPE {exercise.rpe} - )} - - - ))} - - - - {/* Notes */} - {workout.content && ( - - - Notes - - - {workout.content} - - - )} - - {/* Interactions */} - - - - - - - - ); -} -``` - -### Profile Enhancements - -```tsx -// Enhancement to app/(tabs)/profile.tsx -// Add sections for user's shared workouts and templates -function UserWorkouts({ pubkey }: { pubkey: string }) { - const { ndk } = useNDK(); - const [workouts, setWorkouts] = useState([]); - const [loading, setLoading] = useState(true); - - // Use your subscription mechanism - const { events, isLoading } = useSubscribe([{ - kinds: [1301], - authors: [pubkey], - limit: 5 - }], { enabled: !!ndk }); - - useEffect(() => { - if (events.length) { - setWorkouts(events); - } - setLoading(isLoading); - }, [events, isLoading]); - - if (loading) { - return ; - } - - if (workouts.length === 0) { - return ; - } - - return ( - - {workouts.map(event => ( - - ))} - - ); -} -``` - -## Implementation Roadmap - -### Phase 1: Enhanced NDK Integration (2-3 weeks) -- Complete NDK integration with proper caching -- Implement event parsers for workout-specific event kinds -- Enhance existing subscription mechanisms - -### Phase 2: Core Social Feed Components (3-4 weeks) -- Enhance SocialPost component for different event types -- Implement detailed WorkoutPost, TemplatePost, and ExercisePost components -- Add interaction components (likes, comments, reposts) - -### Phase 3: Workout Sharing Functionality (2-3 weeks) -- Integrate with existing workout completion flow -- Implement bidirectional sync between local and Nostr data -- Add ability to share templates and exercises - -### Phase 4: Detail Screens & Advanced Features (3-4 weeks) -- Create detail screens for different content types -- Implement comments and replies functionality -- Add profile enhancements to show user's content - -### Phase 5: Polish & Optimization (2 weeks) -- Optimize performance for large feeds -- Enhance offline support -- Add media handling for workout photos -- Implement pull-to-refresh and infinite scrolling - -## \ No newline at end of file diff --git a/docs/design/Social/SocialFeedCacheImplementation.md b/docs/design/Social/SocialFeedCacheImplementation.md deleted file mode 100644 index d11115a..0000000 --- a/docs/design/Social/SocialFeedCacheImplementation.md +++ /dev/null @@ -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; // 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({ 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(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)[] = []; -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 { - 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 { - 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): Promise { - // 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 { - 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(() => ( - - Debug Info: - User: {currentUser?.pubkey?.substring(0, 8)}... - Feed Items: {entries.length} - Loading: {loading ? "Yes" : "No"} - Offline: {isOffline ? "Yes" : "No"} - Contacts: {contacts.length} - Loading Contacts: {isLoadingContacts ? "Yes" : "No"} - - - - Check Relays - - - - Force Refresh - - - -), [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. diff --git a/docs/design/Social/SocialFeedFilteringRules.md b/docs/design/Social/SocialFeedFilteringRules.md deleted file mode 100644 index 1f0ee27..0000000 --- a/docs/design/Social/SocialFeedFilteringRules.md +++ /dev/null @@ -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(() => { - 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 ; -``` - -### 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 diff --git a/docs/design/Social/UpdatedPlan.md b/docs/design/Social/UpdatedPlan.md deleted file mode 100644 index 5e15eea..0000000 --- a/docs/design/Social/UpdatedPlan.md +++ /dev/null @@ -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> { - 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(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. \ No newline at end of file diff --git a/docs/design/WorkoutCompletion.md b/docs/design/WorkoutCompletion.md deleted file mode 100644 index 24c5f2d..0000000 --- a/docs/design/WorkoutCompletion.md +++ /dev/null @@ -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; - processQueue(): Promise; - 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 { - // 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) \ No newline at end of file diff --git a/docs/design/WorkoutTab/WorkoutDataFlowSpec.md b/docs/design/WorkoutTab/WorkoutDataFlowSpec.md deleted file mode 100644 index de96c6e..0000000 --- a/docs/design/WorkoutTab/WorkoutDataFlowSpec.md +++ /dev/null @@ -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 { - 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 { - 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 { - // 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 -) { - const [lastSaveTime, setLastSaveTime] = useState(null); - const saveIntervalRef = useRef(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 { - 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 { - 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 { - try { - const activeWorkouts = await db.getAllAsync( - '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(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 { - 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; - volumeByMuscleGroup: Record; - - // 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 \ No newline at end of file diff --git a/docs/design/WorkoutTab/WorkoutTabDesignDoc.md b/docs/design/WorkoutTab/WorkoutTabDesignDoc.md deleted file mode 100644 index 3aba79c..0000000 --- a/docs/design/WorkoutTab/WorkoutTabDesignDoc.md +++ /dev/null @@ -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 } } - | { 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 \ No newline at end of file diff --git a/docs/design/WorkoutTab/WorkoutUIComponentSpec.md b/docs/design/WorkoutTab/WorkoutUIComponentSpec.md deleted file mode 100644 index 0ea6b12..0000000 --- a/docs/design/WorkoutTab/WorkoutUIComponentSpec.md +++ /dev/null @@ -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 -![Workout Header](https://via.placeholder.com/400x100) - -```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 -![Exercise Navigation](https://via.placeholder.com/400x80) - -```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 -![Set Tracker](https://via.placeholder.com/400x300) - -```tsx -interface SetTrackerProps { - sets: WorkoutSet[]; - exercise: WorkoutExercise; - onUpdateSet: (setIndex: number, data: Partial) => 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 -![Rest Timer](https://via.placeholder.com/300x300) - -```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 -![Exercise Details](https://via.placeholder.com/400x250) - -```tsx -interface ExerciseDetailsPanelProps { - exercise: WorkoutExercise; - previousPerformance?: { - date: number; - sets: WorkoutSet[]; - personalBests: Record; - }; - 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 -![Workout Controls](https://via.placeholder.com/400x120) - -```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 -![Workout Summary](https://via.placeholder.com/400x500) - -```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 \ No newline at end of file diff --git a/docs/design/nostr-exercise-nip.md b/docs/design/nostr-exercise-nip.md deleted file mode 100644 index 461c245..0000000 --- a/docs/design/nostr-exercise-nip.md +++ /dev/null @@ -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": , - "kind": 33401, - "content": "", - "tags": [ - ["d", ""], - ["title", ""], - ["format", "", "", "", ""], - ["format_units", "", "", "", ""], - ["equipment", ""], - ["difficulty", ""], - ["imeta", - "url ", - "m ", - "dim ", - "alt " - ], - ["t", ""], - ["t", ""], - ["t", ""] - ] -} -``` - -### Workout Template (kind: 33402) -Defines a complete workout plan. The `content` field contains workout notes and instructions. Workout templates can prescribe specific parameters while leaving others configurable by the user performing the workout. - -#### 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", "::", "", ...parameters matching exercise template format] -* `rounds` (optional) - Number of rounds for repeating formats -* `duration` (optional) - Total workout duration in seconds -* `interval` (optional) - Duration of each exercise portion in seconds (for timed workouts) -* `rest_between_rounds` (optional) - Rest time between rounds in seconds -* `t` (optional, repeated) - Hashtags for categorization - -``` -{ - "id": <32-bytes lowercase hex-encoded SHA-256 of the serialized event data>, - "pubkey": <32-bytes lowercase hex-encoded public key of the event creator>, - "created_at": , - "kind": 33402, - "content": "", - "tags": [ - ["d", ""], - ["title", ""], - ["type", ""], - ["rounds", ""], - ["duration", ""], - ["interval", ""], - ["rest_between_rounds", ""], - ["exercise", "::", "", "", "", "", ""], - ["exercise", "::", "", "", "", "", ""], - ["t", ""], - ["t", ""] - ] -} -``` - -### Workout Record (kind: 1301) -Records a completed workout session. The `content` field contains notes about the workout. - -#### 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", "::", "", ...parameters matching exercise template format] -* `start` (required) - Unix timestamp in seconds for workout start -* `end` (required) - Unix timestamp in seconds for workout end -* `completed` (required) - Boolean indicating if workout was completed as planned -* `rounds_completed` (optional) - Number of rounds completed -* `interval` (optional) - Duration of each exercise portion in seconds (for timed workouts) -* `template` (optional) - Reference to the workout template used, if any. Format: ["template", "::", ""] -* `pr` (optional, repeated) - Personal Record achieved during workout. Format: "::,," -* `t` (optional, repeated) - Hashtags for categorization - -``` -{ - "id": <32-bytes lowercase hex-encoded SHA-256 of the serialized event data>, - "pubkey": <32-bytes lowercase hex-encoded public key of the event creator>, - "created_at": , - "kind": 1301, - "content": "", - "tags": [ - ["d", ""], - ["title", ""], - ["type", ""], - ["rounds_completed", ""], - ["start", ""], - ["end", ""], - - ["exercise", "::", "", "", "", "", ""], - ["exercise", "::", "", "", "", "", ""], - - ["template", "::", ""], - ["pr", "::,,"], - ["completed", ""], - ["t", ""], - ["t", ""] - ] -} -``` - -## Exercise Parameters - -### 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", ""], - ["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", ""], - ["title", "20min Squat/Deadlift EMOM"], - ["type", "emom"], - ["duration", "1200"], - ["rounds", "20"], - ["interval", "30"], - - ["exercise", "33401::", "", "", "5", "7", "normal"], - ["exercise", "33401::", "", "", "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", ""], - ["title", "Leg Circuit"], - ["type", "circuit"], - ["rounds_completed", "1.5"], - ["start", "1706454000"], - ["end", "1706455800"], - - ["exercise", "33401::", "", "80", "12", "7", "normal"], - ["exercise", "33401::", "", "100", "10", "7", "normal"], - - ["exercise", "33401::", "", "80", "12", "8", "normal"], - ["exercise", "33401::", "", "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) \ No newline at end of file diff --git a/lib/hooks/useWorkoutHistory.ts b/lib/hooks/useWorkoutHistory.ts index 6b53378..078369f 100644 --- a/lib/hooks/useWorkoutHistory.ts +++ b/lib/hooks/useWorkoutHistory.ts @@ -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(() => {