4be489d6d3
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.
150 lines
4.0 KiB
TypeScript
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"
|
|
);
|
|
}
|