Skip to content

Commit 6ec51db

Browse files
Merge pull request #34 from TextureHQ/feat/changelog-feed
2 parents e651b6d + 30105a1 commit 6ec51db

File tree

11 files changed

+745
-100
lines changed

11 files changed

+745
-100
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,7 @@ data/transmission-lines.geojson
2525
# Track the .pmtiles files but ignore any extracted/legacy tile directories
2626
public/tiles/*
2727
!public/tiles/*.pmtiles
28+
29+
# Changelog snapshot — large JSON used only for diffing during sync
30+
# changelog.json itself is tracked; only the snapshot dir is ignored
31+
data/.snapshot/

app/(shell)/about/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const dataHighlights = [
2626

2727
export default function AboutPage() {
2828
return (
29-
<PageLayout>
29+
<PageLayout maxWidth={900}>
3030
<PageLayout.Header title="About OpenGrid" />
3131
<PageLayout.Content>
3232
{/* Hero */}

app/(shell)/changelog/page.tsx

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
"use client";
2+
3+
import { Badge, Card, Icon, PageLayout, Section } from "@texturehq/edges";
4+
import { getChangelog } from "@/lib/data";
5+
import type { ChangelogEntry } from "@/types/changelog";
6+
7+
// ── Helpers ───────────────────────────────────────────────────────────────────
8+
9+
const AVATAR_COLORS = [
10+
"bg-blue-100 text-blue-600",
11+
"bg-purple-100 text-purple-600",
12+
"bg-teal-100 text-teal-600",
13+
"bg-orange-100 text-orange-600",
14+
"bg-sky-100 text-sky-600",
15+
"bg-green-100 text-green-600",
16+
"bg-rose-100 text-rose-600",
17+
"bg-amber-100 text-amber-600",
18+
"bg-indigo-100 text-indigo-600",
19+
"bg-emerald-100 text-emerald-600",
20+
"bg-lime-100 text-lime-600",
21+
"bg-cyan-100 text-cyan-600",
22+
"bg-violet-100 text-violet-600",
23+
] as const;
24+
25+
function avatarColor(name: string): string {
26+
let hash = 0;
27+
for (let i = 0; i < name.length; i++) {
28+
hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
29+
}
30+
return AVATAR_COLORS[hash % AVATAR_COLORS.length];
31+
}
32+
33+
function formatRelativeTime(isoTimestamp: string): string {
34+
const diffMs = Date.now() - new Date(isoTimestamp).getTime();
35+
const diffMin = Math.floor(diffMs / 60_000);
36+
if (diffMin < 60) return `${diffMin}m ago`;
37+
const diffHr = Math.floor(diffMin / 60);
38+
if (diffHr < 24) return `${diffHr}h ago`;
39+
const diffDay = Math.floor(diffHr / 24);
40+
return `${diffDay}d ago`;
41+
}
42+
43+
function formatDate(isoTimestamp: string): string {
44+
return new Date(isoTimestamp).toLocaleDateString("en-US", {
45+
month: "long",
46+
day: "numeric",
47+
year: "numeric",
48+
});
49+
}
50+
51+
/** Group entries by calendar date (local) */
52+
function groupByDate(entries: ChangelogEntry[]): Array<{ date: string; entries: ChangelogEntry[] }> {
53+
const groups = new Map<string, ChangelogEntry[]>();
54+
for (const entry of entries) {
55+
const date = formatDate(entry.isoTimestamp);
56+
if (!groups.has(date)) groups.set(date, []);
57+
groups.get(date)!.push(entry);
58+
}
59+
return Array.from(groups.entries()).map(([date, entries]) => ({ date, entries }));
60+
}
61+
62+
// ── Components ────────────────────────────────────────────────────────────────
63+
64+
function EntryRow({ entry }: { entry: ChangelogEntry }) {
65+
const color = avatarColor(entry.name);
66+
const initial = entry.name.charAt(0).toUpperCase();
67+
68+
return (
69+
<div className="flex items-start gap-4 py-4 border-b border-border-default last:border-0">
70+
{/* Avatar */}
71+
<div
72+
className={`flex-none w-9 h-9 rounded-full flex items-center justify-center text-sm font-semibold mt-0.5 ${color}`}
73+
>
74+
{initial}
75+
</div>
76+
77+
{/* Body */}
78+
<div className="flex-1 min-w-0">
79+
<div className="flex items-center gap-2 flex-wrap mb-0.5">
80+
<span className="text-sm font-semibold text-text-heading">{entry.name}</span>
81+
<Badge
82+
size="sm"
83+
shape="pill"
84+
variant={entry.kind === "added" ? "success" : "info"}
85+
>
86+
{entry.kind === "added" ? "New" : "Updated"}
87+
</Badge>
88+
</div>
89+
<div className="text-sm text-text-muted">{entry.detail}</div>
90+
</div>
91+
92+
{/* Time */}
93+
<div className="flex-none text-xs text-text-muted tabular-nums whitespace-nowrap mt-1">
94+
{formatRelativeTime(entry.isoTimestamp)}
95+
</div>
96+
</div>
97+
);
98+
}
99+
100+
function DateGroup({ date, entries }: { date: string; entries: ChangelogEntry[] }) {
101+
return (
102+
<div className="mb-8">
103+
<div className="flex items-center gap-3 mb-3">
104+
<div className="text-xs font-semibold uppercase tracking-widest text-text-muted">{date}</div>
105+
<div className="flex-1 h-px bg-border-default" />
106+
<div className="text-xs text-text-muted">{entries.length} change{entries.length !== 1 ? "s" : ""}</div>
107+
</div>
108+
<Card variant="outlined">
109+
<Card.Content className="p-0 px-6">
110+
{entries.map((entry) => (
111+
<EntryRow key={`${entry.kind}:${entry.entityType}:${entry.slug}`} entry={entry} />
112+
))}
113+
</Card.Content>
114+
</Card>
115+
</div>
116+
);
117+
}
118+
119+
// ── Page ──────────────────────────────────────────────────────────────────────
120+
121+
export default function ChangelogPage() {
122+
const changelog = getChangelog();
123+
124+
// Merge and sort all entries newest-first
125+
const allEntries = [...changelog.recentlyUpdated, ...changelog.newlyAdded].sort(
126+
(a, b) => new Date(b.isoTimestamp).getTime() - new Date(a.isoTimestamp).getTime(),
127+
);
128+
129+
const groups = groupByDate(allEntries);
130+
131+
const lastUpdated = changelog.updatedAt
132+
? new Date(changelog.updatedAt).toLocaleDateString("en-US", {
133+
month: "long",
134+
day: "numeric",
135+
year: "numeric",
136+
})
137+
: null;
138+
139+
return (
140+
<PageLayout maxWidth={900}>
141+
<PageLayout.Header
142+
title="Changelog"
143+
description="Every update to the OpenGrid dataset — new entities added and existing records updated from authoritative sources."
144+
/>
145+
<PageLayout.Content>
146+
<Section id="feed" navLabel="Changes" withDivider={false}>
147+
{/* Meta bar */}
148+
<div className="flex items-center justify-between mb-6">
149+
<div className="flex items-center gap-2">
150+
<span className="inline-block w-2 h-2 rounded-full bg-green-500" />
151+
<span className="text-sm text-text-muted">
152+
Synced from authoritative sources daily
153+
</span>
154+
</div>
155+
{lastUpdated && (
156+
<span className="text-xs text-text-muted">
157+
Last updated {lastUpdated}
158+
</span>
159+
)}
160+
</div>
161+
162+
{/* Stats */}
163+
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-8">
164+
{[
165+
{
166+
label: "Recently updated",
167+
value: changelog.recentlyUpdated.length,
168+
icon: "ArrowCounterClockwise" as const,
169+
color: "text-blue-500",
170+
bg: "bg-blue-50",
171+
},
172+
{
173+
label: "Newly added",
174+
value: changelog.newlyAdded.length,
175+
icon: "Plus" as const,
176+
color: "text-green-500",
177+
bg: "bg-green-50",
178+
},
179+
{
180+
label: "Total changes",
181+
value: allEntries.length,
182+
icon: "ListBullets" as const,
183+
color: "text-text-muted",
184+
bg: "bg-background-subtle",
185+
},
186+
].map((stat) => (
187+
<Card key={stat.label} variant="outlined">
188+
<Card.Content className="p-4 flex items-center gap-3">
189+
<div className={`w-8 h-8 rounded-lg ${stat.bg} flex items-center justify-center flex-none`}>
190+
<Icon name={stat.icon} size={16} className={stat.color} />
191+
</div>
192+
<div>
193+
<div className="text-xl font-bold text-text-heading tabular-nums">{stat.value}</div>
194+
<div className="text-xs text-text-muted">{stat.label}</div>
195+
</div>
196+
</Card.Content>
197+
</Card>
198+
))}
199+
</div>
200+
201+
{/* Feed */}
202+
{allEntries.length === 0 ? (
203+
<Card variant="outlined">
204+
<Card.Content className="py-12 text-center">
205+
<Icon name="Clock" size={32} className="text-text-muted mx-auto mb-3" />
206+
<p className="text-text-muted text-sm">
207+
No changes recorded yet. Run{" "}
208+
<code className="text-xs bg-background-subtle px-1.5 py-0.5 rounded">
209+
npm run generate:changelog
210+
</code>{" "}
211+
after a sync to populate this feed.
212+
</p>
213+
</Card.Content>
214+
</Card>
215+
) : (
216+
groups.map(({ date, entries }) => (
217+
<DateGroup key={date} date={date} entries={entries} />
218+
))
219+
)}
220+
</Section>
221+
</PageLayout.Content>
222+
</PageLayout>
223+
);
224+
}

app/(shell)/layout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export default function ShellLayout({ children }: { children: ReactNode }) {
55
const navigation = [
66
{ id: "explore", label: "Explore", href: "/explore" },
77
{ id: "api", label: "API", href: "https://docs.opengrid.dev", external: true },
8+
{ id: "changelog", label: "Changelog", href: "/changelog" },
89
{ id: "about", label: "About", href: "/about" },
910
];
1011

0 commit comments

Comments
 (0)