diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index b3100a01..5917352e 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -92,12 +92,15 @@ import type * as garmin_credentials from "../garmin/credentials.js"; import type * as garmin_oauth1 from "../garmin/oauth1.js"; import type * as garmin_oauthFlow from "../garmin/oauthFlow.js"; import type * as garmin_registration from "../garmin/registration.js"; +import type * as garmin_trainingApi from "../garmin/trainingApi.js"; import type * as garmin_webhookDispatch from "../garmin/webhookDispatch.js"; import type * as garmin_webhookEvents from "../garmin/webhookEvents.js"; import type * as garmin_webhookPayloads from "../garmin/webhookPayloads.js"; import type * as garmin_webhookSignature from "../garmin/webhookSignature.js"; import type * as garmin_wellnessDaily from "../garmin/wellnessDaily.js"; import type * as garmin_wellnessNormalizers from "../garmin/wellnessNormalizers.js"; +import type * as garmin_workoutDelivery from "../garmin/workoutDelivery.js"; +import type * as garmin_workoutPayload from "../garmin/workoutPayload.js"; import type * as goals from "../goals.js"; import type * as healthCheck from "../healthCheck.js"; import type * as http from "../http.js"; @@ -269,12 +272,15 @@ declare const fullApi: ApiFromModules<{ "garmin/oauth1": typeof garmin_oauth1; "garmin/oauthFlow": typeof garmin_oauthFlow; "garmin/registration": typeof garmin_registration; + "garmin/trainingApi": typeof garmin_trainingApi; "garmin/webhookDispatch": typeof garmin_webhookDispatch; "garmin/webhookEvents": typeof garmin_webhookEvents; "garmin/webhookPayloads": typeof garmin_webhookPayloads; "garmin/webhookSignature": typeof garmin_webhookSignature; "garmin/wellnessDaily": typeof garmin_wellnessDaily; "garmin/wellnessNormalizers": typeof garmin_wellnessNormalizers; + "garmin/workoutDelivery": typeof garmin_workoutDelivery; + "garmin/workoutPayload": typeof garmin_workoutPayload; goals: typeof goals; healthCheck: typeof healthCheck; http: typeof http; diff --git a/convex/accountDeletion.test.ts b/convex/accountDeletion.test.ts index 44db5ca4..21919a87 100644 --- a/convex/accountDeletion.test.ts +++ b/convex/accountDeletion.test.ts @@ -106,7 +106,7 @@ async function drainAuthData(t: ReturnType, userId: Id<"users async function drainUserTableBatch( t: ReturnType, userId: Id<"users">, - table: "currentStrengthScores" | "garminWebhookEvents", + table: "currentStrengthScores" | "garminWebhookEvents" | "garminWorkoutDeliveries", ) { let iterations = 0; while (await t.mutation(internal.accountDeletion.deleteUserTableBatch, { userId, table })) { @@ -296,6 +296,62 @@ describe("deleteUserTableBatch", () => { expect(remaining.deletedBlobExists).toBe(false); expect(remaining.otherBlobExists).toBe(true); }); + + test("deletes Garmin workout deliveries via the shared registry", async () => { + const t = convexTest(schema, modules); + const userId = await createUser(t); + const otherUserId = await createUser(t); + + await t.run(async (ctx) => { + const now = 1_778_000_400_000; + const workoutPlanId = await ctx.db.insert("workoutPlans", { + userId, + title: "Push Day", + blocks: [{ exercises: [{ movementId: "bench", sets: 3, reps: 8 }] }], + status: "pushed", + createdAt: now, + }); + const otherWorkoutPlanId = await ctx.db.insert("workoutPlans", { + userId: otherUserId, + title: "Pull Day", + blocks: [{ exercises: [{ movementId: "row", sets: 3, reps: 8 }] }], + status: "pushed", + createdAt: now, + }); + await ctx.db.insert("garminWorkoutDeliveries", { + userId, + workoutPlanId, + scheduledDate: "2026-05-05", + status: "sent", + garminWorkoutId: "123", + createdAt: now, + updatedAt: now, + sentAt: now, + }); + await ctx.db.insert("garminWorkoutDeliveries", { + userId: otherUserId, + workoutPlanId: otherWorkoutPlanId, + scheduledDate: "2026-05-05", + status: "sent", + garminWorkoutId: "456", + createdAt: now, + updatedAt: now, + sentAt: now, + }); + }); + + await drainUserTableBatch(t, userId, "garminWorkoutDeliveries"); + + const remaining = await t.run(async (ctx) => { + const rows = await ctx.db.query("garminWorkoutDeliveries").collect(); + return { + targetRows: rows.filter((row) => row.userId === userId), + otherRows: rows.filter((row) => row.userId === otherUserId), + }; + }); + expect(remaining.targetRows).toHaveLength(0); + expect(remaining.otherRows).toHaveLength(1); + }); }); describe("deletionInProgress", () => { diff --git a/convex/accountDeletion.ts b/convex/accountDeletion.ts index b5b95561..00e93a1c 100644 --- a/convex/accountDeletion.ts +++ b/convex/accountDeletion.ts @@ -94,6 +94,7 @@ async function takeBatchForDeletion( case "userProfileActivity": case "garminConnections": case "garminOauthStates": + case "garminWorkoutDeliveries": case "garminWellnessDaily": return ( await ctx.db diff --git a/convex/ai/contextWindow.ts b/convex/ai/contextWindow.ts index 6bb0cec6..e2f65ee1 100644 --- a/convex/ai/contextWindow.ts +++ b/convex/ai/contextWindow.ts @@ -4,6 +4,7 @@ */ import type { ModelMessage, UserContent } from "ai"; +import { enforceToolCallAdjacency } from "./toolCallAdjacency"; // --------------------------------------------------------------------------- // Merge consecutive same-role messages @@ -109,7 +110,7 @@ export function stripOrphanedToolCalls(messages: ModelMessage[]): ModelMessage[] } } - return messages + const firstPass = messages .map((msg) => { if (msg.role === "assistant") { if (typeof msg.content === "string" || !Array.isArray(msg.content)) return msg; @@ -148,6 +149,26 @@ export function stripOrphanedToolCalls(messages: ModelMessage[]): ModelMessage[] return msg; }) .filter((msg): msg is ModelMessage => msg !== null); + + // Belt-and-suspenders adjacency repair (PostHog issue 019d510a). + // Gemini's hard rule: a function-call turn MUST be immediately followed by a + // function-response turn. The set-based logic above proves a tool-result/ + // approval-response exists *somewhere* in history, but not that it sits in + // the slot Gemini requires. This final pass walks the surviving messages and + // drops any tool-call (or stray tool message) that fails the adjacency check. + const repaired = enforceToolCallAdjacency( + firstPass, + approvalIdToToolCallId, + liveApprovalToolCallIds, + ); + if (repaired.dropCount > 0) { + // Surface adjacency repairs in Convex logs so a regression in upstream + // persistence (the bug class this pass defends against) is observable. + console.warn( + `[stripOrphanedToolCalls] adjacency repair dropped ${repaired.dropCount} message(s)/part(s) (PostHog 019d510a)`, + ); + } + return repaired.messages; } // --------------------------------------------------------------------------- diff --git a/convex/ai/toolCallAdjacency.test.ts b/convex/ai/toolCallAdjacency.test.ts new file mode 100644 index 00000000..f83fab85 --- /dev/null +++ b/convex/ai/toolCallAdjacency.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from "vitest"; +import type { ModelMessage } from "ai"; +import { stripOrphanedToolCalls } from "./contextWindow"; + +// Adjacency-repair pass (PostHog issue 019d510a). +// +// Gemini rejects history where an assistant function-call turn isn't +// immediately followed by a function-response turn. The set-based logic in +// stripOrphanedToolCalls proves a tool-result/approval-response exists +// *somewhere*; the adjacency pass enforces that it sits in the slot Gemini +// requires. These tests exercise the second pass specifically. +describe("stripOrphanedToolCalls — adjacency repair", () => { + it("drops a tool-call when the next message is a text-only assistant", () => { + // The set-based pass would keep tc1 (its result is in history far away), + // but the adjacency pass drops it because the immediate next turn is not + // a tool message. We keep the assistant text-only message that follows. + const msgs: ModelMessage[] = [ + { role: "user", content: "check scores" }, + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "tc1", toolName: "get_scores", input: {} }], + }, + { role: "assistant", content: [{ type: "text", text: "Stale assistant turn" }] }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "tc1", + toolName: "get_scores", + output: { type: "text", value: "ok" }, + }, + ], + }, + { role: "user", content: "follow up" }, + ]; + + const result = stripOrphanedToolCalls(msgs); + + expect(result.find((m) => m.role === "tool")).toBeUndefined(); + const assistantMsgs = result.filter((m) => m.role === "assistant"); + expect(assistantMsgs).toHaveLength(1); + const surviving = assistantMsgs[0].content as Array<{ type: string }>; + expect(surviving).toEqual([{ type: "text", text: "Stale assistant turn" }]); + }); + + it("drops a tool-call but keeps text when next message is a fresh user", () => { + const msgs: ModelMessage[] = [ + { role: "user", content: "hi" }, + { + role: "assistant", + content: [ + { type: "text", text: "Let me search." }, + { type: "tool-call", toolCallId: "tc1", toolName: "search", input: {} }, + ], + }, + { role: "user", content: "actually nevermind" }, + ]; + + const result = stripOrphanedToolCalls(msgs); + + expect(result).toHaveLength(3); + const assistantContent = result[1].content as Array<{ type: string; text?: string }>; + expect(assistantContent).toHaveLength(1); + expect(assistantContent[0]).toEqual({ type: "text", text: "Let me search." }); + }); + + it("preserves an assistant tool-call when next message has matching tool-result", () => { + const msgs: ModelMessage[] = [ + { role: "user", content: "scores" }, + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "tc1", toolName: "get_scores", input: {} }], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "tc1", + toolName: "get_scores", + output: { type: "text", value: "done" }, + }, + ], + }, + { role: "assistant", content: "Here are your scores." }, + ]; + + expect(stripOrphanedToolCalls(msgs)).toEqual(msgs); + }); + + it("drops a tool-call AND the mismatched tool message when toolCallIds don't pair", () => { + const msgs: ModelMessage[] = [ + { role: "user", content: "scores" }, + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "tc-X", toolName: "get_scores", input: {} }], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "tc-Y", + toolName: "search", + output: { type: "text", value: "wrong" }, + }, + ], + }, + { role: "user", content: "next" }, + ]; + + const result = stripOrphanedToolCalls(msgs); + + expect(result).toEqual([ + { role: "user", content: "scores" }, + { role: "user", content: "next" }, + ]); + }); + + it("preserves a tool-call paired by tool-approval-response via approvalId", () => { + const msgs: ModelMessage[] = [ + { role: "user", content: "deploy please" }, + { + role: "assistant", + content: [ + { type: "text", text: "Approve?" }, + { type: "tool-call", toolCallId: "tc1", toolName: "approve_week_plan", input: {} }, + { type: "tool-approval-request", approvalId: "ap1", toolCallId: "tc1" }, + ], + }, + { + role: "tool", + content: [{ type: "tool-approval-response", approvalId: "ap1", approved: true }], + }, + { role: "user", content: "ok" }, + ]; + + expect(stripOrphanedToolCalls(msgs)).toEqual(msgs); + }); + + it("drops a tool message at the start with no preceding assistant", () => { + const msgs: ModelMessage[] = [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "tc-orphan", + toolName: "search", + output: { type: "text", value: "leftover" }, + }, + ], + }, + { role: "user", content: "hi" }, + { role: "assistant", content: "Hello" }, + ]; + + const result = stripOrphanedToolCalls(msgs); + + expect(result.find((m) => m.role === "tool")).toBeUndefined(); + expect(result).toEqual([ + { role: "user", content: "hi" }, + { role: "assistant", content: "Hello" }, + ]); + }); + + it("drops a tool message when the previous message is also a tool message", () => { + // Two consecutive tool messages cannot both pair with the same preceding + // assistant turn; the second has no assistant tool-call directly before it. + const msgs: ModelMessage[] = [ + { role: "user", content: "scores" }, + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "tc1", toolName: "get_scores", input: {} }], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "tc1", + toolName: "get_scores", + output: { type: "text", value: "ok" }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "tc1", + toolName: "get_scores", + output: { type: "text", value: "duplicate" }, + }, + ], + }, + { role: "assistant", content: "Done" }, + ]; + + const result = stripOrphanedToolCalls(msgs); + + const toolMsgs = result.filter((m) => m.role === "tool"); + expect(toolMsgs).toHaveLength(1); + const parts = toolMsgs[0].content as Array<{ type: string; output: { value: string } }>; + expect(parts[0].output.value).toBe("ok"); + }); +}); diff --git a/convex/ai/toolCallAdjacency.ts b/convex/ai/toolCallAdjacency.ts new file mode 100644 index 00000000..738372b1 --- /dev/null +++ b/convex/ai/toolCallAdjacency.ts @@ -0,0 +1,168 @@ +/** + * Belt-and-suspenders adjacency repair pass for stripOrphanedToolCalls. + * + * Gemini's hard rule: a function-call turn MUST be immediately followed by a + * function-response turn. The set-based logic in stripOrphanedToolCalls proves + * a matching tool-result / approval-response exists *somewhere* in history, + * but not that it sits in the slot Gemini requires. This pass walks the + * surviving messages and drops any tool-call (or stray tool message) that + * fails the adjacency check. + * + * Tracking: PostHog issue 019d510a — "Please ensure that function call turn + * comes immediately after a user turn or after a function response turn." + */ + +import type { ModelMessage } from "ai"; + +interface ToolCallPart { + readonly type: string; + readonly toolCallId?: string; + readonly approvalId?: string; +} + +function partsOf(content: ModelMessage["content"]): readonly ToolCallPart[] { + if (typeof content === "string" || !Array.isArray(content)) return []; + // Validated upstream by the `ai` SDK; we only read the discriminated `type` + // string and a couple of optional id fields, so this cast is safe. + return content as readonly ToolCallPart[]; +} + +function collectToolPartIds(content: ModelMessage["content"]): { + toolCallIds: Set; + approvalIds: Set; +} { + const toolCallIds = new Set(); + const approvalIds = new Set(); + for (const part of partsOf(content)) { + if (part.type === "tool-result" && part.toolCallId) { + toolCallIds.add(part.toolCallId); + } + if (part.type === "tool-approval-response" && part.approvalId) { + approvalIds.add(part.approvalId); + } + } + return { toolCallIds, approvalIds }; +} + +interface DropCounter { + count: number; +} + +function repairAssistantToolCalls( + repaired: (ModelMessage | null)[], + approvalIdToToolCallId: ReadonlyMap, + liveApprovalToolCallIds: ReadonlySet, + drops: DropCounter, +): void { + for (let i = 0; i < repaired.length; i++) { + const msg = repaired[i]; + if (!msg || msg.role !== "assistant") continue; + + const parts = partsOf(msg.content); + if (parts.length === 0) continue; + if (!parts.some((p) => p.type === "tool-call")) continue; + + const next = repaired[i + 1]; + const nextIds = + next && next.role === "tool" + ? collectToolPartIds(next.content) + : { toolCallIds: new Set(), approvalIds: new Set() }; + + const filtered = parts.filter((part) => { + if (part.type !== "tool-call") return true; + if (!part.toolCallId) return false; + // Pending live-approval flow: the assistant is awaiting the user's + // approve/deny click. The agent runtime appends the approval-response + // before the next model call, so preserving the tool-call here keeps + // the chain intact through that next turn. + if (liveApprovalToolCallIds.has(part.toolCallId)) return true; + if (nextIds.toolCallIds.has(part.toolCallId)) return true; + for (const apprId of nextIds.approvalIds) { + if (approvalIdToToolCallId.get(apprId) === part.toolCallId) return true; + } + return false; + }); + + if (filtered.length === 0) { + repaired[i] = null; + drops.count += 1; + } else if (filtered.length !== parts.length) { + repaired[i] = { ...msg, content: filtered } as ModelMessage; + drops.count += parts.length - filtered.length; + } + } +} + +function repairOrphanToolMessages( + repaired: (ModelMessage | null)[], + approvalIdToToolCallId: ReadonlyMap, + drops: DropCounter, +): void { + for (let i = 0; i < repaired.length; i++) { + const msg = repaired[i]; + if (!msg || msg.role !== "tool") continue; + + const prev = i > 0 ? repaired[i - 1] : null; + if (!prev || prev.role !== "assistant") { + repaired[i] = null; + drops.count += 1; + continue; + } + const prevToolCallIds = new Set(); + for (const part of partsOf(prev.content)) { + if (part.type === "tool-call" && part.toolCallId) { + prevToolCallIds.add(part.toolCallId); + } + } + if (prevToolCallIds.size === 0) { + repaired[i] = null; + drops.count += 1; + continue; + } + + const parts = partsOf(msg.content); + if (parts.length === 0) continue; + const filtered = parts.filter((part) => { + if (part.type === "tool-result") { + return part.toolCallId !== undefined && prevToolCallIds.has(part.toolCallId); + } + if (part.type === "tool-approval-response") { + if (!part.approvalId) return false; + const tcId = approvalIdToToolCallId.get(part.approvalId); + return tcId !== undefined && prevToolCallIds.has(tcId); + } + return true; + }); + + if (filtered.length === 0) { + repaired[i] = null; + drops.count += 1; + } else if (filtered.length !== parts.length) { + repaired[i] = { ...msg, content: filtered } as ModelMessage; + drops.count += parts.length - filtered.length; + } + } +} + +export interface AdjacencyRepairResult { + readonly messages: ModelMessage[]; + /** Count of messages or parts dropped/trimmed by the adjacency pass. >0 + * signals that an upstream persistence anomaly was caught — useful for + * observability (PostHog issue 019d510a regression watch). */ + readonly dropCount: number; +} + +export function enforceToolCallAdjacency( + messages: readonly ModelMessage[], + approvalIdToToolCallId: ReadonlyMap, + liveApprovalToolCallIds: ReadonlySet, +): AdjacencyRepairResult { + const repaired: (ModelMessage | null)[] = [...messages]; + const drops: DropCounter = { count: 0 }; + repairAssistantToolCalls(repaired, approvalIdToToolCallId, liveApprovalToolCallIds, drops); + repairOrphanToolMessages(repaired, approvalIdToToolCallId, drops); + return { + messages: repaired.filter((msg): msg is ModelMessage => msg !== null), + dropCount: drops.count, + }; +} diff --git a/convex/dataExport.ts b/convex/dataExport.ts index f52db6f3..cc4c5c89 100644 --- a/convex/dataExport.ts +++ b/convex/dataExport.ts @@ -10,6 +10,10 @@ type GarminWellnessDailyExportRow = Omit< Doc<"garminWellnessDaily">, "_id" | "_creationTime" | "userId" >; +type GarminWorkoutDeliveryExportRow = Omit< + Doc<"garminWorkoutDeliveries">, + "_id" | "_creationTime" | "userId" +>; interface ExportedData extends Record { exportedAt: string; @@ -81,6 +85,7 @@ interface ExportedData extends Record, +): GarminWorkoutDeliveryExportRow { + const { + _id: _unusedId, + _creationTime: _unusedCreationTime, + userId: _unusedUserId, + ...exportRow + } = row; + return exportRow; +} + export const exportData = action({ args: {}, handler: async (ctx): Promise => { @@ -228,6 +245,11 @@ export const collectUserData = internalQuery({ .withIndex("by_userId_calendarDate", (q) => q.eq("userId", userId)) .collect(); + const garminWorkoutDeliveries = await ctx.db + .query("garminWorkoutDeliveries") + .withIndex("by_userId", (q) => q.eq("userId", userId)) + .collect(); + // Build movement ID → name lookup, fetching only movements actually // referenced by this user's exercisePerformance rows. const movementIds = new Set(exercisePerformanceRows.map((ep) => ep.movementId)); @@ -341,6 +363,7 @@ export const collectUserData = internalQuery({ muscleGroups: exclusion.muscleGroups, createdAt: exclusion.createdAt, })), + garminWorkoutDeliveries: garminWorkoutDeliveries.map(garminWorkoutDeliveryToExportRow), garminWellnessDaily: garminWellnessDaily.map(garminWellnessDailyToExportRow), }; }, diff --git a/convex/garmin/trainingApi.test.ts b/convex/garmin/trainingApi.test.ts new file mode 100644 index 00000000..7e3b0bfe --- /dev/null +++ b/convex/garmin/trainingApi.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { createAndScheduleGarminWorkout } from "./trainingApi"; +import type { GarminWorkoutPayload } from "./workoutPayload"; + +const workoutPayload: GarminWorkoutPayload = { + workoutName: "Push Day", + description: "Roni workout scheduled for 2026-05-05.", + sport: "STRENGTH_TRAINING", + workoutProvider: "Roni", + workoutSourceId: "roni:plan-1", + steps: [], +}; + +describe("createAndScheduleGarminWorkout", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("schedules string workout IDs without numeric reparsing", async () => { + const garminWorkoutId = "900719925474099312345"; + const fetchMock = vi.fn(async (input, init) => { + const url = String(input); + if (url.includes("/workout/")) { + return new Response(JSON.stringify({ workoutId: garminWorkoutId }), { status: 201 }); + } + if (url.includes("/schedule/")) { + const body = init?.body; + if (typeof body !== "string") throw new Error("Expected JSON schedule request body."); + const scheduleBody: unknown = JSON.parse(body); + expect(scheduleBody).toEqual({ workoutId: garminWorkoutId, date: "2026-05-05" }); + return new Response(JSON.stringify({ scheduleId: "schedule-1" }), { status: 201 }); + } + throw new Error(`Unexpected Garmin URL ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await createAndScheduleGarminWorkout({ + credentials: { + consumerKey: "consumer-key", + consumerSecret: "consumer-secret", + token: "access-token", + tokenSecret: "access-token-secret", + }, + payload: workoutPayload, + scheduledDate: "2026-05-05", + }); + + expect(result).toEqual({ + garminWorkoutId, + garminScheduleId: "schedule-1", + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/convex/garmin/trainingApi.ts b/convex/garmin/trainingApi.ts new file mode 100644 index 00000000..86993b38 --- /dev/null +++ b/convex/garmin/trainingApi.ts @@ -0,0 +1,132 @@ +import { type OAuth1Credentials, signOAuth1Request } from "./oauth1"; +import type { GarminWorkoutPayload } from "./workoutPayload"; + +const GARMIN_WORKOUT_URL = "https://apis.garmin.com/training-api/workout/"; +const GARMIN_SCHEDULE_URL = "https://apis.garmin.com/training-api/schedule/"; +const GARMIN_FETCH_TIMEOUT_MS = 10_000; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +async function parseJsonBody(response: Response): Promise { + const body = await response.text(); + if (body.trim() === "") return null; + try { + return JSON.parse(body) as unknown; + } catch { + return null; + } +} + +function extractRemoteId(body: unknown, fieldName: string): string | null { + if (!isRecord(body)) return null; + const value = body[fieldName]; + if (typeof value === "string" && value.trim() !== "") return value; + if (typeof value === "number" && Number.isFinite(value)) return String(Math.trunc(value)); + return null; +} + +function describeStatusError(status: number, operation: string): string { + if (status === 401 || status === 403) { + return "Garmin authorization is no longer valid. Reconnect Garmin and try again."; + } + if (status === 412) { + return "Garmin workout import permission is not enabled for this connection."; + } + if (status === 429) { + return "Garmin rate-limited workout delivery. Try again later."; + } + return `Garmin ${operation} failed with HTTP ${status}.`; +} + +async function sendGarminJson({ + credentials, + url, + method, + body, +}: { + credentials: OAuth1Credentials; + url: string; + method: "POST" | "DELETE"; + body?: unknown; +}): Promise<{ status: number; body: unknown }> { + const signed = await signOAuth1Request(credentials, { method, url }); + const response = await fetch(url, { + method, + headers: { + Authorization: signed.authorizationHeader, + Accept: "application/json", + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + signal: AbortSignal.timeout(GARMIN_FETCH_TIMEOUT_MS), + }); + + return { status: response.status, body: await parseJsonBody(response) }; +} + +async function deleteGarminWorkoutBestEffort( + credentials: OAuth1Credentials, + garminWorkoutId: string, +): Promise { + try { + await sendGarminJson({ + credentials, + url: `${GARMIN_WORKOUT_URL}${encodeURIComponent(garminWorkoutId)}`, + method: "DELETE", + }); + } catch (error) { + console.error("[garminWorkoutDelivery] failed to clean up unscheduled Garmin workout", { + garminWorkoutId, + error, + }); + } +} + +export async function createAndScheduleGarminWorkout({ + credentials, + payload, + scheduledDate, +}: { + credentials: OAuth1Credentials; + payload: GarminWorkoutPayload; + scheduledDate: string; +}): Promise<{ garminWorkoutId: string; garminScheduleId?: string }> { + const workoutResponse = await sendGarminJson({ + credentials, + url: GARMIN_WORKOUT_URL, + method: "POST", + body: payload, + }); + if (workoutResponse.status < 200 || workoutResponse.status >= 300) { + throw new Error(describeStatusError(workoutResponse.status, "workout create")); + } + + const garminWorkoutId = extractRemoteId(workoutResponse.body, "workoutId"); + if (!garminWorkoutId) { + throw new Error("Garmin workout create response did not include workoutId."); + } + + try { + const scheduleResponse = await sendGarminJson({ + credentials, + url: GARMIN_SCHEDULE_URL, + method: "POST", + body: { + workoutId: garminWorkoutId, + date: scheduledDate, + }, + }); + if (scheduleResponse.status < 200 || scheduleResponse.status >= 300) { + throw new Error(describeStatusError(scheduleResponse.status, "schedule create")); + } + return { + garminWorkoutId, + garminScheduleId: extractRemoteId(scheduleResponse.body, "scheduleId") ?? undefined, + }; + } catch (error) { + await deleteGarminWorkoutBestEffort(credentials, garminWorkoutId); + throw error; + } +} diff --git a/convex/garmin/workoutDelivery.test.ts b/convex/garmin/workoutDelivery.test.ts new file mode 100644 index 00000000..11c34bcc --- /dev/null +++ b/convex/garmin/workoutDelivery.test.ts @@ -0,0 +1,188 @@ +/// +import { convexTest } from "convex-test"; +import { describe, expect, test } from "vitest"; +import { internal } from "../_generated/api"; +import type { Id } from "../_generated/dataModel"; +import schema from "../schema"; +import { TONAL_REST_MOVEMENT_ID } from "../tonal/transforms"; +import { + buildGarminStrengthWorkoutPayloadFromPlan, + inferGarminExerciseCategory, +} from "./workoutPayload"; + +const rawModules = import.meta.glob("../**/*.*s"); +const modules: typeof rawModules = {}; +// This glob is relative to this test file; convexTest expects keys rooted at convex/. +for (const [key, value] of Object.entries(rawModules)) { + modules[key.startsWith("./") ? "../garmin/" + key.slice(2) : key] = value; +} + +const FIXED_PLAN_CREATED_AT = 1_714_000_000_000; + +function sampleMovement(id: string, name: string, countReps = true) { + return { + id, + name, + shortName: name, + muscleGroups: [], + inFreeLift: true, + onMachine: true, + countReps, + isTwoSided: false, + isBilateral: true, + isAlternating: false, + descriptionHow: "", + descriptionWhy: "", + skillLevel: 1, + publishState: "published", + sortOrder: 1, + }; +} + +async function seedPlan(t: ReturnType) { + return t.run(async (ctx) => { + const userId = await ctx.db.insert("users", {}); + const workoutPlanId = await ctx.db.insert("workoutPlans", { + userId, + title: "Push Day", + blocks: [{ exercises: [{ movementId: "bench", sets: 2, reps: 8 }] }], + status: "pushed", + createdAt: FIXED_PLAN_CREATED_AT, + }); + return { userId, workoutPlanId }; + }); +} + +describe("inferGarminExerciseCategory", () => { + test("maps common Tonal names onto Garmin strength categories", () => { + expect(inferGarminExerciseCategory("Barbell Bench Press")).toBe("BENCH_PRESS"); + expect(inferGarminExerciseCategory("Goblet Squat")).toBe("SQUAT"); + expect(inferGarminExerciseCategory("Standing Biceps Curl")).toBe("CURL"); + }); + + test("falls back to UNKNOWN for unmatched exercise names", () => { + expect(inferGarminExerciseCategory("Tonal Special Movement")).toBe("UNKNOWN"); + }); +}); + +describe("buildGarminStrengthWorkoutPayloadFromPlan", () => { + test("expands Roni blocks into Garmin strength workout steps", () => { + const payload = buildGarminStrengthWorkoutPayloadFromPlan({ + workoutPlanId: "plan-1", + title: "Push Day", + scheduledDate: "2026-05-05", + movements: [sampleMovement("bench", "Bench Press")], + blocks: [ + { + exercises: [ + { movementId: "bench", sets: 2, reps: 8 }, + { movementId: TONAL_REST_MOVEMENT_ID, sets: 2, duration: 90 }, + ], + }, + ], + }); + + expect(payload).toMatchObject({ + workoutName: "Push Day", + sport: "STRENGTH_TRAINING", + workoutProvider: "Roni", + workoutSourceId: "roni:plan-1", + }); + expect(payload.steps).toHaveLength(4); + expect(payload.steps[0]).toMatchObject({ + stepOrder: 1, + intensity: "INTERVAL", + durationType: "REPS", + durationValue: 8, + exerciseCategory: "BENCH_PRESS", + exerciseName: "Bench Press", + }); + expect(payload.steps[1]).toMatchObject({ + stepOrder: 2, + intensity: "REST", + durationType: "TIME", + durationValue: 90, + exerciseName: "Rest", + }); + }); + + test("uses time duration for duration-based movements", () => { + const payload = buildGarminStrengthWorkoutPayloadFromPlan({ + workoutPlanId: "plan-1", + title: "Core", + scheduledDate: "2026-05-05", + movements: [sampleMovement("plank", "Plank", false)], + blocks: [{ exercises: [{ movementId: "plank", sets: 1 }] }], + }); + + expect(payload.steps[0]).toMatchObject({ + durationType: "TIME", + durationValue: 30, + exerciseCategory: "PLANK", + }); + }); + + test("rejects empty workouts before calling Garmin", () => { + expect(() => + buildGarminStrengthWorkoutPayloadFromPlan({ + workoutPlanId: "plan-1", + title: "Empty", + scheduledDate: "2026-05-05", + movements: [], + blocks: [], + }), + ).toThrow("Workout has no exercises to send to Garmin"); + }); +}); + +describe("garminWorkoutDeliveries", () => { + test("claims a delivery exactly once while it is in progress", async () => { + const t = convexTest(schema, modules); + const { userId, workoutPlanId } = await seedPlan(t); + + const first = await t.mutation(internal.garmin.workoutDelivery.startDeliveryAttempt, { + userId, + workoutPlanId, + scheduledDate: "2026-05-05", + }); + const second = await t.mutation(internal.garmin.workoutDelivery.startDeliveryAttempt, { + userId, + workoutPlanId, + scheduledDate: "2026-05-05", + }); + + expect(first.state).toBe("claimed"); + expect(second).toEqual({ state: "in_progress" }); + }); + + test("returns sent deliveries instead of creating duplicates", async () => { + const t = convexTest(schema, modules); + const { userId, workoutPlanId } = await seedPlan(t); + const claim = await t.mutation(internal.garmin.workoutDelivery.startDeliveryAttempt, { + userId, + workoutPlanId, + scheduledDate: "2026-05-05", + }); + if (claim.state !== "claimed") throw new Error("Expected delivery claim"); + + await t.mutation(internal.garmin.workoutDelivery.markDeliverySent, { + deliveryId: claim.deliveryId as Id<"garminWorkoutDeliveries">, + garminWorkoutId: "123", + garminScheduleId: "456", + }); + const duplicate = await t.mutation(internal.garmin.workoutDelivery.startDeliveryAttempt, { + userId, + workoutPlanId, + scheduledDate: "2026-05-05", + }); + + expect(duplicate.state).toBe("already_sent"); + if (duplicate.state === "already_sent") { + expect(duplicate.delivery).toMatchObject({ + status: "sent", + garminWorkoutId: "123", + garminScheduleId: "456", + }); + } + }); +}); diff --git a/convex/garmin/workoutDelivery.ts b/convex/garmin/workoutDelivery.ts new file mode 100644 index 00000000..deab53b9 --- /dev/null +++ b/convex/garmin/workoutDelivery.ts @@ -0,0 +1,287 @@ +import { isRateLimitError } from "@convex-dev/rate-limiter"; +import { v } from "convex/values"; +import { z } from "zod"; +import { action, internalMutation, query } from "../_generated/server"; +import { internal } from "../_generated/api"; +import type { Doc, Id } from "../_generated/dataModel"; +import { rateLimiter } from "../rateLimits"; +import type { Movement } from "../tonal/types"; +import { getEffectiveUserId } from "../lib/auth"; +import { decryptGarminSecret, getGarminAppConfig, isGarminConfigured } from "./credentials"; +import { createAndScheduleGarminWorkout } from "./trainingApi"; +import { buildGarminStrengthWorkoutPayloadFromPlan } from "./workoutPayload"; + +const WORKOUT_IMPORT_PERMISSION = "WORKOUT_IMPORT"; +const STALE_SENDING_MS = 10 * 60 * 1000; +const ERROR_REASON_MAX_LENGTH = 300; +const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +const scheduledDateSchema = z + .string() + .regex(ISO_DATE_RE, "scheduledDate must be YYYY-MM-DD.") + .refine( + (value) => { + const parsed = new Date(`${value}T12:00:00Z`); + return !Number.isNaN(parsed.getTime()) && parsed.toISOString().startsWith(value); + }, + { message: "scheduledDate must be YYYY-MM-DD." }, + ); + +type DeliveryStatus = Doc<"garminWorkoutDeliveries">["status"]; + +export type GarminWorkoutDeliverySummary = + | { status: "none" } + | { + status: DeliveryStatus; + scheduledDate: string; + garminWorkoutId?: string; + garminScheduleId?: string; + errorReason?: string; + sentAt?: number; + updatedAt: number; + }; + +export type SendGarminWorkoutResult = + | { + success: true; + delivery: Extract; + } + | { success: false; error: string }; + +function truncate(input: string, maxLength: number): string { + return input.length <= maxLength ? input : input.slice(0, maxLength).trimEnd(); +} + +function toSummary(row: Doc<"garminWorkoutDeliveries"> | null): GarminWorkoutDeliverySummary { + if (!row) return { status: "none" }; + return { + status: row.status, + scheduledDate: row.scheduledDate, + ...(row.garminWorkoutId !== undefined && { garminWorkoutId: row.garminWorkoutId }), + ...(row.garminScheduleId !== undefined && { garminScheduleId: row.garminScheduleId }), + ...(row.errorReason !== undefined && { errorReason: row.errorReason }), + ...(row.sentAt !== undefined && { sentAt: row.sentAt }), + updatedAt: row.updatedAt, + }; +} + +export const getMyWorkoutDelivery = query({ + args: { + workoutPlanId: v.id("workoutPlans"), + scheduledDate: v.string(), + }, + handler: async (ctx, { workoutPlanId, scheduledDate }): Promise => { + const userId = await getEffectiveUserId(ctx); + if (!userId) return { status: "none" }; + + const plan = await ctx.db.get(workoutPlanId); + if (!plan || plan.userId !== userId) return { status: "none" }; + + const row = await ctx.db + .query("garminWorkoutDeliveries") + .withIndex("by_userId_workoutPlanId_scheduledDate", (q) => + q + .eq("userId", userId) + .eq("workoutPlanId", workoutPlanId) + .eq("scheduledDate", scheduledDate), + ) + .unique(); + return toSummary(row); + }, +}); + +export const startDeliveryAttempt = internalMutation({ + args: { + userId: v.id("users"), + workoutPlanId: v.id("workoutPlans"), + scheduledDate: v.string(), + }, + handler: async ( + ctx, + { userId, workoutPlanId, scheduledDate }, + ): Promise< + | { state: "claimed"; deliveryId: Id<"garminWorkoutDeliveries"> } + | { state: "already_sent"; delivery: Extract } + | { state: "in_progress" } + > => { + const now = Date.now(); + const existing = await ctx.db + .query("garminWorkoutDeliveries") + .withIndex("by_userId_workoutPlanId_scheduledDate", (q) => + q + .eq("userId", userId) + .eq("workoutPlanId", workoutPlanId) + .eq("scheduledDate", scheduledDate), + ) + .unique(); + + if (!existing) { + const deliveryId = await ctx.db.insert("garminWorkoutDeliveries", { + userId, + workoutPlanId, + scheduledDate, + status: "sending", + createdAt: now, + updatedAt: now, + }); + return { state: "claimed", deliveryId }; + } + + if (existing.status === "sent") { + return { + state: "already_sent", + delivery: toSummary(existing) as Extract, + }; + } + + if (existing.status === "sending" && existing.updatedAt > now - STALE_SENDING_MS) { + return { state: "in_progress" }; + } + + await ctx.db.patch(existing._id, { + status: "sending", + errorReason: undefined, + updatedAt: now, + }); + return { state: "claimed", deliveryId: existing._id }; + }, +}); + +export const markDeliverySent = internalMutation({ + args: { + deliveryId: v.id("garminWorkoutDeliveries"), + garminWorkoutId: v.string(), + garminScheduleId: v.optional(v.string()), + }, + handler: async ( + ctx, + { deliveryId, garminWorkoutId, garminScheduleId }, + ): Promise> => { + const now = Date.now(); + await ctx.db.patch(deliveryId, { + status: "sent", + garminWorkoutId, + ...(garminScheduleId !== undefined && { garminScheduleId }), + errorReason: undefined, + sentAt: now, + updatedAt: now, + }); + const row = await ctx.db.get(deliveryId); + return toSummary(row) as Extract; + }, +}); + +export const markDeliveryFailed = internalMutation({ + args: { + deliveryId: v.id("garminWorkoutDeliveries"), + errorReason: v.string(), + }, + handler: async (ctx, { deliveryId, errorReason }) => { + await ctx.db.patch(deliveryId, { + status: "failed", + errorReason: truncate(errorReason, ERROR_REASON_MAX_LENGTH), + updatedAt: Date.now(), + }); + }, +}); + +export const sendWorkoutPlanToGarmin = action({ + args: { + workoutPlanId: v.id("workoutPlans"), + scheduledDate: v.string(), + }, + handler: async (ctx, { workoutPlanId, scheduledDate }): Promise => { + const parsedScheduledDate = scheduledDateSchema.safeParse(scheduledDate); + if (!parsedScheduledDate.success) { + return { + success: false, + error: parsedScheduledDate.error.issues[0]?.message ?? "scheduledDate must be YYYY-MM-DD.", + }; + } + const validScheduledDate = parsedScheduledDate.data; + + if (!isGarminConfigured()) { + return { success: false, error: "Garmin integration is not available on this deployment." }; + } + + const userId = await ctx.runQuery(internal.lib.auth.resolveEffectiveUserId, {}); + if (!userId) return { success: false, error: "Not authenticated" }; + + const plan = (await ctx.runQuery(internal.workoutPlans.getById, { + planId: workoutPlanId, + userId, + })) as Doc<"workoutPlans"> | null; + if (!plan) return { success: false, error: "Workout plan not found." }; + + const connection = await ctx.runQuery(internal.garmin.connections.getActiveConnectionByUserId, { + userId, + }); + if (!connection) return { success: false, error: "Garmin is not connected." }; + if (!connection.permissions.includes(WORKOUT_IMPORT_PERMISSION)) { + return { + success: false, + error: "Garmin workout import permission is not enabled for this connection.", + }; + } + + try { + await rateLimiter.limit(ctx, "sendGarminWorkout", { key: userId, throws: true }); + } catch (error) { + return { + success: false, + error: isRateLimitError(error) + ? "Too many Garmin send attempts. Please wait and try again." + : "Unable to send this workout to Garmin right now.", + }; + } + + const claim = await ctx.runMutation(internal.garmin.workoutDelivery.startDeliveryAttempt, { + userId, + workoutPlanId, + scheduledDate: validScheduledDate, + }); + if (claim.state === "already_sent") return { success: true, delivery: claim.delivery }; + if (claim.state === "in_progress") { + return { success: false, error: "Garmin delivery is already in progress." }; + } + + try { + const [accessToken, accessTokenSecret, movements] = await Promise.all([ + decryptGarminSecret(connection.accessTokenEncrypted), + decryptGarminSecret(connection.accessTokenSecretEncrypted), + ctx.runQuery(internal.tonal.movementSync.getAllMovements) as Promise, + ]); + const config = getGarminAppConfig(); + const deliveryResult = await createAndScheduleGarminWorkout({ + credentials: { + consumerKey: config.consumerKey, + consumerSecret: config.consumerSecret, + token: accessToken, + tokenSecret: accessTokenSecret, + }, + payload: buildGarminStrengthWorkoutPayloadFromPlan({ + workoutPlanId, + title: plan.title, + blocks: plan.blocks, + movements, + scheduledDate: validScheduledDate, + }), + scheduledDate: validScheduledDate, + }); + + const delivery = await ctx.runMutation(internal.garmin.workoutDelivery.markDeliverySent, { + deliveryId: claim.deliveryId, + garminWorkoutId: deliveryResult.garminWorkoutId, + garminScheduleId: deliveryResult.garminScheduleId, + }); + return { success: true, delivery }; + } catch (error) { + const message = error instanceof Error ? error.message : "Garmin workout delivery failed."; + await ctx.runMutation(internal.garmin.workoutDelivery.markDeliveryFailed, { + deliveryId: claim.deliveryId, + errorReason: message, + }); + return { success: false, error: message }; + } + }, +}); diff --git a/convex/garmin/workoutPayload.ts b/convex/garmin/workoutPayload.ts new file mode 100644 index 00000000..36e9be4c --- /dev/null +++ b/convex/garmin/workoutPayload.ts @@ -0,0 +1,263 @@ +import type { BlockInput, ExerciseInput } from "../tonal/transforms"; +import { TONAL_REST_MOVEMENT_ID } from "../tonal/transforms"; +import type { Movement } from "../tonal/types"; + +const GARMIN_WORKOUT_PROVIDER = "Roni"; +const MAX_WORKOUT_NAME_LENGTH = 80; +const MAX_DESCRIPTION_LENGTH = 512; +const MAX_STEP_DESCRIPTION_LENGTH = 80; +const DEFAULT_REPS = 10; +const DEFAULT_DURATION_SECONDS = 30; +const DEFAULT_REST_SECONDS = 60; + +interface MovementLookup { + name: string; + countReps: boolean; + isAlternating: boolean; +} + +type GarminIntensity = "REST" | "WARMUP" | "INTERVAL"; +type GarminDurationType = "TIME" | "REPS"; + +export interface GarminWorkoutStep { + type: "WorkoutStep"; + stepOrder: number; + intensity: GarminIntensity; + description: string | null; + durationType: GarminDurationType; + durationValue: number; + durationValueType: null; + targetType: "OPEN"; + targetValue: null; + targetValueLow: null; + targetValueHigh: null; + targetValueType: null; + strokeType: null; + equipmentType: null; + exerciseCategory: string | null; + exerciseName: string | null; + weightValue: null; + weightDisplayUnit: null; +} + +export interface GarminWorkoutPayload { + workoutName: string; + description: string; + sport: "STRENGTH_TRAINING"; + workoutProvider: string; + workoutSourceId: string; + steps: GarminWorkoutStep[]; +} + +function truncate(input: string, maxLength: number): string { + return input.length <= maxLength ? input : input.slice(0, maxLength).trimEnd(); +} + +function normalizeName(input: string): string { + return input + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +export function inferGarminExerciseCategory(exerciseName: string): string { + const normalized = normalizeName(exerciseName); + const matchers: { category: string; patterns: readonly string[] }[] = [ + { category: "BENCH_PRESS", patterns: ["bench press", "chest press"] }, + { category: "CALF_RAISE", patterns: ["calf raise"] }, + { category: "CARRY", patterns: ["carry"] }, + { category: "CHOP", patterns: ["chop"] }, + { category: "CORE", patterns: ["core"] }, + { category: "CRUNCH", patterns: ["crunch"] }, + { category: "CURL", patterns: ["curl"] }, + { category: "DEADLIFT", patterns: ["deadlift"] }, + { category: "FLYE", patterns: ["fly", "flye"] }, + { category: "HIP_RAISE", patterns: ["hip raise", "glute bridge"] }, + { category: "HIP_STABILITY", patterns: ["hip stability"] }, + { category: "HYPEREXTENSION", patterns: ["hyperextension"] }, + { category: "LATERAL_RAISE", patterns: ["lateral raise"] }, + { category: "LEG_CURL", patterns: ["leg curl", "hamstring curl"] }, + { category: "LEG_RAISE", patterns: ["leg raise"] }, + { category: "LUNGE", patterns: ["lunge", "split squat"] }, + { category: "OLYMPIC_LIFT", patterns: ["clean", "snatch"] }, + { category: "PLANK", patterns: ["plank"] }, + { category: "PULL_UP", patterns: ["pull up", "chin up"] }, + { category: "PUSH_UP", patterns: ["push up"] }, + { category: "ROW", patterns: ["row"] }, + { category: "SHOULDER_PRESS", patterns: ["shoulder press", "overhead press"] }, + { category: "SHRUG", patterns: ["shrug"] }, + { category: "SIT_UP", patterns: ["sit up"] }, + { category: "SQUAT", patterns: ["squat"] }, + { category: "TRICEPS_EXTENSION", patterns: ["triceps", "tricep"] }, + ]; + + for (const matcher of matchers) { + if (matcher.patterns.some((pattern) => normalized.includes(pattern))) { + return matcher.category; + } + } + return "UNKNOWN"; +} + +function describeFlags(exercise: ExerciseInput): string | null { + const flags = [ + exercise.warmUp ? "warm-up" : null, + exercise.eccentric ? "eccentric" : null, + exercise.chains ? "chains" : null, + exercise.burnout ? "burnout" : null, + exercise.dropSet ? "drop set" : null, + exercise.spotter ? "spotter" : null, + ].filter((flag): flag is string => flag !== null); + return flags.length > 0 ? flags.join(", ") : null; +} + +function resolveDuration( + exercise: ExerciseInput, + movement: MovementLookup | undefined, +): { durationType: GarminDurationType; durationValue: number } { + if (exercise.duration != null || movement?.countReps === false) { + return { + durationType: "TIME", + durationValue: Math.max(1, Math.round(exercise.duration ?? DEFAULT_DURATION_SECONDS)), + }; + } + + const baseReps = Math.max(1, Math.round(exercise.reps ?? DEFAULT_REPS)); + return { + durationType: "REPS", + durationValue: movement?.isAlternating ? baseReps * 2 : baseReps, + }; +} + +function buildRestStep(stepOrder: number, exercise: ExerciseInput): GarminWorkoutStep { + return { + type: "WorkoutStep", + stepOrder, + intensity: "REST", + description: null, + durationType: "TIME", + durationValue: Math.max(1, Math.round(exercise.duration ?? DEFAULT_REST_SECONDS)), + durationValueType: null, + targetType: "OPEN", + targetValue: null, + targetValueLow: null, + targetValueHigh: null, + targetValueType: null, + strokeType: null, + equipmentType: null, + exerciseCategory: null, + exerciseName: "Rest", + weightValue: null, + weightDisplayUnit: null, + }; +} + +function buildExerciseStep({ + stepOrder, + exercise, + movement, + round, +}: { + stepOrder: number; + exercise: ExerciseInput; + movement: MovementLookup | undefined; + round: number; +}): GarminWorkoutStep { + const exerciseName = movement?.name ?? exercise.movementId; + const duration = resolveDuration(exercise, movement); + const flags = describeFlags(exercise); + const description = flags + ? truncate(`Set ${round} of ${exercise.sets}: ${flags}`, MAX_STEP_DESCRIPTION_LENGTH) + : null; + + return { + type: "WorkoutStep", + stepOrder, + intensity: exercise.warmUp ? "WARMUP" : "INTERVAL", + description, + durationType: duration.durationType, + durationValue: duration.durationValue, + durationValueType: null, + targetType: "OPEN", + targetValue: null, + targetValueLow: null, + targetValueHigh: null, + targetValueType: null, + strokeType: null, + equipmentType: null, + exerciseCategory: inferGarminExerciseCategory(exerciseName), + exerciseName, + weightValue: null, + weightDisplayUnit: null, + }; +} + +function expandBlocksToGarminSteps( + blocks: readonly BlockInput[], + movementMap: ReadonlyMap, +): GarminWorkoutStep[] { + const steps: GarminWorkoutStep[] = []; + + for (const block of blocks) { + const maxRounds = Math.max(...block.exercises.map((exercise) => Math.floor(exercise.sets)), 0); + for (let round = 1; round <= maxRounds; round += 1) { + for (const exercise of block.exercises) { + if (round > Math.floor(exercise.sets)) continue; + const stepOrder = steps.length + 1; + if (exercise.movementId === TONAL_REST_MOVEMENT_ID) { + steps.push(buildRestStep(stepOrder, exercise)); + continue; + } + steps.push( + buildExerciseStep({ + stepOrder, + exercise, + movement: movementMap.get(exercise.movementId), + round, + }), + ); + } + } + } + + return steps; +} + +function movementsToIdLookup(movements: readonly Movement[]): Map { + return new Map( + movements.map((movement) => [ + movement.id, + { + name: movement.name, + countReps: movement.countReps, + isAlternating: movement.isAlternating, + }, + ]), + ); +} + +export function buildGarminStrengthWorkoutPayloadFromPlan({ + workoutPlanId, + title, + blocks, + movements, + scheduledDate, +}: { + workoutPlanId: string; + title: string; + blocks: readonly BlockInput[]; + movements: readonly Movement[]; + scheduledDate: string; +}): GarminWorkoutPayload { + const steps = expandBlocksToGarminSteps(blocks, movementsToIdLookup(movements)); + if (steps.length === 0) throw new Error("Workout has no exercises to send to Garmin"); + + return { + workoutName: truncate(title, MAX_WORKOUT_NAME_LENGTH), + description: truncate(`Roni workout scheduled for ${scheduledDate}.`, MAX_DESCRIPTION_LENGTH), + sport: "STRENGTH_TRAINING", + workoutProvider: GARMIN_WORKOUT_PROVIDER, + workoutSourceId: `roni:${workoutPlanId}`, + steps, + }; +} diff --git a/convex/migrations/repairOrphanedAuthAccounts.ts b/convex/migrations/repairOrphanedAuthAccounts.ts index 30c8c761..cf8a44b8 100644 --- a/convex/migrations/repairOrphanedAuthAccounts.ts +++ b/convex/migrations/repairOrphanedAuthAccounts.ts @@ -57,6 +57,7 @@ const USER_TABLE_BATCH_SAFETY_INDEXES = { garminOauthStates: "by_userId", garminWebhookEvents: "by_userId", garminWellnessDaily: "by_userId", + garminWorkoutDeliveries: "by_userId", injuries: "by_userId", muscleReadiness: "by_userId", strengthScoreSnapshots: "by_userId_date", diff --git a/convex/rateLimits.ts b/convex/rateLimits.ts index f2340638..6285a6bd 100644 --- a/convex/rateLimits.ts +++ b/convex/rateLimits.ts @@ -166,4 +166,10 @@ export const rateLimiter = new RateLimiter(components.rateLimiter, { period: MINUTE, capacity: 3, }, + sendGarminWorkout: { + kind: "token bucket", + rate: 12, + period: HOUR, + capacity: 6, + }, }); diff --git a/convex/schedule.ts b/convex/schedule.ts index 5d32133e..650ceca0 100644 --- a/convex/schedule.ts +++ b/convex/schedule.ts @@ -6,7 +6,7 @@ import { action } from "./_generated/server"; import { api, internal } from "./_generated/api"; -import type { Doc } from "./_generated/dataModel"; +import type { Doc, Id } from "./_generated/dataModel"; import type { EnrichedWeekPlan } from "./weekPlanEnriched"; import type { Movement } from "./tonal/types"; import { DAY_NAMES } from "./coach/weekProgrammingHelpers"; @@ -36,6 +36,7 @@ export interface ScheduleDay { date: string; sessionType: string; derivedStatus: "rest" | "programmed" | "completed" | "failed"; + workoutPlanId?: Id<"workoutPlans">; workoutTitle?: string; exercises: ScheduleExercise[]; estimatedDuration?: number; @@ -163,6 +164,7 @@ export const getScheduleData = action({ date: dayDate(enriched.weekStartDate, i), sessionType: day.sessionType, derivedStatus: day.derivedStatus, + ...(wp && { workoutPlanId: wp._id }), workoutTitle: wp?.title, exercises, estimatedDuration: day.estimatedDuration, diff --git a/convex/schema.ts b/convex/schema.ts index dfed869f..86631ad5 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -834,6 +834,26 @@ export default defineSchema({ .index("by_garminUserId_status", ["garminUserId", "status"]) .index("by_status", ["status"]), + /** Per-plan Garmin Training API delivery state for coach-generated workouts. */ + garminWorkoutDeliveries: defineTable({ + userId: v.id("users"), + workoutPlanId: v.id("workoutPlans"), + /** ISO date (YYYY-MM-DD) the workout was scheduled for in Garmin Connect. */ + scheduledDate: v.string(), + status: v.union(v.literal("sending"), v.literal("sent"), v.literal("failed")), + /** Garmin Training API workout id returned by POST /training-api/workout/. */ + garminWorkoutId: v.optional(v.string()), + /** Garmin Training API schedule id returned by POST /training-api/schedule/, when provided. */ + garminScheduleId: v.optional(v.string()), + errorReason: v.optional(v.string()), + createdAt: v.number(), + updatedAt: v.number(), + sentAt: v.optional(v.number()), + }) + .index("by_userId", ["userId"]) + .index("by_workoutPlanId", ["workoutPlanId"]) + .index("by_userId_workoutPlanId_scheduledDate", ["userId", "workoutPlanId", "scheduledDate"]), + /** * Daily wellness rollup from Garmin Health API. Garmin sends separate * Push payloads per domain (Daily Summary, Sleep Summary, Stress diff --git a/convex/userData.test.ts b/convex/userData.test.ts index 1b90b29f..46af86a6 100644 --- a/convex/userData.test.ts +++ b/convex/userData.test.ts @@ -98,6 +98,48 @@ describe("USER_DATA_TABLES", () => { expect(data.garminWellnessDaily[0]).not.toHaveProperty("userId"); }); + test("collectUserData exports Garmin workout deliveries without Convex metadata", async () => { + const t = convexTest(schema, modules); + const userId = await t.run(async (ctx) => { + const id = await ctx.db.insert("users", {}); + const workoutPlanId = await ctx.db.insert("workoutPlans", { + userId: id, + title: "Push Day", + blocks: [{ exercises: [{ movementId: "bench", sets: 3, reps: 8 }] }], + status: "pushed", + createdAt: 1_714_000_000_000, + }); + await ctx.db.insert("garminWorkoutDeliveries", { + userId: id, + workoutPlanId, + scheduledDate: "2026-05-05", + status: "sent", + garminWorkoutId: "123", + garminScheduleId: "456", + createdAt: 1_714_000_000_000, + updatedAt: 1_714_000_001_000, + sentAt: 1_714_000_001_000, + }); + return id; + }); + + const data = await t.query(internal.dataExport.collectUserData, { userId }); + + expect(data.garminWorkoutDeliveries).toHaveLength(1); + expect(data.garminWorkoutDeliveries[0]).toMatchObject({ + scheduledDate: "2026-05-05", + status: "sent", + garminWorkoutId: "123", + garminScheduleId: "456", + createdAt: 1_714_000_000_000, + updatedAt: 1_714_000_001_000, + sentAt: 1_714_000_001_000, + }); + expect(data.garminWorkoutDeliveries[0]).not.toHaveProperty("_id"); + expect(data.garminWorkoutDeliveries[0]).not.toHaveProperty("_creationTime"); + expect(data.garminWorkoutDeliveries[0]).not.toHaveProperty("userId"); + }); + test("collectUserData exports exercise exclusions", async () => { const t = convexTest(schema, modules); const userId = await t.run(async (ctx) => { diff --git a/convex/userData.ts b/convex/userData.ts index 44ca7171..6efd10cd 100644 --- a/convex/userData.ts +++ b/convex/userData.ts @@ -46,6 +46,11 @@ export const USER_DATA_TABLES = [ { table: "authAccounts", delete: "authData", jsonExportKey: null }, { table: "garminConnections", delete: "byUserIdBatch", jsonExportKey: null }, { table: "garminOauthStates", delete: "byUserIdBatch", jsonExportKey: null }, + { + table: "garminWorkoutDeliveries", + delete: "byUserIdBatch", + jsonExportKey: "garminWorkoutDeliveries", + }, { table: "garminWellnessDaily", delete: "byUserIdBatch", diff --git a/src/app/(app)/chat/WelcomeInput.tsx b/src/app/(app)/chat/WelcomeInput.tsx index 2c349360..2b3f0f6e 100644 --- a/src/app/(app)/chat/WelcomeInput.tsx +++ b/src/app/(app)/chat/WelcomeInput.tsx @@ -6,6 +6,8 @@ import { api } from "../../../../convex/_generated/api"; import type { Id } from "../../../../convex/_generated/dataModel"; import { Button } from "@/components/ui/button"; import { ImagePreviewRow } from "@/features/chat/ImagePreviewRow"; +import { FailureBanner, type FailureReason } from "@/features/byok/FailureBanner"; +import { parseByokError } from "@/features/byok/parseByokError"; import { useImageUpload } from "@/hooks/useImageUpload"; import { useAnalytics } from "@/lib/analytics"; import { ImagePlus, Loader2, SendHorizontal } from "lucide-react"; @@ -21,6 +23,7 @@ export function WelcomeInput({ const [input, setInput] = useState(""); const [sending, setSending] = useState(false); const [error, setError] = useState(null); + const [byokError, setByokError] = useState(null); const fileInputRef = useRef(null); const { track } = useAnalytics(); @@ -60,14 +63,22 @@ export function WelcomeInput({ prompt: trimmed || "What do you see in these images?", ...(imageStorageIds && imageStorageIds.length > 0 && { imageStorageIds }), }); + setByokError(null); track("message_sent", { message_length: trimmed.length, has_images: imageCount > 0, image_count: imageCount, }); - } catch { + } catch (err) { setInput(trimmed); - setError("Message failed to send. Please try again."); + console.error("Welcome send failed:", err); + const byokReason = parseByokError(err); + if (byokReason) { + setByokError(byokReason); + setError(null); + } else { + setError("Message failed to send. Please try again."); + } } finally { setSending(false); } @@ -85,13 +96,19 @@ export function WelcomeInput({ return (
- {error && ( -
- {error} + {byokError ? ( +
+
+ ) : ( + error && ( +
+ {error} +
+ ) )}
diff --git a/src/app/(app)/chat/page.tsx b/src/app/(app)/chat/page.tsx index f8246058..f11209cb 100644 --- a/src/app/(app)/chat/page.tsx +++ b/src/app/(app)/chat/page.tsx @@ -6,6 +6,8 @@ import { useAction, useQuery } from "convex/react"; import { api } from "../../../../convex/_generated/api"; import type { Id } from "../../../../convex/_generated/dataModel"; import { ChatThread } from "@/features/chat/ChatThread"; +import { FailureBanner, type FailureReason } from "@/features/byok/FailureBanner"; +import { parseByokError } from "@/features/byok/parseByokError"; import { useAnalytics } from "@/lib/analytics"; import { getBrowserTimezone } from "@/lib/timezone"; import { Activity, Dumbbell, Flame, Loader2, Sparkles, TrendingUp, Zap } from "lucide-react"; @@ -40,6 +42,7 @@ function ChatPageInner() { const mountedRef = useRef(true); const [waitingForCoach, setWaitingForCoach] = useState(false); const [sendError, setSendError] = useState(null); + const [byokError, setByokError] = useState(null); const { track } = useAnalytics(); useEffect(() => { @@ -50,7 +53,10 @@ function ChatPageInner() { }, []); const sendAndWait = async (args: { prompt: string; imageStorageIds?: Id<"_storage">[] }) => { - if (mountedRef.current) setSendError(null); + if (mountedRef.current) { + setSendError(null); + setByokError(null); + } if (mountedRef.current) setWaitingForCoach(true); try { return await createThreadWithMessage({ ...args, userTimezone: getBrowserTimezone() }); @@ -73,7 +79,12 @@ function ChatPageInner() { // Reset so the user can manually retry the same prompt from the input. autoSentRef.current = false; if (mountedRef.current) { - setSendError(SEND_ERROR_MESSAGE); + const byokReason = parseByokError(err); + if (byokReason) { + setByokError(byokReason); + } else { + setSendError(SEND_ERROR_MESSAGE); + } } } })(); @@ -127,7 +138,12 @@ function ChatPageInner() { sendAndWait({ prompt: text }).catch((err: unknown) => { console.error("Suggestion send failed:", err); if (mountedRef.current) { - setSendError(SEND_ERROR_MESSAGE); + const byokReason = parseByokError(err); + if (byokReason) { + setByokError(byokReason); + } else { + setSendError(SEND_ERROR_MESSAGE); + } } }); }} @@ -139,10 +155,16 @@ function ChatPageInner() { ))}
- {sendError && ( -

- {sendError} -

+ {byokError ? ( +
+ +
+ ) : ( + sendError && ( +

+ {sendError} +

+ ) )}
diff --git a/src/app/(app)/schedule/[dayIndex]/page.tsx b/src/app/(app)/schedule/[dayIndex]/page.tsx index dd8e6404..d546dd36 100644 --- a/src/app/(app)/schedule/[dayIndex]/page.tsx +++ b/src/app/(app)/schedule/[dayIndex]/page.tsx @@ -8,6 +8,7 @@ import { useActionData } from "@/hooks/useActionData"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { ErrorAlert } from "@/components/ErrorAlert"; +import { GarminWorkoutDeliveryCard } from "@/features/schedule/GarminWorkoutDeliveryCard"; import { StatusBadge } from "@/features/schedule/StatusBadge"; import { ArrowLeft, Clock, Dumbbell, MessageSquare } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -132,6 +133,14 @@ export default function ScheduleDayPage({ params }: { params: Promise<{ dayIndex />
+ {day.workoutPlanId && day.exercises.length > 0 && ( + + )} + {/* Full exercise list */}

diff --git a/src/features/schedule/GarminWorkoutDeliveryCard.test.tsx b/src/features/schedule/GarminWorkoutDeliveryCard.test.tsx new file mode 100644 index 00000000..b537a197 --- /dev/null +++ b/src/features/schedule/GarminWorkoutDeliveryCard.test.tsx @@ -0,0 +1,144 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { GarminWorkoutDeliveryCard } from "./GarminWorkoutDeliveryCard"; +import type { Id } from "../../../convex/_generated/dataModel"; + +const mockSendToGarmin = vi.fn(); +let mockConnection: unknown; +let mockDelivery: unknown; + +vi.mock("convex/react", () => ({ + useAction: () => mockSendToGarmin, + useQuery: (ref: string) => { + if (ref === "garmin:connections:getMyGarminStatus") return mockConnection; + if (ref === "garmin:workoutDelivery:getMyWorkoutDelivery") return mockDelivery; + throw new Error(`Unexpected query ${ref}`); + }, +})); + +vi.mock("../../../convex/_generated/api", () => ({ + api: { + garmin: { + connections: { + getMyGarminStatus: "garmin:connections:getMyGarminStatus", + }, + workoutDelivery: { + getMyWorkoutDelivery: "garmin:workoutDelivery:getMyWorkoutDelivery", + sendWorkoutPlanToGarmin: "garmin:workoutDelivery:sendWorkoutPlanToGarmin", + }, + }, + }, +})); + +const workoutPlanId = "plan-1" as Id<"workoutPlans">; + +function renderCard(isPast = false) { + return render( + , + ); +} + +describe("GarminWorkoutDeliveryCard", () => { + beforeEach(() => { + mockSendToGarmin.mockReset(); + mockConnection = { + state: "active", + garminUserId: "garmin-user-1", + connectedAt: Date.UTC(2026, 4, 1), + permissions: ["WORKOUT_IMPORT", "ACTIVITY_EXPORT"], + }; + mockDelivery = { status: "none" }; + }); + + it("sends the scheduled workout to Garmin", async () => { + mockSendToGarmin.mockResolvedValueOnce({ + success: true, + delivery: { + status: "sent", + scheduledDate: "2026-05-05", + garminWorkoutId: "123", + updatedAt: Date.UTC(2026, 4, 5), + }, + }); + renderCard(); + + fireEvent.click(screen.getByRole("button", { name: /send to garmin/i })); + + await waitFor(() => { + expect(mockSendToGarmin).toHaveBeenCalledWith({ + workoutPlanId, + scheduledDate: "2026-05-05", + }); + }); + }); + + it("shows sent state without allowing duplicate sends", () => { + mockDelivery = { + status: "sent", + scheduledDate: "2026-05-05", + garminWorkoutId: "123", + updatedAt: Date.UTC(2026, 4, 5), + sentAt: Date.UTC(2026, 4, 5), + }; + + renderCard(); + + expect(screen.getByText(/sent on may 5/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /sent/i })).toBeDisabled(); + }); + + it("links to settings when Garmin is not connected", () => { + mockConnection = { state: "none" }; + + renderCard(); + + expect(screen.getByRole("button", { name: /connect garmin/i })).toHaveAttribute( + "href", + "/settings", + ); + }); + + it("blocks sends when workout import permission is missing", () => { + mockConnection = { + state: "active", + garminUserId: "garmin-user-1", + connectedAt: Date.UTC(2026, 4, 1), + permissions: ["ACTIVITY_EXPORT"], + }; + + renderCard(); + + expect(screen.getByRole("button", { name: /missing permission/i })).toBeDisabled(); + }); + + it("shows failed action errors", async () => { + mockSendToGarmin.mockResolvedValueOnce({ + success: false, + error: "Garmin rate-limited workout.", + }); + renderCard(); + + fireEvent.click(screen.getByRole("button", { name: /send to garmin/i })); + + expect(await screen.findByText("Garmin rate-limited workout.")).toBeInTheDocument(); + }); + + it("disables sends for past schedule dates", () => { + renderCard(true); + + expect(screen.getByRole("button", { name: /past date/i })).toBeDisabled(); + }); + + it("uses a neutral loading action while queries are unresolved", () => { + mockConnection = undefined; + mockDelivery = undefined; + + renderCard(); + + expect(screen.getByRole("button", { name: /checking/i })).toBeDisabled(); + }); +}); diff --git a/src/features/schedule/GarminWorkoutDeliveryCard.tsx b/src/features/schedule/GarminWorkoutDeliveryCard.tsx new file mode 100644 index 00000000..d7edde3e --- /dev/null +++ b/src/features/schedule/GarminWorkoutDeliveryCard.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useAction, useQuery } from "convex/react"; +import { AlertTriangle, CheckCircle2, Loader2, Send, Watch } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { api } from "../../../convex/_generated/api"; +import type { Id } from "../../../convex/_generated/dataModel"; +import type { GarminWorkoutDeliverySummary } from "../../../convex/garmin/workoutDelivery"; + +interface GarminWorkoutDeliveryCardProps { + workoutPlanId: Id<"workoutPlans">; + scheduledDate: string; + isPast: boolean; +} + +const PERMISSION_WORKOUT_IMPORT = "WORKOUT_IMPORT"; + +const shortDateFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + timeZone: "UTC", +}); + +function formatShortDate(date: string | number): string { + const parsed = typeof date === "number" ? new Date(date) : new Date(`${date}T12:00:00Z`); + return shortDateFormatter.format(parsed); +} + +function getDeliveryLabel(delivery: GarminWorkoutDeliverySummary | undefined): string { + if (!delivery) return "Checking Garmin..."; + if (delivery.status === "sent") { + return delivery.sentAt !== undefined + ? `Sent on ${formatShortDate(delivery.sentAt)}` + : `Sent for ${formatShortDate(delivery.scheduledDate)}`; + } + if (delivery.status === "sending") return "Sending..."; + if (delivery.status === "failed") return "Send failed"; + return "Ready"; +} + +function getActionLabel({ + isLoading, + isInFlight, + isSent, + isPast, + hasWorkoutPermission, +}: { + isLoading: boolean; + isInFlight: boolean; + isSent: boolean; + isPast: boolean; + hasWorkoutPermission: boolean; +}) { + if (isLoading) return "Checking"; + if (isInFlight) return "Sending"; + if (isSent) return "Sent"; + if (isPast) return "Past date"; + return hasWorkoutPermission ? "Send to Garmin" : "Missing permission"; +} + +export function GarminWorkoutDeliveryCard({ + workoutPlanId, + scheduledDate, + isPast, +}: GarminWorkoutDeliveryCardProps) { + const [isSending, setIsSending] = useState(false); + const [localError, setLocalError] = useState(null); + const connection = useQuery(api.garmin.connections.getMyGarminStatus, {}); + const delivery = useQuery(api.garmin.workoutDelivery.getMyWorkoutDelivery, { + workoutPlanId, + scheduledDate, + }); + const sendToGarmin = useAction(api.garmin.workoutDelivery.sendWorkoutPlanToGarmin); + + const isLoading = connection === undefined || delivery === undefined; + const isSent = delivery?.status === "sent"; + const isInFlight = isSending || delivery?.status === "sending"; + const isConnected = connection?.state === "active"; + const hasWorkoutPermission = + connection?.state === "active" && connection.permissions.includes(PERMISSION_WORKOUT_IMPORT); + const canSend = !isLoading && isConnected && hasWorkoutPermission && !isPast && !isSent; + const visibleError = localError ?? (delivery?.status === "failed" ? delivery.errorReason : null); + + async function handleSend() { + if (!canSend || isInFlight) return; + setIsSending(true); + setLocalError(null); + try { + const result = await sendToGarmin({ workoutPlanId, scheduledDate }); + if (!result.success) setLocalError(result.error); + } catch (error) { + setLocalError(error instanceof Error ? error.message : "Garmin send failed."); + } finally { + setIsSending(false); + } + } + + return ( + + +
+ + +
+
+

Garmin

+ + {getDeliveryLabel(delivery)} + +
+ {visibleError ? ( +

+

+ ) : ( +

+ Scheduled for {formatShortDate(scheduledDate)} +

+ )} +
+
+ + {!isConnected && !isLoading ? ( + + ) : ( + + )} +
+
+ ); +}