diff --git a/apps/journal/app/components/SportBadge.tsx b/apps/journal/app/components/SportBadge.tsx new file mode 100644 index 0000000..e1a130e --- /dev/null +++ b/apps/journal/app/components/SportBadge.tsx @@ -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 = { + 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 ( + + {SPORT_EMOJI[sportType]} + {t(`activities.sport.${sportType}`)} + + ); +} diff --git a/apps/journal/app/lib/activities.server.ts b/apps/journal/app/lib/activities.server.ts index cfc77b0..4058326 100644 --- a/apps/journal/app/lib/activities.server.ts +++ b/apps/journal/app/lib/activities.server.ts @@ -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, diff --git a/apps/journal/app/lib/federation-objects.server.test.ts b/apps/journal/app/lib/federation-objects.server.test.ts index 92ff81d..7b30df7 100644 --- a/apps/journal/app/lib/federation-objects.server.test.ts +++ b/apps/journal/app/lib/federation-objects.server.test.ts @@ -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", () => { diff --git a/apps/journal/app/lib/federation-objects.server.ts b/apps/journal/app/lib/federation-objects.server.ts index d07a267..6981c10 100644 --- a/apps/journal/app/lib/federation-objects.server.ts +++ b/apps/journal/app/lib/federation-objects.server.ts @@ -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), diff --git a/apps/journal/app/lib/federation-outbox.server.ts b/apps/journal/app/lib/federation-outbox.server.ts index 098f8ce..6f9dee0 100644 --- a/apps/journal/app/lib/federation-outbox.server.ts +++ b/apps/journal/app/lib/federation-outbox.server.ts @@ -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, diff --git a/apps/journal/app/lib/komoot-bulk-import.server.ts b/apps/journal/app/lib/komoot-bulk-import.server.ts index 474be09..defb7a4 100644 --- a/apps/journal/app/lib/komoot-bulk-import.server.ts +++ b/apps/journal/app/lib/komoot-bulk-import.server.ts @@ -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, diff --git a/apps/journal/app/lib/sport-type.test.ts b/apps/journal/app/lib/sport-type.test.ts new file mode 100644 index 0000000..df35644 --- /dev/null +++ b/apps/journal/app/lib/sport-type.test.ts @@ -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(); + }); +}); diff --git a/apps/journal/app/lib/sport-type.ts b/apps/journal/app/lib/sport-type.ts new file mode 100644 index 0000000..5317801 --- /dev/null +++ b/apps/journal/app/lib/sport-type.ts @@ -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 = { + // 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"; +} diff --git a/apps/journal/app/lib/sync/imports.server.ts b/apps/journal/app/lib/sync/imports.server.ts index aa5f3cb..18c4116 100644 --- a/apps/journal/app/lib/sync/imports.server.ts +++ b/apps/journal/app/lib/sync/imports.server.ts @@ -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 }; diff --git a/apps/journal/app/routes/activities.$id.server.ts b/apps/journal/app/routes/activities.$id.server.ts index dbca57c..d808f03 100644 --- a/apps/journal/app/routes/activities.$id.server.ts +++ b/apps/journal/app/routes/activities.$id.server.ts @@ -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, diff --git a/apps/journal/app/routes/activities.$id.tsx b/apps/journal/app/routes/activities.$id.tsx index 37220d0..27d1636 100644 --- a/apps/journal/app/routes/activities.$id.tsx +++ b/apps/journal/app/routes/activities.$id.tsx @@ -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)

{activity.name}

+ {activity.importSource && ( {t("activities.importedFrom", { provider: activity.importSource.provider })} diff --git a/apps/journal/app/routes/activities.new.server.ts b/apps/journal/app/routes/activities.new.server.ts index c288f05..a0fb6d0 100644 --- a/apps/journal/app/routes/activities.new.server.ts +++ b/apps/journal/app/routes/activities.new.server.ts @@ -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, }); diff --git a/apps/journal/app/routes/activities.new.tsx b/apps/journal/app/routes/activities.new.tsx index cd66ee1..8caa7ad 100644 --- a/apps/journal/app/routes/activities.new.tsx +++ b/apps/journal/app/routes/activities.new.tsx @@ -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 (
@@ -48,6 +51,25 @@ export default function NewActivityPage({ loaderData }: Route.ComponentProps) { />
+
+ + +
+
-

{a.name}

+
+

{a.name}

+ +
{a.remote ? ( @@ -132,6 +136,9 @@ export default function Feed({ loaderData }: Route.ComponentProps) { {a.ownerDisplayName ?? a.ownerUsername} )} + {a.sportType && ( + {t(`activities.sport.verb.${a.sportType}`)} + )} {" · "}
diff --git a/apps/journal/app/routes/users.$username.server.ts b/apps/journal/app/routes/users.$username.server.ts index 280f9cd..61e6bc4 100644 --- a/apps/journal/app/routes/users.$username.server.ts +++ b/apps/journal/app/routes/users.$username.server.ts @@ -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, diff --git a/apps/journal/app/routes/users.$username.tsx b/apps/journal/app/routes/users.$username.tsx index 7fb950d..e344b46 100644 --- a/apps/journal/app/routes/users.$username.tsx +++ b/apps/journal/app/routes/users.$username.tsx @@ -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) {
  • - {a.name} + + {a.name} + + {a.distance != null && ( {(a.distance / 1000).toFixed(1)} km diff --git a/e2e/activity-sport-type.test.ts b/e2e/activity-sport-type.test.ts new file mode 100644 index 0000000..3fbe207 --- /dev/null +++ b/e2e/activity-sport-type.test.ts @@ -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(); + }); +}); diff --git a/openspec/changes/activity-sport-type/tasks.md b/openspec/changes/activity-sport-type/tasks.md index f41d303..ea5ccc3 100644 --- a/openspec/changes/activity-sport-type/tasks.md +++ b/openspec/changes/activity-sport-type/tasks.md @@ -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()` 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()` 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 `` 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. diff --git a/packages/api/src/activities.ts b/packages/api/src/activities.ts index 8da6e39..b59b138 100644 --- a/packages/api/src/activities.ts +++ b/packages/api/src/activities.ts @@ -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; + /** 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(), diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index fc88341..b8b4a2d 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -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 diff --git a/packages/db/src/schema/journal.ts b/packages/db/src/schema/journal.ts index e4b7b68..a3c99b2 100644 --- a/packages/db/src/schema/journal.ts +++ b/packages/db/src/schema/journal.ts @@ -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(), gpx: text("gpx"), geom: lineString("geom"), startedAt: timestamp("started_at", { withTimezone: true }), diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 8c9255c..9677bfb 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -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: { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 72f4d6e..cc40ce6 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -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: { diff --git a/playwright.config.ts b/playwright.config.ts index 07140d1..9e5c0dc 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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 ? [