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:
parent
ab870dbfa5
commit
fdd193cbf0
6 changed files with 84 additions and 8 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue