From 98961e352eedb8f9854031617a6ab59fe77296c9 Mon Sep 17 00:00:00 2001 From: mroxso <24775431+mroxso@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:54:44 +0200 Subject: [PATCH] Add NIP-101e workout publishing and discovery --- NIP.md | 6 ++ src/apps/workouts/index.tsx | 164 ++++++++++++++++++++++++++++++++++++ src/hooks/useFollows.ts | 10 ++- src/lib/workouts.ts | 82 ++++++++++++++++++ src/os/registry.ts | 12 ++- 5 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 src/apps/workouts/index.tsx create mode 100644 src/lib/workouts.ts diff --git a/NIP.md b/NIP.md index b52b94d..86ccfad 100644 --- a/NIP.md +++ b/NIP.md @@ -21,6 +21,12 @@ protocols: ## Adopted third-party kinds +- **Kind `1301` (NIP-101e / deployed RUNSTR fitness dialect)** — the Workouts app + publishes regular workout records using the interoperable `exercise`, `duration`, + `distance`, `elevation_gain`, `workout_start_time`, heart-rate, `cadence`, `source` + and `t` tags. It includes the required human-readable `alt` tag. No custom schema + extensions are introduced. + - **Kind `777` ("Spell")** — a third-party draft NIP from the [Grimoire](https://github.com/purrgrammer/grimoire) client, adopted as-is for interop. See `docs/apps.md` ("Spells are a third-party kind") and `src/hooks/useSpells.ts`. diff --git a/src/apps/workouts/index.tsx b/src/apps/workouts/index.tsx new file mode 100644 index 0000000..1bbdf78 --- /dev/null +++ b/src/apps/workouts/index.tsx @@ -0,0 +1,164 @@ +import { useEffect, useState, type FormEvent } from 'react'; +import { useNostr } from '@nostrify/react'; +import { useQuery } from '@tanstack/react-query'; +import { Bike, Clock3, Flame, HeartPulse, Loader2, MapPinned, Mountain, Plus, RotateCw, Users } from 'lucide-react'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { AuthorLine } from '@/components/nostr/AuthorLine'; +import { LoginRequired } from '@/components/nostr/LoginRequired'; +import { AppBody, AppLayout, AppToolbar, EmptyState } from '@/components/os/AppChrome'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Textarea } from '@/components/ui/textarea'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useMyFollows } from '@/hooks/useFollows'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useToast } from '@/hooks/useToast'; +import { cn } from '@/lib/utils'; +import { formatDuration, isWorkout, parseWorkout, WORKOUT_KIND } from '@/lib/workouts'; +import type { AppProps } from '@/os/types'; + +type Scope = 'public' | 'following'; +type Activity = 'running' | 'cycling' | 'walking' | 'hiking' | 'swimming' | 'rowing' | 'strength' | 'yoga'; + +const activities: { value: Activity; label: string }[] = [ + { value: 'running', label: 'Running' }, { value: 'cycling', label: 'Cycling' }, + { value: 'walking', label: 'Walking' }, { value: 'hiking', label: 'Hiking' }, + { value: 'swimming', label: 'Swimming' }, { value: 'rowing', label: 'Rowing' }, + { value: 'strength', label: 'Strength' }, { value: 'yoga', label: 'Yoga' }, +]; + +function useWorkouts(scope: Scope, authors: string[] | undefined, topic: string) { + const { nostr } = useNostr(); + const canQuery = scope === 'public' || Boolean(authors?.length); + return useQuery({ + queryKey: ['nostr', 'workouts', scope, authors?.join(',') ?? '', topic], + enabled: canQuery, + queryFn: async ({ signal }) => { + const events = await nostr.query([{ + kinds: [WORKOUT_KIND], + ...(scope === 'following' ? { authors } : {}), + ...(topic ? { '#t': [topic] } : {}), + since: Math.floor(Date.now() / 1000) - 180 * 24 * 60 * 60, + limit: 80, + }], { signal: AbortSignal.any([signal, AbortSignal.timeout(6000)]) }); + return events.filter(isWorkout).sort((a, b) => b.created_at - a.created_at); + }, + staleTime: 30_000, + }); +} + +export default function WorkoutsApp({ setTitle }: AppProps) { + const { user } = useCurrentUser(); + const follows = useMyFollows(); + const [scope, setScope] = useState('public'); + const [topic, setTopic] = useState(''); + const [composerOpen, setComposerOpen] = useState(false); + const effectiveScope = user ? scope : 'public'; + const workouts = useWorkouts(effectiveScope, follows.data, topic.trim().replace(/^#/, '').toLowerCase()); + + useEffect(() => setTitle('Workouts'), [setTitle]); + + return + +
+ setScope('public')}>Public + setScope('following')} disabled={!user} title={!user ? 'Sign in to see accounts you follow' : undefined}> Following +
+ +
+ +
+ setTopic(event.target.value.replace(/^#/, ''))} aria-label="Filter workouts by topic" placeholder="Filter #topic" className="h-8 max-w-52 text-sm" /> + {workouts.isFetching && } +
+ {effectiveScope === 'following' && follows.isLoading ? : effectiveScope === 'following' && !follows.data?.length ? ( + + ) : workouts.isLoading ? : workouts.isError ? ( + workouts.refetch()}> Try again} /> + ) : workouts.data?.length ?
{workouts.data.map((event) => )}
: ( + + )} +
+ void workouts.refetch()} /> +
; +} + +function ScopeButton({ active, children, ...props }: React.ComponentProps & { active: boolean }) { + return ; +} + +function WorkoutCard({ event }: { event: NostrEvent }) { + const workout = parseWorkout(event); + if (!workout) return null; + const date = workout.startedAt ? new Date(workout.startedAt * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : undefined; + const metrics = [ + workout.duration !== undefined && { icon: Clock3, text: formatDuration(workout.duration) }, + workout.distance && { icon: MapPinned, text: `${workout.distance.value} ${workout.distance.unit}` }, + workout.elevationGain && { icon: Mountain, text: `${workout.elevationGain.value} ${workout.elevationGain.unit}` }, + workout.calories !== undefined && { icon: Flame, text: `${workout.calories} kcal` }, + workout.averageHeartRate !== undefined && { icon: HeartPulse, text: `${workout.averageHeartRate} bpm avg` }, + ].filter(Boolean) as { icon: typeof Clock3; text: string }[]; + return
+
+
+

{workout.activity}

{date &&

{date}

}
+
+
+ {event.content.trim() &&

{event.content}

} + {metrics.length > 0 &&
{metrics.map(({ icon: Icon, text }) => {text})}
} + {(workout.maximumHeartRate !== undefined || workout.cadence !== undefined || workout.source || workout.topics.length > 0) &&
+ {workout.maximumHeartRate !== undefined && Max {workout.maximumHeartRate} bpm}{workout.cadence !== undefined && {workout.cadence} rpm}{workout.source && via {workout.source}}{workout.topics.map((topic) => #{topic})} +
} +
+
; +} + +interface WorkoutForm { activity: Activity; start: string; end: string; durationMinutes: string; distance: string; distanceUnit: 'km' | 'mi' | 'm'; elevation: string; calories: string; averageHeartRate: string; maximumHeartRate: string; cadence: string; caption: string; source: string; topics: string; } +const emptyForm: WorkoutForm = { activity: 'running', start: '', end: '', durationMinutes: '', distance: '', distanceUnit: 'km', elevation: '', calories: '', averageHeartRate: '', maximumHeartRate: '', cadence: '', caption: '', source: 'manual', topics: '' }; + +function WorkoutDialog({ open, onOpenChange, onPublished }: { open: boolean; onOpenChange: (open: boolean) => void; onPublished: () => void }) { + const { user } = useCurrentUser(); + const publish = useNostrPublish(); + const { toast } = useToast(); + const [form, setForm] = useState(emptyForm); + const [error, setError] = useState(null); + const set = (key: K, value: WorkoutForm[K]) => setForm((current) => ({ ...current, [key]: value })); + const submit = async (event: FormEvent) => { + event.preventDefault(); setError(null); + const start = form.start ? Math.floor(new Date(form.start).getTime() / 1000) : undefined; + const end = form.end ? Math.floor(new Date(form.end).getTime() / 1000) : undefined; + const duration = form.durationMinutes ? Number(form.durationMinutes) * 60 : end && start ? end - start : undefined; + const inRange = (value: string, maximum: number) => value === '' || (Number.isFinite(Number(value)) && Number(value) >= 0 && Number(value) <= maximum); + if ((form.start && !start) || (form.end && !end) || (start && end && end <= start) || !duration || duration <= 0 || !inRange(form.distance, 100000) || !inRange(form.elevation, 30000) || !inRange(form.calories, 100000) || !inRange(form.averageHeartRate, 300) || !inRange(form.maximumHeartRate, 300) || !inRange(form.cadence, 300)) { setError('Enter a valid duration or start and end time. Check that metric values are in a realistic range.'); return; } + if (form.maximumHeartRate && form.averageHeartRate && Number(form.maximumHeartRate) < Number(form.averageHeartRate)) { setError('Maximum heart rate cannot be lower than average heart rate.'); return; } + const numberTag = (name: string, value: string) => value === '' ? [] : [[name, String(Number(value))]]; + const topics = [...new Set(form.topics.split(/[\s,]+/).map((topic) => topic.replace(/^#/, '').toLowerCase()).filter((topic) => /^[a-z0-9][a-z0-9-_]{0,63}$/.test(topic)))]; + const activityLabel = activities.find((activity) => activity.value === form.activity)?.label ?? form.activity; + const tags: string[][] = [ + ['exercise', form.activity], ['duration', formatDuration(duration)], ...(start ? [['workout_start_time', String(start)]] : []), ...(end ? [['end', String(end)]] : []), + ...(form.distance ? [['distance', String(Number(form.distance)), form.distanceUnit]] : []), ...(form.elevation ? [['elevation_gain', String(Number(form.elevation)), 'm']] : []), + ...numberTag('calories', form.calories), ...numberTag('avg_heart_rate', form.averageHeartRate), ...numberTag('max_heart_rate', form.maximumHeartRate), ...numberTag('cadence', form.cadence), + ...(form.source.trim() ? [['source', form.source.trim().slice(0, 100)]] : []), ...topics.map((topic) => ['t', topic]), ['alt', `${activityLabel} workout, ${formatDuration(duration)}${form.distance ? `, ${form.distance} ${form.distanceUnit}` : ''}`], + ]; + try { await publish.mutateAsync({ kind: WORKOUT_KIND, content: form.caption.trim(), tags }); setForm(emptyForm); onOpenChange(false); onPublished(); toast({ title: 'Workout published' }); } + catch (reason) { setError(reason instanceof Error ? reason.message : 'Publishing failed. Your workout is still here to retry.'); } + }; + return Record a workoutPublish a manual, interoperable Nostr workout. No device or GPS data is claimed.{!user ? :
void submit(event)}> +
set('durationMinutes', event.target.value)} placeholder="45" />
+

Or supply both start and end time; duration is calculated automatically.

+
set('start', event.target.value)} /> set('end', event.target.value)} />
+
set('distance', event.target.value)} placeholder="5" />
set('elevation', event.target.value)} />
+
set('calories', event.target.value)} /> set('cadence', event.target.value)} /> set('averageHeartRate', event.target.value)} /> set('maximumHeartRate', event.target.value)} />
+