mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
+57
-15
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
+67
@@ -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"
|
||||
}
|
||||
}
|
||||
+9
@@ -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 ->
|
||||
|
||||
+14
-1
@@ -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()
|
||||
|
||||
+20
@@ -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 }
|
||||
|
||||
+78
@@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -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
|
||||
|
||||
|
||||
+59
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user