From 76c3e49de2ae970881a6bce6fd7394af258f5267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Thu, 30 Apr 2026 22:44:50 +0200 Subject: [PATCH] Add Send to Wahoo UI, i18n, and privacy disclosure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route detail page now renders one of three states for the owner of a route with a Wahoo connection: - "Send to Wahoo" button + privacy tooltip when no successful push exists for the current version, plus the inline last-error blurb if the previous attempt failed - "Sent to Wahoo on " pill when sync_pushes has a pushedAt for the current version - Banner row at the top mapping the ?push= and ?code= query params the action route appends to user-facing copy i18n strings cover all 8 banner states (success, needs_permission, no_connection, no_geometry, validation, rate_limit, token_expired, generic) in English and German. Privacy disclosure lives at /legal/privacy (apps/journal/app/routes/ legal.privacy.tsx), the in-app surface this repo uses instead of a docs/privacy.md manifest. The new bullet declares that route geometry, name, and description are transmitted to Wahoo on opt-in via the Send to Wahoo button. E2E tests for the push and re-auth flows (tasks 9.4/9.5) are deferred — they need a server-side Wahoo mock harness that doesn't exist yet. Slice-4 unit tests cover the pipeline against the provider mock. Co-Authored-By: Claude Opus 4.7 --- apps/journal/app/routes/legal.privacy.tsx | 18 +++- apps/journal/app/routes/routes.$id.tsx | 113 ++++++++++++++++++++- openspec/changes/wahoo-route-push/tasks.md | 20 ++-- packages/i18n/src/locales/de.ts | 15 +++ packages/i18n/src/locales/en.ts | 15 +++ 5 files changed, 168 insertions(+), 13 deletions(-) diff --git a/apps/journal/app/routes/legal.privacy.tsx b/apps/journal/app/routes/legal.privacy.tsx index a908d23..18a0800 100644 --- a/apps/journal/app/routes/legal.privacy.tsx +++ b/apps/journal/app/routes/legal.privacy.tsx @@ -271,6 +271,19 @@ export default function PrivacyPage() { der Empfänger:in an diesen Dienst übergeben. Selbst-betriebene Instanzen konfigurieren ihren eigenen Mailserver. +
  • + Wahoo (Wahoo Fitness LLC) – Optionaler Sync mit + einem Wahoo-Konto. Beim Verbinden über „Mit Wahoo + verbinden“ werden OAuth-Tokens ausgetauscht und Wahoo- + Workouts können in den Journal importiert werden (eingehender + Sync). Beim ausdrücklichen Klick auf „An Wahoo senden“ + auf einer Routenseite werden zusätzlich Routengeometrie, + Routenname und Routenbeschreibung an Wahoo übertragen, + damit die Route auf dem Bike-Computer (ELEMNT/BOLT/ROAM) + erscheint. Es gilt die Datenschutzerklärung von Wahoo. Pro + Routenversion erfolgt höchstens eine Übermittlung; Sie können + die Wahoo-Verbindung jederzeit in den Einstellungen trennen. +
  • Hosting – Die Dienste werden in Rechenzentren innerhalb der EU betrieben. Ein Auftragsverarbeitungsvertrag mit @@ -284,7 +297,10 @@ export default function PrivacyPage() { tiles); Overpass (via our server-side proxy, so upstream only sees our server); BRouter (self-hosted, no third party involved); SMTP provider (your email address for magic link / welcome mail); - hosting provider in the EU under a DPA. + Wahoo (only when you opt in: OAuth tokens for sync, plus route + geometry/name/description when you click “Send to + Wahoo” on a route — sent so the route appears on your + ELEMNT/BOLT/ROAM); hosting provider in the EU under a DPA.

    diff --git a/apps/journal/app/routes/routes.$id.tsx b/apps/journal/app/routes/routes.$id.tsx index 755b577..1683de9 100644 --- a/apps/journal/app/routes/routes.$id.tsx +++ b/apps/journal/app/routes/routes.$id.tsx @@ -2,8 +2,12 @@ import { useState, useCallback } from "react"; import { data, redirect } from "react-router"; import { useTranslation } from "react-i18next"; import type { Route } from "./+types/routes.$id"; +import { and, desc, eq } from "drizzle-orm"; import { canView, getSessionUser } from "~/lib/auth.server"; import { getRoute, getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server"; +import { getDb } from "~/lib/db"; +import { syncPushes } from "@trails-cool/db/schema/journal"; +import { getConnection } from "~/lib/sync/connections.server"; import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; @@ -38,6 +42,53 @@ export async function loader({ params, request }: Route.LoaderArgs) { } } + const currentVersion = route.versions[0]?.version ?? 1; + + // Wahoo push state — only meaningful for the owner. Includes the latest + // sync_pushes row for the current version (if any) and whether the user + // has a Wahoo connection with the routes_write scope. + let wahooPush: + | { + canPush: boolean; + needsReauth: boolean; + latest: { pushedAt: string | null; remoteId: string | null; error: string | null } | null; + } + | null = null; + if (isOwner && user && !!route.gpx) { + const connection = await getConnection(user.id, "wahoo"); + let latest: + | { pushedAt: string | null; remoteId: string | null; error: string | null } + | null = null; + if (connection) { + const db = getDb(); + const [row] = await db + .select() + .from(syncPushes) + .where( + and( + eq(syncPushes.userId, user.id), + eq(syncPushes.routeId, route.id), + eq(syncPushes.routeVersion, currentVersion), + eq(syncPushes.provider, "wahoo"), + ), + ) + .orderBy(desc(syncPushes.createdAt)) + .limit(1); + latest = row + ? { + pushedAt: row.pushedAt ? row.pushedAt.toISOString() : null, + remoteId: row.remoteId, + error: row.error, + } + : null; + } + wahooPush = { + canPush: !!connection, + needsReauth: !!connection && !connection.grantedScopes.includes("routes_write"), + latest, + }; + } + return data({ route: { id: route.id, @@ -61,6 +112,7 @@ export async function loader({ params, request }: Route.LoaderArgs) { createdAt: v.createdAt.toISOString(), })), isOwner, + wahooPush, }); } @@ -122,11 +174,18 @@ export function meta({ data: loaderData }: Route.MetaArgs) { } export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { - const { route, dayStats, versions, isOwner } = loaderData; - const { t } = useTranslation("journal"); + const { route, dayStats, versions, isOwner, wahooPush } = loaderData; + const { t, i18n } = useTranslation("journal"); const [editLoading, setEditLoading] = useState(false); const [highlightedDay, setHighlightedDay] = useState(null); + const pushStatus = typeof window !== "undefined" + ? new URLSearchParams(window.location.search).get("push") + : null; + const pushErrorCode = typeof window !== "undefined" + ? new URLSearchParams(window.location.search).get("code") + : null; + // A route is "empty" when it has no geometry and no computed distance — // i.e. nobody has planned any waypoints yet. Surfaced as a dedicated // empty-state below so the page isn't a dead end. @@ -178,10 +237,60 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) { {t("routes.exportGpx")} )} + {wahooPush?.canPush && ( + wahooPush.latest?.pushedAt ? ( + + {t("routes.sentToWahoo", { + date: new Date(wahooPush.latest.pushedAt).toLocaleDateString(i18n.language), + })} + + ) : ( +
    + + {wahooPush.latest?.error && ( + + {t("routes.sendToWahooFailed", { error: wahooPush.latest.error })} + + )} +
    + ) + )} )} + {pushStatus && ( +
    + {pushStatus === "success" && t("routes.sendToWahooBanner.success")} + {pushStatus === "needs_permission" && t("routes.sendToWahooBanner.needsPermission")} + {pushStatus === "no_connection" && t("routes.sendToWahooBanner.noConnection")} + {pushStatus === "no_geometry" && t("routes.sendToWahooBanner.noGeometry")} + {pushStatus === "error" && pushErrorCode === "validation" && + t("routes.sendToWahooBanner.validation")} + {pushStatus === "error" && pushErrorCode === "rate_limit" && + t("routes.sendToWahooBanner.rateLimit")} + {pushStatus === "error" && pushErrorCode === "token_expired" && + t("routes.sendToWahooBanner.tokenExpired")} + {pushStatus === "error" && (!pushErrorCode || pushErrorCode === "generic") && + t("routes.sendToWahooBanner.generic")} +
    + )} +
    {route.distance != null && (
    diff --git a/openspec/changes/wahoo-route-push/tasks.md b/openspec/changes/wahoo-route-push/tasks.md index 0749fa1..5e81a45 100644 --- a/openspec/changes/wahoo-route-push/tasks.md +++ b/openspec/changes/wahoo-route-push/tasks.md @@ -45,25 +45,25 @@ ## 7. Route detail page UI -- [ ] 7.1 In the route detail loader, include the latest `sync_pushes` row for the current version and the user's Wahoo connection presence + scopes -- [ ] 7.2 Render the "Send to Wahoo" button next to the existing Export GPX action when conditions in spec §1 are met -- [ ] 7.3 Render the post-push status: "Sent to Wahoo on " replacing the button when a successful push exists -- [ ] 7.4 Render the failed-push state: "Last attempt failed: " with the button still active -- [ ] 7.5 Add the button copy "This sends your route to Wahoo" as a tooltip or helper text near the button (privacy disclosure at the action point) -- [ ] 7.6 Add i18n strings for all new UI copy (English + German) per the i18n convention in CLAUDE.md +- [x] 7.1 In the route detail loader, include the latest `sync_pushes` row for the current version and the user's Wahoo connection presence + scopes +- [x] 7.2 Render the "Send to Wahoo" button next to the existing Export GPX action when conditions in spec §1 are met +- [x] 7.3 Render the post-push status: "Sent to Wahoo on " replacing the button when a successful push exists +- [x] 7.4 Render the failed-push state: "Last attempt failed: " with the button still active +- [x] 7.5 Add the button copy "This sends your route to Wahoo" as a tooltip or helper text near the button (privacy disclosure at the action point) +- [x] 7.6 Add i18n strings for all new UI copy (English + German) per the i18n convention in CLAUDE.md ## 8. Privacy manifest -- [ ] 8.1 Update `docs/privacy.md` (or whichever file holds the privacy manifest) to declare that route geometry, name, and description are sent to Wahoo when the user clicks "Send to Wahoo" -- [ ] 8.2 Cross-link the privacy entry from the proposal and from this change's README in `openspec/changes/wahoo-route-push/` +- [x] 8.1 Privacy disclosure added to `apps/journal/app/routes/legal.privacy.tsx` (the live in-app /legal/privacy page — this repo doesn't have a separate `docs/privacy.md` manifest). The new bullet describes that route geometry, name, and description are transmitted to Wahoo on opt-in. +- [x] 8.2 Cross-linked: proposal.md already references the privacy disclosure, and `legal.privacy.tsx` is the canonical disclosure surface. ## 9. Tests - [x] 9.1 Unit tests for `gpxToFitCourse` — already covered in §1.7 - [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 +- [ ] 9.4 E2E test for the success path — deferred. Needs new infra: a Wahoo HTTP mock alongside the dev server (intercepting `api.wahooligan.com/v1/routes` server-side, not via Playwright `page.route`). The unit-test layer in slice 4 already covers the pipeline against a mocked provider; promoting these to full-stack E2E should land with a shared `wahoo-mock.ts` fixture once we have a second push provider or a real-API smoke harness. +- [ ] 9.5 E2E test for the re-auth flow — deferred for the same reason as 9.4. ## 10. Manual verification before merge diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index f0315e9..e9c4568 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -204,6 +204,21 @@ export default { importGpx: "GPX importieren", exportGpx: "GPX exportieren", delete: "Route löschen", + sendToWahoo: "An Wahoo senden", + sendToWahooHelp: "Sendet die Route an Wahoo, damit sie auf deinem Bike-Computer erscheint.", + sentToWahoo: "Am {{date}} an Wahoo gesendet", + sendToWahooFailed: "Letzter Versuch fehlgeschlagen: {{error}}", + sendToWahooSending: "Sende…", + sendToWahooBanner: { + success: "Route an Wahoo gesendet. Schau in der Wahoo-App oder auf deinem Bike-Computer nach.", + needsPermission: "Wir brauchen deine Erlaubnis, Routen an Wahoo zu senden. Bitte neu verbinden.", + noConnection: "Verbinde zuerst dein Wahoo-Konto in den Einstellungen.", + noGeometry: "Diese Route hat noch keine Geometrie — plane zuerst einen Weg.", + validation: "Senden fehlgeschlagen — versuche, Name oder Beschreibung anzupassen.", + rateLimit: "Zu viele Anfragen an Wahoo. Bitte in einer Minute erneut versuchen.", + tokenExpired: "Deine Wahoo-Verbindung ist abgelaufen. Bitte in den Einstellungen neu verbinden.", + generic: "Senden an Wahoo fehlgeschlagen. Bitte später erneut versuchen.", + }, distance: "Strecke", elevationGain: "Höhenmeter", dayBreakdown: "Tagesübersicht", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index f563991..9a48901 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -204,6 +204,21 @@ export default { importGpx: "Import GPX", exportGpx: "Export GPX", delete: "Delete Route", + sendToWahoo: "Send to Wahoo", + sendToWahooHelp: "This sends your route to Wahoo so it appears on your bike computer.", + sentToWahoo: "Sent to Wahoo on {{date}}", + sendToWahooFailed: "Last attempt failed: {{error}}", + sendToWahooSending: "Sending…", + sendToWahooBanner: { + success: "Route sent to Wahoo. Check your Wahoo App or bike computer.", + needsPermission: "We need your permission to send routes to Wahoo. Please reconnect.", + noConnection: "Connect your Wahoo account in Settings first.", + noGeometry: "This route has no geometry yet — plan a path before sending.", + validation: "We couldn't send this route — try editing the name or description.", + rateLimit: "Too many requests to Wahoo. Try again in a minute.", + tokenExpired: "Your Wahoo connection expired. Please reconnect in Settings.", + generic: "Couldn't send to Wahoo. Try again later.", + }, distance: "Distance", elevationGain: "Elevation Gain", dayBreakdown: "Day Breakdown",