v1.2.0:4 — make avg. watts a first-class SetLog field
Average watts (assault bike, rower, ski erg) was a free-text entry stuffed into the per-set customMetrics JSON blob. Promote it to a real nullable column, SetLog.watts, written through every set path (create / PATCH / add-sets / import-save / account-import) and shown everywhere as "Avg. watts" with a proper numeric input. The column is added by the boot-time guarded ALTER in docker_entrypoint.sh (additive, idempotent), so the version migration stays empty. Existing data is untouched: legacy watts values remain readable from customMetrics and migrate to the column the next time a set is saved.
This commit is contained in:
@@ -19,6 +19,7 @@ interface ParsedSet {
|
||||
distance?: number;
|
||||
distanceUnit?: string;
|
||||
calories?: number;
|
||||
watts?: number;
|
||||
rpe?: number;
|
||||
customMetrics?: Record<string, string>;
|
||||
notes?: string;
|
||||
@@ -136,6 +137,7 @@ export async function POST(request: NextRequest) {
|
||||
"distance_unit",
|
||||
"distanceunit",
|
||||
"calories",
|
||||
"watts",
|
||||
"rpe",
|
||||
"notes",
|
||||
"custom_metrics_json",
|
||||
@@ -199,6 +201,7 @@ export async function POST(request: NextRequest) {
|
||||
const distance = parseFloatMaybe(row.distance);
|
||||
const distanceUnit = row.distance_unit || row.distanceunit || (distance !== undefined ? "mi" : undefined);
|
||||
const calories = parseIntMaybe(row.calories);
|
||||
const watts = parseIntMaybe(row.watts);
|
||||
const rpe = parseIntMaybe(row.rpe);
|
||||
|
||||
const customMetrics: Record<string, string> = {};
|
||||
@@ -253,6 +256,7 @@ export async function POST(request: NextRequest) {
|
||||
distance,
|
||||
distanceUnit,
|
||||
calories,
|
||||
watts,
|
||||
rpe,
|
||||
customMetrics: Object.keys(customMetrics).length > 0 ? customMetrics : undefined,
|
||||
notes: notes || undefined,
|
||||
|
||||
@@ -55,6 +55,7 @@ const setLogImport = z.object({
|
||||
distance: z.number().nullable().optional(),
|
||||
distanceUnit: z.string().nullable().optional(),
|
||||
calories: z.number().int().nullable().optional(),
|
||||
watts: z.number().int().nullable().optional(),
|
||||
customMetrics: z.string().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
// The exported set carries an exerciseId pointing into the export's
|
||||
@@ -205,6 +206,7 @@ export async function POST(request: NextRequest) {
|
||||
distance: s.distance ?? null,
|
||||
distanceUnit: s.distanceUnit ?? null,
|
||||
calories: s.calories ?? null,
|
||||
watts: s.watts ?? null,
|
||||
customMetrics: s.customMetrics ?? null,
|
||||
notes: s.notes ?? null,
|
||||
});
|
||||
|
||||
@@ -70,6 +70,7 @@ export async function GET() {
|
||||
"distance",
|
||||
"distanceUnit",
|
||||
"setCalories",
|
||||
"setWatts",
|
||||
"rpe",
|
||||
"setNotes",
|
||||
"customMetricsJson",
|
||||
@@ -101,6 +102,7 @@ export async function GET() {
|
||||
set.distance ?? "",
|
||||
set.distanceUnit ?? "",
|
||||
set.calories ?? "",
|
||||
set.watts ?? "",
|
||||
set.rpe ?? "",
|
||||
set.notes ?? "",
|
||||
set.customMetrics ?? "",
|
||||
|
||||
@@ -61,6 +61,7 @@ const setSchema = z.object({
|
||||
distance: z.number().positive().optional().nullable(),
|
||||
distanceUnit: z.string().optional().nullable(),
|
||||
calories: z.number().int().positive().optional().nullable(),
|
||||
watts: z.number().int().positive().optional().nullable(),
|
||||
customMetrics: z.record(z.string()).optional().nullable(),
|
||||
notes: z.string().optional().nullable(),
|
||||
});
|
||||
@@ -156,6 +157,7 @@ export async function PATCH(
|
||||
distance: set.distance ?? undefined,
|
||||
distanceUnit: set.distanceUnit ?? undefined,
|
||||
calories: set.calories ?? undefined,
|
||||
watts: set.watts ?? undefined,
|
||||
customMetrics:
|
||||
set.customMetrics && Object.keys(set.customMetrics).length > 0
|
||||
? JSON.stringify(set.customMetrics)
|
||||
|
||||
@@ -18,6 +18,7 @@ const addSetsSchema = z.object({
|
||||
distance: z.number().positive().optional(),
|
||||
distanceUnit: z.string().optional(),
|
||||
calories: z.number().int().positive().optional(),
|
||||
watts: z.number().int().positive().optional(),
|
||||
customMetrics: z.record(z.string()).optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
@@ -83,6 +84,7 @@ export async function POST(
|
||||
distance: set.distance,
|
||||
distanceUnit: set.distanceUnit,
|
||||
calories: set.calories,
|
||||
watts: set.watts,
|
||||
customMetrics:
|
||||
set.customMetrics && Object.keys(set.customMetrics).length > 0
|
||||
? JSON.stringify(set.customMetrics)
|
||||
|
||||
@@ -13,6 +13,7 @@ const setSchema = z.object({
|
||||
distance: z.number().positive().optional(),
|
||||
distanceUnit: z.string().optional(),
|
||||
calories: z.number().int().positive().optional(),
|
||||
watts: z.number().int().positive().optional(),
|
||||
rpe: z.number().int().min(1).max(10).optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
@@ -127,6 +128,7 @@ export async function POST(request: Request) {
|
||||
distance: set.distance || null,
|
||||
distanceUnit: set.distanceUnit || null,
|
||||
calories: set.calories || null,
|
||||
watts: set.watts || null,
|
||||
notes: set.notes || null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ const createWorkoutSchema = z.object({
|
||||
distance: z.number().positive().optional(),
|
||||
distanceUnit: z.string().optional(),
|
||||
calories: z.number().int().positive().optional(),
|
||||
watts: z.number().int().positive().optional(),
|
||||
customMetrics: z.record(z.string()).optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
@@ -178,6 +179,7 @@ export async function POST(request: NextRequest) {
|
||||
distance: set.distance,
|
||||
distanceUnit: set.distanceUnit,
|
||||
calories: set.calories,
|
||||
watts: set.watts,
|
||||
customMetrics:
|
||||
set.customMetrics && Object.keys(set.customMetrics).length > 0
|
||||
? JSON.stringify(set.customMetrics)
|
||||
|
||||
@@ -57,6 +57,7 @@ const INPUT_FIELD_OPTIONS = [
|
||||
{ value: "duration", label: "Duration" },
|
||||
{ value: "distance", label: "Distance" },
|
||||
{ value: "calories", label: "Calories" },
|
||||
{ value: "watts", label: "Avg. watts" },
|
||||
{ value: "notes", label: "Notes" },
|
||||
];
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ interface ParsedSet {
|
||||
distance?: number;
|
||||
distanceUnit?: string;
|
||||
calories?: number;
|
||||
watts?: number;
|
||||
rpe?: number;
|
||||
customMetrics?: Record<string, string>;
|
||||
notes?: string;
|
||||
@@ -392,6 +393,9 @@ export default function ImportCSVPage() {
|
||||
if (typeof set.calories === "number" && !Number.isNaN(set.calories)) {
|
||||
payloadSet.calories = set.calories;
|
||||
}
|
||||
if (typeof set.watts === "number" && !Number.isNaN(set.watts)) {
|
||||
payloadSet.watts = set.watts;
|
||||
}
|
||||
if (typeof set.rpe === "number" && !Number.isNaN(set.rpe)) {
|
||||
payloadSet.rpe = set.rpe;
|
||||
}
|
||||
@@ -763,7 +767,7 @@ export default function ImportCSVPage() {
|
||||
</p>
|
||||
<p className="text-sm text-zinc-500">
|
||||
CSV columns: date, exercise, set, weight, reps, duration_seconds,
|
||||
distance, distance_unit, calories, rpe, notes, custom_*
|
||||
distance, distance_unit, calories, watts, rpe, notes, custom_*
|
||||
</p>
|
||||
</div>
|
||||
{loading && (
|
||||
@@ -778,12 +782,12 @@ export default function ImportCSVPage() {
|
||||
CSV Format Example
|
||||
</h3>
|
||||
<pre className="text-xs text-zinc-400 overflow-x-auto bg-zinc-950 p-4 rounded border border-zinc-800">
|
||||
{`date,exercise,set,weight,weight_unit,reps,duration_seconds,distance,distance_unit,calories,rpe,notes,custom_temperature,custom_watts,custom_metrics_json
|
||||
2025-02-15,Bench,1,225,lbs,5,,,,,8,good form,,,
|
||||
2025-02-15,Bench,2,225,lbs,5,,,,,8,,,,
|
||||
2025-02-16,Squat,1,315,lbs,8,,,,,9,30kg per leg,,,
|
||||
2025-02-17,Assault Bike,1,,, ,900,5,mi,120,7,,,"{\"resistance\":\"8\"}"
|
||||
2025-02-18,Cold Plunge,1,,, ,180,,,,,felt great,50,,`}
|
||||
{`date,exercise,set,weight,weight_unit,reps,duration_seconds,distance,distance_unit,calories,watts,rpe,notes,custom_temperature,custom_metrics_json
|
||||
2025-02-15,Bench,1,225,lbs,5,,,,,,8,good form,,
|
||||
2025-02-15,Bench,2,225,lbs,5,,,,,,8,,,
|
||||
2025-02-16,Squat,1,315,lbs,8,,,,,,9,30kg per leg,,
|
||||
2025-02-17,Assault Bike,1,,, ,900,5,mi,120,157,7,,,"{\"resistance\":\"8\"}"
|
||||
2025-02-18,Cold Plunge,1,,, ,180,,,,,,felt great,50,`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ function buildSetSummary(set: {
|
||||
durationSeconds?: number | null;
|
||||
distance?: number | null;
|
||||
calories?: number | null;
|
||||
watts?: number | null;
|
||||
customMetrics?: string | null;
|
||||
}) {
|
||||
const parts: string[] = [];
|
||||
@@ -35,11 +36,20 @@ function buildSetSummary(set: {
|
||||
}
|
||||
if ((set as any).distance) parts.push(`${(set as any).distance} mi`);
|
||||
if ((set as any).calories) parts.push(`${(set as any).calories} cal`);
|
||||
if ((set as any).watts) parts.push(`${(set as any).watts} W`);
|
||||
if ((set as any).customMetrics) {
|
||||
try {
|
||||
const custom = JSON.parse((set as any).customMetrics) as Record<string, string>;
|
||||
for (const [k, v] of Object.entries(custom)) {
|
||||
if (v) parts.push(`${k}: ${v}`);
|
||||
if (!v) continue;
|
||||
// Watts is now a first-class column. Legacy sets still carry it under
|
||||
// customMetrics — render it the same way (and skip if the column
|
||||
// already supplied it) so old and new sets read identically.
|
||||
if (k === "watts") {
|
||||
if (!(set as any).watts) parts.push(`${v} W`);
|
||||
continue;
|
||||
}
|
||||
parts.push(`${k}: ${v}`);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ export default async function NewWorkoutPage(props: {
|
||||
durationSeconds: set.durationSeconds ?? undefined,
|
||||
distance: set.distance ?? undefined,
|
||||
calories: set.calories ?? undefined,
|
||||
watts: set.watts ?? undefined,
|
||||
customMetrics,
|
||||
notes: set.notes ?? undefined,
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function ExercisePicker({
|
||||
// Derive custom types/muscles/fields from existing exercises
|
||||
const knownTypeValues = EXERCISE_TYPES.map((t) => t.value);
|
||||
const knownMuscleValues = MUSCLE_GROUPS.map((g) => g.toLowerCase());
|
||||
const knownFieldValues = ["sets", "reps", "weight", "duration", "distance", "calories", "notes"];
|
||||
const knownFieldValues = ["sets", "reps", "weight", "duration", "distance", "calories", "watts", "notes"];
|
||||
|
||||
const derivedCustomTypes = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
@@ -506,6 +506,7 @@ export default function ExercisePicker({
|
||||
{ value: "duration", label: "Time" },
|
||||
{ value: "distance", label: "Distance" },
|
||||
{ value: "calories", label: "Calories" },
|
||||
{ value: "watts", label: "Avg. watts" },
|
||||
{ value: "notes", label: "Notes" },
|
||||
...customFieldOptions,
|
||||
].map((field) => (
|
||||
@@ -527,7 +528,7 @@ export default function ExercisePicker({
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const val = newFieldText.trim().toLowerCase();
|
||||
const knownValues = ["sets", "reps", "weight", "duration", "distance", "calories", "notes"];
|
||||
const knownValues = ["sets", "reps", "weight", "duration", "distance", "calories", "watts", "notes"];
|
||||
if (val && !knownValues.includes(val) && !customFieldOptions.some((f) => f.value === val)) {
|
||||
setSessionCustomFields((p) => [...p, { value: val, label: val.charAt(0).toUpperCase() + val.slice(1) }]);
|
||||
}
|
||||
@@ -545,7 +546,7 @@ export default function ExercisePicker({
|
||||
onChange={(e) => setNewFieldText(e.target.value)}
|
||||
onBlur={() => {
|
||||
const val = newFieldText.trim().toLowerCase();
|
||||
const knownValues = ["sets", "reps", "weight", "duration", "distance", "calories", "notes"];
|
||||
const knownValues = ["sets", "reps", "weight", "duration", "distance", "calories", "watts", "notes"];
|
||||
if (val && !knownValues.includes(val) && !customFieldOptions.some((f) => f.value === val)) {
|
||||
setSessionCustomFields((p) => [...p, { value: val, label: val.charAt(0).toUpperCase() + val.slice(1) }]);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export type InputField =
|
||||
| "duration"
|
||||
| "distance"
|
||||
| "calories"
|
||||
| "watts"
|
||||
| "notes"
|
||||
| string;
|
||||
|
||||
@@ -24,6 +25,7 @@ export interface SetRowProps {
|
||||
initialDuration?: number;
|
||||
initialDistance?: number;
|
||||
initialCalories?: number;
|
||||
initialWatts?: number;
|
||||
initialCustomMetrics?: Record<string, string>;
|
||||
initialLocked?: boolean;
|
||||
autoFocus?: boolean;
|
||||
@@ -35,6 +37,7 @@ export interface SetRowProps {
|
||||
durationSeconds?: number;
|
||||
distance?: number;
|
||||
calories?: number;
|
||||
watts?: number;
|
||||
customMetrics?: Record<string, string>;
|
||||
}) => void;
|
||||
onConfirm?: () => void;
|
||||
@@ -46,6 +49,7 @@ export interface SetRowProps {
|
||||
duration?: string;
|
||||
distance?: string;
|
||||
calories?: string;
|
||||
watts?: string;
|
||||
}) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
@@ -61,6 +65,7 @@ export default function SetRow({
|
||||
initialDuration,
|
||||
initialDistance,
|
||||
initialCalories,
|
||||
initialWatts,
|
||||
initialCustomMetrics,
|
||||
initialLocked = false,
|
||||
autoFocus = false,
|
||||
@@ -90,9 +95,16 @@ export default function SetRow({
|
||||
const [duration, setDuration] = useState(secondsToMinuteString(initialDuration));
|
||||
const [distance, setDistance] = useState(initialDistance?.toString() || "");
|
||||
const [calories, setCalories] = useState(initialCalories?.toString() || "");
|
||||
const [customValues, setCustomValues] = useState<Record<string, string>>(
|
||||
initialCustomMetrics || {}
|
||||
// Watts is now a first-class field. Legacy sets stored it under the
|
||||
// customMetrics "watts" key — seed from there so old data shows up, and
|
||||
// strip it from customValues so it isn't also rendered in the custom grid.
|
||||
const [watts, setWatts] = useState(
|
||||
initialWatts?.toString() || initialCustomMetrics?.watts || ""
|
||||
);
|
||||
const [customValues, setCustomValues] = useState<Record<string, string>>(() => {
|
||||
const { watts: _legacyWatts, ...rest } = initialCustomMetrics || {};
|
||||
return rest;
|
||||
});
|
||||
const [showNotes, setShowNotes] = useState(!!initialNotes);
|
||||
const [locked, setLocked] = useState(initialLocked);
|
||||
|
||||
@@ -101,6 +113,7 @@ export default function SetRow({
|
||||
const showDuration = inputFields.includes("duration");
|
||||
const showDistance = inputFields.includes("distance");
|
||||
const showCalories = inputFields.includes("calories");
|
||||
const showWatts = inputFields.includes("watts");
|
||||
const showNotesField = inputFields.includes("notes");
|
||||
const customFields = inputFields.filter(
|
||||
(f) =>
|
||||
@@ -111,6 +124,7 @@ export default function SetRow({
|
||||
"duration",
|
||||
"distance",
|
||||
"calories",
|
||||
"watts",
|
||||
"notes",
|
||||
].includes(f)
|
||||
);
|
||||
@@ -124,6 +138,7 @@ export default function SetRow({
|
||||
duration?: string;
|
||||
distance?: string;
|
||||
calories?: string;
|
||||
watts?: string;
|
||||
customMetrics?: Record<string, string>;
|
||||
}) => {
|
||||
const r = overrides.reps ?? reps;
|
||||
@@ -133,6 +148,7 @@ export default function SetRow({
|
||||
const dur = overrides.duration ?? duration;
|
||||
const dist = overrides.distance ?? distance;
|
||||
const cal = overrides.calories ?? calories;
|
||||
const wt = overrides.watts ?? watts;
|
||||
const cm = overrides.customMetrics ?? customValues;
|
||||
const cleanedCustomMetrics = Object.fromEntries(
|
||||
Object.entries(cm).filter(([, value]) => value !== "")
|
||||
@@ -146,13 +162,14 @@ export default function SetRow({
|
||||
durationSeconds: minuteStringToSeconds(dur),
|
||||
distance: dist ? parseFloat(dist) : undefined,
|
||||
calories: cal ? parseInt(cal) : undefined,
|
||||
watts: wt ? parseInt(wt) : undefined,
|
||||
customMetrics:
|
||||
Object.keys(cleanedCustomMetrics).length > 0
|
||||
? cleanedCustomMetrics
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
[reps, weight, rpe, notes, duration, distance, calories, customValues, onUpdate]
|
||||
[reps, weight, rpe, notes, duration, distance, calories, watts, customValues, onUpdate]
|
||||
);
|
||||
|
||||
const handleConfirm = () => {
|
||||
@@ -175,7 +192,7 @@ export default function SetRow({
|
||||
const handleNextSet = () => {
|
||||
emitUpdate({});
|
||||
setLocked(true);
|
||||
onNextSet?.({ weight, reps, rpe, notes, duration, distance, calories });
|
||||
onNextSet?.({ weight, reps, rpe, notes, duration, distance, calories, watts });
|
||||
};
|
||||
|
||||
// Build a summary string for the locked view
|
||||
@@ -186,6 +203,7 @@ export default function SetRow({
|
||||
if (showDuration && duration) parts.push(`${duration} min`);
|
||||
if (showDistance && distance) parts.push(`${distance} mi`);
|
||||
if (showCalories && calories) parts.push(`${calories} cal`);
|
||||
if (showWatts && watts) parts.push(`${watts} W`);
|
||||
for (const field of customFields) {
|
||||
const value = customValues[field];
|
||||
if (value) parts.push(`${field}: ${value}`);
|
||||
@@ -238,7 +256,7 @@ export default function SetRow({
|
||||
}
|
||||
|
||||
// Determine which field gets autoFocus
|
||||
const firstField = showWeight ? "weight" : showReps ? "reps" : showDuration ? "duration" : showDistance ? "distance" : showCalories ? "calories" : null;
|
||||
const firstField = showWeight ? "weight" : showReps ? "reps" : showDuration ? "duration" : showDistance ? "distance" : showCalories ? "calories" : showWatts ? "watts" : null;
|
||||
|
||||
// ---------- EDIT VIEW ----------
|
||||
return (
|
||||
@@ -357,6 +375,27 @@ export default function SetRow({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Avg. watts input */}
|
||||
{showWatts && (
|
||||
<div className="flex-1 min-w-[55px]">
|
||||
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
|
||||
Avg. watts
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
autoFocus={autoFocus && firstField === "watts"}
|
||||
value={watts}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setWatts(val);
|
||||
emitUpdate({ watts: val });
|
||||
}}
|
||||
placeholder="0"
|
||||
className="w-full px-2 py-1.5 border border-zinc-700 rounded-md text-sm bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RPE select — always shown */}
|
||||
<div className="flex-1 min-w-[50px] max-w-[60px]">
|
||||
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
|
||||
|
||||
@@ -235,6 +235,7 @@ interface ExerciseWithSets {
|
||||
durationSeconds?: number;
|
||||
distance?: number;
|
||||
calories?: number;
|
||||
watts?: number;
|
||||
customMetrics?: Record<string, string>;
|
||||
notes?: string;
|
||||
forceEdit?: boolean; // When true, start in edit mode even if data is pre-filled
|
||||
@@ -259,6 +260,7 @@ export interface EditWorkoutData {
|
||||
durationSeconds?: number;
|
||||
distance?: number;
|
||||
calories?: number;
|
||||
watts?: number;
|
||||
customMetrics?: Record<string, string>;
|
||||
notes?: string;
|
||||
}>;
|
||||
@@ -346,6 +348,7 @@ export default function WorkoutForm({
|
||||
distance: s.distance,
|
||||
distanceUnit: s.distance !== undefined ? "mi" : undefined,
|
||||
calories: s.calories,
|
||||
watts: s.watts,
|
||||
customMetrics: s.customMetrics,
|
||||
notes: s.notes,
|
||||
}))
|
||||
@@ -508,6 +511,7 @@ export default function WorkoutForm({
|
||||
durationSeconds?: number;
|
||||
distance?: number;
|
||||
calories?: number;
|
||||
watts?: number;
|
||||
customMetrics?: Record<string, string>;
|
||||
}
|
||||
) => {
|
||||
@@ -858,6 +862,7 @@ export default function WorkoutForm({
|
||||
initialDuration={set.durationSeconds}
|
||||
initialDistance={set.distance}
|
||||
initialCalories={set.calories}
|
||||
initialWatts={set.watts}
|
||||
initialCustomMetrics={set.customMetrics}
|
||||
initialNotes={set.notes}
|
||||
initialLocked={
|
||||
@@ -869,6 +874,7 @@ export default function WorkoutForm({
|
||||
set.durationSeconds ||
|
||||
set.distance ||
|
||||
set.calories ||
|
||||
set.watts ||
|
||||
(set.customMetrics &&
|
||||
Object.values(set.customMetrics).some((v) => v))
|
||||
)
|
||||
@@ -880,7 +886,8 @@ export default function WorkoutForm({
|
||||
!set.weight &&
|
||||
!set.durationSeconds &&
|
||||
!set.distance &&
|
||||
!set.calories)
|
||||
!set.calories &&
|
||||
!set.watts)
|
||||
}
|
||||
onUpdate={(data) =>
|
||||
handleUpdateSet(
|
||||
|
||||
@@ -46,6 +46,7 @@ export const BASE_TRACKING_FIELDS: Option[] = [
|
||||
{ value: "duration", label: "Time" },
|
||||
{ value: "distance", label: "Distance" },
|
||||
{ value: "calories", label: "Calories" },
|
||||
{ value: "watts", label: "Avg. watts" },
|
||||
{ value: "notes", label: "Notes" },
|
||||
];
|
||||
|
||||
|
||||
@@ -121,7 +121,8 @@ model SetLog {
|
||||
distance Float? // for distance-based exercises
|
||||
distanceUnit String? // "mi", "km", "m"
|
||||
calories Int? // for cardio machines that report calories
|
||||
customMetrics String? // JSON map for dynamic custom metrics (e.g. {"watts":"157"})
|
||||
watts Int? // average watts for cardio machines (assault bike, rower, ski erg)
|
||||
customMetrics String? // JSON map for dynamic custom metrics (legacy watts lived here as {"watts":"157"})
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
|
||||
@@ -302,6 +302,43 @@ describe('POST /api/workouts', () => {
|
||||
expect(body.setLogs[1].rpe).toBe(8);
|
||||
});
|
||||
|
||||
it('persists avg. watts as a first-class set field (assault bike)', async () => {
|
||||
const alice = await makeUser({ email: 'a@x' });
|
||||
const bike = await prisma.exercise.create({
|
||||
data: {
|
||||
userId: alice.id,
|
||||
name: 'Assault Bike',
|
||||
type: 'assault bike',
|
||||
muscleGroups: '[]',
|
||||
inputFields: '["sets","duration","distance","calories","watts","notes"]',
|
||||
},
|
||||
});
|
||||
getCurrentUserMock.mockResolvedValue(alice);
|
||||
const res = await postWorkout(
|
||||
jsonReq('http://x/api/workouts', {
|
||||
name: 'Conditioning',
|
||||
sets: [
|
||||
{
|
||||
exerciseId: bike.id,
|
||||
setNumber: 1,
|
||||
durationSeconds: 600,
|
||||
distance: 2.5,
|
||||
distanceUnit: 'mi',
|
||||
calories: 120,
|
||||
watts: 157,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.setLogs).toHaveLength(1);
|
||||
expect(body.setLogs[0].watts).toBe(157);
|
||||
// And it round-trips out of the DB, not just the response.
|
||||
const stored = await prisma.setLog.findFirst({ where: { workoutId: body.id } });
|
||||
expect(stored?.watts).toBe(157);
|
||||
});
|
||||
|
||||
it('rejects negative reps via Zod with 400', async () => {
|
||||
const alice = await makeUser({ email: 'a@x' });
|
||||
const bench = await prisma.exercise.create({
|
||||
@@ -403,6 +440,32 @@ describe('PATCH /api/workouts/[id]', () => {
|
||||
// The guard runs before the set-replace transaction.
|
||||
expect(await prisma.setLog.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('persists avg. watts when replacing sets via PATCH', async () => {
|
||||
const alice = await makeUser({ email: 'a@x' });
|
||||
const bike = await prisma.exercise.create({
|
||||
data: { userId: alice.id, name: 'Assault Bike', type: 'assault bike', muscleGroups: '[]' },
|
||||
});
|
||||
const workout = await prisma.workout.create({
|
||||
data: { userId: alice.id, date: new Date(), name: 'Cond' },
|
||||
});
|
||||
getCurrentUserMock.mockResolvedValue(alice);
|
||||
const res = await patchWorkout(
|
||||
jsonReq(
|
||||
'http://x/api/workouts/' + workout.id,
|
||||
{
|
||||
sets: [
|
||||
{ exerciseId: bike.id, setNumber: 1, durationSeconds: 600, watts: 180 },
|
||||
],
|
||||
},
|
||||
{ method: 'PATCH' },
|
||||
),
|
||||
{ params: Promise.resolve({ id: workout.id }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const stored = await prisma.setLog.findFirst({ where: { workoutId: workout.id } });
|
||||
expect(stored?.watts).toBe(180);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/workouts/import/save', () => {
|
||||
|
||||
@@ -89,6 +89,7 @@ export type ParsedSet = {
|
||||
distance?: number | null;
|
||||
distanceUnit?: string | null;
|
||||
calories?: number | null;
|
||||
watts?: number | null;
|
||||
rpe?: number | null;
|
||||
notes?: string | null;
|
||||
};
|
||||
|
||||
@@ -72,6 +72,11 @@ if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then
|
||||
sqlite3 "$DB_PATH" "ALTER TABLE SetLog ADD COLUMN customMetrics TEXT;"
|
||||
fi
|
||||
|
||||
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('SetLog');" 2>/dev/null | grep -q "|watts|"; then
|
||||
log "adding missing column SetLog.watts"
|
||||
sqlite3 "$DB_PATH" "ALTER TABLE SetLog ADD COLUMN watts INTEGER;"
|
||||
fi
|
||||
|
||||
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('Workout');" 2>/dev/null | grep -q "|deletedAt|"; then
|
||||
log "adding missing column Workout.deletedAt"
|
||||
sqlite3 "$DB_PATH" "ALTER TABLE Workout ADD COLUMN deletedAt DATETIME;"
|
||||
|
||||
@@ -18,6 +18,7 @@ import { v_1_1_0_9 } from './v1.1.0.9'
|
||||
import { v_1_2_0_1 } from './v1.2.0.1'
|
||||
import { v_1_2_0_2 } from './v1.2.0.2'
|
||||
import { v_1_2_0_3 } from './v1.2.0.3'
|
||||
import { v_1_2_0_4 } from './v1.2.0.4'
|
||||
|
||||
/**
|
||||
* Version graph for the `proof-of-work` package.
|
||||
@@ -71,9 +72,13 @@ import { v_1_2_0_3 } from './v1.2.0.3'
|
||||
* bcrypt on unknown email) and enforce exerciseId ownership on
|
||||
* workout create/PATCH/add-sets + CSV-import-save (shared
|
||||
* lib/exerciseOwnership). No schema/data change.
|
||||
* v1.2.0:4 — Avg. watts promoted to a first-class set field (SetLog.watts
|
||||
* column, added by the boot-time additive ALTER). Written through
|
||||
* every set path; legacy watts in customMetrics stays readable and
|
||||
* migrates on next save.
|
||||
*/
|
||||
export const versionGraph = VersionGraph.of({
|
||||
current: v_1_2_0_3,
|
||||
current: v_1_2_0_4,
|
||||
other: [
|
||||
v_1_0_0_1,
|
||||
v_1_0_0_2,
|
||||
@@ -93,5 +98,6 @@ export const versionGraph = VersionGraph.of({
|
||||
v_1_1_0_9,
|
||||
v_1_2_0_1,
|
||||
v_1_2_0_2,
|
||||
v_1_2_0_3,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
/**
|
||||
* v1.2.0:4 — Avg. watts as a first-class set field (2026-06-16).
|
||||
*
|
||||
* Average watts (assault bike, rower, ski erg) used to be a free-text entry
|
||||
* stuffed into the per-set customMetrics JSON blob. It's now a real nullable
|
||||
* column, SetLog.watts, written through every set path (create / PATCH /
|
||||
* add-sets / import-save / account-import) and shown everywhere as
|
||||
* "Avg. watts" with a proper numeric input.
|
||||
*
|
||||
* Additive schema change: the SetLog.watts column is added by the boot-time
|
||||
* guarded ALTER in docker_entrypoint.sh (so this migration stays empty, like
|
||||
* every other column add). Existing data is untouched — legacy watts values
|
||||
* remain readable from customMetrics and migrate to the column on next save.
|
||||
*/
|
||||
export const v_1_2_0_4 = VersionInfo.of({
|
||||
version: '1.2.0:4',
|
||||
releaseNotes: {
|
||||
en_US:
|
||||
'Average watts is now a first-class field for cardio machines (assault bike, rower, ski erg) — a proper numeric "Avg. watts" input instead of a free-text custom metric. Existing data is preserved.',
|
||||
},
|
||||
migrations: {
|
||||
up: async () => {},
|
||||
down: IMPOSSIBLE,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user