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:
@@ -132,6 +132,13 @@ export async function PATCH(
|
||||
if (validated.defaultWeightUnit !== undefined)
|
||||
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({
|
||||
where: { id: params.id },
|
||||
data,
|
||||
|
||||
@@ -6,13 +6,14 @@ import { z } from "zod";
|
||||
const PreferencesSchema = z.object({
|
||||
theme: z.enum(["light", "dark", "system"]).optional(),
|
||||
defaultWeightUnit: z.enum(["lbs", "kg"]).optional(),
|
||||
enableClaudeAI: z.boolean().optional(),
|
||||
claudeApiKey: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
try {
|
||||
@@ -26,24 +27,18 @@ export async function GET(_request: NextRequest) {
|
||||
});
|
||||
|
||||
if (!preferences) {
|
||||
// Create default preferences
|
||||
preferences = await prisma.userPreferences.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
theme: "system",
|
||||
defaultWeightUnit: "lbs",
|
||||
defaultRestSeconds: 90,
|
||||
enableClaudeAI: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Don't return API key in response
|
||||
const { claudeApiKey, ...safePreferences } = preferences;
|
||||
return NextResponse.json({
|
||||
...safePreferences,
|
||||
claudeApiKey: claudeApiKey ? "***" : undefined,
|
||||
});
|
||||
const { claudeApiKey, enableClaudeAI, ...safe } = preferences;
|
||||
return NextResponse.json(safe);
|
||||
} catch (error) {
|
||||
console.error("GET /api/preferences error:", error);
|
||||
return NextResponse.json(
|
||||
@@ -55,7 +50,9 @@ export async function GET(_request: NextRequest) {
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
try {
|
||||
@@ -67,7 +64,6 @@ export async function POST(request: NextRequest) {
|
||||
const body = await request.json();
|
||||
const validated = PreferencesSchema.parse(body);
|
||||
|
||||
// Get or create preferences
|
||||
let preferences = await prisma.userPreferences.findUnique({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
@@ -86,23 +82,15 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Don't return API key in response
|
||||
const { claudeApiKey, ...safePreferences } = preferences;
|
||||
return NextResponse.json({
|
||||
...safePreferences,
|
||||
claudeApiKey: claudeApiKey ? "***" : undefined,
|
||||
});
|
||||
const { claudeApiKey, enableClaudeAI, ...safe } = preferences;
|
||||
return NextResponse.json(safe);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Validation error",
|
||||
details: error.errors,
|
||||
},
|
||||
{ error: "Validation error", details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.error("POST /api/preferences error:", error);
|
||||
return NextResponse.json(
|
||||
{ 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,6 @@ export async function signupAction(
|
||||
theme: 'system',
|
||||
defaultWeightUnit: 'lbs',
|
||||
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 {
|
||||
Loader2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Upload,
|
||||
Download,
|
||||
AlertTriangle,
|
||||
@@ -17,20 +15,16 @@ interface UserPreferences {
|
||||
theme: string;
|
||||
defaultWeightUnit: string;
|
||||
defaultRestSeconds: number;
|
||||
enableClaudeAI: boolean;
|
||||
claudeApiKey?: string;
|
||||
}
|
||||
|
||||
export default function SettingsForm({ user }: { user: User }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [preferences, setPreferences] = useState<UserPreferences>({
|
||||
theme: "system",
|
||||
defaultWeightUnit: "lbs",
|
||||
defaultRestSeconds: 90,
|
||||
enableClaudeAI: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -179,90 +173,12 @@ export default function SettingsForm({ user }: { user: User }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Claude AI Section */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-6">
|
||||
<h2 className="text-lg font-bold text-white mb-4">
|
||||
Claude AI Integration
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-500 mb-4">
|
||||
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>
|
||||
{/* AI integration section deliberately removed — was a misleading
|
||||
placeholder for "Claude AI workout recommendations" that the
|
||||
codebase never actually delivered. A real model-agnostic AI
|
||||
integration (Claude / OpenAI / Gemini / self-hosted Ollama) is
|
||||
on the roadmap; the underlying enableClaudeAI/claudeApiKey
|
||||
schema columns stay as harmless dead fields until that lands. */}
|
||||
|
||||
{/* Save Button */}
|
||||
<button
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ensureExerciseLibrary — runs at every container boot from
|
||||
* docker_entrypoint.sh. Inserts every exercise from
|
||||
* /app/prisma/exercises.seed.json into the Exercise table for every
|
||||
* existing user, using `INSERT OR IGNORE` keyed on (userId, name).
|
||||
* docker_entrypoint.sh. Reconciles every existing user's exercise
|
||||
* library with the curated /app/prisma/exercises.seed.json:
|
||||
*
|
||||
* Properties:
|
||||
* - Multi-user-aware. Iterates all rows in `User` so every user on the
|
||||
* instance gets the same curated library.
|
||||
* - Idempotent. Re-running is a no-op for exercises that already exist.
|
||||
* - Additive only. Never deletes or updates existing rows. Users keep
|
||||
* their own custom exercises (isCustom=true) untouched, and existing
|
||||
* library entries are not reshaped if the maintainer changed the
|
||||
* description/inputFields/etc. for them downstream.
|
||||
* - Cheap. Wrapped in a single transaction; ~164 rows x N users runs in
|
||||
* well under a second.
|
||||
* - INSERT new library entries (rows that don't exist yet for this
|
||||
* user) — same as previous behavior.
|
||||
* - UPDATE existing rows where `isCustom = 0` to match the curated
|
||||
* values (description, type, muscleGroups, inputFields,
|
||||
* defaultWeightUnit). This propagates maintainer-side fixes
|
||||
* (e.g. correcting cardio inputFields) to existing installs
|
||||
* without overwriting user-customized rows.
|
||||
* - SKIP rows where `isCustom = 1` — user customizations win, end
|
||||
* of story. PATCH /api/exercises/[id] flips isCustom to 1 on
|
||||
* 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:
|
||||
* node /app/prisma/ensureExerciseLibrary.cjs --db /data/app.db --json /app/prisma/exercises.seed.json
|
||||
*
|
||||
* Uses the sqlite3 CLI (already installed in the runner image) instead of a
|
||||
* Node SQLite binding so we don't have to ship a native dep.
|
||||
* Uses the sqlite3 CLI (already in the runner image) instead of a
|
||||
* Node SQLite binding so we don't ship a native dep.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
@@ -55,28 +62,24 @@ if (!Array.isArray(library) || library.length === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Get all user ids
|
||||
const usersRaw = execFileSync('sqlite3', [dbPath, 'SELECT id FROM User;'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const userIds = usersRaw.split('\n').map((s) => s.trim()).filter(Boolean);
|
||||
|
||||
if (userIds.length === 0) {
|
||||
console.error('[ensure-library] no users yet; skipping (will run on next boot after a user exists)');
|
||||
console.error('[ensure-library] no users yet; skipping');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Quote a string for safe use as a SQLite single-quoted literal.
|
||||
const q = (s) => `'${String(s).replace(/'/g, "''")}'`;
|
||||
// Generate a 25-char id with a "c" prefix to roughly match cuid shape.
|
||||
// Uniqueness comes from 12 random bytes; collision probability is negligible.
|
||||
const newId = () => 'c' + crypto.randomBytes(12).toString('hex');
|
||||
|
||||
const stmts = ['BEGIN;'];
|
||||
let inserts = 0;
|
||||
let upserts = 0;
|
||||
for (const userId of userIds) {
|
||||
for (const ex of library) {
|
||||
inserts++;
|
||||
upserts++;
|
||||
const muscleGroups = q(JSON.stringify(ex.muscleGroups || []));
|
||||
const inputFields = q(
|
||||
JSON.stringify(ex.inputFields || ['sets', 'reps', 'weight']),
|
||||
@@ -85,12 +88,23 @@ for (const userId of userIds) {
|
||||
ex.defaultWeightUnit == null ? 'NULL' : q(ex.defaultWeightUnit);
|
||||
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(
|
||||
`INSERT OR IGNORE INTO Exercise ` +
|
||||
`INSERT INTO Exercise ` +
|
||||
`(id, userId, name, description, muscleGroups, type, inputFields, defaultWeightUnit, isCustom, createdAt) ` +
|
||||
`VALUES (${q(newId())}, ${q(userId)}, ${q(ex.name)}, ${description}, ` +
|
||||
`${muscleGroups}, ${q(ex.type)}, ${inputFields}, ${defaultWeightUnit}, ` +
|
||||
`0, CURRENT_TIMESTAMP);`,
|
||||
`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') });
|
||||
console.error(
|
||||
`[ensure-library] processed ${userIds.length} user(s) x ${library.length} exercise(s) ` +
|
||||
`(${inserts} INSERT OR IGNORE statements)`,
|
||||
`[ensure-library] reconciled ${userIds.length} user(s) x ${library.length} ` +
|
||||
`exercise(s) (${upserts} INSERT-OR-UPDATE statements; user-customized ` +
|
||||
`rows skipped)`,
|
||||
);
|
||||
|
||||
@@ -487,7 +487,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Core",
|
||||
"description": null,
|
||||
"description": "Generic core circuit — note specific exercises (crunches, bicycle, decline situps, etc.) in the notes field.",
|
||||
"type": "bodyweight",
|
||||
"muscleGroups": [],
|
||||
"inputFields": [
|
||||
@@ -679,7 +679,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Plank",
|
||||
"description": "Bodyweight plank for core stability",
|
||||
"description": "Front plank — log hold duration; add weight for plate-loaded variations.",
|
||||
"type": "bodyweight",
|
||||
"muscleGroups": [
|
||||
"core",
|
||||
@@ -687,7 +687,7 @@
|
||||
],
|
||||
"inputFields": [
|
||||
"sets",
|
||||
"reps",
|
||||
"duration",
|
||||
"weight"
|
||||
],
|
||||
"defaultWeightUnit": null
|
||||
@@ -725,7 +725,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Resistance Band",
|
||||
"description": null,
|
||||
"description": "Generic resistance band exercise — log specific motion (pull-apart, face pull, etc.) in notes.",
|
||||
"type": "bodyweight",
|
||||
"muscleGroups": [],
|
||||
"inputFields": [
|
||||
@@ -1023,83 +1023,81 @@
|
||||
},
|
||||
{
|
||||
"name": "Cycling",
|
||||
"description": "Cycling for lower body cardio",
|
||||
"description": "Stationary or outdoor cycling — log distance + duration as a single set.",
|
||||
"type": "cardio",
|
||||
"muscleGroups": [
|
||||
"cardio"
|
||||
],
|
||||
"inputFields": [
|
||||
"sets",
|
||||
"reps",
|
||||
"weight"
|
||||
"duration",
|
||||
"distance",
|
||||
"calories"
|
||||
],
|
||||
"defaultWeightUnit": null
|
||||
},
|
||||
{
|
||||
"name": "Headstand",
|
||||
"description": null,
|
||||
"type": "cardio",
|
||||
"description": "Inverted headstand hold — log duration per set.",
|
||||
"type": "bodyweight",
|
||||
"muscleGroups": [],
|
||||
"inputFields": [
|
||||
"sets",
|
||||
"reps",
|
||||
"duration",
|
||||
"notes"
|
||||
"duration"
|
||||
],
|
||||
"defaultWeightUnit": null
|
||||
},
|
||||
{
|
||||
"name": "Hip Extension",
|
||||
"description": null,
|
||||
"type": "cardio",
|
||||
"description": "Glute hip extension on bench or GHD — log reps per set.",
|
||||
"type": "bodyweight",
|
||||
"muscleGroups": [],
|
||||
"inputFields": [
|
||||
"sets",
|
||||
"reps",
|
||||
"duration",
|
||||
"notes"
|
||||
"reps"
|
||||
],
|
||||
"defaultWeightUnit": null
|
||||
},
|
||||
{
|
||||
"name": "Jump Rope",
|
||||
"description": "Jump rope for cardio and footwork",
|
||||
"description": "Jump rope intervals — log duration per set.",
|
||||
"type": "cardio",
|
||||
"muscleGroups": [
|
||||
"cardio"
|
||||
],
|
||||
"inputFields": [
|
||||
"sets",
|
||||
"reps",
|
||||
"weight"
|
||||
"duration"
|
||||
],
|
||||
"defaultWeightUnit": null
|
||||
},
|
||||
{
|
||||
"name": "Rowing",
|
||||
"description": "Rowing machine for full-body cardio",
|
||||
"description": "Rowing erg — log distance, duration, calories per piece.",
|
||||
"type": "cardio",
|
||||
"muscleGroups": [
|
||||
"cardio"
|
||||
],
|
||||
"inputFields": [
|
||||
"sets",
|
||||
"reps",
|
||||
"weight"
|
||||
"duration",
|
||||
"distance",
|
||||
"calories"
|
||||
],
|
||||
"defaultWeightUnit": null
|
||||
},
|
||||
{
|
||||
"name": "Running",
|
||||
"description": "Running for cardiovascular endurance",
|
||||
"description": "Running outdoor or treadmill — log distance + duration per piece.",
|
||||
"type": "cardio",
|
||||
"muscleGroups": [
|
||||
"cardio"
|
||||
],
|
||||
"inputFields": [
|
||||
"sets",
|
||||
"reps",
|
||||
"weight"
|
||||
"duration",
|
||||
"distance",
|
||||
"calories"
|
||||
],
|
||||
"defaultWeightUnit": null
|
||||
},
|
||||
@@ -1121,27 +1119,24 @@
|
||||
},
|
||||
{
|
||||
"name": "Walking Lunge",
|
||||
"description": null,
|
||||
"type": "cardio",
|
||||
"description": "Alternating walking lunges — bodyweight or with dumbbells in hand.",
|
||||
"type": "bodyweight",
|
||||
"muscleGroups": [],
|
||||
"inputFields": [
|
||||
"sets",
|
||||
"reps",
|
||||
"weight",
|
||||
"duration",
|
||||
"notes"
|
||||
"weight"
|
||||
],
|
||||
"defaultWeightUnit": null
|
||||
},
|
||||
{
|
||||
"name": "Wall Sit",
|
||||
"description": null,
|
||||
"type": "cardio",
|
||||
"description": "Isometric wall sit — log hold duration per set.",
|
||||
"type": "bodyweight",
|
||||
"muscleGroups": [],
|
||||
"inputFields": [
|
||||
"sets",
|
||||
"duration",
|
||||
"notes"
|
||||
"duration"
|
||||
],
|
||||
"defaultWeightUnit": null
|
||||
},
|
||||
@@ -1566,7 +1561,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Stir the pot",
|
||||
"description": null,
|
||||
"description": "Stability-ball plank with circular forearm motion (\"stirring the pot\").",
|
||||
"type": "exercise ball",
|
||||
"muscleGroups": [
|
||||
"abs"
|
||||
@@ -1579,7 +1574,7 @@
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"muscleGroups": [
|
||||
"forearms"
|
||||
@@ -1769,7 +1764,7 @@
|
||||
},
|
||||
{
|
||||
"name": "TGU",
|
||||
"description": null,
|
||||
"description": "Turkish Get-Up — full ground-to-standing kettlebell movement; log kettlebell weight.",
|
||||
"type": "kettlebell",
|
||||
"muscleGroups": [
|
||||
"full body",
|
||||
@@ -1800,14 +1795,16 @@
|
||||
},
|
||||
{
|
||||
"name": "Mace warmup",
|
||||
"description": null,
|
||||
"description": "Steel mace warm-up flow — 360s, figure-8s, etc. Log mace weight.",
|
||||
"type": "mace bar",
|
||||
"muscleGroups": [
|
||||
"shoulders",
|
||||
"core"
|
||||
],
|
||||
"inputFields": [
|
||||
"sets"
|
||||
"sets",
|
||||
"reps",
|
||||
"weight"
|
||||
],
|
||||
"defaultWeightUnit": null
|
||||
},
|
||||
@@ -2030,11 +2027,12 @@
|
||||
},
|
||||
{
|
||||
"name": "Hollow Body Landmine",
|
||||
"description": null,
|
||||
"description": "Landmine press performed in hollow body position — log reps + barbell weight.",
|
||||
"type": "other",
|
||||
"muscleGroups": [],
|
||||
"inputFields": [
|
||||
"sets",
|
||||
"reps",
|
||||
"weight"
|
||||
],
|
||||
"defaultWeightUnit": null
|
||||
@@ -2090,7 +2088,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Neck Circuit",
|
||||
"description": null,
|
||||
"description": "Series of neck flexion/extension/lateral isometric movements.",
|
||||
"type": "other",
|
||||
"muscleGroups": [
|
||||
"neck"
|
||||
@@ -2177,7 +2175,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Slide Board",
|
||||
"description": null,
|
||||
"description": "Sliding lateral skating-style movement on a slick slide board.",
|
||||
"type": "other",
|
||||
"muscleGroups": [],
|
||||
"inputFields": [
|
||||
@@ -2188,15 +2186,16 @@
|
||||
},
|
||||
{
|
||||
"name": "Soccer",
|
||||
"description": null,
|
||||
"description": "Pickup or training soccer — log overall duration + optional distance.",
|
||||
"type": "other",
|
||||
"muscleGroups": [
|
||||
"cardio"
|
||||
],
|
||||
"inputFields": [
|
||||
"calories",
|
||||
"sets",
|
||||
"duration",
|
||||
"distance",
|
||||
"duration"
|
||||
"calories"
|
||||
],
|
||||
"defaultWeightUnit": null
|
||||
},
|
||||
|
||||
@@ -141,12 +141,19 @@ if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Step 3 — ensure curated exercise library for every user (multi-user-aware).
|
||||
# New entries shipped in /app/prisma/exercises.seed.json appear on every boot.
|
||||
# `INSERT OR IGNORE` keyed on (userId, name) so we never overwrite a user's
|
||||
# own custom exercises. Designed to be additive only — exercises removed from
|
||||
# the curated JSON are not deleted from existing installs (users may have
|
||||
# logged sets against them).
|
||||
# Step 3 — reconcile curated exercise library for every user
|
||||
# (multi-user-aware). As of v1.0.0:7 this is INSERT-or-UPDATE rather than
|
||||
# INSERT-or-IGNORE: existing rows where isCustom = 0 get refreshed from
|
||||
# /app/prisma/exercises.seed.json so maintainer-side fixes (e.g. correct
|
||||
# inputFields for cardio) propagate to existing installs. Rows where
|
||||
# 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
|
||||
log "ensuring curated exercise library is present for every user"
|
||||
|
||||
@@ -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_5 } from './v1.0.0.5'
|
||||
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.
|
||||
@@ -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:2 — CSP fix.
|
||||
* v1.0.0:3 — post-cutover seed strip.
|
||||
* v1.0.0:4 — removes default admin@local credentials; operator must
|
||||
* run StartOS Action to bootstrap the first admin.
|
||||
* v1.0.0:4 — removes default admin@local credentials.
|
||||
* v1.0.0:5 — internal cleanup (caloriesBurned raw-SQL workaround).
|
||||
* v1.0.0:6 — paginate workout history (infinite scroll); removes
|
||||
* invisible 50-row caps on the clock-button popup and
|
||||
* the /main/workouts page.
|
||||
* v1.0.0:6 — paginate workout history (infinite scroll).
|
||||
* v1.0.0:7 — exercise library cleanup, photo-import removal,
|
||||
* UI honesty about AI.
|
||||
*/
|
||||
export const versionGraph = VersionGraph.of({
|
||||
current: v_1_0_0_6,
|
||||
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],
|
||||
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, v_1_0_0_6],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user