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
39
apps/journal/app/components/SportBadge.tsx
Normal file
39
apps/journal/app/components/SportBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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="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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
- [x] 5.1 Unit: `mapSportType` table (known sports, unknown → `other`, empty → undefined).
|
||||
- [ ] 5.2 Round-trip (create→detail persists `sportType`) — folded into the E2E below.
|
||||
- [ ] 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.
|
||||
- [ ] 5.4 `pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2e` green. (typecheck/lint/unit green; e2e pending.)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue