Merge pull request #531 from trails-cool/apply-activity-sport-type

activity-sport-type: sport/activity type on activities
This commit is contained in:
Ullrich Schäfer 2026-06-12 14:37:20 +02:00 committed by GitHub
commit ede263d4d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 366 additions and 19 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

View file

@ -0,0 +1,29 @@
import { test, expect, gotoHydrated } from "./fixtures/test";
import { setupVirtualAuthenticator, registerUser } from "./helpers/auth";
test.describe("Activity sport type", () => {
test("create an activity with a sport renders the sport badge", async ({ page }) => {
const cdp = await page.context().newCDPSession(page);
await setupVirtualAuthenticator(cdp);
const stamp = Date.now();
await registerUser(page, `sport-${stamp}@example.com`, `sport${stamp}`);
await gotoHydrated(page, "/activities/new");
// Name deliberately avoids the sport word so the badge assertion can't
// accidentally match the title.
await page.getByLabel("Name").fill("Saturday loop");
// exact: the nav avatar button's aria-label is the username ("sport…"),
// which a loose "Sport" match would also catch.
await page.getByLabel("Sport", { exact: true }).selectOption("gravel");
await page.getByRole("button", { name: "Create Activity" }).click();
// Redirects to the new activity's detail page.
await expect(page).toHaveURL(/\/activities\/[0-9a-f-]+$/, { timeout: 10000 });
// Title plus the localized sport badge ("Gravel").
await expect(page.getByRole("heading", { name: "Saturday loop" })).toBeVisible();
await expect(page.getByText("Gravel")).toBeVisible();
});
});

View file

