diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 5f67ba1..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.dockerignore b/.dockerignore index bad7881..d432ffd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a5d327 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f475a7 --- /dev/null +++ b/README.md @@ -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 # 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. diff --git a/workout-planner/.env.example b/proof-of-work/.env.example similarity index 100% rename from workout-planner/.env.example rename to proof-of-work/.env.example diff --git a/workout-planner/.gitignore b/proof-of-work/.gitignore similarity index 100% rename from workout-planner/.gitignore rename to proof-of-work/.gitignore diff --git a/workout-planner/README.md b/proof-of-work/README.md similarity index 100% rename from workout-planner/README.md rename to proof-of-work/README.md diff --git a/workout-planner/app/api/auth/logout/route.ts b/proof-of-work/app/api/auth/logout/route.ts similarity index 100% rename from workout-planner/app/api/auth/logout/route.ts rename to proof-of-work/app/api/auth/logout/route.ts diff --git a/workout-planner/app/api/auth/route.ts b/proof-of-work/app/api/auth/route.ts similarity index 100% rename from workout-planner/app/api/auth/route.ts rename to proof-of-work/app/api/auth/route.ts diff --git a/workout-planner/app/api/exercises/[id]/route.ts b/proof-of-work/app/api/exercises/[id]/route.ts similarity index 97% rename from workout-planner/app/api/exercises/[id]/route.ts rename to proof-of-work/app/api/exercises/[id]/route.ts index 852244b..96bee2b 100644 --- a/workout-planner/app/api/exercises/[id]/route.ts +++ b/proof-of-work/app/api/exercises/[id]/route.ts @@ -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, diff --git a/workout-planner/app/api/exercises/route.ts b/proof-of-work/app/api/exercises/route.ts similarity index 100% rename from workout-planner/app/api/exercises/route.ts rename to proof-of-work/app/api/exercises/route.ts diff --git a/workout-planner/app/api/health/route.ts b/proof-of-work/app/api/health/route.ts similarity index 100% rename from workout-planner/app/api/health/route.ts rename to proof-of-work/app/api/health/route.ts diff --git a/proof-of-work/app/api/import/exercises/seed/route.ts b/proof-of-work/app/api/import/exercises/seed/route.ts new file mode 100644 index 0000000..00cd0f9 --- /dev/null +++ b/proof-of-work/app/api/import/exercises/seed/route.ts @@ -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 } + ); + } +} diff --git a/workout-planner/app/api/import/parse/route.ts b/proof-of-work/app/api/import/parse/route.ts similarity index 54% rename from workout-planner/app/api/import/parse/route.ts rename to proof-of-work/app/api/import/parse/route.ts index 17d1f15..212ba97 100644 --- a/workout-planner/app/api/import/parse/route.ts +++ b/proof-of-work/app/api/import/parse/route.ts @@ -38,12 +38,20 @@ interface ParsedSet { weight?: number; weightUnit: string; reps?: number; + durationSeconds?: number; + distance?: number; + distanceUnit?: string; + calories?: number; + rpe?: number; + customMetrics?: Record; 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> { - 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 = {}; + 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> = []; + + for (let i = 1; i < parsedRows.length; i++) { + const values = parsedRows[i]; + const out: Record = {}; 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 = {}; + 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)) { + 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, }); } diff --git a/workout-planner/app/api/preferences/route.ts b/proof-of-work/app/api/preferences/route.ts similarity index 100% rename from workout-planner/app/api/preferences/route.ts rename to proof-of-work/app/api/preferences/route.ts diff --git a/proof-of-work/app/api/settings/export-csv/route.ts b/proof-of-work/app/api/settings/export-csv/route.ts new file mode 100644 index 0000000..259e893 --- /dev/null +++ b/proof-of-work/app/api/settings/export-csv/route.ts @@ -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 } + ); + } +} diff --git a/proof-of-work/app/api/settings/export-db/route.ts b/proof-of-work/app/api/settings/export-db/route.ts new file mode 100644 index 0000000..a6c5678 --- /dev/null +++ b/proof-of-work/app/api/settings/export-db/route.ts @@ -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 } + ); + } +} diff --git a/workout-planner/app/api/settings/import-db/route.ts b/proof-of-work/app/api/settings/import-db/route.ts similarity index 91% rename from workout-planner/app/api/settings/import-db/route.ts rename to proof-of-work/app/api/settings/import-db/route.ts index 7bce0ca..baff584 100644 --- a/workout-planner/app/api/settings/import-db/route.ts +++ b/proof-of-work/app/api/settings/import-db/route.ts @@ -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"; diff --git a/workout-planner/app/api/workouts/[id]/route.ts b/proof-of-work/app/api/workouts/[id]/route.ts similarity index 84% rename from workout-planner/app/api/workouts/[id]/route.ts rename to proof-of-work/app/api/workouts/[id]/route.ts index 1c0aa8c..e93be76 100644 --- a/workout-planner/app/api/workouts/[id]/route.ts +++ b/proof-of-work/app/api/workouts/[id]/route.ts @@ -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 }); diff --git a/workout-planner/app/api/workouts/[id]/sets/route.ts b/proof-of-work/app/api/workouts/[id]/sets/route.ts similarity index 88% rename from workout-planner/app/api/workouts/[id]/sets/route.ts rename to proof-of-work/app/api/workouts/[id]/sets/route.ts index 164db4d..0704a6a 100644 --- a/workout-planner/app/api/workouts/[id]/sets/route.ts +++ b/proof-of-work/app/api/workouts/[id]/sets/route.ts @@ -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 }, }); diff --git a/workout-planner/app/api/workouts/import/route.ts b/proof-of-work/app/api/workouts/import/route.ts similarity index 100% rename from workout-planner/app/api/workouts/import/route.ts rename to proof-of-work/app/api/workouts/import/route.ts diff --git a/workout-planner/app/api/workouts/import/save/route.ts b/proof-of-work/app/api/workouts/import/save/route.ts similarity index 100% rename from workout-planner/app/api/workouts/import/save/route.ts rename to proof-of-work/app/api/workouts/import/save/route.ts diff --git a/workout-planner/app/api/workouts/route.ts b/proof-of-work/app/api/workouts/route.ts similarity index 95% rename from workout-planner/app/api/workouts/route.ts rename to proof-of-work/app/api/workouts/route.ts index 0e23317..f68bb3f 100644 --- a/workout-planner/app/api/workouts/route.ts +++ b/proof-of-work/app/api/workouts/route.ts @@ -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)), } diff --git a/workout-planner/app/auth/login/actions.ts b/proof-of-work/app/auth/login/actions.ts similarity index 100% rename from workout-planner/app/auth/login/actions.ts rename to proof-of-work/app/auth/login/actions.ts diff --git a/workout-planner/app/auth/login/page.tsx b/proof-of-work/app/auth/login/page.tsx similarity index 99% rename from workout-planner/app/auth/login/page.tsx rename to proof-of-work/app/auth/login/page.tsx index de690da..d19cecb 100644 --- a/workout-planner/app/auth/login/page.tsx +++ b/proof-of-work/app/auth/login/page.tsx @@ -40,7 +40,7 @@ export default function LoginPage() {

- Workout Planner + Proof of Work

Track. Lift. Dominate. diff --git a/workout-planner/app/globals.css b/proof-of-work/app/globals.css similarity index 100% rename from workout-planner/app/globals.css rename to proof-of-work/app/globals.css diff --git a/workout-planner/app/layout.tsx b/proof-of-work/app/layout.tsx similarity index 80% rename from workout-planner/app/layout.tsx rename to proof-of-work/app/layout.tsx index 4657e40..3260b04 100644 --- a/workout-planner/app/layout.tsx +++ b/proof-of-work/app/layout.tsx @@ -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', }, }; diff --git a/workout-planner/app/main/actions.ts b/proof-of-work/app/main/actions.ts similarity index 100% rename from workout-planner/app/main/actions.ts rename to proof-of-work/app/main/actions.ts diff --git a/workout-planner/app/main/dashboard/page.tsx b/proof-of-work/app/main/dashboard/page.tsx similarity index 94% rename from workout-planner/app/main/dashboard/page.tsx rename to proof-of-work/app/main/dashboard/page.tsx index 14ee180..8076ca4 100644 --- a/workout-planner/app/main/dashboard/page.tsx +++ b/proof-of-work/app/main/dashboard/page.tsx @@ -28,18 +28,6 @@ export default async function DashboardPage() { return (

- {/* Header with greeting */} -
-
-

- Welcome back, {user.name || "Trainer"}! -

-

