diff --git a/proof-of-work/app/api/import/parse/route.ts b/proof-of-work/app/api/import/parse/route.ts
index bf8316b..0290275 100644
--- a/proof-of-work/app/api/import/parse/route.ts
+++ b/proof-of-work/app/api/import/parse/route.ts
@@ -19,6 +19,7 @@ interface ParsedSet {
distance?: number;
distanceUnit?: string;
calories?: number;
+ watts?: number;
rpe?: number;
customMetrics?: Record;
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 = {};
@@ -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,
diff --git a/proof-of-work/app/api/me/import/route.ts b/proof-of-work/app/api/me/import/route.ts
index 6bad297..c0be414 100644
--- a/proof-of-work/app/api/me/import/route.ts
+++ b/proof-of-work/app/api/me/import/route.ts
@@ -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,
});
diff --git a/proof-of-work/app/api/settings/export-csv/route.ts b/proof-of-work/app/api/settings/export-csv/route.ts
index 01ce22c..03f2c3d 100644
--- a/proof-of-work/app/api/settings/export-csv/route.ts
+++ b/proof-of-work/app/api/settings/export-csv/route.ts
@@ -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 ?? "",
diff --git a/proof-of-work/app/api/workouts/[id]/route.ts b/proof-of-work/app/api/workouts/[id]/route.ts
index 025ebb2..56259a1 100644
--- a/proof-of-work/app/api/workouts/[id]/route.ts
+++ b/proof-of-work/app/api/workouts/[id]/route.ts
@@ -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)
diff --git a/proof-of-work/app/api/workouts/[id]/sets/route.ts b/proof-of-work/app/api/workouts/[id]/sets/route.ts
index 6a3dcaf..3cc011a 100644
--- a/proof-of-work/app/api/workouts/[id]/sets/route.ts
+++ b/proof-of-work/app/api/workouts/[id]/sets/route.ts
@@ -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)
diff --git a/proof-of-work/app/api/workouts/import/save/route.ts b/proof-of-work/app/api/workouts/import/save/route.ts
index 019eed8..27b0790 100644
--- a/proof-of-work/app/api/workouts/import/save/route.ts
+++ b/proof-of-work/app/api/workouts/import/save/route.ts
@@ -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,
});
});
diff --git a/proof-of-work/app/api/workouts/route.ts b/proof-of-work/app/api/workouts/route.ts
index 4c51540..2c31f92 100644
--- a/proof-of-work/app/api/workouts/route.ts
+++ b/proof-of-work/app/api/workouts/route.ts
@@ -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)
diff --git a/proof-of-work/app/main/exercises/[id]/page.tsx b/proof-of-work/app/main/exercises/[id]/page.tsx
index f969643..b404a17 100644
--- a/proof-of-work/app/main/exercises/[id]/page.tsx
+++ b/proof-of-work/app/main/exercises/[id]/page.tsx
@@ -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" },
];
diff --git a/proof-of-work/app/main/import/page-csv.tsx b/proof-of-work/app/main/import/page-csv.tsx
index 4fe651c..7c99c46 100644
--- a/proof-of-work/app/main/import/page-csv.tsx
+++ b/proof-of-work/app/main/import/page-csv.tsx
@@ -22,6 +22,7 @@ interface ParsedSet {
distance?: number;
distanceUnit?: string;
calories?: number;
+ watts?: number;
rpe?: number;
customMetrics?: Record;
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() {
CSV columns: date, exercise, set, weight, reps, duration_seconds,
- distance, distance_unit, calories, rpe, notes, custom_*
+ distance, distance_unit, calories, watts, rpe, notes, custom_*
{loading && (
@@ -778,12 +782,12 @@ export default function ImportCSVPage() {
CSV Format Example
- {`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,`}
diff --git a/proof-of-work/app/main/workouts/[id]/page.tsx b/proof-of-work/app/main/workouts/[id]/page.tsx
index 93cde96..8b36c52 100644
--- a/proof-of-work/app/main/workouts/[id]/page.tsx
+++ b/proof-of-work/app/main/workouts/[id]/page.tsx
@@ -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;
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 {}
}
diff --git a/proof-of-work/app/main/workouts/new/page.tsx b/proof-of-work/app/main/workouts/new/page.tsx
index f3f237f..ea2bda0 100644
--- a/proof-of-work/app/main/workouts/new/page.tsx
+++ b/proof-of-work/app/main/workouts/new/page.tsx
@@ -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,
});
diff --git a/proof-of-work/components/workouts/ExercisePicker.tsx b/proof-of-work/components/workouts/ExercisePicker.tsx
index ac8b283..95216bf 100644
--- a/proof-of-work/components/workouts/ExercisePicker.tsx
+++ b/proof-of-work/components/workouts/ExercisePicker.tsx
@@ -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();
@@ -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) }]);
}
diff --git a/proof-of-work/components/workouts/SetRow.tsx b/proof-of-work/components/workouts/SetRow.tsx
index e0144a1..8884e23 100644
--- a/proof-of-work/components/workouts/SetRow.tsx
+++ b/proof-of-work/components/workouts/SetRow.tsx
@@ -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;
initialLocked?: boolean;
autoFocus?: boolean;
@@ -35,6 +37,7 @@ export interface SetRowProps {
durationSeconds?: number;
distance?: number;
calories?: number;
+ watts?: number;
customMetrics?: Record;
}) => 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>(
- 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>(() => {
+ 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;
}) => {
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({
)}
+ {/* Avg. watts input */}
+ {showWatts && (
+
+
+ {
+ 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"
+ />
+
+ )}
+
{/* RPE select — always shown */}