Merge pull request #3323 from vitorpamplona/claude/powr-workout-interop-r1efh0

Add POWR / NIP-101e strength workout interop (read-only)
This commit is contained in:
Vitor Pamplona
2026-06-21 11:52:55 -04:00
committed by GitHub
18 changed files with 1092 additions and 9 deletions
@@ -59,6 +59,7 @@ import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
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
@@ -4004,6 +4005,10 @@ object LocalCache : ILocalCache, ICacheProvider {
consumeRegularEvent(event, relay, wasVerified)
}
is ExerciseTemplateEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is PaymentTargetsEvent -> {
consume(event, relay, wasVerified)
}
@@ -194,6 +194,7 @@ import com.vitorpamplona.amethyst.ui.note.types.observeZapSender
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.ExerciseTemplateDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutDisplay
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
@@ -226,6 +227,7 @@ import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
@@ -1408,6 +1410,10 @@ private fun RenderNoteRow(
WorkoutDisplay(baseNote, backgroundColor, canPreview, quotesLeft, accountViewModel, nav)
}
is ExerciseTemplateEvent -> {
ExerciseTemplateDisplay(baseNote, backgroundColor, canPreview, quotesLeft, accountViewModel, nav)
}
is BaseVoiceEvent -> {
RenderVoiceTrack(baseNote, accountViewModel, nav)
}
@@ -0,0 +1,138 @@
/*
* 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.amethyst.ui.screen.loggedIn.workouts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
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.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent
import com.vitorpamplona.quartz.experimental.fitness.workout.slugToTitle
/** Icon for the equipment of a kind-33401 exercise template. */
private fun equipmentSymbol(equipment: String?): MaterialSymbol =
when (equipment?.lowercase()) {
"cardio" -> MaterialSymbols.DirectionsRun
else -> MaterialSymbols.FitnessCenter
}
/** Renders a kind-33401 exercise template (POWR / NIP-101e): title, equipment/difficulty, instructions. */
@Composable
fun ExerciseTemplateDisplay(
baseNote: Note,
backgroundColor: MutableState<Color>,
canPreview: Boolean,
quotesLeft: Int,
accountViewModel: AccountViewModel,
nav: INav,
) {
val event = (baseNote.event as? ExerciseTemplateEvent) ?: return
val title = remember(event) { event.title() ?: event.dTag().ifBlank { null }?.let(::slugToTitle) }
val equipment = remember(event) { event.equipment() }
val difficulty = remember(event) { event.difficulty() }
val subtitle = remember(event) { listOfNotNull(equipment, difficulty).joinToString(" · ") { it.replaceFirstChar(Char::uppercaseChar) } }
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 5.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier =
Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = equipmentSymbol(equipment),
contentDescription = title,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title ?: subtitle.ifEmpty { "" },
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium,
)
if (subtitle.isNotEmpty()) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
)
}
}
}
// Form instructions / notes, through the same kind-1 rich-text pipeline.
val notes = event.content.trim()
if (notes.isNotEmpty()) {
val callbackUri = remember(baseNote) { baseNote.toNostrUri() }
val tags = remember(baseNote) { event.tags.toImmutableListOfLists() }
SensitivityWarning(note = baseNote, accountViewModel = accountViewModel) {
TranslatableRichTextViewer(
content = notes,
canPreview = canPreview,
quotesLeft = quotesLeft,
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
tags = tags,
backgroundColor = backgroundColor,
id = baseNote.idHex,
callbackUri = callbackUri,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
@@ -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,18 +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
@@ -78,6 +84,9 @@ fun ExerciseType?.symbol(): MaterialSymbol =
ExerciseType.MEDITATION -> MaterialSymbols.SelfImprovement
ExerciseType.DIET -> MaterialSymbols.Restaurant
ExerciseType.FASTING -> MaterialSymbols.Timer
ExerciseType.CIRCUIT -> MaterialSymbols.FitnessCenter
ExerciseType.EMOM -> MaterialSymbols.FitnessCenter
ExerciseType.AMRAP -> MaterialSymbols.FitnessCenter
null -> MaterialSymbols.DirectionsRun
}
@@ -94,6 +103,9 @@ fun ExerciseType.labelRes(): Int =
ExerciseType.MEDITATION -> R.string.exercise_meditation
ExerciseType.DIET -> R.string.exercise_diet
ExerciseType.FASTING -> R.string.exercise_fasting
ExerciseType.CIRCUIT -> R.string.exercise_circuit
ExerciseType.EMOM -> R.string.exercise_emom
ExerciseType.AMRAP -> R.string.exercise_amrap
}
private fun Double.trimmed(): String = if (this % 1.0 == 0.0 && abs(this) < 1e15) toLong().toString() else toString()
@@ -148,12 +160,44 @@ private fun WeightTag.toDisplay(miles: Boolean): WeightTag =
WeightTag(round(toKilograms() * 10.0) / 10.0, WeightTag.KILOGRAMS)
}
/** Renders a kilogram weight (POWR's native unit) in the viewer's preferred unit, e.g. `84 kg` or `185 lbs`. */
private fun formatWeightKg(
kg: Double,
miles: Boolean,
): String {
val display = WeightTag(kg, WeightTag.KILOGRAMS).toDisplay(miles)
return "${display.value.trimmed()} ${display.unit}"
}
/** One readable line summarizing the sets logged for a single exercise. */
private fun ExerciseGroup.summaryLine(miles: Boolean): String {
val descriptors =
sets.map { set ->
val reps = set.reps
val kg = set.weightKg?.takeIf { it > 0.0 }
when {
kg != null && reps != null -> "$reps × ${formatWeightKg(kg, miles)}"
reps != null -> "$reps reps"
kg != null -> formatWeightKg(kg, miles)
else -> "—"
}
}
if (descriptors.isEmpty()) return ""
// Collapse identical sets (e.g. 3 sets of 8 × 84 kg) into "3 × 8 × 84 kg".
return if (descriptors.size > 1 && descriptors.distinct().size == 1) {
"${descriptors.size} × ${descriptors.first()}"
} else {
descriptors.joinToString(", ")
}
}
/** One-shot snapshot of the parsed workout tags, so the feed doesn't re-scan the tag array on every recomposition. */
@Immutable
class WorkoutInfo(
val title: String?,
val type: ExerciseType?,
val exerciseRaw: String?,
val typeRaw: String?,
val source: String?,
val durationSeconds: Long?,
val distance: DistanceTag?,
@@ -166,6 +210,8 @@ class WorkoutInfo(
val sets: Int?,
val reps: Int?,
val weight: WeightTag?,
// POWR / NIP-101e strength dialect: per-exercise logged sets (weights are in kilograms).
val exerciseGroups: List<ExerciseGroup>,
) {
/** Rewrites the unit-bearing metrics into the viewer's preferred system (miles/feet/lbs vs km/m/kg). */
fun inUnits(miles: Boolean) =
@@ -173,6 +219,7 @@ class WorkoutInfo(
title = title,
type = type,
exerciseRaw = exerciseRaw,
typeRaw = typeRaw,
source = source,
durationSeconds = durationSeconds,
distance = distance?.toDisplay(miles),
@@ -185,16 +232,19 @@ class WorkoutInfo(
sets = sets,
reps = reps,
weight = weight?.toDisplay(miles),
// Kept in kilograms; the breakdown converts per the viewer's unit at render time.
exerciseGroups = exerciseGroups,
)
companion object {
fun from(event: WorkoutRecordEvent) =
WorkoutInfo(
title = event.title(),
type = event.exerciseType(),
type = event.activityType(),
exerciseRaw = event.exercise(),
source = event.workoutSource(),
durationSeconds = event.durationSeconds(),
typeRaw = event.workoutTypeCode(),
source = event.workoutSource() ?: event.client(),
durationSeconds = event.effectiveDurationSeconds(),
distance = event.distance(),
elevationGain = event.elevationGain(),
elevationLoss = event.elevationLoss(),
@@ -205,6 +255,7 @@ class WorkoutInfo(
sets = event.sets(),
reps = event.reps(),
weight = event.weight(),
exerciseGroups = event.exerciseGroups(),
)
}
}
@@ -232,7 +283,11 @@ fun WorkoutDisplay(
val miles = remember { phonePrefersMiles() }
val info = remember(baseNote, miles) { WorkoutInfo.from(event).inUnits(miles) }
val typeLabel = info.type?.let { stringRes(it.labelRes()) } ?: info.exerciseRaw ?: stringRes(R.string.workout)
val typeLabel =
info.type?.let { stringRes(it.labelRes()) }
?: info.exerciseRaw
?: info.typeRaw?.replaceFirstChar { it.uppercaseChar() }
?: stringRes(R.string.workout)
val duration = info.durationSeconds
val distance = info.distance
@@ -250,6 +305,7 @@ fun WorkoutDisplay(
buildSecondaryStats(
info = info,
heroKind = heroKind,
miles = miles,
durationLabel = stringRes(R.string.workout_duration),
distanceLabel = stringRes(R.string.workout_distance),
paceLabel = stringRes(R.string.workout_pace),
@@ -263,6 +319,8 @@ fun WorkoutDisplay(
setsLabel = stringRes(R.string.workout_sets),
repsLabel = stringRes(R.string.workout_reps),
weightLabel = stringRes(R.string.workout_weight),
exercisesLabel = stringRes(R.string.workout_exercises),
volumeLabel = stringRes(R.string.workout_volume),
)
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 5.dp)) {
@@ -318,6 +376,10 @@ fun WorkoutDisplay(
WorkoutStatsGrid(secondaryStats)
if (info.exerciseGroups.isNotEmpty()) {
ExerciseBreakdown(info.exerciseGroups, miles, accountViewModel)
}
// Route the note (event content) through the same kind-1 pipeline: rich text with
// links/mentions/hashtags, embeds, sensitivity warning and inline translations.
val notes = event.content.trim()
@@ -346,6 +408,7 @@ fun WorkoutDisplay(
private fun buildSecondaryStats(
info: WorkoutInfo,
heroKind: HeroKind,
miles: Boolean,
durationLabel: String,
distanceLabel: String,
paceLabel: String,
@@ -359,11 +422,21 @@ private fun buildSecondaryStats(
setsLabel: String,
repsLabel: String,
weightLabel: String,
exercisesLabel: String,
volumeLabel: String,
): List<Stat> {
val duration = info.durationSeconds
val distance = info.distance
return buildList {
// POWR / NIP-101e strength workouts: aggregate the per-set exercise tags.
val groups = info.exerciseGroups
if (groups.isNotEmpty()) {
add(Stat("${groups.size}", exercisesLabel))
add(Stat("${groups.sumOf { it.sets.size }}", setsLabel))
val volumeKg = groups.mapNotNull { it.totalVolumeKg() }.takeIf { it.isNotEmpty() }?.sum()
volumeKg?.let { add(Stat(formatWeightKg(it, miles), volumeLabel)) }
}
if (heroKind != HeroKind.DURATION) {
duration?.let { add(Stat(DurationTag.formatTime(it), durationLabel)) }
}
@@ -454,6 +527,72 @@ private fun WorkoutStatsGrid(
}
}
/** Per-exercise breakdown for POWR / NIP-101e strength workouts: exercise name + its logged sets. */
@Composable
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 ->
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) {
+5
View File
@@ -635,6 +635,8 @@
<string name="workout_sets">Sets</string>
<string name="workout_reps">Reps</string>
<string name="workout_weight">Weight</string>
<string name="workout_exercises">Exercises</string>
<string name="workout_volume">Volume</string>
<string name="workout_notes">Notes</string>
<string name="workout_hours">Hours</string>
<string name="workout_minutes">Minutes</string>
@@ -655,6 +657,9 @@
<string name="exercise_meditation">Meditation</string>
<string name="exercise_diet">Diet</string>
<string name="exercise_fasting">Fasting</string>
<string name="exercise_circuit">Circuit</string>
<string name="exercise_emom">EMOM</string>
<string name="exercise_amrap">AMRAP</string>
<string name="software_apps">Apps</string>
<string name="nip82_repository_label">Source: %1$s</string>
<string name="nip82_version_label">v%1$s</string>
+68
View File
@@ -0,0 +1,68 @@
# POWR interop: rendering kind-1301 strength workouts
Research date: 2026-06-20. Source: POWR app (`DocNR/POWR`), spec at
`docs/technical/nostr/exercise_nip.md` (the NIP-101e "Workout Events" draft:
kinds 33401 exercise template, 33402 workout template, 1301 workout record).
Sample event provided by a POWR user (`client: POWR`, `relay.powr.build`).
## The problem
POWR and RUNSTR both publish **kind 1301**, but the tag schemas are
structurally incompatible. Amethyst already supported the RUNSTR dialect
(`quartz/.../experimental/fitness/workout/`), so a POWR event rendered badly:
the activity label showed the raw coordinate `33401:…:back-squat-bb`, there was
no duration, and none of the set data appeared.
| Concern | RUNSTR dialect (existing) | POWR / NIP-101e dialect (new) |
|---|---|---|
| Activity type | `["exercise", "running"]` (verb) | `["type", "strength"]` (`strength`/`circuit`/`emom`/`amrap`) |
| `exercise` tag | the activity verb | one **logged set**, referencing a 33401 template: `["exercise", "33401:pubkey:d-tag", relay, weightKg, reps, rpe, set_type, set_number]` |
| Duration | `["duration", "HH:MM:SS"]` or raw seconds | none — derive from `["start", unix]` / `["end", unix]` |
| Weight unit | lbs | **kg** (empty = bodyweight, negative = assisted) |
| Misc | `source`, `t` hashtags | `completed`, `template`, `client` |
## What was implemented (read/render only)
Parsing + tests in Quartz, rendering in Amethyst. Amethyst still **publishes**
the RUNSTR-canonical form (no POWR writer) — this is rendering interop.
### Quartz (`experimental/fitness/workout/`)
- `tags/WorkoutTypeTag.kt` — POWR `type`.
- `tags/WorkoutSessionTimeTags.kt` — `start` / `end` / `completed`.
- `tags/ExerciseSetTag.kt` — the per-set coordinate form, with `isCoordinate()`
to distinguish it from a RUNSTR verb (`kind:64-hex-pubkey:d-tag`).
- `ExerciseGroup.kt` — groups sets by template reference (first-seen order,
sorted by set number); `displayName()` prettifies the d-tag slug
(`back-squat-bb` → "Back Squat Bb"); `totalVolumeKg()` / `topWeightKg()`.
- `ExerciseTag.parse()` now returns null for the coordinate form, so the verb
path never surfaces `33401:…` as an activity.
- `ExerciseType` gains `CIRCUIT` / `EMOM` / `AMRAP` (icon + label reuse).
- `WorkoutRecordEvent`: `activityType()` (prefers `type`, falls back to the
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.
- `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)
- 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, and the template's
`equipment` as a per-exercise icon.
@@ -0,0 +1,63 @@
/*
* 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.ExerciseSetTag
/**
* The sets logged for one exercise within a POWR / NIP-101e workout, grouped by
* the referenced exercise-template coordinate and ordered by set number.
*/
@Immutable
class ExerciseGroup(
val reference: String,
val sets: List<ExerciseSetTag>,
) {
/** Best-effort human label derived from the template d-tag, e.g. `back-squat-bb` → "Back Squat Bb". */
fun displayName(): String? = sets.firstNotNullOfOrNull { it.dTag() }?.let(::slugToTitle)
/** Sum of every set's volume in kilograms, or null if no set has both weight and reps. */
fun totalVolumeKg(): Double? {
val volumes = sets.mapNotNull { it.volumeKg() }
return if (volumes.isEmpty()) null else volumes.sum()
}
/** Heaviest weight logged across the group's sets, in kilograms. */
fun topWeightKg(): Double? = sets.mapNotNull { it.weightKg }.filter { it > 0.0 }.maxOrNull()
}
/** Groups exercise sets by their template reference, preserving first-seen order and sorting by set number. */
fun groupExerciseSets(sets: List<ExerciseSetTag>): List<ExerciseGroup> {
if (sets.isEmpty()) return emptyList()
val byReference = LinkedHashMap<String, MutableList<ExerciseSetTag>>()
sets.forEach { byReference.getOrPut(it.reference) { mutableListOf() }.add(it) }
return byReference.map { (reference, groupSets) ->
ExerciseGroup(reference, groupSets.sortedBy { it.setNumber ?: Int.MAX_VALUE })
}
}
/** Turns a NIP-101e d-tag slug (`seated-calf-raise-machine`) into a title (`Seated Calf Raise Machine`). */
fun slugToTitle(slug: String): String =
slug
.split('-', '_', ' ')
.filter { it.isNotBlank() }
.joinToString(" ") { word -> word.replaceFirstChar { it.uppercaseChar() } }
@@ -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"
}
}
@@ -26,16 +26,23 @@ 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.ElevationGainTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ElevationLossTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseSetTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.MaxHeartRateTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.RepsTag
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
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WorkoutEndTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WorkoutStartTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WorkoutStartTimeTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WorkoutTypeTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
fun TagArray.title() = firstNotNullOfOrNull(TitleTag::parse)
@@ -71,3 +78,31 @@ fun TagArray.weight() = firstNotNullOfOrNull(WeightTag::parse)
fun TagArray.workoutSource() = firstNotNullOfOrNull(SourceTag::parse)
fun TagArray.workoutStartTime() = firstNotNullOfOrNull(WorkoutStartTimeTag::parse)
// --- POWR / NIP-101e strength dialect ---
fun TagArray.workoutTypeCode() = firstNotNullOfOrNull(WorkoutTypeTag::parse)
fun TagArray.workoutType() = workoutTypeCode()?.let(ExerciseType::parse)
fun TagArray.workoutStart() = firstNotNullOfOrNull(WorkoutStartTag::parse)
fun TagArray.workoutEnd() = firstNotNullOfOrNull(WorkoutEndTag::parse)
fun TagArray.workoutCompleted() = firstNotNullOfOrNull(WorkoutCompletedTag::parse)
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 ->
if (tag.size > 1 && tag[0] == "client" && tag[1].isNotEmpty()) tag[1] else null
}
@@ -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.nip50Search.SearchableEvent
@@ -49,9 +50,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()
@@ -86,6 +99,38 @@ class WorkoutRecordEvent(
fun workoutStartTime() = tags.workoutStartTime()
// --- POWR / NIP-101e strength dialect ---
fun workoutTypeCode() = tags.workoutTypeCode()
fun workoutType() = tags.workoutType()
fun workoutStart() = tags.workoutStart()
fun workoutEnd() = tags.workoutEnd()
fun workoutCompleted() = tags.workoutCompleted()
fun exerciseSets() = tags.exerciseSets()
fun exerciseGroups() = groupExerciseSets(exerciseSets())
fun client() = tags.clientName()
/** Activity type, preferring the POWR `type` tag and falling back to the RUNSTR `exercise` verb. */
fun activityType() = workoutType() ?: exerciseType()
/**
* Duration in seconds: the explicit `duration` tag if present (RUNSTR),
* otherwise derived from the POWR `start`/`end` session timestamps.
*/
fun effectiveDurationSeconds(): Long? {
durationSeconds()?.let { return it }
val start = workoutStart()
val end = workoutEnd()
return if (start != null && end != null && end > start) end - start else null
}
companion object {
const val KIND = 1301
@@ -0,0 +1,115 @@
/*
* 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 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
/**
* A single logged set in the POWR / NIP-101e strength dialect.
*
* Tag layout: `["exercise", "<kind>:<pubkey>:<d-tag>", "<relay>", weight, reps,
* rpe, set_type, set_number]`, where the second element is an addressable
* coordinate to a kind-33401 exercise template. The set fields, in order:
*
* - **weight** — kilograms; empty string means bodyweight, negative means assisted.
* - **reps** — repetition count.
* - **rpe** — Rate of Perceived Exertion, 0..10.
* - **set_type** — `warmup` | `normal` | `drop` | `failure`.
* - **set_number** — 1-based index (POWR extension, not in the base spec).
*
* This is structurally distinct from RUNSTR's `exercise` tag (a plain activity
* verb such as `running`), so [parse] only matches the coordinate form.
*/
@Stable
class ExerciseSetTag(
val reference: String,
val relayHint: String?,
val weightKg: Double?,
val reps: Int?,
val rpe: Double?,
val setType: String?,
val setNumber: Int?,
) {
/** The d-tag slug of the referenced exercise template, e.g. `back-squat-bb`. */
fun dTag(): String? = reference.split(":", limit = 3).getOrNull(2)?.ifBlank { null }
/** Volume of this set in kilograms (weight × reps), when both are known and positive. */
fun volumeKg(): Double? {
val w = weightKg ?: return null
val r = reps ?: return null
return if (w > 0.0 && r > 0) w * r else null
}
companion object {
const val TAG_NAME = "exercise"
const val SET_TYPE_WARMUP = "warmup"
const val SET_TYPE_NORMAL = "normal"
const val SET_TYPE_DROP = "drop"
const val SET_TYPE_FAILURE = "failure"
/**
* True when [value] is an addressable coordinate (`kind:pubkey:d-tag`) rather
* than a plain verb. Used to tell the POWR `exercise` form from RUNSTR's.
*/
fun isCoordinate(value: String): Boolean {
val parts = value.split(":", limit = 3)
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 }
ensure(isCoordinate(tag[1])) { return null }
return ExerciseSetTag(
reference = tag[1],
relayHint = tag.getOrNull(2)?.ifBlank { null },
weightKg = tag.getOrNull(3)?.toDoubleOrNull(),
reps = tag.getOrNull(4)?.toIntOrNull(),
rpe = tag.getOrNull(5)?.toDoubleOrNull(),
setType = tag.getOrNull(6)?.ifBlank { null },
setNumber = tag.getOrNull(7)?.toIntOrNull(),
)
}
}
}
@@ -24,9 +24,13 @@ import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
/**
* Activity verbs used by NIP-101e clients (RUNSTR dialect). The tag value is the
* lowercase [code]; the matching capitalized [hashtag] is published as a `t` tag
* so the workout is discoverable.
* Activity / workout types understood across the kind-1301 dialects.
*
* The first group are the RUNSTR activity verbs carried in the `exercise` tag;
* the [STRENGTH]/[CIRCUIT]/[EMOM]/[AMRAP] values double as the POWR / NIP-101e
* `type` tag classifications. The tag value is the lowercase [code]; the
* matching capitalized [hashtag] is published as a `t` tag so RUNSTR workouts
* stay discoverable.
*/
enum class ExerciseType(
val code: String,
@@ -43,6 +47,9 @@ enum class ExerciseType(
MEDITATION("meditation", "Meditation"),
DIET("diet", "Diet"),
FASTING("fasting", "Fasting"),
CIRCUIT("circuit", "Circuit"),
EMOM("emom", "EMOM"),
AMRAP("amrap", "AMRAP"),
;
companion object {
@@ -56,11 +63,17 @@ class ExerciseTag {
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
/** Returns the raw verb. Other clients may publish verbs outside [ExerciseType]. */
/**
* Returns the raw activity verb. Other clients may publish verbs outside
* [ExerciseType]. Returns null for the POWR coordinate form
* (`33401:pubkey:d-tag`), which carries per-set data, not a verb — parse
* those with [ExerciseSetTag] instead.
*/
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 }
ensure(!ExerciseSetTag.isCoordinate(tag[1])) { return null }
return tag[1]
}
@@ -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)
}
}
}
@@ -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
/**
* Session start time (unix seconds) as published by the POWR / NIP-101e dialect.
* RUNSTR uses [WorkoutStartTimeTag] (`workout_start_time`) instead.
*/
class WorkoutStartTag {
companion object {
const val TAG_NAME = "start"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): Long? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return tag[1].toLongOrNull()
}
fun assemble(timestamp: Long) = arrayOf(TAG_NAME, timestamp.toString())
}
}
/** Session end time (unix seconds), POWR / NIP-101e dialect. */
class WorkoutEndTag {
companion object {
const val TAG_NAME = "end"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): Long? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return tag[1].toLongOrNull()
}
fun assemble(timestamp: Long) = arrayOf(TAG_NAME, timestamp.toString())
}
}
/** Whether the session was completed as planned, POWR / NIP-101e dialect. */
class WorkoutCompletedTag {
companion object {
const val TAG_NAME = "completed"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): Boolean? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
return tag[1].toBooleanStrictOrNull()
}
fun assemble(completed: Boolean) = arrayOf(TAG_NAME, completed.toString())
}
}
@@ -0,0 +1,46 @@
/*
* 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
/**
* Workout classification used by the POWR / NIP-101e strength dialect, e.g.
* `strength`, `circuit`, `emom`, `amrap`. RUNSTR clients put the activity verb
* in the `exercise` tag instead and omit this one, so treat it as optional.
*/
class WorkoutTypeTag {
companion object {
const val TAG_NAME = "type"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
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]
}
fun assemble(type: String) = arrayOf(TAG_NAME, type)
}
}
@@ -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,9 +20,11 @@
*/
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
import com.vitorpamplona.quartz.experimental.fitness.workout.slugToTitle
import com.vitorpamplona.quartz.experimental.fitness.workout.source
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DurationTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
@@ -134,6 +136,130 @@ class WorkoutRecordEventTest {
assertNull(event.distance())
}
/** Tag layout as published by POWR (NIP-101e strength dialect: per-set `exercise` coordinates). */
@Test
fun parsesPowrDialect() {
val coord = "33401:0bdd91e8a30d87d041eafd1871f17d426fa415c69a9a822eccad49017bac59e7:back-squat-bb"
val relay = "wss://relay.powr.build"
val event =
parse(
arrayOf(
arrayOf("title", "Novice Hypertrophy — Day 2"),
arrayOf("type", "strength"),
arrayOf("start", "1781969106"),
arrayOf("end", "1781972319"),
arrayOf("completed", "true"),
arrayOf("template", "33402:0bdd91e8a30d87d041eafd1871f17d426fa415c69a9a822eccad49017bac59e7:novice-hyp-day2-lower-a", relay),
arrayOf("exercise", coord, relay, "84", "8", "8", "normal", "1"),
arrayOf("exercise", coord, relay, "84", "8", "8", "normal", "2"),
arrayOf("exercise", coord, relay, "84", "8", "9", "normal", "3"),
arrayOf("exercise", "33401:0bdd91e8a30d87d041eafd1871f17d426fa415c69a9a822eccad49017bac59e7:seated-calf-raise-machine", relay, "", "8", "", "normal", "1"),
arrayOf("client", "POWR"),
),
) as WorkoutRecordEvent
// The activity type now comes from the `type` tag, not the coordinate-form `exercise` tag.
assertEquals(ExerciseType.STRENGTH, event.workoutType())
assertEquals(ExerciseType.STRENGTH, event.activityType())
assertNull(event.exercise()) // must not surface "33401:..." as a verb
assertNull(event.exerciseType())
assertEquals("Novice Hypertrophy — Day 2", event.title())
assertEquals(1781969106L, event.workoutStart())
assertEquals(1781972319L, event.workoutEnd())
assertEquals(true, event.workoutCompleted())
assertEquals("POWR", event.client())
// No `duration` tag: derived from start/end.
assertEquals(1781972319L - 1781969106L, event.effectiveDurationSeconds())
val sets = event.exerciseSets()
assertEquals(4, sets.size)
assertEquals(84.0, sets[0].weightKg)
assertEquals(8, sets[0].reps)
assertEquals(8.0, sets[0].rpe)
assertEquals("normal", sets[0].setType)
assertEquals(1, sets[0].setNumber)
assertEquals("back-squat-bb", sets[0].dTag())
// Bodyweight / machine set leaves weight empty.
assertNull(sets[3].weightKg)
assertEquals(8, sets[3].reps)
val groups = event.exerciseGroups()
assertEquals(2, groups.size)
assertEquals("Back Squat Bb", groups[0].displayName())
assertEquals(3, groups[0].sets.size)
assertEquals(84.0 * 8 * 3, groups[0].totalVolumeKg()) // 3 sets of 8 reps @ 84 kg (the 9 is RPE)
assertEquals(84.0, groups[0].topWeightKg())
assertEquals("Seated Calf Raise Machine", groups[1].displayName())
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"))
assertEquals("Seated Calf Raise Machine", slugToTitle("seated-calf-raise-machine"))
assertEquals("Deadlift", slugToTitle("deadlift"))
}
@Test
fun durationFormatsAsPaddedTime() {
assertEquals("00:31:30", DurationTag.formatTime(31 * 60 + 30L))