mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
+101
-4
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
+63
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
+32
@@ -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"
|
||||
|
||||
+95
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-4
@@ -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]
|
||||
}
|
||||
|
||||
|
||||
+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
|
||||
|
||||
/**
|
||||
* 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())
|
||||
}
|
||||
}
|
||||
+46
@@ -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)
|
||||
}
|
||||
}
|
||||
+67
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user