From e2f2c4654e48c1988ddd84d7d3efe2e516f8ab51 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 19:24:25 +0000 Subject: [PATCH] feat(fitness): resolve POWR exercise templates (kind 33401) Render real exercise names in POWR workout cards instead of slug-derived labels by fetching the referenced kind-33401 exercise templates. - ExerciseTemplateEvent (kind 33401, addressable) with title/format/ format_units/equipment/difficulty accessors; registered in EventFactory. - WorkoutRecordEvent now implements AddressHintProvider: linkedAddressIds (deduped 33401 + 33402 coordinates) seed the gatherer so the card re-renders when a template arrives, and addressHints feed the relay-hint index with the relay.powr.build hints so the fetch reaches where POWR published the templates. - WorkoutDisplay resolves each exercise via LoadAddressableNote + observeNoteEvent, showing the template title once fetched and the slug until then. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QJwXz6CWez8r7trgHXT545 --- .../loggedIn/workouts/WorkoutDisplay.kt | 72 +++++++++++++---- quartz/plans/2026-06-20-powr-interop.md | 22 +++++- .../fitness/workout/ExerciseTemplateEvent.kt | 67 ++++++++++++++++ .../fitness/workout/TagArrayExt.kt | 9 +++ .../fitness/workout/WorkoutRecordEvent.kt | 15 +++- .../fitness/workout/tags/ExerciseSetTag.kt | 20 +++++ .../workout/tags/ExerciseTemplateTags.kt | 78 +++++++++++++++++++ .../fitness/workout/tags/TemplateTag.kt | 54 +++++++++++++ .../quartz/utils/EventFactory.kt | 2 + .../fitness/WorkoutRecordEventTest.kt | 59 ++++++++++++++ 10 files changed, 378 insertions(+), 20 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/ExerciseTemplateEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ExerciseTemplateTags.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/TemplateTag.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt index 597beed0dd..985948e8f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt @@ -37,6 +37,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -50,19 +51,23 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseGroup +import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DistanceTag import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DurationTag import com.vitorpamplona.quartz.experimental.fitness.workout.tags.Elevation import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WeightTag +import com.vitorpamplona.quartz.nip01Core.core.Address import kotlin.math.abs import kotlin.math.round @@ -372,7 +377,7 @@ fun WorkoutDisplay( WorkoutStatsGrid(secondaryStats) if (info.exerciseGroups.isNotEmpty()) { - ExerciseBreakdown(info.exerciseGroups, miles) + ExerciseBreakdown(info.exerciseGroups, miles, accountViewModel) } // Route the note (event content) through the same kind-1 pipeline: rich text with @@ -527,30 +532,67 @@ private fun WorkoutStatsGrid( private fun ExerciseBreakdown( groups: List, miles: Boolean, + accountViewModel: AccountViewModel, ) { Column( modifier = Modifier.fillMaxWidth().padding(top = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { groups.forEach { group -> - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { - Text( - text = group.displayName() ?: "—", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - modifier = Modifier.weight(1f), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = group.summaryLine(miles), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.placeholderText, - ) - } + ExerciseRow(group, miles, accountViewModel) } } } +@Composable +private fun ExerciseRow( + group: ExerciseGroup, + miles: Boolean, + accountViewModel: AccountViewModel, +) { + val fallback = group.displayName() ?: "—" + val address = remember(group.reference) { Address.parse(group.reference) } + + if (address == null) { + ExerciseRowContent(fallback, group.summaryLine(miles)) + return + } + + // Resolve the kind-33401 template to show its real title; falls back to the slug + // until the template is fetched (the workout event's relay hints drive the fetch). + LoadAddressableNote(address, accountViewModel) { templateNote -> + val name = + if (templateNote != null) { + val templateEvent by observeNoteEvent(templateNote, accountViewModel) + templateEvent?.title() ?: fallback + } else { + fallback + } + ExerciseRowContent(name, group.summaryLine(miles)) + } +} + +@Composable +private fun ExerciseRowContent( + name: String, + summary: String, +) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { + Text( + text = name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = summary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + } +} + /** Small chip showing how the workout was recorded (e.g. GPS, RUNSTR, HEALTHKIT, MANUAL). */ @Composable private fun WorkoutSourceBadge(source: String) { diff --git a/quartz/plans/2026-06-20-powr-interop.md b/quartz/plans/2026-06-20-powr-interop.md index 0afae2dc6e..9b0cbe07cd 100644 --- a/quartz/plans/2026-06-20-powr-interop.md +++ b/quartz/plans/2026-06-20-powr-interop.md @@ -41,14 +41,28 @@ the RUNSTR-canonical form (no POWR writer) — this is rendering interop. verb), `effectiveDurationSeconds()` (duration tag, else `end - start`), `exerciseGroups()`, `client()`, `workoutCompleted()`. +### Exercise-template resolution (kind 33401) +- `ExerciseTemplateEvent.kt` (kind 33401, addressable) + tag accessors + (`title`, `format`, `format_units`, `equipment`, `difficulty`); registered + in `EventFactory`. +- `WorkoutRecordEvent` implements `AddressHintProvider`: `linkedAddressIds()` + (deduped 33401 + 33402 coordinates) seed the gatherer so the card re-renders + when a template arrives; `addressHints()` feed the relay-hint index with the + `relay.powr.build` hints from each `exercise`/`template` tag — without these + the fetch would only hit the viewer's own relays and usually miss. +- `ExerciseSetTag.parseAsHint` / `TemplateTag.parseAsHint` build the hints. + ### Amethyst (`WorkoutDisplay.kt`) - Activity label/icon come from `activityType()`; coordinate never leaks. - Hero = derived duration; secondary stats add Exercises / Sets / Volume. -- New `ExerciseBreakdown` lists each exercise with its sets, e.g. +- `ExerciseBreakdown` lists each exercise with its sets, e.g. `Back Squat Bb → 3 × 8 × 84 kg`, in the viewer's preferred unit (kg↔lbs). +- Each row resolves its 33401 template via `LoadAddressableNote` + + `observeNoteEvent` (fetch-on-demand), showing the + template's real `title()` once it arrives and the slug until then. ## Not done (possible follow-ups) -- Fetch 33401 templates to show real exercise titles / equipment / media - instead of the slug, and 33402 to show the planned-vs-done template. +- Render 33402 (the planned template) to show planned-vs-done. - A POWR-dialect writer (would need exercise-template authoring — large). -- Surface `rpe` / `set_type` (warmup/drop/failure) per set in the breakdown. +- Surface `rpe` / `set_type` (warmup/drop/failure) per set, and the template's + `equipment` as a per-exercise icon. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/ExerciseTemplateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/ExerciseTemplateEvent.kt new file mode 100644 index 0000000000..78b85eee05 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/ExerciseTemplateEvent.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.fitness.workout + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DifficultyTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.EquipmentTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.FormatTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.FormatUnitsTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.TitleTag +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip50Search.SearchableEvent + +/** + * NIP-101e (draft) exercise template, kind 33401, as published by POWR. An + * addressable, reusable definition referenced from the per-set `exercise` tags + * of a [WorkoutRecordEvent] via its `33401:pubkey:d-tag` coordinate. + * + * Amethyst fetches these to show a real exercise name (and equipment) in a + * workout card instead of the d-tag slug. Parsing is lax: every tag optional. + */ +@Immutable +class ExerciseTemplateEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), content).joinToString("\n") + + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun format() = tags.firstNotNullOfOrNull(FormatTag::parse) + + fun formatUnits() = tags.firstNotNullOfOrNull(FormatUnitsTag::parse) + + fun equipment() = tags.firstNotNullOfOrNull(EquipmentTag::parse) + + fun difficulty() = tags.firstNotNullOfOrNull(DifficultyTag::parse) + + companion object { + const val KIND = 33401 + const val ALT_DESCRIPTION = "Exercise template" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/TagArrayExt.kt index 7bd4296d29..eb80669d86 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/TagArrayExt.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.experimental.fitness.workout.tags.SetsTag import com.vitorpamplona.quartz.experimental.fitness.workout.tags.SourceTag import com.vitorpamplona.quartz.experimental.fitness.workout.tags.SplitTag import com.vitorpamplona.quartz.experimental.fitness.workout.tags.StepsTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.TemplateTag import com.vitorpamplona.quartz.experimental.fitness.workout.tags.TitleTag import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WeightTag import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WorkoutCompletedTag @@ -92,6 +93,14 @@ fun TagArray.workoutCompleted() = firstNotNullOfOrNull(WorkoutCompletedTag::pars fun TagArray.exerciseSets() = mapNotNull(ExerciseSetTag::parse) +fun TagArray.exerciseSetAddressIds() = mapNotNull(ExerciseSetTag::parseAddressId) + +fun TagArray.exerciseSetHints() = mapNotNull(ExerciseSetTag::parseAsHint) + +fun TagArray.templateAddressId() = firstNotNullOfOrNull(TemplateTag::parseAddressId) + +fun TagArray.templateHint() = firstNotNullOfOrNull(TemplateTag::parseAsHint) + /** The client name from a `["client", name, ...]` tag (RUNSTR and POWR both emit this). */ fun TagArray.clientName() = firstNotNullOfOrNull { tag -> diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/WorkoutRecordEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/WorkoutRecordEvent.kt index 833705fb7b..269a67e6da 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/WorkoutRecordEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/WorkoutRecordEvent.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip31Alts.alt @@ -50,9 +51,21 @@ class WorkoutRecordEvent( sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), RootScope, - SearchableEvent { + SearchableEvent, + AddressHintProvider { override fun indexableContent() = listOfNotNull(title(), content).joinToString("\n") + // POWR / NIP-101e workouts reference kind-33401 exercise templates (and a kind-33402 + // workout template) by coordinate + relay hint, so the templates can be fetched and + // rendered with real names. RUNSTR workouts carry none of these and contribute nothing. + override fun addressHints() = + buildList { + addAll(tags.exerciseSetHints()) + tags.templateHint()?.let { add(it) } + } + + override fun linkedAddressIds() = (tags.exerciseSetAddressIds() + listOfNotNull(tags.templateAddressId())).distinct() + fun title() = tags.title() fun exercise() = tags.exercise() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ExerciseSetTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ExerciseSetTag.kt index a6c0dc1bf1..e3dfc355af 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ExerciseSetTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ExerciseSetTag.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.quartz.experimental.fitness.workout.tags import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.ensure /** @@ -77,6 +79,24 @@ class ExerciseSetTag( return parts.size == 3 && parts[0].toIntOrNull() != null && parts[1].length == 64 } + /** The referenced exercise-template coordinate, when the tag is the POWR set form. */ + fun parseAddressId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(isCoordinate(tag[1])) { return null } + return tag[1] + } + + /** The relay hint for the referenced exercise template, so it can be fetched. */ + fun parseAsHint(tag: Array): AddressHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(isCoordinate(tag[1])) { return null } + ensure(tag[2].isNotEmpty()) { return null } + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) ?: return null + return AddressHint(tag[1], relayHint) + } + fun parse(tag: Array): ExerciseSetTag? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ExerciseTemplateTags.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ExerciseTemplateTags.kt new file mode 100644 index 0000000000..55568d5268 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ExerciseTemplateTags.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.fitness.workout.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** The ordered parameter names a kind-33401 exercise expects per set, e.g. `weight reps rpe set_type`. */ +class FormatTag { + companion object { + const val TAG_NAME = "format" + + fun parse(tag: Array): List? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag.drop(1).filter { it.isNotEmpty() }.ifEmpty { null } + } + } +} + +/** The units for each [FormatTag] parameter, e.g. `kg count 0-10 enum`. */ +class FormatUnitsTag { + companion object { + const val TAG_NAME = "format_units" + + fun parse(tag: Array): List? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag.drop(1).filter { it.isNotEmpty() }.ifEmpty { null } + } + } +} + +/** Equipment used by a kind-33401 exercise: `barbell` | `dumbbell` | `bodyweight` | `machine` | `cardio`. */ +class EquipmentTag { + companion object { + const val TAG_NAME = "equipment" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + } +} + +/** Difficulty of a kind-33401 exercise: `beginner` | `intermediate` | `advanced`. */ +class DifficultyTag { + companion object { + const val TAG_NAME = "difficulty" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/TemplateTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/TemplateTag.kt new file mode 100644 index 0000000000..176fed85cf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/TemplateTag.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.fitness.workout.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.ensure + +/** + * Reference to the kind-33402 workout template a POWR session was built from: + * `["template", "33402:pubkey:d-tag", ""]`. + */ +class TemplateTag { + companion object { + const val TAG_NAME = "template" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parseAddressId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(ExerciseSetTag.isCoordinate(tag[1])) { return null } + return tag[1] + } + + fun parseAsHint(tag: Array): AddressHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(ExerciseSetTag.isCoordinate(tag[1])) { return null } + ensure(tag[2].isNotEmpty()) { return null } + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) ?: return null + return AddressHint(tag[1], relayHint) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 9c66762853..c3871af281 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent +import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent @@ -618,6 +619,7 @@ class EventFactory { WebBookmarkEvent.KIND -> WebBookmarkEvent(id, pubKey, createdAt, tags, content, sig) WikiNoteEvent.KIND -> WikiNoteEvent(id, pubKey, createdAt, tags, content, sig) WorkoutRecordEvent.KIND -> WorkoutRecordEvent(id, pubKey, createdAt, tags, content, sig) + ExerciseTemplateEvent.KIND -> ExerciseTemplateEvent(id, pubKey, createdAt, tags, content, sig) else -> factories[kind]?.build(id, pubKey, createdAt, tags, content, sig) ?: Event(id, pubKey, createdAt, kind, tags, content, sig) } as T diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/fitness/WorkoutRecordEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/fitness/WorkoutRecordEventTest.kt index ddc21269b2..3c69e15539 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/fitness/WorkoutRecordEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/fitness/WorkoutRecordEventTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.experimental.fitness +import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent import com.vitorpamplona.quartz.experimental.fitness.workout.calories import com.vitorpamplona.quartz.experimental.fitness.workout.distance @@ -194,6 +195,64 @@ class WorkoutRecordEventTest { assertNull(groups[1].totalVolumeKg()) // no weights logged } + @Test + fun exposesPowrTemplateReferencesAsHints() { + val backSquat = "33401:0bdd91e8a30d87d041eafd1871f17d426fa415c69a9a822eccad49017bac59e7:back-squat-bb" + val deadlift = "33401:0bdd91e8a30d87d041eafd1871f17d426fa415c69a9a822eccad49017bac59e7:deadlift-bb" + val template = "33402:0bdd91e8a30d87d041eafd1871f17d426fa415c69a9a822eccad49017bac59e7:novice-hyp-day2-lower-a" + val relay = "wss://relay.powr.build" + val event = + parse( + arrayOf( + arrayOf("type", "strength"), + arrayOf("template", template, relay), + arrayOf("exercise", backSquat, relay, "84", "8", "8", "normal", "1"), + arrayOf("exercise", backSquat, relay, "84", "8", "8", "normal", "2"), + arrayOf("exercise", deadlift, relay, "100", "5", "8", "normal", "1"), + ), + ) as WorkoutRecordEvent + + // Each referenced template is exposed once for fetching (deduped), incl. the 33402 template. + val linked = event.linkedAddressIds() + assertEquals(setOf(backSquat, deadlift, template), linked.toSet()) + assertEquals(3, linked.size) + + // Relay hints let Amethyst fetch the templates from where POWR published them. + val hints = event.addressHints() + assertEquals(setOf(backSquat, deadlift, template), hints.map { it.addressId }.toSet()) + assertTrue(hints.all { it.relay.url == "wss://relay.powr.build/" }) + } + + @Test + fun parsesExerciseTemplate() { + val event: Event = + EventFactory.create( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1718000000L, + kind = ExerciseTemplateEvent.KIND, + tags = + arrayOf( + arrayOf("d", "back-squat-bb"), + arrayOf("title", "Back Squat (Barbell)"), + arrayOf("format", "weight", "reps", "rpe", "set_type"), + arrayOf("format_units", "kg", "count", "0-10", "enum"), + arrayOf("equipment", "barbell"), + arrayOf("difficulty", "intermediate"), + ), + content = "Keep a neutral spine.", + sig = "c".repeat(128), + ) + + assertTrue(event is ExerciseTemplateEvent) + assertEquals("back-squat-bb", event.dTag()) + assertEquals("Back Squat (Barbell)", event.title()) + assertEquals(listOf("weight", "reps", "rpe", "set_type"), event.format()) + assertEquals(listOf("kg", "count", "0-10", "enum"), event.formatUnits()) + assertEquals("barbell", event.equipment()) + assertEquals("intermediate", event.difficulty()) + } + @Test fun slugToTitlePrettifiesDTags() { assertEquals("Back Squat Bb", slugToTitle("back-squat-bb"))