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<ExerciseTemplateEvent>, showing the template title once
  fetched and the slug until then.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJwXz6CWez8r7trgHXT545
This commit is contained in:
Claude
2026-06-20 19:24:25 +00:00
parent b9ac370f4f
commit e2f2c4654e
10 changed files with 378 additions and 20 deletions
@@ -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<ExerciseGroup>,
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<ExerciseTemplateEvent>(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) {
+18 -4
View File
@@ -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<ExerciseTemplateEvent>` (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.
@@ -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<Array<String>>,
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"
}
}
@@ -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 ->
@@ -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()
@@ -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>): 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<String>): 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<String>): ExerciseSetTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
@@ -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<String>): List<String>? {
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<String>): List<String>? {
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>): 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>): String? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
return tag[1]
}
}
}
@@ -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", "<relay>"]`.
*/
class TemplateTag {
companion object {
const val TAG_NAME = "template"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parseAddressId(tag: Array<String>): 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<String>): 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)
}
}
}
@@ -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
@@ -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"))