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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue