From b9ac370f4f09443fcd0eaa5bc503821c33e54de0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 16:47:39 +0000 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01QJwXz6CWez8r7trgHXT545 --- .../loggedIn/workouts/WorkoutDisplay.kt | 105 +++++++++++++++++- amethyst/src/main/res/values/strings.xml | 5 + quartz/plans/2026-06-20-powr-interop.md | 54 +++++++++ .../fitness/workout/ExerciseGroup.kt | 63 +++++++++++ .../fitness/workout/TagArrayExt.kt | 26 +++++ .../fitness/workout/WorkoutRecordEvent.kt | 32 ++++++ .../fitness/workout/tags/ExerciseSetTag.kt | 95 ++++++++++++++++ .../fitness/workout/tags/ExerciseTag.kt | 21 +++- .../workout/tags/WorkoutSessionTimeTags.kt | 78 +++++++++++++ .../fitness/workout/tags/WorkoutTypeTag.kt | 46 ++++++++ .../fitness/WorkoutRecordEventTest.kt | 67 +++++++++++ 11 files changed, 584 insertions(+), 8 deletions(-) create mode 100644 quartz/plans/2026-06-20-powr-interop.md create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/ExerciseGroup.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ExerciseSetTag.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/WorkoutSessionTimeTags.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/WorkoutTypeTag.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt index e8a9cda509..597beed0dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/WorkoutDisplay.kt @@ -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, ) { /** 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 { 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, + 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) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 453bcf2bae..d746f58834 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -706,6 +706,8 @@ Sets Reps Weight + Exercises + Volume Notes Hours Minutes @@ -731,6 +733,9 @@ Meditation Diet Fasting + Circuit + EMOM + AMRAP Apps Apps Source: %1$s diff --git a/quartz/plans/2026-06-20-powr-interop.md b/quartz/plans/2026-06-20-powr-interop.md new file mode 100644 index 0000000000..0afae2dc6e --- /dev/null +++ b/quartz/plans/2026-06-20-powr-interop.md @@ -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. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/ExerciseGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/ExerciseGroup.kt new file mode 100644 index 0000000000..26b6664df2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/ExerciseGroup.kt @@ -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, +) { + /** 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): List { + if (sets.isEmpty()) return emptyList() + val byReference = LinkedHashMap>() + 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() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/TagArrayExt.kt index 2f56b1fa0b..7bd4296d29 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/TagArrayExt.kt @@ -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 + } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/WorkoutRecordEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/WorkoutRecordEvent.kt index 028304279d..833705fb7b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/WorkoutRecordEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/WorkoutRecordEvent.kt @@ -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" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ExerciseSetTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ExerciseSetTag.kt new file mode 100644 index 0000000000..a6c0dc1bf1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ExerciseSetTag.kt @@ -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", "::", "", 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): 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(), + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ExerciseTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ExerciseTag.kt index 0621bdf17b..ced95e493a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ExerciseTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ExerciseTag.kt @@ -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) = 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? { 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] } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/WorkoutSessionTimeTags.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/WorkoutSessionTimeTags.kt new file mode 100644 index 0000000000..52797eb38f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/WorkoutSessionTimeTags.kt @@ -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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): 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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): 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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): 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()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/WorkoutTypeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/WorkoutTypeTag.kt new file mode 100644 index 0000000000..f175022bd6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/WorkoutTypeTag.kt @@ -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) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): 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) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/fitness/WorkoutRecordEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/fitness/WorkoutRecordEventTest.kt index 581052ce34..ddc21269b2 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/fitness/WorkoutRecordEventTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/fitness/WorkoutRecordEventTest.kt @@ -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))