v1.0.0:7 — exercise library cleanup, photo-import removal, AI-section honesty

Library JSON cleanup (proof-of-work/prisma/exercises.seed.json)
  19 exercises corrected:
  - Cycling/Jump Rope/Rowing/Running: type=cardio with proper
    inputFields (duration/distance/calories — no more reps/weight).
  - Walking Lunge/Wall Sit/Headstand/Hip Extension: reclassified
    out of cardio into bodyweight.
  - Plank/Mace warmup/Hollow Body Landmine/Soccer: inputFields
    fixed.
  - Descriptions added for ~10 cryptic exercises (Core, Resistance
    Band, Stir the pot, Slide Board, Neck Circuit, TGU, Captains
    of Crush, etc.).

Reconcile-on-boot (ensureExerciseLibrary.cjs)
  Changed from INSERT-OR-IGNORE to INSERT-OR-UPDATE keyed on
  (userId, name). Existing rows where isCustom = 0 get
  description/type/muscleGroups/inputFields/defaultWeightUnit
  refreshed from the curated JSON. Rows where isCustom = 1 are
  skipped — user customizations always win.

  Verified end-to-end: applied patches propagate to a copy of the
  user's snapshot DB; manually-tampered isCustom=1 rows survive a
  second reconcile pass untouched.

PATCH /api/exercises/[id] flips isCustom -> true on user edits
  Once you edit a library exercise via the in-app UI, the row's
  isCustom flag becomes 1 and the boot-time reconcile leaves it
  alone forever. Closes the only failure mode where a maintainer
  curated-library refresh could overwrite user edits.

Photo-import (Claude vision) removed
  - app/api/workouts/import/route.ts deleted.
  - components/import/WorkoutImportClient.tsx deleted (orphan
    component — wasn't referenced anywhere by the live UI).
  - CSV import (app/main/import → page-csv.tsx →
    /api/workouts/import/save) is unchanged. The save endpoint
    stays — it's used by the CSV flow too.

Settings UI: "Claude AI Integration" section removed
  The toggle + API key input promised "personalized workout
  recommendations" that the codebase never delivered (the only
  actually-wired use was the photo-import we just removed).
  Schema columns User.enableClaudeAI / User.claudeApiKey stay
  as harmless dead fields — they'll get cleaned up or repurposed
  when the model-agnostic AI work lands. The preferences API
  no longer accepts or returns those fields.

