From 19f25f8e2689bbe3fcc74576be1730b716f92bda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Fri, 24 Apr 2026 20:16:56 +0200 Subject: [PATCH] Personal activity dashboard for signed-in users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously a signed-in user landing on `/` saw the same page as an anonymous visitor: the visitor hero, the flagship marketing cards, and the instance-wide public feed. "Home" should mean "your stuff." The home route now branches on session: signed-out visitors keep the marketing + public-feed layout from #303; signed-in users get a personal dashboard showing their own activities reverse-chronologically (all visibilities), a welcome line linking to their profile, and a "New Activity" CTA. The public instance feed and marketing blurbs are suppressed for signed-in users — they'd be redundant on a personal landing surface. Loader picks `listActivities(user.id)` vs `listRecentPublicActivities` based on session, so only one feed query runs per request. Updates the `journal-landing` spec with the new session-based split and a "Personal dashboard for signed-in users" requirement. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/journal/app/routes/home.tsx | 219 ++++++++++++++++++------- openspec/specs/journal-landing/spec.md | 46 ++++-- packages/i18n/src/locales/de.ts | 1 + packages/i18n/src/locales/en.ts | 1 + 4 files changed, 193 insertions(+), 74 deletions(-) diff --git a/apps/journal/app/routes/home.tsx b/apps/journal/app/routes/home.tsx index f3d9802..a1850aa 100644 --- a/apps/journal/app/routes/home.tsx +++ b/apps/journal/app/routes/home.tsx @@ -6,7 +6,7 @@ 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 { listActivities, listRecentPublicActivities } from "~/lib/activities.server"; import { ClientDate } from "~/components/ClientDate"; import { ClientMap } from "~/components/ClientMap"; @@ -17,6 +17,22 @@ export function meta(_args: Route.MetaArgs) { ]; } +interface ActivityCard { + id: string; + name: string; + distance: number | null; + elevationGain: number | null; + duration: number | null; + startedAt: string | null; + createdAt: string; + geojson: string | null; + // Populated only for the public (logged-out) feed, where the card + // needs to attribute the activity to an owner. Personal feed skips + // these because it's always "you". + ownerUsername: string | null; + ownerDisplayName: string | null; +} + export async function loader({ request }: Route.LoaderArgs) { const user = await getSessionUser(request); const url = new URL(request.url); @@ -33,16 +49,30 @@ 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) => ({ + // Logged-in users get their own recent activities as the home feed — + // "home" should mean your stuff, not the instance's public stream. + // Logged-out visitors get the instance-wide public feed instead. + let activities: ActivityCard[]; + if (user) { + const rows = await listActivities(user.id); + activities = rows.slice(0, 20).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: null, + ownerDisplayName: null, + })); + } else { + const rows = await listRecentPublicActivities(20); + activities = rows.map((a) => ({ id: a.id, name: a.name, distance: a.distance, @@ -53,7 +83,15 @@ export async function loader({ request }: Route.LoaderArgs) { geojson: a.geojson ?? null, ownerUsername: a.ownerUsername, ownerDisplayName: a.ownerDisplayName, - })), + })); + } + + return data({ + user: user ? { id: user.id, username: user.username, displayName: user.displayName } : null, + showAddPasskey, + plannerUrl, + isFlagship, + activities, }); } @@ -118,53 +156,24 @@ export default function Home({ loaderData }: Route.ComponentProps) { } }, [user]); - return ( -
- {/* 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. */} -
-

- {t("home.heroTitle")} -

-

{t("home.heroSubtitle")}

- - {user ? ( -
+ // ---------- Logged-in: personal activity stream ---------- + if (user) { + return ( +
+ - ) : ( - <> - {/* Primary auth CTAs — the two actions we actually want most - visitors to take. */} - - {/* Demoted escape hatch: the Planner is anonymous and useful - on its own, but shouldn't compete visually with sign-up. */} -

- {t("home.tryPlannerPrefix")} - - {t("home.tryPlannerLink")} - - {t("home.tryPlannerSuffix")} -

- - )} + + + {t("activities.new")} + +
{showAddPasskey && !passkeyDone && supportsPasskey === true && (
@@ -190,6 +199,92 @@ export default function Home({ loaderData }: Route.ComponentProps) {

{t("passkeyAdded")}

)} + + {activities.length === 0 ? ( +

+ {t("home.dashboardEmpty")} +

+ ) : ( + + )} +
+ ); + } + + // ---------- Logged-out: visitor home (hero + marketing + public feed) ---------- + return ( +
+ {/* 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. */} +
+

