Add public changelog with Atom feed and What's New indicator

- Changelog entries authored as markdown files in apps/journal/changelog/
  with YAML frontmatter, loaded via Vite import.meta.glob at build time
- /changelog list page with date, title, and preview
- /changelog/:slug detail page with full markdown rendering (react-markdown)
  and Open Graph meta tags for social sharing
- /changelog/feed.xml Atom feed with auto-discovery <link> tags
- "What's New" dot on nav bar using localStorage timestamp tracking
- Notification toggle: users can permanently disable/re-enable the indicator
- i18n strings for en and de
- Initial changelog entry covering the Phase 1 launch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-07 13:49:52 +02:00
parent 8a076f8c4b
commit 18e68bb176
16 changed files with 1109 additions and 25 deletions

View file

@ -0,0 +1,78 @@
import { useEffect, useState } from "react";
import { Link, useLocation } from "react-router";
import { useTranslation } from "react-i18next";
const LAST_SEEN_KEY = "changelog:lastSeen";
const DISABLED_KEY = "changelog:disabled";
export function ChangelogLink({
newestDate,
className,
}: {
newestDate: string | null;
className: (active: boolean) => string;
}) {
const { t } = useTranslation("journal");
const location = useLocation();
const [showDot, setShowDot] = useState(false);
const isActive =
location.pathname === "/changelog" ||
location.pathname.startsWith("/changelog/");
useEffect(() => {
if (!newestDate) return;
const disabled = localStorage.getItem(DISABLED_KEY);
if (disabled === "true") return;
const lastSeen = localStorage.getItem(LAST_SEEN_KEY);
if (!lastSeen || newestDate > lastSeen) {
setShowDot(true);
}
}, [newestDate]);
useEffect(() => {
if (isActive && newestDate) {
localStorage.setItem(LAST_SEEN_KEY, newestDate);
setShowDot(false);
}
}, [isActive, newestDate]);
return (
<Link to="/changelog" className={className(isActive)}>
{t("nav.changelog")}
{showDot && (
<span className="ml-1 inline-block h-2 w-2 rounded-full bg-blue-500" />
)}
</Link>
);
}
export function ChangelogNotificationToggle() {
const { t } = useTranslation("journal");
const [disabled, setDisabled] = useState(false);
useEffect(() => {
setDisabled(localStorage.getItem(DISABLED_KEY) === "true");
}, []);
function toggle() {
if (disabled) {
localStorage.removeItem(DISABLED_KEY);
setDisabled(false);
} else {
localStorage.setItem(DISABLED_KEY, "true");
setDisabled(true);
}
}
return (
<label className="flex cursor-pointer items-center gap-2 text-sm text-gray-500">
<input
type="checkbox"
checked={!disabled}
onChange={toggle}
className="rounded border-gray-300"
/>
{t("changelog.showNotifications")}
</label>
);
}

View file

@ -0,0 +1,73 @@
const modules = import.meta.glob("/changelog/*.md", {
query: "?raw",
import: "default",
eager: true,
}) as Record<string, string>;
export interface ChangelogEntry {
slug: string;
title: string;
date: string;
preview: string;
content: string;
}
function parseFrontmatter(raw: string): {
attrs: Record<string, string>;
body: string;
} {
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!match) return { attrs: {}, body: raw };
const attrs: Record<string, string> = {};
for (const line of match[1]!.split("\n")) {
const idx = line.indexOf(":");
if (idx === -1) continue;
const key = line.slice(0, idx).trim();
const value = line.slice(idx + 1).trim().replace(/^["']|["']$/g, "");
attrs[key] = value;
}
return { attrs, body: match[2]! };
}
function firstParagraph(md: string): string {
const trimmed = md.trim();
const lines: string[] = [];
for (const line of trimmed.split("\n")) {
if (line.startsWith("#")) continue;
if (lines.length > 0 && line.trim() === "") break;
if (line.trim() !== "") lines.push(line);
}
return lines.join(" ").slice(0, 200);
}
function parseEntry(path: string, raw: string): ChangelogEntry {
const slug = path.replace(/^\/changelog\//, "").replace(/\.md$/, "");
const { attrs, body } = parseFrontmatter(raw);
return {
slug,
title: attrs.title ?? slug,
date: attrs.date ?? slug,
preview: firstParagraph(body),
content: body,
};
}
let cachedEntries: ChangelogEntry[] | null = null;
export function getAllEntries(): ChangelogEntry[] {
if (!cachedEntries) {
cachedEntries = Object.entries(modules)
.map(([path, raw]) => parseEntry(path, raw))
.sort((a, b) => b.date.localeCompare(a.date));
}
return cachedEntries;
}
export function getEntry(slug: string): ChangelogEntry | undefined {
return getAllEntries().find((e) => e.slug === slug);
}
export function getNewestDate(): string | null {
const entries = getAllEntries();
return entries.length > 0 ? entries[0]!.date : null;
}

View file

@ -1,5 +1,6 @@
import { useEffect } from "react";
import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse, useLocation, Form, Link } from "react-router";
import { ChangelogLink } from "~/components/ChangelogIndicator";
import type { LinksFunction } from "react-router";
import type { Route } from "./+types/root";
import * as Sentry from "@sentry/react";
@ -34,12 +35,13 @@ export function Layout({ children }: { children: React.ReactNode }) {
}
export async function loader({ request }: Route.LoaderArgs) {
const { getNewestDate } = await import("~/lib/changelog.server");
const user = await getSessionUser(request);
const locale = detectLocale(request);
return { user: user ? { id: user.id, username: user.username } : null, locale };
return { user: user ? { id: user.id, username: user.username } : null, locale, newestChangelogDate: getNewestDate() };
}
function NavBar({ user }: { user: { id: string; username: string } | null }) {
function NavBar({ user, newestChangelogDate }: { user: { id: string; username: string } | null; newestChangelogDate: string | null }) {
const { t } = useTranslation("journal");
const location = useLocation();
@ -60,6 +62,7 @@ function NavBar({ user }: { user: { id: string; username: string } | null }) {
<Link to="/" className="text-lg font-semibold text-gray-900">
{t("title")}
</Link>
<ChangelogLink newestDate={newestChangelogDate} className={() => linkClass("/changelog")} />
{user && (
<>
<Link to="/routes" className={linkClass("/routes")}>
@ -124,7 +127,7 @@ export default function App({ loaderData }: Route.ComponentProps) {
return (
<LocaleProvider locale={locale}>
<NavBar user={user ?? null} />
<NavBar user={user ?? null} newestChangelogDate={loaderData?.newestChangelogDate ?? null} />
<Outlet />
</LocaleProvider>
);

View file

@ -30,4 +30,7 @@ export default [
route("api/sync/disconnect/:provider", "routes/api.sync.disconnect.$provider.ts"),
route("api/sync/webhook/:provider", "routes/api.sync.webhook.$provider.ts"),
route("privacy", "routes/privacy.tsx"),
route("changelog", "routes/changelog._index.tsx"),
route("changelog/feed.xml", "routes/changelog.feed[.xml].ts"),
route("changelog/:slug", "routes/changelog.$slug.tsx"),
] satisfies RouteConfig;

View file

@ -0,0 +1,50 @@
import { data } from "react-router";
import { useTranslation } from "react-i18next";
import Markdown from "react-markdown";
import type { Route } from "./+types/changelog.$slug";
import { getEntry } from "~/lib/changelog.server";
import { ClientDate } from "~/components/ClientDate";
export function meta({ data: entry }: Route.MetaArgs) {
if (!entry) return [{ title: "Not Found — trails.cool" }];
return [
{ title: `${entry.title} — Changelog — trails.cool` },
{ name: "description", content: entry.preview },
{ property: "og:title", content: entry.title },
{ property: "og:description", content: entry.preview },
{ property: "og:url", content: `https://trails.cool/changelog/${entry.slug}` },
];
}
export function links() {
return [
{ rel: "alternate", type: "application/atom+xml", title: "trails.cool Changelog", href: "/changelog/feed.xml" },
];
}
export async function loader({ params }: Route.LoaderArgs) {
const entry = getEntry(params.slug);
if (!entry) throw data(null, { status: 404 });
return entry;
}
export default function ChangelogEntryPage({ loaderData: entry }: Route.ComponentProps) {
const { t } = useTranslation("journal");
return (
<div className="mx-auto max-w-3xl px-4 py-8">
<a href="/changelog" className="text-sm text-blue-600 hover:underline">
{t("changelog.backToList")}
</a>
<article className="mt-4">
<time className="text-sm text-gray-500">
<ClientDate iso={entry.date} />
</time>
<h1 className="mt-1 text-3xl font-bold text-gray-900">{entry.title}</h1>
<div className="prose prose-gray mt-6 max-w-none">
<Markdown>{entry.content}</Markdown>
</div>
</article>
</div>
);
}

View file

@ -0,0 +1,58 @@
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import type { Route } from "./+types/changelog._index";
import { getAllEntries, getNewestDate } from "~/lib/changelog.server";
import { ClientDate } from "~/components/ClientDate";
import { ChangelogNotificationToggle } from "~/components/ChangelogIndicator";
export function meta() {
return [
{ title: "Changelog — trails.cool" },
{ name: "description", content: "What's new on trails.cool" },
{ property: "og:title", content: "Changelog — trails.cool" },
{ property: "og:description", content: "What's new on trails.cool" },
];
}
export function links() {
return [
{ rel: "alternate", type: "application/atom+xml", title: "trails.cool Changelog", href: "/changelog/feed.xml" },
];
}
export async function loader() {
return { entries: getAllEntries(), newestDate: getNewestDate() };
}
export default function ChangelogListPage({ loaderData }: Route.ComponentProps) {
const { entries } = loaderData;
const { t } = useTranslation("journal");
return (
<div className="mx-auto max-w-3xl px-4 py-8">
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">{t("changelog.title")}</h1>
<p className="mt-1 text-gray-500">{t("changelog.subtitle")}</p>
</div>
<ChangelogNotificationToggle />
</div>
<div className="mt-8 space-y-6">
{entries.map((entry) => (
<Link
key={entry.slug}
to={`/changelog/${entry.slug}`}
className="block rounded-lg border border-gray-200 bg-white p-6 transition hover:border-blue-300 hover:shadow-sm"
>
<time className="text-sm text-gray-500">
<ClientDate iso={entry.date} />
</time>
<h2 className="mt-1 text-xl font-semibold text-gray-900">{entry.title}</h2>
<p className="mt-2 text-gray-600">{entry.preview}</p>
</Link>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,51 @@
import { getAllEntries } from "~/lib/changelog.server";
import Markdown from "react-markdown";
import { renderToStaticMarkup } from "react-dom/server";
import { createElement } from "react";
function renderMarkdown(md: string): string {
return renderToStaticMarkup(createElement(Markdown, null, md));
}
function escapeXml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
export async function loader() {
const entries = getAllEntries();
const domain = process.env.DOMAIN ?? "trails.cool";
const baseUrl = `https://${domain}`;
const updated = entries[0]?.date ?? new Date().toISOString().slice(0, 10);
const atomEntries = entries.map((entry) => {
const html = renderMarkdown(entry.content);
return ` <entry>
<title>${escapeXml(entry.title)}</title>
<link href="${baseUrl}/changelog/${entry.slug}" rel="alternate"/>
<id>${baseUrl}/changelog/${entry.slug}</id>
<updated>${entry.date}T00:00:00Z</updated>
<content type="html">${escapeXml(html)}</content>
</entry>`;
});
const feed = `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>trails.cool Changelog</title>
<link href="${baseUrl}/changelog" rel="alternate"/>
<link href="${baseUrl}/changelog/feed.xml" rel="self"/>
<id>${baseUrl}/changelog</id>
<updated>${updated}T00:00:00Z</updated>
${atomEntries.join("\n")}
</feed>`;
return new Response(feed, {
headers: {
"Content-Type": "application/atom+xml; charset=utf-8",
"Cache-Control": "public, max-age=3600",
},
});
}

View file

@ -0,0 +1,34 @@
---
title: "trails.cool is live"
date: 2026-03-25
---
We're excited to launch trails.cool — a federated, self-hostable platform for outdoor enthusiasts.
## Planner: Collaborative Route Planning
Plan hiking, cycling, and driving routes together in real-time. No account needed — just share a link and start editing.
- **Real-time collaboration** via Yjs — changes sync instantly across all participants
- **Smart routing** powered by BRouter with profiles for hiking, fast cycling, safe cycling, shortest path, and car
- **Elevation profiles** with total ascent and descent statistics
- **Route coloring** by elevation gradient or surface type
- **No-go areas** — draw polygons on the map to avoid specific regions
- **GPX import and export** — bring routes from other apps or export for your GPS device
- **Session notes** — shared text area for trip planning details
- **Crash recovery** — unsaved work is preserved if your browser crashes
## Journal: Your Activity Hub
Track your routes and outdoor activities with a personal journal.
- **Route management** with GPX import/export and sequential versioning
- **Activity tracking** — log activities with GPS traces, distances, and durations
- **Wahoo integration** — automatic sync of workouts via webhook, plus manual import
- **Map previews** on route and activity list pages
- **Passkey authentication** — passwordless login, no passwords to remember
- **Magic link fallback** for devices without passkey support
## Self-Hostable and Privacy-First
trails.cool is designed for data ownership. Host your own instance, keep your data, export everything as GPX or JSON. We collect zero analytics, zero tracking — your routes are yours.

View file

@ -16,7 +16,7 @@ produces shareable social links.
**Non-Goals:**
- CMS or admin interface for writing entries
- RSS feed (future, not now)
- RSS/Atom feed for subscribing to updates
- Email notifications for new entries
- Per-entry images or rich media (just markdown text)
- Bilingual entries (English only for now)
@ -50,11 +50,30 @@ Store `changelog:lastSeen` timestamp in localStorage. If the newest entry's
date is after this timestamp, show a dot on the nav "Changelog" link. Clicking
the changelog page updates the timestamp. No server state needed.
Users can permanently disable the "What's New" indicator by setting
`changelog:disabled` in localStorage. A "Don't show again" option is available
on the changelog page. When disabled, the dot never appears regardless of new
entries. The setting can be re-enabled from the changelog page itself via a
"Show new entry notifications" toggle. All client-side, no auth required.
### D4: Open Graph meta for social sharing
Each `/changelog/:date` page sets `og:title`, `og:description` (first
paragraph), and `og:url`. No og:image for now — text previews are fine.
### D5: RSS/Atom feed at `/changelog/feed.xml`
A standard Atom feed at `/changelog/feed.xml` so users and tools can subscribe.
Generated server-side from the same changelog entries used by the HTML pages.
Each `<entry>` includes the title, date, full markdown content rendered as HTML,
and a link to the individual changelog page.
The feed URL is advertised via a `<link rel="alternate" type="application/atom+xml">`
tag in the `<head>` of the changelog pages, so feed readers auto-discover it.
Atom over RSS 2.0 because Atom has a proper spec (RFC 4287), required dates,
and better content handling. All major feed readers support both.
## Risks / Trade-offs
- **Build-time loading means deploy to publish** → Acceptable. We deploy on

View file

@ -22,7 +22,7 @@ project is alive and improving), and social followers (shareable updates).
### New Capabilities
- `changelog`: Public changelog with dated entries, shareable individual pages with OG tags, "what's new" indicator
- `changelog`: Public changelog with dated entries, shareable individual pages with OG tags, "what's new" indicator, RSS/Atom feed
### Modified Capabilities
@ -30,8 +30,8 @@ project is alive and improving), and social followers (shareable updates).
## Impact
- **Files**: New routes (`/changelog`, `/changelog/:slug`), markdown entry files
in `docs/changelog/` or `apps/journal/changelog/`, nav bar update
- **Files**: New routes (`/changelog`, `/changelog/:slug`, `/changelog/feed.xml`),
markdown entry files in `apps/journal/changelog/`, nav bar update
- **Dependencies**: A markdown renderer (could reuse `react-markdown` or render
at build time)
- **i18n**: Changelog entries written in English (primary audience), German

View file

@ -35,3 +35,24 @@ The system SHALL show a visual indicator when there are changelog entries the us
#### Scenario: Indicator dismissed
- **WHEN** a user visits the /changelog page
- **THEN** the indicator is dismissed (localStorage timestamp updated)
#### Scenario: Permanently disable indicator
- **WHEN** a user clicks "Don't show again" on the changelog page
- **THEN** `changelog:disabled` is set in localStorage
- **AND** the "What's New" dot no longer appears for any future entries
#### Scenario: Re-enable indicator
- **WHEN** a user toggles "Show new entry notifications" on the changelog page
- **THEN** `changelog:disabled` is removed from localStorage
- **AND** the indicator resumes normal behavior
### Requirement: RSS/Atom feed
The system SHALL provide an Atom feed at `/changelog/feed.xml` for subscribing to changelog updates.
#### Scenario: Subscribe to feed
- **WHEN** a feed reader requests `/changelog/feed.xml`
- **THEN** it receives a valid Atom feed with all changelog entries including title, date, content, and link
#### Scenario: Feed auto-discovery
- **WHEN** a browser or feed reader visits any `/changelog` page
- **THEN** a `<link rel="alternate" type="application/atom+xml">` tag in the page head points to the feed URL

View file

@ -1,29 +1,41 @@
## 1. Changelog Data Layer
- [ ] 1.1 Create `apps/journal/changelog/` directory with a sample entry (`2026-03-25.md`) covering the initial launch
- [ ] 1.2 Set up Vite `import.meta.glob` to load all `.md` files from the changelog directory with frontmatter parsing
- [ ] 1.3 Create `apps/journal/app/lib/changelog.server.ts` with functions: getAllEntries(), getEntry(date) returning parsed markdown + frontmatter
- [x] 1.1 Create `apps/journal/changelog/` directory with a sample entry (`2026-03-25.md`) covering the initial launch
- [x] 1.2 Set up Vite `import.meta.glob` to load all `.md` files from the changelog directory with frontmatter parsing
- [x] 1.3 Create `apps/journal/app/lib/changelog.server.ts` with functions: getAllEntries(), getEntry(date) returning parsed markdown + frontmatter
## 2. Routes
- [ ] 2.1 Add `/changelog` route showing all entries (date, title, preview) newest-first
- [ ] 2.2 Add `/changelog/:date` route rendering full markdown entry
- [ ] 2.3 Add Open Graph meta tags on entry pages (og:title, og:description, og:url)
- [ ] 2.4 Add both routes to `routes.ts`
- [x] 2.1 Add `/changelog` route showing all entries (date, title, preview) newest-first
- [x] 2.2 Add `/changelog/:date` route rendering full markdown entry
- [x] 2.3 Add Open Graph meta tags on entry pages (og:title, og:description, og:url)
- [x] 2.4 Add both routes to `routes.ts`
## 3. What's New Indicator
## 3. RSS/Atom Feed
- [ ] 3.1 Add "Changelog" link to Journal nav bar
- [ ] 3.2 Track `changelog:lastSeen` in localStorage, show dot when newest entry is newer
- [ ] 3.3 Clear indicator when user visits /changelog
- [x] 3.1 Add `/changelog/feed.xml` route that returns an Atom feed (application/atom+xml) with all entries
- [x] 3.2 Add `<link rel="alternate" type="application/atom+xml">` to changelog page heads for feed auto-discovery
## 4. Content
## 4. What's New Indicator
- [ ] 4.1 Write initial changelog entry for the launch (features shipped in Phase 1)
- [ ] 4.2 Add markdown rendering (react-markdown or built-in) for entry content
- [x] 4.1 Add "Changelog" link to Journal nav bar
- [x] 4.2 Track `changelog:lastSeen` in localStorage, show dot when newest entry is newer
- [x] 4.3 Clear indicator when user visits /changelog
- [x] 4.4 Add "Don't show again" option on changelog page that sets `changelog:disabled` in localStorage
- [x] 4.5 Add "Show new entry notifications" toggle on changelog page to re-enable the indicator
- [x] 4.6 Skip indicator check when `changelog:disabled` is set
## 5. Verify
## 5. Content
- [ ] 5.1 Verify /changelog lists entries correctly
- [ ] 5.2 Verify /changelog/:date renders full entry with OG tags
- [ ] 5.3 Verify "What's New" dot appears and dismisses
- [x] 5.1 Write initial changelog entry for the launch (features shipped in Phase 1)
- [x] 5.2 Add markdown rendering (react-markdown or built-in) for entry content
## 6. Verify
- [x] 6.1 Verify /changelog lists entries correctly
- [x] 6.2 Verify /changelog/:date renders full entry with OG tags
- [x] 6.3 Verify "What's New" dot appears and dismisses
- [x] 6.6 Verify "Don't show again" disables indicator permanently
- [x] 6.7 Verify re-enabling notifications restores indicator behavior
- [x] 6.4 Verify /changelog/feed.xml returns valid Atom feed
- [x] 6.5 Verify feed auto-discovery link is present in page source

View file

@ -66,6 +66,7 @@
"react-dom": "^19.2.4",
"react-i18next": "^17.0.2",
"react-leaflet": "^5.0.0",
"react-markdown": "^10.1.0",
"react-router": "catalog:",
"tailwindcss": "^4.2.2",
"turbo": "^2.9.3",

View file

@ -192,9 +192,16 @@ export default {
previous: "Zurück",
next: "Weiter",
},
changelog: {
title: "Änderungsprotokoll",
subtitle: "Was gibt es Neues auf trails.cool",
backToList: "Zurück zum Änderungsprotokoll",
showNotifications: "Über neue Einträge benachrichtigen",
},
nav: {
routes: "Routen",
activities: "Aktivitäten",
changelog: "Änderungen",
login: "Anmelden",
register: "Registrieren",
profile: "Profil",

View file

@ -192,9 +192,16 @@ export default {
previous: "Previous",
next: "Next",
},
changelog: {
title: "Changelog",
subtitle: "What's new on trails.cool",
backToList: "Back to Changelog",
showNotifications: "Notify me of new entries",
},
nav: {
routes: "Routes",
activities: "Activities",
changelog: "Changelog",
login: "Sign In",
register: "Register",
profile: "Profile",

667
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff