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:
parent
a9c0093877
commit
76c3e49de2
5 changed files with 168 additions and 13 deletions
|
|
@ -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 „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 <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 “Send to
|
||||
Wahoo” on a route — sent so the route appears on your
|
||||
ELEMNT/BOLT/ROAM); hosting provider in the EU under a DPA.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue