Rebrand to Proof of Work; multi-user 0.4 package with curated library sync
Repo cleanup - Add top-level .gitignore (was missing; node_modules, .next, *.s9pk, image.tar, seed/data/*.db, log files, etc.) and a root README. - Delete legacy start9/0.3.5/ package (StartOS 0.3.5 wrapper, no longer the deploy target). - Delete start9-example-packaging/ (template from another project). - Delete planning docs (START9_PACKAGING_LOG.md, VERSIONING.md, STARTOS_0.4_UPGRADE_PROMPT.md, ICON_FILES_INDEX.md, etc.) — info now lives in the deploy guide and code comments. - Drop the standalone Dockerfile, docker-compose.yml, ICON_*, and dev log/build artifacts from the app dir. - Drop the v0.1.0:18/19/20 version files (they belonged to the legacy workout-log package and don't apply to the new id). Rename + new package - Rename app dir workout-planner/ -> proof-of-work/. - Rename StartOS package id workout-log -> proof-of-work; the new id makes this a brand new StartOS service (clean cutover from the old one rather than in-place upgrade). - Reset version graph; v1.0.0:1 is the seeded cutover release. The Dockerfile bakes a one-time /data snapshot and docker_entrypoint.sh copies it into the new volume on truly-fresh first boot only (both /data/app.db missing AND /data/.seeded absent). - Move start9/0.4-migration/ -> start9/0.4/; the old start9/0.4/ stub is gone. Curated exercise library (multi-user-aware) - proof-of-work/prisma/exercises.seed.json is the canonical library shipped to every install (164 exercises today, dumped from the live snapshot). - proof-of-work/scripts/sync-library.cjs (npm run sync-library) refreshes the JSON from start9/0.4/seed/data/app.db after refresh_seed.sh. - proof-of-work/prisma/seed.ts now reads from the JSON instead of a hardcoded 52-exercise array; runs at Docker build time to seed the fallback DB and on first boot for fresh installs. - proof-of-work/prisma/ensureExerciseLibrary.cjs runs on every container boot (from docker_entrypoint.sh) and INSERT OR IGNOREs every library entry for every user, keyed on (userId, name). Library updates flow to existing installs on package upgrade; user-custom exercises (isCustom=true) and any colliding names are never overwritten; removed exercises stay on existing installs (additive-only). Deploy guide (start9/0.4/DEPLOY_040.md) - Rewritten end-to-end for the workout-log -> proof-of-work cutover: refresh_seed, sync-library, build, sideload, verify, rotate creds, stop the old service, then post-cutover cleanup release v1.0.0:2.
@@ -4,15 +4,15 @@ startos-wrapper/docker-images
|
||||
startos-wrapper/*.s9pk
|
||||
|
||||
# Keep local app artifacts out of Docker build context
|
||||
workout-planner/node_modules
|
||||
workout-planner/.next
|
||||
workout-planner/logs
|
||||
workout-planner/.server.pid
|
||||
workout-planner/prisma/*.db
|
||||
workout-planner/prisma/data/*.db
|
||||
workout-planner/.env
|
||||
workout-planner/.env.local
|
||||
workout-planner/.env.*.local
|
||||
proof-of-work/node_modules
|
||||
proof-of-work/.next
|
||||
proof-of-work/logs
|
||||
proof-of-work/.server.pid
|
||||
proof-of-work/prisma/*.db
|
||||
proof-of-work/prisma/data/*.db
|
||||
proof-of-work/.env
|
||||
proof-of-work/.env.local
|
||||
proof-of-work/.env.*.local
|
||||
|
||||
# OS/editor junk
|
||||
.DS_Store
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Node / build
|
||||
node_modules/
|
||||
*.tsbuildinfo
|
||||
.next/
|
||||
out/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Logs / runtime
|
||||
logs/
|
||||
*.log
|
||||
*.pid
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Local DB snapshots that aren't part of the package
|
||||
proof-of-work-*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Start9 build artifacts
|
||||
*.s9pk
|
||||
image.tar
|
||||
start9/*/javascript/
|
||||
|
||||
# App-local dev DB
|
||||
proof-of-work/prisma/dev.db
|
||||
proof-of-work/prisma/data/*.db
|
||||
!proof-of-work/prisma/data/.keep
|
||||
|
||||
# Live data snapshot pulled off the running 0.3.5 host — contains real
|
||||
# workout history. Stays on disk so the maintainer can rebuild the seeded
|
||||
# cutover image, but MUST never be committed to a public repo.
|
||||
start9/*/seed/data/*.db
|
||||
@@ -0,0 +1,65 @@
|
||||
# Proof of Work
|
||||
|
||||
Self-hosted multi-user workout planner and logger. Plan training cycles,
|
||||
log daily workouts, search your history, and curate a shared exercise
|
||||
library across everyone on the instance. Distributed as a StartOS 0.4
|
||||
sideload package.
|
||||
|
||||
## Repo layout
|
||||
|
||||
```
|
||||
proof-of-work/ Next.js app (TypeScript, Prisma + SQLite, Tailwind, PWA)
|
||||
start9/0.4/ StartOS 0.4 package wrapper (manifest, Dockerfile,
|
||||
entrypoint, version graph, change-credentials action)
|
||||
```
|
||||
|
||||
Everything else is generated at build time.
|
||||
|
||||
## Local development
|
||||
|
||||
```sh
|
||||
cd proof-of-work
|
||||
npm install
|
||||
npx prisma db push # create the dev DB at prisma/data/app.db
|
||||
npm run db:seed # admin@local / workout123 + curated exercise library
|
||||
npm run dev # http://localhost:3000
|
||||
```
|
||||
|
||||
## Building the StartOS package
|
||||
|
||||
See **[start9/0.4/DEPLOY_040.md](start9/0.4/DEPLOY_040.md)** for the full
|
||||
deployment / cutover guide. Short version:
|
||||
|
||||
```sh
|
||||
cd start9/0.4
|
||||
npm ci
|
||||
make clean
|
||||
make x86 # produces proof-of-work_x86_64.s9pk
|
||||
make install # sideload to the host in ~/.startos/config.yaml
|
||||
```
|
||||
|
||||
## Curated exercise library
|
||||
|
||||
`proof-of-work/prisma/exercises.seed.json` is the canonical library
|
||||
shipped to every install. It seeds fresh installs (via `prisma/seed.ts`)
|
||||
and is re-applied on every boot to existing installs (via
|
||||
`docker_entrypoint.sh` + `ensureExerciseLibrary.cjs`) so updates flow to
|
||||
all users on package upgrade.
|
||||
|
||||
Refresh the JSON from the maintainer's live host:
|
||||
|
||||
```sh
|
||||
./start9/0.4/refresh_seed.sh <ssh-target> # pull a fresh /data snapshot
|
||||
cd proof-of-work && npm run sync-library # extract Exercise table -> JSON
|
||||
git diff prisma/exercises.seed.json
|
||||
```
|
||||
|
||||
The system is additive only — removing an exercise from the JSON does
|
||||
not delete it from existing installs (users may have logged sets against
|
||||
it). Users' own custom exercises (`isCustom = true`) are never touched.
|
||||
|
||||
## Privacy
|
||||
|
||||
`start9/0.4/seed/data/app.db` is your live `/data` snapshot. It contains
|
||||
real workout history and a bcrypt'd password hash. The top-level
|
||||
`.gitignore` keeps it out of git; do NOT commit it to any public repo.
|
||||
@@ -34,6 +34,7 @@ export async function GET(
|
||||
exerciseId: params.id,
|
||||
workout: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
@@ -49,7 +50,7 @@ export async function GET(
|
||||
{ workout: { date: "desc" } },
|
||||
{ setNumber: "asc" },
|
||||
],
|
||||
take: 100,
|
||||
take: 500,
|
||||
});
|
||||
|
||||
// Group by workout
|
||||
@@ -62,7 +63,7 @@ export async function GET(
|
||||
workoutMap.get(key)!.sets.push(log);
|
||||
}
|
||||
|
||||
const history = Array.from(workoutMap.values()).slice(0, 20);
|
||||
const history = Array.from(workoutMap.values()).slice(0, 50);
|
||||
|
||||
return NextResponse.json({
|
||||
exercise,
|
||||
@@ -0,0 +1,104 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const SeedExerciseSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
type: z.string().min(1).default("other"),
|
||||
muscleGroups: z.array(z.string()).optional().default([]),
|
||||
inputFields: z.array(z.string().min(1)).optional().default(["sets", "reps", "weight"]),
|
||||
defaultWeightUnit: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
const SeedPayloadSchema = z.object({
|
||||
exercises: z.array(SeedExerciseSchema).min(1),
|
||||
});
|
||||
|
||||
function normalizeToken(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = SeedPayloadSchema.parse(body);
|
||||
|
||||
const existingExercises = await prisma.exercise.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { name: true },
|
||||
});
|
||||
const existingNames = new Set(existingExercises.map((x) => normalizeToken(x.name)));
|
||||
|
||||
let created = 0;
|
||||
let skipped = 0;
|
||||
const errors: Array<{ name: string; error: string }> = [];
|
||||
|
||||
for (const item of parsed.exercises) {
|
||||
const rawName = item.name.trim();
|
||||
const nameKey = normalizeToken(rawName);
|
||||
if (!rawName) continue;
|
||||
|
||||
if (existingNames.has(nameKey)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const muscleGroups = Array.from(
|
||||
new Set(item.muscleGroups.map((v) => normalizeToken(v)).filter(Boolean))
|
||||
);
|
||||
let inputFields = Array.from(
|
||||
new Set(item.inputFields.map((v) => normalizeToken(v)).filter(Boolean))
|
||||
);
|
||||
if (!inputFields.includes("sets")) {
|
||||
inputFields = ["sets", ...inputFields];
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.exercise.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: rawName,
|
||||
type: normalizeToken(item.type || "other"),
|
||||
muscleGroups: JSON.stringify(muscleGroups),
|
||||
inputFields: JSON.stringify(inputFields),
|
||||
defaultWeightUnit: item.defaultWeightUnit ?? null,
|
||||
isCustom: true,
|
||||
} as any,
|
||||
});
|
||||
existingNames.add(nameKey);
|
||||
created += 1;
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
name: rawName,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
created,
|
||||
skipped,
|
||||
errors,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid seed payload", details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.error("POST /api/import/exercises/seed error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to process exercise seed" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -38,12 +38,20 @@ interface ParsedSet {
|
||||
weight?: number;
|
||||
weightUnit: string;
|
||||
reps?: number;
|
||||
durationSeconds?: number;
|
||||
distance?: number;
|
||||
distanceUnit?: string;
|
||||
calories?: number;
|
||||
rpe?: number;
|
||||
customMetrics?: Record<string, string>;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
interface ParsedExercise {
|
||||
exerciseId: string;
|
||||
exerciseName: string;
|
||||
sourceName?: string;
|
||||
unmapped?: boolean;
|
||||
sets: ParsedSet[];
|
||||
}
|
||||
|
||||
@@ -58,33 +66,86 @@ interface ParseResponse {
|
||||
}
|
||||
|
||||
function parseCSV(content: string): Array<Record<string, string>> {
|
||||
const lines = content.trim().split("\n");
|
||||
if (lines.length === 0) return [];
|
||||
const text = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
if (!text.trim()) return [];
|
||||
|
||||
// Parse header
|
||||
const header = lines[0].split(",").map((h) => h.trim().toLowerCase());
|
||||
const rows = [];
|
||||
const parsedRows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let cell = "";
|
||||
let inQuotes = false;
|
||||
|
||||
// Parse data rows
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
const next = text[i + 1];
|
||||
|
||||
const values = line.split(",").map((v) => v.trim());
|
||||
const row: Record<string, string> = {};
|
||||
if (ch === "\"") {
|
||||
if (inQuotes && next === "\"") {
|
||||
cell += "\"";
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "," && !inQuotes) {
|
||||
row.push(cell);
|
||||
cell = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "\n" && !inQuotes) {
|
||||
row.push(cell);
|
||||
if (row.some((v) => v.trim() !== "")) {
|
||||
parsedRows.push(row);
|
||||
}
|
||||
row = [];
|
||||
cell = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
cell += ch;
|
||||
}
|
||||
|
||||
row.push(cell);
|
||||
if (row.some((v) => v.trim() !== "")) {
|
||||
parsedRows.push(row);
|
||||
}
|
||||
|
||||
if (parsedRows.length === 0) return [];
|
||||
|
||||
const header = parsedRows[0].map((h) => h.trim().toLowerCase());
|
||||
const rows: Array<Record<string, string>> = [];
|
||||
|
||||
for (let i = 1; i < parsedRows.length; i++) {
|
||||
const values = parsedRows[i];
|
||||
const out: Record<string, string> = {};
|
||||
header.forEach((col, idx) => {
|
||||
if (values[idx]) {
|
||||
row[col] = values[idx];
|
||||
const value = (values[idx] || "").trim();
|
||||
if (value !== "") {
|
||||
out[col] = value;
|
||||
}
|
||||
});
|
||||
|
||||
rows.push(row);
|
||||
if (Object.keys(out).length > 0) {
|
||||
rows.push(out);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function parseFloatMaybe(value?: string): number | undefined {
|
||||
if (!value) return undefined;
|
||||
const n = Number.parseFloat(value);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function parseIntMaybe(value?: string): number | undefined {
|
||||
if (!value) return undefined;
|
||||
const n = Number.parseInt(value, 10);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function getVariationNote(originalName: string): string | null {
|
||||
if (originalName.includes("Narrow")) return "narrow";
|
||||
if (originalName.includes("Negatives")) return "negatives";
|
||||
@@ -191,6 +252,33 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Build parsed workouts
|
||||
const parsedWorkouts: ParsedWorkout[] = [];
|
||||
const knownColumns = new Set([
|
||||
"date",
|
||||
"date_str",
|
||||
"workout_date",
|
||||
"exercise",
|
||||
"exercise_name",
|
||||
"set",
|
||||
"set_number",
|
||||
"weight",
|
||||
"weight_unit",
|
||||
"weightunit",
|
||||
"reps",
|
||||
"duration",
|
||||
"duration_seconds",
|
||||
"duration_minutes",
|
||||
"time",
|
||||
"time_seconds",
|
||||
"time_minutes",
|
||||
"distance",
|
||||
"distance_unit",
|
||||
"distanceunit",
|
||||
"calories",
|
||||
"rpe",
|
||||
"notes",
|
||||
"custom_metrics_json",
|
||||
"custommetricsjson",
|
||||
]);
|
||||
|
||||
for (const [date, rowsForDate] of workoutsByDate) {
|
||||
const exercisesMap = new Map<
|
||||
@@ -198,6 +286,8 @@ export async function POST(request: NextRequest) {
|
||||
{
|
||||
exerciseId: string;
|
||||
exerciseName: string;
|
||||
sourceName?: string;
|
||||
unmapped?: boolean;
|
||||
sets: ParsedSet[];
|
||||
}
|
||||
>();
|
||||
@@ -205,33 +295,80 @@ export async function POST(request: NextRequest) {
|
||||
for (const row of rowsForDate) {
|
||||
const csvExerciseName = row.exercise || row.exercise_name || "";
|
||||
const resolvedName = resolveExerciseName(csvExerciseName);
|
||||
const exerciseId =
|
||||
exerciseMap.get(resolvedName.toLowerCase()) || "";
|
||||
const exerciseId = exerciseMap.get(resolvedName.toLowerCase()) || "";
|
||||
const isUnmapped = !exerciseId;
|
||||
const exerciseKey = isUnmapped
|
||||
? `unmapped:${csvExerciseName.toLowerCase()}`
|
||||
: exerciseId;
|
||||
|
||||
if (!exerciseId) {
|
||||
unmappedExercises.add(csvExerciseName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!exercisesMap.has(exerciseId)) {
|
||||
exercisesMap.set(exerciseId, {
|
||||
exerciseId,
|
||||
exerciseName: resolvedName,
|
||||
if (!exercisesMap.has(exerciseKey)) {
|
||||
exercisesMap.set(exerciseKey, {
|
||||
exerciseId: exerciseId || "",
|
||||
exerciseName: isUnmapped ? csvExerciseName : resolvedName,
|
||||
sourceName: csvExerciseName,
|
||||
unmapped: isUnmapped,
|
||||
sets: [],
|
||||
});
|
||||
}
|
||||
|
||||
const exerciseData = exercisesMap.get(exerciseId)!;
|
||||
const weight = row.weight ? parseFloat(row.weight) : undefined;
|
||||
const reps = row.reps ? parseInt(row.reps, 10) : undefined;
|
||||
const exerciseData = exercisesMap.get(exerciseKey)!;
|
||||
const weight = parseFloatMaybe(row.weight);
|
||||
const reps = parseIntMaybe(row.reps);
|
||||
let notes = row.notes || "";
|
||||
|
||||
// Detect weight unit from notes
|
||||
let weightUnit = "lbs";
|
||||
let weightUnit = row.weight_unit || row.weightunit || "lbs";
|
||||
if (notes.toLowerCase().includes("kg")) {
|
||||
weightUnit = "kg";
|
||||
}
|
||||
|
||||
const durationSeconds =
|
||||
parseIntMaybe(row.duration_seconds) ??
|
||||
parseIntMaybe(row.time_seconds) ??
|
||||
(parseFloatMaybe(row.duration_minutes) !== undefined
|
||||
? Math.round((parseFloatMaybe(row.duration_minutes) || 0) * 60)
|
||||
: undefined) ??
|
||||
(parseFloatMaybe(row.time_minutes) !== undefined
|
||||
? Math.round((parseFloatMaybe(row.time_minutes) || 0) * 60)
|
||||
: undefined) ??
|
||||
parseIntMaybe(row.duration) ??
|
||||
parseIntMaybe(row.time);
|
||||
|
||||
const distance = parseFloatMaybe(row.distance);
|
||||
const distanceUnit = row.distance_unit || row.distanceunit || (distance !== undefined ? "mi" : undefined);
|
||||
const calories = parseIntMaybe(row.calories);
|
||||
const rpe = parseIntMaybe(row.rpe);
|
||||
|
||||
const customMetrics: Record<string, string> = {};
|
||||
const customJson = row.custom_metrics_json || row.custommetricsjson;
|
||||
if (customJson) {
|
||||
try {
|
||||
const parsed = JSON.parse(customJson);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (v !== null && v !== undefined && String(v).trim() !== "") {
|
||||
customMetrics[String(k)] = String(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed JSON field.
|
||||
}
|
||||
}
|
||||
|
||||
for (const [col, val] of Object.entries(row)) {
|
||||
if (!val) continue;
|
||||
const normalizedCol = col.toLowerCase().trim();
|
||||
if (knownColumns.has(normalizedCol)) continue;
|
||||
if (normalizedCol.startsWith("custom_")) {
|
||||
const key = normalizedCol.replace(/^custom_/, "");
|
||||
if (key) customMetrics[key] = val;
|
||||
continue;
|
||||
}
|
||||
// Treat extra columns as custom metrics too.
|
||||
customMetrics[normalizedCol] = val;
|
||||
}
|
||||
|
||||
// Add variation note if applicable
|
||||
const variationNote = getVariationNote(csvExerciseName);
|
||||
if (variationNote) {
|
||||
@@ -240,13 +377,22 @@ export async function POST(request: NextRequest) {
|
||||
: `(${variationNote})`;
|
||||
}
|
||||
|
||||
const setNumber = exerciseData.sets.length + 1;
|
||||
const setNumber =
|
||||
parseIntMaybe(row.set_number) ??
|
||||
parseIntMaybe(row.set) ??
|
||||
exerciseData.sets.length + 1;
|
||||
|
||||
exerciseData.sets.push({
|
||||
setNumber,
|
||||
weight,
|
||||
weightUnit,
|
||||
reps,
|
||||
durationSeconds,
|
||||
distance,
|
||||
distanceUnit,
|
||||
calories,
|
||||
rpe,
|
||||
customMetrics: Object.keys(customMetrics).length > 0 ? customMetrics : undefined,
|
||||
notes: notes || undefined,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { getTimestampFileSuffix } from "@/lib/db-file";
|
||||
import { getCaloriesBurnedBulk, prisma } from "@/lib/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function csvCell(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
const text = String(value);
|
||||
if (text.includes(",") || text.includes("\"") || text.includes("\n")) {
|
||||
return `"${text.replace(/"/g, "\"\"")}"`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/settings/export-csv
|
||||
* Exports workout + set-level rows to CSV for offline backup.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const setLogs = await prisma.setLog.findMany({
|
||||
where: {
|
||||
workout: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
exercise: true,
|
||||
workout: {
|
||||
select: {
|
||||
id: true,
|
||||
date: true,
|
||||
name: true,
|
||||
notes: true,
|
||||
durationMinutes: true,
|
||||
difficulty: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ workout: { date: "desc" } },
|
||||
{ setNumber: "asc" },
|
||||
],
|
||||
});
|
||||
|
||||
const workoutIds = Array.from(new Set(setLogs.map((s) => s.workout.id)));
|
||||
const caloriesMap = await getCaloriesBurnedBulk(workoutIds);
|
||||
|
||||
const header = [
|
||||
"workoutId",
|
||||
"workoutDate",
|
||||
"workoutName",
|
||||
"workoutNotes",
|
||||
"workoutDurationMinutes",
|
||||
"workoutDifficulty",
|
||||
"workoutCaloriesBurned",
|
||||
"exerciseId",
|
||||
"exerciseName",
|
||||
"setNumber",
|
||||
"reps",
|
||||
"weight",
|
||||
"weightUnit",
|
||||
"durationMinutes",
|
||||
"distance",
|
||||
"distanceUnit",
|
||||
"setCalories",
|
||||
"rpe",
|
||||
"setNotes",
|
||||
"customMetricsJson",
|
||||
];
|
||||
|
||||
const lines = [header.join(",")];
|
||||
|
||||
for (const set of setLogs) {
|
||||
const durationMinutes =
|
||||
typeof set.durationSeconds === "number"
|
||||
? (set.durationSeconds / 60).toFixed(2)
|
||||
: "";
|
||||
|
||||
const row = [
|
||||
set.workout.id,
|
||||
set.workout.date.toISOString(),
|
||||
set.workout.name ?? "",
|
||||
set.workout.notes ?? "",
|
||||
set.workout.durationMinutes ?? "",
|
||||
set.workout.difficulty ?? "",
|
||||
caloriesMap[set.workout.id] ?? "",
|
||||
set.exerciseId,
|
||||
set.exercise.name,
|
||||
set.setNumber,
|
||||
set.reps ?? "",
|
||||
set.weight ?? "",
|
||||
set.weightUnit ?? "",
|
||||
durationMinutes,
|
||||
set.distance ?? "",
|
||||
set.distanceUnit ?? "",
|
||||
set.calories ?? "",
|
||||
set.rpe ?? "",
|
||||
set.notes ?? "",
|
||||
set.customMetrics ?? "",
|
||||
];
|
||||
|
||||
lines.push(row.map(csvCell).join(","));
|
||||
}
|
||||
|
||||
const csv = lines.join("\n");
|
||||
const fileName = `proof-of-work-export-${getTimestampFileSuffix()}.csv`;
|
||||
|
||||
return new NextResponse(csv, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${fileName}"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("CSV export error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to export CSV" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { getTimestampFileSuffix, resolveDatabasePath } from "@/lib/db-file";
|
||||
import { readFile } from "fs/promises";
|
||||
import { NextResponse } from "next/server";
|
||||
import path from "path";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* GET /api/settings/export-db
|
||||
* Download the current SQLite database file.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const dbPath = resolveDatabasePath();
|
||||
const data = await readFile(dbPath);
|
||||
const fileName = `proof-of-work-${getTimestampFileSuffix()}.db`;
|
||||
|
||||
return new NextResponse(data, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/x-sqlite3",
|
||||
"Content-Disposition": `attachment; filename="${path.basename(fileName)}"`,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Database export error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to export database" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { writeFile, copyFile, unlink } from "fs/promises";
|
||||
import { existsSync } from "fs";
|
||||
import path from "path";
|
||||
import { execSync } from "child_process";
|
||||
import { resolveDatabasePath } from "@/lib/db-file";
|
||||
|
||||
/**
|
||||
* POST /api/settings/import-db
|
||||
@@ -49,18 +50,7 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Determine the database file path from DATABASE_URL
|
||||
const dbUrl = process.env.DATABASE_URL || "file:./data/app.db";
|
||||
let dbPath: string;
|
||||
if (dbUrl.startsWith("file:")) {
|
||||
dbPath = dbUrl.replace("file:", "");
|
||||
// Handle relative paths
|
||||
if (!path.isAbsolute(dbPath)) {
|
||||
dbPath = path.resolve(process.cwd(), "prisma", dbPath.replace("./", ""));
|
||||
}
|
||||
} else {
|
||||
dbPath = path.resolve(process.cwd(), "prisma", "data", "app.db");
|
||||
}
|
||||
const dbPath = resolveDatabasePath();
|
||||
|
||||
// Write uploaded file to a temp location for validation
|
||||
const tempPath = dbPath + ".upload-temp";
|
||||
@@ -14,8 +14,8 @@ export async function GET(
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const workout = await prisma.workout.findUnique({
|
||||
where: { id: params.id },
|
||||
const workout = await prisma.workout.findFirst({
|
||||
where: { id: params.id, deletedAt: null },
|
||||
include: {
|
||||
setLogs: {
|
||||
include: {
|
||||
@@ -57,6 +57,11 @@ const setSchema = z.object({
|
||||
weight: z.number().optional().nullable(),
|
||||
weightUnit: z.string().default("lbs"),
|
||||
rpe: z.number().int().min(1).max(10).optional().nullable(),
|
||||
durationSeconds: z.number().int().positive().optional().nullable(),
|
||||
distance: z.number().positive().optional().nullable(),
|
||||
distanceUnit: z.string().optional().nullable(),
|
||||
calories: z.number().int().positive().optional().nullable(),
|
||||
customMetrics: z.record(z.string()).optional().nullable(),
|
||||
notes: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
@@ -80,8 +85,8 @@ export async function PATCH(
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const workout = await prisma.workout.findUnique({
|
||||
where: { id: params.id },
|
||||
const workout = await prisma.workout.findFirst({
|
||||
where: { id: params.id, deletedAt: null },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
@@ -134,14 +139,22 @@ export async function PATCH(
|
||||
weight: set.weight ?? undefined,
|
||||
weightUnit: set.weightUnit,
|
||||
rpe: set.rpe ?? undefined,
|
||||
durationSeconds: set.durationSeconds ?? undefined,
|
||||
distance: set.distance ?? undefined,
|
||||
distanceUnit: set.distanceUnit ?? undefined,
|
||||
calories: set.calories ?? undefined,
|
||||
customMetrics:
|
||||
set.customMetrics && Object.keys(set.customMetrics).length > 0
|
||||
? JSON.stringify(set.customMetrics)
|
||||
: undefined,
|
||||
notes: set.notes ?? undefined,
|
||||
} as any)),
|
||||
});
|
||||
}
|
||||
|
||||
// Return full updated workout
|
||||
return tx.workout.findUnique({
|
||||
where: { id: params.id },
|
||||
return tx.workout.findFirst({
|
||||
where: { id: params.id, deletedAt: null },
|
||||
include: {
|
||||
setLogs: {
|
||||
include: { exercise: true },
|
||||
@@ -173,8 +186,8 @@ export async function PATCH(
|
||||
await setCaloriesBurned(params.id, caloriesValue ?? null);
|
||||
}
|
||||
|
||||
const updated = await prisma.workout.findUnique({
|
||||
where: { id: params.id },
|
||||
const updated = await prisma.workout.findFirst({
|
||||
where: { id: params.id, deletedAt: null },
|
||||
include: {
|
||||
setLogs: {
|
||||
include: { exercise: true },
|
||||
@@ -211,8 +224,8 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const workout = await prisma.workout.findUnique({
|
||||
where: { id: params.id },
|
||||
const workout = await prisma.workout.findFirst({
|
||||
where: { id: params.id, deletedAt: null },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
@@ -224,8 +237,9 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
await prisma.workout.delete({
|
||||
await prisma.workout.update({
|
||||
where: { id: params.id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
@@ -16,6 +16,7 @@ const addSetsSchema = z.object({
|
||||
distance: z.number().positive().optional(),
|
||||
distanceUnit: z.string().optional(),
|
||||
calories: z.number().int().positive().optional(),
|
||||
customMetrics: z.record(z.string()).optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
),
|
||||
@@ -32,8 +33,8 @@ export async function POST(
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const workout = await prisma.workout.findUnique({
|
||||
where: { id: params.id },
|
||||
const workout = await prisma.workout.findFirst({
|
||||
where: { id: params.id, deletedAt: null },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
@@ -70,13 +71,17 @@ export async function POST(
|
||||
distance: set.distance,
|
||||
distanceUnit: set.distanceUnit,
|
||||
calories: set.calories,
|
||||
customMetrics:
|
||||
set.customMetrics && Object.keys(set.customMetrics).length > 0
|
||||
? JSON.stringify(set.customMetrics)
|
||||
: undefined,
|
||||
notes: set.notes,
|
||||
} as any)),
|
||||
});
|
||||
|
||||
// Return updated workout
|
||||
const updated = await prisma.workout.findUnique({
|
||||
where: { id: params.id },
|
||||
const updated = await prisma.workout.findFirst({
|
||||
where: { id: params.id, deletedAt: null },
|
||||
include: {
|
||||
setLogs: {
|
||||
include: { exercise: true },
|
||||
@@ -112,8 +117,8 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const workout = await prisma.workout.findUnique({
|
||||
where: { id: params.id },
|
||||
const workout = await prisma.workout.findFirst({
|
||||
where: { id: params.id, deletedAt: null },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ const createWorkoutSchema = z.object({
|
||||
distance: z.number().positive().optional(),
|
||||
distanceUnit: z.string().optional(),
|
||||
calories: z.number().int().positive().optional(),
|
||||
customMetrics: z.record(z.string()).optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
)
|
||||
@@ -48,6 +49,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const where: any = {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
if (query) {
|
||||
@@ -154,6 +156,10 @@ export async function POST(request: NextRequest) {
|
||||
distance: set.distance,
|
||||
distanceUnit: set.distanceUnit,
|
||||
calories: set.calories,
|
||||
customMetrics:
|
||||
set.customMetrics && Object.keys(set.customMetrics).length > 0
|
||||
? JSON.stringify(set.customMetrics)
|
||||
: undefined,
|
||||
notes: set.notes,
|
||||
} as any)),
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export default function LoginPage() {
|
||||
<div className="bg-zinc-900 rounded border border-zinc-800 shadow-2xl">
|
||||
<div className="flex flex-col space-y-2 p-8 text-center">
|
||||
<h1 className="text-3xl font-bold leading-none tracking-tight text-white">
|
||||
Workout Planner
|
||||
Proof of Work
|
||||
</h1>
|
||||
<p className="text-xs text-zinc-500 mt-2 uppercase tracking-widest">
|
||||
Track. Lift. Dominate.
|
||||
@@ -25,16 +25,20 @@ export const viewport: Viewport = {
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Workout Planner',
|
||||
title: 'Proof of Work',
|
||||
description: 'Track. Lift. Dominate.',
|
||||
manifest: '/manifest.json',
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'black-translucent',
|
||||
title: 'Workout',
|
||||
title: 'Proof of Work',
|
||||
},
|
||||
icons: {
|
||||
icon: '/icons/favicon.svg',
|
||||
icon: [
|
||||
{ url: '/icons/favicon-16x16.png', sizes: '16x16', type: 'image/png' },
|
||||
{ url: '/icons/favicon-32x32.png', sizes: '32x32', type: 'image/png' },
|
||||
{ url: '/icons/favicon-64x64.png', sizes: '64x64', type: 'image/png' },
|
||||
],
|
||||
apple: '/icons/icon-192x192.png',
|
||||
},
|
||||
};
|
||||
@@ -28,18 +28,6 @@ export default async function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0A0A0A]">
|
||||
{/* Header with greeting */}
|
||||
<div className="px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h1 className="text-4xl font-bold text-white">
|
||||
Welcome back, {user.name || "Trainer"}!
|
||||
</h1>
|
||||
<p className="text-zinc-400 mt-2">
|
||||
Keep pushing your limits and achieving your goals.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
|
||||
{/* Stats Cards */}
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
LayoutDashboard,
|
||||
Dumbbell,
|
||||
ListChecks,
|
||||
Upload,
|
||||
Settings,
|
||||
LogOut,
|
||||
} from 'lucide-react';
|
||||
@@ -19,7 +18,6 @@ const navLinks = [
|
||||
{ href: '/main/dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ href: '/main/workouts', label: 'Workouts', icon: Dumbbell },
|
||||
{ href: '/main/exercises', label: 'Exercises', icon: ListChecks },
|
||||
{ href: '/main/import', label: 'Import', icon: Upload },
|
||||
{ href: '/main/settings', label: 'Settings', icon: Settings },
|
||||
];
|
||||
|
||||
@@ -41,8 +39,7 @@ export default function Navigation({ userName }: NavigationProps) {
|
||||
{/* Desktop Sidebar */}
|
||||
<aside className="hidden md:flex fixed left-0 top-0 h-screen w-[var(--sidebar-width)] border-r border-zinc-800 bg-[#0A0A0A] flex-col">
|
||||
<div className="p-6 border-b border-zinc-800">
|
||||
<h2 className="text-3xl font-display text-white tracking-wider">Workout</h2>
|
||||
<p className="text-xs text-zinc-500 mt-1 uppercase tracking-widest font-sans">Planner</p>
|
||||
<h2 className="text-3xl font-display text-white tracking-wider">Proof of Work</h2>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||
@@ -23,13 +23,26 @@ function buildSetSummary(set: {
|
||||
durationSeconds?: number | null;
|
||||
distance?: number | null;
|
||||
calories?: number | null;
|
||||
customMetrics?: string | null;
|
||||
}) {
|
||||
const parts: string[] = [];
|
||||
if (set.weight) parts.push(`${set.weight} ${set.weightUnit === "kg" ? "kg" : "lbs"}`);
|
||||
if (set.reps) parts.push(`${set.reps} reps`);
|
||||
if ((set as any).durationSeconds) parts.push(`${(set as any).durationSeconds}s`);
|
||||
if ((set as any).durationSeconds) {
|
||||
const minutes = (set as any).durationSeconds / 60;
|
||||
const rounded = Math.round(minutes * 10) / 10;
|
||||
parts.push(`${Number.isInteger(rounded) ? Math.trunc(rounded) : rounded} min`);
|
||||
}
|
||||
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).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}`);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (set.rpe) parts.push(`RPE ${set.rpe}`);
|
||||
if (set.notes) parts.push(set.notes);
|
||||
return parts.length > 0 ? parts.join(" · ") : "No data";
|
||||
@@ -32,6 +32,14 @@ export default async function NewWorkoutPage({
|
||||
const grouped: Record<string, EditWorkoutData["exercises"][number]> = {};
|
||||
for (const set of workout.setLogs) {
|
||||
const exId = set.exercise.id;
|
||||
let customMetrics: Record<string, string> | undefined;
|
||||
if ((set as any).customMetrics) {
|
||||
try {
|
||||
customMetrics = JSON.parse((set as any).customMetrics);
|
||||
} catch {
|
||||
customMetrics = undefined;
|
||||
}
|
||||
}
|
||||
if (!grouped[exId]) {
|
||||
grouped[exId] = {
|
||||
exercise: set.exercise,
|
||||
@@ -43,6 +51,10 @@ export default async function NewWorkoutPage({
|
||||
reps: set.reps ?? undefined,
|
||||
weight: set.weight ?? undefined,
|
||||
rpe: set.rpe ?? undefined,
|
||||
durationSeconds: set.durationSeconds ?? undefined,
|
||||
distance: set.distance ?? undefined,
|
||||
calories: set.calories ?? undefined,
|
||||
customMetrics,
|
||||
notes: set.notes ?? undefined,
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export const metadata = {
|
||||
title: "Workout History",
|
||||
description: "View your workout history",
|
||||
};
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
export default async function WorkoutsPage({ searchParams }: PageProps) {
|
||||
const user = await getCurrentUser();
|
||||
@@ -0,0 +1,401 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Exercise } from "@prisma/client";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
deriveEquipmentOptions,
|
||||
deriveMuscleGroupOptions,
|
||||
deriveTrackingFieldOptions,
|
||||
displayLabel,
|
||||
normalizeValue,
|
||||
Option,
|
||||
} from "@/lib/exerciseOptions";
|
||||
|
||||
interface AddExerciseFormProps {
|
||||
onExerciseAdded: (exercise: Exercise) => void;
|
||||
}
|
||||
|
||||
export default function AddExerciseForm({ onExerciseAdded }: AddExerciseFormProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [libraryExercises, setLibraryExercises] = useState<Exercise[]>([]);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
type: "barbell",
|
||||
muscleGroups: [] as string[],
|
||||
inputFields: ["sets", "reps", "weight"] as string[],
|
||||
description: "",
|
||||
});
|
||||
|
||||
const [addingType, setAddingType] = useState(false);
|
||||
const [newTypeText, setNewTypeText] = useState("");
|
||||
const [sessionTypes, setSessionTypes] = useState<Option[]>([]);
|
||||
|
||||
const [addingMuscle, setAddingMuscle] = useState(false);
|
||||
const [newMuscleText, setNewMuscleText] = useState("");
|
||||
const [sessionMuscles, setSessionMuscles] = useState<string[]>([]);
|
||||
|
||||
const [addingField, setAddingField] = useState(false);
|
||||
const [newFieldText, setNewFieldText] = useState("");
|
||||
const [sessionFields, setSessionFields] = useState<Option[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchExercises = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/exercises");
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
setLibraryExercises(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch exercise library:", err);
|
||||
}
|
||||
};
|
||||
fetchExercises();
|
||||
}, []);
|
||||
|
||||
const equipmentOptions = useMemo(() => {
|
||||
const base = deriveEquipmentOptions(libraryExercises);
|
||||
const merged = [...base];
|
||||
for (const option of sessionTypes) {
|
||||
if (!merged.some((item) => item.value === option.value)) {
|
||||
merged.push(option);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}, [libraryExercises, sessionTypes]);
|
||||
|
||||
const muscleOptions = useMemo(() => {
|
||||
const base = deriveMuscleGroupOptions(libraryExercises);
|
||||
const merged = [...base];
|
||||
for (const muscle of sessionMuscles) {
|
||||
if (!merged.includes(muscle)) {
|
||||
merged.push(muscle);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}, [libraryExercises, sessionMuscles]);
|
||||
|
||||
const trackingOptions = useMemo(() => {
|
||||
const base = deriveTrackingFieldOptions(libraryExercises);
|
||||
const merged = [...base];
|
||||
for (const option of sessionFields) {
|
||||
if (!merged.some((item) => item.value === option.value)) {
|
||||
merged.push(option);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}, [libraryExercises, sessionFields]);
|
||||
|
||||
const getDefaultFieldsForType = (type: string): string[] => {
|
||||
if (type === "cardio") return ["sets", "duration", "distance", "calories", "notes"];
|
||||
if (type === "bodyweight") return ["sets", "reps", "notes"];
|
||||
return ["sets", "reps", "weight", "notes"];
|
||||
};
|
||||
|
||||
const handleTypeChange = (type: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
type,
|
||||
inputFields: getDefaultFieldsForType(type),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleMuscleGroupToggle = (group: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
muscleGroups: prev.muscleGroups.includes(group)
|
||||
? prev.muscleGroups.filter((item) => item !== group)
|
||||
: [...prev.muscleGroups, group],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleInputFieldToggle = (field: string) => {
|
||||
if (field === "sets") return;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
inputFields: prev.inputFields.includes(field)
|
||||
? prev.inputFields.filter((item) => item !== field)
|
||||
: [...prev.inputFields, field],
|
||||
}));
|
||||
};
|
||||
|
||||
const commitNewType = () => {
|
||||
const value = normalizeValue(newTypeText);
|
||||
if (!value) {
|
||||
setAddingType(false);
|
||||
setNewTypeText("");
|
||||
return;
|
||||
}
|
||||
if (!equipmentOptions.some((option) => option.value === value)) {
|
||||
setSessionTypes((prev) => [...prev, { value, label: displayLabel(value) }]);
|
||||
}
|
||||
handleTypeChange(value);
|
||||
setAddingType(false);
|
||||
setNewTypeText("");
|
||||
};
|
||||
|
||||
const commitNewMuscle = () => {
|
||||
const value = normalizeValue(newMuscleText);
|
||||
if (!value) {
|
||||
setAddingMuscle(false);
|
||||
setNewMuscleText("");
|
||||
return;
|
||||
}
|
||||
if (!muscleOptions.includes(value)) {
|
||||
setSessionMuscles((prev) => [...prev, value]);
|
||||
}
|
||||
if (!formData.muscleGroups.includes(value)) {
|
||||
setFormData((prev) => ({ ...prev, muscleGroups: [...prev.muscleGroups, value] }));
|
||||
}
|
||||
setAddingMuscle(false);
|
||||
setNewMuscleText("");
|
||||
};
|
||||
|
||||
const commitNewField = () => {
|
||||
const value = normalizeValue(newFieldText);
|
||||
if (!value || value === "sets") {
|
||||
setAddingField(false);
|
||||
setNewFieldText("");
|
||||
return;
|
||||
}
|
||||
if (!trackingOptions.some((option) => option.value === value)) {
|
||||
setSessionFields((prev) => [...prev, { value, label: displayLabel(value) }]);
|
||||
}
|
||||
if (!formData.inputFields.includes(value)) {
|
||||
setFormData((prev) => ({ ...prev, inputFields: [...prev.inputFields, value] }));
|
||||
}
|
||||
setAddingField(false);
|
||||
setNewFieldText("");
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/exercises", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: formData.name,
|
||||
type: formData.type,
|
||||
muscleGroups: formData.muscleGroups,
|
||||
inputFields: formData.inputFields,
|
||||
description: formData.description || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Failed to add exercise");
|
||||
}
|
||||
|
||||
const exercise = await response.json();
|
||||
onExerciseAdded(exercise);
|
||||
setLibraryExercises((prev) => [...prev, exercise]);
|
||||
|
||||
setFormData({
|
||||
name: "",
|
||||
type: "barbell",
|
||||
muscleGroups: [],
|
||||
inputFields: ["sets", "reps", "weight"],
|
||||
description: "",
|
||||
});
|
||||
setSessionTypes([]);
|
||||
setSessionMuscles([]);
|
||||
setSessionFields([]);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="bg-red-900/50 border border-red-800 rounded-lg p-3 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
|
||||
Exercise Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
||||
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white/20"
|
||||
placeholder="e.g., Barbell Bench Press"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
|
||||
Equipment
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{equipmentOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => handleTypeChange(option.value)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
|
||||
formData.type === option.value
|
||||
? "bg-white text-black"
|
||||
: "bg-zinc-800 text-zinc-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
{addingType ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={newTypeText}
|
||||
onChange={(e) => setNewTypeText(e.target.value)}
|
||||
onBlur={commitNewType}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commitNewType();
|
||||
}
|
||||
}}
|
||||
placeholder="New"
|
||||
className="w-20 px-2 py-1.5 rounded-lg text-sm bg-zinc-800 border border-zinc-600 text-white"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddingType(true)}
|
||||
className="px-2.5 py-1.5 rounded-lg text-sm font-medium bg-zinc-800 text-zinc-500 hover:text-white border border-dashed border-zinc-600 transition"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-1">
|
||||
Tracked Fields
|
||||
</label>
|
||||
<p className="text-xs text-zinc-600 mb-2">What data do you log for this exercise?</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{trackingOptions.map((field) => (
|
||||
<button
|
||||
key={field.value}
|
||||
type="button"
|
||||
onClick={() => handleInputFieldToggle(field.value)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
|
||||
formData.inputFields.includes(field.value)
|
||||
? "bg-white text-black"
|
||||
: "bg-zinc-800 text-zinc-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{field.label}
|
||||
</button>
|
||||
))}
|
||||
{addingField ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={newFieldText}
|
||||
onChange={(e) => setNewFieldText(e.target.value)}
|
||||
onBlur={commitNewField}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commitNewField();
|
||||
}
|
||||
}}
|
||||
placeholder="New"
|
||||
className="w-20 px-2 py-1.5 rounded-lg text-sm bg-zinc-800 border border-zinc-600 text-white"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddingField(true)}
|
||||
className="px-2.5 py-1.5 rounded-lg text-sm font-medium bg-zinc-800 text-zinc-500 hover:text-white border border-dashed border-zinc-600 transition"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
|
||||
Muscle Groups
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{muscleOptions.map((group) => (
|
||||
<button
|
||||
key={group}
|
||||
type="button"
|
||||
onClick={() => handleMuscleGroupToggle(group)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
|
||||
formData.muscleGroups.includes(group)
|
||||
? "bg-white text-black"
|
||||
: "bg-zinc-800 text-zinc-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{displayLabel(group)}
|
||||
</button>
|
||||
))}
|
||||
{addingMuscle ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={newMuscleText}
|
||||
onChange={(e) => setNewMuscleText(e.target.value)}
|
||||
onBlur={commitNewMuscle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commitNewMuscle();
|
||||
}
|
||||
}}
|
||||
placeholder="New"
|
||||
className="w-20 px-2 py-1.5 rounded-lg text-sm bg-zinc-800 border border-zinc-600 text-white"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddingMuscle(true)}
|
||||
className="px-2.5 py-1.5 rounded-lg text-sm font-medium bg-zinc-800 text-zinc-500 hover:text-white border border-dashed border-zinc-600 transition"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-zinc-400 uppercase tracking-wider mb-2">
|
||||
Description (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
|
||||
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-white/20 resize-none"
|
||||
placeholder="Notes about form, tips, or variations..."
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !formData.name}
|
||||
className="w-full bg-white hover:bg-zinc-200 disabled:bg-zinc-700 disabled:text-zinc-500 text-black font-bold py-3 px-4 rounded-lg transition flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{loading ? "Adding..." : "Add Exercise"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,16 @@
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { User } from "@prisma/client";
|
||||
import { Loader2, Eye, EyeOff, Upload, AlertTriangle, CheckCircle2 } from "lucide-react";
|
||||
import {
|
||||
Loader2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Upload,
|
||||
Download,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface UserPreferences {
|
||||
theme: string;
|
||||
@@ -266,11 +275,145 @@ export default function SettingsForm({ user }: { user: User }) {
|
||||
</button>
|
||||
|
||||
{/* Database Import Section */}
|
||||
<DatabaseExport />
|
||||
<WorkoutCsvImportShortcut />
|
||||
<DatabaseImport />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Database Export Component ----------
|
||||
function DatabaseExport() {
|
||||
const [exportingDb, setExportingDb] = useState(false);
|
||||
const [exportingCsv, setExportingCsv] = useState(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
|
||||
const triggerDownload = async (
|
||||
endpoint: string,
|
||||
fallbackName: string,
|
||||
setLoading: (v: boolean) => void
|
||||
) => {
|
||||
setLoading(true);
|
||||
setExportError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const maybeJson = await response.json().catch(() => null);
|
||||
throw new Error(maybeJson?.error || "Export failed");
|
||||
}
|
||||
|
||||
const disposition = response.headers.get("content-disposition") || "";
|
||||
const match = disposition.match(/filename="([^"]+)"/i);
|
||||
const fileName = match?.[1] || fallbackName;
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
setExportError(err instanceof Error ? err.message : "Export failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
|
||||
<h2 className="text-lg font-bold text-white mb-1">Export Backups</h2>
|
||||
<p className="text-sm text-zinc-500 mb-4">
|
||||
Download a full database backup or a CSV export of workout logs.
|
||||
</p>
|
||||
|
||||
{exportError && (
|
||||
<div className="bg-red-900/30 border border-red-800 rounded-lg p-3 mb-4 flex items-start gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-red-400">{exportError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
triggerDownload(
|
||||
"/api/settings/export-db",
|
||||
"proof-of-work-backup.db",
|
||||
setExportingDb
|
||||
)
|
||||
}
|
||||
disabled={exportingDb || exportingCsv}
|
||||
className="py-3 border border-zinc-700 rounded-lg text-zinc-300 text-sm font-medium hover:text-white hover:border-zinc-500 disabled:opacity-50 transition flex items-center justify-center gap-2"
|
||||
>
|
||||
{exportingDb ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Exporting DB...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4" />
|
||||
Export SQLite (.db)
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
triggerDownload(
|
||||
"/api/settings/export-csv",
|
||||
"proof-of-work-export.csv",
|
||||
setExportingCsv
|
||||
)
|
||||
}
|
||||
disabled={exportingDb || exportingCsv}
|
||||
className="py-3 border border-zinc-700 rounded-lg text-zinc-300 text-sm font-medium hover:text-white hover:border-zinc-500 disabled:opacity-50 transition flex items-center justify-center gap-2"
|
||||
>
|
||||
{exportingCsv ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Exporting CSV...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4" />
|
||||
Export Workout CSV
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- CSV Import Shortcut ----------
|
||||
function WorkoutCsvImportShortcut() {
|
||||
return (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
|
||||
<h2 className="text-lg font-bold text-white mb-1">Import Workouts (CSV)</h2>
|
||||
<p className="text-sm text-zinc-500 mb-4">
|
||||
One-time migration tool for importing older workout history from CSV.
|
||||
</p>
|
||||
<Link
|
||||
href="/main/import"
|
||||
className="w-full inline-flex items-center justify-center gap-2 py-3 border border-zinc-700 rounded-lg text-zinc-300 text-sm font-medium hover:text-white hover:border-zinc-500 transition"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Open CSV Import Tool
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Database Import Component ----------
|
||||
function DatabaseImport() {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -3,7 +3,15 @@
|
||||
import { Trash2, Check, Pencil, CornerDownLeft } from "lucide-react";
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
export type InputField = "sets" | "reps" | "weight" | "duration" | "distance" | "calories" | "notes";
|
||||
export type InputField =
|
||||
| "sets"
|
||||
| "reps"
|
||||
| "weight"
|
||||
| "duration"
|
||||
| "distance"
|
||||
| "calories"
|
||||
| "notes"
|
||||
| string;
|
||||
|
||||
export interface SetRowProps {
|
||||
setNumber: number;
|
||||
@@ -16,6 +24,7 @@ export interface SetRowProps {
|
||||
initialDuration?: number;
|
||||
initialDistance?: number;
|
||||
initialCalories?: number;
|
||||
initialCustomMetrics?: Record<string, string>;
|
||||
initialLocked?: boolean;
|
||||
autoFocus?: boolean;
|
||||
onUpdate: (data: {
|
||||
@@ -26,6 +35,7 @@ export interface SetRowProps {
|
||||
durationSeconds?: number;
|
||||
distance?: number;
|
||||
calories?: number;
|
||||
customMetrics?: Record<string, string>;
|
||||
}) => void;
|
||||
onConfirm?: () => void;
|
||||
onNextSet?: (currentValues: {
|
||||
@@ -51,6 +61,7 @@ export default function SetRow({
|
||||
initialDuration,
|
||||
initialDistance,
|
||||
initialCalories,
|
||||
initialCustomMetrics,
|
||||
initialLocked = false,
|
||||
autoFocus = false,
|
||||
onUpdate,
|
||||
@@ -58,13 +69,30 @@ export default function SetRow({
|
||||
onNextSet,
|
||||
onDelete,
|
||||
}: SetRowProps) {
|
||||
const secondsToMinuteString = (seconds?: number) => {
|
||||
if (seconds === undefined || seconds === null) return "";
|
||||
const minutes = seconds / 60;
|
||||
const rounded = Math.round(minutes * 10) / 10;
|
||||
return Number.isInteger(rounded) ? String(Math.trunc(rounded)) : String(rounded);
|
||||
};
|
||||
|
||||
const minuteStringToSeconds = (minutes: string) => {
|
||||
if (!minutes) return undefined;
|
||||
const parsed = parseFloat(minutes);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
|
||||
return Math.round(parsed * 60);
|
||||
};
|
||||
|
||||
const [reps, setReps] = useState(initialReps?.toString() || "");
|
||||
const [weight, setWeight] = useState(initialWeight?.toString() || "");
|
||||
const [rpe, setRpe] = useState(initialRpe?.toString() || "");
|
||||
const [notes, setNotes] = useState(initialNotes || "");
|
||||
const [duration, setDuration] = useState(initialDuration?.toString() || "");
|
||||
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 || {}
|
||||
);
|
||||
const [showNotes, setShowNotes] = useState(!!initialNotes);
|
||||
const [locked, setLocked] = useState(initialLocked);
|
||||
|
||||
@@ -74,6 +102,18 @@ export default function SetRow({
|
||||
const showDistance = inputFields.includes("distance");
|
||||
const showCalories = inputFields.includes("calories");
|
||||
const showNotesField = inputFields.includes("notes");
|
||||
const customFields = inputFields.filter(
|
||||
(f) =>
|
||||
![
|
||||
"sets",
|
||||
"reps",
|
||||
"weight",
|
||||
"duration",
|
||||
"distance",
|
||||
"calories",
|
||||
"notes",
|
||||
].includes(f)
|
||||
);
|
||||
|
||||
const emitUpdate = useCallback(
|
||||
(overrides: {
|
||||
@@ -84,6 +124,7 @@ export default function SetRow({
|
||||
duration?: string;
|
||||
distance?: string;
|
||||
calories?: string;
|
||||
customMetrics?: Record<string, string>;
|
||||
}) => {
|
||||
const r = overrides.reps ?? reps;
|
||||
const w = overrides.weight ?? weight;
|
||||
@@ -92,18 +133,26 @@ export default function SetRow({
|
||||
const dur = overrides.duration ?? duration;
|
||||
const dist = overrides.distance ?? distance;
|
||||
const cal = overrides.calories ?? calories;
|
||||
const cm = overrides.customMetrics ?? customValues;
|
||||
const cleanedCustomMetrics = Object.fromEntries(
|
||||
Object.entries(cm).filter(([, value]) => value !== "")
|
||||
);
|
||||
|
||||
onUpdate({
|
||||
reps: r ? parseInt(r) : undefined,
|
||||
weight: w ? parseFloat(w) : undefined,
|
||||
rpe: p ? parseInt(p) : undefined,
|
||||
notes: n || undefined,
|
||||
durationSeconds: dur ? parseInt(dur) : undefined,
|
||||
durationSeconds: minuteStringToSeconds(dur),
|
||||
distance: dist ? parseFloat(dist) : undefined,
|
||||
calories: cal ? parseInt(cal) : undefined,
|
||||
customMetrics:
|
||||
Object.keys(cleanedCustomMetrics).length > 0
|
||||
? cleanedCustomMetrics
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
[reps, weight, rpe, notes, duration, distance, calories, onUpdate]
|
||||
[reps, weight, rpe, notes, duration, distance, calories, customValues, onUpdate]
|
||||
);
|
||||
|
||||
const handleConfirm = () => {
|
||||
@@ -134,9 +183,13 @@ export default function SetRow({
|
||||
const parts: string[] = [];
|
||||
if (showWeight && weight) parts.push(`${weight} ${weightUnit}`);
|
||||
if (showReps && reps) parts.push(`${reps} reps`);
|
||||
if (showDuration && duration) parts.push(`${duration}s`);
|
||||
if (showDuration && duration) parts.push(`${duration} min`);
|
||||
if (showDistance && distance) parts.push(`${distance} mi`);
|
||||
if (showCalories && calories) parts.push(`${calories} cal`);
|
||||
for (const field of customFields) {
|
||||
const value = customValues[field];
|
||||
if (value) parts.push(`${field}: ${value}`);
|
||||
}
|
||||
if (rpe) parts.push(`RPE ${rpe}`);
|
||||
if (showNotesField && notes) parts.push(notes);
|
||||
return parts.length > 0 ? parts.join(" · ") : "No data";
|
||||
@@ -243,10 +296,11 @@ export default function SetRow({
|
||||
{showDuration && (
|
||||
<div className="flex-1 min-w-[55px]">
|
||||
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
|
||||
Time (s)
|
||||
Time (min)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
autoFocus={autoFocus && firstField === "duration"}
|
||||
value={duration}
|
||||
onChange={(e) => {
|
||||
@@ -359,6 +413,33 @@ export default function SetRow({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dynamic custom metrics configured on the exercise (e.g., watts) */}
|
||||
{customFields.length > 0 && (
|
||||
<div className="ml-8 grid grid-cols-2 gap-1.5">
|
||||
{customFields.map((field) => (
|
||||
<div key={field}>
|
||||
<label className="block text-[10px] font-medium text-zinc-500 mb-0.5">
|
||||
{field.charAt(0).toUpperCase() + field.slice(1)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customValues[field] || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setCustomValues((prev) => {
|
||||
const next = { ...prev, [field]: val };
|
||||
emitUpdate({ customMetrics: next });
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
placeholder="-"
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes — always visible when configured as input field */}
|
||||
{showNotesField && (
|
||||
<div className="ml-8">
|
||||
@@ -67,7 +67,7 @@ function ExerciseHistoryPopup({
|
||||
<p className="text-xs text-zinc-500 text-center py-4">No history yet</p>
|
||||
) : (
|
||||
<div className="divide-y divide-zinc-800/50">
|
||||
{history.slice(0, 10).map((entry) => {
|
||||
{history.slice(0, 50).map((entry) => {
|
||||
const d = new Date(entry.workout.date);
|
||||
const dateStr = d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
const summary = formatSetsSummary(entry.sets);
|
||||
@@ -107,6 +107,10 @@ interface ExerciseWithSets {
|
||||
reps?: number;
|
||||
weight?: number;
|
||||
rpe?: number;
|
||||
durationSeconds?: number;
|
||||
distance?: number;
|
||||
calories?: number;
|
||||
customMetrics?: Record<string, string>;
|
||||
notes?: string;
|
||||
forceEdit?: boolean; // When true, start in edit mode even if data is pre-filled
|
||||
}>;
|
||||
@@ -127,6 +131,10 @@ export interface EditWorkoutData {
|
||||
reps?: number;
|
||||
weight?: number;
|
||||
rpe?: number;
|
||||
durationSeconds?: number;
|
||||
distance?: number;
|
||||
calories?: number;
|
||||
customMetrics?: Record<string, string>;
|
||||
notes?: string;
|
||||
}>;
|
||||
}>;
|
||||
@@ -173,6 +181,7 @@ export default function WorkoutForm({
|
||||
const [autoSaving, setAutoSaving] = useState(false);
|
||||
const [showSavedFlash, setShowSavedFlash] = useState(false);
|
||||
const savingRef = useRef(false);
|
||||
const pendingAutoSaveRef = useRef(false);
|
||||
const savedFlashTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Flash "Saved ✓" briefly after each successful save
|
||||
@@ -208,6 +217,11 @@ export default function WorkoutForm({
|
||||
weight: s.weight,
|
||||
weightUnit: (e.exercise as any).defaultWeightUnit || "lbs",
|
||||
rpe: s.rpe,
|
||||
durationSeconds: s.durationSeconds,
|
||||
distance: s.distance,
|
||||
distanceUnit: s.distance !== undefined ? "mi" : undefined,
|
||||
calories: s.calories,
|
||||
customMetrics: s.customMetrics,
|
||||
notes: s.notes,
|
||||
}))
|
||||
),
|
||||
@@ -219,7 +233,11 @@ export default function WorkoutForm({
|
||||
// ---------- Auto-save: create or update ----------
|
||||
const autoSave = useCallback(
|
||||
async (overrideExercises?: ExerciseWithSets[]) => {
|
||||
if (savingRef.current) return;
|
||||
if (savingRef.current) {
|
||||
// Don't drop updates while a save is in-flight; queue one follow-up save.
|
||||
pendingAutoSaveRef.current = true;
|
||||
return;
|
||||
}
|
||||
savingRef.current = true;
|
||||
setAutoSaving(true);
|
||||
|
||||
@@ -264,6 +282,12 @@ export default function WorkoutForm({
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
setAutoSaving(false);
|
||||
if (pendingAutoSaveRef.current) {
|
||||
pendingAutoSaveRef.current = false;
|
||||
setTimeout(() => {
|
||||
void autoSave();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
},
|
||||
[buildPayload, savedWorkoutId, triggerSavedFlash]
|
||||
@@ -351,7 +375,16 @@ export default function WorkoutForm({
|
||||
const handleUpdateSet = (
|
||||
exerciseId: string,
|
||||
setNumber: number,
|
||||
data: { reps?: number; weight?: number; rpe?: number; notes?: string }
|
||||
data: {
|
||||
reps?: number;
|
||||
weight?: number;
|
||||
rpe?: number;
|
||||
notes?: string;
|
||||
durationSeconds?: number;
|
||||
distance?: number;
|
||||
calories?: number;
|
||||
customMetrics?: Record<string, string>;
|
||||
}
|
||||
) => {
|
||||
setAddedExercises((prev) =>
|
||||
prev.map((e) => {
|
||||
@@ -456,6 +489,7 @@ export default function WorkoutForm({
|
||||
}
|
||||
// Prevent auto-saves from starting while we do the final save
|
||||
savingRef.current = true;
|
||||
pendingAutoSaveRef.current = false;
|
||||
|
||||
const payload = buildPayload();
|
||||
|
||||
@@ -696,9 +730,33 @@ export default function WorkoutForm({
|
||||
initialReps={set.reps}
|
||||
initialWeight={set.weight}
|
||||
initialRpe={set.rpe}
|
||||
initialDuration={set.durationSeconds}
|
||||
initialDistance={set.distance}
|
||||
initialCalories={set.calories}
|
||||
initialCustomMetrics={set.customMetrics}
|
||||
initialNotes={set.notes}
|
||||
initialLocked={set.forceEdit ? false : !!(set.reps || set.weight)}
|
||||
autoFocus={set.forceEdit || (idx === item.sets.length - 1 && !set.reps && !set.weight)}
|
||||
initialLocked={
|
||||
set.forceEdit
|
||||
? false
|
||||
: !!(
|
||||
set.reps ||
|
||||
set.weight ||
|
||||
set.durationSeconds ||
|
||||
set.distance ||
|
||||
set.calories ||
|
||||
(set.customMetrics &&
|
||||
Object.values(set.customMetrics).some((v) => v))
|
||||
)
|
||||
}
|
||||
autoFocus={
|
||||
set.forceEdit ||
|
||||
(idx === item.sets.length - 1 &&
|
||||
!set.reps &&
|
||||
!set.weight &&
|
||||
!set.durationSeconds &&
|
||||
!set.distance &&
|
||||
!set.calories)
|
||||
}
|
||||
onUpdate={(data) =>
|
||||
handleUpdateSet(
|
||||
item.exercise.id,
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
_Last updated: February 18, 2026_
|
||||
|
||||
## Project Root (`workout-planner/`)
|
||||
## Project Root (`proof-of-work/`)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
@@ -166,7 +166,7 @@ All pages under `/main/` require authentication (enforced by middleware).
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `import-data/workout-log-feb2026.csv` | Parsed handwritten workout logs (Jan–Feb 2026, ~362 rows). Format: `date,exercise,weight,reps,notes`. More pages to be added. |
|
||||
| `import-data/proof-of-work-feb2026.csv` | Parsed handwritten workout logs (Jan–Feb 2026, ~362 rows). Format: `date,exercise,weight,reps,notes`. More pages to be added. |
|
||||
|
||||
## `docs/` — Project Documentation
|
||||
|
||||
@@ -129,5 +129,5 @@ Database lives at `prisma/data/app.db`. Environment variables in `.env` / `.env.
|
||||
|
||||
- The actual database file is `prisma/data/app.db`, NOT `prisma/dev.db` (which exists but is empty/stale).
|
||||
- SQLite on some mounted filesystems (Docker volumes, network mounts) can have journal mode issues. If you get "disk I/O error", try copying the DB locally, modifying with `PRAGMA journal_mode=OFF`, then copying back.
|
||||
- The import CSV file at `import-data/workout-log-feb2026.csv` contains parsed data from handwritten logs covering January-February 2026. More pages will be added over time.
|
||||
- The import CSV file at `import-data/proof-of-work-feb2026.csv` contains parsed data from handwritten logs covering January-February 2026. More pages will be added over time.
|
||||
- Exercise name mapping in the import parser (`NAME_MAP` in `app/api/import/parse/route.ts`) should be updated as new shorthand names are encountered in CSV data.
|
||||
@@ -0,0 +1,473 @@
|
||||
date,exercise,setNumber,reps,weight,weightUnit,durationMinutes,distance,distanceUnit,setCalories,rpe,notes,customMetricsJson
|
||||
1/2/2026,Face Pulls,1,10,68,lbs,,,,,,,
|
||||
1/2/2026,Rear delt,1,10,16,lbs,,,,,,,
|
||||
1/2/2026,EQ Bar Incline Bench,1,13,16,kg,,,,,,16kg KB each side,
|
||||
1/2/2026,EQ Bar Incline Bench,2,10,21,lbs,,,,,,16kg KB + 5lb each side,
|
||||
1/2/2026,EQ Bar Incline Bench,3,10,26,lbs,,,,,,16kg KB + 10lb each side,
|
||||
1/2/2026,EQ Bar Incline Bench,4,12,26,lbs,,,,,,16kg KB + 10lb each side,
|
||||
1/2/2026,EQ Bar Incline Bench,5,10,31,lbs,,,,,,16kg KB + 15lb each side,
|
||||
1/2/2026,Chinup,1,10,,lbs,,,,,,,
|
||||
1/2/2026,Chinup,2,7,,lbs,,,,,,,
|
||||
1/2/2026,Chinup,3,7,,lbs,,,,,,,
|
||||
1/2/2026,Chinup,4,5,,lbs,,,,,,slow negatives,
|
||||
1/2/2026,Chinup,5,5,,lbs,,,,,,slow negatives,
|
||||
1/2/2026,Alternating Step Cable Cross,1,16,44,lbs,,,,,,,
|
||||
1/2/2026,Alternating Step Cable Cross,2,16,44,lbs,,,,,,,
|
||||
1/2/2026,Alternating Step Cable Cross,3,16,44,lbs,,,,,,,
|
||||
1/2/2026,Tuck Inversion,1,2,,lbs,,,,,,vest,
|
||||
1/2/2026,Tuck Inversion,2,2,,lbs,,,,,,vest,
|
||||
1/2/2026,Ab Wheel Rollout,1,7,,lbs,,,,,,vest,
|
||||
1/2/2026,Ab Wheel Rollout,2,7,,lbs,,,,,,vest,
|
||||
1/2/2026,Ab Wheel Rollout,3,7,,lbs,,,,,,vest,
|
||||
1/2/2026,Ring Dip,1,3,,lbs,,,,,,,
|
||||
1/2/2026,Ring Dip,2,3,,lbs,,,,,,,
|
||||
1/2/2026,Ring Dip,3,3,,lbs,,,,,,,
|
||||
1/2/2026,Overhead Tricep Extension,1,13,44,lbs,,,,,,,
|
||||
1/2/2026,Overhead Tricep Extension,2,13,44,lbs,,,,,,,
|
||||
1/2/2026,Overhead Tricep Extension,3,13,44,lbs,,,,,,,
|
||||
1/2/2026,Barbell Curl,1,17,45,lbs,,,,,,bar only,
|
||||
1/2/2026,Barbell Curl,2,17,45,lbs,,,,,,bar only,
|
||||
1/2/2026,Barbell Curl,3,17,45,lbs,,,,,,bar only,
|
||||
1/2/2026,Band Pushup,1,7,,lbs,,,,,,black/gray band,
|
||||
1/2/2026,Band Pushup,2,7,,lbs,,,,,,black/gray band,
|
||||
1/2/2026,Band Pushup,3,7,,lbs,,,,,,black/gray band,
|
||||
1/3/2026,TGU,1,1,36,kg,,,,,,,
|
||||
1/3/2026,TGU,2,1,40,kg,,,,,,,
|
||||
1/3/2026,TGU,3,1,44,kg,,,,,,,
|
||||
1/3/2026,TGU,4,1,48,kg,,,,,,,
|
||||
1/3/2026,TGU,5,1,48,kg,,,,,,,
|
||||
1/3/2026,TGU,6,1,48,kg,,,,,,,
|
||||
1/3/2026,Deadlift,1,5,185,lbs,,,,,,,
|
||||
1/3/2026,Deadlift,2,5,225,lbs,,,,,,,
|
||||
1/3/2026,Deadlift,3,5,275,lbs,,,,,,,
|
||||
1/3/2026,Deadlift,4,5,275,lbs,,,,,,,
|
||||
1/3/2026,Deadlift,5,5,275,lbs,,,,,,,
|
||||
1/3/2026,Deadlift,6,6,275,lbs,,,,,,,
|
||||
1/3/2026,Assault Bike,1,,,lbs,,,,72,,10/20 intervals,
|
||||
1/4/2026,Assault Bike,1,,,lbs,5,,,,,target zone 4 cardio,
|
||||
1/4/2026,SkiErg,1,,,lbs,5,,,,,target zone 4 cardio,
|
||||
1/4/2026,Jump Rope,1,,,lbs,5,,,,,target zone 4 cardio,
|
||||
1/4/2026,KB Swing,1,5,24,kg,,,,,,single arm every 60 sec / arm every 45 sec - 5 min,
|
||||
1/4/2026,Neck Circuit,1,17,10,lbs,,,,,,,
|
||||
1/6/2026,Squat,1,7,135,lbs,,,,,,,
|
||||
1/6/2026,Squat,2,7,165,lbs,,,,,,,
|
||||
1/6/2026,Squat,3,7,165,lbs,,,,,,chains - 207 total,
|
||||
1/6/2026,Squat,4,7,165,lbs,,,,,,chains - 207 total,
|
||||
1/6/2026,Squat,5,7,165,lbs,,,,,,chains - 207 total,
|
||||
1/6/2026,Bulgarian Split Squat,1,10,30,lbs,,,,,,,
|
||||
1/6/2026,Bulgarian Split Squat,2,10,30,lbs,,,,,,,
|
||||
1/6/2026,Bulgarian Split Squat,3,10,30,lbs,,,,,,,
|
||||
1/6/2026,Hip Flexor,1,10,12,kg,,,,,,,
|
||||
1/6/2026,Hip Flexor,2,10,12,kg,,,,,,,
|
||||
1/6/2026,Hip Flexor,3,10,12,kg,,,,,,,
|
||||
1/6/2026,Kettlebell Leg Extension,1,10,12,kg,,,,,,,
|
||||
1/6/2026,Kettlebell Leg Extension,2,10,12,kg,,,,,,,
|
||||
1/6/2026,Kettlebell Leg Extension,3,10,12,kg,,,,,,,
|
||||
1/6/2026,Adductor Bench,1,10,,lbs,,,,,,,
|
||||
1/6/2026,Adductor Bench,2,10,,lbs,,,,,,,
|
||||
1/6/2026,Adductor Bench,3,10,,lbs,,,,,,,
|
||||
1/6/2026,Glute Bridge,1,13,135,lbs,,,,,,,
|
||||
1/6/2026,Glute Bridge,2,13,135,lbs,,,,,,,
|
||||
1/6/2026,Glute Bridge,3,13,135,lbs,,,,,,,
|
||||
1/7/2026,Face Pulls,1,10,68,lbs,,,,,,,
|
||||
1/7/2026,Rear delt,1,8,16,lbs,,,,,,,
|
||||
1/7/2026,EQ Bar Incline Bench,1,10,16,kg,,,,,,16kg KB each side,
|
||||
1/7/2026,EQ Bar Incline Bench,2,10,26,lbs,,,,,,16kg KB + 10lb each side,
|
||||
1/7/2026,EQ Bar Incline Bench,3,10,26,lbs,,,,,,16kg KB + 10lb each side,
|
||||
1/7/2026,EQ Bar Incline Bench,4,13,31,lbs,,,,,,16kg KB + 15lb each side,
|
||||
1/7/2026,EQ Bar Incline Bench,5,9,31,lbs,,,,,,16kg KB + 15lb each side,
|
||||
1/7/2026,EQ Bar Incline Bench,6,8,31,lbs,,,,,,16kg KB + 15lb each side,
|
||||
1/7/2026,Chinup,1,8,,lbs,,,,,,,
|
||||
1/7/2026,Chinup,2,6,,lbs,,,,,,,
|
||||
1/7/2026,Chinup,3,6,,lbs,,,,,,,
|
||||
1/7/2026,Chinup,4,6,,lbs,,,,,,,
|
||||
1/7/2026,Alternating Step Cable Cross,1,16,44,lbs,,,,,,,
|
||||
1/7/2026,Alternating Step Cable Cross,2,16,44,lbs,,,,,,,
|
||||
1/7/2026,Alternating Step Cable Cross,3,16,44,lbs,,,,,,,
|
||||
1/7/2026,Tuck Inversion,1,2,,lbs,,,,,,vest,
|
||||
1/7/2026,Tuck Inversion,2,2,,lbs,,,,,,vest,
|
||||
1/7/2026,Ab Wheel Rollout,1,7,,lbs,,,,,,vest,
|
||||
1/7/2026,Ab Wheel Rollout,2,7,,lbs,,,,,,vest,
|
||||
1/7/2026,Ab Wheel Rollout,3,7,,lbs,,,,,,vest,
|
||||
1/7/2026,Ring Dip,1,7,,lbs,,,,,,,
|
||||
1/7/2026,Ring Dip,2,7,,lbs,,,,,,,
|
||||
1/10/2026,Deadlift,1,5,185,lbs,,,,,,,
|
||||
1/10/2026,Deadlift,2,5,225,lbs,,,,,,,
|
||||
1/10/2026,Deadlift,3,5,275,lbs,,,,,,,
|
||||
1/10/2026,Deadlift,4,4,295,lbs,,,,,,,
|
||||
1/10/2026,Deadlift,5,5,295,lbs,,,,,,,
|
||||
1/10/2026,TGU,1,1,44,kg,,,,,,,
|
||||
1/10/2026,TGU,2,1,48,kg,,,,,,,
|
||||
1/10/2026,TGU,3,1,48,kg,,,,,,,
|
||||
1/10/2026,Ring row,1,10,,lbs,,,,,,,
|
||||
1/10/2026,Ring row,2,7,,lbs,,,,,,,
|
||||
1/10/2026,Ring row,3,6,,lbs,,,,,,,
|
||||
1/10/2026,Glute Bridge,1,13,135,lbs,,,,,,,
|
||||
1/10/2026,Glute Bridge,2,13,135,lbs,,,,,,,
|
||||
1/10/2026,Glute Bridge,3,13,135,lbs,,,,,,,
|
||||
1/10/2026,Fire Hydrant,1,17,,lbs,,,,,,blue band,
|
||||
1/10/2026,Fire Hydrant,2,13,,lbs,,,,,,blue band,
|
||||
1/10/2026,Fire Hydrant,3,17,,lbs,,,,,,blue band,
|
||||
1/10/2026,Glute ham developer,1,7,,lbs,,,,,,"pad on floor, gray band",
|
||||
1/10/2026,Glute ham developer,2,4,,lbs,,,,,,"pad on floor, gray band",
|
||||
1/10/2026,Glute ham developer,3,7,,lbs,,,,,,"pad on floor, gray band",
|
||||
1/10/2026,Neck Circuit,1,17,10,lbs,,,,,,,
|
||||
1/12/2026,Barbell step back lunge,1,10,75,lbs,,,,,,,
|
||||
1/12/2026,Barbell step back lunge,2,10,95,lbs,,,,,,,
|
||||
1/12/2026,Barbell step back lunge,3,10,115,lbs,,,,,,,
|
||||
1/12/2026,Bench Step Up,1,10,16,kg,,,,,,quad band black,
|
||||
1/12/2026,Bench Step Up,2,10,16,kg,,,,,,quad band black,
|
||||
1/12/2026,Kettlebell Leg Extension,1,17,12,kg,,,,,,,
|
||||
1/12/2026,Kettlebell Leg Extension,2,17,12,kg,,,,,,,
|
||||
1/12/2026,Kettlebell Leg Extension,3,23,12,kg,,,,,,,
|
||||
1/13/2026,Shoulder warmup,1,,15,lbs,,,,,,warmup,
|
||||
1/13/2026,Mace warmup,1,,,lbs,,,,,,warmup,
|
||||
1/13/2026,Face Pulls,1,10,72,lbs,,,,,,,
|
||||
1/13/2026,Rear delt,1,7,16,lbs,,,,,,,
|
||||
1/13/2026,Rear delt,2,10,16,lbs,,,,,,,
|
||||
1/13/2026,Rear delt,3,7,16,lbs,,,,,,,
|
||||
1/13/2026,Dumbbell Row,1,10,55,lbs,,,,,,,
|
||||
1/13/2026,Dumbbell Row,2,10,55,lbs,,,,,,,
|
||||
1/13/2026,Dumbbell Row,3,10,55,lbs,,,,,,,
|
||||
1/13/2026,EQ Bar Incline Bench,1,10,16,kg,,,,,,16kg KB each side,
|
||||
1/13/2026,EQ Bar Incline Bench,2,14,31,lbs,,,,,,16kg + 15 lbs,
|
||||
1/13/2026,EQ Bar Incline Bench,3,15,31,lbs,,,,,,16kg + 15 lbs,
|
||||
1/13/2026,EQ Bar Incline Bench,4,10,31,lbs,,,,,,slow,
|
||||
1/13/2026,Chinup,1,10,,lbs,,,,,,,
|
||||
1/13/2026,Chinup,2,6,,lbs,,,,,,,
|
||||
1/13/2026,Chinup,3,6,,lbs,,,,,,,
|
||||
1/13/2026,Exercise Ball Situp,1,17,,lbs,,,,,,,
|
||||
1/13/2026,Exercise Ball Situp,2,13,,lbs,,,,,,,
|
||||
1/13/2026,Exercise Ball Situp,3,17,,lbs,,,,,,,
|
||||
1/13/2026,Alternating Step Cable Cross,1,16,44,lbs,,,,,,,
|
||||
1/13/2026,Alternating Step Cable Cross,2,16,44,lbs,,,,,,,
|
||||
1/13/2026,Alternating Step Cable Cross,3,10,44,lbs,,,,,,,
|
||||
1/13/2026,Tuck Inversion,1,2,,lbs,,,,,,vest,
|
||||
1/13/2026,Tuck Inversion,2,2,,lbs,,,,,,vest,
|
||||
1/13/2026,Tuck Inversion,3,2,,lbs,,,,,,vest,
|
||||
1/13/2026,Ab Wheel Rollout,1,10,,lbs,,,,,,vest,
|
||||
1/13/2026,Ab Wheel Rollout,2,10,,lbs,,,,,,vest,
|
||||
1/13/2026,Ring Dip,1,5,,lbs,,,,,,,
|
||||
1/13/2026,Ring Dip,2,5,,lbs,,,,,,,
|
||||
1/13/2026,Tricep Pushdown,1,10,44,lbs,,,,,,rope,
|
||||
1/13/2026,Tricep Pushdown,2,10,44,lbs,,,,,,rope,
|
||||
1/14/2026,Hex Bar Deadlift,1,5,200,lbs,,,,,,,
|
||||
1/14/2026,Hex Bar Deadlift,2,5,240,lbs,,,,,,,
|
||||
1/14/2026,Hex Bar Deadlift,3,5,290,lbs,,,,,,,
|
||||
1/14/2026,Hex Bar Deadlift,4,5,320,lbs,,,,,,,
|
||||
1/14/2026,Hex Bar Deadlift,5,3,340,lbs,,,,,,,
|
||||
1/14/2026,Hex Bar Deadlift,6,3,355,lbs,,,,,,best ever,
|
||||
1/14/2026,Glute Bridge,1,13,135,lbs,,,,,,,
|
||||
1/14/2026,Glute Bridge,2,13,135,lbs,,,,,,,
|
||||
1/14/2026,Glute Bridge,3,13,135,lbs,,,,,,,
|
||||
1/14/2026,SL Quad Step Down,1,10,,lbs,,,,,,on bench,
|
||||
1/14/2026,SL Quad Step Down,2,10,15,lbs,,,,,,on bench,
|
||||
1/14/2026,SL Quad Step Down,3,10,16,kg,,,,,,"on bench, good weight",
|
||||
1/14/2026,Fire Hydrant,1,17,,lbs,,,,,,black band,
|
||||
1/14/2026,Fire Hydrant,2,17,,lbs,,,,,,black band,
|
||||
1/14/2026,Assault Bike,1,,,lbs,,,,69,,10/20 intervals,
|
||||
1/16/2026,Shoulder warmup,1,,,lbs,,,,,,warmup,
|
||||
1/16/2026,Face Pulls,1,10,68,lbs,,,,,,,
|
||||
1/16/2026,Mace warmup,1,,,lbs,,,,,,warmup,
|
||||
1/16/2026,Rear delt,1,10,16,lbs,,,,,,,
|
||||
1/16/2026,TGU,1,1,44,kg,,,,,,,
|
||||
1/16/2026,TGU,2,1,48,kg,,,,,,,
|
||||
1/16/2026,TGU,3,1,48,kg,,,,,,,
|
||||
1/16/2026,TGU,4,1,48,kg,,,,,,,
|
||||
1/16/2026,TGU,5,1,48,kg,,,,,,,
|
||||
1/16/2026,TGU,6,1,48,kg,,,,,,,
|
||||
1/16/2026,Overhead Press,1,7,75,lbs,,,,,,,
|
||||
1/16/2026,Overhead Press,2,7,95,lbs,,,,,,,
|
||||
1/16/2026,Overhead Press,3,5,115,lbs,,,,,,,
|
||||
1/16/2026,Overhead Press,4,5,115,lbs,,,,,,,
|
||||
1/16/2026,Overhead Press,5,5,115,lbs,,,,,,,
|
||||
1/16/2026,Chinup,1,4,,lbs,,,,,,vest,
|
||||
1/16/2026,Chinup,2,5,,lbs,,,,,,vest,
|
||||
1/16/2026,Chinup,3,5,,lbs,,,,,,vest,
|
||||
1/16/2026,Chinup,4,5,,lbs,,,,,,vest,
|
||||
1/16/2026,Arnold Press,1,8,40,lbs,,,,,,,
|
||||
1/16/2026,Arnold Press,2,10,40,lbs,,,,,,,
|
||||
1/16/2026,Arnold Press,3,10,40,lbs,,,,,,,
|
||||
1/16/2026,Barbell Curl,1,10,45,lbs,,,,,,bar only,
|
||||
1/16/2026,Barbell Curl,2,10,65,lbs,,,,,,,
|
||||
1/16/2026,Barbell Curl,3,10,65,lbs,,,,,,,
|
||||
1/16/2026,Barbell Row,1,10,95,lbs,,,,,,,
|
||||
1/16/2026,Barbell Row,2,14,95,lbs,,,,,,,
|
||||
1/16/2026,Barbell Row,3,13,95,lbs,,,,,,,
|
||||
1/16/2026,Waiter Carry,1,20,16,kg,,,,,,20 steps,
|
||||
1/16/2026,Farmer's Carry,1,40,44,kg,,,,,,"40 steps, repeat circuit",
|
||||
1/16/2026,Captains of Crush,1,7,0.5,lbs,,,,,,gripper #0.5,
|
||||
1/16/2026,Captains of Crush,2,3,1,lbs,,,,,,gripper #1,
|
||||
1/16/2026,Captains of Crush,3,5,1,lbs,,,,,,gripper #1,
|
||||
1/16/2026,Captains of Crush,4,6,1,lbs,,,,,,gripper #1,
|
||||
1/16/2026,Neck Circuit,1,17,10,lbs,,,,,,,
|
||||
1/16/2026,Neck Circuit,2,17,10,lbs,,,,,,,
|
||||
1/18/2026,Squat,1,7,135,lbs,,,,,,,
|
||||
1/18/2026,Squat,2,5,155,lbs,,,,,,,
|
||||
1/18/2026,Squat,3,5,170,lbs,,,,,,,
|
||||
1/18/2026,Squat,4,11,185,lbs,,,,,,work set,
|
||||
1/18/2026,Squat,5,10,185,lbs,,,,,,work set,
|
||||
1/18/2026,DB Step Back Lunge,1,7,40,lbs,,,,,,,
|
||||
1/18/2026,DB Step Back Lunge,2,7,60,lbs,,,,,,,
|
||||
1/18/2026,DB Step Back Lunge,3,7,80,lbs,,,,,,work set,
|
||||
1/18/2026,DB Step Back Lunge,4,10,80,lbs,,,,,,work set - max,
|
||||
1/18/2026,Bench Press,1,7,135,lbs,,,,,,,
|
||||
1/18/2026,Bench Press,2,5,165,lbs,,,,,,,
|
||||
1/18/2026,Bench Press,3,9,185,lbs,,,,,,work set,
|
||||
1/18/2026,Bench Press,4,8,185,lbs,,,,,,work set,
|
||||
1/18/2026,Lat Pulldown,1,7,60,lbs,,,,,,single arm,
|
||||
1/18/2026,Lat Pulldown,2,10,71,lbs,,,,,,"work set, single arm",
|
||||
1/18/2026,Lat Pulldown,3,11,71,lbs,,,,,,"work set, single arm",
|
||||
1/18/2026,Ab Wheel Rollout,1,11,,lbs,,,,,,vest,
|
||||
1/18/2026,Ab Wheel Rollout,2,10,,lbs,,,,,,vest,
|
||||
1/18/2026,Calf Raise,1,17,,lbs,,,,,,vest,
|
||||
1/18/2026,Calf Raise,2,13,,lbs,,,,,,vest,
|
||||
1/18/2026,KB Press,1,5,20,kg,,,,,,,
|
||||
1/18/2026,KB Press,2,11,24,kg,,,,,,work set,
|
||||
1/18/2026,KB Press,3,10,24,kg,,,,,,work set,
|
||||
1/19/2026,Kettlebell Leg Extension,1,13,12,kg,,,,,,,
|
||||
1/19/2026,Kettlebell Leg Extension,2,17,12,kg,,,,,,,
|
||||
1/19/2026,Kettlebell Leg Extension,3,17,12,kg,,,,,,,
|
||||
1/19/2026,Kettlebell Leg Extension,4,21,12,kg,,,,,,ankle weight,
|
||||
1/19/2026,Kettlebell Leg Extension,5,21,12,kg,,,,,,ankle weight,
|
||||
1/19/2026,Knee Raise,1,21,,lbs,,,,,,ankle weight,
|
||||
1/19/2026,Knee Raise,2,17,,lbs,,,,,,ankle weight,
|
||||
1/19/2026,Hip Flexor,1,10,12,lbs,,,,,,,
|
||||
1/19/2026,Hip Flexor,2,10,12,lbs,,,,,,,
|
||||
1/19/2026,Hip Flexor,3,10,12,lbs,,,,,,,
|
||||
1/19/2026,Adductor Bench,1,17,,lbs,,,,,,,
|
||||
1/19/2026,Adductor Bench,2,21,,lbs,,,,,,,
|
||||
1/19/2026,Glute ham developer,1,7,,lbs,,,,,,,
|
||||
1/19/2026,Glute ham developer,2,7,,lbs,,,,,,,
|
||||
1/19/2026,Glute ham developer,3,7,,lbs,,,,,,,
|
||||
1/20/2026,Ab KB Drag,1,10,12,lbs,,,,,,,
|
||||
1/20/2026,Ab KB Drag,2,11,12,lbs,,,,,,,
|
||||
1/20/2026,Dips (Chest),1,13,,lbs,,,,,,,
|
||||
1/20/2026,Dips (Chest),2,17,,lbs,,,,,,,
|
||||
1/20/2026,SA Tricep Extension,1,11,22,lbs,,,,,,,
|
||||
1/20/2026,SA Tricep Extension,2,10,22,lbs,,,,,,,
|
||||
1/20/2026,Captains of Crush,1,7,0.5,lbs,,,,,,gripper #0.5,
|
||||
1/20/2026,Captains of Crush,2,6,1,lbs,,,,,,gripper #1,
|
||||
1/20/2026,Captains of Crush,3,7,1,lbs,,,,,,gripper #1,
|
||||
1/20/2026,Captains of Crush,4,3,1.5,lbs,,,,,,gripper #1.5,
|
||||
1/20/2026,Captains of Crush,5,2,1.5,lbs,,,,,,gripper #1.5,
|
||||
1/20/2026,Captains of Crush,6,10,1,lbs,,,,,,gripper #1,
|
||||
1/20/2026,Ab Mat,1,17,,lbs,,,,,,,
|
||||
1/20/2026,Ab Mat,2,13,,lbs,,,,,,,
|
||||
1/20/2026,SA DB Curl,1,11,30,lbs,,,,,,,
|
||||
1/20/2026,SA DB Curl,2,10,30,lbs,,,,,,,
|
||||
1/20/2026,EQ Military Press,1,7,16,kg,,,,,,16kg KB each side,
|
||||
1/20/2026,EQ Military Press,2,10,16,kg,,,,,,16kg KB each side,
|
||||
1/20/2026,Tuck Inversion,1,2,,lbs,,,,,,,
|
||||
1/20/2026,Tuck Inversion,2,2,,lbs,,,,,,,
|
||||
1/20/2026,Rear delt,1,10,16,lbs,,,,,,,
|
||||
1/20/2026,Rear delt,2,7,16,lbs,,,,,,,
|
||||
1/20/2026,Ab Scissors,1,,,lbs,,,,,,,
|
||||
1/20/2026,Ab Scissors,2,,,lbs,,,,,,,
|
||||
1/20/2026,Chinup,1,2,,lbs,,,,,,negatives,
|
||||
1/20/2026,Chinup,2,2,,lbs,,,,,,negatives,
|
||||
1/22/2026,TGU,1,1,44,kg,,,,,,,
|
||||
1/22/2026,TGU,2,1,44,kg,,,,,,,
|
||||
1/22/2026,TGU,3,1,44,kg,,,,,,,
|
||||
1/22/2026,EQ Military Press,1,10,16,kg,,,,,,16kg KB each side,
|
||||
1/22/2026,EQ Military Press,2,10,16,kg,,,,,,16kg KB each side,
|
||||
1/22/2026,EQ Military Press,3,10,16,kg,,,,,,16kg KB each side,
|
||||
1/22/2026,Barbell Row,1,21,95,lbs,,,,,,,
|
||||
1/22/2026,Barbell Row,2,17,95,lbs,,,,,,,
|
||||
1/22/2026,Windmill,1,6,12,kg,,,,,,,
|
||||
1/22/2026,Half Kneel Windmill,1,7,16,kg,,,,,,,
|
||||
1/22/2026,Half Kneel Windmill,2,8,16,kg,,,,,,,
|
||||
1/22/2026,Side Lying Press,1,21,20,lbs,,,,,,,
|
||||
1/22/2026,Side Lying Press,2,21,24,lbs,,,,,,,
|
||||
1/22/2026,Exercise Ball Situp,1,17,,lbs,,,,,,,
|
||||
1/22/2026,Exercise Ball Situp,2,17,,lbs,,,,,,,
|
||||
1/22/2026,Exercise Ball Situp,3,17,,lbs,,,,,,,
|
||||
1/22/2026,Neck Circuit,1,17,10,lbs,,,,,,,
|
||||
1/22/2026,Neck Circuit,2,17,10,lbs,,,,,,,
|
||||
1/22/2026,Neck Circuit,3,17,10,lbs,,,,,,,
|
||||
1/27/2026,Squat,1,7,135,lbs,,,,,,,
|
||||
1/27/2026,Squat,2,7,185,lbs,,,,,,,
|
||||
1/27/2026,Squat,3,5,185,lbs,,,,,,,
|
||||
1/27/2026,Squat,4,5,225,lbs,,,,,,,
|
||||
1/27/2026,Squat,5,5,225,lbs,,,,,,,
|
||||
1/27/2026,Squat,6,5,225,lbs,,,,,,,
|
||||
1/27/2026,Squat,7,6,225,lbs,,,,,,,
|
||||
1/27/2026,Bulgarian Split Squat,1,10,15,lbs,,,,,,,
|
||||
1/27/2026,Bulgarian Split Squat,2,10,15,lbs,,,,,,,
|
||||
1/27/2026,Bulgarian Split Squat,3,10,15,lbs,,,,,,,
|
||||
1/27/2026,Kettlebell Leg Extension,1,17,16,kg,,,,,,,
|
||||
1/27/2026,Kettlebell Leg Extension,2,21,16,kg,,,,,,,
|
||||
1/27/2026,Calf Raise,1,17,,lbs,,,,,,,
|
||||
1/27/2026,Calf Raise,2,21,,lbs,,,,,,,
|
||||
1/29/2026,Hamstring deadlift,1,10,50,lbs,,,,,,,
|
||||
1/29/2026,Hex Bar Deadlift,1,5,240,lbs,,,,,,,
|
||||
1/29/2026,Hex Bar Deadlift,2,7,295,lbs,,,,,,,
|
||||
1/29/2026,Hex Bar Deadlift,3,7,295,lbs,,,,,,,
|
||||
1/29/2026,Hex Bar Deadlift,4,7,295,lbs,,,,,,,
|
||||
1/29/2026,Bench Press,1,5,135,lbs,,,,,,,
|
||||
1/29/2026,Bench Press,2,7,185,lbs,,,,,,,
|
||||
1/29/2026,Bench Press,3,8,185,lbs,,,,,,,
|
||||
1/29/2026,Bench Press,4,6,185,lbs,,,,,,,
|
||||
1/29/2026,Tuck Inversion,1,2,,lbs,,,,,,,
|
||||
1/29/2026,Ring row,1,7,,lbs,,,,,,,
|
||||
1/29/2026,Ring row,2,7,,lbs,,,,,,,
|
||||
1/29/2026,Ring row,3,7,,lbs,,,,,,,
|
||||
1/29/2026,Exercise Ball Situp,1,17,,lbs,,,,,,,
|
||||
1/29/2026,Exercise Ball Situp,2,13,,lbs,,,,,,,
|
||||
1/29/2026,Alt Leg Lift,1,10,,lbs,,,,,,,
|
||||
1/29/2026,Alt Leg Lift,2,10,,lbs,,,,,,,
|
||||
1/29/2026,Rear delt,1,7,16,lbs,,,,,,,
|
||||
1/29/2026,Rear delt,2,10,16,lbs,,,,,,,
|
||||
1/29/2026,SA Landmine Press,1,10,25,lbs,,,,,,,
|
||||
1/29/2026,SA Landmine Press,2,10,25,lbs,,,,,,,
|
||||
1/29/2026,SA Landmine Press,3,10,25,lbs,,,,,,,
|
||||
1/29/2026,Landmine Pull and Press,1,10,25,lbs,,,,,,,
|
||||
1/29/2026,Landmine Pull and Press,2,11,25,lbs,,,,,,,
|
||||
1/29/2026,Wide Grip Pull Up,1,5,,lbs,,,,,,,
|
||||
1/29/2026,Wide Grip Pull Up,2,5,,lbs,,,,,,,
|
||||
1/29/2026,Wide Grip Pull Up,3,5,,lbs,,,,,,,
|
||||
1/30/2026,Face Pulls,1,10,66,lbs,,,,,,,
|
||||
1/30/2026,KB Press,1,7,20,kg,,,,,,,
|
||||
1/30/2026,KB Press,2,7,24,kg,,,,,,,
|
||||
1/30/2026,KB Press,3,3,32,kg,,,,,,,
|
||||
1/30/2026,KB Press,4,3,32,kg,,,,,,,
|
||||
1/30/2026,KB Press,5,3,32,kg,,,,,,,
|
||||
1/30/2026,Side Lying Press,1,7,20,lbs,,,,,,,
|
||||
1/30/2026,Side Lying Press,2,7,24,lbs,,,,,,,
|
||||
1/30/2026,Side Lying Press,3,13,24,lbs,,,,,,,
|
||||
1/30/2026,Side Lying Press,4,17,24,lbs,,,,,,,
|
||||
1/30/2026,TGU,1,1,44,kg,,,,,,,
|
||||
1/30/2026,TGU,2,1,48,kg,,,,,,,
|
||||
1/30/2026,TGU,3,1,48,kg,,,,,,,
|
||||
1/30/2026,BB Reverse Curl,1,17,45,lbs,,,,,,bar only,
|
||||
1/30/2026,BB Reverse Curl,2,13,55,lbs,,,,,,,
|
||||
1/30/2026,BB Reverse Curl,3,13,60,lbs,,,,,,,
|
||||
1/30/2026,Captains of Crush,1,7,0.5,lbs,,,,,,gripper #0.5,
|
||||
1/30/2026,Captains of Crush,2,5,1,lbs,,,,,,gripper #1,
|
||||
1/30/2026,Captains of Crush,3,5,1,lbs,,,,,,gripper #1,
|
||||
1/30/2026,SA Tricep Extension,1,10,27,lbs,,,,,,,
|
||||
1/30/2026,SA Tricep Extension,2,11,27,lbs,,,,,,,
|
||||
1/30/2026,Dumbbell Curl,1,10,27,lbs,,,,,,ball bicep curl,
|
||||
1/30/2026,Dumbbell Curl,2,11,27,lbs,,,,,,ball bicep curl,
|
||||
1/30/2026,Neck Circuit,1,17,10,lbs,,,,,,,
|
||||
1/30/2026,Neck Circuit,2,17,10,lbs,,,,,,,
|
||||
1/30/2026,Bench Dip,1,21,,lbs,,,,,,,
|
||||
1/30/2026,Bench Dip,2,21,,lbs,,,,,,,
|
||||
1/30/2026,Assault Bike,1,,,lbs,,,,77,,10/20 intervals,
|
||||
2/2/2026,Squat,1,5,165,lbs,,,,,,,
|
||||
2/2/2026,Zercher Squat,1,13,95,lbs,,,,,,,
|
||||
2/2/2026,Zercher Squat,2,17,95,lbs,,,,,,,
|
||||
2/2/2026,Zercher Squat,3,17,95,lbs,,,,,,,
|
||||
2/2/2026,Zercher Squat,4,10,95,lbs,,,,,,,
|
||||
2/2/2026,Zercher Squat,5,10,95,lbs,,,,,,,
|
||||
2/2/2026,Hamstring deadlift,1,10,50,lbs,,,,,,,
|
||||
2/2/2026,Hamstring deadlift,2,10,50,lbs,,,,,,,
|
||||
2/2/2026,Hamstring deadlift,3,10,50,lbs,,,,,,,
|
||||
2/2/2026,KB Sidestep,1,10,20,lbs,,,,,,,
|
||||
2/2/2026,KB Sidestep,2,10,20,lbs,,,,,,,
|
||||
2/2/2026,KB Sidestep,3,10,20,lbs,,,,,,,
|
||||
2/2/2026,Calf Raise,1,17,,lbs,,,,,,,
|
||||
2/2/2026,Calf Raise,2,17,,lbs,,,,,,,
|
||||
2/2/2026,Calf Raise,3,17,,lbs,,,,,,,
|
||||
2/2/2026,Bulgarian Split Squat,1,10,15,lbs,,,,,,,
|
||||
2/2/2026,Bulgarian Split Squat,2,10,15,lbs,,,,,,,
|
||||
2/2/2026,Bulgarian Split Squat,3,10,15,lbs,,,,,,,
|
||||
2/2/2026,Bulgarian Split Squat,4,10,15,lbs,,,,,,,
|
||||
2/3/2026,Face Pulls,1,10,72,lbs,,,,,,,
|
||||
2/3/2026,Dumbbell Row,1,10,50,lbs,,,,,,,
|
||||
2/3/2026,Dumbbell Row,2,10,60,lbs,,,,,,,
|
||||
2/3/2026,Dumbbell Row,3,11,60,lbs,,,,,,,
|
||||
2/3/2026,KB Press,1,10,24,kg,,,,,,,
|
||||
2/3/2026,KB Press,2,11,24,kg,,,,,,,
|
||||
2/3/2026,Cable Row,1,10,49,lbs,,,,,,,
|
||||
2/3/2026,Cable Row,2,11,49,lbs,,,,,,,
|
||||
2/3/2026,EQ Bar Incline Bench,1,7,16,kg,,,,,,16kg KB each side,
|
||||
2/3/2026,EQ Bar Incline Bench,2,11,31,lbs,,,,,,16kg KB + 15lb DB each side,
|
||||
2/3/2026,EQ Bar Incline Bench,3,10,31,lbs,,,,,,16kg KB + 15lb DB each side,
|
||||
2/3/2026,EQ Bar Incline Bench,4,21,16,kg,,,,,,16kg KB each side,
|
||||
2/3/2026,Upright Row,1,17,65,lbs,,,,,,,
|
||||
2/3/2026,Upright Row,2,21,65,lbs,,,,,,,
|
||||
2/3/2026,Cable Fly,1,17,53,lbs,,,,,,,
|
||||
2/3/2026,Cable Fly,2,13,53,lbs,,,,,,,
|
||||
2/3/2026,Lateral Raise,1,11,10,lbs,,,,,,,
|
||||
2/3/2026,Lateral Raise,2,10,10,lbs,,,,,,,
|
||||
2/9/2026,Squat,1,3,135,lbs,,,,,,foot elevated,
|
||||
2/9/2026,Squat,2,7,165,lbs,,,,,,foot elevated,
|
||||
2/9/2026,Squat,3,7,185,lbs,,,,,,foot elevated,
|
||||
2/9/2026,Squat,4,7,205,lbs,,,,,,foot elevated,
|
||||
2/9/2026,Squat,5,7,205,lbs,,,,,,foot elevated,
|
||||
2/9/2026,Squat,6,7,205,lbs,,,,,,foot elevated,
|
||||
2/9/2026,Hamstring deadlift,1,10,60,lbs,,,,,,,
|
||||
2/9/2026,Hamstring deadlift,2,10,60,lbs,,,,,,,
|
||||
2/9/2026,Hamstring deadlift,3,10,60,lbs,,,,,,,
|
||||
2/9/2026,Hamstring deadlift,4,10,60,lbs,,,,,,,
|
||||
2/9/2026,Kettlebell Leg Extension,1,10,16,kg,,,,,,,
|
||||
2/9/2026,Kettlebell Leg Extension,2,10,16,kg,,,,,,,
|
||||
2/9/2026,Kettlebell Leg Extension,3,17,16,kg,,,,,,,
|
||||
2/9/2026,Kettlebell Leg Extension,4,21,16,kg,,,,,,,
|
||||
2/9/2026,Calf Raise,1,17,,lbs,,,,,,,
|
||||
2/9/2026,Calf Raise,2,13,,lbs,,,,,,,
|
||||
2/9/2026,Calf Raise,3,21,,lbs,,,,,,,
|
||||
2/9/2026,Calf Raise,4,21,,lbs,,,,,,,
|
||||
2/9/2026,Bulgarian Split Squat,1,10,,lbs,,,,,,,
|
||||
2/9/2026,Bulgarian Split Squat,2,10,15,lbs,,,,,,,
|
||||
2/9/2026,Bulgarian Split Squat,3,10,25,lbs,,,,,,,
|
||||
2/9/2026,Bulgarian Split Squat,4,12,30,lbs,,,,,,,
|
||||
2/9/2026,Adductor Bench,1,21,,lbs,,,,,,,
|
||||
2/9/2026,Adductor Bench,2,21,,lbs,,,,,,,
|
||||
2/10/2026,EQ Bar Incline Bench,1,7,16,kg,,,,,,16kg KB each side,
|
||||
2/10/2026,EQ Bar Incline Bench,2,13,31,lbs,,,,,,16kg KB + 15lb DB each side,
|
||||
2/10/2026,EQ Bar Incline Bench,3,13,31,lbs,,,,,,16kg KB + 15lb DB each side,
|
||||
2/10/2026,Ring row,1,10,,lbs,,,,,,,
|
||||
2/10/2026,Ring row,2,10,,lbs,,,,,,,
|
||||
2/10/2026,Ring row,3,11,,lbs,,,,,,,
|
||||
2/10/2026,Ring row,4,11,,lbs,,,,,,,
|
||||
2/10/2026,Low to High Crossover,1,17,16,lbs,,,,,,,
|
||||
2/10/2026,Low to High Crossover,2,17,16,lbs,,,,,,,
|
||||
2/10/2026,Chinup,1,7,,lbs,,,,,,,
|
||||
2/10/2026,Chinup,2,7,,lbs,,,,,,,
|
||||
2/10/2026,Chinup,3,7,,lbs,,,,,,,
|
||||
2/10/2026,KB Press,1,17,32,kg,,,,,,,
|
||||
2/10/2026,KB Press,2,17,32,kg,,,,,,,
|
||||
2/10/2026,Ab Wheel Rollout,1,17,,lbs,,,,,,,
|
||||
2/10/2026,Ab Wheel Rollout,2,21,,lbs,,,,,,,
|
||||
2/10/2026,SA Tricep Extension,1,21,16,lbs,,,,,,,
|
||||
2/10/2026,SA Tricep Extension,2,17,22,lbs,,,,,,,
|
||||
2/11/2026,Zercher Squat,1,10,75,lbs,,,,,,,
|
||||
2/11/2026,Zercher Squat,2,10,95,lbs,,,,,,,
|
||||
2/11/2026,Zercher Squat,3,10,115,lbs,,,,,,,
|
||||
2/11/2026,Zercher Squat,4,10,115,lbs,,,,,,,
|
||||
2/11/2026,Zercher Squat,5,10,115,lbs,,,,,,,
|
||||
2/11/2026,Zercher Squat,6,10,115,lbs,,,,,,,
|
||||
2/11/2026,Zercher Squat,7,10,115,lbs,,,,,,,
|
||||
2/11/2026,Calf Raise,1,21,,lbs,,,,,,,
|
||||
2/11/2026,Calf Raise,2,21,,lbs,,,,,,,
|
||||
2/12/2026,TGU,1,2,36,kg,,,,,,,
|
||||
2/12/2026,TGU,2,2,40,kg,,,,,,,
|
||||
2/12/2026,TGU,3,2,44,kg,,,,,,,
|
||||
2/12/2026,TGU,4,2,44,kg,,,,,,,
|
||||
2/12/2026,TGU,5,2,44,kg,,,,,,,
|
||||
2/12/2026,Rear delt,1,10,16,lbs,,,,,,,
|
||||
2/12/2026,Rear delt,2,11,16,lbs,,,,,,,
|
||||
2/12/2026,Overhead Press,1,7,75,lbs,,,,,,,
|
||||
2/12/2026,Overhead Press,2,7,95,lbs,,,,,,,
|
||||
2/12/2026,Overhead Press,3,6,115,lbs,,,,,,,
|
||||
2/12/2026,Overhead Press,4,3,135,lbs,,,,,,,
|
||||
2/12/2026,Overhead Press,5,3,135,lbs,,,,,,,
|
||||
2/12/2026,Overhead Press,6,3,135,lbs,,,,,,,
|
||||
2/12/2026,Tuck Inversion,1,2,,lbs,,,,,,,
|
||||
2/12/2026,Tuck Inversion,2,2,,lbs,,,,,,,
|
||||
2/12/2026,Tuck Inversion,3,2,,lbs,,,,,,,
|
||||
2/12/2026,Neck Circuit,1,17,10,lbs,,,,,,,
|
||||
2/14/2026,Deadlift,1,6,185,lbs,,,,,,,
|
||||
2/14/2026,Deadlift,2,5,225,lbs,,,,,,,
|
||||
2/14/2026,Deadlift,3,5,275,lbs,,,,,,,
|
||||
2/14/2026,Deadlift,4,5,275,lbs,,,,,,,
|
||||
2/14/2026,Deadlift,5,6,275,lbs,,,,,,,
|
||||
2/14/2026,Deadlift,6,5,275,lbs,,,,,,,
|
||||
2/14/2026,Fire Hydrant,1,17,,lbs,,,,,,,
|
||||
2/14/2026,Fire Hydrant,2,17,,lbs,,,,,,,
|
||||
2/14/2026,Fire Hydrant,3,17,,lbs,,,,,,,
|
||||
2/14/2026,SL Deadlift,1,10,36,lbs,,,,,,,
|
||||
2/14/2026,SL Deadlift,2,10,40,lbs,,,,,,,
|
||||
2/14/2026,Chinup,1,5,,lbs,,,,,,"narrow, vest",
|
||||
|
@@ -0,0 +1,473 @@
|
||||
date,exercise,set,weight,weight_unit,reps,duration_seconds,distance,distance_unit,calories,rpe,notes,custom_metrics_json
|
||||
1/2/2026,Face Pulls,1,68,lbs,10,,,,,,,
|
||||
1/2/2026,Rear delt,1,16,lbs,10,,,,,,,
|
||||
1/2/2026,EQ Bar Incline Bench,1,16,kg,13,,,,,,16kg KB each side,
|
||||
1/2/2026,EQ Bar Incline Bench,2,21,lbs,10,,,,,,16kg KB + 5lb each side,
|
||||
1/2/2026,EQ Bar Incline Bench,3,26,lbs,10,,,,,,16kg KB + 10lb each side,
|
||||
1/2/2026,EQ Bar Incline Bench,4,26,lbs,12,,,,,,16kg KB + 10lb each side,
|
||||
1/2/2026,EQ Bar Incline Bench,5,31,lbs,10,,,,,,16kg KB + 15lb each side,
|
||||
1/2/2026,Chinup,1,,lbs,10,,,,,,,
|
||||
1/2/2026,Chinup,2,,lbs,7,,,,,,,
|
||||
1/2/2026,Chinup,3,,lbs,7,,,,,,,
|
||||
1/2/2026,Chinup,4,,lbs,5,,,,,,slow negatives,
|
||||
1/2/2026,Chinup,5,,lbs,5,,,,,,slow negatives,
|
||||
1/2/2026,Alternating Step Cable Cross,1,44,lbs,16,,,,,,,
|
||||
1/2/2026,Alternating Step Cable Cross,2,44,lbs,16,,,,,,,
|
||||
1/2/2026,Alternating Step Cable Cross,3,44,lbs,16,,,,,,,
|
||||
1/2/2026,Tuck Inversion,1,,lbs,2,,,,,,vest,
|
||||
1/2/2026,Tuck Inversion,2,,lbs,2,,,,,,vest,
|
||||
1/2/2026,Ab Wheel Rollout,1,,lbs,7,,,,,,vest,
|
||||
1/2/2026,Ab Wheel Rollout,2,,lbs,7,,,,,,vest,
|
||||
1/2/2026,Ab Wheel Rollout,3,,lbs,7,,,,,,vest,
|
||||
1/2/2026,Ring Dip,1,,lbs,3,,,,,,,
|
||||
1/2/2026,Ring Dip,2,,lbs,3,,,,,,,
|
||||
1/2/2026,Ring Dip,3,,lbs,3,,,,,,,
|
||||
1/2/2026,Overhead Tricep Extension,1,44,lbs,13,,,,,,,
|
||||
1/2/2026,Overhead Tricep Extension,2,44,lbs,13,,,,,,,
|
||||
1/2/2026,Overhead Tricep Extension,3,44,lbs,13,,,,,,,
|
||||
1/2/2026,Barbell Curl,1,45,lbs,17,,,,,,bar only,
|
||||
1/2/2026,Barbell Curl,2,45,lbs,17,,,,,,bar only,
|
||||
1/2/2026,Barbell Curl,3,45,lbs,17,,,,,,bar only,
|
||||
1/2/2026,Band Pushup,1,,lbs,7,,,,,,black/gray band,
|
||||
1/2/2026,Band Pushup,2,,lbs,7,,,,,,black/gray band,
|
||||
1/2/2026,Band Pushup,3,,lbs,7,,,,,,black/gray band,
|
||||
1/3/2026,TGU,1,36,kg,1,,,,,,,
|
||||
1/3/2026,TGU,2,40,kg,1,,,,,,,
|
||||
1/3/2026,TGU,3,44,kg,1,,,,,,,
|
||||
1/3/2026,TGU,4,48,kg,1,,,,,,,
|
||||
1/3/2026,TGU,5,48,kg,1,,,,,,,
|
||||
1/3/2026,TGU,6,48,kg,1,,,,,,,
|
||||
1/3/2026,Deadlift,1,185,lbs,5,,,,,,,
|
||||
1/3/2026,Deadlift,2,225,lbs,5,,,,,,,
|
||||
1/3/2026,Deadlift,3,275,lbs,5,,,,,,,
|
||||
1/3/2026,Deadlift,4,275,lbs,5,,,,,,,
|
||||
1/3/2026,Deadlift,5,275,lbs,5,,,,,,,
|
||||
1/3/2026,Deadlift,6,275,lbs,6,,,,,,,
|
||||
1/3/2026,Assault Bike,1,,lbs,,,,,72,,10/20 intervals,
|
||||
1/4/2026,Assault Bike,1,,lbs,,300,,,,,target zone 4 cardio,
|
||||
1/4/2026,SkiErg,1,,lbs,,300,,,,,target zone 4 cardio,
|
||||
1/4/2026,Jump Rope,1,,lbs,,300,,,,,target zone 4 cardio,
|
||||
1/4/2026,KB Swing,1,24,kg,5,,,,,,single arm every 60 sec / arm every 45 sec - 5 min,
|
||||
1/4/2026,Neck Circuit,1,10,lbs,17,,,,,,,
|
||||
1/6/2026,Squat,1,135,lbs,7,,,,,,,
|
||||
1/6/2026,Squat,2,165,lbs,7,,,,,,,
|
||||
1/6/2026,Squat,3,165,lbs,7,,,,,,chains - 207 total,
|
||||
1/6/2026,Squat,4,165,lbs,7,,,,,,chains - 207 total,
|
||||
1/6/2026,Squat,5,165,lbs,7,,,,,,chains - 207 total,
|
||||
1/6/2026,Bulgarian Split Squat,1,30,lbs,10,,,,,,,
|
||||
1/6/2026,Bulgarian Split Squat,2,30,lbs,10,,,,,,,
|
||||
1/6/2026,Bulgarian Split Squat,3,30,lbs,10,,,,,,,
|
||||
1/6/2026,Hip Flexor,1,12,kg,10,,,,,,,
|
||||
1/6/2026,Hip Flexor,2,12,kg,10,,,,,,,
|
||||
1/6/2026,Hip Flexor,3,12,kg,10,,,,,,,
|
||||
1/6/2026,Kettlebell Leg Extension,1,12,kg,10,,,,,,,
|
||||
1/6/2026,Kettlebell Leg Extension,2,12,kg,10,,,,,,,
|
||||
1/6/2026,Kettlebell Leg Extension,3,12,kg,10,,,,,,,
|
||||
1/6/2026,Adductor Bench,1,,lbs,10,,,,,,,
|
||||
1/6/2026,Adductor Bench,2,,lbs,10,,,,,,,
|
||||
1/6/2026,Adductor Bench,3,,lbs,10,,,,,,,
|
||||
1/6/2026,Glute Bridge,1,135,lbs,13,,,,,,,
|
||||
1/6/2026,Glute Bridge,2,135,lbs,13,,,,,,,
|
||||
1/6/2026,Glute Bridge,3,135,lbs,13,,,,,,,
|
||||
1/7/2026,Face Pulls,1,68,lbs,10,,,,,,,
|
||||
1/7/2026,Rear delt,1,16,lbs,8,,,,,,,
|
||||
1/7/2026,EQ Bar Incline Bench,1,16,kg,10,,,,,,16kg KB each side,
|
||||
1/7/2026,EQ Bar Incline Bench,2,26,lbs,10,,,,,,16kg KB + 10lb each side,
|
||||
1/7/2026,EQ Bar Incline Bench,3,26,lbs,10,,,,,,16kg KB + 10lb each side,
|
||||
1/7/2026,EQ Bar Incline Bench,4,31,lbs,13,,,,,,16kg KB + 15lb each side,
|
||||
1/7/2026,EQ Bar Incline Bench,5,31,lbs,9,,,,,,16kg KB + 15lb each side,
|
||||
1/7/2026,EQ Bar Incline Bench,6,31,lbs,8,,,,,,16kg KB + 15lb each side,
|
||||
1/7/2026,Chinup,1,,lbs,8,,,,,,,
|
||||
1/7/2026,Chinup,2,,lbs,6,,,,,,,
|
||||
1/7/2026,Chinup,3,,lbs,6,,,,,,,
|
||||
1/7/2026,Chinup,4,,lbs,6,,,,,,,
|
||||
1/7/2026,Alternating Step Cable Cross,1,44,lbs,16,,,,,,,
|
||||
1/7/2026,Alternating Step Cable Cross,2,44,lbs,16,,,,,,,
|
||||
1/7/2026,Alternating Step Cable Cross,3,44,lbs,16,,,,,,,
|
||||
1/7/2026,Tuck Inversion,1,,lbs,2,,,,,,vest,
|
||||
1/7/2026,Tuck Inversion,2,,lbs,2,,,,,,vest,
|
||||
1/7/2026,Ab Wheel Rollout,1,,lbs,7,,,,,,vest,
|
||||
1/7/2026,Ab Wheel Rollout,2,,lbs,7,,,,,,vest,
|
||||
1/7/2026,Ab Wheel Rollout,3,,lbs,7,,,,,,vest,
|
||||
1/7/2026,Ring Dip,1,,lbs,7,,,,,,,
|
||||
1/7/2026,Ring Dip,2,,lbs,7,,,,,,,
|
||||
1/10/2026,Deadlift,1,185,lbs,5,,,,,,,
|
||||
1/10/2026,Deadlift,2,225,lbs,5,,,,,,,
|
||||
1/10/2026,Deadlift,3,275,lbs,5,,,,,,,
|
||||
1/10/2026,Deadlift,4,295,lbs,4,,,,,,,
|
||||
1/10/2026,Deadlift,5,295,lbs,5,,,,,,,
|
||||
1/10/2026,TGU,1,44,kg,1,,,,,,,
|
||||
1/10/2026,TGU,2,48,kg,1,,,,,,,
|
||||
1/10/2026,TGU,3,48,kg,1,,,,,,,
|
||||
1/10/2026,Ring row,1,,lbs,10,,,,,,,
|
||||
1/10/2026,Ring row,2,,lbs,7,,,,,,,
|
||||
1/10/2026,Ring row,3,,lbs,6,,,,,,,
|
||||
1/10/2026,Glute Bridge,1,135,lbs,13,,,,,,,
|
||||
1/10/2026,Glute Bridge,2,135,lbs,13,,,,,,,
|
||||
1/10/2026,Glute Bridge,3,135,lbs,13,,,,,,,
|
||||
1/10/2026,Fire Hydrant,1,,lbs,17,,,,,,blue band,
|
||||
1/10/2026,Fire Hydrant,2,,lbs,13,,,,,,blue band,
|
||||
1/10/2026,Fire Hydrant,3,,lbs,17,,,,,,blue band,
|
||||
1/10/2026,Glute ham developer,1,,lbs,7,,,,,,"pad on floor, gray band",
|
||||
1/10/2026,Glute ham developer,2,,lbs,4,,,,,,"pad on floor, gray band",
|
||||
1/10/2026,Glute ham developer,3,,lbs,7,,,,,,"pad on floor, gray band",
|
||||
1/10/2026,Neck Circuit,1,10,lbs,17,,,,,,,
|
||||
1/12/2026,Barbell step back lunge,1,75,lbs,10,,,,,,,
|
||||
1/12/2026,Barbell step back lunge,2,95,lbs,10,,,,,,,
|
||||
1/12/2026,Barbell step back lunge,3,115,lbs,10,,,,,,,
|
||||
1/12/2026,Bench Step Up,1,16,kg,10,,,,,,quad band black,
|
||||
1/12/2026,Bench Step Up,2,16,kg,10,,,,,,quad band black,
|
||||
1/12/2026,Kettlebell Leg Extension,1,12,kg,17,,,,,,,
|
||||
1/12/2026,Kettlebell Leg Extension,2,12,kg,17,,,,,,,
|
||||
1/12/2026,Kettlebell Leg Extension,3,12,kg,23,,,,,,,
|
||||
1/13/2026,Shoulder warmup,1,15,lbs,,,,,,,warmup,
|
||||
1/13/2026,Mace warmup,1,,lbs,,,,,,,warmup,
|
||||
1/13/2026,Face Pulls,1,72,lbs,10,,,,,,,
|
||||
1/13/2026,Rear delt,1,16,lbs,7,,,,,,,
|
||||
1/13/2026,Rear delt,2,16,lbs,10,,,,,,,
|
||||
1/13/2026,Rear delt,3,16,lbs,7,,,,,,,
|
||||
1/13/2026,Dumbbell Row,1,55,lbs,10,,,,,,,
|
||||
1/13/2026,Dumbbell Row,2,55,lbs,10,,,,,,,
|
||||
1/13/2026,Dumbbell Row,3,55,lbs,10,,,,,,,
|
||||
1/13/2026,EQ Bar Incline Bench,1,16,kg,10,,,,,,16kg KB each side,
|
||||
1/13/2026,EQ Bar Incline Bench,2,31,lbs,14,,,,,,16kg + 15 lbs,
|
||||
1/13/2026,EQ Bar Incline Bench,3,31,lbs,15,,,,,,16kg + 15 lbs,
|
||||
1/13/2026,EQ Bar Incline Bench,4,31,lbs,10,,,,,,slow,
|
||||
1/13/2026,Chinup,1,,lbs,10,,,,,,,
|
||||
1/13/2026,Chinup,2,,lbs,6,,,,,,,
|
||||
1/13/2026,Chinup,3,,lbs,6,,,,,,,
|
||||
1/13/2026,Exercise Ball Situp,1,,lbs,17,,,,,,,
|
||||
1/13/2026,Exercise Ball Situp,2,,lbs,13,,,,,,,
|
||||
1/13/2026,Exercise Ball Situp,3,,lbs,17,,,,,,,
|
||||
1/13/2026,Alternating Step Cable Cross,1,44,lbs,16,,,,,,,
|
||||
1/13/2026,Alternating Step Cable Cross,2,44,lbs,16,,,,,,,
|
||||
1/13/2026,Alternating Step Cable Cross,3,44,lbs,10,,,,,,,
|
||||
1/13/2026,Tuck Inversion,1,,lbs,2,,,,,,vest,
|
||||
1/13/2026,Tuck Inversion,2,,lbs,2,,,,,,vest,
|
||||
1/13/2026,Tuck Inversion,3,,lbs,2,,,,,,vest,
|
||||
1/13/2026,Ab Wheel Rollout,1,,lbs,10,,,,,,vest,
|
||||
1/13/2026,Ab Wheel Rollout,2,,lbs,10,,,,,,vest,
|
||||
1/13/2026,Ring Dip,1,,lbs,5,,,,,,,
|
||||
1/13/2026,Ring Dip,2,,lbs,5,,,,,,,
|
||||
1/13/2026,Tricep Pushdown,1,44,lbs,10,,,,,,rope,
|
||||
1/13/2026,Tricep Pushdown,2,44,lbs,10,,,,,,rope,
|
||||
1/14/2026,Hex Bar Deadlift,1,200,lbs,5,,,,,,,
|
||||
1/14/2026,Hex Bar Deadlift,2,240,lbs,5,,,,,,,
|
||||
1/14/2026,Hex Bar Deadlift,3,290,lbs,5,,,,,,,
|
||||
1/14/2026,Hex Bar Deadlift,4,320,lbs,5,,,,,,,
|
||||
1/14/2026,Hex Bar Deadlift,5,340,lbs,3,,,,,,,
|
||||
1/14/2026,Hex Bar Deadlift,6,355,lbs,3,,,,,,best ever,
|
||||
1/14/2026,Glute Bridge,1,135,lbs,13,,,,,,,
|
||||
1/14/2026,Glute Bridge,2,135,lbs,13,,,,,,,
|
||||
1/14/2026,Glute Bridge,3,135,lbs,13,,,,,,,
|
||||
1/14/2026,SL Quad Step Down,1,,lbs,10,,,,,,on bench,
|
||||
1/14/2026,SL Quad Step Down,2,15,lbs,10,,,,,,on bench,
|
||||
1/14/2026,SL Quad Step Down,3,16,kg,10,,,,,,"on bench, good weight",
|
||||
1/14/2026,Fire Hydrant,1,,lbs,17,,,,,,black band,
|
||||
1/14/2026,Fire Hydrant,2,,lbs,17,,,,,,black band,
|
||||
1/14/2026,Assault Bike,1,,lbs,,,,,69,,10/20 intervals,
|
||||
1/16/2026,Shoulder warmup,1,,lbs,,,,,,,warmup,
|
||||
1/16/2026,Face Pulls,1,68,lbs,10,,,,,,,
|
||||
1/16/2026,Mace warmup,1,,lbs,,,,,,,warmup,
|
||||
1/16/2026,Rear delt,1,16,lbs,10,,,,,,,
|
||||
1/16/2026,TGU,1,44,kg,1,,,,,,,
|
||||
1/16/2026,TGU,2,48,kg,1,,,,,,,
|
||||
1/16/2026,TGU,3,48,kg,1,,,,,,,
|
||||
1/16/2026,TGU,4,48,kg,1,,,,,,,
|
||||
1/16/2026,TGU,5,48,kg,1,,,,,,,
|
||||
1/16/2026,TGU,6,48,kg,1,,,,,,,
|
||||
1/16/2026,Overhead Press,1,75,lbs,7,,,,,,,
|
||||
1/16/2026,Overhead Press,2,95,lbs,7,,,,,,,
|
||||
1/16/2026,Overhead Press,3,115,lbs,5,,,,,,,
|
||||
1/16/2026,Overhead Press,4,115,lbs,5,,,,,,,
|
||||
1/16/2026,Overhead Press,5,115,lbs,5,,,,,,,
|
||||
1/16/2026,Chinup,1,,lbs,4,,,,,,vest,
|
||||
1/16/2026,Chinup,2,,lbs,5,,,,,,vest,
|
||||
1/16/2026,Chinup,3,,lbs,5,,,,,,vest,
|
||||
1/16/2026,Chinup,4,,lbs,5,,,,,,vest,
|
||||
1/16/2026,Arnold Press,1,40,lbs,8,,,,,,,
|
||||
1/16/2026,Arnold Press,2,40,lbs,10,,,,,,,
|
||||
1/16/2026,Arnold Press,3,40,lbs,10,,,,,,,
|
||||
1/16/2026,Barbell Curl,1,45,lbs,10,,,,,,bar only,
|
||||
1/16/2026,Barbell Curl,2,65,lbs,10,,,,,,,
|
||||
1/16/2026,Barbell Curl,3,65,lbs,10,,,,,,,
|
||||
1/16/2026,Barbell Row,1,95,lbs,10,,,,,,,
|
||||
1/16/2026,Barbell Row,2,95,lbs,14,,,,,,,
|
||||
1/16/2026,Barbell Row,3,95,lbs,13,,,,,,,
|
||||
1/16/2026,Waiter Carry,1,16,kg,20,,,,,,20 steps,
|
||||
1/16/2026,Farmer's Carry,1,44,kg,40,,,,,,"40 steps, repeat circuit",
|
||||
1/16/2026,Captains of Crush,1,0.5,lbs,7,,,,,,gripper #0.5,
|
||||
1/16/2026,Captains of Crush,2,1,lbs,3,,,,,,gripper #1,
|
||||
1/16/2026,Captains of Crush,3,1,lbs,5,,,,,,gripper #1,
|
||||
1/16/2026,Captains of Crush,4,1,lbs,6,,,,,,gripper #1,
|
||||
1/16/2026,Neck Circuit,1,10,lbs,17,,,,,,,
|
||||
1/16/2026,Neck Circuit,2,10,lbs,17,,,,,,,
|
||||
1/18/2026,Squat,1,135,lbs,7,,,,,,,
|
||||
1/18/2026,Squat,2,155,lbs,5,,,,,,,
|
||||
1/18/2026,Squat,3,170,lbs,5,,,,,,,
|
||||
1/18/2026,Squat,4,185,lbs,11,,,,,,work set,
|
||||
1/18/2026,Squat,5,185,lbs,10,,,,,,work set,
|
||||
1/18/2026,DB Step Back Lunge,1,40,lbs,7,,,,,,,
|
||||
1/18/2026,DB Step Back Lunge,2,60,lbs,7,,,,,,,
|
||||
1/18/2026,DB Step Back Lunge,3,80,lbs,7,,,,,,work set,
|
||||
1/18/2026,DB Step Back Lunge,4,80,lbs,10,,,,,,work set - max,
|
||||
1/18/2026,Bench Press,1,135,lbs,7,,,,,,,
|
||||
1/18/2026,Bench Press,2,165,lbs,5,,,,,,,
|
||||
1/18/2026,Bench Press,3,185,lbs,9,,,,,,work set,
|
||||
1/18/2026,Bench Press,4,185,lbs,8,,,,,,work set,
|
||||
1/18/2026,Lat Pulldown,1,60,lbs,7,,,,,,single arm,
|
||||
1/18/2026,Lat Pulldown,2,71,lbs,10,,,,,,"work set, single arm",
|
||||
1/18/2026,Lat Pulldown,3,71,lbs,11,,,,,,"work set, single arm",
|
||||
1/18/2026,Ab Wheel Rollout,1,,lbs,11,,,,,,vest,
|
||||
1/18/2026,Ab Wheel Rollout,2,,lbs,10,,,,,,vest,
|
||||
1/18/2026,Calf Raise,1,,lbs,17,,,,,,vest,
|
||||
1/18/2026,Calf Raise,2,,lbs,13,,,,,,vest,
|
||||
1/18/2026,KB Press,1,20,kg,5,,,,,,,
|
||||
1/18/2026,KB Press,2,24,kg,11,,,,,,work set,
|
||||
1/18/2026,KB Press,3,24,kg,10,,,,,,work set,
|
||||
1/19/2026,Kettlebell Leg Extension,1,12,kg,13,,,,,,,
|
||||
1/19/2026,Kettlebell Leg Extension,2,12,kg,17,,,,,,,
|
||||
1/19/2026,Kettlebell Leg Extension,3,12,kg,17,,,,,,,
|
||||
1/19/2026,Kettlebell Leg Extension,4,12,kg,21,,,,,,ankle weight,
|
||||
1/19/2026,Kettlebell Leg Extension,5,12,kg,21,,,,,,ankle weight,
|
||||
1/19/2026,Knee Raise,1,,lbs,21,,,,,,ankle weight,
|
||||
1/19/2026,Knee Raise,2,,lbs,17,,,,,,ankle weight,
|
||||
1/19/2026,Hip Flexor,1,12,lbs,10,,,,,,,
|
||||
1/19/2026,Hip Flexor,2,12,lbs,10,,,,,,,
|
||||
1/19/2026,Hip Flexor,3,12,lbs,10,,,,,,,
|
||||
1/19/2026,Adductor Bench,1,,lbs,17,,,,,,,
|
||||
1/19/2026,Adductor Bench,2,,lbs,21,,,,,,,
|
||||
1/19/2026,Glute ham developer,1,,lbs,7,,,,,,,
|
||||
1/19/2026,Glute ham developer,2,,lbs,7,,,,,,,
|
||||
1/19/2026,Glute ham developer,3,,lbs,7,,,,,,,
|
||||
1/20/2026,Ab KB Drag,1,12,lbs,10,,,,,,,
|
||||
1/20/2026,Ab KB Drag,2,12,lbs,11,,,,,,,
|
||||
1/20/2026,Dips (Chest),1,,lbs,13,,,,,,,
|
||||
1/20/2026,Dips (Chest),2,,lbs,17,,,,,,,
|
||||
1/20/2026,SA Tricep Extension,1,22,lbs,11,,,,,,,
|
||||
1/20/2026,SA Tricep Extension,2,22,lbs,10,,,,,,,
|
||||
1/20/2026,Captains of Crush,1,0.5,lbs,7,,,,,,gripper #0.5,
|
||||
1/20/2026,Captains of Crush,2,1,lbs,6,,,,,,gripper #1,
|
||||
1/20/2026,Captains of Crush,3,1,lbs,7,,,,,,gripper #1,
|
||||
1/20/2026,Captains of Crush,4,1.5,lbs,3,,,,,,gripper #1.5,
|
||||
1/20/2026,Captains of Crush,5,1.5,lbs,2,,,,,,gripper #1.5,
|
||||
1/20/2026,Captains of Crush,6,1,lbs,10,,,,,,gripper #1,
|
||||
1/20/2026,Ab Mat,1,,lbs,17,,,,,,,
|
||||
1/20/2026,Ab Mat,2,,lbs,13,,,,,,,
|
||||
1/20/2026,SA DB Curl,1,30,lbs,11,,,,,,,
|
||||
1/20/2026,SA DB Curl,2,30,lbs,10,,,,,,,
|
||||
1/20/2026,EQ Military Press,1,16,kg,7,,,,,,16kg KB each side,
|
||||
1/20/2026,EQ Military Press,2,16,kg,10,,,,,,16kg KB each side,
|
||||
1/20/2026,Tuck Inversion,1,,lbs,2,,,,,,,
|
||||
1/20/2026,Tuck Inversion,2,,lbs,2,,,,,,,
|
||||
1/20/2026,Rear delt,1,16,lbs,10,,,,,,,
|
||||
1/20/2026,Rear delt,2,16,lbs,7,,,,,,,
|
||||
1/20/2026,Ab Scissors,1,,lbs,,,,,,,,
|
||||
1/20/2026,Ab Scissors,2,,lbs,,,,,,,,
|
||||
1/20/2026,Chinup,1,,lbs,2,,,,,,negatives,
|
||||
1/20/2026,Chinup,2,,lbs,2,,,,,,negatives,
|
||||
1/22/2026,TGU,1,44,kg,1,,,,,,,
|
||||
1/22/2026,TGU,2,44,kg,1,,,,,,,
|
||||
1/22/2026,TGU,3,44,kg,1,,,,,,,
|
||||
1/22/2026,EQ Military Press,1,16,kg,10,,,,,,16kg KB each side,
|
||||
1/22/2026,EQ Military Press,2,16,kg,10,,,,,,16kg KB each side,
|
||||
1/22/2026,EQ Military Press,3,16,kg,10,,,,,,16kg KB each side,
|
||||
1/22/2026,Barbell Row,1,95,lbs,21,,,,,,,
|
||||
1/22/2026,Barbell Row,2,95,lbs,17,,,,,,,
|
||||
1/22/2026,Windmill,1,12,kg,6,,,,,,,
|
||||
1/22/2026,Half Kneel Windmill,1,16,kg,7,,,,,,,
|
||||
1/22/2026,Half Kneel Windmill,2,16,kg,8,,,,,,,
|
||||
1/22/2026,Side Lying Press,1,20,lbs,21,,,,,,,
|
||||
1/22/2026,Side Lying Press,2,24,lbs,21,,,,,,,
|
||||
1/22/2026,Exercise Ball Situp,1,,lbs,17,,,,,,,
|
||||
1/22/2026,Exercise Ball Situp,2,,lbs,17,,,,,,,
|
||||
1/22/2026,Exercise Ball Situp,3,,lbs,17,,,,,,,
|
||||
1/22/2026,Neck Circuit,1,10,lbs,17,,,,,,,
|
||||
1/22/2026,Neck Circuit,2,10,lbs,17,,,,,,,
|
||||
1/22/2026,Neck Circuit,3,10,lbs,17,,,,,,,
|
||||
1/27/2026,Squat,1,135,lbs,7,,,,,,,
|
||||
1/27/2026,Squat,2,185,lbs,7,,,,,,,
|
||||
1/27/2026,Squat,3,185,lbs,5,,,,,,,
|
||||
1/27/2026,Squat,4,225,lbs,5,,,,,,,
|
||||
1/27/2026,Squat,5,225,lbs,5,,,,,,,
|
||||
1/27/2026,Squat,6,225,lbs,5,,,,,,,
|
||||
1/27/2026,Squat,7,225,lbs,6,,,,,,,
|
||||
1/27/2026,Bulgarian Split Squat,1,15,lbs,10,,,,,,,
|
||||
1/27/2026,Bulgarian Split Squat,2,15,lbs,10,,,,,,,
|
||||
1/27/2026,Bulgarian Split Squat,3,15,lbs,10,,,,,,,
|
||||
1/27/2026,Kettlebell Leg Extension,1,16,kg,17,,,,,,,
|
||||
1/27/2026,Kettlebell Leg Extension,2,16,kg,21,,,,,,,
|
||||
1/27/2026,Calf Raise,1,,lbs,17,,,,,,,
|
||||
1/27/2026,Calf Raise,2,,lbs,21,,,,,,,
|
||||
1/29/2026,Hamstring deadlift,1,50,lbs,10,,,,,,,
|
||||
1/29/2026,Hex Bar Deadlift,1,240,lbs,5,,,,,,,
|
||||
1/29/2026,Hex Bar Deadlift,2,295,lbs,7,,,,,,,
|
||||
1/29/2026,Hex Bar Deadlift,3,295,lbs,7,,,,,,,
|
||||
1/29/2026,Hex Bar Deadlift,4,295,lbs,7,,,,,,,
|
||||
1/29/2026,Bench Press,1,135,lbs,5,,,,,,,
|
||||
1/29/2026,Bench Press,2,185,lbs,7,,,,,,,
|
||||
1/29/2026,Bench Press,3,185,lbs,8,,,,,,,
|
||||
1/29/2026,Bench Press,4,185,lbs,6,,,,,,,
|
||||
1/29/2026,Tuck Inversion,1,,lbs,2,,,,,,,
|
||||
1/29/2026,Ring row,1,,lbs,7,,,,,,,
|
||||
1/29/2026,Ring row,2,,lbs,7,,,,,,,
|
||||
1/29/2026,Ring row,3,,lbs,7,,,,,,,
|
||||
1/29/2026,Exercise Ball Situp,1,,lbs,17,,,,,,,
|
||||
1/29/2026,Exercise Ball Situp,2,,lbs,13,,,,,,,
|
||||
1/29/2026,Alt Leg Lift,1,,lbs,10,,,,,,,
|
||||
1/29/2026,Alt Leg Lift,2,,lbs,10,,,,,,,
|
||||
1/29/2026,Rear delt,1,16,lbs,7,,,,,,,
|
||||
1/29/2026,Rear delt,2,16,lbs,10,,,,,,,
|
||||
1/29/2026,SA Landmine Press,1,25,lbs,10,,,,,,,
|
||||
1/29/2026,SA Landmine Press,2,25,lbs,10,,,,,,,
|
||||
1/29/2026,SA Landmine Press,3,25,lbs,10,,,,,,,
|
||||
1/29/2026,Landmine Pull and Press,1,25,lbs,10,,,,,,,
|
||||
1/29/2026,Landmine Pull and Press,2,25,lbs,11,,,,,,,
|
||||
1/29/2026,Wide Grip Pull Up,1,,lbs,5,,,,,,,
|
||||
1/29/2026,Wide Grip Pull Up,2,,lbs,5,,,,,,,
|
||||
1/29/2026,Wide Grip Pull Up,3,,lbs,5,,,,,,,
|
||||
1/30/2026,Face Pulls,1,66,lbs,10,,,,,,,
|
||||
1/30/2026,KB Press,1,20,kg,7,,,,,,,
|
||||
1/30/2026,KB Press,2,24,kg,7,,,,,,,
|
||||
1/30/2026,KB Press,3,32,kg,3,,,,,,,
|
||||
1/30/2026,KB Press,4,32,kg,3,,,,,,,
|
||||
1/30/2026,KB Press,5,32,kg,3,,,,,,,
|
||||
1/30/2026,Side Lying Press,1,20,lbs,7,,,,,,,
|
||||
1/30/2026,Side Lying Press,2,24,lbs,7,,,,,,,
|
||||
1/30/2026,Side Lying Press,3,24,lbs,13,,,,,,,
|
||||
1/30/2026,Side Lying Press,4,24,lbs,17,,,,,,,
|
||||
1/30/2026,TGU,1,44,kg,1,,,,,,,
|
||||
1/30/2026,TGU,2,48,kg,1,,,,,,,
|
||||
1/30/2026,TGU,3,48,kg,1,,,,,,,
|
||||
1/30/2026,BB Reverse Curl,1,45,lbs,17,,,,,,bar only,
|
||||
1/30/2026,BB Reverse Curl,2,55,lbs,13,,,,,,,
|
||||
1/30/2026,BB Reverse Curl,3,60,lbs,13,,,,,,,
|
||||
1/30/2026,Captains of Crush,1,0.5,lbs,7,,,,,,gripper #0.5,
|
||||
1/30/2026,Captains of Crush,2,1,lbs,5,,,,,,gripper #1,
|
||||
1/30/2026,Captains of Crush,3,1,lbs,5,,,,,,gripper #1,
|
||||
1/30/2026,SA Tricep Extension,1,27,lbs,10,,,,,,,
|
||||
1/30/2026,SA Tricep Extension,2,27,lbs,11,,,,,,,
|
||||
1/30/2026,Dumbbell Curl,1,27,lbs,10,,,,,,ball bicep curl,
|
||||
1/30/2026,Dumbbell Curl,2,27,lbs,11,,,,,,ball bicep curl,
|
||||
1/30/2026,Neck Circuit,1,10,lbs,17,,,,,,,
|
||||
1/30/2026,Neck Circuit,2,10,lbs,17,,,,,,,
|
||||
1/30/2026,Bench Dip,1,,lbs,21,,,,,,,
|
||||
1/30/2026,Bench Dip,2,,lbs,21,,,,,,,
|
||||
1/30/2026,Assault Bike,1,,lbs,,,,,77,,10/20 intervals,
|
||||
2/2/2026,Squat,1,165,lbs,5,,,,,,,
|
||||
2/2/2026,Zercher Squat,1,95,lbs,13,,,,,,,
|
||||
2/2/2026,Zercher Squat,2,95,lbs,17,,,,,,,
|
||||
2/2/2026,Zercher Squat,3,95,lbs,17,,,,,,,
|
||||
2/2/2026,Zercher Squat,4,95,lbs,10,,,,,,,
|
||||
2/2/2026,Zercher Squat,5,95,lbs,10,,,,,,,
|
||||
2/2/2026,Hamstring deadlift,1,50,lbs,10,,,,,,,
|
||||
2/2/2026,Hamstring deadlift,2,50,lbs,10,,,,,,,
|
||||
2/2/2026,Hamstring deadlift,3,50,lbs,10,,,,,,,
|
||||
2/2/2026,KB Sidestep,1,20,lbs,10,,,,,,,
|
||||
2/2/2026,KB Sidestep,2,20,lbs,10,,,,,,,
|
||||
2/2/2026,KB Sidestep,3,20,lbs,10,,,,,,,
|
||||
2/2/2026,Calf Raise,1,,lbs,17,,,,,,,
|
||||
2/2/2026,Calf Raise,2,,lbs,17,,,,,,,
|
||||
2/2/2026,Calf Raise,3,,lbs,17,,,,,,,
|
||||
2/2/2026,Bulgarian Split Squat,1,15,lbs,10,,,,,,,
|
||||
2/2/2026,Bulgarian Split Squat,2,15,lbs,10,,,,,,,
|
||||
2/2/2026,Bulgarian Split Squat,3,15,lbs,10,,,,,,,
|
||||
2/2/2026,Bulgarian Split Squat,4,15,lbs,10,,,,,,,
|
||||
2/3/2026,Face Pulls,1,72,lbs,10,,,,,,,
|
||||
2/3/2026,Dumbbell Row,1,50,lbs,10,,,,,,,
|
||||
2/3/2026,Dumbbell Row,2,60,lbs,10,,,,,,,
|
||||
2/3/2026,Dumbbell Row,3,60,lbs,11,,,,,,,
|
||||
2/3/2026,KB Press,1,24,kg,10,,,,,,,
|
||||
2/3/2026,KB Press,2,24,kg,11,,,,,,,
|
||||
2/3/2026,Cable Row,1,49,lbs,10,,,,,,,
|
||||
2/3/2026,Cable Row,2,49,lbs,11,,,,,,,
|
||||
2/3/2026,EQ Bar Incline Bench,1,16,kg,7,,,,,,16kg KB each side,
|
||||
2/3/2026,EQ Bar Incline Bench,2,31,lbs,11,,,,,,16kg KB + 15lb DB each side,
|
||||
2/3/2026,EQ Bar Incline Bench,3,31,lbs,10,,,,,,16kg KB + 15lb DB each side,
|
||||
2/3/2026,EQ Bar Incline Bench,4,16,kg,21,,,,,,16kg KB each side,
|
||||
2/3/2026,Upright Row,1,65,lbs,17,,,,,,,
|
||||
2/3/2026,Upright Row,2,65,lbs,21,,,,,,,
|
||||
2/3/2026,Cable Fly,1,53,lbs,17,,,,,,,
|
||||
2/3/2026,Cable Fly,2,53,lbs,13,,,,,,,
|
||||
2/3/2026,Lateral Raise,1,10,lbs,11,,,,,,,
|
||||
2/3/2026,Lateral Raise,2,10,lbs,10,,,,,,,
|
||||
2/9/2026,Squat,1,135,lbs,3,,,,,,foot elevated,
|
||||
2/9/2026,Squat,2,165,lbs,7,,,,,,foot elevated,
|
||||
2/9/2026,Squat,3,185,lbs,7,,,,,,foot elevated,
|
||||
2/9/2026,Squat,4,205,lbs,7,,,,,,foot elevated,
|
||||
2/9/2026,Squat,5,205,lbs,7,,,,,,foot elevated,
|
||||
2/9/2026,Squat,6,205,lbs,7,,,,,,foot elevated,
|
||||
2/9/2026,Hamstring deadlift,1,60,lbs,10,,,,,,,
|
||||
2/9/2026,Hamstring deadlift,2,60,lbs,10,,,,,,,
|
||||
2/9/2026,Hamstring deadlift,3,60,lbs,10,,,,,,,
|
||||
2/9/2026,Hamstring deadlift,4,60,lbs,10,,,,,,,
|
||||
2/9/2026,Kettlebell Leg Extension,1,16,kg,10,,,,,,,
|
||||
2/9/2026,Kettlebell Leg Extension,2,16,kg,10,,,,,,,
|
||||
2/9/2026,Kettlebell Leg Extension,3,16,kg,17,,,,,,,
|
||||
2/9/2026,Kettlebell Leg Extension,4,16,kg,21,,,,,,,
|
||||
2/9/2026,Calf Raise,1,,lbs,17,,,,,,,
|
||||
2/9/2026,Calf Raise,2,,lbs,13,,,,,,,
|
||||
2/9/2026,Calf Raise,3,,lbs,21,,,,,,,
|
||||
2/9/2026,Calf Raise,4,,lbs,21,,,,,,,
|
||||
2/9/2026,Bulgarian Split Squat,1,,lbs,10,,,,,,,
|
||||
2/9/2026,Bulgarian Split Squat,2,15,lbs,10,,,,,,,
|
||||
2/9/2026,Bulgarian Split Squat,3,25,lbs,10,,,,,,,
|
||||
2/9/2026,Bulgarian Split Squat,4,30,lbs,12,,,,,,,
|
||||
2/9/2026,Adductor Bench,1,,lbs,21,,,,,,,
|
||||
2/9/2026,Adductor Bench,2,,lbs,21,,,,,,,
|
||||
2/10/2026,EQ Bar Incline Bench,1,16,kg,7,,,,,,16kg KB each side,
|
||||
2/10/2026,EQ Bar Incline Bench,2,31,lbs,13,,,,,,16kg KB + 15lb DB each side,
|
||||
2/10/2026,EQ Bar Incline Bench,3,31,lbs,13,,,,,,16kg KB + 15lb DB each side,
|
||||
2/10/2026,Ring row,1,,lbs,10,,,,,,,
|
||||
2/10/2026,Ring row,2,,lbs,10,,,,,,,
|
||||
2/10/2026,Ring row,3,,lbs,11,,,,,,,
|
||||
2/10/2026,Ring row,4,,lbs,11,,,,,,,
|
||||
2/10/2026,Low to High Crossover,1,16,lbs,17,,,,,,,
|
||||
2/10/2026,Low to High Crossover,2,16,lbs,17,,,,,,,
|
||||
2/10/2026,Chinup,1,,lbs,7,,,,,,,
|
||||
2/10/2026,Chinup,2,,lbs,7,,,,,,,
|
||||
2/10/2026,Chinup,3,,lbs,7,,,,,,,
|
||||
2/10/2026,KB Press,1,32,kg,17,,,,,,,
|
||||
2/10/2026,KB Press,2,32,kg,17,,,,,,,
|
||||
2/10/2026,Ab Wheel Rollout,1,,lbs,17,,,,,,,
|
||||
2/10/2026,Ab Wheel Rollout,2,,lbs,21,,,,,,,
|
||||
2/10/2026,SA Tricep Extension,1,16,lbs,21,,,,,,,
|
||||
2/10/2026,SA Tricep Extension,2,22,lbs,17,,,,,,,
|
||||
2/11/2026,Zercher Squat,1,75,lbs,10,,,,,,,
|
||||
2/11/2026,Zercher Squat,2,95,lbs,10,,,,,,,
|
||||
2/11/2026,Zercher Squat,3,115,lbs,10,,,,,,,
|
||||
2/11/2026,Zercher Squat,4,115,lbs,10,,,,,,,
|
||||
2/11/2026,Zercher Squat,5,115,lbs,10,,,,,,,
|
||||
2/11/2026,Zercher Squat,6,115,lbs,10,,,,,,,
|
||||
2/11/2026,Zercher Squat,7,115,lbs,10,,,,,,,
|
||||
2/11/2026,Calf Raise,1,,lbs,21,,,,,,,
|
||||
2/11/2026,Calf Raise,2,,lbs,21,,,,,,,
|
||||
2/12/2026,TGU,1,36,kg,2,,,,,,,
|
||||
2/12/2026,TGU,2,40,kg,2,,,,,,,
|
||||
2/12/2026,TGU,3,44,kg,2,,,,,,,
|
||||
2/12/2026,TGU,4,44,kg,2,,,,,,,
|
||||
2/12/2026,TGU,5,44,kg,2,,,,,,,
|
||||
2/12/2026,Rear delt,1,16,lbs,10,,,,,,,
|
||||
2/12/2026,Rear delt,2,16,lbs,11,,,,,,,
|
||||
2/12/2026,Overhead Press,1,75,lbs,7,,,,,,,
|
||||
2/12/2026,Overhead Press,2,95,lbs,7,,,,,,,
|
||||
2/12/2026,Overhead Press,3,115,lbs,6,,,,,,,
|
||||
2/12/2026,Overhead Press,4,135,lbs,3,,,,,,,
|
||||
2/12/2026,Overhead Press,5,135,lbs,3,,,,,,,
|
||||
2/12/2026,Overhead Press,6,135,lbs,3,,,,,,,
|
||||
2/12/2026,Tuck Inversion,1,,lbs,2,,,,,,,
|
||||
2/12/2026,Tuck Inversion,2,,lbs,2,,,,,,,
|
||||
2/12/2026,Tuck Inversion,3,,lbs,2,,,,,,,
|
||||
2/12/2026,Neck Circuit,1,10,lbs,17,,,,,,,
|
||||
2/14/2026,Deadlift,1,185,lbs,6,,,,,,,
|
||||
2/14/2026,Deadlift,2,225,lbs,5,,,,,,,
|
||||
2/14/2026,Deadlift,3,275,lbs,5,,,,,,,
|
||||
2/14/2026,Deadlift,4,275,lbs,5,,,,,,,
|
||||
2/14/2026,Deadlift,5,275,lbs,6,,,,,,,
|
||||
2/14/2026,Deadlift,6,275,lbs,5,,,,,,,
|
||||
2/14/2026,Fire Hydrant,1,,lbs,17,,,,,,,
|
||||
2/14/2026,Fire Hydrant,2,,lbs,17,,,,,,,
|
||||
2/14/2026,Fire Hydrant,3,,lbs,17,,,,,,,
|
||||
2/14/2026,SL Deadlift,1,36,lbs,10,,,,,,,
|
||||
2/14/2026,SL Deadlift,2,40,lbs,10,,,,,,,
|
||||
2/14/2026,Chinup,1,,lbs,5,,,,,,"narrow, vest",
|
||||
|
@@ -0,0 +1,30 @@
|
||||
import path from "path";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
export function resolveDatabasePath(): string {
|
||||
const dbUrl = process.env.DATABASE_URL || "file:./data/app.db";
|
||||
|
||||
if (!dbUrl.startsWith("file:")) {
|
||||
return path.resolve(process.cwd(), "prisma", "data", "app.db");
|
||||
}
|
||||
|
||||
const rawPath = dbUrl.slice("file:".length);
|
||||
|
||||
if (path.isAbsolute(rawPath)) {
|
||||
return rawPath;
|
||||
}
|
||||
|
||||
const normalized = rawPath.replace(/^\.\//, "");
|
||||
const directPath = path.resolve(process.cwd(), normalized);
|
||||
if (existsSync(directPath)) {
|
||||
return directPath;
|
||||
}
|
||||
|
||||
const prismaPath = path.resolve(process.cwd(), "prisma", normalized);
|
||||
return prismaPath;
|
||||
}
|
||||
|
||||
export function getTimestampFileSuffix(now: Date = new Date()): string {
|
||||
const iso = now.toISOString();
|
||||
return iso.replace(/[:.]/g, "-");
|
||||
}
|
||||
@@ -62,6 +62,7 @@ export async function getExerciseHistory(
|
||||
exerciseId,
|
||||
workout: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
@@ -82,6 +83,7 @@ export async function getPersonalBest(
|
||||
exerciseId,
|
||||
workout: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
@@ -20,6 +20,7 @@ export async function getWeeklyWorkoutCount(userId: string): Promise<number> {
|
||||
const count = await prisma.workout.count({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
date: {
|
||||
gte: mondayStart,
|
||||
lte: sundayEnd,
|
||||
@@ -35,7 +36,7 @@ export async function getWeeklyWorkoutCount(userId: string): Promise<number> {
|
||||
*/
|
||||
export async function getTotalWorkoutCount(userId: string): Promise<number> {
|
||||
return prisma.workout.count({
|
||||
where: { userId },
|
||||
where: { userId, deletedAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,6 +54,7 @@ export async function getMonthlyWorkoutCount(userId: string): Promise<number> {
|
||||
return prisma.workout.count({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
date: {
|
||||
gte: monthStart,
|
||||
lte: monthEnd,
|
||||
@@ -75,6 +77,7 @@ export async function getYearlyWorkoutCount(userId: string): Promise<number> {
|
||||
return prisma.workout.count({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
date: {
|
||||
gte: yearStart,
|
||||
lte: yearEnd,
|
||||
@@ -88,7 +91,7 @@ export async function getYearlyWorkoutCount(userId: string): Promise<number> {
|
||||
*/
|
||||
export async function getCurrentStreak(userId: string): Promise<number> {
|
||||
const workouts = await prisma.workout.findMany({
|
||||
where: { userId },
|
||||
where: { userId, deletedAt: null },
|
||||
select: { date: true },
|
||||
orderBy: { date: "desc" },
|
||||
});
|
||||
@@ -139,6 +142,7 @@ export async function getWeeklyVolume(userId: string): Promise<number> {
|
||||
where: {
|
||||
workout: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
date: {
|
||||
gte: mondayStart,
|
||||
lte: sundayEnd,
|
||||
@@ -176,7 +180,7 @@ export async function getDashboardStats(userId: string): Promise<DashboardStats>
|
||||
getCurrentStreak(userId),
|
||||
getWeeklyVolume(userId),
|
||||
prisma.workout.findMany({
|
||||
where: { userId },
|
||||
where: { userId, deletedAt: null },
|
||||
include: {
|
||||
setLogs: {
|
||||
include: {
|
||||
@@ -20,6 +20,7 @@ export async function getWorkouts(
|
||||
|
||||
const where: any = {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
if (query) {
|
||||
@@ -67,8 +68,8 @@ export async function getWorkouts(
|
||||
* Get a single workout by ID with all its sets
|
||||
*/
|
||||
export async function getWorkoutById(id: string) {
|
||||
return prisma.workout.findUnique({
|
||||
where: { id },
|
||||
return prisma.workout.findFirst({
|
||||
where: { id, deletedAt: null },
|
||||
include: {
|
||||
setLogs: {
|
||||
include: {
|
||||
@@ -107,12 +108,9 @@ export async function createWorkout(data: {
|
||||
* Delete a workout and all its associated sets
|
||||
*/
|
||||
export async function deleteWorkout(id: string): Promise<void> {
|
||||
await prisma.setLog.deleteMany({
|
||||
where: { workoutId: id },
|
||||
});
|
||||
|
||||
await prisma.workout.delete({
|
||||
await prisma.workout.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,7 +122,7 @@ export async function getRecentWorkouts(
|
||||
limit: number = 10
|
||||
) {
|
||||
return prisma.workout.findMany({
|
||||
where: { userId },
|
||||
where: { userId, deletedAt: null },
|
||||
include: {
|
||||
setLogs: {
|
||||
include: {
|
||||
@@ -0,0 +1,132 @@
|
||||
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: "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);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "workout-planner",
|
||||
"name": "proof-of-work",
|
||||
"version": "1.0.0",
|
||||
"description": "A modern workout planning application built with Next.js",
|
||||
"description": "Self-hosted multi-user workout planner and logger.",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -10,7 +10,8 @@
|
||||
"lint": "next lint",
|
||||
"db:push": "prisma db push",
|
||||
"db:seed": "npx tsx prisma/seed.ts",
|
||||
"db:studio": "prisma studio"
|
||||
"db:studio": "prisma studio",
|
||||
"sync-library": "node scripts/sync-library.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^14.0.0",
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ensureExerciseLibrary — runs at every container boot from
|
||||
* docker_entrypoint.sh. Inserts every exercise from
|
||||
* /app/prisma/exercises.seed.json into the Exercise table for every
|
||||
* existing user, using `INSERT OR IGNORE` keyed on (userId, name).
|
||||
*
|
||||
* Properties:
|
||||
* - Multi-user-aware. Iterates all rows in `User` so every user on the
|
||||
* instance gets the same curated library.
|
||||
* - Idempotent. Re-running is a no-op for exercises that already exist.
|
||||
* - Additive only. Never deletes or updates existing rows. Users keep
|
||||
* their own custom exercises (isCustom=true) untouched, and existing
|
||||
* library entries are not reshaped if the maintainer changed the
|
||||
* description/inputFields/etc. for them downstream.
|
||||
* - Cheap. Wrapped in a single transaction; ~164 rows x N users runs in
|
||||
* well under a second.
|
||||
*
|
||||
* Invoked from docker_entrypoint.sh after the schema-compat ALTERs:
|
||||
* node /app/prisma/ensureExerciseLibrary.cjs --db /data/app.db --json /app/prisma/exercises.seed.json
|
||||
*
|
||||
* Uses the sqlite3 CLI (already installed in the runner image) instead of a
|
||||
* Node SQLite binding so we don't have to ship a native dep.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
function arg(name) {
|
||||
const i = process.argv.indexOf(name);
|
||||
return i >= 0 ? process.argv[i + 1] : null;
|
||||
}
|
||||
|
||||
const dbPath = arg('--db');
|
||||
const jsonPath = arg('--json');
|
||||
|
||||
if (!dbPath || !jsonPath) {
|
||||
console.error('usage: ensureExerciseLibrary.cjs --db <path> --json <path>');
|
||||
process.exit(64);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
console.error(`[ensure-library] db not found: ${dbPath}`);
|
||||
process.exit(0); // soft-fail; entrypoint should keep going
|
||||
}
|
||||
if (!fs.existsSync(jsonPath)) {
|
||||
console.error(`[ensure-library] library json not found: ${jsonPath}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const library = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
||||
if (!Array.isArray(library) || library.length === 0) {
|
||||
console.error('[ensure-library] library is empty; nothing to do');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Get all user ids
|
||||
const usersRaw = execFileSync('sqlite3', [dbPath, 'SELECT id FROM User;'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const userIds = usersRaw.split('\n').map((s) => s.trim()).filter(Boolean);
|
||||
|
||||
if (userIds.length === 0) {
|
||||
console.error('[ensure-library] no users yet; skipping (will run on next boot after a user exists)');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Quote a string for safe use as a SQLite single-quoted literal.
|
||||
const q = (s) => `'${String(s).replace(/'/g, "''")}'`;
|
||||
// Generate a 25-char id with a "c" prefix to roughly match cuid shape.
|
||||
// Uniqueness comes from 12 random bytes; collision probability is negligible.
|
||||
const newId = () => 'c' + crypto.randomBytes(12).toString('hex');
|
||||
|
||||
const stmts = ['BEGIN;'];
|
||||
let inserts = 0;
|
||||
for (const userId of userIds) {
|
||||
for (const ex of library) {
|
||||
inserts++;
|
||||
const muscleGroups = q(JSON.stringify(ex.muscleGroups || []));
|
||||
const inputFields = q(
|
||||
JSON.stringify(ex.inputFields || ['sets', 'reps', 'weight']),
|
||||
);
|
||||
const defaultWeightUnit =
|
||||
ex.defaultWeightUnit == null ? 'NULL' : q(ex.defaultWeightUnit);
|
||||
const description = ex.description == null ? 'NULL' : q(ex.description);
|
||||
|
||||
stmts.push(
|
||||
`INSERT OR IGNORE INTO Exercise ` +
|
||||
`(id, userId, name, description, muscleGroups, type, inputFields, defaultWeightUnit, isCustom, createdAt) ` +
|
||||
`VALUES (${q(newId())}, ${q(userId)}, ${q(ex.name)}, ${description}, ` +
|
||||
`${muscleGroups}, ${q(ex.type)}, ${inputFields}, ${defaultWeightUnit}, ` +
|
||||
`0, CURRENT_TIMESTAMP);`,
|
||||
);
|
||||
}
|
||||
}
|
||||
stmts.push('COMMIT;');
|
||||
|
||||
execFileSync('sqlite3', [dbPath], { input: stmts.join('\n') });
|
||||
console.error(
|
||||
`[ensure-library] processed ${userIds.length} user(s) x ${library.length} exercise(s) ` +
|
||||
`(${inserts} INSERT OR IGNORE statements)`,
|
||||
);
|
||||
@@ -76,6 +76,7 @@ model Workout {
|
||||
durationMinutes Int?
|
||||
difficulty Int? // 1-10 scale
|
||||
caloriesBurned Int?
|
||||
deletedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -85,6 +86,7 @@ model Workout {
|
||||
|
||||
@@index([userId])
|
||||
@@index([date])
|
||||
@@index([deletedAt])
|
||||
}
|
||||
|
||||
model SetLog {
|
||||
@@ -100,6 +102,7 @@ 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"})
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import * as bcryptjs from "bcryptjs";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
/**
|
||||
* Seeds a fresh database with:
|
||||
* 1. The default `admin@local` user (password: `workout123`).
|
||||
* 2. Default UserPreferences for that user.
|
||||
* 3. The full curated exercise library, loaded from
|
||||
* `prisma/exercises.seed.json`.
|
||||
*
|
||||
* Idempotent — re-running upserts the user and exercises without
|
||||
* duplicates. Used at Docker build time to populate the empty-schema
|
||||
* fallback DB and at first boot for any host that didn't get a baked seed.
|
||||
*
|
||||
* The curated library is the same JSON read at runtime by
|
||||
* `ensureExerciseLibrary.cjs` from docker_entrypoint.sh, so updates
|
||||
* shipped in a new package version reach existing installs too.
|
||||
*/
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface LibraryExercise {
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: string;
|
||||
muscleGroups: string[];
|
||||
inputFields: string[];
|
||||
defaultWeightUnit: string | null;
|
||||
}
|
||||
|
||||
function loadLibrary(): LibraryExercise[] {
|
||||
const libPath = path.resolve(__dirname, "exercises.seed.json");
|
||||
if (!fs.existsSync(libPath)) {
|
||||
console.warn(`[seed] library file not found at ${libPath}; seeding 0 exercises`);
|
||||
return [];
|
||||
}
|
||||
const raw = JSON.parse(fs.readFileSync(libPath, "utf8"));
|
||||
if (!Array.isArray(raw)) {
|
||||
throw new Error(`[seed] library file at ${libPath} is not an array`);
|
||||
}
|
||||
return raw as LibraryExercise[];
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const hashedPassword = await bcryptjs.hash("workout123", 10);
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: "admin@local" },
|
||||
update: {},
|
||||
create: {
|
||||
email: "admin@local",
|
||||
passwordHash: hashedPassword,
|
||||
name: "Admin User",
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Created/verified user:", user.id);
|
||||
|
||||
await prisma.userPreferences.upsert({
|
||||
where: { userId: user.id },
|
||||
update: {},
|
||||
create: {
|
||||
userId: user.id,
|
||||
theme: "system",
|
||||
defaultWeightUnit: "lbs",
|
||||
defaultRestSeconds: 90,
|
||||
enableClaudeAI: false,
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Created/verified user preferences");
|
||||
|
||||
const exercises = loadLibrary();
|
||||
console.log(`[seed] loading ${exercises.length} exercises from exercises.seed.json`);
|
||||
|
||||
for (const exercise of exercises) {
|
||||
await prisma.exercise.upsert({
|
||||
where: {
|
||||
userId_name: {
|
||||
userId: user.id,
|
||||
name: exercise.name,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
userId: user.id,
|
||||
name: exercise.name,
|
||||
description: exercise.description,
|
||||
muscleGroups: JSON.stringify(exercise.muscleGroups),
|
||||
type: exercise.type,
|
||||
inputFields: JSON.stringify(exercise.inputFields),
|
||||
defaultWeightUnit: exercise.defaultWeightUnit,
|
||||
isCustom: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Created/verified ${exercises.length} exercises`);
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect();
|
||||
})
|
||||
.catch(async (e) => {
|
||||
console.error(e);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
|
After Width: | Height: | Size: 569 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<image href="/icons/gemini-kettlebell.png" width="1024" height="1024"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 146 B |
|
After Width: | Height: | Size: 593 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Workout Planner",
|
||||
"short_name": "Workout",
|
||||
"name": "Proof of Work",
|
||||
"short_name": "Proof",
|
||||
"description": "Track. Lift. Dominate.",
|
||||
"start_url": "/main/dashboard",
|
||||
"scope": "/",
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = 'workout-planner-v1';
|
||||
const CACHE_NAME = 'proof-of-work-v1';
|
||||
|
||||
// Assets to pre-cache for offline shell
|
||||
const PRECACHE_URLS = [
|
||||
@@ -28,10 +28,9 @@ function ensureOutputDir() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a stylized "W" character on a pixel buffer
|
||||
* Uses simple geometric shapes to create the letter
|
||||
* Draw a simple white kettlebell on black background.
|
||||
*/
|
||||
function drawW(buffer, width, height) {
|
||||
function drawKettlebell(buffer, width, height) {
|
||||
const canvasWidth = width;
|
||||
const canvasHeight = height;
|
||||
|
||||
@@ -62,45 +61,52 @@ function drawW(buffer, width, height) {
|
||||
// Fill background
|
||||
fillRect(0, 0, canvasWidth, canvasHeight, BACKGROUND_COLOR.r, BACKGROUND_COLOR.g, BACKGROUND_COLOR.b);
|
||||
|
||||
// Calculate dimensions for the "W"
|
||||
const padding = Math.floor(canvasHeight * 0.1);
|
||||
const letterWidth = canvasWidth - padding * 2;
|
||||
const letterHeight = canvasHeight - padding * 2;
|
||||
const cx = canvasWidth / 2;
|
||||
const bodyTop = Math.floor(canvasHeight * 0.34);
|
||||
const bodyBottom = Math.floor(canvasHeight * 0.86);
|
||||
const bodyHalfWidth = canvasWidth * 0.27;
|
||||
|
||||
const startX = padding;
|
||||
const startY = padding;
|
||||
const handleOuterRadius = canvasWidth * 0.2;
|
||||
const handleInnerRadius = canvasWidth * 0.11;
|
||||
const handleCenterY = canvasHeight * 0.35;
|
||||
|
||||
// Draw a stylized "W" using rectangles (five vertical bars forming W shape)
|
||||
const barWidth = letterWidth * 0.12;
|
||||
const spacing = letterWidth / 4;
|
||||
// Draw ring handle (outer white circle, inner black circle).
|
||||
for (let y = 0; y < canvasHeight; y++) {
|
||||
for (let x = 0; x < canvasWidth; x++) {
|
||||
const dx = x - cx;
|
||||
const dy = y - handleCenterY;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist <= handleOuterRadius && dist >= handleInnerRadius) {
|
||||
setPixel(x, y, TEXT_COLOR.r, TEXT_COLOR.g, TEXT_COLOR.b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a V-V-V pattern (W shape)
|
||||
// First V: two diagonal bars meeting at a point
|
||||
const v1LeftX = startX + spacing * 0.2;
|
||||
const v1RightX = startX + spacing * 0.8;
|
||||
const v1MidX = (v1LeftX + v1RightX) / 2;
|
||||
const v1BottomY = startY + letterHeight;
|
||||
const v1TopY = startY;
|
||||
// Handle connectors.
|
||||
const connectorWidth = Math.floor(canvasWidth * 0.06);
|
||||
const connectorHeight = Math.floor(canvasHeight * 0.1);
|
||||
const leftConnectorX = Math.floor(cx - handleOuterRadius - connectorWidth * 0.2);
|
||||
const rightConnectorX = Math.floor(cx + handleOuterRadius - connectorWidth * 0.8);
|
||||
const connectorY = Math.floor(canvasHeight * 0.39);
|
||||
fillRect(leftConnectorX, connectorY, connectorWidth, connectorHeight, TEXT_COLOR.r, TEXT_COLOR.g, TEXT_COLOR.b);
|
||||
fillRect(rightConnectorX, connectorY, connectorWidth, connectorHeight, TEXT_COLOR.r, TEXT_COLOR.g, TEXT_COLOR.b);
|
||||
|
||||
// Left bar of first V
|
||||
fillRect(v1LeftX, v1TopY, barWidth, letterHeight, TEXT_COLOR.r, TEXT_COLOR.g, TEXT_COLOR.b);
|
||||
// Rounded-ish body made from stacked horizontal bars.
|
||||
const bodyHeight = bodyBottom - bodyTop;
|
||||
for (let i = 0; i < bodyHeight; i++) {
|
||||
const t = i / bodyHeight;
|
||||
const curve = 1 - Math.pow((t - 0.5) * 2, 2); // widest in middle
|
||||
const half = bodyHalfWidth * (0.78 + 0.22 * Math.max(0, curve));
|
||||
const y = bodyTop + i;
|
||||
const x = Math.floor(cx - half);
|
||||
const w = Math.ceil(half * 2);
|
||||
fillRect(x, y, w, 1, TEXT_COLOR.r, TEXT_COLOR.g, TEXT_COLOR.b);
|
||||
}
|
||||
|
||||
// Right bar of first V
|
||||
fillRect(v1RightX, v1TopY, barWidth, letterHeight, TEXT_COLOR.r, TEXT_COLOR.g, TEXT_COLOR.b);
|
||||
|
||||
// Second V
|
||||
const v2LeftX = startX + spacing * 0.9;
|
||||
const v2RightX = startX + spacing * 1.5;
|
||||
|
||||
fillRect(v2LeftX, v1TopY, barWidth, letterHeight, TEXT_COLOR.r, TEXT_COLOR.g, TEXT_COLOR.b);
|
||||
fillRect(v2RightX, v1TopY, barWidth, letterHeight, TEXT_COLOR.r, TEXT_COLOR.g, TEXT_COLOR.b);
|
||||
|
||||
// Third V (right side)
|
||||
const v3LeftX = startX + spacing * 1.6;
|
||||
const v3RightX = startX + spacing * 2.2;
|
||||
|
||||
fillRect(v3LeftX, v1TopY, barWidth, letterHeight, TEXT_COLOR.r, TEXT_COLOR.g, TEXT_COLOR.b);
|
||||
fillRect(v3RightX, v1TopY, barWidth, letterHeight, TEXT_COLOR.r, TEXT_COLOR.g, TEXT_COLOR.b);
|
||||
// Flat base accent.
|
||||
const baseWidth = Math.floor(canvasWidth * 0.42);
|
||||
const baseHeight = Math.max(1, Math.floor(canvasHeight * 0.035));
|
||||
fillRect(Math.floor(cx - baseWidth / 2), bodyBottom - baseHeight, baseWidth, baseHeight, TEXT_COLOR.r, TEXT_COLOR.g, TEXT_COLOR.b);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,7 +199,7 @@ function generateIcons() {
|
||||
ICON_SIZES.forEach((size) => {
|
||||
// Regular icon
|
||||
const buffer = Buffer.alloc(size * size * 4);
|
||||
drawW(buffer, size, size);
|
||||
drawKettlebell(buffer, size, size);
|
||||
|
||||
const outputPath = path.join(OUTPUT_DIR, `icon-${size}x${size}.png`);
|
||||
createPNG(size, size, buffer, outputPath);
|
||||
@@ -202,7 +208,7 @@ function generateIcons() {
|
||||
// Maskable variant (for adaptive icons on Android)
|
||||
if (MASKABLE_SIZES.includes(size)) {
|
||||
const maskableBuffer = Buffer.alloc(size * size * 4);
|
||||
drawW(maskableBuffer, size, size);
|
||||
drawKettlebell(maskableBuffer, size, size);
|
||||
|
||||
const maskablePath = path.join(OUTPUT_DIR, `icon-${size}x${size}-maskable.png`);
|
||||
createPNG(size, size, maskableBuffer, maskablePath);
|
||||
@@ -210,11 +216,14 @@ function generateIcons() {
|
||||
}
|
||||
});
|
||||
|
||||
// Create a favicon SVG as well
|
||||
// Create a matching favicon SVG as well.
|
||||
const svgPath = path.join(OUTPUT_DIR, 'favicon.svg');
|
||||
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
|
||||
<rect width="192" height="192" fill="#0A0A0A"/>
|
||||
<text x="96" y="132" font-size="120" font-weight="bold" text-anchor="middle" fill="white" font-family="Arial, sans-serif">W</text>
|
||||
<circle cx="96" cy="66" r="38" fill="none" stroke="#fff" stroke-width="18"/>
|
||||
<rect x="52" y="69" width="18" height="22" fill="#fff"/>
|
||||
<rect x="122" y="69" width="18" height="22" fill="#fff"/>
|
||||
<path d="M 43 77 C 52 62, 140 62, 149 77 L 149 150 C 149 163, 131 170, 96 170 C 61 170, 43 163, 43 150 Z" fill="#fff"/>
|
||||
</svg>`;
|
||||
fs.writeFileSync(svgPath, svgContent);
|
||||
console.log(`✓ Created ${path.basename(svgPath)}`);
|
||||
@@ -3,13 +3,13 @@
|
||||
# Usage: ./scripts/setup-autostart.sh
|
||||
#
|
||||
# This creates a Launch Agent plist that runs scripts/start.sh on login.
|
||||
# To remove: launchctl unload ~/Library/LaunchAgents/com.workout-planner.plist
|
||||
# To remove: launchctl unload ~/Library/LaunchAgents/com.proof-of-work.plist
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
PLIST_NAME="com.workout-planner"
|
||||
PLIST_NAME="com.proof-of-work"
|
||||
PLIST_DIR="$HOME/Library/LaunchAgents"
|
||||
PLIST_PATH="$PLIST_DIR/$PLIST_NAME.plist"
|
||||
LOG_DIR="$PROJECT_DIR/logs"
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* sync-library — extract the curated exercise library from a SQLite snapshot
|
||||
* and write it to prisma/exercises.seed.json (the canonical library source
|
||||
* of truth used by both `prisma/seed.ts` for fresh installs and the
|
||||
* `ensureExerciseLibrary.cjs` runtime ensure step for updates on existing
|
||||
* installs).
|
||||
*
|
||||
* Default source: ../start9/0.4/seed/data/app.db
|
||||
* Default target: prisma/exercises.seed.json
|
||||
*
|
||||
* Maintainer release loop:
|
||||
* 1. Add new exercises in the running app on your StartOS host.
|
||||
* 2. ./start9/0.4/refresh_seed.sh <ssh-target> # pull a fresh snapshot
|
||||
* 3. cd proof-of-work && npm run sync-library # update the JSON
|
||||
* 4. git diff prisma/exercises.seed.json # eyeball the new rows
|
||||
* 5. git add prisma/exercises.seed.json && commit
|
||||
* 6. Bump start9/0.4/startos/utils.ts:PACKAGE_VERSION + add a version file
|
||||
* 7. make -C start9/0.4 clean x86 && make -C start9/0.4 install
|
||||
*
|
||||
* Notes:
|
||||
* - All exercises in the JSON ship with isCustom=false on consumption,
|
||||
* regardless of how they were created on the maintainer's host. They are
|
||||
* part of the curated library now, not user-custom.
|
||||
* - Removing an exercise from the JSON does NOT remove it from existing
|
||||
* installs (users may have logged sets against it). The system is
|
||||
* additive only.
|
||||
* - This script never touches the snapshot DB. Read-only.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||
const DEFAULT_DB = path.join(repoRoot, 'start9', '0.4', 'seed', 'data', 'app.db');
|
||||
const DEFAULT_OUT = path.resolve(__dirname, '..', 'prisma', 'exercises.seed.json');
|
||||
|
||||
function arg(name, fallback) {
|
||||
const i = process.argv.indexOf(name);
|
||||
return i >= 0 ? process.argv[i + 1] : fallback;
|
||||
}
|
||||
|
||||
const dbPath = arg('--db', DEFAULT_DB);
|
||||
const outPath = arg('--out', DEFAULT_OUT);
|
||||
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
console.error(`[sync-library] snapshot DB not found at ${dbPath}`);
|
||||
console.error(' Run start9/0.4/refresh_seed.sh first, or pass --db <path>.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
console.error(`[sync-library] reading from ${dbPath}`);
|
||||
|
||||
const sql =
|
||||
'SELECT name, description, muscleGroups, type, inputFields, defaultWeightUnit ' +
|
||||
'FROM Exercise ORDER BY type, name;';
|
||||
const raw = execFileSync('sqlite3', ['-cmd', '.mode json', dbPath, sql], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const rows = JSON.parse(raw);
|
||||
|
||||
const library = rows.map((r) => ({
|
||||
name: r.name,
|
||||
description: r.description ?? null,
|
||||
type: r.type,
|
||||
muscleGroups: r.muscleGroups ? JSON.parse(r.muscleGroups) : [],
|
||||
inputFields: r.inputFields ? JSON.parse(r.inputFields) : ['sets', 'reps', 'weight'],
|
||||
defaultWeightUnit: r.defaultWeightUnit ?? null,
|
||||
}));
|
||||
|
||||
fs.writeFileSync(outPath, JSON.stringify(library, null, 2) + '\n');
|
||||
console.error(`[sync-library] wrote ${library.length} exercises to ${outPath}`);
|
||||