mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #3323 from vitorpamplona/claude/powr-workout-interop-r1efh0
Add POWR / NIP-101e strength workout interop (read-only)
This commit is contained in:
@@ -59,6 +59,7 @@ import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
|
||||
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
|
||||
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent
|
||||
@@ -4004,6 +4005,10 @@ object LocalCache : ILocalCache, ICacheProvider {
|
||||
consumeRegularEvent(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is ExerciseTemplateEvent -> {
|
||||
consumeBaseReplaceable(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
is PaymentTargetsEvent -> {
|
||||
consume(event, relay, wasVerified)
|
||||
}
|
||||
|
||||
@@ -194,6 +194,7 @@ import com.vitorpamplona.amethyst.ui.note.types.observeZapSender
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.RenderChatClip
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.ExerciseTemplateDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.WorkoutDisplay
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
@@ -226,6 +227,7 @@ import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
|
||||
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
|
||||
import com.vitorpamplona.quartz.experimental.bounties.bountyBaseReward
|
||||
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
|
||||
import com.vitorpamplona.quartz.experimental.forks.IForkableEvent
|
||||
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
|
||||
@@ -1408,6 +1410,10 @@ private fun RenderNoteRow(
|
||||
WorkoutDisplay(baseNote, backgroundColor, canPreview, quotesLeft, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is ExerciseTemplateEvent -> {
|
||||
ExerciseTemplateDisplay(baseNote, backgroundColor, canPreview, quotesLeft, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is BaseVoiceEvent -> {
|
||||
RenderVoiceTrack(baseNote, accountViewModel, nav)
|
||||
}
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.slugToTitle
|
||||
|
||||
/** Icon for the equipment of a kind-33401 exercise template. */
|
||||
private fun equipmentSymbol(equipment: String?): MaterialSymbol =
|
||||
when (equipment?.lowercase()) {
|
||||
"cardio" -> MaterialSymbols.DirectionsRun
|
||||
else -> MaterialSymbols.FitnessCenter
|
||||
}
|
||||
|
||||
/** Renders a kind-33401 exercise template (POWR / NIP-101e): title, equipment/difficulty, instructions. */
|
||||
@Composable
|
||||
fun ExerciseTemplateDisplay(
|
||||
baseNote: Note,
|
||||
backgroundColor: MutableState<Color>,
|
||||
canPreview: Boolean,
|
||||
quotesLeft: Int,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val event = (baseNote.event as? ExerciseTemplateEvent) ?: return
|
||||
|
||||
val title = remember(event) { event.title() ?: event.dTag().ifBlank { null }?.let(::slugToTitle) }
|
||||
val equipment = remember(event) { event.equipment() }
|
||||
val difficulty = remember(event) { event.difficulty() }
|
||||
val subtitle = remember(event) { listOfNotNull(equipment, difficulty).joinToString(" · ") { it.replaceFirstChar(Char::uppercaseChar) } }
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 5.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
symbol = equipmentSymbol(equipment),
|
||||
contentDescription = title,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title ?: subtitle.ifEmpty { "" },
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Form instructions / notes, through the same kind-1 rich-text pipeline.
|
||||
val notes = event.content.trim()
|
||||
if (notes.isNotEmpty()) {
|
||||
val callbackUri = remember(baseNote) { baseNote.toNostrUri() }
|
||||
val tags = remember(baseNote) { event.tags.toImmutableListOfLists() }
|
||||
SensitivityWarning(note = baseNote, accountViewModel = accountViewModel) {
|
||||
TranslatableRichTextViewer(
|
||||
content = notes,
|
||||
canPreview = canPreview,
|
||||
quotesLeft = quotesLeft,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
tags = tags,
|
||||
backgroundColor = backgroundColor,
|
||||
id = baseNote.idHex,
|
||||
callbackUri = callbackUri,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+143
-4
@@ -37,6 +37,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -50,18 +51,23 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
|
||||
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseGroup
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DistanceTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DurationTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.Elevation
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WeightTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.round
|
||||
|
||||
@@ -78,6 +84,9 @@ fun ExerciseType?.symbol(): MaterialSymbol =
|
||||
ExerciseType.MEDITATION -> MaterialSymbols.SelfImprovement
|
||||
ExerciseType.DIET -> MaterialSymbols.Restaurant
|
||||
ExerciseType.FASTING -> MaterialSymbols.Timer
|
||||
ExerciseType.CIRCUIT -> MaterialSymbols.FitnessCenter
|
||||
ExerciseType.EMOM -> MaterialSymbols.FitnessCenter
|
||||
ExerciseType.AMRAP -> MaterialSymbols.FitnessCenter
|
||||
null -> MaterialSymbols.DirectionsRun
|
||||
}
|
||||
|
||||
@@ -94,6 +103,9 @@ fun ExerciseType.labelRes(): Int =
|
||||
ExerciseType.MEDITATION -> R.string.exercise_meditation
|
||||
ExerciseType.DIET -> R.string.exercise_diet
|
||||
ExerciseType.FASTING -> R.string.exercise_fasting
|
||||
ExerciseType.CIRCUIT -> R.string.exercise_circuit
|
||||
ExerciseType.EMOM -> R.string.exercise_emom
|
||||
ExerciseType.AMRAP -> R.string.exercise_amrap
|
||||
}
|
||||
|
||||
private fun Double.trimmed(): String = if (this % 1.0 == 0.0 && abs(this) < 1e15) toLong().toString() else toString()
|
||||
@@ -148,12 +160,44 @@ private fun WeightTag.toDisplay(miles: Boolean): WeightTag =
|
||||
WeightTag(round(toKilograms() * 10.0) / 10.0, WeightTag.KILOGRAMS)
|
||||
}
|
||||
|
||||
/** Renders a kilogram weight (POWR's native unit) in the viewer's preferred unit, e.g. `84 kg` or `185 lbs`. */
|
||||
private fun formatWeightKg(
|
||||
kg: Double,
|
||||
miles: Boolean,
|
||||
): String {
|
||||
val display = WeightTag(kg, WeightTag.KILOGRAMS).toDisplay(miles)
|
||||
return "${display.value.trimmed()} ${display.unit}"
|
||||
}
|
||||
|
||||
/** One readable line summarizing the sets logged for a single exercise. */
|
||||
private fun ExerciseGroup.summaryLine(miles: Boolean): String {
|
||||
val descriptors =
|
||||
sets.map { set ->
|
||||
val reps = set.reps
|
||||
val kg = set.weightKg?.takeIf { it > 0.0 }
|
||||
when {
|
||||
kg != null && reps != null -> "$reps × ${formatWeightKg(kg, miles)}"
|
||||
reps != null -> "$reps reps"
|
||||
kg != null -> formatWeightKg(kg, miles)
|
||||
else -> "—"
|
||||
}
|
||||
}
|
||||
if (descriptors.isEmpty()) return ""
|
||||
// Collapse identical sets (e.g. 3 sets of 8 × 84 kg) into "3 × 8 × 84 kg".
|
||||
return if (descriptors.size > 1 && descriptors.distinct().size == 1) {
|
||||
"${descriptors.size} × ${descriptors.first()}"
|
||||
} else {
|
||||
descriptors.joinToString(", ")
|
||||
}
|
||||
}
|
||||
|
||||
/** One-shot snapshot of the parsed workout tags, so the feed doesn't re-scan the tag array on every recomposition. */
|
||||
@Immutable
|
||||
class WorkoutInfo(
|
||||
val title: String?,
|
||||
val type: ExerciseType?,
|
||||
val exerciseRaw: String?,
|
||||
val typeRaw: String?,
|
||||
val source: String?,
|
||||
val durationSeconds: Long?,
|
||||
val distance: DistanceTag?,
|
||||
@@ -166,6 +210,8 @@ class WorkoutInfo(
|
||||
val sets: Int?,
|
||||
val reps: Int?,
|
||||
val weight: WeightTag?,
|
||||
// POWR / NIP-101e strength dialect: per-exercise logged sets (weights are in kilograms).
|
||||
val exerciseGroups: List<ExerciseGroup>,
|
||||
) {
|
||||
/** Rewrites the unit-bearing metrics into the viewer's preferred system (miles/feet/lbs vs km/m/kg). */
|
||||
fun inUnits(miles: Boolean) =
|
||||
@@ -173,6 +219,7 @@ class WorkoutInfo(
|
||||
title = title,
|
||||
type = type,
|
||||
exerciseRaw = exerciseRaw,
|
||||
typeRaw = typeRaw,
|
||||
source = source,
|
||||
durationSeconds = durationSeconds,
|
||||
distance = distance?.toDisplay(miles),
|
||||
@@ -185,16 +232,19 @@ class WorkoutInfo(
|
||||
sets = sets,
|
||||
reps = reps,
|
||||
weight = weight?.toDisplay(miles),
|
||||
// Kept in kilograms; the breakdown converts per the viewer's unit at render time.
|
||||
exerciseGroups = exerciseGroups,
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun from(event: WorkoutRecordEvent) =
|
||||
WorkoutInfo(
|
||||
title = event.title(),
|
||||
type = event.exerciseType(),
|
||||
type = event.activityType(),
|
||||
exerciseRaw = event.exercise(),
|
||||
source = event.workoutSource(),
|
||||
durationSeconds = event.durationSeconds(),
|
||||
typeRaw = event.workoutTypeCode(),
|
||||
source = event.workoutSource() ?: event.client(),
|
||||
durationSeconds = event.effectiveDurationSeconds(),
|
||||
distance = event.distance(),
|
||||
elevationGain = event.elevationGain(),
|
||||
elevationLoss = event.elevationLoss(),
|
||||
@@ -205,6 +255,7 @@ class WorkoutInfo(
|
||||
sets = event.sets(),
|
||||
reps = event.reps(),
|
||||
weight = event.weight(),
|
||||
exerciseGroups = event.exerciseGroups(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -232,7 +283,11 @@ fun WorkoutDisplay(
|
||||
|
||||
val miles = remember { phonePrefersMiles() }
|
||||
val info = remember(baseNote, miles) { WorkoutInfo.from(event).inUnits(miles) }
|
||||
val typeLabel = info.type?.let { stringRes(it.labelRes()) } ?: info.exerciseRaw ?: stringRes(R.string.workout)
|
||||
val typeLabel =
|
||||
info.type?.let { stringRes(it.labelRes()) }
|
||||
?: info.exerciseRaw
|
||||
?: info.typeRaw?.replaceFirstChar { it.uppercaseChar() }
|
||||
?: stringRes(R.string.workout)
|
||||
|
||||
val duration = info.durationSeconds
|
||||
val distance = info.distance
|
||||
@@ -250,6 +305,7 @@ fun WorkoutDisplay(
|
||||
buildSecondaryStats(
|
||||
info = info,
|
||||
heroKind = heroKind,
|
||||
miles = miles,
|
||||
durationLabel = stringRes(R.string.workout_duration),
|
||||
distanceLabel = stringRes(R.string.workout_distance),
|
||||
paceLabel = stringRes(R.string.workout_pace),
|
||||
@@ -263,6 +319,8 @@ fun WorkoutDisplay(
|
||||
setsLabel = stringRes(R.string.workout_sets),
|
||||
repsLabel = stringRes(R.string.workout_reps),
|
||||
weightLabel = stringRes(R.string.workout_weight),
|
||||
exercisesLabel = stringRes(R.string.workout_exercises),
|
||||
volumeLabel = stringRes(R.string.workout_volume),
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 5.dp)) {
|
||||
@@ -318,6 +376,10 @@ fun WorkoutDisplay(
|
||||
|
||||
WorkoutStatsGrid(secondaryStats)
|
||||
|
||||
if (info.exerciseGroups.isNotEmpty()) {
|
||||
ExerciseBreakdown(info.exerciseGroups, miles, accountViewModel)
|
||||
}
|
||||
|
||||
// Route the note (event content) through the same kind-1 pipeline: rich text with
|
||||
// links/mentions/hashtags, embeds, sensitivity warning and inline translations.
|
||||
val notes = event.content.trim()
|
||||
@@ -346,6 +408,7 @@ fun WorkoutDisplay(
|
||||
private fun buildSecondaryStats(
|
||||
info: WorkoutInfo,
|
||||
heroKind: HeroKind,
|
||||
miles: Boolean,
|
||||
durationLabel: String,
|
||||
distanceLabel: String,
|
||||
paceLabel: String,
|
||||
@@ -359,11 +422,21 @@ private fun buildSecondaryStats(
|
||||
setsLabel: String,
|
||||
repsLabel: String,
|
||||
weightLabel: String,
|
||||
exercisesLabel: String,
|
||||
volumeLabel: String,
|
||||
): List<Stat> {
|
||||
val duration = info.durationSeconds
|
||||
val distance = info.distance
|
||||
|
||||
return buildList {
|
||||
// POWR / NIP-101e strength workouts: aggregate the per-set exercise tags.
|
||||
val groups = info.exerciseGroups
|
||||
if (groups.isNotEmpty()) {
|
||||
add(Stat("${groups.size}", exercisesLabel))
|
||||
add(Stat("${groups.sumOf { it.sets.size }}", setsLabel))
|
||||
val volumeKg = groups.mapNotNull { it.totalVolumeKg() }.takeIf { it.isNotEmpty() }?.sum()
|
||||
volumeKg?.let { add(Stat(formatWeightKg(it, miles), volumeLabel)) }
|
||||
}
|
||||
if (heroKind != HeroKind.DURATION) {
|
||||
duration?.let { add(Stat(DurationTag.formatTime(it), durationLabel)) }
|
||||
}
|
||||
@@ -454,6 +527,72 @@ private fun WorkoutStatsGrid(
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-exercise breakdown for POWR / NIP-101e strength workouts: exercise name + its logged sets. */
|
||||
@Composable
|
||||
private fun ExerciseBreakdown(
|
||||
groups: List<ExerciseGroup>,
|
||||
miles: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
groups.forEach { group ->
|
||||
ExerciseRow(group, miles, accountViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExerciseRow(
|
||||
group: ExerciseGroup,
|
||||
miles: Boolean,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val fallback = group.displayName() ?: "—"
|
||||
val address = remember(group.reference) { Address.parse(group.reference) }
|
||||
|
||||
if (address == null) {
|
||||
ExerciseRowContent(fallback, group.summaryLine(miles))
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the kind-33401 template to show its real title; falls back to the slug
|
||||
// until the template is fetched (the workout event's relay hints drive the fetch).
|
||||
LoadAddressableNote(address, accountViewModel) { templateNote ->
|
||||
val name =
|
||||
if (templateNote != null) {
|
||||
val templateEvent by observeNoteEvent<ExerciseTemplateEvent>(templateNote, accountViewModel)
|
||||
templateEvent?.title() ?: fallback
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
ExerciseRowContent(name, group.summaryLine(miles))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExerciseRowContent(
|
||||
name: String,
|
||||
summary: String,
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
|
||||
Text(
|
||||
text = name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Small chip showing how the workout was recorded (e.g. GPS, RUNSTR, HEALTHKIT, MANUAL). */
|
||||
@Composable
|
||||
private fun WorkoutSourceBadge(source: String) {
|
||||
|
||||
@@ -635,6 +635,8 @@
|
||||
<string name="workout_sets">Sets</string>
|
||||
<string name="workout_reps">Reps</string>
|
||||
<string name="workout_weight">Weight</string>
|
||||
<string name="workout_exercises">Exercises</string>
|
||||
<string name="workout_volume">Volume</string>
|
||||
<string name="workout_notes">Notes</string>
|
||||
<string name="workout_hours">Hours</string>
|
||||
<string name="workout_minutes">Minutes</string>
|
||||
@@ -655,6 +657,9 @@
|
||||
<string name="exercise_meditation">Meditation</string>
|
||||
<string name="exercise_diet">Diet</string>
|
||||
<string name="exercise_fasting">Fasting</string>
|
||||
<string name="exercise_circuit">Circuit</string>
|
||||
<string name="exercise_emom">EMOM</string>
|
||||
<string name="exercise_amrap">AMRAP</string>
|
||||
<string name="software_apps">Apps</string>
|
||||
<string name="nip82_repository_label">Source: %1$s</string>
|
||||
<string name="nip82_version_label">v%1$s</string>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# POWR interop: rendering kind-1301 strength workouts
|
||||
|
||||
Research date: 2026-06-20. Source: POWR app (`DocNR/POWR`), spec at
|
||||
`docs/technical/nostr/exercise_nip.md` (the NIP-101e "Workout Events" draft:
|
||||
kinds 33401 exercise template, 33402 workout template, 1301 workout record).
|
||||
Sample event provided by a POWR user (`client: POWR`, `relay.powr.build`).
|
||||
|
||||
## The problem
|
||||
|
||||
POWR and RUNSTR both publish **kind 1301**, but the tag schemas are
|
||||
structurally incompatible. Amethyst already supported the RUNSTR dialect
|
||||
(`quartz/.../experimental/fitness/workout/`), so a POWR event rendered badly:
|
||||
the activity label showed the raw coordinate `33401:…:back-squat-bb`, there was
|
||||
no duration, and none of the set data appeared.
|
||||
|
||||
| Concern | RUNSTR dialect (existing) | POWR / NIP-101e dialect (new) |
|
||||
|---|---|---|
|
||||
| Activity type | `["exercise", "running"]` (verb) | `["type", "strength"]` (`strength`/`circuit`/`emom`/`amrap`) |
|
||||
| `exercise` tag | the activity verb | one **logged set**, referencing a 33401 template: `["exercise", "33401:pubkey:d-tag", relay, weightKg, reps, rpe, set_type, set_number]` |
|
||||
| Duration | `["duration", "HH:MM:SS"]` or raw seconds | none — derive from `["start", unix]` / `["end", unix]` |
|
||||
| Weight unit | lbs | **kg** (empty = bodyweight, negative = assisted) |
|
||||
| Misc | `source`, `t` hashtags | `completed`, `template`, `client` |
|
||||
|
||||
## What was implemented (read/render only)
|
||||
|
||||
Parsing + tests in Quartz, rendering in Amethyst. Amethyst still **publishes**
|
||||
the RUNSTR-canonical form (no POWR writer) — this is rendering interop.
|
||||
|
||||
### Quartz (`experimental/fitness/workout/`)
|
||||
- `tags/WorkoutTypeTag.kt` — POWR `type`.
|
||||
- `tags/WorkoutSessionTimeTags.kt` — `start` / `end` / `completed`.
|
||||
- `tags/ExerciseSetTag.kt` — the per-set coordinate form, with `isCoordinate()`
|
||||
to distinguish it from a RUNSTR verb (`kind:64-hex-pubkey:d-tag`).
|
||||
- `ExerciseGroup.kt` — groups sets by template reference (first-seen order,
|
||||
sorted by set number); `displayName()` prettifies the d-tag slug
|
||||
(`back-squat-bb` → "Back Squat Bb"); `totalVolumeKg()` / `topWeightKg()`.
|
||||
- `ExerciseTag.parse()` now returns null for the coordinate form, so the verb
|
||||
path never surfaces `33401:…` as an activity.
|
||||
- `ExerciseType` gains `CIRCUIT` / `EMOM` / `AMRAP` (icon + label reuse).
|
||||
- `WorkoutRecordEvent`: `activityType()` (prefers `type`, falls back to the
|
||||
verb), `effectiveDurationSeconds()` (duration tag, else `end - start`),
|
||||
`exerciseGroups()`, `client()`, `workoutCompleted()`.
|
||||
|
||||
### Exercise-template resolution (kind 33401)
|
||||
- `ExerciseTemplateEvent.kt` (kind 33401, addressable) + tag accessors
|
||||
(`title`, `format`, `format_units`, `equipment`, `difficulty`); registered
|
||||
in `EventFactory`.
|
||||
- `WorkoutRecordEvent` implements `AddressHintProvider`: `linkedAddressIds()`
|
||||
(deduped 33401 + 33402 coordinates) seed the gatherer so the card re-renders
|
||||
when a template arrives; `addressHints()` feed the relay-hint index with the
|
||||
`relay.powr.build` hints from each `exercise`/`template` tag — without these
|
||||
the fetch would only hit the viewer's own relays and usually miss.
|
||||
- `ExerciseSetTag.parseAsHint` / `TemplateTag.parseAsHint` build the hints.
|
||||
|
||||
### Amethyst (`WorkoutDisplay.kt`)
|
||||
- Activity label/icon come from `activityType()`; coordinate never leaks.
|
||||
- Hero = derived duration; secondary stats add Exercises / Sets / Volume.
|
||||
- `ExerciseBreakdown` lists each exercise with its sets, e.g.
|
||||
`Back Squat Bb → 3 × 8 × 84 kg`, in the viewer's preferred unit (kg↔lbs).
|
||||
- Each row resolves its 33401 template via `LoadAddressableNote` +
|
||||
`observeNoteEvent<ExerciseTemplateEvent>` (fetch-on-demand), showing the
|
||||
template's real `title()` once it arrives and the slug until then.
|
||||
|
||||
## Not done (possible follow-ups)
|
||||
- Render 33402 (the planned template) to show planned-vs-done.
|
||||
- A POWR-dialect writer (would need exercise-template authoring — large).
|
||||
- Surface `rpe` / `set_type` (warmup/drop/failure) per set, and the template's
|
||||
`equipment` as a per-exercise icon.
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.fitness.workout
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseSetTag
|
||||
|
||||
/**
|
||||
* The sets logged for one exercise within a POWR / NIP-101e workout, grouped by
|
||||
* the referenced exercise-template coordinate and ordered by set number.
|
||||
*/
|
||||
@Immutable
|
||||
class ExerciseGroup(
|
||||
val reference: String,
|
||||
val sets: List<ExerciseSetTag>,
|
||||
) {
|
||||
/** Best-effort human label derived from the template d-tag, e.g. `back-squat-bb` → "Back Squat Bb". */
|
||||
fun displayName(): String? = sets.firstNotNullOfOrNull { it.dTag() }?.let(::slugToTitle)
|
||||
|
||||
/** Sum of every set's volume in kilograms, or null if no set has both weight and reps. */
|
||||
fun totalVolumeKg(): Double? {
|
||||
val volumes = sets.mapNotNull { it.volumeKg() }
|
||||
return if (volumes.isEmpty()) null else volumes.sum()
|
||||
}
|
||||
|
||||
/** Heaviest weight logged across the group's sets, in kilograms. */
|
||||
fun topWeightKg(): Double? = sets.mapNotNull { it.weightKg }.filter { it > 0.0 }.maxOrNull()
|
||||
}
|
||||
|
||||
/** Groups exercise sets by their template reference, preserving first-seen order and sorting by set number. */
|
||||
fun groupExerciseSets(sets: List<ExerciseSetTag>): List<ExerciseGroup> {
|
||||
if (sets.isEmpty()) return emptyList()
|
||||
val byReference = LinkedHashMap<String, MutableList<ExerciseSetTag>>()
|
||||
sets.forEach { byReference.getOrPut(it.reference) { mutableListOf() }.add(it) }
|
||||
return byReference.map { (reference, groupSets) ->
|
||||
ExerciseGroup(reference, groupSets.sortedBy { it.setNumber ?: Int.MAX_VALUE })
|
||||
}
|
||||
}
|
||||
|
||||
/** Turns a NIP-101e d-tag slug (`seated-calf-raise-machine`) into a title (`Seated Calf Raise Machine`). */
|
||||
fun slugToTitle(slug: String): String =
|
||||
slug
|
||||
.split('-', '_', ' ')
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" ") { word -> word.replaceFirstChar { it.uppercaseChar() } }
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.fitness.workout
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DifficultyTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.EquipmentTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.FormatTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.FormatUnitsTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.TitleTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
|
||||
|
||||
/**
|
||||
* NIP-101e (draft) exercise template, kind 33401, as published by POWR. An
|
||||
* addressable, reusable definition referenced from the per-set `exercise` tags
|
||||
* of a [WorkoutRecordEvent] via its `33401:pubkey:d-tag` coordinate.
|
||||
*
|
||||
* Amethyst fetches these to show a real exercise name (and equipment) in a
|
||||
* workout card instead of the d-tag slug. Parsing is lax: every tag optional.
|
||||
*/
|
||||
@Immutable
|
||||
class ExerciseTemplateEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
SearchableEvent {
|
||||
override fun indexableContent() = listOfNotNull(title(), content).joinToString("\n")
|
||||
|
||||
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
|
||||
|
||||
fun format() = tags.firstNotNullOfOrNull(FormatTag::parse)
|
||||
|
||||
fun formatUnits() = tags.firstNotNullOfOrNull(FormatUnitsTag::parse)
|
||||
|
||||
fun equipment() = tags.firstNotNullOfOrNull(EquipmentTag::parse)
|
||||
|
||||
fun difficulty() = tags.firstNotNullOfOrNull(DifficultyTag::parse)
|
||||
|
||||
companion object {
|
||||
const val KIND = 33401
|
||||
const val ALT_DESCRIPTION = "Exercise template"
|
||||
}
|
||||
}
|
||||
+35
@@ -26,16 +26,23 @@ import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DistanceTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DurationTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ElevationGainTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ElevationLossTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseSetTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.MaxHeartRateTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.RepsTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.SetsTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.SourceTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.SplitTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.StepsTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.TemplateTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.TitleTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WeightTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WorkoutCompletedTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WorkoutEndTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WorkoutStartTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WorkoutStartTimeTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WorkoutTypeTag
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
|
||||
fun TagArray.title() = firstNotNullOfOrNull(TitleTag::parse)
|
||||
@@ -71,3 +78,31 @@ fun TagArray.weight() = firstNotNullOfOrNull(WeightTag::parse)
|
||||
fun TagArray.workoutSource() = firstNotNullOfOrNull(SourceTag::parse)
|
||||
|
||||
fun TagArray.workoutStartTime() = firstNotNullOfOrNull(WorkoutStartTimeTag::parse)
|
||||
|
||||
// --- POWR / NIP-101e strength dialect ---
|
||||
|
||||
fun TagArray.workoutTypeCode() = firstNotNullOfOrNull(WorkoutTypeTag::parse)
|
||||
|
||||
fun TagArray.workoutType() = workoutTypeCode()?.let(ExerciseType::parse)
|
||||
|
||||
fun TagArray.workoutStart() = firstNotNullOfOrNull(WorkoutStartTag::parse)
|
||||
|
||||
fun TagArray.workoutEnd() = firstNotNullOfOrNull(WorkoutEndTag::parse)
|
||||
|
||||
fun TagArray.workoutCompleted() = firstNotNullOfOrNull(WorkoutCompletedTag::parse)
|
||||
|
||||
fun TagArray.exerciseSets() = mapNotNull(ExerciseSetTag::parse)
|
||||
|
||||
fun TagArray.exerciseSetAddressIds() = mapNotNull(ExerciseSetTag::parseAddressId)
|
||||
|
||||
fun TagArray.exerciseSetHints() = mapNotNull(ExerciseSetTag::parseAsHint)
|
||||
|
||||
fun TagArray.templateAddressId() = firstNotNullOfOrNull(TemplateTag::parseAddressId)
|
||||
|
||||
fun TagArray.templateHint() = firstNotNullOfOrNull(TemplateTag::parseAsHint)
|
||||
|
||||
/** The client name from a `["client", name, ...]` tag (RUNSTR and POWR both emit this). */
|
||||
fun TagArray.clientName() =
|
||||
firstNotNullOfOrNull { tag ->
|
||||
if (tag.size > 1 && tag[0] == "client" && tag[1].isNotEmpty()) tag[1] else null
|
||||
}
|
||||
|
||||
+46
-1
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip22Comments.RootScope
|
||||
import com.vitorpamplona.quartz.nip50Search.SearchableEvent
|
||||
@@ -49,9 +50,21 @@ class WorkoutRecordEvent(
|
||||
sig: HexKey,
|
||||
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
RootScope,
|
||||
SearchableEvent {
|
||||
SearchableEvent,
|
||||
AddressHintProvider {
|
||||
override fun indexableContent() = listOfNotNull(title(), content).joinToString("\n")
|
||||
|
||||
// POWR / NIP-101e workouts reference kind-33401 exercise templates (and a kind-33402
|
||||
// workout template) by coordinate + relay hint, so the templates can be fetched and
|
||||
// rendered with real names. RUNSTR workouts carry none of these and contribute nothing.
|
||||
override fun addressHints() =
|
||||
buildList {
|
||||
addAll(tags.exerciseSetHints())
|
||||
tags.templateHint()?.let { add(it) }
|
||||
}
|
||||
|
||||
override fun linkedAddressIds() = (tags.exerciseSetAddressIds() + listOfNotNull(tags.templateAddressId())).distinct()
|
||||
|
||||
fun title() = tags.title()
|
||||
|
||||
fun exercise() = tags.exercise()
|
||||
@@ -86,6 +99,38 @@ class WorkoutRecordEvent(
|
||||
|
||||
fun workoutStartTime() = tags.workoutStartTime()
|
||||
|
||||
// --- POWR / NIP-101e strength dialect ---
|
||||
|
||||
fun workoutTypeCode() = tags.workoutTypeCode()
|
||||
|
||||
fun workoutType() = tags.workoutType()
|
||||
|
||||
fun workoutStart() = tags.workoutStart()
|
||||
|
||||
fun workoutEnd() = tags.workoutEnd()
|
||||
|
||||
fun workoutCompleted() = tags.workoutCompleted()
|
||||
|
||||
fun exerciseSets() = tags.exerciseSets()
|
||||
|
||||
fun exerciseGroups() = groupExerciseSets(exerciseSets())
|
||||
|
||||
fun client() = tags.clientName()
|
||||
|
||||
/** Activity type, preferring the POWR `type` tag and falling back to the RUNSTR `exercise` verb. */
|
||||
fun activityType() = workoutType() ?: exerciseType()
|
||||
|
||||
/**
|
||||
* Duration in seconds: the explicit `duration` tag if present (RUNSTR),
|
||||
* otherwise derived from the POWR `start`/`end` session timestamps.
|
||||
*/
|
||||
fun effectiveDurationSeconds(): Long? {
|
||||
durationSeconds()?.let { return it }
|
||||
val start = workoutStart()
|
||||
val end = workoutEnd()
|
||||
return if (start != null && end != null && end > start) end - start else null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KIND = 1301
|
||||
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.fitness.workout.tags
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* A single logged set in the POWR / NIP-101e strength dialect.
|
||||
*
|
||||
* Tag layout: `["exercise", "<kind>:<pubkey>:<d-tag>", "<relay>", weight, reps,
|
||||
* rpe, set_type, set_number]`, where the second element is an addressable
|
||||
* coordinate to a kind-33401 exercise template. The set fields, in order:
|
||||
*
|
||||
* - **weight** — kilograms; empty string means bodyweight, negative means assisted.
|
||||
* - **reps** — repetition count.
|
||||
* - **rpe** — Rate of Perceived Exertion, 0..10.
|
||||
* - **set_type** — `warmup` | `normal` | `drop` | `failure`.
|
||||
* - **set_number** — 1-based index (POWR extension, not in the base spec).
|
||||
*
|
||||
* This is structurally distinct from RUNSTR's `exercise` tag (a plain activity
|
||||
* verb such as `running`), so [parse] only matches the coordinate form.
|
||||
*/
|
||||
@Stable
|
||||
class ExerciseSetTag(
|
||||
val reference: String,
|
||||
val relayHint: String?,
|
||||
val weightKg: Double?,
|
||||
val reps: Int?,
|
||||
val rpe: Double?,
|
||||
val setType: String?,
|
||||
val setNumber: Int?,
|
||||
) {
|
||||
/** The d-tag slug of the referenced exercise template, e.g. `back-squat-bb`. */
|
||||
fun dTag(): String? = reference.split(":", limit = 3).getOrNull(2)?.ifBlank { null }
|
||||
|
||||
/** Volume of this set in kilograms (weight × reps), when both are known and positive. */
|
||||
fun volumeKg(): Double? {
|
||||
val w = weightKg ?: return null
|
||||
val r = reps ?: return null
|
||||
return if (w > 0.0 && r > 0) w * r else null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG_NAME = "exercise"
|
||||
|
||||
const val SET_TYPE_WARMUP = "warmup"
|
||||
const val SET_TYPE_NORMAL = "normal"
|
||||
const val SET_TYPE_DROP = "drop"
|
||||
const val SET_TYPE_FAILURE = "failure"
|
||||
|
||||
/**
|
||||
* True when [value] is an addressable coordinate (`kind:pubkey:d-tag`) rather
|
||||
* than a plain verb. Used to tell the POWR `exercise` form from RUNSTR's.
|
||||
*/
|
||||
fun isCoordinate(value: String): Boolean {
|
||||
val parts = value.split(":", limit = 3)
|
||||
return parts.size == 3 && parts[0].toIntOrNull() != null && parts[1].length == 64
|
||||
}
|
||||
|
||||
/** The referenced exercise-template coordinate, when the tag is the POWR set form. */
|
||||
fun parseAddressId(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(isCoordinate(tag[1])) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
/** The relay hint for the referenced exercise template, so it can be fetched. */
|
||||
fun parseAsHint(tag: Array<String>): AddressHint? {
|
||||
ensure(tag.has(2)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(isCoordinate(tag[1])) { return null }
|
||||
ensure(tag[2].isNotEmpty()) { return null }
|
||||
val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) ?: return null
|
||||
return AddressHint(tag[1], relayHint)
|
||||
}
|
||||
|
||||
fun parse(tag: Array<String>): ExerciseSetTag? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(isCoordinate(tag[1])) { return null }
|
||||
return ExerciseSetTag(
|
||||
reference = tag[1],
|
||||
relayHint = tag.getOrNull(2)?.ifBlank { null },
|
||||
weightKg = tag.getOrNull(3)?.toDoubleOrNull(),
|
||||
reps = tag.getOrNull(4)?.toIntOrNull(),
|
||||
rpe = tag.getOrNull(5)?.toDoubleOrNull(),
|
||||
setType = tag.getOrNull(6)?.ifBlank { null },
|
||||
setNumber = tag.getOrNull(7)?.toIntOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-4
@@ -24,9 +24,13 @@ import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* Activity verbs used by NIP-101e clients (RUNSTR dialect). The tag value is the
|
||||
* lowercase [code]; the matching capitalized [hashtag] is published as a `t` tag
|
||||
* so the workout is discoverable.
|
||||
* Activity / workout types understood across the kind-1301 dialects.
|
||||
*
|
||||
* The first group are the RUNSTR activity verbs carried in the `exercise` tag;
|
||||
* the [STRENGTH]/[CIRCUIT]/[EMOM]/[AMRAP] values double as the POWR / NIP-101e
|
||||
* `type` tag classifications. The tag value is the lowercase [code]; the
|
||||
* matching capitalized [hashtag] is published as a `t` tag so RUNSTR workouts
|
||||
* stay discoverable.
|
||||
*/
|
||||
enum class ExerciseType(
|
||||
val code: String,
|
||||
@@ -43,6 +47,9 @@ enum class ExerciseType(
|
||||
MEDITATION("meditation", "Meditation"),
|
||||
DIET("diet", "Diet"),
|
||||
FASTING("fasting", "Fasting"),
|
||||
CIRCUIT("circuit", "Circuit"),
|
||||
EMOM("emom", "EMOM"),
|
||||
AMRAP("amrap", "AMRAP"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
@@ -56,11 +63,17 @@ class ExerciseTag {
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
/** Returns the raw verb. Other clients may publish verbs outside [ExerciseType]. */
|
||||
/**
|
||||
* Returns the raw activity verb. Other clients may publish verbs outside
|
||||
* [ExerciseType]. Returns null for the POWR coordinate form
|
||||
* (`33401:pubkey:d-tag`), which carries per-set data, not a verb — parse
|
||||
* those with [ExerciseSetTag] instead.
|
||||
*/
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
ensure(!ExerciseSetTag.isCoordinate(tag[1])) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.fitness.workout.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/** The ordered parameter names a kind-33401 exercise expects per set, e.g. `weight reps rpe set_type`. */
|
||||
class FormatTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "format"
|
||||
|
||||
fun parse(tag: Array<String>): List<String>? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
return tag.drop(1).filter { it.isNotEmpty() }.ifEmpty { null }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The units for each [FormatTag] parameter, e.g. `kg count 0-10 enum`. */
|
||||
class FormatUnitsTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "format_units"
|
||||
|
||||
fun parse(tag: Array<String>): List<String>? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
return tag.drop(1).filter { it.isNotEmpty() }.ifEmpty { null }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Equipment used by a kind-33401 exercise: `barbell` | `dumbbell` | `bodyweight` | `machine` | `cardio`. */
|
||||
class EquipmentTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "equipment"
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Difficulty of a kind-33401 exercise: `beginner` | `intermediate` | `advanced`. */
|
||||
class DifficultyTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "difficulty"
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.fitness.workout.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* Reference to the kind-33402 workout template a POWR session was built from:
|
||||
* `["template", "33402:pubkey:d-tag", "<relay>"]`.
|
||||
*/
|
||||
class TemplateTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "template"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parseAddressId(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(ExerciseSetTag.isCoordinate(tag[1])) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun parseAsHint(tag: Array<String>): AddressHint? {
|
||||
ensure(tag.has(2)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(ExerciseSetTag.isCoordinate(tag[1])) { return null }
|
||||
ensure(tag[2].isNotEmpty()) { return null }
|
||||
val relayHint = RelayUrlNormalizer.normalizeOrNull(tag[2]) ?: return null
|
||||
return AddressHint(tag[1], relayHint)
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.fitness.workout.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* Session start time (unix seconds) as published by the POWR / NIP-101e dialect.
|
||||
* RUNSTR uses [WorkoutStartTimeTag] (`workout_start_time`) instead.
|
||||
*/
|
||||
class WorkoutStartTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "start"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): Long? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
return tag[1].toLongOrNull()
|
||||
}
|
||||
|
||||
fun assemble(timestamp: Long) = arrayOf(TAG_NAME, timestamp.toString())
|
||||
}
|
||||
}
|
||||
|
||||
/** Session end time (unix seconds), POWR / NIP-101e dialect. */
|
||||
class WorkoutEndTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "end"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): Long? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
return tag[1].toLongOrNull()
|
||||
}
|
||||
|
||||
fun assemble(timestamp: Long) = arrayOf(TAG_NAME, timestamp.toString())
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the session was completed as planned, POWR / NIP-101e dialect. */
|
||||
class WorkoutCompletedTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "completed"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): Boolean? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
return tag[1].toBooleanStrictOrNull()
|
||||
}
|
||||
|
||||
fun assemble(completed: Boolean) = arrayOf(TAG_NAME, completed.toString())
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.fitness.workout.tags
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.has
|
||||
import com.vitorpamplona.quartz.utils.ensure
|
||||
|
||||
/**
|
||||
* Workout classification used by the POWR / NIP-101e strength dialect, e.g.
|
||||
* `strength`, `circuit`, `emom`, `amrap`. RUNSTR clients put the activity verb
|
||||
* in the `exercise` tag instead and omit this one, so treat it as optional.
|
||||
*/
|
||||
class WorkoutTypeTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "type"
|
||||
|
||||
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
|
||||
|
||||
fun parse(tag: Array<String>): String? {
|
||||
ensure(tag.has(1)) { return null }
|
||||
ensure(tag[0] == TAG_NAME) { return null }
|
||||
ensure(tag[1].isNotEmpty()) { return null }
|
||||
return tag[1]
|
||||
}
|
||||
|
||||
fun assemble(type: String) = arrayOf(TAG_NAME, type)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
+126
@@ -20,9 +20,11 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.experimental.fitness
|
||||
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.calories
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.distance
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.slugToTitle
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.source
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.DurationTag
|
||||
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
|
||||
@@ -134,6 +136,130 @@ class WorkoutRecordEventTest {
|
||||
assertNull(event.distance())
|
||||
}
|
||||
|
||||
/** Tag layout as published by POWR (NIP-101e strength dialect: per-set `exercise` coordinates). */
|
||||
@Test
|
||||
fun parsesPowrDialect() {
|
||||
val coord = "33401:0bdd91e8a30d87d041eafd1871f17d426fa415c69a9a822eccad49017bac59e7:back-squat-bb"
|
||||
val relay = "wss://relay.powr.build"
|
||||
val event =
|
||||
parse(
|
||||
arrayOf(
|
||||
arrayOf("title", "Novice Hypertrophy — Day 2"),
|
||||
arrayOf("type", "strength"),
|
||||
arrayOf("start", "1781969106"),
|
||||
arrayOf("end", "1781972319"),
|
||||
arrayOf("completed", "true"),
|
||||
arrayOf("template", "33402:0bdd91e8a30d87d041eafd1871f17d426fa415c69a9a822eccad49017bac59e7:novice-hyp-day2-lower-a", relay),
|
||||
arrayOf("exercise", coord, relay, "84", "8", "8", "normal", "1"),
|
||||
arrayOf("exercise", coord, relay, "84", "8", "8", "normal", "2"),
|
||||
arrayOf("exercise", coord, relay, "84", "8", "9", "normal", "3"),
|
||||
arrayOf("exercise", "33401:0bdd91e8a30d87d041eafd1871f17d426fa415c69a9a822eccad49017bac59e7:seated-calf-raise-machine", relay, "", "8", "", "normal", "1"),
|
||||
arrayOf("client", "POWR"),
|
||||
),
|
||||
) as WorkoutRecordEvent
|
||||
|
||||
// The activity type now comes from the `type` tag, not the coordinate-form `exercise` tag.
|
||||
assertEquals(ExerciseType.STRENGTH, event.workoutType())
|
||||
assertEquals(ExerciseType.STRENGTH, event.activityType())
|
||||
assertNull(event.exercise()) // must not surface "33401:..." as a verb
|
||||
assertNull(event.exerciseType())
|
||||
|
||||
assertEquals("Novice Hypertrophy — Day 2", event.title())
|
||||
assertEquals(1781969106L, event.workoutStart())
|
||||
assertEquals(1781972319L, event.workoutEnd())
|
||||
assertEquals(true, event.workoutCompleted())
|
||||
assertEquals("POWR", event.client())
|
||||
|
||||
// No `duration` tag: derived from start/end.
|
||||
assertEquals(1781972319L - 1781969106L, event.effectiveDurationSeconds())
|
||||
|
||||
val sets = event.exerciseSets()
|
||||
assertEquals(4, sets.size)
|
||||
assertEquals(84.0, sets[0].weightKg)
|
||||
assertEquals(8, sets[0].reps)
|
||||
assertEquals(8.0, sets[0].rpe)
|
||||
assertEquals("normal", sets[0].setType)
|
||||
assertEquals(1, sets[0].setNumber)
|
||||
assertEquals("back-squat-bb", sets[0].dTag())
|
||||
// Bodyweight / machine set leaves weight empty.
|
||||
assertNull(sets[3].weightKg)
|
||||
assertEquals(8, sets[3].reps)
|
||||
|
||||
val groups = event.exerciseGroups()
|
||||
assertEquals(2, groups.size)
|
||||
assertEquals("Back Squat Bb", groups[0].displayName())
|
||||
assertEquals(3, groups[0].sets.size)
|
||||
assertEquals(84.0 * 8 * 3, groups[0].totalVolumeKg()) // 3 sets of 8 reps @ 84 kg (the 9 is RPE)
|
||||
assertEquals(84.0, groups[0].topWeightKg())
|
||||
assertEquals("Seated Calf Raise Machine", groups[1].displayName())
|
||||
assertNull(groups[1].totalVolumeKg()) // no weights logged
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exposesPowrTemplateReferencesAsHints() {
|
||||
val backSquat = "33401:0bdd91e8a30d87d041eafd1871f17d426fa415c69a9a822eccad49017bac59e7:back-squat-bb"
|
||||
val deadlift = "33401:0bdd91e8a30d87d041eafd1871f17d426fa415c69a9a822eccad49017bac59e7:deadlift-bb"
|
||||
val template = "33402:0bdd91e8a30d87d041eafd1871f17d426fa415c69a9a822eccad49017bac59e7:novice-hyp-day2-lower-a"
|
||||
val relay = "wss://relay.powr.build"
|
||||
val event =
|
||||
parse(
|
||||
arrayOf(
|
||||
arrayOf("type", "strength"),
|
||||
arrayOf("template", template, relay),
|
||||
arrayOf("exercise", backSquat, relay, "84", "8", "8", "normal", "1"),
|
||||
arrayOf("exercise", backSquat, relay, "84", "8", "8", "normal", "2"),
|
||||
arrayOf("exercise", deadlift, relay, "100", "5", "8", "normal", "1"),
|
||||
),
|
||||
) as WorkoutRecordEvent
|
||||
|
||||
// Each referenced template is exposed once for fetching (deduped), incl. the 33402 template.
|
||||
val linked = event.linkedAddressIds()
|
||||
assertEquals(setOf(backSquat, deadlift, template), linked.toSet())
|
||||
assertEquals(3, linked.size)
|
||||
|
||||
// Relay hints let Amethyst fetch the templates from where POWR published them.
|
||||
val hints = event.addressHints()
|
||||
assertEquals(setOf(backSquat, deadlift, template), hints.map { it.addressId }.toSet())
|
||||
assertTrue(hints.all { it.relay.url == "wss://relay.powr.build/" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesExerciseTemplate() {
|
||||
val event: Event =
|
||||
EventFactory.create(
|
||||
id = "a".repeat(64),
|
||||
pubKey = "b".repeat(64),
|
||||
createdAt = 1718000000L,
|
||||
kind = ExerciseTemplateEvent.KIND,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("d", "back-squat-bb"),
|
||||
arrayOf("title", "Back Squat (Barbell)"),
|
||||
arrayOf("format", "weight", "reps", "rpe", "set_type"),
|
||||
arrayOf("format_units", "kg", "count", "0-10", "enum"),
|
||||
arrayOf("equipment", "barbell"),
|
||||
arrayOf("difficulty", "intermediate"),
|
||||
),
|
||||
content = "Keep a neutral spine.",
|
||||
sig = "c".repeat(128),
|
||||
)
|
||||
|
||||
assertTrue(event is ExerciseTemplateEvent)
|
||||
assertEquals("back-squat-bb", event.dTag())
|
||||
assertEquals("Back Squat (Barbell)", event.title())
|
||||
assertEquals(listOf("weight", "reps", "rpe", "set_type"), event.format())
|
||||
assertEquals(listOf("kg", "count", "0-10", "enum"), event.formatUnits())
|
||||
assertEquals("barbell", event.equipment())
|
||||
assertEquals("intermediate", event.difficulty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun slugToTitlePrettifiesDTags() {
|
||||
assertEquals("Back Squat Bb", slugToTitle("back-squat-bb"))
|
||||
assertEquals("Seated Calf Raise Machine", slugToTitle("seated-calf-raise-machine"))
|
||||
assertEquals("Deadlift", slugToTitle("deadlift"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun durationFormatsAsPaddedTime() {
|
||||
assertEquals("00:31:30", DurationTag.formatTime(31 * 60 + 30L))
|
||||
|
||||
Reference in New Issue
Block a user