Add activity import source badge, delete action, and No GPS indicator

- Activity detail: show "Imported from wahoo" badge when activity was
  imported from an external provider
- Activity detail: add delete button with confirmation, cleans up
  sync_imports so the workout can be reimported
- Import page: show "No GPS" badge with tooltip on workouts that have
  no file URL from the provider

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-05 16:49:42 +02:00
parent ab870dbfa5
commit fdd193cbf0
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
6 changed files with 84 additions and 8 deletions

View file

@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { eq, desc, sql } from "drizzle-orm";
import { eq, desc, and, sql } from "drizzle-orm";
import { getDb } from "./db.ts";
import { activities, routes } from "@trails-cool/db/schema/journal";
import { activities, routes, syncImports } from "@trails-cool/db/schema/journal";
import { parseGpxAsync } from "@trails-cool/gpx";
import { setGeomFromGpx } from "./routes.server.ts";
@ -61,7 +61,26 @@ export async function getActivity(id: string) {
const [activity] = await db.select().from(activities).where(eq(activities.id, id));
if (!activity) return null;
const geojson = await getActivityGeojson(id);
return { ...activity, geojson };
const importSource = await getImportSource(id);
return { ...activity, geojson, importSource };
}
export async function deleteActivity(id: string, ownerId: string): Promise<boolean> {
const db = getDb();
const [activity] = await db.select({ id: activities.id }).from(activities)
.where(and(eq(activities.id, id), eq(activities.ownerId, ownerId)));
if (!activity) return false;
await db.delete(activities).where(eq(activities.id, id));
return true;
}
async function getImportSource(activityId: string): Promise<{ provider: string; externalWorkoutId: string } | null> {
const db = getDb();
const [row] = await db
.select({ provider: syncImports.provider, externalWorkoutId: syncImports.externalWorkoutId })
.from(syncImports)
.where(eq(syncImports.activityId, activityId));
return row ?? null;
}
export async function listActivities(ownerId: string) {

View file

@ -38,6 +38,11 @@ export async function isAlreadyImported(
return !!row;
}
export async function deleteImportByActivity(activityId: string) {
const db = getDb();
await db.delete(syncImports).where(eq(syncImports.activityId, activityId));
}
export async function getImportedIds(
userId: string,
provider: string,

View file

@ -1,8 +1,10 @@
import { Suspense, lazy } from "react";
import { data, redirect } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/activities.$id";
import { getSessionUser } from "~/lib/auth.server";
import { getActivity, linkActivityToRoute, createRouteFromActivity } from "~/lib/activities.server";
import { getActivity, deleteActivity, linkActivityToRoute, createRouteFromActivity } from "~/lib/activities.server";
import { deleteImportByActivity } from "~/lib/sync/imports.server";
import { listRoutes } from "~/lib/routes.server";
import { ClientDate } from "~/components/ClientDate";
@ -33,6 +35,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
geojson: activity.geojson ?? null,
startedAt: activity.startedAt?.toISOString() ?? null,
createdAt: activity.createdAt.toISOString(),
importSource: activity.importSource,
},
isOwner,
routes: userRoutes.map((r) => ({ id: r.id, name: r.name })),
@ -60,6 +63,13 @@ export async function action({ params, request }: Route.ActionArgs) {
return data({ error: "No GPX data to create route from" }, { status: 400 });
}
if (intent === "delete") {
await deleteImportByActivity(params.id);
const deleted = await deleteActivity(params.id, user.id);
if (deleted) return redirect("/activities");
return data({ error: "Activity not found" }, { status: 404 });
}
return data({ error: "Unknown action" }, { status: 400 });
}
@ -70,10 +80,18 @@ export function meta({ data: loaderData }: Route.MetaArgs) {
export default function ActivityDetailPage({ loaderData }: Route.ComponentProps) {
const { activity, isOwner, routes } = loaderData;
const { t } = useTranslation("journal");
return (
<div className="mx-auto max-w-4xl px-4 py-8">
<h1 className="text-2xl font-bold text-gray-900">{activity.name}</h1>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900">{activity.name}</h1>
{activity.importSource && (
<span className="inline-flex items-center rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
{t("activities.importedFrom", { provider: activity.importSource.provider })}
</span>
)}
</div>
{activity.description && (
<p className="mt-2 text-gray-600">{activity.description}</p>
)}
@ -128,7 +146,7 @@ export default function ActivityDetailPage({ loaderData }: Route.ComponentProps)
<input type="hidden" name="intent" value="link-route" />
<div className="flex-1">
<label htmlFor="routeId" className="block text-sm font-medium text-gray-700">
Link to existing route
{t("activities.linkToRoute")}
</label>
<select
id="routeId"
@ -158,12 +176,26 @@ export default function ActivityDetailPage({ loaderData }: Route.ComponentProps)
type="submit"
className="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Create Route from Activity
{t("activities.createRouteFromActivity")}
</button>
</form>
)}
</div>
)}
{isOwner && (
<div className="mt-8 border-t border-gray-200 pt-6">
<form method="post" onSubmit={(e) => { if (!confirm(t("activities.deleteConfirm"))) e.preventDefault(); }}>
<input type="hidden" name="intent" value="delete" />
<button
type="submit"
className="rounded-md border border-red-200 px-3 py-2 text-sm text-red-600 hover:bg-red-50"
>
{t("activities.delete")}
</button>
</form>
</div>
)}
</div>
);
}

View file

@ -128,7 +128,17 @@ export default function SyncImportPage({ loaderData, actionData }: Route.Compone
className="flex items-center justify-between rounded-lg border border-gray-200 p-4"
>
<div>
<p className="font-medium text-gray-900">{w.name}</p>
<div className="flex items-center gap-2">
<p className="font-medium text-gray-900">{w.name}</p>
{!w.fileUrl && (
<span
className="inline-flex items-center rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700"
title={t("sync.noGpsTooltip", { provider: provider.name })}
>
{t("sync.noGps")}
</span>
)}
</div>
<div className="mt-1 flex gap-3 text-sm text-gray-500">
{w.startedAt && <ClientDate iso={w.startedAt} />}
{w.distance != null && <span>{(w.distance / 1000).toFixed(1)} km</span>}

View file

@ -134,6 +134,9 @@ export default {
duration: "Dauer",
linkToRoute: "Mit Route verknüpfen",
createRouteFromActivity: "Route aus Aktivität erstellen",
delete: "Aktivität löschen",
deleteConfirm: "Möchtest du diese Aktivität wirklich löschen?",
importedFrom: "Importiert von {{provider}}",
},
settings: {
title: "Einstellungen",
@ -181,6 +184,8 @@ export default {
importFrom: "Import von {{provider}}",
imported: "Importiert",
noWorkouts: "Keine Workouts gefunden.",
noGps: "Kein GPS",
noGpsTooltip: "{{provider}} stellt für diese Aktivität keine Routendaten bereit",
previous: "Zurück",
next: "Weiter",
},

View file

@ -134,6 +134,9 @@ export default {
duration: "Duration",
linkToRoute: "Link to Route",
createRouteFromActivity: "Create Route from Activity",
delete: "Delete Activity",
deleteConfirm: "Are you sure you want to delete this activity?",
importedFrom: "Imported from {{provider}}",
},
settings: {
title: "Settings",
@ -181,6 +184,8 @@ export default {
importFrom: "Import from {{provider}}",
imported: "Imported",
noWorkouts: "No workouts found.",
noGps: "No GPS",
noGpsTooltip: "{{provider}} does not provide route data for this activity",
previous: "Previous",
next: "Next",
},