From a446265ad51a88ff3adb4fba1bcfca667bec5236 Mon Sep 17 00:00:00 2001 From: HearSilent Date: Wed, 18 Mar 2026 11:54:14 +0800 Subject: [PATCH 01/13] feat: add in-app changelog - Implement useChangelog hook to fetch GitHub releases - Create ChangelogDialog with current version filtering - Add interactive markdown parsing for links, bolds, PRs, and users --- src/components/about-dialog.tsx | 27 ++- src/components/changelog-dialog.tsx | 281 ++++++++++++++++++++++++++++ src/hooks/use-changelog.ts | 49 +++++ 3 files changed, 353 insertions(+), 4 deletions(-) create mode 100644 src/components/changelog-dialog.tsx create mode 100644 src/hooks/use-changelog.ts diff --git a/src/components/about-dialog.tsx b/src/components/about-dialog.tsx index 9fc36104..da379c59 100644 --- a/src/components/about-dialog.tsx +++ b/src/components/about-dialog.tsx @@ -1,5 +1,7 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { openUrl } from "@tauri-apps/plugin-opener"; +import { ChangelogDialog } from "./changelog-dialog"; +import { Button } from "@/components/ui/button"; interface AboutDialogProps { version: string; @@ -29,6 +31,8 @@ function ExternalLink({ } export function AboutDialog({ version, onClose }: AboutDialogProps) { + const [view, setView] = useState<"about" | "changelog">("about"); + // Close on ESC key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -59,6 +63,10 @@ export function AboutDialog({ version, onClose }: AboutDialogProps) { } }; + if (view === "changelog") { + return setView("about")} onClose={onClose} />; + } + return (
OpenUsage - - v{version} - +
+ + v{version} + + +

@@ -103,3 +121,4 @@ export function AboutDialog({ version, onClose }: AboutDialogProps) {

); } + diff --git a/src/components/changelog-dialog.tsx b/src/components/changelog-dialog.tsx new file mode 100644 index 00000000..5e0f52a2 --- /dev/null +++ b/src/components/changelog-dialog.tsx @@ -0,0 +1,281 @@ +import { useEffect } from "react" +import { Loader2, X, ChevronRight, ExternalLink as ExternalLinkIcon } from "lucide-react" +import { useChangelog } from "@/hooks/use-changelog" +import { Button } from "@/components/ui/button" +import { openUrl } from "@tauri-apps/plugin-opener" + +interface ChangelogDialogProps { + currentVersion: string + onBack: () => void + onClose: () => void +} + +function SimpleMarkdown({ content }: { content: string }) { + // Regex for identifying various markdown elements + const patterns = [ + // Markdown links: [label](url) + { type: "link", regex: /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g }, + // Bolds: **text** or __text__ + { type: "bold", regex: /(\*\*|__)(.*?)\1/g }, + // Italics: *text* or _text_ + { type: "italic", regex: /(\*|_)(.*?)\1/g }, + // PR/Issue numbers: #123 + { type: "pr", regex: /(#\d+)/g }, + // Usernames: @user + { type: "user", regex: /(@[\w-]+)/g }, + // Commit hashes: 7 chars hex + { type: "commit", regex: /\b([a-f0-9]{7})\b/g }, + ]; + + const renderText = (text: string): React.ReactNode => { + let parts: Array<{ type: string; content: string; url?: string }> = [ + { type: "text", content: text }, + ]; + + patterns.forEach((pattern) => { + const newParts: typeof parts = []; + parts.forEach((part) => { + if (part.type !== "text") { + newParts.push(part); + return; + } + + let lastIndex = 0; + let match; + const regex = new RegExp(pattern.regex); + + while ((match = regex.exec(part.content)) !== null) { + if (match.index > lastIndex) { + newParts.push({ type: "text", content: part.content.slice(lastIndex, match.index) }); + } + + if (pattern.type === "link") { + newParts.push({ type: "link", content: match[1], url: match[2] }); + } else if (pattern.type === "bold") { + newParts.push({ type: "bold", content: match[2] }); + } else if (pattern.type === "italic") { + newParts.push({ type: "italic", content: match[2] }); + } else if (pattern.type === "pr") { + newParts.push({ type: "pr", content: match[1] }); + } else if (pattern.type === "user") { + newParts.push({ type: "user", content: match[1] }); + } else if (pattern.type === "commit") { + const isHex = /^[a-f0-9]+$/.test(match[1]); + if (isHex && match[1].length === 7) { + newParts.push({ type: "commit", content: match[1] }); + } else { + newParts.push({ type: "text", content: match[1] }); + } + } + + lastIndex = regex.lastIndex; + } + + if (lastIndex < part.content.length) { + newParts.push({ type: "text", content: part.content.slice(lastIndex) }); + } + }); + parts = newParts; + }); + + const linkClass = "text-[#58a6ff] hover:underline hover:text-[#58a6ff]/80 transition-colors cursor-pointer"; + + return parts.map((part, i) => { + if (part.type === "link") { + return ( + + ); + } + if (part.type === "bold") { + return {renderText(part.content)}; + } + if (part.type === "italic") { + return {renderText(part.content)}; + } + if (part.type === "pr") { + return ( + + ); + } + if (part.type === "user") { + return ( + + ); + } + if (part.type === "commit") { + return ( + + ); + } + return {part.content}; + }); + }; + + const lines = content.split("\n"); + return ( +
+ {lines.map((line, i) => { + const trimmed = line.trim(); + if (trimmed === "---" || trimmed === "***" || trimmed === "--") { + return
+ } + if (trimmed.startsWith("###")) { + return

{renderText(trimmed.replace(/^###\s*/, ""))}

+ } + if (trimmed.startsWith("##")) { + return

{renderText(trimmed.replace(/^##\s*/, ""))}

+ } + if (trimmed.startsWith("-") || trimmed.startsWith("*")) { + if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) { + return ( +
+ + {renderText(trimmed.replace(/^[-*]\s*/, ""))} +
+ ) + } + } + if (!trimmed) return
+ return

{renderText(line)}

+ })} +
+ ) +} + +export function ChangelogDialog({ currentVersion, onBack, onClose }: ChangelogDialogProps) { + const { releases, loading, error } = useChangelog() + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault() + onClose() + } + } + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [onClose]) + + const currentRelease = releases.find(r => + r.tag_name === currentVersion || + r.tag_name === `v${currentVersion}` || + r.name === currentVersion || + r.name === `v${currentVersion}` + ) + + return ( +
+
+
+
+ +

Release Notes

+
+ +
+ +
+ {loading ? ( +
+ + Fetching release info... +
+ ) : error ? ( +
+ Failed to load release notes + {error} + +
+ ) : currentRelease ? ( +
+
+
+

{currentRelease.name || currentRelease.tag_name}

+

+ Released on {(() => { + const d = new Date(currentRelease.published_at); + return `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`; + })()} +

+
+ +
+ +
+ +
+ + {releases.length > 1 && ( +
+

+ Looking for older versions? Check the{" "} + +

+
+ )} +
+ ) : ( +
+ No specific notes for v{currentVersion} + This version might be a pre-release or local build. + +
+ )} +
+
+
+ ) +} diff --git a/src/hooks/use-changelog.ts b/src/hooks/use-changelog.ts new file mode 100644 index 00000000..0889f905 --- /dev/null +++ b/src/hooks/use-changelog.ts @@ -0,0 +1,49 @@ +import { useState, useEffect } from "react" + +export interface Release { + id: number + tag_name: string + name: string + body: string + published_at: string + html_url: string +} + +export function useChangelog() { + const [releases, setReleases] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + let mounted = true + setLoading(true) + + fetch("https://api.github.com/repos/robinebers/openusage/releases") + .then((res) => { + if (!res.ok) throw new Error("Failed to fetch releases") + return res.json() + }) + .then((data) => { + if (mounted) { + setReleases(data) + setError(null) + } + }) + .catch((err) => { + if (mounted) { + setError(err.message) + } + }) + .finally(() => { + if (mounted) { + setLoading(false) + } + }) + + return () => { + mounted = false + } + }, []) + + return { releases, loading, error } +} From 0ffd9c11d07d0dbd9ae7722abc027ed05466b91a Mon Sep 17 00:00:00 2001 From: HearSilent Date: Wed, 18 Mar 2026 12:21:18 +0800 Subject: [PATCH 02/13] test: add changelog dialog tests Add Vitest/RTL coverage for changelog dialog states and link behaviors. --- src/components/changelog-dialog.test.tsx | 232 +++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 src/components/changelog-dialog.test.tsx diff --git a/src/components/changelog-dialog.test.tsx b/src/components/changelog-dialog.test.tsx new file mode 100644 index 00000000..d53c253b --- /dev/null +++ b/src/components/changelog-dialog.test.tsx @@ -0,0 +1,232 @@ +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { describe, expect, it, vi, beforeEach } from "vitest" + +const openerState = vi.hoisted(() => ({ + openUrlMock: vi.fn(() => Promise.resolve()), +})) + +const changelogState = vi.hoisted(() => ({ + releases: [] as import("@/hooks/use-changelog").Release[], + loading: false, + error: null as string | null, +})) + +vi.mock("@tauri-apps/plugin-opener", () => ({ + openUrl: openerState.openUrlMock, +})) + +vi.mock("@/hooks/use-changelog", () => ({ + useChangelog: () => changelogState, +})) + +import { ChangelogDialog } from "@/components/changelog-dialog" + +describe("ChangelogDialog", () => { + beforeEach(() => { + changelogState.releases = [] + changelogState.loading = false + changelogState.error = null + openerState.openUrlMock.mockClear() + }) + + it("renders loading state", () => { + changelogState.loading = true + + render( + {}} + onClose={() => {}} + />, + ) + + expect(screen.getByText("Fetching release info...")).toBeInTheDocument() + }) + + it("renders error state and shows retry button", async () => { + changelogState.error = "something went wrong" + + render( + {}} + onClose={() => {}} + />, + ) + + expect(screen.getByText("Failed to load release notes")).toBeInTheDocument() + expect(screen.getByText("something went wrong")).toBeInTheDocument() + + const retryButton = screen.getByRole("button", { name: "Try again" }) + expect(retryButton).toBeInTheDocument() + }) + + it("renders current release with markdown content and GitHub link", async () => { + const body = + "Intro\n\n" + + "## Heading\n" + + "- item\n" + + "PR #123 by @user in commit abcdef1\n" + + "See [docs](https://example.com/docs)" + + changelogState.releases = [ + { + id: 1, + tag_name: "v1.2.3", + name: "v1.2.3", + body, + published_at: "2024-01-02T00:00:00Z", + html_url: "https://github.com/robinebers/openusage/releases/tag/v1.2.3", + }, + ] + + render( + {}} + onClose={() => {}} + />, + ) + + expect(screen.getByText("v1.2.3")).toBeInTheDocument() + expect(screen.getByText("Intro")).toBeInTheDocument() + expect(screen.getByText("Heading")).toBeInTheDocument() + expect(screen.getByText("item")).toBeInTheDocument() + + // GitHub button opens the release URL. + await userEvent.click(screen.getByRole("button", { name: "GitHub" })) + expect(openerState.openUrlMock).toHaveBeenCalledWith( + "https://github.com/robinebers/openusage/releases/tag/v1.2.3", + ) + + openerState.openUrlMock.mockClear() + + // Markdown link button. + await userEvent.click(screen.getByRole("button", { name: "docs" })) + expect(openerState.openUrlMock).toHaveBeenCalledWith("https://example.com/docs") + + openerState.openUrlMock.mockClear() + + // PR, user, and commit buttons. + await userEvent.click(screen.getByRole("button", { name: "#123" })) + expect(openerState.openUrlMock).toHaveBeenCalledWith( + "https://github.com/robinebers/openusage/pull/123", + ) + + openerState.openUrlMock.mockClear() + + await userEvent.click(screen.getByRole("button", { name: "@user" })) + expect(openerState.openUrlMock).toHaveBeenCalledWith("https://github.com/user") + + openerState.openUrlMock.mockClear() + + await userEvent.click(screen.getByRole("button", { name: "abcdef1" })) + expect(openerState.openUrlMock).toHaveBeenCalledWith( + "https://github.com/robinebers/openusage/commit/abcdef1", + ) + }) + + it("shows link to full changelog when multiple releases exist", async () => { + changelogState.releases = [ + { + id: 1, + tag_name: "v1.0.0", + name: "v1.0.0", + body: "body", + published_at: "2024-01-02T00:00:00Z", + html_url: "https://github.com/robinebers/openusage/releases/tag/v1.0.0", + }, + { + id: 2, + tag_name: "v0.9.0", + name: "v0.9.0", + body: "older", + published_at: "2024-01-01T00:00:00Z", + html_url: "https://github.com/robinebers/openusage/releases/tag/v0.9.0", + }, + ] + + render( + {}} + onClose={() => {}} + />, + ) + + const fullChangelogButton = screen.getByRole("button", { name: "full changelog" }) + await userEvent.click(fullChangelogButton) + + expect(openerState.openUrlMock).toHaveBeenCalledWith( + "https://github.com/robinebers/openusage/releases", + ) + }) + + it("renders fallback when no current release is found", async () => { + changelogState.releases = [ + { + id: 1, + tag_name: "v0.1.0", + name: "v0.1.0", + body: "old", + published_at: "2023-01-01T00:00:00Z", + html_url: "https://github.com/robinebers/openusage/releases/tag/v0.1.0", + }, + ] + + render( + {}} + onClose={() => {}} + />, + ) + + expect( + screen.getByText("No specific notes for v9.9.9"), + ).toBeInTheDocument() + + await userEvent.click( + screen.getByRole("button", { name: "View all releases on GitHub" }), + ) + + expect(openerState.openUrlMock).toHaveBeenCalledWith( + "https://github.com/robinebers/openusage/releases", + ) + }) + + it("invokes navigation callbacks and closes on Escape", async () => { + const onBack = vi.fn() + const onClose = vi.fn() + + changelogState.releases = [ + { + id: 1, + tag_name: "v1.0.0", + name: "v1.0.0", + body: "body", + published_at: "2024-01-02T00:00:00Z", + html_url: "https://github.com/robinebers/openusage/releases/tag/v1.0.0", + }, + ] + + render( + , + ) + + await userEvent.click(screen.getByRole("button", { name: "Back" })) + expect(onBack).toHaveBeenCalled() + + await userEvent.click(screen.getByRole("button", { name: "Close" })) + expect(onClose).toHaveBeenCalledTimes(1) + + await userEvent.keyboard("{Escape}") + expect(onClose).toHaveBeenCalledTimes(2) + }) +}) + From ce45bd9d23b9dc300df3f352215d2b5ad2ef7945 Mon Sep 17 00:00:00 2001 From: HearSilent Date: Wed, 18 Mar 2026 12:48:53 +0800 Subject: [PATCH 03/13] fix: prevent double Escape in dialogs Ensure AboutDialog only listens for Escape in the about view, wire ChangelogDialog Escape to go back to About, remove the redundant Close button in the changelog header, and update dialog tests to match the new navigation behavior. --- src/components/about-dialog.test.tsx | 14 ++++++++++++++ src/components/about-dialog.tsx | 16 ++++++++++++++-- src/components/changelog-dialog.test.tsx | 7 +++---- src/components/changelog-dialog.tsx | 7 ------- 4 files changed, 31 insertions(+), 13 deletions(-) diff --git a/src/components/about-dialog.test.tsx b/src/components/about-dialog.test.tsx index 91ea0cf2..54ad394b 100644 --- a/src/components/about-dialog.test.tsx +++ b/src/components/about-dialog.test.tsx @@ -40,6 +40,20 @@ describe("AboutDialog", () => { expect(onClose).toHaveBeenCalled() }) + it("goes back to about view on Escape when showing changelog", async () => { + const onClose = vi.fn() + render() + + // Switch to changelog view. + await userEvent.click(screen.getByRole("button", { name: "View Changelog" })) + + // Press Escape; should go back to About view, not close. + await userEvent.keyboard("{Escape}") + + expect(onClose).not.toHaveBeenCalled() + expect(screen.getByText("OpenUsage")).toBeInTheDocument() + }) + it("does not close on other keys", async () => { const onClose = vi.fn() render() diff --git a/src/components/about-dialog.tsx b/src/components/about-dialog.tsx index da379c59..31315c82 100644 --- a/src/components/about-dialog.tsx +++ b/src/components/about-dialog.tsx @@ -35,6 +35,10 @@ export function AboutDialog({ version, onClose }: AboutDialogProps) { // Close on ESC key useEffect(() => { + if (view !== "about") { + return; + } + const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); @@ -43,7 +47,7 @@ export function AboutDialog({ version, onClose }: AboutDialogProps) { }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [onClose]); + }, [onClose, view]); // Close when panel hides (loses visibility) useEffect(() => { @@ -64,7 +68,15 @@ export function AboutDialog({ version, onClose }: AboutDialogProps) { }; if (view === "changelog") { - return setView("about")} onClose={onClose} />; + return ( + setView("about")} + // In changelog view, Escape should go back to About instead of + // closing the entire dialog, so hand off to setView. + onClose={() => setView("about")} + /> + ); } return ( diff --git a/src/components/changelog-dialog.test.tsx b/src/components/changelog-dialog.test.tsx index d53c253b..6bb22f1a 100644 --- a/src/components/changelog-dialog.test.tsx +++ b/src/components/changelog-dialog.test.tsx @@ -219,14 +219,13 @@ describe("ChangelogDialog", () => { />, ) + // Back goes to previous view await userEvent.click(screen.getByRole("button", { name: "Back" })) expect(onBack).toHaveBeenCalled() - await userEvent.click(screen.getByRole("button", { name: "Close" })) - expect(onClose).toHaveBeenCalledTimes(1) - + // Escape should trigger onClose once await userEvent.keyboard("{Escape}") - expect(onClose).toHaveBeenCalledTimes(2) + expect(onClose).toHaveBeenCalledTimes(1) }) }) diff --git a/src/components/changelog-dialog.tsx b/src/components/changelog-dialog.tsx index 5e0f52a2..60d992ac 100644 --- a/src/components/changelog-dialog.tsx +++ b/src/components/changelog-dialog.tsx @@ -201,13 +201,6 @@ export function ChangelogDialog({ currentVersion, onBack, onClose }: ChangelogDi

Release Notes

-
From c8535beb71bfe998140f44fd860b20c7aa3fb35a Mon Sep 17 00:00:00 2001 From: HearSilent Date: Wed, 18 Mar 2026 12:51:57 +0800 Subject: [PATCH 04/13] fix: prevent crash on null changelog markdown --- src/components/changelog-dialog.test.tsx | 24 ++++++++++++++++++++++++ src/components/changelog-dialog.tsx | 4 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/components/changelog-dialog.test.tsx b/src/components/changelog-dialog.test.tsx index 6bb22f1a..1a480ee4 100644 --- a/src/components/changelog-dialog.test.tsx +++ b/src/components/changelog-dialog.test.tsx @@ -127,6 +127,30 @@ describe("ChangelogDialog", () => { ) }) + it("handles null body without crashing", () => { + changelogState.releases = [ + { + id: 1, + tag_name: "v1.0.0", + name: "v1.0.0", + body: null as any, + published_at: "2024-01-02T00:00:00Z", + html_url: "https://github.com/robinebers/openusage/releases/tag/v1.0.0", + }, + ] + + render( + {}} + onClose={() => {}} + />, + ) + + // If it renders the title, we know it didn't crash when rendering the markdown. + expect(screen.getByText("v1.0.0")).toBeInTheDocument() + }) + it("shows link to full changelog when multiple releases exist", async () => { changelogState.releases = [ { diff --git a/src/components/changelog-dialog.tsx b/src/components/changelog-dialog.tsx index 60d992ac..59c186f1 100644 --- a/src/components/changelog-dialog.tsx +++ b/src/components/changelog-dialog.tsx @@ -1,5 +1,5 @@ import { useEffect } from "react" -import { Loader2, X, ChevronRight, ExternalLink as ExternalLinkIcon } from "lucide-react" +import { Loader2, ChevronRight, ExternalLink as ExternalLinkIcon } from "lucide-react" import { useChangelog } from "@/hooks/use-changelog" import { Button } from "@/components/ui/button" import { openUrl } from "@tauri-apps/plugin-opener" @@ -238,7 +238,7 @@ export function ChangelogDialog({ currentVersion, onBack, onClose }: ChangelogDi
- +
{releases.length > 1 && ( From 7326d84aef336978ca3e31bd67f3c0dffd3d4083 Mon Sep 17 00:00:00 2001 From: HearSilent Date: Wed, 18 Mar 2026 12:54:08 +0800 Subject: [PATCH 05/13] fix: avoid nested buttons in changelog links --- src/components/changelog-dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/changelog-dialog.tsx b/src/components/changelog-dialog.tsx index 59c186f1..5792fc5e 100644 --- a/src/components/changelog-dialog.tsx +++ b/src/components/changelog-dialog.tsx @@ -88,7 +88,7 @@ function SimpleMarkdown({ content }: { content: string }) { onClick={() => openUrl(part.url!).catch(console.error)} className={linkClass} > - {renderText(part.content)} + {part.content} ); } From 0c249a38bbfb0d4841b346eba8439112bd43b46d Mon Sep 17 00:00:00 2001 From: HearSilent Date: Wed, 18 Mar 2026 12:56:33 +0800 Subject: [PATCH 06/13] fix: use UTC date for changelog release --- src/components/changelog-dialog.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/changelog-dialog.tsx b/src/components/changelog-dialog.tsx index 5792fc5e..37ed8481 100644 --- a/src/components/changelog-dialog.tsx +++ b/src/components/changelog-dialog.tsx @@ -223,9 +223,13 @@ export function ChangelogDialog({ currentVersion, onBack, onClose }: ChangelogDi

{currentRelease.name || currentRelease.tag_name}

- Released on {(() => { - const d = new Date(currentRelease.published_at); - return `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`; + Released on{" "} + {(() => { + const d = new Date(currentRelease.published_at) + const year = d.getUTCFullYear() + const month = String(d.getUTCMonth() + 1).padStart(2, "0") + const day = String(d.getUTCDate()).padStart(2, "0") + return `${year}/${month}/${day}` })()}

From 2e531636cd7527493d5dd6d32cb939d05697281a Mon Sep 17 00:00:00 2001 From: HearSilent Date: Wed, 18 Mar 2026 12:58:52 +0800 Subject: [PATCH 07/13] fix: harden changelog dialog for null fields --- src/components/changelog-dialog.test.tsx | 24 ++++++++++++++++++++++++ src/components/changelog-dialog.tsx | 17 +++++++++-------- src/hooks/use-changelog.ts | 6 +++--- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/components/changelog-dialog.test.tsx b/src/components/changelog-dialog.test.tsx index 1a480ee4..88e78f40 100644 --- a/src/components/changelog-dialog.test.tsx +++ b/src/components/changelog-dialog.test.tsx @@ -151,6 +151,30 @@ describe("ChangelogDialog", () => { expect(screen.getByText("v1.0.0")).toBeInTheDocument() }) + it("handles null published_at gracefully", () => { + changelogState.releases = [ + { + id: 1, + tag_name: "v1.0.1", + name: "v1.0.1", + body: "body", + published_at: null, + html_url: "https://github.com/robinebers/openusage/releases/tag/v1.0.1", + }, + ] + + render( + {}} + onClose={() => {}} + />, + ) + + expect(screen.getByText("v1.0.1")).toBeInTheDocument() + expect(screen.getByText("Unpublished release")).toBeInTheDocument() + }) + it("shows link to full changelog when multiple releases exist", async () => { changelogState.releases = [ { diff --git a/src/components/changelog-dialog.tsx b/src/components/changelog-dialog.tsx index 37ed8481..8c0d33a5 100644 --- a/src/components/changelog-dialog.tsx +++ b/src/components/changelog-dialog.tsx @@ -223,14 +223,15 @@ export function ChangelogDialog({ currentVersion, onBack, onClose }: ChangelogDi

{currentRelease.name || currentRelease.tag_name}

- Released on{" "} - {(() => { - const d = new Date(currentRelease.published_at) - const year = d.getUTCFullYear() - const month = String(d.getUTCMonth() + 1).padStart(2, "0") - const day = String(d.getUTCDate()).padStart(2, "0") - return `${year}/${month}/${day}` - })()} + {currentRelease.published_at + ? (() => { + const d = new Date(currentRelease.published_at) + const year = d.getUTCFullYear() + const month = String(d.getUTCMonth() + 1).padStart(2, "0") + const day = String(d.getUTCDate()).padStart(2, "0") + return `Released on ${year}/${month}/${day}` + })() + : "Unpublished release"}