feat(fitness): render POWR kind-1301 strength workouts

POWR and RUNSTR both publish kind 1301 but with incompatible tag schemas.
A POWR event previously rendered with the raw "33401:...:back-squat-bb"
coordinate as its activity label, no duration, and none of the set data.

Parse the POWR / NIP-101e dialect in quartz and render it in Amethyst:

- type tag for the activity (strength/circuit/emom/amrap), preferred over
  the RUNSTR exercise verb; coordinate-form exercise tags no longer leak as
  a verb.
- start/end session timestamps -> derived duration; completed flag.
- structured per-set exercise tags (kg weights, reps, rpe, set_type),
  grouped per exercise template with volume/top-weight aggregates.
- WorkoutDisplay now shows Exercises/Sets/Volume stats and a per-exercise
  breakdown (e.g. "Back Squat Bb -> 3 x 8 x 84 kg") in the viewer's unit.

Rendering interop only; Amethyst still publishes the RUNSTR-canonical form.

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 16:47:39 +00:00
parent b7aad6f61c
commit b9ac370f4f
11 changed files with 584 additions and 8 deletions
@@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
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.WorkoutRecordEvent
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DistanceTag
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DurationTag
@@ -78,6 +79,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 +98,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 +155,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 +205,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 +214,7 @@ class WorkoutInfo(
title = title,
type = type,
exerciseRaw = exerciseRaw,
typeRaw = typeRaw,
source = source,
durationSeconds = durationSeconds,
distance = distance?.toDisplay(miles),
@@ -185,16 +227,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 +250,7 @@ class WorkoutInfo(
sets = event.sets(),
reps = event.reps(),
weight = event.weight(),
exerciseGroups = event.exerciseGroups(),
)
}
}
@@ -232,7 +278,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 +300,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 +314,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 +371,10 @@ fun WorkoutDisplay(
WorkoutStatsGrid(secondaryStats)
if (info.exerciseGroups.isNotEmpty()) {
ExerciseBreakdown(info.exerciseGroups, miles)
}
// 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 +403,7 @@ fun WorkoutDisplay(
private fun buildSecondaryStats(
info: WorkoutInfo,
heroKind: HeroKind,
miles: Boolean,
durationLabel: String,
distanceLabel: String,
paceLabel: String,
@@ -359,11 +417,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 +522,35 @@ 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,
) {
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,
)
}
}
}
}
/** Small chip showing how the workout was recorded (e.g. GPS, RUNSTR, HEALTHKIT, MANUAL). */
@Composable
private fun WorkoutSourceBadge(source: String) {
+5
View File
@@ -706,6 +706,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>
@@ -731,6 +733,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="route_software_apps">Apps</string>
<string name="nip82_repository_label">Source: %1$s</string>
+54
View File
@@ -0,0 +1,54 @@
# 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()`.
### 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.
`Back Squat Bb → 3 × 8 × 84 kg`, in the viewer's preferred unit (kg↔lbs).
## 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.
- A POWR-dialect writer (would need exercise-template authoring — large).
- Surface `rpe` / `set_type` (warmup/drop/failure) per set in the breakdown.
@@ -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() } }
@@ -26,7 +26,9 @@ 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
@@ -35,7 +37,11 @@ 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.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 +77,23 @@ 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)
/** 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
}
@@ -87,6 +87,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
const val ALT_DESCRIPTION = "Workout record"
@@ -0,0 +1,95 @@
/*
* 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.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
}
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
/**
* 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)
}
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.experimental.fitness
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 +135,72 @@ 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 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))