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)
|
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,
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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