page.tsx115 lines · main
| 1 | import { DocsShell } from '../../components/shell'; |
| 2 | import { CHANGELOG_ENTRIES, type ChangelogEntry, type ChangelogTag } from './entries'; |
| 3 | |
| 4 | export const metadata = { |
| 5 | title: 'changelog', |
| 6 | }; |
| 7 | |
| 8 | type Entry = ChangelogEntry; |
| 9 | type Tag = ChangelogTag; |
| 10 | |
| 11 | |
| 12 | const TAG_STYLE: Record<Tag, string> = { |
| 13 | feat: 'bg-[var(--color-primary)] text-[var(--color-text-inverse)]', |
| 14 | fix: 'border border-[var(--color-border)] text-[var(--color-text-muted)]', |
| 15 | security: 'bg-[var(--color-text-error)] text-[var(--color-text-inverse)]', |
| 16 | docs: 'border border-[var(--color-border)] text-[var(--color-text-muted)]', |
| 17 | infra: 'border border-[var(--color-border)] text-[var(--color-text-muted)]', |
| 18 | chore: 'border border-[var(--color-border)] text-[var(--color-text-subtle)]', |
| 19 | }; |
| 20 | |
| 21 | export default function ChangelogPage() { |
| 22 | const grouped = groupByMonth(CHANGELOG_ENTRIES); |
| 23 | return ( |
| 24 | <DocsShell> |
| 25 | <div className="flex items-start justify-between gap-4"> |
| 26 | <h1 className="font-mono text-2xl tracking-tight">changelog</h1> |
| 27 | <a |
| 28 | href="/changelog/feed.xml" |
| 29 | className="rounded-md border border-[var(--color-border)] px-3 py-1 font-mono text-xs text-[var(--color-text-muted)] hover:text-[var(--color-text)]" |
| 30 | > |
| 31 | rss feed |
| 32 | </a> |
| 33 | </div> |
| 34 | <p className="mt-2 font-mono text-sm text-[var(--color-text-muted)]"> |
| 35 | what landed, in reverse chronological order. one entry per shipped change worth |
| 36 | knowing about — security, features, infrastructure, fixes. |
| 37 | </p> |
| 38 | |
| 39 | <div className="mt-6 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-4 font-mono text-xs text-[var(--color-text-muted)]"> |
| 40 | briven ran dogfood-first through the first half of 2026 and graduates to{' '} |
| 41 | <strong>public beta on 2026-05-21</strong>. everything prior is internal validation on |
| 42 | j's own products; everything after is open to anyone with a credit card. |
| 43 | </div> |
| 44 | |
| 45 | <div className="mt-12 flex flex-col gap-12"> |
| 46 | {grouped.map(({ month, entries }) => ( |
| 47 | <section key={month}> |
| 48 | <h2 className="font-mono text-xl tracking-tight">{month}</h2> |
| 49 | <ul className="mt-6 flex flex-col gap-6"> |
| 50 | {entries.map((entry) => ( |
| 51 | <li key={`${entry.date}-${entry.title}`} className="flex flex-col gap-2"> |
| 52 | <div className="flex items-center gap-3"> |
| 53 | <time className="font-mono text-xs text-[var(--color-text-subtle)]"> |
| 54 | {entry.date} |
| 55 | </time> |
| 56 | <div className="flex gap-1"> |
| 57 | {entry.tags.map((tag) => ( |
| 58 | <span |
| 59 | key={tag} |
| 60 | className={`rounded px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wide ${TAG_STYLE[tag]}`} |
| 61 | > |
| 62 | {tag} |
| 63 | </span> |
| 64 | ))} |
| 65 | </div> |
| 66 | </div> |
| 67 | <p className="font-mono text-sm">{entry.title}</p> |
| 68 | <p className="font-mono text-sm text-[var(--color-text-muted)]">{entry.body}</p> |
| 69 | </li> |
| 70 | ))} |
| 71 | </ul> |
| 72 | </section> |
| 73 | ))} |
| 74 | </div> |
| 75 | </DocsShell> |
| 76 | ); |
| 77 | } |
| 78 | |
| 79 | function groupByMonth(entries: readonly Entry[]): { month: string; entries: Entry[] }[] { |
| 80 | const buckets = new Map<string, Entry[]>(); |
| 81 | for (const entry of entries) { |
| 82 | const month = entry.date.slice(0, 7); // yyyy-mm |
| 83 | const list = buckets.get(month) ?? []; |
| 84 | list.push(entry); |
| 85 | buckets.set(month, list); |
| 86 | } |
| 87 | // Sort entries within each bucket newest-first, then sort buckets newest-first. |
| 88 | for (const list of buckets.values()) { |
| 89 | list.sort((a, b) => b.date.localeCompare(a.date)); |
| 90 | } |
| 91 | return [...buckets.entries()] |
| 92 | .sort(([a], [b]) => b.localeCompare(a)) |
| 93 | .map(([month, list]) => ({ month: formatMonth(month), entries: list })); |
| 94 | } |
| 95 | |
| 96 | function formatMonth(yyyymm: string): string { |
| 97 | const [yearStr, monthStr] = yyyymm.split('-'); |
| 98 | const year = Number(yearStr); |
| 99 | const monthIdx = Number(monthStr) - 1; |
| 100 | const monthNames = [ |
| 101 | 'january', |
| 102 | 'february', |
| 103 | 'march', |
| 104 | 'april', |
| 105 | 'may', |
| 106 | 'june', |
| 107 | 'july', |
| 108 | 'august', |
| 109 | 'september', |
| 110 | 'october', |
| 111 | 'november', |
| 112 | 'december', |
| 113 | ]; |
| 114 | return `${monthNames[monthIdx] ?? yyyymm} ${year}`; |
| 115 | } |