Merge pull request #173 from trails-cool/feat/activity-import-ux

Add activity import source, delete, and No GPS indicator
This commit is contained in:
Ullrich Schäfer 2026-04-05 15:52:15 +01:00 committed by GitHub
commit 01d7620617
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 84 additions and 8 deletions

View file

@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto"; 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 { 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 { parseGpxAsync } from "@trails-cool/gpx";
import { setGeomFromGpx } from "./routes.server.ts"; 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)); const [activity] = await db.select().from(activities).where(eq(activities.id, id));
if (!activity) return null; if (!activity) return null;
const geojson = await getActivityGeojson(id); 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) { export async function listActivities(ownerId: string) {

View file

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

View file

@ -1,8 +1,10 @@
import { Suspense, lazy } from "react"; import { Suspense, lazy } from "react";
import { data, redirect } from "react-router"; import { data, redirect } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/activities.$id"; import type { Route } from "./+types/activities.$id";
import { getSessionUser } from "~/lib/auth.server"; 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 { listRoutes } from "~/lib/routes.server";
import { ClientDate } from "~/components/ClientDate"; import { ClientDate } from "~/components/ClientDate";
@ -33,6 +35,7 @@ export async function loader({ params, request }: Route.LoaderArgs) {
geojson: activity.geojson ?? null, geojson: activity.geojson ?? null,
startedAt: activity.startedAt?.toISOString() ?? null, startedAt: activity.startedAt?.toISOString() ?? null,
createdAt: activity.createdAt.toISOString(), createdAt: activity.createdAt.toISOString(),
importSource: activity.importSource,
}, },
isOwner, isOwner,
routes: userRoutes.map((r) => ({ id: r.id, name: r.name })), 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 }); 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 }); 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) { export default function ActivityDetailPage({ loaderData }: Route.ComponentProps) {
const { activity, isOwner, routes } = loaderData; const { activity, isOwner, routes } = loaderData;
const { t } = useTranslation("journal");
return ( return (
<div className="mx-auto max-w-4xl px-4 py-8"> <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 && ( {activity.description && (
<p className="mt-2 text-gray-600">{activity.description}</p> <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" /> <input type="hidden" name="intent" value="link-route" />
<div className="flex-1"> <div className="flex-1">
<label htmlFor="routeId" className="block text-sm font-medium text-gray-700"> <label htmlFor="routeId" className="block text-sm font-medium text-gray-700">
Link to existing route {t("activities.linkToRoute")}
</label> </label>
<select <select
id="routeId" id="routeId"
@ -158,12 +176,26 @@ export default function ActivityDetailPage({ loaderData }: Route.ComponentProps)
type="submit" type="submit"
className="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50" 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> </button>
</form> </form>
)} )}
</div> </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> </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" className="flex items-center justify-between rounded-lg border border-gray-200 p-4"
> >
<div> <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"> <div className="mt-1 flex gap-3 text-sm text-gray-500">
{w.startedAt && <ClientDate iso={w.startedAt} />} {w.startedAt && <ClientDate iso={w.startedAt} />}
{w.distance != null && <span>{(w.distance / 1000).toFixed(1)} km</span>} {w.distance != null && <span>{(w.distance / 1000).toFixed(1)} km</span>}

View file

@ -134,6 +134,9 @@ export default {
duration: "Dauer", duration: "Dauer",
linkToRoute: "Mit Route verknüpfen", linkToRoute: "Mit Route verknüpfen",
createRouteFromActivity: "Route aus Aktivität erstellen", 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: { settings: {
title: "Einstellungen", title: "Einstellungen",
@ -181,6 +184,8 @@ export default {
importFrom: "Import von {{provider}}", importFrom: "Import von {{provider}}",
imported: "Importiert", imported: "Importiert",
noWorkouts: "Keine Workouts gefunden.", noWorkouts: "Keine Workouts gefunden.",
noGps: "Kein GPS",
noGpsTooltip: "{{provider}} stellt für diese Aktivität keine Routendaten bereit",
previous: "Zurück", previous: "Zurück",
next: "Weiter", next: "Weiter",
}, },

View file

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