No data migration. /data on existing installs is untouched.
v1.0.0:7 promoted to current; :1-:6 in other.
This commit is contained in:
Keysat
2026-05-09 21:24:00 -05:00
parent ffa8e0d480
commit 55c17614b8
11 changed files with 189 additions and 1427 deletions
@@ -132,6 +132,13 @@ export async function PATCH(
if (validated.defaultWeightUnit !== undefined) if (validated.defaultWeightUnit !== undefined)
data.defaultWeightUnit = validated.defaultWeightUnit; data.defaultWeightUnit = validated.defaultWeightUnit;
// Flip isCustom -> true on any user edit. The boot-time
// ensureExerciseLibrary reconciliation only updates rows where
// isCustom = 0, so this preserves the user's intent: once they've
// edited a library exercise, the maintainer can no longer
// overwrite their changes via a curated-library refresh.
data.isCustom = true;
const updated = await prisma.exercise.update({ const updated = await prisma.exercise.update({
where: { id: params.id }, where: { id: params.id },
data, data,
+12 -24
View File
@@ -6,13 +6,14 @@ import { z } from "zod";
const PreferencesSchema = z.object({ const PreferencesSchema = z.object({
theme: z.enum(["light", "dark", "system"]).optional(), theme: z.enum(["light", "dark", "system"]).optional(),
defaultWeightUnit: z.enum(["lbs", "kg"]).optional(), defaultWeightUnit: z.enum(["lbs", "kg"]).optional(),
enableClaudeAI: z.boolean().optional(),
claudeApiKey: z.string().optional(),
}); });
/** /**
* GET /api/preferences * GET /api/preferences
* Get user preferences * Get user preferences. Strips the dead Claude AI fields
* (enableClaudeAI / claudeApiKey) — those columns still exist in the
* schema but are slated for replacement by the model-agnostic AI work
* (Option 3 / future). Don't reintroduce them in the response.
*/ */
export async function GET(_request: NextRequest) { export async function GET(_request: NextRequest) {
try { try {
@@ -26,24 +27,18 @@ export async function GET(_request: NextRequest) {
}); });
if (!preferences) { if (!preferences) {
// Create default preferences
preferences = await prisma.userPreferences.create({ preferences = await prisma.userPreferences.create({
data: { data: {
userId: user.id, userId: user.id,
theme: "system", theme: "system",
defaultWeightUnit: "lbs", defaultWeightUnit: "lbs",
defaultRestSeconds: 90, defaultRestSeconds: 90,
enableClaudeAI: false,
}, },
}); });
} }
// Don't return API key in response const { claudeApiKey, enableClaudeAI, ...safe } = preferences;
const { claudeApiKey, ...safePreferences } = preferences; return NextResponse.json(safe);
return NextResponse.json({
...safePreferences,
claudeApiKey: claudeApiKey ? "***" : undefined,
});
} catch (error) { } catch (error) {
console.error("GET /api/preferences error:", error); console.error("GET /api/preferences error:", error);
return NextResponse.json( return NextResponse.json(
@@ -55,7 +50,9 @@ export async function GET(_request: NextRequest) {
/** /**
* POST /api/preferences * POST /api/preferences
* Update user preferences * Update user preferences. Only the fields in `PreferencesSchema` are
* accepted; anything else (including the dead Claude AI fields) is
* silently dropped at the Zod boundary.
*/ */
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -67,7 +64,6 @@ export async function POST(request: NextRequest) {
const body = await request.json(); const body = await request.json();
const validated = PreferencesSchema.parse(body); const validated = PreferencesSchema.parse(body);
// Get or create preferences
let preferences = await prisma.userPreferences.findUnique({ let preferences = await prisma.userPreferences.findUnique({
where: { userId: user.id }, where: { userId: user.id },
}); });
@@ -86,23 +82,15 @@ export async function POST(request: NextRequest) {
}); });
} }
// Don't return API key in response const { claudeApiKey, enableClaudeAI, ...safe } = preferences;
const { claudeApiKey, ...safePreferences } = preferences; return NextResponse.json(safe);
return NextResponse.json({
...safePreferences,
claudeApiKey: claudeApiKey ? "***" : undefined,
});
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
return NextResponse.json( return NextResponse.json(
{ { error: "Validation error", details: error.errors },
error: "Validation error",
details: error.errors,
},
{ status: 400 } { status: 400 }
); );
} }
console.error("POST /api/preferences error:", error); console.error("POST /api/preferences error:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Internal server error" }, { error: "Internal server error" },
@@ -1,216 +0,0 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const importSchema = z.object({
images: z.array(z.string()).min(1, "At least one image is required"),
});
const CLAUDE_API_URL = "https://api.anthropic.com/v1/messages";
const SYSTEM_PROMPT = `You are analyzing photos of handwritten workout logs, Apple Notes, or other workout records. Extract all workout data you can find.
IMPORTANT RULES:
- If you can identify a date for the workout, include it as an ISO date string (YYYY-MM-DD)
- If no date is visible, set date to null
- Extract exercise names as closely as written
- For each exercise, extract all sets with whatever data is visible (reps, weight, duration, etc.)
- If you're unsure about an exercise name or value, set "uncertain": true and explain in "uncertainReason"
- Weight units: assume lbs unless kg or kilograms is explicitly written
- For cardio exercises (running, biking, rowing, assault bike, jump rope, etc.), look for duration, distance, and calories
- Be conservative — only include data you can actually read
Return ONLY valid JSON with this exact structure (no markdown, no code fences):
{
"workouts": [
{
"date": "2025-01-15" or null,
"name": "Upper Body" or null,
"notes": "any overall notes" or null,
"exercises": [
{
"name": "Bench Press",
"type": "barbell" | "dumbbell" | "machine" | "cable" | "bodyweight" | "cardio" | "kettlebell" | "other",
"sets": [
{
"reps": 8,
"weight": 225,
"weightUnit": "lbs",
"durationSeconds": null,
"distance": null,
"distanceUnit": null,
"calories": null,
"rpe": null,
"notes": null
}
],
"notes": null,
"uncertain": false,
"uncertainReason": null
}
]
}
],
"confidence": "high" | "medium" | "low",
"warnings": ["list any legibility issues or assumptions made"]
}`;
export async function POST(request: Request) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Get user's Claude API key from preferences
const preferences = await prisma.userPreferences.findUnique({
where: { userId: user.id },
});
if (!preferences?.enableClaudeAI || !preferences?.claudeApiKey) {
return NextResponse.json(
{
error: "Claude AI is not configured. Please add your API key in Settings.",
code: "NO_API_KEY",
},
{ status: 400 }
);
}
const body = await request.json();
const validated = importSchema.parse(body);
// Build Claude API request with vision
const content: any[] = [
{
type: "text",
text: "Please analyze the following workout log image(s) and extract all workout data. Return ONLY valid JSON.",
},
];
// Add each image
for (const imageData of validated.images) {
// imageData could be a data URL or raw base64
let base64 = imageData;
let mediaType = "image/jpeg";
if (imageData.startsWith("data:")) {
const match = imageData.match(/^data:(image\/\w+);base64,(.+)$/);
if (match) {
mediaType = match[1];
base64 = match[2];
}
}
content.push({
type: "image",
source: {
type: "base64",
media_type: mediaType,
data: base64,
},
});
}
// Call Claude API
const claudeResponse = await fetch(CLAUDE_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": preferences.claudeApiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "claude-sonnet-4-20250514",
max_tokens: 4096,
system: SYSTEM_PROMPT,
messages: [
{
role: "user",
content,
},
],
}),
});
if (!claudeResponse.ok) {
const errorBody = await claudeResponse.text();
console.error("Claude API error:", claudeResponse.status, errorBody);
if (claudeResponse.status === 401) {
return NextResponse.json(
{ error: "Invalid Claude API key. Please check your key in Settings.", code: "INVALID_KEY" },
{ status: 400 }
);
}
if (claudeResponse.status === 429) {
return NextResponse.json(
{ error: "Claude API rate limit reached. Please try again in a moment.", code: "RATE_LIMITED" },
{ status: 429 }
);
}
return NextResponse.json(
{ error: "Failed to analyze images. Please try again.", code: "API_ERROR" },
{ status: 502 }
);
}
const claudeData = await claudeResponse.json();
// Extract text content from Claude's response
const textContent = claudeData.content?.find((c: any) => c.type === "text");
if (!textContent?.text) {
return NextResponse.json(
{ error: "No response from Claude. Please try again.", code: "EMPTY_RESPONSE" },
{ status: 502 }
);
}
// Parse the JSON response
let parsed;
try {
// Try to extract JSON from the response (Claude might wrap it in code fences)
let jsonText = textContent.text.trim();
const jsonMatch = jsonText.match(/```(?:json)?\s*([\s\S]*?)```/);
if (jsonMatch) {
jsonText = jsonMatch[1].trim();
}
parsed = JSON.parse(jsonText);
} catch {
console.error("Failed to parse Claude response:", textContent.text);
return NextResponse.json(
{
error: "Could not parse the workout data. The image may be too unclear.",
code: "PARSE_ERROR",
raw: textContent.text.substring(0, 500),
},
{ status: 422 }
);
}
// Validate basic structure
if (!parsed.workouts || !Array.isArray(parsed.workouts)) {
return NextResponse.json(
{ error: "Invalid response structure from Claude.", code: "INVALID_STRUCTURE" },
{ status: 422 }
);
}
return NextResponse.json(parsed);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Invalid request data", details: error.errors },
{ status: 400 }
);
}
console.error("POST /api/workouts/import error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
}
-1
View File
@@ -62,7 +62,6 @@ export async function signupAction(
theme: 'system', theme: 'system',
defaultWeightUnit: 'lbs', defaultWeightUnit: 'lbs',
defaultRestSeconds: 90, defaultRestSeconds: 90,
enableClaudeAI: false,
}, },
}, },
}, },
File diff suppressed because it is too large Load Diff
@@ -4,8 +4,6 @@ import { useState, useEffect, useRef } from "react";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import { import {
Loader2, Loader2,
Eye,
EyeOff,
Upload, Upload,
Download, Download,
AlertTriangle, AlertTriangle,
@@ -17,20 +15,16 @@ interface UserPreferences {
theme: string; theme: string;
defaultWeightUnit: string; defaultWeightUnit: string;
defaultRestSeconds: number; defaultRestSeconds: number;
enableClaudeAI: boolean;
claudeApiKey?: string;
} }
export default function SettingsForm({ user }: { user: User }) { export default function SettingsForm({ user }: { user: User }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
const [preferences, setPreferences] = useState<UserPreferences>({ const [preferences, setPreferences] = useState<UserPreferences>({
theme: "system", theme: "system",
defaultWeightUnit: "lbs", defaultWeightUnit: "lbs",
defaultRestSeconds: 90, defaultRestSeconds: 90,
enableClaudeAI: false,
}); });
useEffect(() => { useEffect(() => {
@@ -179,90 +173,12 @@ export default function SettingsForm({ user }: { user: User }) {
</div> </div>
</div> </div>
{/* Claude AI Section */} {/* AI integration section deliberately removed — was a misleading
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6"> placeholder for "Claude AI workout recommendations" that the
<h2 className="text-lg font-bold text-white mb-4"> codebase never actually delivered. A real model-agnostic AI
Claude AI Integration integration (Claude / OpenAI / Gemini / self-hosted Ollama) is
</h2> on the roadmap; the underlying enableClaudeAI/claudeApiKey
<p className="text-sm text-zinc-500 mb-4"> schema columns stay as harmless dead fields until that lands. */}
Enable Claude AI to get personalized workout recommendations and
program optimization suggestions.
</p>
<div className="space-y-4">
{/* Enable Toggle */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-zinc-300">
Enable Claude AI
</span>
<button
type="button"
onClick={() =>
setPreferences((prev) => ({
...prev,
enableClaudeAI: !prev.enableClaudeAI,
}))
}
className={`relative w-11 h-6 rounded-full transition ${
preferences.enableClaudeAI ? "bg-white" : "bg-zinc-700"
}`}
>
<span
className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full transition-transform ${
preferences.enableClaudeAI
? "translate-x-5 bg-black"
: "translate-x-0 bg-zinc-400"
}`}
/>
</button>
</div>
{/* API Key Input - Only show if enabled */}
{preferences.enableClaudeAI && (
<div>
<label className="block text-sm font-medium text-zinc-400 mb-1">
Claude API Key
</label>
<div className="relative">
<input
type={showApiKey ? "text" : "password"}
value={preferences.claudeApiKey || ""}
onChange={(e) =>
setPreferences((prev) => ({
...prev,
claudeApiKey: e.target.value,
}))
}
placeholder="sk-..."
className="w-full px-3 py-2 border border-zinc-700 rounded-lg bg-zinc-800 text-white focus:outline-none focus:ring-2 focus:ring-white/20 pr-10"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-3 top-2.5 text-zinc-500 hover:text-zinc-300"
>
{showApiKey ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
<p className="text-xs text-zinc-600 mt-1">
Get your API key from{" "}
<a
href="https://console.anthropic.com"
target="_blank"
rel="noopener noreferrer"
className="text-zinc-400 hover:text-white underline"
>
console.anthropic.com
</a>
</p>
</div>
)}
</div>
</div>
{/* Save Button */} {/* Save Button */}
<button <button
+41 -26
View File
@@ -1,26 +1,33 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* ensureExerciseLibrary — runs at every container boot from * ensureExerciseLibrary — runs at every container boot from
* docker_entrypoint.sh. Inserts every exercise from * docker_entrypoint.sh. Reconciles every existing user's exercise
* /app/prisma/exercises.seed.json into the Exercise table for every * library with the curated /app/prisma/exercises.seed.json:
* existing user, using `INSERT OR IGNORE` keyed on (userId, name).
* *
* Properties: * - INSERT new library entries (rows that don't exist yet for this
* - Multi-user-aware. Iterates all rows in `User` so every user on the * user) — same as previous behavior.
* instance gets the same curated library. * - UPDATE existing rows where `isCustom = 0` to match the curated
* - Idempotent. Re-running is a no-op for exercises that already exist. * values (description, type, muscleGroups, inputFields,
* - Additive only. Never deletes or updates existing rows. Users keep * defaultWeightUnit). This propagates maintainer-side fixes
* their own custom exercises (isCustom=true) untouched, and existing * (e.g. correcting cardio inputFields) to existing installs
* library entries are not reshaped if the maintainer changed the * without overwriting user-customized rows.
* description/inputFields/etc. for them downstream. * - SKIP rows where `isCustom = 1` — user customizations win, end
* - Cheap. Wrapped in a single transaction; ~164 rows x N users runs in * of story. PATCH /api/exercises/[id] flips isCustom to 1 on
* well under a second. * any user edit so this rule is honored automatically.
*
* Multi-user-aware: iterates all rows in `User`. Idempotent.
* Additive only — exercises removed from the curated JSON are NOT
* deleted from existing installs (users may have logged sets
* against them).
*
* Cheap: a single transaction, ~164 rows × N users runs in well
* under a second.
* *
* Invoked from docker_entrypoint.sh after the schema-compat ALTERs: * 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 * 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 * Uses the sqlite3 CLI (already in the runner image) instead of a
* Node SQLite binding so we don't have to ship a native dep. * Node SQLite binding so we don't ship a native dep.
*/ */
const fs = require('fs'); const fs = require('fs');
@@ -55,28 +62,24 @@ if (!Array.isArray(library) || library.length === 0) {
process.exit(0); process.exit(0);
} }
// Get all user ids
const usersRaw = execFileSync('sqlite3', [dbPath, 'SELECT id FROM User;'], { const usersRaw = execFileSync('sqlite3', [dbPath, 'SELECT id FROM User;'], {
encoding: 'utf8', encoding: 'utf8',
}); });
const userIds = usersRaw.split('\n').map((s) => s.trim()).filter(Boolean); const userIds = usersRaw.split('\n').map((s) => s.trim()).filter(Boolean);
if (userIds.length === 0) { if (userIds.length === 0) {
console.error('[ensure-library] no users yet; skipping (will run on next boot after a user exists)'); console.error('[ensure-library] no users yet; skipping');
process.exit(0); process.exit(0);
} }
// Quote a string for safe use as a SQLite single-quoted literal.
const q = (s) => `'${String(s).replace(/'/g, "''")}'`; 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 newId = () => 'c' + crypto.randomBytes(12).toString('hex');
const stmts = ['BEGIN;']; const stmts = ['BEGIN;'];
let inserts = 0; let upserts = 0;
for (const userId of userIds) { for (const userId of userIds) {
for (const ex of library) { for (const ex of library) {
inserts++; upserts++;
const muscleGroups = q(JSON.stringify(ex.muscleGroups || [])); const muscleGroups = q(JSON.stringify(ex.muscleGroups || []));
const inputFields = q( const inputFields = q(
JSON.stringify(ex.inputFields || ['sets', 'reps', 'weight']), JSON.stringify(ex.inputFields || ['sets', 'reps', 'weight']),
@@ -85,12 +88,23 @@ for (const userId of userIds) {
ex.defaultWeightUnit == null ? 'NULL' : q(ex.defaultWeightUnit); ex.defaultWeightUnit == null ? 'NULL' : q(ex.defaultWeightUnit);
const description = ex.description == null ? 'NULL' : q(ex.description); const description = ex.description == null ? 'NULL' : q(ex.description);
// INSERT a new row for this (userId, name) pair, or UPDATE the
// existing one IF it's not user-customized. The WHERE clause on
// the DO UPDATE side is the key piece — skips rows the user has
// edited (isCustom=1). SQLite supports this since 3.24 (2018).
stmts.push( stmts.push(
`INSERT OR IGNORE INTO Exercise ` + `INSERT INTO Exercise ` +
`(id, userId, name, description, muscleGroups, type, inputFields, defaultWeightUnit, isCustom, createdAt) ` + `(id, userId, name, description, muscleGroups, type, inputFields, defaultWeightUnit, isCustom, createdAt) ` +
`VALUES (${q(newId())}, ${q(userId)}, ${q(ex.name)}, ${description}, ` + `VALUES (${q(newId())}, ${q(userId)}, ${q(ex.name)}, ${description}, ` +
`${muscleGroups}, ${q(ex.type)}, ${inputFields}, ${defaultWeightUnit}, ` + `${muscleGroups}, ${q(ex.type)}, ${inputFields}, ${defaultWeightUnit}, ` +
`0, CURRENT_TIMESTAMP);`, `0, CURRENT_TIMESTAMP) ` +
`ON CONFLICT(userId, name) DO UPDATE SET ` +
`description = excluded.description, ` +
`muscleGroups = excluded.muscleGroups, ` +
`type = excluded.type, ` +
`inputFields = excluded.inputFields, ` +
`defaultWeightUnit = excluded.defaultWeightUnit ` +
`WHERE Exercise.isCustom = 0;`,
); );
} }
} }
@@ -98,6 +112,7 @@ stmts.push('COMMIT;');
execFileSync('sqlite3', [dbPath], { input: stmts.join('\n') }); execFileSync('sqlite3', [dbPath], { input: stmts.join('\n') });
console.error( console.error(
`[ensure-library] processed ${userIds.length} user(s) x ${library.length} exercise(s) ` + `[ensure-library] reconciled ${userIds.length} user(s) x ${library.length} ` +
`(${inserts} INSERT OR IGNORE statements)`, `exercise(s) (${upserts} INSERT-OR-UPDATE statements; user-customized ` +
`rows skipped)`,
); );
+45 -46
View File
@@ -487,7 +487,7 @@
}, },
{ {
"name": "Core", "name": "Core",
"description": null, "description": "Generic core circuit — note specific exercises (crunches, bicycle, decline situps, etc.) in the notes field.",
"type": "bodyweight", "type": "bodyweight",
"muscleGroups": [], "muscleGroups": [],
"inputFields": [ "inputFields": [
@@ -679,7 +679,7 @@
}, },
{ {
"name": "Plank", "name": "Plank",
"description": "Bodyweight plank for core stability", "description": "Front plank — log hold duration; add weight for plate-loaded variations.",
"type": "bodyweight", "type": "bodyweight",
"muscleGroups": [ "muscleGroups": [
"core", "core",
@@ -687,7 +687,7 @@
], ],
"inputFields": [ "inputFields": [
"sets", "sets",
"reps", "duration",
"weight" "weight"
], ],
"defaultWeightUnit": null "defaultWeightUnit": null
@@ -725,7 +725,7 @@
}, },
{ {
"name": "Resistance Band", "name": "Resistance Band",
"description": null, "description": "Generic resistance band exercise — log specific motion (pull-apart, face pull, etc.) in notes.",
"type": "bodyweight", "type": "bodyweight",
"muscleGroups": [], "muscleGroups": [],
"inputFields": [ "inputFields": [
@@ -1023,83 +1023,81 @@
}, },
{ {
"name": "Cycling", "name": "Cycling",
"description": "Cycling for lower body cardio", "description": "Stationary or outdoor cycling — log distance + duration as a single set.",
"type": "cardio", "type": "cardio",
"muscleGroups": [ "muscleGroups": [
"cardio" "cardio"
], ],
"inputFields": [ "inputFields": [
"sets", "sets",
"reps", "duration",
"weight" "distance",
"calories"
], ],
"defaultWeightUnit": null "defaultWeightUnit": null
}, },
{ {
"name": "Headstand", "name": "Headstand",
"description": null, "description": "Inverted headstand hold — log duration per set.",
"type": "cardio", "type": "bodyweight",
"muscleGroups": [], "muscleGroups": [],
"inputFields": [ "inputFields": [
"sets", "sets",
"reps", "duration"
"duration",
"notes"
], ],
"defaultWeightUnit": null "defaultWeightUnit": null
}, },
{ {
"name": "Hip Extension", "name": "Hip Extension",
"description": null, "description": "Glute hip extension on bench or GHD — log reps per set.",
"type": "cardio", "type": "bodyweight",
"muscleGroups": [], "muscleGroups": [],
"inputFields": [ "inputFields": [
"sets", "sets",
"reps", "reps"
"duration",
"notes"
], ],
"defaultWeightUnit": null "defaultWeightUnit": null
}, },
{ {
"name": "Jump Rope", "name": "Jump Rope",
"description": "Jump rope for cardio and footwork", "description": "Jump rope intervals — log duration per set.",
"type": "cardio", "type": "cardio",
"muscleGroups": [ "muscleGroups": [
"cardio" "cardio"
], ],
"inputFields": [ "inputFields": [
"sets", "sets",
"reps", "duration"
"weight"
], ],
"defaultWeightUnit": null "defaultWeightUnit": null
}, },
{ {
"name": "Rowing", "name": "Rowing",
"description": "Rowing machine for full-body cardio", "description": "Rowing erg — log distance, duration, calories per piece.",
"type": "cardio", "type": "cardio",
"muscleGroups": [ "muscleGroups": [
"cardio" "cardio"
], ],
"inputFields": [ "inputFields": [
"sets", "sets",
"reps", "duration",
"weight" "distance",
"calories"
], ],
"defaultWeightUnit": null "defaultWeightUnit": null
}, },
{ {
"name": "Running", "name": "Running",
"description": "Running for cardiovascular endurance", "description": "Running outdoor or treadmill — log distance + duration per piece.",
"type": "cardio", "type": "cardio",
"muscleGroups": [ "muscleGroups": [
"cardio" "cardio"
], ],
"inputFields": [ "inputFields": [
"sets", "sets",
"reps", "duration",
"weight" "distance",
"calories"
], ],
"defaultWeightUnit": null "defaultWeightUnit": null
}, },
@@ -1121,27 +1119,24 @@
}, },
{ {
"name": "Walking Lunge", "name": "Walking Lunge",
"description": null, "description": "Alternating walking lunges — bodyweight or with dumbbells in hand.",
"type": "cardio", "type": "bodyweight",
"muscleGroups": [], "muscleGroups": [],
"inputFields": [ "inputFields": [
"sets", "sets",
"reps", "reps",
"weight", "weight"
"duration",
"notes"
], ],
"defaultWeightUnit": null "defaultWeightUnit": null
}, },
{ {
"name": "Wall Sit", "name": "Wall Sit",
"description": null, "description": "Isometric wall sit — log hold duration per set.",
"type": "cardio", "type": "bodyweight",
"muscleGroups": [], "muscleGroups": [],
"inputFields": [ "inputFields": [
"sets", "sets",
"duration", "duration"
"notes"
], ],
"defaultWeightUnit": null "defaultWeightUnit": null
}, },
@@ -1566,7 +1561,7 @@
}, },
{ {
"name": "Stir the pot", "name": "Stir the pot",
"description": null, "description": "Stability-ball plank with circular forearm motion (\"stirring the pot\").",
"type": "exercise ball", "type": "exercise ball",
"muscleGroups": [ "muscleGroups": [
"abs" "abs"
@@ -1579,7 +1574,7 @@
}, },
{ {
"name": "Captains of Crush", "name": "Captains of Crush",
"description": null, "description": "Hand gripper for forearm/grip strength — record gripper level (1, 1.5, 2, etc.) in notes.",
"type": "grippers", "type": "grippers",
"muscleGroups": [ "muscleGroups": [
"forearms" "forearms"
@@ -1769,7 +1764,7 @@
}, },
{ {
"name": "TGU", "name": "TGU",
"description": null, "description": "Turkish Get-Up — full ground-to-standing kettlebell movement; log kettlebell weight.",
"type": "kettlebell", "type": "kettlebell",
"muscleGroups": [ "muscleGroups": [
"full body", "full body",
@@ -1800,14 +1795,16 @@
}, },
{ {
"name": "Mace warmup", "name": "Mace warmup",
"description": null, "description": "Steel mace warm-up flow — 360s, figure-8s, etc. Log mace weight.",
"type": "mace bar", "type": "mace bar",
"muscleGroups": [ "muscleGroups": [
"shoulders", "shoulders",
"core" "core"
], ],
"inputFields": [ "inputFields": [
"sets" "sets",
"reps",
"weight"
], ],
"defaultWeightUnit": null "defaultWeightUnit": null
}, },
@@ -2030,11 +2027,12 @@
}, },
{ {
"name": "Hollow Body Landmine", "name": "Hollow Body Landmine",
"description": null, "description": "Landmine press performed in hollow body position — log reps + barbell weight.",
"type": "other", "type": "other",
"muscleGroups": [], "muscleGroups": [],
"inputFields": [ "inputFields": [
"sets", "sets",
"reps",
"weight" "weight"
], ],
"defaultWeightUnit": null "defaultWeightUnit": null
@@ -2090,7 +2088,7 @@
}, },
{ {
"name": "Neck Circuit", "name": "Neck Circuit",
"description": null, "description": "Series of neck flexion/extension/lateral isometric movements.",
"type": "other", "type": "other",
"muscleGroups": [ "muscleGroups": [
"neck" "neck"
@@ -2177,7 +2175,7 @@
}, },
{ {
"name": "Slide Board", "name": "Slide Board",
"description": null, "description": "Sliding lateral skating-style movement on a slick slide board.",
"type": "other", "type": "other",
"muscleGroups": [], "muscleGroups": [],
"inputFields": [ "inputFields": [
@@ -2188,15 +2186,16 @@
}, },
{ {
"name": "Soccer", "name": "Soccer",
"description": null, "description": "Pickup or training soccer — log overall duration + optional distance.",
"type": "other", "type": "other",
"muscleGroups": [ "muscleGroups": [
"cardio" "cardio"
], ],
"inputFields": [ "inputFields": [
"calories", "sets",
"duration",
"distance", "distance",
"duration" "calories"
], ],
"defaultWeightUnit": null "defaultWeightUnit": null
}, },
+13 -6
View File
@@ -141,12 +141,19 @@ if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then
fi fi
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Step 3 — ensure curated exercise library for every user (multi-user-aware). # Step 3 — reconcile curated exercise library for every user
# New entries shipped in /app/prisma/exercises.seed.json appear on every boot. # (multi-user-aware). As of v1.0.0:7 this is INSERT-or-UPDATE rather than
# `INSERT OR IGNORE` keyed on (userId, name) so we never overwrite a user's # INSERT-or-IGNORE: existing rows where isCustom = 0 get refreshed from
# own custom exercises. Designed to be additive only — exercises removed from # /app/prisma/exercises.seed.json so maintainer-side fixes (e.g. correct
# the curated JSON are not deleted from existing installs (users may have # inputFields for cardio) propagate to existing installs. Rows where
# logged sets against them). # isCustom = 1 are skipped — user customizations win.
#
# PATCH /api/exercises/[id] flips isCustom to 1 on any user edit, so the
# moment you change a library exercise via the in-app UI it stops getting
# overwritten on subsequent boots.
#
# Additive on names: exercises removed from the curated JSON are NOT
# deleted from existing installs (users may have logged sets against them).
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
if [ -f "$LIBRARY_JSON_PATH" ] && [ -f "$DB_PATH" ]; then if [ -f "$LIBRARY_JSON_PATH" ] && [ -f "$DB_PATH" ]; then
log "ensuring curated exercise library is present for every user" log "ensuring curated exercise library is present for every user"
+7 -7
View File
@@ -5,6 +5,7 @@ import { v_1_0_0_3 } from './v1.0.0.3'
import { v_1_0_0_4 } from './v1.0.0.4' import { v_1_0_0_4 } from './v1.0.0.4'
import { v_1_0_0_5 } from './v1.0.0.5' import { v_1_0_0_5 } from './v1.0.0.5'
import { v_1_0_0_6 } from './v1.0.0.6' import { v_1_0_0_6 } from './v1.0.0.6'
import { v_1_0_0_7 } from './v1.0.0.7'
/** /**
* Version graph for the `proof-of-work` package. * Version graph for the `proof-of-work` package.
@@ -12,14 +13,13 @@ import { v_1_0_0_6 } from './v1.0.0.6'
* v1.0.0:1 — initial release, seeded cutover from `workout-log`. * v1.0.0:1 — initial release, seeded cutover from `workout-log`.
* v1.0.0:2 — CSP fix. * v1.0.0:2 — CSP fix.
* v1.0.0:3 — post-cutover seed strip. * v1.0.0:3 — post-cutover seed strip.
* v1.0.0:4 — removes default admin@local credentials; operator must * v1.0.0:4 — removes default admin@local credentials.
* run StartOS Action to bootstrap the first admin.
* v1.0.0:5 — internal cleanup (caloriesBurned raw-SQL workaround). * v1.0.0:5 — internal cleanup (caloriesBurned raw-SQL workaround).
* v1.0.0:6 — paginate workout history (infinite scroll); removes * v1.0.0:6 — paginate workout history (infinite scroll).
* invisible 50-row caps on the clock-button popup and * v1.0.0:7 — exercise library cleanup, photo-import removal,
* the /main/workouts page. * UI honesty about AI.
*/ */
export const versionGraph = VersionGraph.of({ export const versionGraph = VersionGraph.of({
current: v_1_0_0_6, current: v_1_0_0_7,
other: [v_1_0_0_1, v_1_0_0_2, v_1_0_0_3, v_1_0_0_4, v_1_0_0_5], other: [v_1_0_0_1, v_1_0_0_2, v_1_0_0_3, v_1_0_0_4, v_1_0_0_5, v_1_0_0_6],
}) })
+58
View File
@@ -0,0 +1,58 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
/**
* v1.0.0:7 — exercise library cleanup, photo-import removal,
* UI honesty about AI.
*
* Library cleanup
* - Cycling, Jump Rope, Rowing, Running: type=cardio with the
* correct inputFields (duration, distance, calories — no more
* reps/weight where they don't apply).
* - Walking Lunge, Wall Sit, Headstand, Hip Extension:
* reclassified out of "cardio" into bodyweight (they aren't
* aerobic conditioning).
* - Plank, Mace warmup, Hollow Body Landmine, Soccer:
* inputFields fixed.
* - Descriptions added for ~10 previously-cryptic exercises:
* Core, Resistance Band, Stir the pot, Slide Board,
* Neck Circuit, TGU, Captains of Crush, plus new descriptions
* for the cardio + reclassified entries above.
*
* Reconcile-on-boot
* - ensureExerciseLibrary.cjs is now INSERT-or-UPDATE instead of
* INSERT-or-IGNORE. Existing exercise rows where isCustom = 0
* get their description/type/muscleGroups/inputFields/
* defaultWeightUnit refreshed from the curated JSON on every
* boot. Rows with isCustom = 1 are skipped — the user's
* customizations always win.
* - PATCH /api/exercises/[id] now flips isCustom -> true on any
* user edit. So the moment you edit a library exercise via the
* in-app UI, it stops getting overwritten by future curated-
* library refreshes.
*
* Photo-import (Claude vision) removed
* - The /api/workouts/import endpoint that uploaded photos to
* Claude is gone, along with the orphan WorkoutImportClient
* component that called it. CSV import (the actually-used flow
* at /main/import) is unchanged.
* - The "Claude AI Integration" section in Settings has been
* removed — it promised "personalized workout recommendations"
* that never existed and only enabled the photo-import
* feature, which is also gone.
* - Schema columns User.enableClaudeAI / User.claudeApiKey stay
* as harmless dead fields. They'll be removed (or repurposed)
* when the model-agnostic AI work lands.
*
* No data migration. /data on existing installs is untouched.
*/
export const v_1_0_0_7 = VersionInfo.of({
version: '1.0.0:7',
releaseNotes: {
en_US:
'Exercise library cleanup: 19 exercises got correct inputFields / type / descriptions (Cycling/Rowing/Running/etc. now properly track duration+distance instead of reps+weight). Library reconciliation runs on every boot — maintainer-side fixes propagate to existing installs without overwriting your edits. The Claude photo-import feature and the misleading "Claude AI Integration" Settings section are gone; a real model-agnostic AI integration (with self-hosted Ollama support) is on the roadmap as its own release.',
},
migrations: {
up: async () => {},
down: IMPOSSIBLE,
},
})