Rebuild the Journal home around a public feed and flagship marketing

The old home was an h1, subtitle, and two auth buttons — visitors
arriving at trails.cool had no idea what it was, and self-hosted
instances had nothing interesting to land on.

The new home is one layout that serves both audiences:

- Hero: a product-describing h1 ("Federated outdoor journal") +
  tagline. The site name already lives in the top banner and nav brand,
  so the h1 carries the pitch instead of triplicating "trails.cool".
- Auth CTAs: Register (blue) + Sign In (outlined) get primary weight.
  Planner demotes to a small "Or try the Planner without an account →"
  link below them — it's a nice escape hatch but not the goal.
- Marketing cards: on the flagship only, a 2x2 grid of emoji-icon cards
  for Planner / Journal / Federation / Ownership, matching the Planner
  home's visual weight. `IS_FLAGSHIP=true` toggles it. Self-hosters get
  a "Powered by trails.cool — about the project" link back to the
  flagship instead, so they don't have to write their own marketing.
- Public feed: reverse-chronological list of the 20 most recent public
  activities on the instance, with owner + distance + date. Empty state
  for fresh installs.

Plumbing:
- `listRecentPublicActivities(limit)` in activities.server.ts joins
  users so the feed can render "by <displayName>" in one query.
- `IS_FLAGSHIP` env wired through docker-compose.yml; cd-apps and
  cd-infra both write `IS_FLAGSHIP=true` alongside `DOMAIN=trails.cool`,
  so self-hosters (who deploy without these workflows) default to off.

Specs:
- `activity-feed`: new "Instance-wide public activity feed" requirement
  so the visibility contract is pinned (public in feed, unlisted +
  private are not).
- `journal-landing`: new small spec capturing the hero / marketing /
  feed layout contract and the `IS_FLAGSHIP` toggle, so future
  instance-branding or empty-state changes have a stable anchor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-24 19:35:45 +02:00
parent a9847c5cab
commit f0ef989ac3
10 changed files with 339 additions and 65 deletions

View file

@ -68,6 +68,8 @@ jobs:
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/app.env
echo "SENTRY_RELEASE=${{ github.sha }}" >> infrastructure/app.env
echo "DOMAIN=trails.cool" >> infrastructure/app.env
# Flagship marker — see cd-infra.yml for what this gates.
echo "IS_FLAGSHIP=true" >> infrastructure/app.env
- name: Copy files to server
uses: appleboy/scp-action@v1

View file

@ -32,6 +32,10 @@ jobs:
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/.env
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.infra.env >> infrastructure/.env
echo "DOMAIN=trails.cool" >> infrastructure/.env
# Flagship marker — the Journal home renders the project
# marketing block when this is "true" and the self-host
# footer link when it's unset.
echo "IS_FLAGSHIP=true" >> infrastructure/.env
- name: Copy configs to server
uses: appleboy/scp-action@v1

View file

