From b9ac370f4f09443fcd0eaa5bc503821c33e54de0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 16:47:39 +0000 Subject: [PATCH 1/4] 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)) From e2f2c4654e48c1988ddd84d7d3efe2e516f8ab51 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 19:24:25 +0000 Subject: [PATCH 2/4] feat(fitness): resolve POWR exercise templates (kind 33401) Render real exercise names in POWR workout cards instead of slug-derived labels by fetching the referenced kind-33401 exercise templates. - ExerciseTemplateEvent (kind 33401, addressable) with title/format/ format_units/equipment/difficulty accessors; registered in EventFactory. - WorkoutRecordEvent now implements AddressHintProvider: linkedAddressIds (deduped 33401 + 33402 coordinates) seed the gatherer so the card re-renders when a template arrives, and addressHints feed the relay-hint index with the relay.powr.build hints so the fetch reaches where POWR published the templates. - WorkoutDisplay resolves each exercise via LoadAddressableNote + observeNoteEvent, showing the template title once fetched and the slug until then. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QJwXz6CWez8r7trgHXT545 --- .../loggedIn/workouts/WorkoutDisplay.kt | 72 +++++++++++++---- quartz/plans/2026-06-20-powr-interop.md | 22 +++++- .../fitness/workout/ExerciseTemplateEvent.kt | 67 ++++++++++++++++ .../fitness/workout/TagArrayExt.kt | 9 +++ .../fitness/workout/WorkoutRecordEvent.kt | 15 +++- .../fitness/workout/tags/ExerciseSetTag.kt | 20 +++++ .../workout/tags/ExerciseTemplateTags.kt | 78 +++++++++++++++++++ .../fitness/workout/tags/TemplateTag.kt | 54 +++++++++++++ .../quartz/utils/EventFactory.kt | 2 + .../fitness/WorkoutRecordEventTest.kt | 59 ++++++++++++++ 10 files changed, 378 insertions(+), 20 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/ExerciseTemplateEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ExerciseTemplateTags.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/TemplateTag.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 597beed0dd..985948e8f0 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 @@ -37,6 +37,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -50,19 +51,23 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseGroup +import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DistanceTag import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DurationTag import com.vitorpamplona.quartz.experimental.fitness.workout.tags.Elevation import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WeightTag +import com.vitorpamplona.quartz.nip01Core.core.Address import kotlin.math.abs import kotlin.math.round @@ -372,7 +377,7 @@ fun WorkoutDisplay( WorkoutStatsGrid(secondaryStats) if (info.exerciseGroups.isNotEmpty()) { - ExerciseBreakdown(info.exerciseGroups, miles) + ExerciseBreakdown(info.exerciseGroups, miles, accountViewModel) } // Route the note (event content) through the same kind-1 pipeline: rich text with @@ -527,30 +532,67 @@ private fun WorkoutStatsGrid( private fun ExerciseBreakdown( groups: List, miles: Boolean, + accountViewModel: AccountViewModel, ) { Column( modifier = Modifier.fillMaxWidth().padding(top = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { groups.forEach { group -> - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { - Text( - text = group.displayName() ?: "—", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - modifier = Modifier.weight(1f), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = group.summaryLine(miles), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.placeholderText, - ) - } + ExerciseRow(group, miles, accountViewModel) } } } +@Composable +private fun ExerciseRow( + group: ExerciseGroup, + miles: Boolean, + accountViewModel: AccountViewModel, +) { + val fallback = group.displayName() ?: "—" + val address = remember(group.reference) { Address.parse(group.reference) } + + if (address == null) { + ExerciseRowContent(fallback, group.summaryLine(miles)) + return + } + + // Resolve the kind-33401 template to show its real title; falls back to the slug + // until the template is fetched (the workout event's relay hints drive the fetch). + LoadAddressableNote(address, accountViewModel) { templateNote -> + val name = + if (templateNote != null) { + val templateEvent by observeNoteEvent(templateNote, accountViewModel) + templateEvent?.title() ?: fallback + } else { + fallback + } + ExerciseRowContent(name, group.summaryLine(miles)) + } +} + +@Composable +private fun ExerciseRowContent( + name: String, + summary: String, +) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) { + Text( + text = name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = summary, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + } +} + /** Small chip showing how the workout was recorded (e.g. GPS, RUNSTR, HEALTHKIT, MANUAL). */ @Composable private fun WorkoutSourceBadge(source: String) { diff --git a/quartz/plans/2026-06-20-powr-interop.md b/quartz/plans/2026-06-20-powr-interop.md index 0afae2dc6e..9b0cbe07cd 100644 --- a/quartz/plans/2026-06-20-powr-interop.md +++ b/quartz/plans/2026-06-20-powr-interop.md @@ -41,14 +41,28 @@ the RUNSTR-canonical form (no POWR writer) — this is rendering interop. verb), `effectiveDurationSeconds()` (duration tag, else `end - start`), `exerciseGroups()`, `client()`, `workoutCompleted()`. +### Exercise-template resolution (kind 33401) +- `ExerciseTemplateEvent.kt` (kind 33401, addressable) + tag accessors + (`title`, `format`, `format_units`, `equipment`, `difficulty`); registered + in `EventFactory`. +- `WorkoutRecordEvent` implements `AddressHintProvider`: `linkedAddressIds()` + (deduped 33401 + 33402 coordinates) seed the gatherer so the card re-renders + when a template arrives; `addressHints()` feed the relay-hint index with the + `relay.powr.build` hints from each `exercise`/`template` tag — without these + the fetch would only hit the viewer's own relays and usually miss. +- `ExerciseSetTag.parseAsHint` / `TemplateTag.parseAsHint` build the hints. + ### Amethyst (`WorkoutDisplay.kt`) - Activity label/icon come from `activityType()`; coordinate never leaks. - Hero = derived duration; secondary stats add Exercises / Sets / Volume. -- New `ExerciseBreakdown` lists each exercise with its sets, e.g. +- `ExerciseBreakdown` lists each exercise with its sets, e.g. `Back Squat Bb → 3 × 8 × 84 kg`, in the viewer's preferred unit (kg↔lbs). +- Each row resolves its 33401 template via `LoadAddressableNote` + + `observeNoteEvent` (fetch-on-demand), showing the + template's real `title()` once it arrives and the slug until then. ## Not done (possible follow-ups) -- Fetch 33401 templates to show real exercise titles / equipment / media - instead of the slug, and 33402 to show the planned-vs-done template. +- Render 33402 (the planned template) to show planned-vs-done. - A POWR-dialect writer (would need exercise-template authoring — large). -- Surface `rpe` / `set_type` (warmup/drop/failure) per set in the breakdown. +- Surface `rpe` / `set_type` (warmup/drop/failure) per set, and the template's + `equipment` as a per-exercise icon. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/ExerciseTemplateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/ExerciseTemplateEvent.kt new file mode 100644 index 0000000000..78b85eee05 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/ExerciseTemplateEvent.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.fitness.workout + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DifficultyTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.EquipmentTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.FormatTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.FormatUnitsTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.TitleTag +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip50Search.SearchableEvent + +/** + * NIP-101e (draft) exercise template, kind 33401, as published by POWR. An + * addressable, reusable definition referenced from the per-set `exercise` tags + * of a [WorkoutRecordEvent] via its `33401:pubkey:d-tag` coordinate. + * + * Amethyst fetches these to show a real exercise name (and equipment) in a + * workout card instead of the d-tag slug. Parsing is lax: every tag optional. + */ +@Immutable +class ExerciseTemplateEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), content).joinToString("\n") + + fun title() = tags.firstNotNullOfOrNull(TitleTag::parse) + + fun format() = tags.firstNotNullOfOrNull(FormatTag::parse) + + fun formatUnits() = tags.firstNotNullOfOrNull(FormatUnitsTag::parse) + + fun equipment() = tags.firstNotNullOfOrNull(EquipmentTag::parse) + + fun difficulty() = tags.firstNotNullOfOrNull(DifficultyTag::parse) + + companion object { + const val KIND = 33401 + const val ALT_DESCRIPTION = "Exercise template" + } +} 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 7bd4296d29..eb80669d86 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 @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.experimental.fitness.workout.tags.SetsTag import com.vitorpamplona.quartz.experimental.fitness.workout.tags.SourceTag import com.vitorpamplona.quartz.experimental.fitness.workout.tags.SplitTag import com.vitorpamplona.quartz.experimental.fitness.workout.tags.StepsTag +import com.vitorpamplona.quartz.experimental.fitness.workout.tags.TemplateTag import com.vitorpamplona.quartz.experimental.fitness.workout.tags.TitleTag import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WeightTag import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WorkoutCompletedTag @@ -92,6 +93,14 @@ fun TagArray.workoutCompleted() = firstNotNullOfOrNull(WorkoutCompletedTag::pars fun TagArray.exerciseSets() = mapNotNull(ExerciseSetTag::parse) +fun TagArray.exerciseSetAddressIds() = mapNotNull(ExerciseSetTag::parseAddressId) + +fun TagArray.exerciseSetHints() = mapNotNull(ExerciseSetTag::parseAsHint) + +fun TagArray.templateAddressId() = firstNotNullOfOrNull(TemplateTag::parseAddressId) + +fun TagArray.templateHint() = firstNotNullOfOrNull(TemplateTag::parseAsHint) + /** The client name from a `["client", name, ...]` tag (RUNSTR and POWR both emit this). */ fun TagArray.clientName() = firstNotNullOfOrNull { tag -> 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 833705fb7b..269a67e6da 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 @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip22Comments.RootScope import com.vitorpamplona.quartz.nip31Alts.alt @@ -50,9 +51,21 @@ class WorkoutRecordEvent( sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig), RootScope, - SearchableEvent { + SearchableEvent, + AddressHintProvider { override fun indexableContent() = listOfNotNull(title(), content).joinToString("\n") + // POWR / NIP-101e workouts reference kind-33401 exercise templates (and a kind-33402 + // workout template) by coordinate + relay hint, so the templates can be fetched and + // rendered with real names. RUNSTR workouts carry none of these and contribute nothing. + override fun addressHints() = + buildList { + addAll(tags.exerciseSetHints()) + tags.templateHint()?.let { add(it) } + } + + override fun linkedAddressIds() = (tags.exerciseSetAddressIds() + listOfNotNull(tags.templateAddressId())).distinct() + fun title() = tags.title() fun exercise() = tags.exercise() 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 index a6c0dc1bf1..e3dfc355af 100644 --- 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 @@ -22,6 +22,8 @@ package com.vitorpamplona.quartz.experimental.fitness.workout.tags import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.utils.ensure /** @@ -77,6 +79,24 @@ class ExerciseSetTag( return parts.size == 3 && parts[0].toIntOrNull() != null && parts[1].length == 64 } + /** The referenced exercise-template coordinate, when the tag is the POWR set form. */ + fun parseAddressId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(isCoordinate(tag[1])) { return null } + return tag[1] + } + + /** The relay hint for the referenced exercise template, so it can be fetched. */ + fun parseAsHint(tag: Array): AddressHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(isCoordinate(tag[1])) { return null } + ensure(tag[2].isNotEmpty()) { return null } + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) ?: return null + return AddressHint(tag[1], relayHint) + } + fun parse(tag: Array): ExerciseSetTag? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ExerciseTemplateTags.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ExerciseTemplateTags.kt new file mode 100644 index 0000000000..55568d5268 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/ExerciseTemplateTags.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 + +/** The ordered parameter names a kind-33401 exercise expects per set, e.g. `weight reps rpe set_type`. */ +class FormatTag { + companion object { + const val TAG_NAME = "format" + + fun parse(tag: Array): List? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag.drop(1).filter { it.isNotEmpty() }.ifEmpty { null } + } + } +} + +/** The units for each [FormatTag] parameter, e.g. `kg count 0-10 enum`. */ +class FormatUnitsTag { + companion object { + const val TAG_NAME = "format_units" + + fun parse(tag: Array): List? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag.drop(1).filter { it.isNotEmpty() }.ifEmpty { null } + } + } +} + +/** Equipment used by a kind-33401 exercise: `barbell` | `dumbbell` | `bodyweight` | `machine` | `cardio`. */ +class EquipmentTag { + companion object { + const val TAG_NAME = "equipment" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + } +} + +/** Difficulty of a kind-33401 exercise: `beginner` | `intermediate` | `advanced`. */ +class DifficultyTag { + companion object { + const val TAG_NAME = "difficulty" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/TemplateTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/TemplateTag.kt new file mode 100644 index 0000000000..176fed85cf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/fitness/workout/tags/TemplateTag.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.fitness.workout.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.ensure + +/** + * Reference to the kind-33402 workout template a POWR session was built from: + * `["template", "33402:pubkey:d-tag", ""]`. + */ +class TemplateTag { + companion object { + const val TAG_NAME = "template" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parseAddressId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(ExerciseSetTag.isCoordinate(tag[1])) { return null } + return tag[1] + } + + fun parseAsHint(tag: Array): AddressHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(ExerciseSetTag.isCoordinate(tag[1])) { return null } + ensure(tag[2].isNotEmpty()) { return null } + val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) ?: return null + return AddressHint(tag[1], relayHint) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 9c66762853..c3871af281 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent +import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent @@ -618,6 +619,7 @@ class EventFactory { WebBookmarkEvent.KIND -> WebBookmarkEvent(id, pubKey, createdAt, tags, content, sig) WikiNoteEvent.KIND -> WikiNoteEvent(id, pubKey, createdAt, tags, content, sig) WorkoutRecordEvent.KIND -> WorkoutRecordEvent(id, pubKey, createdAt, tags, content, sig) + ExerciseTemplateEvent.KIND -> ExerciseTemplateEvent(id, pubKey, createdAt, tags, content, sig) else -> factories[kind]?.build(id, pubKey, createdAt, tags, content, sig) ?: Event(id, pubKey, createdAt, kind, tags, content, sig) } as T 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 ddc21269b2..3c69e15539 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 @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.experimental.fitness +import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent import com.vitorpamplona.quartz.experimental.fitness.workout.calories import com.vitorpamplona.quartz.experimental.fitness.workout.distance @@ -194,6 +195,64 @@ class WorkoutRecordEventTest { assertNull(groups[1].totalVolumeKg()) // no weights logged } + @Test + fun exposesPowrTemplateReferencesAsHints() { + val backSquat = "33401:0bdd91e8a30d87d041eafd1871f17d426fa415c69a9a822eccad49017bac59e7:back-squat-bb" + val deadlift = "33401:0bdd91e8a30d87d041eafd1871f17d426fa415c69a9a822eccad49017bac59e7:deadlift-bb" + val template = "33402:0bdd91e8a30d87d041eafd1871f17d426fa415c69a9a822eccad49017bac59e7:novice-hyp-day2-lower-a" + val relay = "wss://relay.powr.build" + val event = + parse( + arrayOf( + arrayOf("type", "strength"), + arrayOf("template", template, relay), + arrayOf("exercise", backSquat, relay, "84", "8", "8", "normal", "1"), + arrayOf("exercise", backSquat, relay, "84", "8", "8", "normal", "2"), + arrayOf("exercise", deadlift, relay, "100", "5", "8", "normal", "1"), + ), + ) as WorkoutRecordEvent + + // Each referenced template is exposed once for fetching (deduped), incl. the 33402 template. + val linked = event.linkedAddressIds() + assertEquals(setOf(backSquat, deadlift, template), linked.toSet()) + assertEquals(3, linked.size) + + // Relay hints let Amethyst fetch the templates from where POWR published them. + val hints = event.addressHints() + assertEquals(setOf(backSquat, deadlift, template), hints.map { it.addressId }.toSet()) + assertTrue(hints.all { it.relay.url == "wss://relay.powr.build/" }) + } + + @Test + fun parsesExerciseTemplate() { + val event: Event = + EventFactory.create( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1718000000L, + kind = ExerciseTemplateEvent.KIND, + tags = + arrayOf( + arrayOf("d", "back-squat-bb"), + arrayOf("title", "Back Squat (Barbell)"), + arrayOf("format", "weight", "reps", "rpe", "set_type"), + arrayOf("format_units", "kg", "count", "0-10", "enum"), + arrayOf("equipment", "barbell"), + arrayOf("difficulty", "intermediate"), + ), + content = "Keep a neutral spine.", + sig = "c".repeat(128), + ) + + assertTrue(event is ExerciseTemplateEvent) + assertEquals("back-squat-bb", event.dTag()) + assertEquals("Back Squat (Barbell)", event.title()) + assertEquals(listOf("weight", "reps", "rpe", "set_type"), event.format()) + assertEquals(listOf("kg", "count", "0-10", "enum"), event.formatUnits()) + assertEquals("barbell", event.equipment()) + assertEquals("intermediate", event.difficulty()) + } + @Test fun slugToTitlePrettifiesDTags() { assertEquals("Back Squat Bb", slugToTitle("back-squat-bb")) From 8c56bc64c817df6b42cf78710c8742c1330247bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 19:56:49 +0000 Subject: [PATCH 3/4] feat(fitness): render kind-33401 exercise templates as a card A standalone POWR exercise template (kind 33401) opened via naddr, a quote, or in a thread previously fell through to RenderTextEvent, showing only its bare instruction text with no title. Add ExerciseTemplateDisplay (equipment icon, title, equipment/difficulty subtitle, instructions via the rich-text pipeline) and dispatch to it from NoteCompose. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QJwXz6CWez8r7trgHXT545 --- .../amethyst/ui/note/NoteCompose.kt | 6 + .../workouts/ExerciseTemplateDisplay.kt | 138 ++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/ExerciseTemplateDisplay.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index b81981e28d..d547d6f896 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -194,6 +194,7 @@ import com.vitorpamplona.amethyst.ui.note.types.observeZapSender import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.ExerciseTemplateDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutDisplay import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer @@ -226,6 +227,7 @@ import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent +import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent import com.vitorpamplona.quartz.experimental.forks.IForkableEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent @@ -1405,6 +1407,10 @@ private fun RenderNoteRow( WorkoutDisplay(baseNote, backgroundColor, canPreview, quotesLeft, accountViewModel, nav) } + is ExerciseTemplateEvent -> { + ExerciseTemplateDisplay(baseNote, backgroundColor, canPreview, quotesLeft, accountViewModel, nav) + } + is BaseVoiceEvent -> { RenderVoiceTrack(baseNote, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/ExerciseTemplateDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/ExerciseTemplateDisplay.kt new file mode 100644 index 0000000000..dd0abbe74a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/workouts/ExerciseTemplateDisplay.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent +import com.vitorpamplona.quartz.experimental.fitness.workout.slugToTitle + +/** Icon for the equipment of a kind-33401 exercise template. */ +private fun equipmentSymbol(equipment: String?): MaterialSymbol = + when (equipment?.lowercase()) { + "cardio" -> MaterialSymbols.DirectionsRun + else -> MaterialSymbols.FitnessCenter + } + +/** Renders a kind-33401 exercise template (POWR / NIP-101e): title, equipment/difficulty, instructions. */ +@Composable +fun ExerciseTemplateDisplay( + baseNote: Note, + backgroundColor: MutableState, + canPreview: Boolean, + quotesLeft: Int, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = (baseNote.event as? ExerciseTemplateEvent) ?: return + + val title = remember(event) { event.title() ?: event.dTag().ifBlank { null }?.let(::slugToTitle) } + val equipment = remember(event) { event.equipment() } + val difficulty = remember(event) { event.difficulty() } + val subtitle = remember(event) { listOfNotNull(equipment, difficulty).joinToString(" · ") { it.replaceFirstChar(Char::uppercaseChar) } } + + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 5.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = + Modifier + .size(40.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = equipmentSymbol(equipment), + contentDescription = title, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + + Spacer(modifier = Modifier.width(10.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = title ?: subtitle.ifEmpty { "" }, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + ) + if (subtitle.isNotEmpty()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.placeholderText, + ) + } + } + } + + // Form instructions / notes, through the same kind-1 rich-text pipeline. + val notes = event.content.trim() + if (notes.isNotEmpty()) { + val callbackUri = remember(baseNote) { baseNote.toNostrUri() } + val tags = remember(baseNote) { event.tags.toImmutableListOfLists() } + SensitivityWarning(note = baseNote, accountViewModel = accountViewModel) { + TranslatableRichTextViewer( + content = notes, + canPreview = canPreview, + quotesLeft = quotesLeft, + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + tags = tags, + backgroundColor = backgroundColor, + id = baseNote.idHex, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } +} From 35af6ca580a36af2d7678bcfb7d5b8798555b5d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 15:39:55 +0000 Subject: [PATCH 4/4] fix(fitness): store kind-33401 exercise templates in LocalCache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit justConsumeInnerInner's when(event) has no generic addressable fallback — its else branch logs "Event Not Supported" and drops the event. A kind-33401 ExerciseTemplateEvent arriving from a relay was therefore never stored as an AddressableNote, so the fetched template never attached to its placeholder note and the workout card's exercise title never resolved. Dispatch it to consumeBaseReplaceable like the other addressable definitions. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QJwXz6CWez8r7trgHXT545 --- .../main/java/com/vitorpamplona/amethyst/model/LocalCache.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index d82d2c221a..051f8dd9c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -59,6 +59,7 @@ import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent +import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent @@ -4004,6 +4005,10 @@ object LocalCache : ILocalCache, ICacheProvider { consumeRegularEvent(event, relay, wasVerified) } + is ExerciseTemplateEvent -> { + consumeBaseReplaceable(event, relay, wasVerified) + } + is PaymentTargetsEvent -> { consume(event, relay, wasVerified) }