+ {t("home.heroTitle")} +

+

{t("home.heroSubtitle")}

+ + {/* Primary auth CTAs — the two actions we actually want most + visitors to take. */} + + {/* Demoted escape hatch: the Planner is anonymous and useful + on its own, but shouldn't compete visually with sign-up. */} +

+ {t("home.tryPlannerPrefix")} + + {t("home.tryPlannerLink")} + + {t("home.tryPlannerSuffix")} +

{/* Marketing blurbs — flagship only. Self-hosted instances link out @@ -245,14 +340,18 @@ export default function Home({ loaderData }: Route.ComponentProps) {

{a.name}

diff --git a/openspec/specs/journal-landing/spec.md b/openspec/specs/journal-landing/spec.md index 9699f71..19b7153 100644 --- a/openspec/specs/journal-landing/spec.md +++ b/openspec/specs/journal-landing/spec.md @@ -1,33 +1,40 @@ ## 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. +The Journal home page (`/`) serves three audiences with one route: anonymous visitors arriving at the flagship trails.cool instance, anyone landing on a self-hosted Journal instance, and signed-in users returning to *their own* home. It introduces the product to visitors on the flagship, shows a public activity feed on every instance for visitors, and swaps in a personal activity stream for signed-in users so "home" means "your stuff." ## 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. +### Requirement: Home behavior depends on session +The `/` route SHALL render one of two distinct layouts based on whether the request carries a valid session. Signed-in users see a personal activity dashboard; signed-out visitors see the marketing + public-feed layout. The two layouts SHALL NOT render simultaneously — a signed-in user does not see the marketing blurbs, auth CTAs, or public instance feed on their home; a signed-out visitor does not see anyone's personal stream. -#### Scenario: Logged-out visitor sees auth CTAs +#### Scenario: Signed-in user gets the personal dashboard +- **WHEN** a request to `/` carries a valid session +- **THEN** the response is the personal dashboard layout (welcome + New Activity CTA + own activity stream) + +#### Scenario: Anonymous visitor gets the visitor home +- **WHEN** a request to `/` carries no session +- **THEN** the response is the visitor home layout (hero + auth CTAs + flagship-conditional marketing + public-feed) + +### Requirement: Visitor home hero and auth CTAs +For signed-out visitors, the home page SHALL render a hero section with a product-describing headline, a short tagline, and 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: Auth CTAs are primary - **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. +The visitor home 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` +- **WHEN** the visitor home 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 +- **WHEN** the visitor home 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. +### Requirement: Public activity feed on visitor home +The visitor home 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 @@ -35,4 +42,15 @@ The home page SHALL list the most recent public activities on the instance (see #### 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 +- **THEN** the visitor home shows an empty-state message instead of the feed list; all other sections (hero, marketing, CTAs) remain unchanged + +### Requirement: Personal dashboard for signed-in users +For signed-in users, the home page SHALL render a personal activity dashboard: a welcome line linking to the user's profile, a "New Activity" CTA, and a reverse-chronological list of the user's own most recent activities (including `private`, `unlisted`, and `public`). The public instance feed SHALL NOT be rendered on this view; users who want it can browse other users' profiles or the dedicated activities/routes pages. + +#### Scenario: Dashboard shows user's own activities +- **WHEN** a signed-in user loads the home page +- **THEN** they see their own activities in reverse-chronological order, regardless of visibility, up to 20 entries + +#### Scenario: Dashboard empty state +- **WHEN** a signed-in user has no activities yet +- **THEN** the dashboard shows an empty-state message pointing to activity creation, while the welcome line and New Activity CTA remain visible diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 2540763..1e23c29 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -190,6 +190,7 @@ export default { heading: "Aktuelle Aktivitäten", empty: "Noch keine öffentlichen Aktivitäten.", }, + dashboardEmpty: "Noch keine Aktivitäten. Erfasse oder importiere deine erste im Aktivitäten-Tab.", poweredBy: "Powered by trails.cool — über das Projekt", }, routes: { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 5d6ae44..f9130e3 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -190,6 +190,7 @@ export default { heading: "Recent activity", empty: "No public activities yet.", }, + dashboardEmpty: "No activities yet. Record or import your first one from the Activities tab.", poweredBy: "Powered by trails.cool — about the project", }, routes: {