activity-sport-type: schema, contract, write/read paths, federation

Implements the activity-sport-type change (specs/activity-sport-type):

- db: nullable `sport_type` column on journal.activities + SportType /
  SPORT_TYPES (text().$type<> convention).
- api: optional sportType on the activity read + create schemas (mirrored
  SPORT_TYPES; @trails-cool/api stays zod-only).
- write: ActivityInput + createActivity persist it; mapSportType() normalizes
  provider strings (Komoot bulk import passes tour.sport; Garmin unset);
  threaded through the unified importActivity.
- read/display: sportType added to the detail/feed/profile loaders and the v1
  REST endpoints; shared SportBadge (glyph + i18n label) on detail, feed, and
  profile; sport-aware feed verb; create-form <select>.
- i18n: journal.activities.sport.* (labels + verbs) in en + de.
- federation: `sport` PropertyValue on the Note when set.

Tests: mapSportType unit table; federation asserts the sport attachment is
present when set and omitted when unset. typecheck + lint + unit all green.
E2E (create→badge) still to add.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-06-12 14:11:41 +02:00
parent ee76945f20
commit 2cb32cd2d3
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
25 changed files with 328 additions and 18 deletions

View file

@ -0,0 +1,39 @@
import { useTranslation } from "react-i18next";
import type { SportType } from "@trails-cool/db/schema/journal";
// Emoji glyphs as lightweight, dependency-free sport icons. The localized
// label carries the meaning; the glyph is decorative (aria-hidden).
const SPORT_EMOJI: Record<SportType, string> = {
hike: "🥾",
walk: "🚶",
run: "🏃",
ride: "🚴",
gravel: "🚲",
mtb: "🚵",
ski: "⛷️",
other: "📍",
};
/**
* Small pill (glyph + localized label) shown next to an activity title on the
* detail page, feed cards, and the profile list. Renders nothing when the
* sport type is unset.
*/
export function SportBadge({
sportType,
className,
}: {
sportType: SportType | null | undefined;
className?: string;
}) {
const { t } = useTranslation("journal");
if (!sportType) return null;
return (
<span
className={`inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-700${className ? ` ${className}` : ""}`}
>
<span aria-hidden>{SPORT_EMOJI[sportType]}</span>
{t(`activities.sport.${sportType}`)}
</span>
);
}

View file

