Skip to content

Commit b32eb9e

Browse files
authored
[Dashboard] Introduce changelog to stack-companion (#1090)
<!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Changelog panel now fetches and displays recent releases with rich Markdown rendering, per-release cards, and change-type labels. * Visual cues (badge, glow, tooltip) indicate when unseen updates are available; last-seen state tracked for users. * **Chores** * Configured external changelog data source and added a backend endpoint to serve parsed changelog entries. <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 7b5cf4f commit b32eb9e

File tree

6 files changed

+543
-250
lines changed

6 files changed

+543
-250
lines changed

apps/backend/.env.development

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ NEXT_PUBLIC_STACK_API_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}0
22
NEXT_PUBLIC_STACK_DASHBOARD_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01
33
STACK_SERVER_SECRET=23-wuNpik0gIW4mruTz25rbIvhuuvZFrLOLtL7J4tyo
44

5+
STACK_CHANGELOG_URL=https://raw.githubusercontent.com/stack-auth/stack-auth/refs/heads/dev/CHANGELOG.md
6+
57
STACK_SEED_ENABLE_DUMMY_PROJECT=true
68
STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED=true
79
STACK_SEED_INTERNAL_PROJECT_OTP_ENABLED=true
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
2+
import { yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
3+
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
4+
5+
const REVALIDATE_SECONDS = 60 * 60;
6+
7+
type ChangeType = "major" | "minor" | "patch";
8+
9+
type ChangelogEntry = {
10+
version: string,
11+
type: ChangeType,
12+
markdown: string,
13+
bulletCount: number,
14+
releasedAt?: string,
15+
isUnreleased?: boolean,
16+
};
17+
18+
type TaggedBullet = { text: string, tags: string[] };
19+
20+
function parseTaggedBullet(line: string): TaggedBullet {
21+
let content = line.replace(/^- /, "").trim();
22+
const tags: string[] = [];
23+
24+
while (content.startsWith("[")) {
25+
const closingIndex = content.indexOf("]");
26+
if (closingIndex === -1) break;
27+
28+
const tag = content.slice(1, closingIndex).trim();
29+
if (!tag) break;
30+
31+
tags.push(tag);
32+
content = content.slice(closingIndex + 1).trim();
33+
}
34+
35+
return { text: content, tags };
36+
}
37+
38+
function parseVersionHeading(raw: string): { version: string, releasedAt?: string, isUnreleased: boolean } {
39+
const normalized = raw.trim();
40+
const isUnreleased = normalized.toLowerCase() === "unreleased";
41+
42+
if (isUnreleased) {
43+
return { version: "Unreleased", isUnreleased: true };
44+
}
45+
46+
const datePattern = /^(\d+\.\d+\.\d+)\s*(?:\(|-)\s*(\d{4}-\d{2}-\d{2})\)?$/;
47+
const match = normalized.match(datePattern);
48+
49+
if (match) {
50+
return {
51+
version: match[1],
52+
releasedAt: match[2],
53+
isUnreleased: false,
54+
};
55+
}
56+
57+
return {
58+
version: normalized,
59+
isUnreleased: false,
60+
};
61+
}
62+
63+
function parseRootChangelog(markdown: string): ChangelogEntry[] {
64+
const entries: ChangelogEntry[] = [];
65+
const sections = markdown.split(/(?=^## .+)/m);
66+
67+
for (const section of sections) {
68+
if (!section.trim()) continue;
69+
70+
const versionMatch = section.match(/^## (.+)/m);
71+
if (!versionMatch) continue;
72+
73+
const heading = versionMatch[1].trim();
74+
const { version, releasedAt, isUnreleased } = parseVersionHeading(heading);
75+
const isSemver = /^\d+\.\d+\.\d+$/.test(version);
76+
const isCalVer = /^\d{4}\.\d{2}\.\d{2}$/.test(version);
77+
78+
if (!isUnreleased && !isSemver && !isCalVer) {
79+
continue;
80+
}
81+
82+
const versionContent = section.replace(/^## .+$/m, "").trim();
83+
84+
let type: ChangeType = "patch";
85+
if (versionContent.includes("### Major Changes")) type = "major";
86+
else if (versionContent.includes("### Minor Changes")) type = "minor";
87+
88+
const lines = versionContent.split("\n");
89+
const processedLines: string[] = [];
90+
91+
for (const line of lines) {
92+
if (line.trim().startsWith("- ")) {
93+
const { text } = parseTaggedBullet(line);
94+
processedLines.push(text ? `- ${text}` : "-");
95+
} else {
96+
processedLines.push(line);
97+
}
98+
}
99+
100+
const normalizedMarkdown = processedLines.join("\n").trim();
101+
const bulletCount = processedLines.filter(l => l.trim().startsWith("-")).length;
102+
103+
entries.push({
104+
version,
105+
type,
106+
markdown: normalizedMarkdown,
107+
bulletCount,
108+
isUnreleased,
109+
releasedAt,
110+
});
111+
}
112+
113+
return entries;
114+
}
115+
116+
const changelogEntrySchema = yupObject({
117+
version: yupString().defined(),
118+
type: yupString().oneOf(["major", "minor", "patch"]).defined(),
119+
markdown: yupString().defined(),
120+
bulletCount: yupNumber().defined(),
121+
releasedAt: yupString().optional(),
122+
isUnreleased: yupBoolean().optional(),
123+
}).defined();
124+
125+
export const GET = createSmartRouteHandler({
126+
metadata: {
127+
hidden: true,
128+
},
129+
request: yupObject({
130+
method: yupString().oneOf(["GET"]).defined(),
131+
}),
132+
response: yupObject({
133+
statusCode: yupNumber().oneOf([200, 502]).defined(),
134+
bodyType: yupString().oneOf(["json"]).defined(),
135+
body: yupObject({
136+
entries: yupArray(changelogEntrySchema).optional(),
137+
error: yupString().optional(),
138+
}).defined(),
139+
}),
140+
handler: async () => {
141+
const changelogUrl = getEnvVariable("STACK_CHANGELOG_URL", "");
142+
143+
if (!changelogUrl) {
144+
return {
145+
statusCode: 200,
146+
bodyType: "json",
147+
body: { entries: [] },
148+
} as const;
149+
}
150+
151+
const response = await fetch(changelogUrl, {
152+
headers: {
153+
"Accept": "text/plain",
154+
"User-Agent": "stack-auth-backend-changelog",
155+
},
156+
next: {
157+
revalidate: REVALIDATE_SECONDS,
158+
},
159+
});
160+
161+
if (!response.ok) {
162+
return {
163+
statusCode: 502,
164+
bodyType: "json",
165+
body: { error: "Failed to download changelog" },
166+
} as const;
167+
}
168+
169+
const content = await response.text();
170+
const entries = parseRootChangelog(content).slice(0, 8);
171+
172+
return {
173+
statusCode: 200,
174+
bodyType: "json",
175+
body: { entries },
176+
} as const;
177+
},
178+
});
179+

apps/dashboard/.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ STACK_DEVELOPMENT_TRANSLATION_LOCALE=# enter the locale to use for the translati
1616
NEXT_PUBLIC_STACK_ENABLE_DEVELOPMENT_FEATURES_PROJECT_IDS='["internal"]'
1717
NEXT_PUBLIC_STACK_DEBUGGER_ON_ASSERTION_ERROR=# set to true to open the debugger on assertion errors (set to true in .env.development)
1818
STACK_FEATUREBASE_JWT_SECRET=# used for Featurebase SSO, you probably won't have to set this
19+
STACK_CHANGELOG_URL=# Used for raw github link to root changelog.md file.

apps/dashboard/src/components/stack-companion.tsx

Lines changed: 128 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,51 @@
11
'use client';
22

33
import { Button, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui';
4+
import { ChangelogEntry } from '@/lib/changelog';
5+
import { getPublicEnvVar } from '@/lib/env';
46
import { cn } from '@/lib/utils';
57
import { checkVersion, VersionCheckResult } from '@/lib/version-check';
68
import { BookOpenIcon, CircleNotchIcon, ClockClockwiseIcon, LightbulbIcon, XIcon } from '@phosphor-icons/react';
9+
import { runAsynchronously } from '@stackframe/stack-shared/dist/utils/promises';
710
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
811
import packageJson from '../../package.json';
912
import { FeedbackForm } from './feedback-form';
1013
import { ChangelogWidget } from './stack-companion/changelog-widget';
1114
import { FeatureRequestBoard } from './stack-companion/feature-request-board';
1215
import { UnifiedDocsWidget } from './stack-companion/unified-docs-widget';
1316

17+
/**
18+
* Compare two CalVer versions in YYYY.MM.DD format
19+
* Returns true if version1 is newer than version2
20+
*/
21+
function isNewerCalVer(version1: string, version2: string): boolean {
22+
const parseCalVer = (version: string): Date | null => {
23+
const match = version.match(/^(\d{4})\.(\d{2})\.(\d{2})$/);
24+
if (!match) return null;
25+
const [, year, month, day] = match;
26+
return new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
27+
};
28+
29+
const date1 = parseCalVer(version1);
30+
const date2 = parseCalVer(version2);
31+
32+
if (!date1 || !date2) {
33+
// Fallback to string comparison if parsing fails
34+
return version1 > version2;
35+
}
36+
37+
return date1.getTime() > date2.getTime();
38+
}
39+
40+
/**
41+
* Sanitize a string value for use in a cookie
42+
* Removes or encodes characters that could break cookie parsing
43+
*/
44+
function sanitizeCookieValue(value: string): string {
45+
// Remove or encode special characters that break cookie parsing
46+
return encodeURIComponent(value);
47+
}
48+
1449
type SidebarItem = {
1550
id: string,
1651
label: string,
@@ -73,6 +108,7 @@ export function useStackCompanion() {
73108
return useContext(StackCompanionContext);
74109
}
75110

111+
76112
export function StackCompanion({ className }: { className?: string }) {
77113
const [activeItem, setActiveItem] = useState<string | null>(null);
78114
const [mounted, setMounted] = useState(false);
@@ -82,6 +118,9 @@ export function StackCompanion({ className }: { className?: string }) {
82118
const [isAnimating, setIsAnimating] = useState(false);
83119
const [isDragging, setIsDragging] = useState(false);
84120
const [isSplitScreenMode, setIsSplitScreenMode] = useState(false);
121+
const [changelogData, setChangelogData] = useState<ChangelogEntry[] | undefined>(undefined);
122+
const [hasNewVersions, setHasNewVersions] = useState(false);
123+
const [lastSeenVersion, setLastSeenVersion] = useState('');
85124

86125
const startXRef = useRef(0);
87126
const startWidthRef = useRef(0);
@@ -125,6 +164,84 @@ export function StackCompanion({ className }: { className?: string }) {
125164
return cleanup;
126165
}, []);
127166

167+
// Fetch changelog data on mount and check for new versions
168+
useEffect(() => {
169+
runAsynchronously(async () => {
170+
const baseUrl = getPublicEnvVar('NEXT_PUBLIC_STACK_API_URL') || '';
171+
const response = await fetch(`${baseUrl}/api/latest/internal/changelog`);
172+
if (!response.ok) {
173+
return;
174+
}
175+
176+
const payload = await response.json();
177+
const entries = payload.entries || [];
178+
setChangelogData(entries);
179+
180+
// Check for new versions
181+
const lastSeenRaw = document.cookie
182+
.split('; ')
183+
.find(row => row.startsWith('stack-last-seen-changelog-version='))
184+
?.split('=')[1] || '';
185+
186+
const lastSeen = lastSeenRaw ? decodeURIComponent(lastSeenRaw) : '';
187+
setLastSeenVersion(lastSeen);
188+
189+
if (entries.length > 0) {
190+
// If no lastSeen cookie, user hasn't seen any changelog yet - show bell
191+
if (!lastSeen) {
192+
setHasNewVersions(true);
193+
} else {
194+
const hasNewer = entries.some((entry: ChangelogEntry) => {
195+
if (entry.isUnreleased) return false;
196+
return isNewerCalVer(entry.version, lastSeen);
197+
});
198+
setHasNewVersions(hasNewer);
199+
}
200+
}
201+
});
202+
}, []);
203+
204+
// Re-check for new versions when changelog is opened/closed
205+
useEffect(() => {
206+
if (activeItem === 'changelog') {
207+
// When changelog is opened, mark the latest released version as seen
208+
// Skip unreleased versions to avoid breaking version comparison
209+
if (changelogData && changelogData.length > 0) {
210+
const latestReleasedEntry = changelogData.find(entry => !entry.isUnreleased);
211+
if (latestReleasedEntry) {
212+
document.cookie = `stack-last-seen-changelog-version=${sanitizeCookieValue(latestReleasedEntry.version)}; path=/; max-age=31536000`; // 1 year
213+
setLastSeenVersion(latestReleasedEntry.version);
214+
}
215+
}
216+
// Clear the notification badge immediately
217+
setHasNewVersions(false);
218+
} else if (activeItem === null) {
219+
// When closed, re-check if there are new versions
220+
const lastSeenRaw = document.cookie
221+
.split('; ')
222+
.find(row => row.startsWith('stack-last-seen-changelog-version='))
223+
?.split('=')[1] || '';
224+
225+
const lastSeen = lastSeenRaw ? decodeURIComponent(lastSeenRaw) : '';
226+
227+
if (changelogData && changelogData.length > 0) {
228+
// If no lastSeen cookie, user hasn't seen any changelog yet - show bell
229+
if (!lastSeen) {
230+
setHasNewVersions(true);
231+
} else {
232+
const hasNewer = changelogData.some((entry: ChangelogEntry) => {
233+
if (entry.isUnreleased) return false;
234+
return isNewerCalVer(entry.version, lastSeen);
235+
});
236+
setHasNewVersions(hasNewer);
237+
}
238+
} else {
239+
setHasNewVersions(false);
240+
}
241+
}
242+
}, [activeItem, changelogData]);
243+
244+
128245
const openDrawer = useCallback((itemId: string) => {
129246
setActiveItem(itemId);
130247
setIsAnimating(true);
@@ -304,7 +421,7 @@ export function StackCompanion({ className }: { className?: string }) {
304421
<div className="flex-1 overflow-y-auto p-5 overflow-x-hidden no-drag cursor-auto">
305422
{activeItem === 'docs' && <UnifiedDocsWidget isActive={true} />}
306423
{activeItem === 'feedback' && <FeatureRequestBoard isActive={true} />}
307-
{activeItem === 'changelog' && <ChangelogWidget isActive={true} />}
424+
{activeItem === 'changelog' && <ChangelogWidget isActive={true} initialData={changelogData} />}
308425
{activeItem === 'support' && <FeedbackForm />}
309426
</div>
310427
</div>
@@ -338,18 +455,26 @@ export function StackCompanion({ className }: { className?: string }) {
338455
className={cn(
339456
"h-10 w-10 p-0 text-muted-foreground transition-all duration-[50ms] rounded-xl relative group",
340457
item.hoverBg,
341-
activeItem === item.id && "bg-foreground/10 text-foreground shadow-sm ring-1 ring-foreground/5"
458+
activeItem === item.id && "bg-foreground/10 text-foreground shadow-sm ring-1 ring-foreground/5",
459+
// Glow effect for changelog with new updates
460+
item.id === 'changelog' && hasNewVersions && "ring-2 ring-green-500/30 bg-green-500/10"
342461
)}
343462
onClick={(e) => {
344463
e.stopPropagation();
345464
handleItemClick(item.id);
346465
}}
347466
>
348467
<item.icon className={cn("h-5 w-5 transition-transform duration-[50ms] group-hover:scale-110", item.color)} />
468+
{item.id === 'changelog' && hasNewVersions && (
469+
<span className="absolute -top-1 -right-1 flex h-3 w-3">
470+
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
471+
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
472+
</span>
473+
)}
349474
</Button>
350475
</TooltipTrigger>
351476
<TooltipContent side="left" className="z-[60] mr-2">
352-
{item.label}
477+
{item.id === 'changelog' && hasNewVersions ? `${item.label} (New updates available!)` : item.label}
353478
</TooltipContent>
354479
</Tooltip>
355480
))}

0 commit comments

Comments
 (0)