From dca4ef5b33b34df50e160f7dfb293a254fbfb41d Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 1 Mar 2025 13:43:42 -0500 Subject: [PATCH] nip update and WIP social tab --- app/(tabs)/_layout.tsx | 2 +- app/(tabs)/social.tsx | 32 ---- app/(tabs)/social/_layout.tsx | 63 +++++++ app/(tabs)/social/following.tsx | 90 ++++++++++ app/(tabs)/social/global.tsx | 114 ++++++++++++ app/(tabs)/social/powr.tsx | 105 +++++++++++ app/(workout)/create.tsx | 100 ++--------- components/UserAvatar.tsx | 2 +- components/social/EmptyFeed.tsx | 25 +++ components/social/NostrLoginPrompt.tsx | 40 +++++ components/social/SocialPost.tsx | 237 +++++++++++++++++++++++++ components/workout/SetInput.tsx | 39 ++-- docs/design/nostr-exercise-nip.md | 21 ++- 13 files changed, 733 insertions(+), 137 deletions(-) delete mode 100644 app/(tabs)/social.tsx create mode 100644 app/(tabs)/social/_layout.tsx create mode 100644 app/(tabs)/social/following.tsx create mode 100644 app/(tabs)/social/global.tsx create mode 100644 app/(tabs)/social/powr.tsx create mode 100644 components/social/EmptyFeed.tsx create mode 100644 components/social/NostrLoginPrompt.tsx create mode 100644 components/social/SocialPost.tsx diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 5182a81..469e934 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -38,7 +38,7 @@ export default function TabLayout() { elevation: 0, shadowOpacity: 0, }, - tabBarActiveTintColor: theme.colors.tabActive, + tabBarActiveTintColor: theme.colors.primary, tabBarInactiveTintColor: theme.colors.tabInactive, tabBarShowLabel: true, tabBarLabelStyle: { diff --git a/app/(tabs)/social.tsx b/app/(tabs)/social.tsx deleted file mode 100644 index 38a2075..0000000 --- a/app/(tabs)/social.tsx +++ /dev/null @@ -1,32 +0,0 @@ -// app/(tabs)/social.tsx -import { View } from 'react-native'; -import { Text } from '@/components/ui/text'; -import { Button } from '@/components/ui/button'; -import { Bell } from 'lucide-react-native'; -import Header from '@/components/Header'; -import { TabScreen } from '@/components/layout/TabScreen'; - -export default function SocialScreen() { - return ( - -
console.log('Open notifications')} - > - - - - - - } - /> - - Social Screen - - - ); -} \ No newline at end of file diff --git a/app/(tabs)/social/_layout.tsx b/app/(tabs)/social/_layout.tsx new file mode 100644 index 0000000..21cfe7f --- /dev/null +++ b/app/(tabs)/social/_layout.tsx @@ -0,0 +1,63 @@ +// app/(tabs)/social/_layout.tsx +import React from 'react'; +import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'; +import FollowingScreen from './following'; +import PowerScreen from './powr'; +import GlobalScreen from './global'; +import Header from '@/components/Header'; +import { useTheme } from '@react-navigation/native'; +import type { CustomTheme } from '@/lib/theme'; +import { TabScreen } from '@/components/layout/TabScreen'; + +const Tab = createMaterialTopTabNavigator(); + +export default function SocialLayout() { + const theme = useTheme() as CustomTheme; + + return ( + +
+ + + + + + + + ); +} \ No newline at end of file diff --git a/app/(tabs)/social/following.tsx b/app/(tabs)/social/following.tsx new file mode 100644 index 0000000..2068d52 --- /dev/null +++ b/app/(tabs)/social/following.tsx @@ -0,0 +1,90 @@ +// app/(tabs)/social/following.tsx +import React from 'react'; +import { View, ScrollView, RefreshControl } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { Button } from '@/components/ui/button'; +import SocialPost from '@/components/social/SocialPost'; +import { useNDKCurrentUser } from '@/lib/hooks/useNDK'; +import NostrLoginPrompt from '@/components/social/NostrLoginPrompt'; +import EmptyFeed from '@/components/social/EmptyFeed'; + +// Sample mock data for posts +const MOCK_POSTS = [ + { + id: '1', + author: { + name: 'Jane Fitness', + handle: 'janefitness', + avatar: 'https://randomuser.me/api/portraits/women/32.jpg', + pubkey: 'npub1q8s7vw...' + }, + content: 'Just crushed this leg workout! New PR on squat ๐Ÿ’ช #fitness #legday', + createdAt: new Date(Date.now() - 3600000 * 2), // 2 hours ago + metrics: { + likes: 24, + comments: 5, + reposts: 3 + }, + workout: { + title: 'Leg Day Destroyer', + exercises: ['Squats', 'Lunges', 'Leg Press'], + duration: 45 + } + }, + { + id: '2', + author: { + name: 'Mark Strong', + handle: 'markstrong', + avatar: 'https://randomuser.me/api/portraits/men/45.jpg', + pubkey: 'npub1z92r3...' + }, + content: 'Morning cardio session complete! 5K run in 22 minutes. Starting the day right! #running #cardio', + createdAt: new Date(Date.now() - 3600000 * 5), // 5 hours ago + metrics: { + likes: 18, + comments: 2, + reposts: 1 + }, + workout: { + title: 'Morning Cardio', + exercises: ['Running'], + duration: 22 + } + } +]; + +export default function FollowingScreen() { + const { isAuthenticated } = useNDKCurrentUser(); + const [refreshing, setRefreshing] = React.useState(false); + const [posts, setPosts] = React.useState(MOCK_POSTS); + + const onRefresh = React.useCallback(() => { + setRefreshing(true); + // Simulate fetch - in a real app, this would be a call to load posts + setTimeout(() => { + setRefreshing(false); + }, 1500); + }, []); + + if (!isAuthenticated) { + return ; + } + + if (posts.length === 0) { + return ; + } + + return ( + + } + > + {posts.map(post => ( + + ))} + + ); +} \ No newline at end of file diff --git a/app/(tabs)/social/global.tsx b/app/(tabs)/social/global.tsx new file mode 100644 index 0000000..d97c719 --- /dev/null +++ b/app/(tabs)/social/global.tsx @@ -0,0 +1,114 @@ +// app/(tabs)/social/global.tsx +import React from 'react'; +import { View, ScrollView, RefreshControl } from 'react-native'; +import { Text } from '@/components/ui/text'; +import SocialPost from '@/components/social/SocialPost'; + +// Sample mock data for global feed - more diverse content +const GLOBAL_POSTS = [ + { + id: '1', + author: { + name: 'Strength Coach', + handle: 'strengthcoach', + avatar: 'https://randomuser.me/api/portraits/men/32.jpg', + pubkey: 'npub1q8s7vw...' + }, + content: 'Form tip: When squatting, make sure your knees track in line with your toes. This helps protect your knees and ensures proper muscle engagement. #squatform #technique', + createdAt: new Date(Date.now() - 3600000 * 3), // 3 hours ago + metrics: { + likes: 132, + comments: 28, + reposts: 45 + } + }, + { + id: '2', + author: { + name: 'Marathon Runner', + handle: 'marathoner', + avatar: 'https://randomuser.me/api/portraits/women/28.jpg', + pubkey: 'npub1z92r3...' + }, + content: 'Just finished my 10th marathon this year! Boston Marathon was an amazing experience. Thanks for all the support! #marathon #running #endurance', + createdAt: new Date(Date.now() - 3600000 * 14), // 14 hours ago + metrics: { + likes: 214, + comments: 38, + reposts: 22 + }, + workout: { + title: 'Boston Marathon', + exercises: ['Running'], + duration: 218 // 3:38 marathon + } + }, + { + id: '3', + author: { + name: 'PowerLifter', + handle: 'liftsheavy', + avatar: 'https://randomuser.me/api/portraits/men/85.jpg', + pubkey: 'npub1xne8q...' + }, + content: 'NEW PR ALERT! ๐Ÿ’ช Just hit 500lbs on deadlift after 3 years of consistent training. Proof that patience and consistency always win. #powerlifting #deadlift #pr', + createdAt: new Date(Date.now() - 3600000 * 36), // 36 hours ago + metrics: { + likes: 347, + comments: 72, + reposts: 41 + }, + workout: { + title: 'Deadlift Day', + exercises: ['Deadlifts', 'Back Accessories'], + duration: 65 + } + }, + { + id: '4', + author: { + name: 'Yoga Master', + handle: 'yogalife', + avatar: 'https://randomuser.me/api/portraits/women/50.jpg', + pubkey: 'npub1r72df...' + }, + content: 'Morning yoga flow to start the day centered and grounded. Remember that flexibility isn\'t just physical - it\'s mental too. #yoga #morningroutine #wellness', + createdAt: new Date(Date.now() - 3600000 * 48), // 2 days ago + metrics: { + likes: 183, + comments: 12, + reposts: 25 + }, + workout: { + title: 'Morning Yoga Flow', + exercises: ['Yoga'], + duration: 30 + } + } +]; + +export default function GlobalScreen() { + const [refreshing, setRefreshing] = React.useState(false); + const [posts, setPosts] = React.useState(GLOBAL_POSTS); + + const onRefresh = React.useCallback(() => { + setRefreshing(true); + // Simulate fetch + setTimeout(() => { + setRefreshing(false); + }, 1500); + }, []); + + return ( + + } + > + {posts.map(post => ( + + ))} + + ); +} \ No newline at end of file diff --git a/app/(tabs)/social/powr.tsx b/app/(tabs)/social/powr.tsx new file mode 100644 index 0000000..295d2ba --- /dev/null +++ b/app/(tabs)/social/powr.tsx @@ -0,0 +1,105 @@ +// app/(tabs)/social/powr.tsx +import React from 'react'; +import { View, ScrollView, RefreshControl } from 'react-native'; +import { Text } from '@/components/ui/text'; +import SocialPost from '@/components/social/SocialPost'; +import { Zap } from 'lucide-react-native'; + +// Sample mock data for posts from POWR team/recommendations +const POWR_POSTS = [ + { + id: '1', + author: { + name: 'POWR Team', + handle: 'powrteam', + avatar: 'https://i.pravatar.cc/150?img=12', + pubkey: 'npub1q8s7vw...', + verified: true + }, + content: 'Welcome to the new social feed in POWR! Share your workouts, follow friends and get inspired by the global fitness community. #powrapp', + createdAt: new Date(Date.now() - 3600000 * 48), // 2 days ago + metrics: { + likes: 158, + comments: 42, + reposts: 27 + }, + featured: true + }, + { + id: '2', + author: { + name: 'Sarah Trainer', + handle: 'sarahfitness', + avatar: 'https://randomuser.me/api/portraits/women/44.jpg', + pubkey: 'npub1z92r3...' + }, + content: 'Just released my new 30-day strength program! Check it out in my profile and let me know what you think. #strengthtraining #30daychallenge', + createdAt: new Date(Date.now() - 3600000 * 24), // 1 day ago + metrics: { + likes: 84, + comments: 15, + reposts: 12 + }, + workout: { + title: '30-Day Strength Builder', + exercises: ['Full Program'], + isProgramPreview: true + } + }, + { + id: '3', + author: { + name: 'POWR Team', + handle: 'powrteam', + avatar: 'https://i.pravatar.cc/150?img=12', + pubkey: 'npub1q8s7vw...', + verified: true + }, + content: 'New features alert! You can now track your rest periods automatically and share your PRs directly to your feed. Update to the latest version to try it out!', + createdAt: new Date(Date.now() - 3600000 * 72), // 3 days ago + metrics: { + likes: 207, + comments: 31, + reposts: 45 + }, + featured: true + } +]; + +export default function PowerScreen() { + const [refreshing, setRefreshing] = React.useState(false); + const [posts, setPosts] = React.useState(POWR_POSTS); + + const onRefresh = React.useCallback(() => { + setRefreshing(true); + // Simulate fetch + setTimeout(() => { + setRefreshing(false); + }, 1500); + }, []); + + return ( + + } + > + {/* POWR Welcome Section */} + + + + POWR Community + + + Official updates, featured content, and community highlights from the POWR team. + + + + {/* Posts */} + {posts.map(post => ( + + ))} + + ); +} \ No newline at end of file diff --git a/app/(workout)/create.tsx b/app/(workout)/create.tsx index 0990379..c7ec420 100644 --- a/app/(workout)/create.tsx +++ b/app/(workout)/create.tsx @@ -1,6 +1,6 @@ // app/(workout)/create.tsx import React, { useState, useEffect } from 'react'; -import { View, ScrollView, StyleSheet, TextInput } from 'react-native'; +import { View, ScrollView, StyleSheet } from 'react-native'; import { router, useNavigation } from 'expo-router'; import { TabScreen } from '@/components/layout/TabScreen'; import { Text } from '@/components/ui/text'; @@ -16,7 +16,7 @@ import { AlertDialogCancel } from '@/components/ui/alert-dialog'; import { useWorkoutStore } from '@/stores/workoutStore'; -import { Plus, Pause, Play, MoreHorizontal, CheckCircle2, Dumbbell, ChevronLeft } from 'lucide-react-native'; +import { Plus, Pause, Play, MoreHorizontal, Dumbbell, ChevronLeft } from 'lucide-react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import EditableText from '@/components/EditableText'; import { cn } from '@/lib/utils'; @@ -25,6 +25,7 @@ import { WorkoutSet } from '@/types/workout'; import { formatTime } from '@/utils/formatTime'; import { ParamListBase } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import SetInput from '@/components/workout/SetInput'; // Define styles outside of component const styles = StyleSheet.create({ @@ -116,19 +117,6 @@ export default function CreateWorkoutScreen() { updateSet(exerciseIndex, exercise.sets.length, newSet); }; - // Handler for completing a set - const handleCompleteSet = (exerciseIndex: number, setIndex: number) => { - if (!activeWorkout) return; - - const exercise = activeWorkout.exercises[exerciseIndex]; - const set = exercise.sets[setIndex]; - - updateSet(exerciseIndex, setIndex, { - ...set, - isCompleted: !set.isCompleted - }); - }; - // Show empty state when no workout is active if (!activeWorkout) { return ( @@ -280,7 +268,7 @@ export default function CreateWorkoutScreen() { > {/* Exercise Header */} - + {exercise.title} - + exerciseIndex={exerciseIndex} + setIndex={setIndex} + setNumber={setIndex + 1} + weight={set.weight} + reps={set.reps} + isCompleted={set.isCompleted} + previousSet={previousSet} + /> ); })} diff --git a/components/UserAvatar.tsx b/components/UserAvatar.tsx index f30ff0f..9603339 100644 --- a/components/UserAvatar.tsx +++ b/components/UserAvatar.tsx @@ -32,7 +32,7 @@ const UserAvatar = ({ }, [uri]); // Log the URI for debugging - console.log("Avatar URI:", uri); + // console.log("Avatar URI:", uri); const containerStyles = cn( { diff --git a/components/social/EmptyFeed.tsx b/components/social/EmptyFeed.tsx new file mode 100644 index 0000000..5f3c0e3 --- /dev/null +++ b/components/social/EmptyFeed.tsx @@ -0,0 +1,25 @@ +// components/social/EmptyFeed.tsx +import React from 'react'; +import { View, ScrollView } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { Users } from 'lucide-react-native'; + +interface EmptyFeedProps { + message: string; +} + +export default function EmptyFeed({ message }: EmptyFeedProps) { + return ( + + + + + No posts yet + + + {message} + + + + ); +} \ No newline at end of file diff --git a/components/social/NostrLoginPrompt.tsx b/components/social/NostrLoginPrompt.tsx new file mode 100644 index 0000000..30d95c4 --- /dev/null +++ b/components/social/NostrLoginPrompt.tsx @@ -0,0 +1,40 @@ +// components/social/NostrLoginPrompt.tsx +import React, { useState } from 'react'; +import { View } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { Button } from '@/components/ui/button'; +import { Zap, Key } from 'lucide-react-native'; +import NostrLoginSheet from '@/components/sheets/NostrLoginSheet'; + +interface NostrLoginPromptProps { + message: string; +} + +export default function NostrLoginPrompt({ message }: NostrLoginPromptProps) { + const [isLoginSheetOpen, setIsLoginSheetOpen] = useState(false); + + return ( + + + + Connect with Nostr + + {message} + + + + + {/* NostrLoginSheet */} + setIsLoginSheetOpen(false)} + /> + + ); +} \ No newline at end of file diff --git a/components/social/SocialPost.tsx b/components/social/SocialPost.tsx new file mode 100644 index 0000000..7f3117f --- /dev/null +++ b/components/social/SocialPost.tsx @@ -0,0 +1,237 @@ +// components/social/SocialPost.tsx +import React from 'react'; +import { View, TouchableOpacity } from 'react-native'; +import { Text } from '@/components/ui/text'; +import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { Heart, MessageSquare, Repeat, Share2, Zap, Clock, Dumbbell, CheckCircle } from 'lucide-react-native'; +import { cn } from '@/lib/utils'; + +// Type definition for a social post +interface PostAuthor { + name: string; + handle: string; + avatar: string; + pubkey: string; + verified?: boolean; +} + +interface WorkoutInfo { + title: string; + exercises: string[]; + duration?: number; + isProgramPreview?: boolean; +} + +interface PostMetrics { + likes: number; + comments: number; + reposts: number; + zaps?: number; +} + +interface SocialPostProps { + post: { + id: string; + author: PostAuthor; + content: string; + createdAt: Date; + metrics: PostMetrics; + workout?: WorkoutInfo; + featured?: boolean; + }; +} + +export default function SocialPost({ post }: SocialPostProps) { + const [liked, setLiked] = React.useState(false); + const [reposted, setReposted] = React.useState(false); + + const formatDate = (date: Date) => { + const now = new Date(); + const diffInHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60)); + + if (diffInHours < 1) { + return 'now'; + } else if (diffInHours < 24) { + return `${diffInHours}h`; + } else { + const diffInDays = Math.floor(diffInHours / 24); + return `${diffInDays}d`; + } + }; + + const formatDuration = (minutes: number) => { + if (minutes < 60) { + return `${minutes}m`; + } else { + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; + } + }; + + return ( + + {/* Author info */} + + + + + {post.author.name.substring(0, 2)} + + + + + + {post.author.name} + {post.author.verified && ( + + )} + + @{post.author.handle} ยท {formatDate(post.createdAt)} + + + + {post.author.pubkey.substring(0, 10)}... + + + + + {/* Post content */} + {post.content} + + {/* Workout card - slimmer version */} + {post.workout && ( + + + + + {post.workout.title} + + + + + {post.workout.isProgramPreview ? ( + + Program + + ) : ( + post.workout.exercises.map((ex, index) => ( + + {ex} + + )) + )} + + + {post.workout.duration && ( + + + + {formatDuration(post.workout.duration)} + + + )} + + + + )} + + {/* Action buttons */} + + {/* Comment button */} + + + {post.metrics.comments > 0 && ( + + {post.metrics.comments} + + )} + + + {/* Repost button */} + setReposted(!reposted)} + > + + {(reposted || post.metrics.reposts > 0) && ( + + {reposted ? post.metrics.reposts + 1 : post.metrics.reposts} + + )} + + + {/* Like button */} + setLiked(!liked)} + > + + {(liked || post.metrics.likes > 0) && ( + + {liked ? post.metrics.likes + 1 : post.metrics.likes} + + )} + + + {/* Zap button */} + + + {post.metrics.zaps && post.metrics.zaps > 0 && ( + + {post.metrics.zaps} + + )} + + + {/* Share button */} + + + + + + ); +} \ No newline at end of file diff --git a/components/workout/SetInput.tsx b/components/workout/SetInput.tsx index eeec03f..4509384 100644 --- a/components/workout/SetInput.tsx +++ b/components/workout/SetInput.tsx @@ -2,10 +2,10 @@ import React, { useState, useCallback } from 'react'; import { View, TextInput, TouchableOpacity } from 'react-native'; import { Text } from '@/components/ui/text'; -import { Button } from '@/components/ui/button'; -import { Check } from 'lucide-react-native'; +import { Feather } from '@expo/vector-icons'; import { cn } from '@/lib/utils'; import { useWorkoutStore } from '@/stores/workoutStore'; +import { useColorScheme } from '@/lib/useColorScheme'; import type { WorkoutSet } from '@/types/workout'; import debounce from 'lodash/debounce'; @@ -28,6 +28,9 @@ export default function SetInput({ isCompleted = false, previousSet }: SetInputProps) { + // Get theme colors + const { isDarkColorScheme } = useColorScheme(); + // Local state for controlled inputs const [weightValue, setWeightValue] = useState(weight.toString()); const [repsValue, setRepsValue] = useState(reps.toString()); @@ -72,7 +75,7 @@ export default function SetInput({ const handleCompleteSet = useCallback(() => { completeSet(exerciseIndex, setIndex); - }, [exerciseIndex, setIndex]); + }, [exerciseIndex, setIndex, completeSet]); const handleCopyPreviousWeight = useCallback(() => { if (previousSet?.weight) { @@ -86,6 +89,13 @@ export default function SetInput({ } }, [previousSet]); + // Get the appropriate colors based on theme variables + // Using the --purple and --muted-foreground from your theme + const purpleColor = 'hsl(261, 90%, 66%)'; // --purple from your constants + const mutedForegroundColor = isDarkColorScheme + ? 'hsl(240, 5%, 64.9%)' // --muted-foreground dark + : 'hsl(240, 3.8%, 46.1%)'; // --muted-foreground light + return ( @@ -139,26 +149,23 @@ export default function SetInput({ onChangeText={handleRepsChange} keyboardType="number-pad" placeholder="0" - placeholderTextColor="text-muted-foreground" + placeholderTextColor={mutedForegroundColor} returnKeyType="done" selectTextOnFocus /> - {/* Complete Button */} - + ); } \ No newline at end of file diff --git a/docs/design/nostr-exercise-nip.md b/docs/design/nostr-exercise-nip.md index 8cc9432..4ceb744 100644 --- a/docs/design/nostr-exercise-nip.md +++ b/docs/design/nostr-exercise-nip.md @@ -6,6 +6,22 @@ This specification defines workout events for fitness tracking. These workout ev ## 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. @@ -37,7 +53,7 @@ Defines a complete workout plan. The `content` field contains workout notes and * `rest_between_rounds` - Rest time between rounds in seconds * `t` - Hashtags for categorization -### Workout Record (kind: 33403) +### Workout Record (kind: 1301) Records a completed workout session. The `content` field contains notes about the workout. #### Required Tags @@ -52,6 +68,7 @@ Records a completed workout session. The `content` field contains notes about th #### Optional Tags * `rounds_completed` - Number of rounds completed * `interval` - Duration of each exercise portion in seconds (for timed workouts) +* `template` - Reference to the workout template used, if any. Format: ["template", "33402::", ""] * `pr` - Personal Record achieved during workout. Format: "kind:pubkey:d-tag,metric,value". Used to track when a user achieves their best performance for a given exercise and metric (e.g., heaviest weight lifted, most reps completed, fastest time) * `t` - Hashtags for categorization @@ -154,7 +171,7 @@ Sets where technical failure was reached before completing prescribed reps. Thes ### Circuit Workout Record ```json { - "kind": 33403, + "kind": 1301, "content": "Completed first round as prescribed. Second round showed form deterioration on deadlifts.", "tags": [ ["d", "workout-20250128"],