diff --git a/apps/journal/app/lib/sync/pushes.server.test.ts b/apps/journal/app/lib/sync/pushes.server.test.ts
new file mode 100644
index 0000000..2f6fc3c
--- /dev/null
+++ b/apps/journal/app/lib/sync/pushes.server.test.ts
@@ -0,0 +1,245 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+const SHORT_GPX = `
+
+
+ 34.0
+ 34.5
+ 35.0
+
+`;
+
+const mockGetRoute = vi.fn();
+const mockGetConnection = vi.fn();
+const mockUpdateTokens = vi.fn();
+const mockGetProvider = vi.fn();
+
+const mockSelect = vi.fn();
+const mockInsert = vi.fn();
+const mockUpdate = vi.fn();
+
+vi.mock("../db.ts", () => ({
+ getDb: () => ({
+ select: mockSelect,
+ insert: mockInsert,
+ update: mockUpdate,
+ }),
+}));
+vi.mock("../routes.server.ts", () => ({ getRoute: mockGetRoute }));
+vi.mock("./connections.server.ts", () => ({
+ getConnection: mockGetConnection,
+ updateTokens: mockUpdateTokens,
+}));
+vi.mock("./registry.ts", () => ({ getProvider: mockGetProvider }));
+
+// Each call to db.select returns a chainable thenable resolving to the next
+// queued result. The pipeline does two selects: routeVersions, sync_pushes.
+function queueSelectResults(...results: unknown[][]) {
+ for (const r of results) {
+ mockSelect.mockReturnValueOnce({
+ from: () => ({
+ where: () => ({
+ orderBy: () => ({
+ limit: () => Promise.resolve(r),
+ }),
+ limit: () => Promise.resolve(r),
+ }),
+ }),
+ });
+ }
+}
+
+function chainNoop() {
+ return {
+ values: () => Promise.resolve(),
+ set: () => ({ where: () => Promise.resolve() }),
+ };
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mockInsert.mockImplementation(() => chainNoop());
+ mockUpdate.mockImplementation(() => chainNoop());
+});
+
+async function importPipeline() {
+ const mod = await import("./pushes.server.ts");
+ return mod;
+}
+
+const baseRoute = {
+ id: "r1",
+ ownerId: "u1",
+ name: "Test route",
+ description: "A test",
+ gpx: SHORT_GPX,
+};
+const baseConnection = {
+ id: "c1",
+ userId: "u1",
+ provider: "wahoo",
+ accessToken: "tok",
+ refreshToken: "ref",
+ expiresAt: new Date(Date.now() + 3600_000),
+ providerUserId: "42",
+ grantedScopes: ["routes_write", "workouts_read"],
+};
+
+function makeProvider(over: Record = {}) {
+ return {
+ id: "wahoo",
+ name: "Wahoo",
+ scopes: ["routes_write"],
+ pushRoute: vi.fn(),
+ refreshToken: vi.fn(),
+ ...over,
+ };
+}
+
+describe("pushRouteToProvider", () => {
+ it("performs a fresh push and records sync_pushes", async () => {
+ mockGetRoute.mockResolvedValue(baseRoute);
+ mockGetConnection.mockResolvedValue(baseConnection);
+ queueSelectResults([{ version: 2, gpx: SHORT_GPX }], []); // route version, sync_pushes existing
+
+ const provider = makeProvider();
+ provider.pushRoute.mockResolvedValue({ remoteId: "wahoo-9" });
+ mockGetProvider.mockReturnValue(provider);
+
+ const { pushRouteToProvider } = await importPipeline();
+ const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" });
+
+ expect(out).toMatchObject({ status: "success", remoteId: "wahoo-9" });
+ expect(provider.pushRoute).toHaveBeenCalledOnce();
+ const [, payload] = provider.pushRoute.mock.calls[0]!;
+ expect(payload.externalId).toBe("route:r1:v2");
+ expect(payload.startLat).toBeCloseTo(52.52, 4);
+ expect(mockInsert).toHaveBeenCalledOnce();
+ expect(mockUpdate).not.toHaveBeenCalled();
+ });
+
+ it("short-circuits when the version is already pushed", async () => {
+ mockGetRoute.mockResolvedValue(baseRoute);
+ mockGetConnection.mockResolvedValue(baseConnection);
+ queueSelectResults(
+ [{ version: 1, gpx: SHORT_GPX }],
+ [{ id: "p1", pushedAt: new Date("2026-04-01T00:00:00Z"), remoteId: "wahoo-7" }],
+ );
+
+ const provider = makeProvider();
+ mockGetProvider.mockReturnValue(provider);
+
+ const { pushRouteToProvider } = await importPipeline();
+ const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" });
+
+ expect(out).toMatchObject({ status: "success", remoteId: "wahoo-7" });
+ expect(provider.pushRoute).not.toHaveBeenCalled();
+ });
+
+ it("retries a failed prior attempt and updates the existing row", async () => {
+ mockGetRoute.mockResolvedValue(baseRoute);
+ mockGetConnection.mockResolvedValue(baseConnection);
+ queueSelectResults(
+ [{ version: 1, gpx: SHORT_GPX }],
+ [{ id: "p1", pushedAt: null, remoteId: null, error: "generic: boom" }],
+ );
+
+ const provider = makeProvider();
+ provider.pushRoute.mockResolvedValue({ remoteId: "wahoo-77" });
+ mockGetProvider.mockReturnValue(provider);
+
+ const { pushRouteToProvider } = await importPipeline();
+ const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" });
+
+ expect(out).toMatchObject({ status: "success", remoteId: "wahoo-77" });
+ expect(mockUpdate).toHaveBeenCalledOnce();
+ expect(mockInsert).not.toHaveBeenCalled();
+ });
+
+ it("redirects via scope_missing when grantedScopes lacks routes_write", async () => {
+ mockGetRoute.mockResolvedValue(baseRoute);
+ mockGetConnection.mockResolvedValue({ ...baseConnection, grantedScopes: ["workouts_read"] });
+ mockGetProvider.mockReturnValue(makeProvider());
+
+ const { pushRouteToProvider } = await importPipeline();
+ const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" });
+
+ expect(out).toEqual({ status: "scope_missing" });
+ });
+
+ it("returns no_connection when no sync_connections row exists", async () => {
+ mockGetRoute.mockResolvedValue(baseRoute);
+ mockGetConnection.mockResolvedValue(null);
+ mockGetProvider.mockReturnValue(makeProvider());
+
+ const { pushRouteToProvider } = await importPipeline();
+ const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" });
+ expect(out).toEqual({ status: "no_connection" });
+ });
+
+ it("returns not_owner when the requesting user isn't the owner", async () => {
+ mockGetRoute.mockResolvedValue({ ...baseRoute, ownerId: "other" });
+ mockGetProvider.mockReturnValue(makeProvider());
+
+ const { pushRouteToProvider } = await importPipeline();
+ const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" });
+ expect(out).toEqual({ status: "not_owner" });
+ });
+
+ it("refreshes tokens and retries once on token_expired", async () => {
+ mockGetRoute.mockResolvedValue(baseRoute);
+ mockGetConnection.mockResolvedValue(baseConnection);
+ queueSelectResults([{ version: 1, gpx: SHORT_GPX }], []);
+
+ const provider = makeProvider();
+ const { PushError } = await import("./types.ts");
+ provider.pushRoute
+ .mockRejectedValueOnce(new PushError("token_expired", "401", 401))
+ .mockResolvedValueOnce({ remoteId: "wahoo-after-refresh" });
+ provider.refreshToken.mockResolvedValue({
+ accessToken: "new-tok",
+ refreshToken: "new-ref",
+ expiresAt: new Date(Date.now() + 3600_000),
+ });
+ mockGetProvider.mockReturnValue(provider);
+
+ const { pushRouteToProvider } = await importPipeline();
+ const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" });
+
+ expect(out).toMatchObject({ status: "success", remoteId: "wahoo-after-refresh" });
+ expect(provider.refreshToken).toHaveBeenCalledOnce();
+ expect(provider.pushRoute).toHaveBeenCalledTimes(2);
+ expect(mockUpdateTokens).toHaveBeenCalledWith("c1", expect.objectContaining({ accessToken: "new-tok" }));
+ });
+
+ it("records validation errors and surfaces them to the caller", async () => {
+ mockGetRoute.mockResolvedValue(baseRoute);
+ mockGetConnection.mockResolvedValue(baseConnection);
+ queueSelectResults([{ version: 1, gpx: SHORT_GPX }], []);
+
+ const provider = makeProvider();
+ const { PushError } = await import("./types.ts");
+ provider.pushRoute.mockRejectedValue(new PushError("validation", "bad name", 422));
+ mockGetProvider.mockReturnValue(provider);
+
+ const { pushRouteToProvider } = await importPipeline();
+ const out = await pushRouteToProvider({ userId: "u1", providerId: "wahoo", routeId: "r1" });
+
+ expect(out).toMatchObject({ status: "error", code: "validation" });
+ expect(mockInsert).toHaveBeenCalledOnce();
+ });
+});
+
+describe("encodeOAuthState / decodeOAuthState", () => {
+ it("round-trips a push-after state", async () => {
+ const { encodeOAuthState, decodeOAuthState } = await importPipeline();
+ const encoded = encodeOAuthState({ pushAfter: { routeId: "abc" }, returnTo: "/routes/abc" });
+ expect(decodeOAuthState(encoded)).toEqual({ pushAfter: { routeId: "abc" }, returnTo: "/routes/abc" });
+ });
+
+ it("returns empty object for missing or malformed state", async () => {
+ const { decodeOAuthState } = await importPipeline();
+ expect(decodeOAuthState(null)).toEqual({});
+ expect(decodeOAuthState("not-base64-json!!")).toEqual({});
+ });
+});
diff --git a/apps/journal/app/lib/sync/pushes.server.ts b/apps/journal/app/lib/sync/pushes.server.ts
new file mode 100644
index 0000000..3100a51
--- /dev/null
+++ b/apps/journal/app/lib/sync/pushes.server.ts
@@ -0,0 +1,194 @@
+import { randomUUID } from "node:crypto";
+import { and, desc, eq } from "drizzle-orm";
+import { gpxToFitCourse } from "@trails-cool/fit";
+import { parseGpxAsync } from "@trails-cool/gpx";
+import { routeVersions, syncPushes } from "@trails-cool/db/schema/journal";
+import { getDb } from "../db.ts";
+import { getRoute } from "../routes.server.ts";
+import { getConnection, updateTokens } from "./connections.server.ts";
+import { getProvider } from "./registry.ts";
+import { PushError, providerSupportsPush } from "./types.ts";
+import type { TokenSet } from "./types.ts";
+
+export type PushOutcome =
+ | { status: "success"; remoteId: string; pushedAt: Date }
+ | { status: "scope_missing" }
+ | { status: "no_connection" }
+ | { status: "not_owner" }
+ | { status: "not_found" }
+ | { status: "unsupported_provider" }
+ | { status: "no_geometry" }
+ | { status: "error"; code: "validation" | "rate_limit" | "token_expired" | "generic"; message: string };
+
+export interface PushRouteOptions {
+ userId: string;
+ providerId: string;
+ routeId: string;
+}
+
+/**
+ * End-to-end push pipeline. Resolves the route, checks ownership and scopes,
+ * short-circuits on already-pushed versions, refreshes tokens on 401, and
+ * records the outcome in `sync_pushes`.
+ */
+export async function pushRouteToProvider(opts: PushRouteOptions): Promise {
+ const { userId, providerId, routeId } = opts;
+ const db = getDb();
+
+ const provider = getProvider(providerId);
+ if (!provider) return { status: "not_found" };
+ if (!providerSupportsPush(provider)) return { status: "unsupported_provider" };
+
+ const route = await getRoute(routeId);
+ if (!route) return { status: "not_found" };
+ if (route.ownerId !== userId) return { status: "not_owner" };
+
+ const connection = await getConnection(userId, providerId);
+ if (!connection) return { status: "no_connection" };
+ if (!connection.grantedScopes.includes("routes_write")) return { status: "scope_missing" };
+
+ const [latestVersion] = await db
+ .select()
+ .from(routeVersions)
+ .where(eq(routeVersions.routeId, routeId))
+ .orderBy(desc(routeVersions.version))
+ .limit(1);
+
+ const versionGpx = latestVersion?.gpx ?? route.gpx;
+ const versionNumber = latestVersion?.version ?? 1;
+ if (!versionGpx) return { status: "no_geometry" };
+
+ // Idempotency: if this exact (user, route, version) was already pushed, return that result.
+ const existing = await db
+ .select()
+ .from(syncPushes)
+ .where(
+ and(
+ eq(syncPushes.userId, userId),
+ eq(syncPushes.routeId, routeId),
+ eq(syncPushes.routeVersion, versionNumber),
+ eq(syncPushes.provider, providerId),
+ ),
+ )
+ .limit(1);
+ const existingRow = existing[0];
+ if (existingRow?.pushedAt && existingRow.remoteId) {
+ return { status: "success", remoteId: existingRow.remoteId, pushedAt: existingRow.pushedAt };
+ }
+
+ // Build payload from the locked-in version GPX, not routes.gpx.
+ const parsed = await parseGpxAsync(versionGpx);
+ const points = parsed.tracks.flat();
+ if (points.length === 0) return { status: "no_geometry" };
+
+ const fit = await gpxToFitCourse({
+ gpx: versionGpx,
+ name: route.name,
+ description: route.description ?? undefined,
+ });
+
+ const externalId = `route:${routeId}:v${versionNumber}`;
+ const payload = {
+ fit,
+ externalId,
+ providerUpdatedAt: new Date(),
+ name: route.name,
+ description: route.description ?? undefined,
+ startLat: points[0]!.lat,
+ startLng: points[0]!.lon,
+ distance: parsed.distance,
+ ascent: parsed.elevation.gain,
+ };
+
+ let tokens: TokenSet = {
+ accessToken: connection.accessToken,
+ refreshToken: connection.refreshToken,
+ expiresAt: connection.expiresAt,
+ providerUserId: connection.providerUserId ?? undefined,
+ };
+
+ if (Date.now() >= tokens.expiresAt.getTime()) {
+ tokens = await provider.refreshToken(tokens.refreshToken);
+ await updateTokens(connection.id, tokens);
+ }
+
+ const recordResult = async (
+ success: boolean,
+ remoteId: string | null,
+ error: string | null,
+ ): Promise => {
+ const now = new Date();
+ if (existingRow) {
+ await db
+ .update(syncPushes)
+ .set({
+ remoteId,
+ pushedAt: success ? now : null,
+ error,
+ updatedAt: now,
+ })
+ .where(eq(syncPushes.id, existingRow.id));
+ } else {
+ await db.insert(syncPushes).values({
+ id: randomUUID(),
+ userId,
+ routeId,
+ routeVersion: versionNumber,
+ provider: providerId,
+ externalId,
+ remoteId,
+ pushedAt: success ? now : null,
+ error,
+ });
+ }
+ };
+
+ try {
+ let result;
+ try {
+ result = await provider.pushRoute(tokens, payload);
+ } catch (e) {
+ if (e instanceof PushError && e.code === "token_expired") {
+ // 401 mid-call — refresh once and retry.
+ tokens = await provider.refreshToken(tokens.refreshToken);
+ await updateTokens(connection.id, tokens);
+ result = await provider.pushRoute(tokens, payload);
+ } else {
+ throw e;
+ }
+ }
+ await recordResult(true, result.remoteId, null);
+ return { status: "success", remoteId: result.remoteId, pushedAt: new Date() };
+ } catch (e) {
+ if (e instanceof PushError) {
+ await recordResult(false, null, `${e.code}: ${e.message}`);
+ if (e.code === "scope_missing") return { status: "scope_missing" };
+ return { status: "error", code: e.code, message: e.message };
+ }
+ const message = e instanceof Error ? e.message : String(e);
+ await recordResult(false, null, `generic: ${message}`);
+ return { status: "error", code: "generic", message };
+ }
+}
+
+// State payload encoded into the OAuth `state` query param so the callback
+// can resume a push that was interrupted by a re-auth redirect.
+export interface PushOAuthState {
+ pushAfter?: { routeId: string };
+ returnTo?: string;
+}
+
+export function encodeOAuthState(state: PushOAuthState): string {
+ return Buffer.from(JSON.stringify(state), "utf8").toString("base64url");
+}
+
+export function decodeOAuthState(raw: string | null | undefined): PushOAuthState {
+ if (!raw) return {};
+ try {
+ const json = Buffer.from(raw, "base64url").toString("utf8");
+ const parsed = JSON.parse(json) as PushOAuthState;
+ return typeof parsed === "object" && parsed != null ? parsed : {};
+ } catch {
+ return {};
+ }
+}
diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts
index 28aec4c..4c51727 100644
--- a/apps/journal/app/routes.ts
+++ b/apps/journal/app/routes.ts
@@ -52,6 +52,7 @@ export default [
route("api/sync/callback/:provider", "routes/api.sync.callback.$provider.ts"),
route("api/sync/disconnect/:provider", "routes/api.sync.disconnect.$provider.ts"),
route("api/sync/webhook/:provider", "routes/api.sync.webhook.$provider.ts"),
+ route("api/sync/push/:provider/:routeId", "routes/api.sync.push.$provider.$routeId.ts"),
route("privacy", "routes/privacy.tsx"),
route("legal/imprint", "routes/legal.imprint.tsx"),
route("legal/terms", "routes/legal.terms.tsx"),
diff --git a/apps/journal/app/routes/api.sync.callback.$provider.ts b/apps/journal/app/routes/api.sync.callback.$provider.ts
index 7f3b88b..4ad85c5 100644
--- a/apps/journal/app/routes/api.sync.callback.$provider.ts
+++ b/apps/journal/app/routes/api.sync.callback.$provider.ts
@@ -3,6 +3,7 @@ import type { Route } from "./+types/api.sync.callback.$provider";
import { getSessionUser } from "~/lib/auth.server";
import { getProvider } from "~/lib/sync/registry";
import { saveConnection } from "~/lib/sync/connections.server";
+import { decodeOAuthState, pushRouteToProvider } from "~/lib/sync/pushes.server";
export async function loader({ params, request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
@@ -12,6 +13,15 @@ export async function loader({ params, request }: Route.LoaderArgs) {
if (!provider) return data({ error: "Unknown provider" }, { status: 404 });
const url = new URL(request.url);
+ const state = decodeOAuthState(url.searchParams.get("state"));
+ const fallbackReturn = state.returnTo ?? "/settings";
+
+ // User denied the new scope at Wahoo. Send them back to the originating
+ // page with a notice instead of looping them through OAuth again.
+ if (url.searchParams.get("error") === "access_denied") {
+ return redirect(`${fallbackReturn}?push=needs_permission`);
+ }
+
const code = url.searchParams.get("code");
if (!code) return data({ error: "Missing authorization code" }, { status: 400 });
@@ -25,8 +35,21 @@ export async function loader({ params, request }: Route.LoaderArgs) {
await saveConnection(user.id, provider.id, tokens, provider.scopes);
} catch (e) {
console.error(`OAuth callback failed for ${params.provider}:`, e);
- return redirect("/settings?error=sync_failed");
+ return redirect(`${fallbackReturn}?error=sync_failed`);
}
- return redirect("/settings");
+ if (state.pushAfter?.routeId) {
+ const outcome = await pushRouteToProvider({
+ userId: user.id,
+ providerId: provider.id,
+ routeId: state.pushAfter.routeId,
+ });
+ const target = state.returnTo ?? `/routes/${state.pushAfter.routeId}`;
+ if (outcome.status === "success") return redirect(`${target}?push=success`);
+ if (outcome.status === "scope_missing") return redirect(`${target}?push=needs_permission`);
+ if (outcome.status === "error") return redirect(`${target}?push=error&code=${outcome.code}`);
+ return redirect(`${target}?push=${outcome.status}`);
+ }
+
+ return redirect(state.returnTo ?? "/settings");
}
diff --git a/apps/journal/app/routes/api.sync.push.$provider.$routeId.ts b/apps/journal/app/routes/api.sync.push.$provider.$routeId.ts
new file mode 100644
index 0000000..c865ee2
--- /dev/null
+++ b/apps/journal/app/routes/api.sync.push.$provider.$routeId.ts
@@ -0,0 +1,50 @@
+import { redirect, data } from "react-router";
+import type { Route } from "./+types/api.sync.push.$provider.$routeId";
+import { getSessionUser } from "~/lib/auth.server";
+import { getProvider } from "~/lib/sync/registry";
+import { pushRouteToProvider, encodeOAuthState } from "~/lib/sync/pushes.server";
+
+export async function action({ params, request }: Route.ActionArgs) {
+ const user = await getSessionUser(request);
+ if (!user) return redirect("/auth/login");
+
+ const provider = getProvider(params.provider);
+ if (!provider) return data({ error: "Unknown provider" }, { status: 404 });
+
+ const returnTo = `/routes/${params.routeId}`;
+ const outcome = await pushRouteToProvider({
+ userId: user.id,
+ providerId: provider.id,
+ routeId: params.routeId,
+ });
+
+ switch (outcome.status) {
+ case "success":
+ return redirect(`${returnTo}?push=success`);
+ case "scope_missing": {
+ const origin = process.env.ORIGIN ?? "http://localhost:3000";
+ const redirectUri = `${origin}/api/sync/callback/${provider.id}`;
+ const state = encodeOAuthState({
+ pushAfter: { routeId: params.routeId },
+ returnTo,
+ });
+ return redirect(provider.getAuthUrl(redirectUri, state));
+ }
+ case "no_connection":
+ return redirect(`${returnTo}?push=no_connection`);
+ case "no_geometry":
+ return redirect(`${returnTo}?push=no_geometry`);
+ case "not_owner":
+ return data({ error: "Forbidden" }, { status: 403 });
+ case "not_found":
+ return data({ error: "Not found" }, { status: 404 });
+ case "unsupported_provider":
+ return data({ error: "Provider does not support push" }, { status: 400 });
+ case "error":
+ return redirect(`${returnTo}?push=error&code=${outcome.code}`);
+ }
+}
+
+export function loader() {
+ return data({ error: "Method not allowed" }, { status: 405 });
+}
diff --git a/openspec/changes/wahoo-route-push/tasks.md b/openspec/changes/wahoo-route-push/tasks.md
index 9d3fdcc..0749fa1 100644
--- a/openspec/changes/wahoo-route-push/tasks.md
+++ b/openspec/changes/wahoo-route-push/tasks.md
@@ -31,17 +31,17 @@
## 5. Push action route and idempotency layer
-- [ ] 5.1 Add an action route at `/api/sync/push/wahoo/:routeId` that resolves the route, verifies the requesting user is the owner, looks up the user's Wahoo connection, and runs the push
-- [ ] 5.2 Before calling Wahoo, check `sync_connections.granted_scopes` for `routes_write`; if missing, redirect to `getAuthUrl` with state `{ return_to, push_after }`
-- [ ] 5.3 Look up an existing `sync_pushes` row for `(user_id, route_id, route_version, 'wahoo')` — if `pushed_at` is set, return the existing `remote_id` without calling Wahoo
-- [ ] 5.4 On a fresh push, build the payload (`external_id = "route::v"`, current timestamp, route name/description, start lat/lng, computed distance and ascent), call `provider.pushRoute`, and write the result to `sync_pushes` (insert on success, update existing row on retry of a failed attempt)
-- [ ] 5.5 Map error types to user-facing copy: scope missing → re-auth redirect; token expired after refresh → reconnect prompt; 422 validation → "We couldn't send this route — try editing the name or description"; 5xx / network → generic retry
+- [x] 5.1 Add an action route at `/api/sync/push/wahoo/:routeId` that resolves the route, verifies the requesting user is the owner, looks up the user's Wahoo connection, and runs the push
+- [x] 5.2 Before calling Wahoo, check `sync_connections.granted_scopes` for `routes_write`; if missing, redirect to `getAuthUrl` with state `{ return_to, push_after }`
+- [x] 5.3 Look up an existing `sync_pushes` row for `(user_id, route_id, route_version, 'wahoo')` — if `pushed_at` is set, return the existing `remote_id` without calling Wahoo
+- [x] 5.4 On a fresh push, build the payload (`external_id = "route::v"`, current timestamp, route name/description, start lat/lng, computed distance and ascent), call `provider.pushRoute`, and write the result to `sync_pushes` (insert on success, update existing row on retry of a failed attempt)
+- [x] 5.5 Map error types to user-facing copy: scope missing → re-auth redirect; token expired after refresh → reconnect prompt; 422 validation → "We couldn't send this route — try editing the name or description"; 5xx / network → generic retry
## 6. OAuth callback resumes pending push
-- [ ] 6.1 Extend the OAuth callback handler at `/api/sync/callback/wahoo` to parse `state.push_after` and `state.return_to`
-- [ ] 6.2 If `push_after`, after persisting the new tokens and scopes, run the push pipeline server-side and redirect to `return_to` with a success or failure flash message
-- [ ] 6.3 If the user denied the new scope (callback arrives with `?error=access_denied`), redirect to `return_to` with the "needs route permission" notice
+- [x] 6.1 Extend the OAuth callback handler at `/api/sync/callback/wahoo` to parse `state.push_after` and `state.return_to`
+- [x] 6.2 If `push_after`, after persisting the new tokens and scopes, run the push pipeline server-side and redirect to `return_to` with a success or failure flash message
+- [x] 6.3 If the user denied the new scope (callback arrives with `?error=access_denied`), redirect to `return_to` with the "needs route permission" notice
## 7. Route detail page UI
@@ -60,8 +60,8 @@
## 9. Tests
- [x] 9.1 Unit tests for `gpxToFitCourse` — already covered in §1.7
-- [ ] 9.2 Unit tests for the action route's idempotency logic (mock Wahoo HTTP layer): fresh push, re-push of pushed version, retry of failed push, push of new version after edit
-- [ ] 9.3 Unit tests for the scope-mismatch redirect flow
+- [x] 9.2 Unit tests for the action route's idempotency logic (mock Wahoo HTTP layer): fresh push, re-push of pushed version, retry of failed push, push of new version after edit
+- [x] 9.3 Unit tests for the scope-mismatch redirect flow
- [ ] 9.4 E2E test in `e2e/`: log in as a user with a (mocked) Wahoo connection, open a route detail page, click "Send to Wahoo", assert the success toast and the "Sent to Wahoo on …" status appear
- [ ] 9.5 E2E test for the re-auth flow: user without `routes_write` clicks Send to Wahoo, redirects to OAuth (mocked), returns, push completes, status renders
diff --git a/packages/fit/src/fitsdk.d.ts b/packages/fit/src/fitsdk-shim.ts
similarity index 100%
rename from packages/fit/src/fitsdk.d.ts
rename to packages/fit/src/fitsdk-shim.ts
diff --git a/packages/fit/src/gpx-to-fit-course.ts b/packages/fit/src/gpx-to-fit-course.ts
index 066bd0c..7f877eb 100644
--- a/packages/fit/src/gpx-to-fit-course.ts
+++ b/packages/fit/src/gpx-to-fit-course.ts
@@ -1,3 +1,4 @@
+import "./fitsdk-shim.ts";
import { Encoder, Profile } from "@garmin/fitsdk";
import { parseGpxAsync } from "@trails-cool/gpx";
import type { TrackPoint } from "@trails-cool/gpx";