@ -1,30 +1,30 @@
## 1. Data & contract
- [ ] 1.1 Add `SportType` union + `SPORT_TYPES` const to `@trails-cool/db`; add nullable `sportType: text("sport_type").$type<SportType>()` to `journal.activities`.
- [ ] 1.2 `pnpm db:push` against the local DB (additive nullable column, no data migration).
- [ ] 1.3 Add optional `sportType` to the activity read + create Zod schemas in `packages/api/src/activities.ts`.
- [x] 1.1 Add `SportType` union + `SPORT_TYPES` const to `@trails-cool/db`; add nullable `sportType: text("sport_type").$type<SportType>()` to `journal.activities`.
- [x] 1.2 Apply the additive nullable column to local DBs. (`pnpm db:push` is blocked by an unrelated pre-existing `remote_origin_iri` unique-constraint prompt; applied via a non-destructive `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` to dev + e2e. CI applies schema to a fresh DB.)
- [x] 1.3 Add optional `sportType` to the activity read + create Zod schemas in `packages/api/src/activities.ts` (mirrored `SPORT_TYPES`; api is zod-only/standalone).
## 2. Write path
- [ ] 2.1 Add `sportType?: SportType` to `ActivityInput` and persist it in `createActivity` (`apps/journal/app/lib/activities.server.ts`).
- [ ] 2.2 Add `mapSportType(raw: string): SportType` helper with the normalization table; unit-test it.
- [ ] 2.3 Thread `sportType` through the unified `importActivity` input; pass `mapSportType(tour.sport)` from the Komoot bulk import; leave Garmin unset.
- [ ] 2.4 Parse the `sportType` form field in `activitiesNewAction` and add the `<select>` to `activities.new.tsx` (optional, i18n labels).
- [x] 2.1 Add `sportType?: SportType` to `ActivityInput` and persist it in `createActivity` (`apps/journal/app/lib/activities.server.ts`).
- [x] 2.2 Add `mapSportType(raw)` helper with the normalization table; unit-test it (`sport-type.ts` + `sport-type.test.ts`).
- [x] 2.3 Thread `sportType` through the unified `importActivity` input; pass `mapSportType(tour.sport)` from the Komoot bulk import; leave Garmin unset.
- [x] 2.4 Parse the `sportType` form field in `activitiesNewAction` and add the `<select>` to `activities.new.tsx` (optional, i18n labels).
## 3. Read path & display
- [ ] 3.1 Add `sportType` to the detail, feed, and profile loaders' activity projections.
- [ ] 3.2 Build a shared `SportBadge` (inline-SVG icon + i18n label); render it on the detail page, feed cards, and profile list.
- [ ] 3.3 Derive the feed verb from the sport (generic fallback when unset).
- [ ] 3.4 Add `journal.activities.sport.*` keys (label, per-sport names, per-sport verbs) to en + de.
- [x] 3.1 Add `sportType` to the detail, feed, and profile loaders' activity projections (+ the v1 REST endpoints).
- [x] 3.2 Build a shared `SportBadge` (emoji glyph + i18n label); render it on the detail page, feed cards, and profile list.
- [x] 3.3 Derive the feed verb from the sport (generic fallback when unset).
- [x] 3.4 Add `journal.activities.sport.*` keys (label, per-sport names, per-sport verbs) to en + de.
## 4. Federation
- [ ] 4.1 Append a `sport` PropertyValue attachment in `activityToNote()` when set; extend the federation-outbox test to assert it.
- [x] 4.1 Append a `sport` PropertyValue attachment in `activityToNote()` when set; extend the federation-objects test to assert it (and that it's omitted when unset).
## 5. Tests & checks
- [ ] 5.1 Unit: `mapSportType` table (known sports, unknown → `other`, empty → `other`).
- [ ] 5.2 Unit/integration: create→detail round-trip persists and returns `sportType`.
- [ ] 5.3 E2E: create an activity with a sport, assert the badge renders on the detail page.
- [ ] 5.4 `pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2e` green.
- [x] 5.1 Unit: `mapSportType` table (known sports, unknown → `other`, empty → undefined).
- [x] 5.2 Round-trip (create→detail persists `sportType`) — covered by the E2E below.
- [x] 5.3 E2E: create an activity with a sport, assert the badge renders (`e2e/activity-sport-type.test.ts`, registered in `playwright.config.ts`). Passes locally against an `E2E=true` server.
- [x] 5.4 typecheck + lint + unit all green; the new e2e spec passes locally. Full `pnpm test:e2e` runs in CI.

View file

@ -1,10 +1,29 @@
import { z } from "zod";
/**
* Sport / activity type. Must stay in sync with `SPORT_TYPES` in
* `@trails-cool/db` (schema/journal.ts) this package is intentionally
* standalone (zod only), so the wire enum is mirrored here.
*/
export const SPORT_TYPES = [
"hike",
"walk",
"run",
"ride",
"gravel",
"mtb",
"ski",
"other",
] as const;
export const SportTypeSchema = z.enum(SPORT_TYPES);
export type SportType = z.infer<typeof SportTypeSchema>;
/** Activity summary for list views */
export const ActivitySummarySchema = z.object({
id: z.uuid(),
name: z.string(),
description: z.string(),
sportType: SportTypeSchema.nullable(),
routeId: z.uuid().nullable(),
routeName: z.string().nullable(),
distance: z.number().nullable(),
@ -32,6 +51,7 @@ export const ActivityListResponseSchema = z.object({
export const CreateActivityRequestSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().max(5000).default(""),
sportType: SportTypeSchema.optional(),
gpx: z.string().optional(),
routeId: z.uuid().optional(),
startedAt: z.iso.datetime().optional(),

View file

@ -47,9 +47,11 @@ export {
ActivitySummarySchema, ActivityDetailSchema,
ActivityListResponseSchema,
CreateActivityRequestSchema, CreateActivityResponseSchema,
SPORT_TYPES, SportTypeSchema,
type ActivitySummary, type ActivityDetail,
type ActivityListResponse,
type CreateActivityRequest, type CreateActivityResponse,
type SportType,
} from "./activities.ts";
// Uploads

View file

@ -142,6 +142,24 @@ export const routeVersions = journalSchema.table("route_versions", {
*/
export type Audience = "public" | "followers-only";
/**
* Sport / activity type. Nullable on activities (NULL = unspecified, which
* is always valid). Stored as a plain text() column with a `.$type<>` guard,
* matching the visibility/audience convention (no pgEnum). `SPORT_TYPES` is
* the runtime source of truth for validation and iteration.
*/
export const SPORT_TYPES = [
"hike",
"walk",
"run",
"ride",
"gravel",
"mtb",
"ski",
"other",
] as const;
export type SportType = (typeof SPORT_TYPES)[number];
export const activities = journalSchema.table("activities", {
id: text("id").primaryKey(),
// Local author. NULL for activities ingested from a remote trails
@ -153,6 +171,8 @@ export const activities = journalSchema.table("activities", {
routeId: text("route_id").references(() => routes.id),
name: text("name").notNull(),
description: text("description").default(""),
// Sport / activity type (NULL = unspecified). See SPORT_TYPES above.
sportType: text("sport_type").$type<SportType>(),
gpx: text("gpx"),
geom: lineString("geom"),
startedAt: timestamp("started_at", { withTimezone: true }),

View file

@ -365,6 +365,28 @@ export default {
delete: "Aktivität löschen",
deleteConfirm: "Möchtest du diese Aktivität wirklich löschen?",
importedFrom: "Importiert von {{provider}}",
sport: {
label: "Sportart",
unspecified: "Nicht angegeben",
hike: "Wandern",
walk: "Gehen",
run: "Laufen",
ride: "Radfahren",
gravel: "Gravel",
mtb: "Mountainbike",
ski: "Ski",
other: "Sonstiges",
verb: {
hike: "war wandern",
walk: "war spazieren",
run: "war laufen",
ride: "war Rad fahren",
gravel: "war Gravel fahren",
mtb: "war Mountainbiken",
ski: "war Ski fahren",
other: "hat eine Aktivität erfasst",
},
},
sortByDate: "Aktivitätsdatum",
sortByAdded: "Hinzugefügt am",
visibility: {

View file

@ -365,6 +365,28 @@ export default {
delete: "Delete Activity",
deleteConfirm: "Are you sure you want to delete this activity?",
importedFrom: "Imported from {{provider}}",
sport: {
label: "Sport",
unspecified: "Unspecified",
hike: "Hike",
walk: "Walk",
run: "Run",
ride: "Ride",
gravel: "Gravel",
mtb: "Mountain bike",
ski: "Ski",
other: "Other",
verb: {
hike: "went hiking",
walk: "went for a walk",
run: "went for a run",
ride: "went for a ride",
gravel: "went gravel riding",
mtb: "went mountain biking",
ski: "went skiing",
other: "logged an activity",
},
},
sortByDate: "Activity date",
sortByAdded: "Date added",
visibility: {

View file

@ -114,6 +114,14 @@ export default defineConfig({
baseURL: "http://localhost:3000",
},
},
{
name: "activity-sport-type",
testMatch: "activity-sport-type.test.ts",
use: {
...devices["Desktop Chrome"],
baseURL: "http://localhost:3000",
},
},
],
webServer: process.env.CI
? [