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>
126 lines
4.6 KiB
TypeScript
126 lines
4.6 KiB
TypeScript
// Mapping from journal activities to ActivityStreams objects (spec:
|
|
// social-federation, "Outbox publishes user's public activities" +
|
|
// push delivery). One mapping, used by both the outbox dispatcher and
|
|
// the deliver-activity job so remotes see identical shapes either way.
|
|
//
|
|
// Shape decision (design.md open question, resolved toward Mastodon
|
|
// compat): plain `Create(Note)`. The Note's HTML content carries the
|
|
// human-readable text + a link back to the activity page; structured
|
|
// trails metadata (distance, elevation, duration) rides along as
|
|
// PropertyValue attachments, which Mastodon ignores gracefully and
|
|
// trails consumers can read without parsing the HTML.
|
|
|
|
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;
|
|
startedAt: Date | null;
|
|
createdAt: Date;
|
|
}
|
|
|
|
/** Public IRI of an activity — its journal detail page. */
|
|
export function activityObjectIri(activityId: string): string {
|
|
return `${getOrigin()}/activities/${activityId}`;
|
|
}
|
|
|
|
function escapeHtml(s: string): string {
|
|
return s
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """);
|
|
}
|
|
|
|
function formatStats(a: FederatableActivity): string {
|
|
const parts: string[] = [];
|
|
if (a.distance != null) parts.push(`${(a.distance / 1000).toFixed(1)} km`);
|
|
if (a.elevationGain != null) parts.push(`↗ ${Math.round(a.elevationGain)} m`);
|
|
if (a.duration != null) {
|
|
const h = Math.floor(a.duration / 3600);
|
|
const m = Math.round((a.duration % 3600) / 60);
|
|
parts.push(h > 0 ? `${h}h ${m}m` : `${m}m`);
|
|
}
|
|
return parts.join(" · ");
|
|
}
|
|
|
|
// Fedify's types reference the *global* Temporal namespace
|
|
// (esnext.temporal lib); Node 22 doesn't ship Temporal at runtime, so
|
|
// we construct via the polyfill and bridge the nominally-distinct (but
|
|
// structurally identical) types with a cast.
|
|
function toInstant(d: Date): Temporal.Instant {
|
|
return TemporalPolyfill.Instant.fromEpochMilliseconds(
|
|
d.getTime(),
|
|
) as unknown as Temporal.Instant;
|
|
}
|
|
|
|
export function activityToNote(a: FederatableActivity, ownerUsername: string): Note {
|
|
const objectIri = activityObjectIri(a.id);
|
|
const stats = formatStats(a);
|
|
const paragraphs = [
|
|
`<p>${escapeHtml(a.name)}</p>`,
|
|
a.description ? `<p>${escapeHtml(a.description)}</p>` : "",
|
|
stats ? `<p>${escapeHtml(stats)}</p>` : "",
|
|
`<p><a href="${objectIri}">${objectIri}</a></p>`,
|
|
].filter(Boolean);
|
|
|
|
const attachments: PropertyValue[] = [];
|
|
if (a.distance != null) {
|
|
attachments.push(new PropertyValue({ name: "distance-m", value: String(Math.round(a.distance)) }));
|
|
}
|
|
if (a.elevationGain != null) {
|
|
attachments.push(new PropertyValue({ name: "elevation-gain-m", value: String(Math.round(a.elevationGain)) }));
|
|
}
|
|
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),
|
|
attribution: new URL(localActorIri(ownerUsername)),
|
|
content: paragraphs.join("\n"),
|
|
mediaType: "text/html",
|
|
url: new URL(objectIri),
|
|
published: toInstant(a.startedAt ?? a.createdAt),
|
|
to: PUBLIC_COLLECTION,
|
|
attachments,
|
|
});
|
|
}
|
|
|
|
export function activityToCreate(a: FederatableActivity, ownerUsername: string): Create {
|
|
return new Create({
|
|
// Stable id derived from the object so replays/dedupe work across
|
|
// outbox pages and push deliveries alike.
|
|
id: new URL(`${activityObjectIri(a.id)}#create`),
|
|
actor: new URL(localActorIri(ownerUsername)),
|
|
object: activityToNote(a, ownerUsername),
|
|
published: toInstant(a.startedAt ?? a.createdAt),
|
|
to: PUBLIC_COLLECTION,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Retraction for a deleted (or un-publicized) activity. Carries a
|
|
* Tombstone so remotes drop their copy (Mastodon honors Delete +
|
|
* Tombstone).
|
|
*/
|
|
export function activityToDelete(objectIri: string, ownerUsername: string): Delete {
|
|
return new Delete({
|
|
id: new URL(`${objectIri}#delete-${Date.now()}`),
|
|
actor: new URL(localActorIri(ownerUsername)),
|
|
object: new Tombstone({ id: new URL(objectIri) }),
|
|
to: PUBLIC_COLLECTION,
|
|
});
|
|
}
|