Files
proof-of-work/proof-of-work/lib/exerciseOptions.ts
T
Keysat 4be489d6d3 v1.2.0:5 — Gear (breathing, 1-5) replaces RPE as the effort field for cardio
Cardio exercises now log a breathing "Gear" (1-5, per Brian MacKenzie)
instead of RPE (6-10) as their effort field; strength keeps RPE. An exercise
counts as cardio when its equipment type is "cardio" or it carries the
"cardio" muscle group (isCardioExercise in lib/exerciseOptions), so the
Assault Bike (type "assault bike") qualifies.

New nullable SetLog.gear column added by the boot-time guarded ALTER in
docker_entrypoint.sh (additive, idempotent); plumbed through all 5 set-write
paths, the summary/edit views, and CSV/JSON import-export. Existing rpe data
is untouched and still displays. Program/AI target-RPE is unaffected.
2026-06-16 14:49:15 -05:00

150 lines
4.0 KiB
TypeScript

import { Exercise } from "@prisma/client";
export type Option = {
value: string;
label: string;
};
export const BASE_EQUIPMENT_OPTIONS: Option[] = [
{ value: "barbell", label: "Barbell" },
{ value: "dumbbell", label: "Dumbbell" },
{ value: "machine", label: "Machine" },
{ value: "cable", label: "Cable" },
{ value: "bodyweight", label: "Bodyweight" },
{ value: "cardio", label: "Cardio" },
{ value: "kettlebell", label: "Kettlebell" },
{ value: "other", label: "Other" },
];
export const BASE_MUSCLE_GROUPS: string[] = [
"chest",
"back",
"shoulders",
"quads",
"hamstrings",
"glutes",
"biceps",
"triceps",
"forearms",
"core",
"calves",
"full body",
"cardio",
"abs",
"adductors",
"legs",
"traps",
"hip flexors",
"neck",
"obliques",
];
export const BASE_TRACKING_FIELDS: Option[] = [
{ value: "sets", label: "Sets" },
{ value: "reps", label: "Reps" },
{ value: "weight", label: "Weight" },
{ value: "duration", label: "Time" },
{ value: "distance", label: "Distance" },
{ value: "calories", label: "Calories" },
{ value: "watts", label: "Avg. watts" },
{ value: "notes", label: "Notes" },
];
function titleCaseToken(input: string): string {
return input
.split(" ")
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function parseJsonArray(raw: string | null): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.map(String) : [];
} catch {
return [];
}
}
export function normalizeValue(input: string): string {
return input.trim().toLowerCase().replace(/\s+/g, " ");
}
export function deriveEquipmentOptions(exercises: Exercise[]): Option[] {
const baseValues = new Set(BASE_EQUIPMENT_OPTIONS.map((item) => item.value));
const customValues = new Set<string>();
for (const exercise of exercises) {
const value = normalizeValue(exercise.type || "");
if (value && !baseValues.has(value)) {
customValues.add(value);
}
}
const customOptions = Array.from(customValues)
.sort()
.map((value) => ({ value, label: titleCaseToken(value) }));
return [...BASE_EQUIPMENT_OPTIONS, ...customOptions];
}
export function deriveMuscleGroupOptions(exercises: Exercise[]): string[] {
const baseValues = new Set(BASE_MUSCLE_GROUPS.map(normalizeValue));
const customValues = new Set<string>();
for (const exercise of exercises) {
const groups = parseJsonArray(exercise.muscleGroups);
for (const group of groups) {
const value = normalizeValue(group);
if (value && !baseValues.has(value)) {
customValues.add(value);
}
}
}
return [...BASE_MUSCLE_GROUPS, ...Array.from(customValues).sort()];
}
export function deriveTrackingFieldOptions(exercises: Exercise[]): Option[] {
const baseValues = new Set(BASE_TRACKING_FIELDS.map((item) => item.value));
const customValues = new Set<string>();
for (const exercise of exercises) {
const fields = parseJsonArray((exercise as any).inputFields || "[]");
for (const field of fields) {
const value = normalizeValue(field);
if (value && !baseValues.has(value)) {
customValues.add(value);
}
}
}
const customOptions = Array.from(customValues)
.sort()
.map((value) => ({ value, label: titleCaseToken(value) }));
return [...BASE_TRACKING_FIELDS, ...customOptions];
}
export function displayLabel(value: string): string {
return titleCaseToken(value);
}
/**
* Cardio exercises log breathing "Gear" (1-5) instead of RPE (6-10) as their
* effort field. An exercise counts as cardio if its equipment type is "cardio"
* or it carries the "cardio" muscle group (e.g. Assault Bike, type
* "assault bike", is tagged cardio).
*/
export function isCardioExercise(exercise: {
type?: string | null;
muscleGroups?: string | null;
}): boolean {
if (normalizeValue(exercise.type || "") === "cardio") return true;
return parseJsonArray(exercise.muscleGroups ?? null).some(
(group) => normalizeValue(group) === "cardio"
);
}