@ -3,7 +3,7 @@ import { eq, desc, and, isNotNull, sql } from "drizzle-orm";
import { unionAll } from "drizzle-orm/pg-core";
import { getDb } from "./db.ts";
import { activities, routes, syncImports, users, follows, remoteActors } from "@trails-cool/db/schema/journal";
import type { Visibility } from "@trails-cool/db/schema/journal";
import type { Visibility, SportType } from "@trails-cool/db/schema/journal";
import { processGpx, writeGeom } from "./gpx-save.server.ts";
import type { ProcessedGpx } from "./gpx-save.server.ts";
import { enqueueOptional } from "./boss.server.ts";
@ -16,6 +16,7 @@ import {
export interface ActivityInput {
name: string;
description?: string;
sportType?: SportType | null;
gpx?: string;
routeId?: string;
distance?: number | null;
@ -93,6 +94,7 @@ export async function createActivity(ownerId: string, input: ActivityInput) {
routeId: input.routeId ?? null,
name: input.name,
description: input.description ?? "",
sportType: input.sportType ?? null,
gpx: input.gpx,
distance,
duration,
@ -220,6 +222,7 @@ export async function listSocialFeed(followerId: string, limit: number = 50) {
.select({
id: activities.id,
name: activities.name,
sportType: activities.sportType,
distance: activities.distance,
elevationGain: activities.elevationGain,
duration: activities.duration,
@ -247,6 +250,7 @@ export async function listSocialFeed(followerId: string, limit: number = 50) {
.select({
id: activities.id,
name: activities.name,
sportType: activities.sportType,
distance: activities.distance,
elevationGain: activities.elevationGain,
duration: activities.duration,
@ -294,6 +298,7 @@ export async function listRecentPublicActivities(limit: number = 20) {
.select({
id: activities.id,
name: activities.name,
sportType: activities.sportType,
distance: activities.distance,
elevationGain: activities.elevationGain,
duration: activities.duration,

View file

@ -11,6 +11,7 @@ const ACTIVITY: FederatableActivity = {
id: "act-1",
name: "Morning ride <3",
description: 'Through the "forest" & hills',
sportType: "ride",
distance: 42_195,
elevationGain: 512.4,
duration: 2 * 3600 + 30 * 60,
@ -50,6 +51,14 @@ describe("activityToNote", () => {
expect(byName.get("distance-m")).toBe("42195");
expect(byName.get("elevation-gain-m")).toBe("512");
expect(byName.get("duration-s")).toBe("9000");
expect(byName.get("sport")).toBe("ride");
});
it("omits the sport attachment when sport is unset", async () => {
const note = activityToNote({ ...ACTIVITY, sportType: null }, "bruno");
const names = [];
for await (const a of note.getAttachments()) names.push(String((a as { name: unknown }).name));
expect(names).not.toContain("sport");
});
it("omits stats it doesn't have", () => {

View file

@ -14,11 +14,13 @@ import { Temporal as TemporalPolyfill } from "@js-temporal/polyfill";
import { Create, Delete, Note, PropertyValue, Tombstone, PUBLIC_COLLECTION } from "@fedify/fedify/vocab";
import { getOrigin } from "./config.server.ts";
import { localActorIri } from "./actor-iri.ts";
import type { SportType } from "@trails-cool/db/schema/journal";
export interface FederatableActivity {
id: string;
name: string;
description: string | null;
sportType: SportType | null;
distance: number | null;
elevationGain: number | null;
duration: number | null;
@ -81,6 +83,9 @@ export function activityToNote(a: FederatableActivity, ownerUsername: string): N
if (a.duration != null) {
attachments.push(new PropertyValue({ name: "duration-s", value: String(a.duration) }));
}
if (a.sportType != null) {
attachments.push(new PropertyValue({ name: "sport", value: a.sportType }));
}
return new Note({
id: new URL(objectIri),

View file

@ -21,6 +21,7 @@ export async function listPublicActivitiesPage(
id: activities.id,
name: activities.name,
description: activities.description,
sportType: activities.sportType,
distance: activities.distance,
elevationGain: activities.elevationGain,
duration: activities.duration,

View file

@ -4,6 +4,7 @@ import { importBatches } from "@trails-cool/db/schema/journal";
import { fetchKomootTours, fetchKomootTourGpx } from "./komoot.server.ts";
import { isAlreadyImported, recordImport } from "./sync/imports.server.ts";
import { createActivity } from "./activities.server.ts";
import { mapSportType } from "./sport-type.ts";
import { decrypt } from "./crypto.server.ts";
import { logger } from "./logger.server.ts";
@ -96,6 +97,7 @@ export async function runKomootBulkImport(
const activityId = await createActivity(userId, {
name: tour.name,
gpx,
sportType: mapSportType(tour.sport),
distance: tour.distance > 0 ? tour.distance : null,
duration: tour.duration > 0 ? tour.duration : null,
startedAt: tour.date ? new Date(tour.date) : null,

View file

@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { mapSportType } from "./sport-type.ts";
describe("mapSportType", () => {
it("maps known foot sports", () => {
expect(mapSportType("hike")).toBe("hike");
expect(mapSportType("mountaineering")).toBe("hike");
expect(mapSportType("jogging")).toBe("run");
expect(mapSportType("running")).toBe("run");
expect(mapSportType("walking")).toBe("walk");
});
it("folds road/touring/city bikes into ride", () => {
expect(mapSportType("racebike")).toBe("ride");
expect(mapSportType("touringbicycle")).toBe("ride");
expect(mapSportType("citybike")).toBe("ride");
expect(mapSportType("e_touringbicycle")).toBe("ride");
});
it("maps gravel and mountain bikes", () => {
expect(mapSportType("gravelbike")).toBe("gravel");
expect(mapSportType("mountainbike")).toBe("mtb");
expect(mapSportType("e_mountainbike")).toBe("mtb");
expect(mapSportType("mountainbikeadvanced")).toBe("mtb");
});
it("maps snow sports to ski", () => {
expect(mapSportType("skitour")).toBe("ski");
expect(mapSportType("skatingnordic")).toBe("ski");
});
it("normalizes case, whitespace, and separators", () => {
expect(mapSportType("Mountain Bike")).toBe("mtb");
expect(mapSportType(" HIKE ")).toBe("hike");
expect(mapSportType("gravel-ride")).toBe("gravel");
});
it("falls back to other for unrecognized non-empty input", () => {
expect(mapSportType("unicycle")).toBe("other");
expect(mapSportType("kitesurf")).toBe("other");
});
it("returns undefined when no descriptor is supplied", () => {
expect(mapSportType(null)).toBeUndefined();
expect(mapSportType(undefined)).toBeUndefined();
expect(mapSportType("")).toBeUndefined();
expect(mapSportType(" ")).toBeUndefined();
});
});

View file

@ -0,0 +1,59 @@
import type { SportType } from "@trails-cool/db/schema/journal";
/**
* Normalize a connected service's sport/activity descriptor into our
* `SportType` enum. Source taxonomies (Komoot's `tour.sport`, etc.) are
* messy and evolve, so this is the single place that maps them; anything
* without a confident match becomes `other`. This is the only spot to extend
* when a provider adds a new descriptor.
*/
const SPORT_ALIASES: Record<string, SportType> = {
// foot
hike: "hike",
hiking: "hike",
mountaineering: "hike",
walk: "walk",
walking: "walk",
snowshoe: "walk",
jogging: "run",
running: "run",
run: "run",
trailrunning: "run",
// wheels — road / touring / city fold into `ride`
ride: "ride",
road: "ride",
racebike: "ride",
touringbicycle: "ride",
citybike: "ride",
e_racebike: "ride",
e_touringbicycle: "ride",
e_citybike: "ride",
// gravel
gravel: "gravel",
gravelbike: "gravel",
gravelride: "gravel",
// mountain bike (incl. e-MTB and difficulty variants)
mtb: "mtb",
mountainbike: "mtb",
mountainbikeeasy: "mtb",
mountainbikeadvanced: "mtb",
e_mountainbike: "mtb",
// snow
ski: "ski",
skitour: "ski",
nordic: "ski",
skatingnordic: "ski",
crosscountryski: "ski",
};
/**
* Map a raw provider sport string to a `SportType`, or `undefined` when the
* provider supplied nothing (so the activity is stored with no sport type
* rather than a guessed `other`).
*/
export function mapSportType(raw: string | null | undefined): SportType | undefined {
if (raw == null) return undefined;
const key = raw.trim().toLowerCase().replace(/[\s-]+/g, "");
if (key === "") return undefined;
return SPORT_ALIASES[key] ?? "other";
}

View file

@ -3,6 +3,7 @@ import { eq, and, inArray } from "drizzle-orm";
import { getDb } from "../db.ts";
import { syncImports } from "@trails-cool/db/schema/journal";
import { createActivity } from "../activities.server.ts";
import type { SportType } from "@trails-cool/db/schema/journal";
export async function recordImport(
userId: string,
@ -59,6 +60,9 @@ export async function importActivity(
distance?: number | null;
duration?: number | null;
startedAt?: Date | null;
// Already normalized to our enum by the caller (see mapSportType);
// undefined when the provider supplied no sport.
sportType?: SportType;
},
): Promise<{ activityId: string }> {
const activityId = await createActivity(userId, {
@ -67,6 +71,7 @@ export async function importActivity(
distance: input.distance,
duration: input.duration,
startedAt: input.startedAt,
sportType: input.sportType,
});
await recordImport(userId, provider, externalWorkoutId, activityId);
return { activityId };

View file

@ -41,6 +41,7 @@ export async function loadActivityDetail(request: Request, id: string | undefine
id: activity.id,
name: activity.name,
description: activity.description,
sportType: activity.sportType,
distance: activity.distance,
elevationGain: activity.elevationGain,
elevationLoss: activity.elevationLoss,

View file

@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import type { Route } from "./+types/activities.$id";
import { ClientDate } from "~/components/ClientDate";
import { ClientMap } from "~/components/ClientMap";
import { SportBadge } from "~/components/SportBadge";
import { loadActivityDetail, activityDetailAction } from "./activities.$id.server";
import {
federationEnabled,
@ -63,6 +64,7 @@ export default function ActivityDetailPage({ loaderData }: Route.ComponentProps)
<div className="mx-auto max-w-4xl px-4 py-8">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900">{activity.name}</h1>
<SportBadge sportType={activity.sportType} />
{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 })}

View file

@ -4,6 +4,8 @@ import { data, redirect } from "react-router";
import { requireSessionUser } from "~/lib/auth/session.server";
import { createActivity } from "~/lib/activities.server";
import { listRoutes } from "~/lib/routes.server";
import { SPORT_TYPES } from "@trails-cool/db/schema/journal";
import type { SportType } from "@trails-cool/db/schema/journal";
export async function loadActivitiesNew(request: Request) {
const user = await requireSessionUser(request);
@ -21,10 +23,17 @@ export async function activitiesNewAction(request: Request) {
const name = formData.get("name") as string;
const description = formData.get("description") as string;
const routeId = formData.get("routeId") as string | null;
const sportRaw = formData.get("sportType");
const gpxFile = formData.get("gpx") as File | null;
if (!name) return data({ error: "Name is required" }, { status: 400 });
// Empty/absent selection → unspecified; anything else must be a known value.
const sportType =
typeof sportRaw === "string" && (SPORT_TYPES as readonly string[]).includes(sportRaw)
? (sportRaw as SportType)
: undefined;
let gpx: string | undefined;
if (gpxFile && gpxFile.size > 0) {
gpx = await gpxFile.text();
@ -33,6 +42,7 @@ export async function activitiesNewAction(request: Request) {
const activityId = await createActivity(user.id, {
name,
description,
sportType,
gpx,
routeId: routeId || undefined,
});

View file

@ -1,4 +1,6 @@
import { data } from "react-router";
import { useTranslation } from "react-i18next";
import { SPORT_TYPES } from "@trails-cool/api";
import type { Route } from "./+types/activities.new";
import { loadActivitiesNew, activitiesNewAction } from "./activities.new.server";
@ -16,6 +18,7 @@ export function meta(_args: Route.MetaArgs) {
export default function NewActivityPage({ loaderData }: Route.ComponentProps) {
const { routes } = loaderData;
const { t } = useTranslation("journal");
return (
<div className="mx-auto max-w-2xl px-4 py-8">
@ -48,6 +51,25 @@ export default function NewActivityPage({ loaderData }: Route.ComponentProps) {
/>
</div>
<div>
<label htmlFor="sportType" className="block text-sm font-medium text-gray-700">
{t("activities.sport.label")}
</label>
<select
id="sportType"
name="sportType"
defaultValue=""
className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">{t("activities.sport.unspecified")}</option>
{SPORT_TYPES.map((s) => (
<option key={s} value={s}>
{t(`activities.sport.${s}`)}
</option>
))}
</select>
</div>
<div>
<label htmlFor="gpx" className="block text-sm font-medium text-gray-700">
GPX file

View file

@ -17,6 +17,7 @@ export async function loader({ request, params }: Route.LoaderArgs) {
id: activity.id,
name: activity.name,
description: activity.description ?? "",
sportType: activity.sportType,
routeId: activity.routeId,
routeName: null, // TODO: join route name (matches the list endpoint)
photos: [], // no photos on this surface yet; contract field

View file

@ -39,6 +39,7 @@ export async function loader({ request }: Route.LoaderArgs) {
id: a.id,
name: a.name,
description: a.description ?? "",
sportType: a.sportType,
routeId: a.routeId,
routeName: null, // TODO: join route name
distance: a.distance,

View file

@ -23,6 +23,7 @@ export async function loadFeed(request: Request) {
activities: rows.map((a) => ({
id: a.id,
name: a.name,
sportType: a.sportType,
distance: a.distance,
elevationGain: a.elevationGain,
duration: a.duration,

View file

@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import type { Route } from "./+types/feed";
import { ClientDate } from "~/components/ClientDate";
import { ClientMap } from "~/components/ClientMap";
import { SportBadge } from "~/components/SportBadge";
import { loadFeed } from "./feed.server";
export async function loader({ request }: Route.LoaderArgs) {
@ -114,7 +115,10 @@ export default function Feed({ loaderData }: Route.ComponentProps) {
</div>
<div className="flex flex-1 flex-col justify-between">
<div>
<h3 className="text-base font-medium text-gray-900">{a.name}</h3>
<div className="flex items-center gap-2">
<h3 className="text-base font-medium text-gray-900">{a.name}</h3>
<SportBadge sportType={a.sportType} />
</div>
<div className="mt-1 text-sm text-gray-500">
{a.remote ? (
<span>
@ -132,6 +136,9 @@ export default function Feed({ loaderData }: Route.ComponentProps) {
{a.ownerDisplayName ?? a.ownerUsername}
</a>
)}
{a.sportType && (
<span> {t(`activities.sport.verb.${a.sportType}`)}</span>
)}
{" · "}
<ClientDate iso={a.startedAt ?? a.createdAt} />
</div>

View file

@ -79,6 +79,7 @@ export async function loadUserProfile(request: Request, username: string) {
id: a.id,
name: a.name,
description: a.description,
sportType: a.sportType,
distance: a.distance,
duration: a.duration,
startedAt: a.startedAt?.toISOString() ?? null,

View file

@ -3,6 +3,7 @@ import type { Route } from "./+types/users.$username";
import { useTranslation } from "react-i18next";
import { ClientDate } from "~/components/ClientDate";
import { FollowButton } from "~/components/FollowButton";
import { SportBadge } from "~/components/SportBadge";
import { loadUserProfile } from "./users.$username.server";
import {
federationEnabled,
@ -224,7 +225,10 @@ export default function UserProfilePage({ loaderData }: Route.ComponentProps) {
<li key={a.id} className="px-4 py-3">
<a href={`/activities/${a.id}`} className="block hover:bg-gray-50">
<div className="flex items-baseline justify-between gap-4">
<span className="font-medium text-gray-900">{a.name}</span>
<span className="flex items-baseline gap-2">
<span className="font-medium text-gray-900">{a.name}</span>
<SportBadge sportType={a.sportType} />
</span>
{a.distance != null && (
<span className="shrink-0 text-sm tabular-nums text-gray-600">
{(a.distance / 1000).toFixed(1)} km