Add Send to Wahoo UI, i18n, and privacy disclosure

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 <date>" 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 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-30 22:44:50 +02:00
parent a9c0093877
commit 76c3e49de2
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
5 changed files with 168 additions and 13 deletions

View file

@ -271,6 +271,19 @@ export default function PrivacyPage() {
der Empfänger:in an diesen Dienst übergeben. Selbst-betriebene
Instanzen konfigurieren ihren eigenen Mailserver.
</li>
<li>
<strong>Wahoo</strong> (Wahoo Fitness LLC) Optionaler Sync mit
einem Wahoo-Konto. Beim Verbinden über &bdquo;Mit Wahoo
verbinden&ldquo; werden OAuth-Tokens ausgetauscht und Wahoo-
Workouts können in den Journal importiert werden (eingehender
Sync). Beim ausdrücklichen Klick auf &bdquo;An Wahoo senden&ldquo;
auf einer Routenseite werden zusätzlich <strong>Routengeometrie,
Routenname und Routenbeschreibung</strong> 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.
</li>
<li>
<strong>Hosting</strong> 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 &ldquo;Send to
Wahoo&rdquo; on a route sent so the route appears on your
ELEMNT/BOLT/ROAM); hosting provider in the EU under a DPA.
</p>
</section>

View file

@ -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<number | null>(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")}
</a>
)}
{wahooPush?.canPush && (
wahooPush.latest?.pushedAt ? (
<span
className="rounded-md bg-green-50 px-3 py-1.5 text-sm text-green-800"
title={t("routes.sendToWahooHelp")}
>
{t("routes.sentToWahoo", {
date: new Date(wahooPush.latest.pushedAt).toLocaleDateString(i18n.language),
})}
</span>
) : (
<form method="post" action={`/api/sync/push/wahoo/${route.id}`} className="inline">
<button
type="submit"
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
title={t("routes.sendToWahooHelp")}
>
{t("routes.sendToWahoo")}
</button>
{wahooPush.latest?.error && (
<span className="ml-2 text-sm text-red-700">
{t("routes.sendToWahooFailed", { error: wahooPush.latest.error })}
</span>
)}
</form>
)
)}
</div>
)}
</div>
{pushStatus && (
<div
className={`mt-4 rounded-md px-3 py-2 text-sm ${
pushStatus === "success"
? "bg-green-50 text-green-800"
: "bg-amber-50 text-amber-900"
}`}
>
{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")}
</div>
)}
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
{route.distance != null && (
<div className="rounded-md bg-gray-50 p-4">

View file

@ -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 <date>" replacing the button when a successful push exists
- [ ] 7.4 Render the failed-push state: "Last attempt failed: <short error>" 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 <date>" replacing the button when a successful push exists
- [x] 7.4 Render the failed-push state: "Last attempt failed: <short error>" 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

View file

@ -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",

View file

@ -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",