@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { eq, desc, and, sql } from "drizzle-orm";
import { getDb } from "./db.ts";
import { activities, routes, syncImports } from "@trails-cool/db/schema/journal";
import { activities, routes, syncImports, users } from "@trails-cool/db/schema/journal";
import type { Visibility } from "@trails-cool/db/schema/journal";
import { parseGpxAsync } from "@trails-cool/gpx";
import { setGeomFromGpx } from "./routes.server.ts";
@ -134,6 +134,38 @@ export async function listPublicActivitiesForOwner(ownerId: string) {
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
}
/**
* Instance-wide public activity feed. Joins users so the caller can
* render "by <displayName>" without a second round-trip. Used by the
* Journal home to give arriving visitors something concrete to look at.
* Unlisted activities are excluded: they're reachable by direct URL
* only and shouldn't surface in any listing.
*/
export async function listRecentPublicActivities(limit: number = 20) {
const db = getDb();
const rows = await db
.select({
id: activities.id,
name: activities.name,
distance: activities.distance,
elevationGain: activities.elevationGain,
duration: activities.duration,
startedAt: activities.startedAt,
createdAt: activities.createdAt,
ownerUsername: users.username,
ownerDisplayName: users.displayName,
})
.from(activities)
.innerJoin(users, eq(activities.ownerId, users.id))
.where(eq(activities.visibility, "public"))
.orderBy(desc(activities.createdAt))
.limit(limit);
const ids = rows.map((r) => r.id);
const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map();
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
}
export async function linkActivityToRoute(activityId: string, routeId: string, _ownerId: string) {
const db = getDb();
await db

View file

@ -6,11 +6,14 @@ import type { Route } from "./+types/home";
import { getSessionUser } from "~/lib/auth.server";
import { getDb } from "~/lib/db";
import { credentials } from "@trails-cool/db/schema/journal";
import { listRecentPublicActivities } from "~/lib/activities.server";
import { ClientDate } from "~/components/ClientDate";
import { ClientMap } from "~/components/ClientMap";
export function meta(_args: Route.MetaArgs) {
return [
{ title: "trails.cool" },
{ name: "description", content: "Your outdoor activity journal" },
{ name: "description", content: "Federated outdoor journal. Plan routes, record activities, own your data." },
];
}
@ -30,14 +33,32 @@ export async function loader({ request }: Route.LoaderArgs) {
showAddPasskey = (row?.count ?? 0) === 0;
}
const recent = await listRecentPublicActivities(20);
const plannerUrl = process.env.PLANNER_URL ?? "https://planner.trails.cool";
const isFlagship = process.env.IS_FLAGSHIP === "true";
return data({
user: user ? { id: user.id, username: user.username, displayName: user.displayName } : null,
showAddPasskey,
plannerUrl,
isFlagship,
activities: recent.map((a) => ({
id: a.id,
name: a.name,
distance: a.distance,
elevationGain: a.elevationGain,
duration: a.duration,
startedAt: a.startedAt?.toISOString() ?? null,
createdAt: a.createdAt.toISOString(),
geojson: a.geojson ?? null,
ownerUsername: a.ownerUsername,
ownerDisplayName: a.ownerDisplayName,
})),
});
}
export default function Home({ loaderData }: Route.ComponentProps) {
const { user, showAddPasskey } = loaderData;
const { user, showAddPasskey, plannerUrl, isFlagship, activities } = loaderData;
const { t } = useTranslation("journal");
const [addingPasskey, setAddingPasskey] = useState(false);
const [passkeyDone, setPasskeyDone] = useState(false);
@ -58,7 +79,6 @@ export default function Home({ loaderData }: Route.ComponentProps) {
setError(null);
try {
// Get registration options for existing user
const startResp = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -99,74 +119,174 @@ export default function Home({ loaderData }: Route.ComponentProps) {
}, [user]);
return (
<div className="mx-auto max-w-4xl px-4 py-16">
<h1 className="text-4xl font-bold text-gray-900">{t("title")}</h1>
<p className="mt-4 text-lg text-gray-600">{t("subtitle")}</p>
<div className="mx-auto max-w-4xl px-4 py-12">
{/* Hero. The site name lives in the top banner + nav brand, so the
h1 carries the product pitch instead to avoid "trails.cool"
triplication on a narrow strip. */}
<section>
<h1 className="text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl">
{t("home.heroTitle")}
</h1>
<p className="mt-4 text-lg text-gray-600">{t("home.heroSubtitle")}</p>
{user ? (
<div className="mt-8">
<p className="text-gray-700">
{t("welcome")} <a href={`/users/${user.username}`} className="text-blue-600 hover:underline">{user.displayName ?? user.username}</a>
</p>
{showAddPasskey && !passkeyDone && supportsPasskey === true && (
<div className="mt-6 rounded-md bg-blue-50 p-4">
<p className="text-sm text-blue-800">
{t("addPasskeyPrompt")}
</p>
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
<button
onClick={handleAddPasskey}
disabled={addingPasskey}
className="mt-3 rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
{user ? (
<div className="mt-6 text-gray-700">
{t("welcome")}{" "}
<a href={`/users/${user.username}`} className="text-blue-600 hover:underline">
{user.displayName ?? user.username}
</a>
</div>
) : (
<>
{/* Primary auth CTAs the two actions we actually want most
visitors to take. */}
<div className="mt-8 flex flex-wrap gap-3">
<a
href="/auth/register"
className="rounded-md bg-blue-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-blue-700"
>
{addingPasskey ? t("settingUp") : t("addPasskey")}
</button>
{t("auth.register")}
</a>
<a
href="/auth/login"
className="rounded-md border border-gray-300 bg-white px-5 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
{t("auth.login")}
</a>
</div>
)}
{/* Demoted escape hatch: the Planner is anonymous and useful
on its own, but shouldn't compete visually with sign-up. */}
<p className="mt-3 text-sm text-gray-500">
{t("home.tryPlannerPrefix")}
<a href={plannerUrl} className="text-blue-600 hover:underline">
{t("home.tryPlannerLink")}
</a>
{t("home.tryPlannerSuffix")}
</p>
</>
)}
{showAddPasskey && !passkeyDone && supportsPasskey === false && (
<div className="mt-6 rounded-md bg-amber-50 p-4">
<p className="text-sm text-amber-800">
{t("addPasskeyPrompt")}
</p>
<p className="mt-2 text-sm text-amber-600">
{t("auth.passkeyNotSupported")}
{showAddPasskey && !passkeyDone && supportsPasskey === true && (
<div className="mt-6 rounded-md bg-blue-50 p-4">
<p className="text-sm text-blue-800">{t("addPasskeyPrompt")}</p>
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
<button
onClick={handleAddPasskey}
disabled={addingPasskey}
className="mt-3 rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
>
{addingPasskey ? t("settingUp") : t("addPasskey")}
</button>
</div>
)}
{showAddPasskey && !passkeyDone && supportsPasskey === false && (
<div className="mt-6 rounded-md bg-amber-50 p-4">
<p className="text-sm text-amber-800">{t("addPasskeyPrompt")}</p>
<p className="mt-2 text-sm text-amber-600">{t("auth.passkeyNotSupported")}</p>
</div>
)}
{passkeyDone && (
<div className="mt-6 rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">{t("passkeyAdded")}</p>
</div>
)}
</section>
{/* Marketing blurbs flagship only. Self-hosted instances link out
via the "Powered by trails.cool" footer line below the feed.
Card styling mirrors the Planner's feature grid (emoji icon,
bordered card) so the two home pages feel consistent. */}
{isFlagship && (
<section className="mt-12 grid gap-4 border-t border-gray-200 pt-10 sm:grid-cols-2">
{[
{ key: "planner", icon: "🗺️" },
{ key: "journal", icon: "📓" },
{ key: "federation", icon: "🌐" },
{ key: "ownership", icon: "🔓" },
].map(({ key, icon }) => (
<div key={key} className="rounded-lg border border-gray-200 bg-white p-5">
<div className="text-2xl" aria-hidden="true">{icon}</div>
<h2 className="mt-2 text-base font-semibold text-gray-900">
{t(`home.marketing.${key}.title`)}
</h2>
<p className="mt-1.5 text-sm text-gray-600">
{t(`home.marketing.${key}.body`)}
</p>
</div>
)}
{passkeyDone && (
<div className="mt-6 rounded-md bg-green-50 p-4">
<p className="text-sm text-green-800">
{t("passkeyAdded")}
</p>
</div>
)}
</div>
) : (
<div className="mt-8 flex gap-4">
<a
href="/auth/register"
className="rounded-md bg-blue-600 px-6 py-2 text-white hover:bg-blue-700"
>
{t("auth.register")}
</a>
<a
href="/auth/login"
className="rounded-md border border-gray-300 px-6 py-2 text-gray-700 hover:bg-gray-50"
>
{t("auth.login")}
</a>
</div>
))}
</section>
)}
<footer className="mt-16 border-t border-gray-200 pt-6">
<a href="/privacy" className="text-sm text-gray-400 hover:text-gray-600">
Privacy
</a>
</footer>
{/* Public activity feed */}
<section className="mt-12">
<h2 className="text-xl font-semibold text-gray-900">{t("home.feed.heading")}</h2>
{activities.length === 0 ? (
<p className="mt-4 text-sm text-gray-500">{t("home.feed.empty")}</p>
) : (
<ul className="mt-6 space-y-4">
{activities.map((a) => (
<li key={a.id}>
<a
href={`/activities/${a.id}`}
className="block rounded-lg border border-gray-200 p-4 hover:bg-gray-50"
>
<div className="flex gap-4">
<div className="w-40 shrink-0">
{a.geojson ? (
<ClientMap geojson={a.geojson} />
) : (
<div className="flex h-28 w-full items-center justify-center rounded bg-gray-100 text-xs text-gray-400">
{t("routes.noMapPreview")}
</div>
)}
</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="mt-1 text-sm text-gray-500">
<a
href={`/users/${a.ownerUsername}`}
className="hover:text-gray-700 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{a.ownerDisplayName ?? a.ownerUsername}
</a>
{" · "}
<ClientDate iso={a.startedAt ?? a.createdAt} />
</div>
<div className="mt-1 flex gap-4 text-sm text-gray-500">
{a.distance != null && (
<span>{(a.distance / 1000).toFixed(1)} km</span>
)}
{a.elevationGain != null && (
<span> {Math.round(a.elevationGain)} m</span>
)}
</div>
</div>
</div>
</div>
</a>
</li>
))}
</ul>
)}
</section>
{/* Self-hosted instances link back to the flagship for project info.
Flagship instances already show the marketing section above. */}
{!isFlagship && (
<p className="mt-8 text-sm text-gray-500">
<a
href="https://trails.cool"
className="hover:text-gray-700 hover:underline"
target="_blank"
rel="noopener noreferrer"
>
{t("home.poweredBy")}
</a>
</p>
)}
</div>
);
}

View file

@ -4,7 +4,9 @@ test.describe("Journal", () => {
test("loads the home page", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle("trails.cool");
await expect(page.getByText("Your outdoor activity journal")).toBeVisible();
await expect(
page.getByRole("heading", { name: "Federated outdoor journal" }),
).toBeVisible();
});
test("shows register and sign in when logged out", async ({ page }) => {

View file

@ -23,6 +23,11 @@ services:
DOMAIN: ${DOMAIN:-trails.cool}
ORIGIN: https://${DOMAIN:-trails.cool}
PLANNER_URL: https://planner.${DOMAIN:-trails.cool}
# "true" only on the flagship trails.cool instance. The Journal
# home renders the project marketing block when set, and the
# "Powered by trails.cool" footer link when not. Default empty =
# self-host, which is the safer default.
IS_FLAGSHIP: ${IS_FLAGSHIP:-}
DATABASE_URL: postgres://trails:${POSTGRES_PASSWORD:-trails}@postgres:5432/trails
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}

View file

@ -46,3 +46,14 @@ Activities SHALL be allowed to exist without a linked route.
#### Scenario: Standalone activity
- **WHEN** a user imports a GPX activity without linking to a route
- **THEN** the activity is stored with route_id as null
### Requirement: Instance-wide public activity feed
The Journal SHALL expose a reverse-chronological feed of every activity on the instance with visibility `public`. This feed powers the instance home page (see `journal-landing`). `private` and `unlisted` activities SHALL NOT appear in this feed; `unlisted` stays reachable by direct URL only.
#### Scenario: Public activity appears in the instance feed
- **WHEN** an owner marks an activity as `public` and another visitor loads the home page
- **THEN** the visitor sees the activity in the instance feed, with the owner's display name, distance, and start date
#### Scenario: Private or unlisted activities stay out of the feed
- **WHEN** an activity's visibility is `private` or `unlisted`
- **THEN** it does not appear in the instance feed, regardless of who is viewing

View file

@ -0,0 +1,38 @@
## Purpose
The Journal home page (`/`) serves two audiences with one layout: anonymous visitors arriving at the flagship trails.cool instance, and anyone landing on a self-hosted Journal instance. It introduces the product on the flagship, shows a public activity feed on every instance, and avoids asking self-hosters to write their own marketing copy.
## Requirements
### Requirement: Hero and auth CTAs
The home page SHALL render a hero section with a product-describing headline, a short tagline, and — for logged-out visitors — Register and Sign In as the primary CTAs. A link to the Planner SHALL be available but visually secondary to auth, because the main conversion goal is account creation.
#### Scenario: Logged-out visitor sees auth CTAs
- **WHEN** a visitor without a session loads the home page
- **THEN** Register (primary button) and Sign In (outlined button) appear together, with a smaller "Or try the Planner without an account →" link below them
#### Scenario: Logged-in user sees welcome instead of CTAs
- **WHEN** a signed-in user loads the home page
- **THEN** the auth CTAs are replaced by a welcome line linking to their profile
### Requirement: Flagship-only marketing section
The home page SHALL render a four-card marketing section (Planner / Journal / Federation / Ownership) if and only if the `IS_FLAGSHIP` environment variable is `"true"`. Self-hosted instances SHALL NOT render this block; instead they SHALL show a "Powered by trails.cool — about the project" link that points to `https://trails.cool` so operators don't have to author their own marketing copy.
#### Scenario: Flagship shows marketing
- **WHEN** the home page renders with `IS_FLAGSHIP=true`
- **THEN** the four marketing cards are shown and the "Powered by" link is suppressed
#### Scenario: Self-host shows the back-link
- **WHEN** the home page renders with `IS_FLAGSHIP` unset or empty
- **THEN** the marketing cards are not rendered and a "Powered by trails.cool — about the project" link appears below the feed
### Requirement: Public activity feed on home
The home page SHALL list the most recent public activities on the instance (see `activity-feed` spec, "Instance-wide public activity feed"). Each entry SHALL link to the activity detail page, show a map thumbnail when geometry is available, and display the owner's display name, distance, and start date.
#### Scenario: Feed populated
- **WHEN** public activities exist on the instance
- **THEN** the home renders a reverse-chronological list of at least the 20 most recent, each as a card with map thumbnail + owner + stats + date
#### Scenario: Empty feed
- **WHEN** the instance has no public activities
- **THEN** the home shows an empty-state message instead of the feed list; all other sections (hero, marketing, CTAs) remain unchanged

View file

@ -162,6 +162,36 @@ export default {
passkeyAdded: "Passkey hinzugefügt! Du kannst dich jetzt sofort auf diesem Gerät anmelden.",
passkeyStatus: "{{count}} Passkey registriert",
passkeyStatus_other: "{{count}} Passkeys registriert",
home: {
heroTitle: "Föderiertes Outdoor-Tagebuch",
heroSubtitle: "Plane Routen, halte Aktivitäten fest, behalte deine Daten.",
tryPlannerPrefix: "Oder ",
tryPlannerLink: "öffne den Planer ohne Konto",
tryPlannerSuffix: " →",
marketing: {
planner: {
title: "Routen gemeinsam planen",
body: "Skizziere Wander- und Radrouten gemeinsam in Echtzeit. Ohne Konto.",
},
journal: {
title: "Aktivitäten festhalten",
body: "Halte deine Touren, Wanderungen und Spaziergänge fest. Privat, mit Freunden geteilt oder öffentlich.",
},
federation: {
title: "Föderiert by design",
body: "Deine Journal-Instanz spricht via ActivityPub mit anderen trails.cool-Servern. Folge Freundinnen und Freunden überall.",
},
ownership: {
title: "Deine Daten, immer",
body: "Keine Werbung, kein Tracking, kein Datenverkauf. Export als GPX oder JSON jederzeit. MIT-lizenziert und selbst hostbar.",
},
},
feed: {
heading: "Aktuelle Aktivitäten",
empty: "Noch keine öffentlichen Aktivitäten.",
},
poweredBy: "Powered by trails.cool — über das Projekt",
},
routes: {
title: "Routen",
myRoutes: "Meine Routen",

View file

@ -162,6 +162,36 @@ export default {
passkeyAdded: "Passkey added! You can now sign in instantly on this device.",
passkeyStatus: "{{count}} passkey registered",
passkeyStatus_other: "{{count}} passkeys registered",
home: {
heroTitle: "Federated outdoor journal",
heroSubtitle: "Plan routes, record activities, own your data.",
tryPlannerPrefix: "Or ",
tryPlannerLink: "try the Planner without an account",
tryPlannerSuffix: " →",
marketing: {
planner: {
title: "Plan routes together",
body: "Sketch hiking and cycling routes collaboratively in real time. No account needed.",
},
journal: {
title: "Record activities",
body: "Log your rides, hikes, and walks. Keep them private, share with friends, or publish to the world.",
},
federation: {
title: "Federated by design",
body: "Your Journal instance talks to other trails.cool servers via ActivityPub. Follow friends anywhere.",
},
ownership: {
title: "Your data, always",
body: "No ads, no tracking, no data sales. Export everything as GPX or JSON any time. MIT licensed and self-hostable.",
},
},
feed: {
heading: "Recent activity",
empty: "No public activities yet.",
},
poweredBy: "Powered by trails.cool — about the project",
},
routes: {
title: "Routes",
myRoutes: "My Routes",