- Keep pushing your limits and achieving your goals. -

-
-
- {/* Main content */}
{/* Stats Cards */} diff --git a/workout-planner/app/main/exercises/[id]/page.tsx b/proof-of-work/app/main/exercises/[id]/page.tsx similarity index 100% rename from workout-planner/app/main/exercises/[id]/page.tsx rename to proof-of-work/app/main/exercises/[id]/page.tsx diff --git a/workout-planner/app/main/exercises/page.tsx b/proof-of-work/app/main/exercises/page.tsx similarity index 100% rename from workout-planner/app/main/exercises/page.tsx rename to proof-of-work/app/main/exercises/page.tsx diff --git a/proof-of-work/app/main/import/page-csv.tsx b/proof-of-work/app/main/import/page-csv.tsx new file mode 100644 index 0000000..4fe651c --- /dev/null +++ b/proof-of-work/app/main/import/page-csv.tsx @@ -0,0 +1,1337 @@ +"use client"; + +import { useMemo, useRef, useState } from "react"; +import { ChevronLeft, Upload, Trash2, Check, X } from "lucide-react"; +import Link from "next/link"; +import { Exercise } from "@prisma/client"; +import { + deriveEquipmentOptions, + deriveMuscleGroupOptions, + deriveTrackingFieldOptions, + displayLabel, + normalizeValue, + Option, +} from "@/lib/exerciseOptions"; + +interface ParsedSet { + setNumber: number; + weight?: number; + weightUnit: string; + reps?: number; + durationSeconds?: number; + distance?: number; + distanceUnit?: string; + calories?: number; + rpe?: number; + customMetrics?: Record; + notes?: string; +} + +interface ParsedExercise { + exerciseId: string; + exerciseName: string; + sourceName?: string; + unmapped?: boolean; + sets: ParsedSet[]; +} + +interface ParsedWorkout { + date: string; + exercises: ParsedExercise[]; +} + +interface WorkoutState extends ParsedWorkout { + status: "pending" | "approved" | "skipped"; +} + +interface ExerciseOption { + id: string; + name: string; +} + +interface NewExerciseDraft { + name: string; + type: string; + muscleGroups: string[]; + inputFields: string[]; +} + +interface SeedExercisePayload { + name: string; + type?: string; + muscleGroups?: string[]; + inputFields?: string[]; + defaultWeightUnit?: string | null; +} + +export default function ImportCSVPage() { + const [workouts, setWorkouts] = useState([]); + const [currentIndex, setCurrentIndex] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [unmapped, setUnmapped] = useState([]); + const [exerciseLibrary, setExerciseLibrary] = useState([]); + const [exerciseOptions, setExerciseOptions] = useState([]); + const [mapSelections, setMapSelections] = useState>({}); + const [createDrafts, setCreateDrafts] = useState>({}); + const [creatingFor, setCreatingFor] = useState>({}); + const [showCreateFor, setShowCreateFor] = useState>({}); + const [addingTypeFor, setAddingTypeFor] = useState>({}); + const [newTypeTextFor, setNewTypeTextFor] = useState>({}); + const [sessionTypes, setSessionTypes] = useState([]); + const [addingMuscleFor, setAddingMuscleFor] = useState>({}); + const [newMuscleTextFor, setNewMuscleTextFor] = useState>({}); + const [sessionMuscles, setSessionMuscles] = useState([]); + const [addingFieldFor, setAddingFieldFor] = useState>({}); + const [newFieldTextFor, setNewFieldTextFor] = useState>({}); + const [sessionFields, setSessionFields] = useState([]); + const [seedFile, setSeedFile] = useState(null); + const [seeding, setSeeding] = useState(false); + const [seedResult, setSeedResult] = useState<{ + created: number; + skipped: number; + errors: Array<{ name: string; error: string }>; + } | null>(null); + const fileInputRef = useRef(null); + const seedInputRef = useRef(null); + + const currentWorkout = workouts[currentIndex]; + const approved = workouts.filter((w) => w.status === "approved").length; + const skipped = workouts.filter((w) => w.status === "skipped").length; + const remaining = workouts.filter((w) => w.status === "pending").length; + + const equipmentOptions = useMemo(() => { + const base = deriveEquipmentOptions(exerciseLibrary); + const merged = [...base]; + for (const option of sessionTypes) { + if (!merged.some((item) => item.value === option.value)) { + merged.push(option); + } + } + return merged; + }, [exerciseLibrary, sessionTypes]); + + const muscleOptions = useMemo(() => { + const base = deriveMuscleGroupOptions(exerciseLibrary); + const merged = [...base]; + for (const muscle of sessionMuscles) { + if (!merged.includes(muscle)) { + merged.push(muscle); + } + } + return merged; + }, [exerciseLibrary, sessionMuscles]); + + const trackingOptions = useMemo(() => { + const base = deriveTrackingFieldOptions(exerciseLibrary); + const merged = [...base]; + for (const option of sessionFields) { + if (!merged.some((item) => item.value === option.value)) { + merged.push(option); + } + } + return merged; + }, [exerciseLibrary, 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 getUnresolvedInWorkout = (workout: WorkoutState): string[] => { + const unresolved = workout.exercises + .filter((exercise) => !exercise.exerciseId) + .map((exercise) => exercise.sourceName || exercise.exerciseName); + return Array.from(new Set(unresolved)); + }; + + const applyExerciseResolution = ( + sourceName: string, + resolvedExercise: { id: string; name: string } + ) => { + setWorkouts((prev) => + prev.map((workout) => ({ + ...workout, + exercises: workout.exercises.map((exercise) => { + const exerciseSource = exercise.sourceName || exercise.exerciseName; + if ( + !exercise.exerciseId && + exerciseSource.toLowerCase() === sourceName.toLowerCase() + ) { + return { + ...exercise, + exerciseId: resolvedExercise.id, + exerciseName: resolvedExercise.name, + unmapped: false, + }; + } + return exercise; + }), + })) + ); + + setUnmapped((prev) => + prev.filter((name) => name.toLowerCase() !== sourceName.toLowerCase()) + ); + setMapSelections((prev) => { + const next = { ...prev }; + delete next[sourceName]; + return next; + }); + setShowCreateFor((prev) => { + const next = { ...prev }; + delete next[sourceName]; + return next; + }); + }; + + const handleFileChange = async ( + event: React.ChangeEvent + ) => { + const file = event.target.files?.[0]; + if (!file) return; + + setLoading(true); + setError(null); + + try { + const formData = new FormData(); + formData.append("file", file); + + const response = await fetch("/api/import/parse", { + method: "POST", + body: formData, + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || "Failed to parse CSV"); + } + + const data = await response.json(); + const initialWorkouts: WorkoutState[] = data.workouts.map( + (w: ParsedWorkout) => ({ + ...w, + status: "pending" as const, + }) + ); + + const exercisesResponse = await fetch("/api/exercises"); + if (!exercisesResponse.ok) { + throw new Error("Failed to load exercise library"); + } + const exercisesData = await exercisesResponse.json(); + const options: ExerciseOption[] = (exercisesData || []).map((exercise: any) => ({ + id: exercise.id, + name: exercise.name, + })); + + setWorkouts(initialWorkouts); + setUnmapped(data.unmapped || []); + setExerciseLibrary(exercisesData || []); + setExerciseOptions(options); + setMapSelections({}); + setShowCreateFor({}); + setCreatingFor({}); + setAddingTypeFor({}); + setNewTypeTextFor({}); + setSessionTypes([]); + setAddingMuscleFor({}); + setNewMuscleTextFor({}); + setSessionMuscles([]); + setAddingFieldFor({}); + setNewFieldTextFor({}); + setSessionFields([]); + const initialDrafts: Record = {}; + for (const name of data.unmapped || []) { + initialDrafts[name] = { + name, + type: "other", + muscleGroups: [], + inputFields: ["sets", "reps", "weight", "notes"], + }; + } + setCreateDrafts(initialDrafts); + setCurrentIndex(0); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to parse CSV"); + } finally { + setLoading(false); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const files = e.dataTransfer.files; + if (files.length > 0) { + const file = files[0]; + if (fileInputRef.current) { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + fileInputRef.current.files = dataTransfer.files; + + const event = new Event("change", { bubbles: true }); + fileInputRef.current.dispatchEvent(event); + } + } + }; + + const updateSet = ( + exerciseIdx: number, + setIdx: number, + field: keyof ParsedSet, + value: any + ) => { + if (!currentWorkout) return; + + const updatedWorkouts = [...workouts]; + const workout = updatedWorkouts[currentIndex]; + const set = workout.exercises[exerciseIdx].sets[setIdx]; + + if (field === "setNumber") { + set[field] = value ? parseInt(value, 10) : 0; + } else if (field === "reps") { + set[field] = value ? parseInt(value, 10) : undefined; + } else if (field === "weight") { + set[field] = value ? parseFloat(value) : undefined; + } else { + (set[field] as any) = value; + } + + setWorkouts(updatedWorkouts); + }; + + const deleteSet = (exerciseIdx: number, setIdx: number) => { + if (!currentWorkout) return; + + const updatedWorkouts = [...workouts]; + const workout = updatedWorkouts[currentIndex]; + const exercise = workout.exercises[exerciseIdx]; + + // Remove the set + exercise.sets.splice(setIdx, 1); + + // Renumber remaining sets + exercise.sets.forEach((set, idx) => { + set.setNumber = idx + 1; + }); + + // If no sets left, remove the exercise + if (exercise.sets.length === 0) { + workout.exercises.splice(exerciseIdx, 1); + } + + setWorkouts(updatedWorkouts); + }; + + const deleteExercise = (exerciseIdx: number) => { + if (!currentWorkout) return; + + const updatedWorkouts = [...workouts]; + const workout = updatedWorkouts[currentIndex]; + workout.exercises.splice(exerciseIdx, 1); + + setWorkouts(updatedWorkouts); + }; + + const approveWorkout = async () => { + if (!currentWorkout) return; + + try { + setLoading(true); + setError(null); + + const unresolved = getUnresolvedInWorkout(currentWorkout); + if (unresolved.length > 0) { + throw new Error( + `Resolve unmapped exercises before approving: ${unresolved.join(", ")}` + ); + } + + // Transform workout to API format + const setLogs = []; + for (const exercise of currentWorkout.exercises) { + for (const set of exercise.sets) { + const payloadSet: any = { + exerciseId: exercise.exerciseId, + setNumber: set.setNumber, + weightUnit: set.weightUnit, + }; + + if (typeof set.weight === "number" && !Number.isNaN(set.weight)) { + payloadSet.weight = set.weight; + } + if (typeof set.reps === "number" && !Number.isNaN(set.reps)) { + payloadSet.reps = set.reps; + } + if (typeof set.notes === "string" && set.notes.trim().length > 0) { + payloadSet.notes = set.notes.trim(); + } + if ( + typeof set.durationSeconds === "number" && + !Number.isNaN(set.durationSeconds) + ) { + payloadSet.durationSeconds = set.durationSeconds; + } + if (typeof set.distance === "number" && !Number.isNaN(set.distance)) { + payloadSet.distance = set.distance; + if (set.distanceUnit) payloadSet.distanceUnit = set.distanceUnit; + } + if (typeof set.calories === "number" && !Number.isNaN(set.calories)) { + payloadSet.calories = set.calories; + } + if (typeof set.rpe === "number" && !Number.isNaN(set.rpe)) { + payloadSet.rpe = set.rpe; + } + if ( + set.customMetrics && + typeof set.customMetrics === "object" && + Object.keys(set.customMetrics).length > 0 + ) { + payloadSet.customMetrics = set.customMetrics; + } + + setLogs.push(payloadSet); + } + } + + const response = await fetch("/api/workouts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + date: currentWorkout.date, + sets: setLogs, + }), + }); + + if (!response.ok) { + const data = await response.json().catch(() => null); + throw new Error(data?.error || "Failed to save workout"); + } + + // Mark as approved and move to next + const updatedWorkouts = [...workouts]; + updatedWorkouts[currentIndex].status = "approved"; + setWorkouts(updatedWorkouts); + + // Find next pending workout + const nextPending = updatedWorkouts.findIndex( + (w) => w.status === "pending" + ); + if (nextPending !== -1) { + setCurrentIndex(nextPending); + } else { + setCurrentIndex(currentIndex + 1); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save workout"); + } finally { + setLoading(false); + } + }; + + const skipWorkout = () => { + if (!currentWorkout) return; + + const updatedWorkouts = [...workouts]; + updatedWorkouts[currentIndex].status = "skipped"; + setWorkouts(updatedWorkouts); + + // Find next pending workout + const nextPending = updatedWorkouts.findIndex( + (w, idx) => w.status === "pending" && idx > currentIndex + ); + if (nextPending !== -1) { + setCurrentIndex(nextPending); + } else { + setCurrentIndex(currentIndex + 1); + } + }; + + const deleteWorkout = () => { + const updatedWorkouts = workouts.filter((_, idx) => idx !== currentIndex); + setWorkouts(updatedWorkouts); + + if (updatedWorkouts.length > 0) { + setCurrentIndex(Math.min(currentIndex, updatedWorkouts.length - 1)); + } + }; + + const handleSeedFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0] || null; + setSeedFile(file); + setSeedResult(null); + }; + + const applyExerciseSeed = async () => { + if (!seedFile) { + setError("Select a seed JSON file first."); + return; + } + + setSeeding(true); + setError(null); + setSeedResult(null); + try { + const text = await seedFile.text(); + const parsed = JSON.parse(text); + const exercises: SeedExercisePayload[] = Array.isArray(parsed) + ? parsed + : Array.isArray(parsed?.exercises) + ? parsed.exercises + : []; + + if (exercises.length === 0) { + throw new Error("Seed file has no exercises."); + } + + const response = await fetch("/api/import/exercises/seed", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ exercises }), + }); + const data = await response.json().catch(() => null); + if (!response.ok) { + throw new Error(data?.error || "Failed to import exercise seed."); + } + + setSeedResult({ + created: data.created || 0, + skipped: data.skipped || 0, + errors: data.errors || [], + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to apply exercise seed."); + } finally { + setSeeding(false); + } + }; + + const handleMapToExisting = (sourceName: string) => { + const selectedId = mapSelections[sourceName]; + if (!selectedId) { + setError(`Select an exercise to map "${sourceName}" first`); + return; + } + const selectedExercise = exerciseOptions.find((exercise) => exercise.id === selectedId); + if (!selectedExercise) { + setError("Selected exercise was not found"); + return; + } + applyExerciseResolution(sourceName, selectedExercise); + setError(null); + }; + + const updateCreateDraft = ( + sourceName: string, + updater: (draft: NewExerciseDraft) => NewExerciseDraft + ) => { + setCreateDrafts((prev) => { + const existing = prev[sourceName] || { + name: sourceName, + type: "other", + muscleGroups: [], + inputFields: ["sets", "reps", "weight", "notes"], + }; + return { + ...prev, + [sourceName]: updater(existing), + }; + }); + }; + + const commitTypeFor = (sourceName: string) => { + const value = normalizeValue(newTypeTextFor[sourceName] || ""); + if (!value) { + setAddingTypeFor((prev) => ({ ...prev, [sourceName]: false })); + setNewTypeTextFor((prev) => ({ ...prev, [sourceName]: "" })); + return; + } + if (!equipmentOptions.some((option) => option.value === value)) { + setSessionTypes((prev) => [...prev, { value, label: displayLabel(value) }]); + } + updateCreateDraft(sourceName, (prev) => ({ + ...prev, + type: value, + inputFields: getDefaultFieldsForType(value), + })); + setAddingTypeFor((prev) => ({ ...prev, [sourceName]: false })); + setNewTypeTextFor((prev) => ({ ...prev, [sourceName]: "" })); + }; + + const commitMuscleFor = (sourceName: string) => { + const value = normalizeValue(newMuscleTextFor[sourceName] || ""); + if (!value) { + setAddingMuscleFor((prev) => ({ ...prev, [sourceName]: false })); + setNewMuscleTextFor((prev) => ({ ...prev, [sourceName]: "" })); + return; + } + if (!muscleOptions.includes(value)) { + setSessionMuscles((prev) => [...prev, value]); + } + updateCreateDraft(sourceName, (prev) => ({ + ...prev, + muscleGroups: prev.muscleGroups.includes(value) + ? prev.muscleGroups + : [...prev.muscleGroups, value], + })); + setAddingMuscleFor((prev) => ({ ...prev, [sourceName]: false })); + setNewMuscleTextFor((prev) => ({ ...prev, [sourceName]: "" })); + }; + + const commitFieldFor = (sourceName: string) => { + const value = normalizeValue(newFieldTextFor[sourceName] || ""); + if (!value || value === "sets") { + setAddingFieldFor((prev) => ({ ...prev, [sourceName]: false })); + setNewFieldTextFor((prev) => ({ ...prev, [sourceName]: "" })); + return; + } + if (!trackingOptions.some((option) => option.value === value)) { + setSessionFields((prev) => [...prev, { value, label: displayLabel(value) }]); + } + updateCreateDraft(sourceName, (prev) => ({ + ...prev, + inputFields: prev.inputFields.includes(value) + ? prev.inputFields + : [...prev.inputFields, value], + })); + setAddingFieldFor((prev) => ({ ...prev, [sourceName]: false })); + setNewFieldTextFor((prev) => ({ ...prev, [sourceName]: "" })); + }; + + const createExerciseFromUnmapped = async (sourceName: string) => { + const draft = createDrafts[sourceName]; + if (!draft?.name?.trim()) { + setError(`Exercise name is required for "${sourceName}"`); + return; + } + if (!draft.inputFields || draft.inputFields.length === 0) { + setError(`Pick at least one tracked field for "${sourceName}"`); + return; + } + + setCreatingFor((prev) => ({ ...prev, [sourceName]: true })); + setError(null); + try { + const response = await fetch("/api/exercises", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: draft.name.trim(), + type: draft.type, + muscleGroups: draft.muscleGroups, + inputFields: draft.inputFields, + }), + }); + const data = await response.json().catch(() => null); + if (!response.ok) { + throw new Error(data?.error || "Failed to create exercise"); + } + + const createdExercise: ExerciseOption = { + id: data.id, + name: data.name, + }; + setExerciseOptions((prev) => + [...prev, createdExercise].sort((a, b) => a.name.localeCompare(b.name)) + ); + setExerciseLibrary((prev) => [...prev, data as Exercise]); + applyExerciseResolution(sourceName, createdExercise); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create exercise"); + } finally { + setCreatingFor((prev) => ({ ...prev, [sourceName]: false })); + } + }; + + // Upload step + if (workouts.length === 0) { + return ( +
+ {/* Header */} +
+
+ + + +

+ Import Workouts +

+
+
+ + {/* Upload Area */} +
+
+

+ Optional: Pre-create missing exercises from seed JSON +

+

+ Use this first if your historical file references exercises not in your library. +

+ + {seedResult && ( +
+

+ Created: {seedResult.created} · Skipped existing: {seedResult.skipped} +

+ {seedResult.errors.length > 0 && ( +

+ Errors: {seedResult.errors.length} (first: {seedResult.errors[0].name}) +

+ )} +
+ )} + +
+ + + +
+
+ + {error && ( +
+

{error}

+ +
+ )} + +
fileInputRef.current?.click()} + > + + +
+
+ +
+
+

+ Upload CSV File +

+

+ Drag and drop your CSV file here or click to select +

+

+ CSV columns: date, exercise, set, weight, reps, duration_seconds, + distance, distance_unit, calories, rpe, notes, custom_* +

+
+ {loading && ( +

Parsing CSV...

+ )} +
+
+ + {/* Example Format */} +
+

+ CSV Format Example +

+
+              {`date,exercise,set,weight,weight_unit,reps,duration_seconds,distance,distance_unit,calories,rpe,notes,custom_temperature,custom_watts,custom_metrics_json
+2025-02-15,Bench,1,225,lbs,5,,,,,8,good form,,,
+2025-02-15,Bench,2,225,lbs,5,,,,,8,,,,
+2025-02-16,Squat,1,315,lbs,8,,,,,9,30kg per leg,,,
+2025-02-17,Assault Bike,1,,, ,900,5,mi,120,7,,,"{\"resistance\":\"8\"}"
+2025-02-18,Cold Plunge,1,,, ,180,,,,,felt great,50,,`}
+            
+
+
+
+ ); + } + + // Review step + if (!currentWorkout) { + return ( +
+
+
+ + + +

+ Import Complete +

+
+
+ +
+
+ +

+ All Done! +

+

+ {approved} workouts approved, {skipped} skipped +

+ + View Workouts + +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+
+ +

+ Review Workouts +

+
+
+ + {/* Progress Bar */} +
+
+ + {approved} approved, {skipped} skipped, {remaining} remaining + + + {currentIndex + 1} of {workouts.length} + +
+
+
+
+
+
+
+ + {/* Content */} +
+ {error && ( +
+

{error}

+
+ )} + + {/* Unmapped Exercises Resolution */} + {unmapped.length > 0 && ( +
+

+ Unmapped exercises (resolve each one before approval): +

+
+ {unmapped.map((name) => { + const draft = createDrafts[name] || { + name, + type: "other", + muscleGroups: [], + inputFields: ["sets", "reps", "weight", "notes"], + }; + const isCreating = !!creatingFor[name]; + const createOpen = !!showCreateFor[name]; + return ( +
+

{name}

+ +
+ + + +
+ + {createOpen && ( +
+
+ + + updateCreateDraft(name, (prev) => ({ + ...prev, + name: e.target.value, + })) + } + className="w-full bg-zinc-800 border border-zinc-700 rounded px-3 py-2 text-sm text-white" + /> +
+ +
+ +
+ {equipmentOptions.map((option) => ( + + ))} + {addingTypeFor[name] ? ( + + setNewTypeTextFor((prev) => ({ + ...prev, + [name]: e.target.value, + })) + } + onBlur={() => commitTypeFor(name)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitTypeFor(name); + } + }} + placeholder="New" + className="w-20 px-2 py-1 rounded text-xs bg-zinc-800 border border-zinc-600 text-white" + /> + ) : ( + + )} +
+
+ +
+ +
+ {trackingOptions.map((field) => { + const active = draft.inputFields.includes(field.value); + return ( + + ); + })} + {addingFieldFor[name] ? ( + + setNewFieldTextFor((prev) => ({ + ...prev, + [name]: e.target.value, + })) + } + onBlur={() => commitFieldFor(name)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitFieldFor(name); + } + }} + placeholder="New" + className="w-20 px-2 py-1 rounded text-xs bg-zinc-800 border border-zinc-600 text-white" + /> + ) : ( + + )} +
+
+ +
+ +
+ {muscleOptions.map((group) => { + const active = draft.muscleGroups.includes(group); + return ( + + ); + })} + {addingMuscleFor[name] ? ( + + setNewMuscleTextFor((prev) => ({ + ...prev, + [name]: e.target.value, + })) + } + onBlur={() => commitMuscleFor(name)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitMuscleFor(name); + } + }} + placeholder="New" + className="w-20 px-2 py-1 rounded text-xs bg-zinc-800 border border-zinc-600 text-white" + /> + ) : ( + + )} +
+
+ + +
+ )} +
+ ); + })} +
+
+ )} + + {/* Workout Card */} +
+ {/* Date Header */} +
+

+ {new Date(currentWorkout.date).toLocaleDateString("en-US", { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + })} +

+
+ + {/* Exercises */} +
+ {currentWorkout.exercises.map((exercise, exIdx) => ( +
+
+
+

+ {exercise.exerciseName} +

+ {!exercise.exerciseId && ( + + unresolved + + )} +
+ +
+ + {/* Sets Table */} +
+ + + + + + + + + + + + + {exercise.sets.map((set, setIdx) => ( + + + + + + + + + ))} + +
+ Set + + Weight + + Unit + + Reps + + Notes + + Action +
+ + updateSet(exIdx, setIdx, "setNumber", e.target.value) + } + className="w-12 bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-white text-center" + /> + + + updateSet(exIdx, setIdx, "weight", e.target.value) + } + className="w-20 bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-white" + /> + + + + + updateSet(exIdx, setIdx, "reps", e.target.value) + } + className="w-16 bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-white" + /> + + + updateSet(exIdx, setIdx, "notes", e.target.value) + } + className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-white text-sm" + /> + + +
+
+
+ ))} +
+
+ + {/* Action Buttons */} +
+ + +
+ + +
+
+
+
+ ); +} diff --git a/workout-planner/app/main/import/page.tsx b/proof-of-work/app/main/import/page.tsx similarity index 100% rename from workout-planner/app/main/import/page.tsx rename to proof-of-work/app/main/import/page.tsx diff --git a/workout-planner/app/main/layout.tsx b/proof-of-work/app/main/layout.tsx similarity index 100% rename from workout-planner/app/main/layout.tsx rename to proof-of-work/app/main/layout.tsx diff --git a/workout-planner/app/main/navigation.tsx b/proof-of-work/app/main/navigation.tsx similarity index 95% rename from workout-planner/app/main/navigation.tsx rename to proof-of-work/app/main/navigation.tsx index a0d6a2a..0d09801 100644 --- a/workout-planner/app/main/navigation.tsx +++ b/proof-of-work/app/main/navigation.tsx @@ -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 */}