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:
parent
ee76945f20
commit
2cb32cd2d3
25 changed files with 328 additions and 18 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
49
apps/journal/app/lib/sport-type.test.ts
Normal file
49
apps/journal/app/lib/sport-type.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
59
apps/journal/app/lib/sport-type.ts
Normal file
59
apps/journal/app/lib/sport-type.ts
Normal 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";
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue