diff --git a/scripts/fetch-milestones.mjs b/scripts/fetch-milestones.mjs
index aee71ce..153cd59 100644
--- a/scripts/fetch-milestones.mjs
+++ b/scripts/fetch-milestones.mjs
@@ -33,8 +33,9 @@ const itemsApi = (number) => `${REPO_API}/issues?milestone=${number}&state=all&p
* open/closed counts stay in the output, so the page can say "and N more". */
const MAX_ITEMS_PER_MILESTONE = 30;
/** Recently-shipped section: enough to show momentum, not a full history. */
-const MAX_CLOSED_MILESTONES = 5;
-
+// #129 — the journey shows every closed milestone; the count is small
+// (14-25) and the data is cheap. The roadmap reads the same file.
+const MAX_CLOSED_MILESTONES = 100;
const headers = {
accept: "application/vnd.github+json",
"user-agent": "keeltrading.com-milestones-fetch",
diff --git a/src/components/pages/RoadmapPage.astro b/src/components/pages/RoadmapPage.astro
index f26b730..403fd7a 100644
--- a/src/components/pages/RoadmapPage.astro
+++ b/src/components/pages/RoadmapPage.astro
@@ -57,6 +57,10 @@ const data: MilestonesFile =
* recently closed. Splitting preserves that order on each side. */
const open = data.milestones.filter((milestone) => milestone.state === "open");
const closed = data.milestones.filter((milestone) => milestone.state === "closed");
+/** #129 — the full journey, oldest first. */
+const journey = [...closed].sort(
+ (a, b) => (a.closedAt ?? "").localeCompare(b.closedAt ?? ""),
+);
/** Full counts travel with each milestone; items are capped in the script. */
const overflow = (milestone: RoadmapMilestone) =>
@@ -145,6 +149,30 @@ const overflow = (milestone: RoadmapMilestone) =>
)}
+ {journey.length > 0 && (
+
+
@@ -317,6 +345,59 @@ const overflow = (milestone: RoadmapMilestone) =>
font-size: 0.88rem;
}
+ /* #129 — the journey: a vertical timeline, oldest at the top. */
+ .journey {
+ margin-block: 2.5rem;
+ }
+
+ .journey-intro {
+ color: var(--muted);
+ font-size: 0.95rem;
+ max-width: 58ch;
+ }
+
+ .journey-timeline {
+ list-style: none;
+ margin: 1.2rem 0 0;
+ padding: 0;
+ position: relative;
+ display: grid;
+ gap: 0.9rem;
+ }
+
+ .journey-timeline::before {
+ content: "";
+ position: absolute;
+ inset-inline-start: 0.55rem;
+ top: 0.6rem;
+ bottom: 0.6rem;
+ width: 2px;
+ background: var(--border);
+ }
+
+ .journey-timeline .milestone {
+ margin: 0;
+ padding-inline-start: 2rem;
+ position: relative;
+ border: 1px solid var(--border);
+ }
+
+ .journey-timeline .milestone::before {
+ content: "";
+ position: absolute;
+ inset-inline-start: -0.35rem;
+ top: 1.2rem;
+ width: 0.7rem;
+ height: 0.7rem;
+ border-radius: 999px;
+ background: var(--accent);
+ border: 2px solid var(--surface);
+ }
+
+ .journey-timeline .milestone-meta {
+ margin: 0.35rem 0 0;
+ }
+
/* Recently-shipped section: quieter, compact list. */
.shipped {
border-top: 1px solid var(--border);
diff --git a/src/i18n/pages/roadmap.ts b/src/i18n/pages/roadmap.ts
index d6586e1..8e0dd40 100644
--- a/src/i18n/pages/roadmap.ts
+++ b/src/i18n/pages/roadmap.ts
@@ -22,6 +22,9 @@ export interface RoadmapContent {
openTitle: string;
/** Section heading above the recently closed milestones. */
closedTitle: string;
+ /** #129 — the chronological journey, oldest first. */
+ journeyTitle: string;
+ journeyIntro: string;
/** Milestone due date, when the repo sets one. */
dueOn: (date: string) => string;
/** A milestone with no due date says so — never an invented date. */
@@ -45,7 +48,7 @@ export interface RoadmapContent {
export const roadmap: LocalizedPage = {
en: {
- rev: "2026-08-28.1",
+ rev: "2026-08-29.1",
title: "keel Roadmap — read from the repo, refreshed hourly",
description:
"keel's roadmap: the open milestones of CodeGateSoftware/keel and the issues under them, read from GitHub at build time. An open milestone is an intention, not a commitment.",
@@ -54,6 +57,9 @@ export const roadmap: LocalizedPage = {
"An open milestone is an intention, not a commitment, and a date on one is a target, not a promise. Work ships when it is honest to say it shipped — the changelog records what actually landed.",
],
openTitle: "In progress — open milestones",
+ journeyTitle: "The journey so far",
+ journeyIntro:
+ "Every closed milestone, oldest first — the project's history read straight from the repository, dated by GitHub on the day each phase actually finished. No narrative was written after the fact; this is the record.",
closedTitle: "Recently shipped",
dueOn: (date) => `Target date: ${date}`,
noDate: "No date set",
@@ -70,8 +76,8 @@ export const roadmap: LocalizedPage = {
},
ar: {
- rev: "2026-08-28.1",
- translatedFromRev: "2026-08-28.1",
+ rev: "2026-08-29.1",
+ translatedFromRev: "2026-08-29.1",
title: "خارطةُ طريق كيل — تُقرأ من المستودع وتتجدّد كل ساعة",
description:
"خارطةُ طريق كيل: المراحلُ المفتوحة في CodeGateSoftware/keel والبنودُ تحتها، تُجلب من GitHub وقت البناء. والمرحلةُ المفتوحة نيّةٌ لا التزام.",
@@ -80,6 +86,9 @@ export const roadmap: LocalizedPage = {
"المرحلةُ المفتوحة نيّةٌ لا التزام، والتاريخُ عليها هدفٌ لا وعد. ويُنجَز العمل حين يصحّ قولُ إنه أُنجز — وسجلُّ التغييرات يوثّق ما هبط فعلًا.",
],
openTitle: "قيدُ العمل — مراحلُ مفتوحة",
+ journeyTitle: "المسيرةُ حتى الآن",
+ journeyIntro:
+ "كلُّ المعالم المغلقة، من الأقدم إلى الأحدث — تاريخُ المشروع مقروءًا من المستودع مباشرةً، بتواريخٍ دوّنها GitHub في اليوم الذي اكتملت فيه كلُّ مرحلةٍ فعلًا. لا سردَ كُتب بعد وقوعه؛ هذا هو السجلّ.",
closedTitle: "أُنجز مؤخّرًا",
dueOn: (date) => `تاريخٌ مستهدف: ${date}`,
noDate: "لم يُحدَّد تاريخ",
@@ -96,8 +105,8 @@ export const roadmap: LocalizedPage = {
},
fr: {
- rev: "2026-08-28.1",
- translatedFromRev: "2026-08-28.1",
+ rev: "2026-08-29.1",
+ translatedFromRev: "2026-08-29.1",
title: "Feuille de route de keel — lue depuis le dépôt, actualisée toutes les heures",
description:
"La feuille de route de keel : les jalons ouverts de CodeGateSoftware/keel et leurs tickets, lus depuis GitHub à la construction. Un jalon ouvert est une intention, pas un engagement.",
@@ -106,6 +115,9 @@ export const roadmap: LocalizedPage = {
"Un jalon ouvert est une intention, pas un engagement, et sa date est un objectif, pas une promesse. Le travail est livré quand il est honnête de dire qu'il l'est — le journal des versions consigne ce qui a réellement atterri.",
],
openTitle: "En cours — jalons ouverts",
+ journeyTitle: "Le parcours jusqu'ici",
+ journeyIntro:
+ "Chaque jalon fermé, du plus ancien au plus récent — l'histoire du projet lue directement du dépôt, datée par GitHub au jour où chaque phase s'est réellement achevée. Aucun récit écrit après coup ; ceci est la trace.",
closedTitle: "Livré récemment",
dueOn: (date) => `Date cible : ${date}`,
noDate: "Aucune date fixée",