From c4ab4a9153ce3c101f4322d0560a5428e3a7f338 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Thu, 18 Jun 2026 03:36:10 +0000 Subject: [PATCH] feat: add in-app release notes viewer Introduces a new feature to fetch, parse, and display app release notes and update highlights directly within the UI. - Backend: Adds the `release-notes` module to fetch GitHub releases and parse markdown highlight sections. - Frontend: Adds the `WhatsNew` dialog and view, including custom markdown rendering and an image lightbox for update media. - Tools: Adds validation scripts (`whats-new-lint`, `whats-new-preview`) to ensure release content parses correctly before publication. --- client/src/App.vue | 26 +- client/src/components/AppHeader.vue | 76 +++-- client/src/components/AppSidebar.vue | 14 +- client/src/composables/useModal.ts | 76 +++++ .../src/features/auth/composables/useAuth.ts | 2 + .../components/NotificationPreferences.vue | 40 ++- .../src/features/whats-new/WhatsNewDialog.vue | 157 ++++++++++ .../src/features/whats-new/WhatsNewView.vue | 283 ++++++++++++++++++ .../whats-new/components/HighlightIcon.vue | 20 ++ .../whats-new/components/HighlightItem.vue | 93 ++++++ .../whats-new/components/ImageLightbox.vue | 92 ++++++ .../whats-new/composables/useWhatsNew.test.ts | 25 ++ .../whats-new/composables/useWhatsNew.ts | 154 ++++++++++ .../features/whats-new/lib/lightbox-state.ts | 13 + .../features/whats-new/lib/markdown.test.ts | 34 +++ client/src/features/whats-new/lib/markdown.ts | 118 ++++++++ .../whats-new/lib/whats-new.logic.test.ts | 64 ++++ .../features/whats-new/lib/whats-new.logic.ts | 58 ++++ client/src/router/index.ts | 6 + docs/RELEASE_PROCESS.md | 6 + docs/whats-new-authoring-guide.md | 128 ++++++++ docs/whats-new-template.md | 8 + packages/types/src/index.ts | 1 + packages/types/src/release-notes.ts | 39 +++ release.config.js | 42 ++- server/package.json | 4 + server/src/app.module.ts | 2 + server/src/config/config.test.ts | 6 + server/src/config/config.ts | 2 + server/src/config/env.validation.ts | 8 + .../dto/release-notes-query.dto.ts | 13 + .../release-notes/highlights.lint.test.ts | 87 ++++++ .../modules/release-notes/highlights.lint.ts | 75 +++++ .../release-notes/highlights.parser.test.ts | 207 +++++++++++++ .../release-notes/highlights.parser.ts | 186 ++++++++++++ .../release-notes/lucide-icon-names.ts | 11 + .../release-notes/release-notes.constants.ts | 26 ++ .../release-notes/release-notes.controller.ts | 22 ++ .../release-notes/release-notes.module.ts | 12 + .../release-notes.service.test.ts | 116 +++++++ .../release-notes/release-notes.service.ts | 254 ++++++++++++++++ .../user-preferences.controller.ts | 12 + .../user-preferences.service.test.ts | 26 ++ .../user-preferences.service.ts | 49 +++ server/src/scripts/README.md | 37 +++ server/src/scripts/whats-new-check.ts | 50 ++++ server/src/scripts/whats-new-images.ts | 133 ++++++++ server/src/scripts/whats-new-lint.ts | 92 ++++++ server/src/scripts/whats-new-preview.ts | 136 +++++++++ 49 files changed, 3110 insertions(+), 31 deletions(-) create mode 100644 client/src/composables/useModal.ts create mode 100644 client/src/features/whats-new/WhatsNewDialog.vue create mode 100644 client/src/features/whats-new/WhatsNewView.vue create mode 100644 client/src/features/whats-new/components/HighlightIcon.vue create mode 100644 client/src/features/whats-new/components/HighlightItem.vue create mode 100644 client/src/features/whats-new/components/ImageLightbox.vue create mode 100644 client/src/features/whats-new/composables/useWhatsNew.test.ts create mode 100644 client/src/features/whats-new/composables/useWhatsNew.ts create mode 100644 client/src/features/whats-new/lib/lightbox-state.ts create mode 100644 client/src/features/whats-new/lib/markdown.test.ts create mode 100644 client/src/features/whats-new/lib/markdown.ts create mode 100644 client/src/features/whats-new/lib/whats-new.logic.test.ts create mode 100644 client/src/features/whats-new/lib/whats-new.logic.ts create mode 100644 docs/whats-new-authoring-guide.md create mode 100644 docs/whats-new-template.md create mode 100644 packages/types/src/release-notes.ts create mode 100644 server/src/modules/release-notes/dto/release-notes-query.dto.ts create mode 100644 server/src/modules/release-notes/highlights.lint.test.ts create mode 100644 server/src/modules/release-notes/highlights.lint.ts create mode 100644 server/src/modules/release-notes/highlights.parser.test.ts create mode 100644 server/src/modules/release-notes/highlights.parser.ts create mode 100644 server/src/modules/release-notes/lucide-icon-names.ts create mode 100644 server/src/modules/release-notes/release-notes.constants.ts create mode 100644 server/src/modules/release-notes/release-notes.controller.ts create mode 100644 server/src/modules/release-notes/release-notes.module.ts create mode 100644 server/src/modules/release-notes/release-notes.service.test.ts create mode 100644 server/src/modules/release-notes/release-notes.service.ts create mode 100644 server/src/scripts/README.md create mode 100644 server/src/scripts/whats-new-check.ts create mode 100644 server/src/scripts/whats-new-images.ts create mode 100644 server/src/scripts/whats-new-lint.ts create mode 100644 server/src/scripts/whats-new-preview.ts diff --git a/client/src/App.vue b/client/src/App.vue index 38112bda3..93c1b9f15 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -1,16 +1,39 @@ + + diff --git a/client/src/features/whats-new/WhatsNewView.vue b/client/src/features/whats-new/WhatsNewView.vue new file mode 100644 index 000000000..eaffb7fbc --- /dev/null +++ b/client/src/features/whats-new/WhatsNewView.vue @@ -0,0 +1,283 @@ + + + + + diff --git a/client/src/features/whats-new/components/HighlightIcon.vue b/client/src/features/whats-new/components/HighlightIcon.vue new file mode 100644 index 000000000..5a5b618ab --- /dev/null +++ b/client/src/features/whats-new/components/HighlightIcon.vue @@ -0,0 +1,20 @@ + + + diff --git a/client/src/features/whats-new/components/HighlightItem.vue b/client/src/features/whats-new/components/HighlightItem.vue new file mode 100644 index 000000000..9680e1ec3 --- /dev/null +++ b/client/src/features/whats-new/components/HighlightItem.vue @@ -0,0 +1,93 @@ + + + diff --git a/client/src/features/whats-new/components/ImageLightbox.vue b/client/src/features/whats-new/components/ImageLightbox.vue new file mode 100644 index 000000000..c1c2bb853 --- /dev/null +++ b/client/src/features/whats-new/components/ImageLightbox.vue @@ -0,0 +1,92 @@ + + + diff --git a/client/src/features/whats-new/composables/useWhatsNew.test.ts b/client/src/features/whats-new/composables/useWhatsNew.test.ts new file mode 100644 index 000000000..83d641e53 --- /dev/null +++ b/client/src/features/whats-new/composables/useWhatsNew.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' + +import type { ReleaseNote } from '@bookorbit/types' + +import { resetWhatsNew, useWhatsNew } from './useWhatsNew' + +describe('resetWhatsNew', () => { + it('clears module-scope state so a user switch cannot inherit the previous user state', () => { + const { releases, popupEnabled, popupOpen, hasMore, error } = useWhatsNew() + + releases.value = [{ version: 'v1.2.0' } as ReleaseNote] + popupEnabled.value = false + popupOpen.value = true + hasMore.value = true + error.value = 'boom' + + resetWhatsNew() + + expect(releases.value).toEqual([]) + expect(popupEnabled.value).toBe(true) + expect(popupOpen.value).toBe(false) + expect(hasMore.value).toBe(false) + expect(error.value).toBeNull() + }) +}) diff --git a/client/src/features/whats-new/composables/useWhatsNew.ts b/client/src/features/whats-new/composables/useWhatsNew.ts new file mode 100644 index 000000000..eb34ac7ae --- /dev/null +++ b/client/src/features/whats-new/composables/useWhatsNew.ts @@ -0,0 +1,154 @@ +import { computed, ref } from 'vue' +import type { ReleaseNote, ReleaseNotesResponse, WhatsNewPreferences } from '@bookorbit/types' +import { api } from '@/lib/api' +import { useAppInfo } from '@/features/settings/composables/useAppInfo' +import { isNewer, isSemverTag, shouldShowPopup } from '../lib/whats-new.logic' + +const lastSeenVersion = ref(null) +const popupEnabled = ref(true) +const releases = ref([]) +const hasMore = ref(false) +const popupOpen = ref(false) +const loading = ref(false) +const error = ref(null) + +let prefsLoaded = false +let evaluated = false +let evaluating = false +let dismissedThisSession = false + +/** + * Clears the module-scope singleton so a user switch in the same SPA session can't inherit the + * previous user's seen-state. Call from auth teardown (see clearAuth in useAuth). + */ +export function resetWhatsNew(): void { + lastSeenVersion.value = null + popupEnabled.value = true + releases.value = [] + hasMore.value = false + popupOpen.value = false + loading.value = false + error.value = null + prefsLoaded = false + evaluated = false + evaluating = false + dismissedThisSession = false +} + +export function useWhatsNew() { + const { version, loadAppInfo } = useAppInfo() + + const hasUnseen = computed(() => releases.value.length > 0) + + async function loadPrefs(): Promise { + if (prefsLoaded) return + const res = await api('/api/v1/user-preferences/whats-new') + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data = (await res.json()) as { settings: WhatsNewPreferences } + lastSeenVersion.value = data.settings.lastSeenVersion + popupEnabled.value = data.settings.popupEnabled + prefsLoaded = true + } + + async function persist(settings: Partial): Promise { + return api('/api/v1/user-preferences/whats-new', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ settings }), + }) + } + + async function saveLastSeen(value: string): Promise { + lastSeenVersion.value = value + releases.value = [] + hasMore.value = false + await persist({ lastSeenVersion: value }).catch(() => {}) + } + + async function evaluate(): Promise { + if (evaluated || evaluating) return + evaluating = true + loading.value = true + error.value = null + try { + await loadAppInfo() + await loadPrefs() + + const current = version.value + if (!isSemverTag(current)) return + + if (lastSeenVersion.value === null) { + await saveLastSeen(current) + return + } + + if (!isNewer(current, lastSeenVersion.value)) return + + const res = await api(`/api/v1/release-notes?since=${encodeURIComponent(lastSeenVersion.value)}`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data = (await res.json()) as ReleaseNotesResponse + releases.value = data.releases + hasMore.value = data.hasMore + evaluated = true + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to load release notes' + } finally { + evaluating = false + loading.value = false + } + } + + function syncPopup(routeName: string | null | undefined): void { + if ( + shouldShowPopup({ hasUnseen: hasUnseen.value, popupEnabled: popupEnabled.value, dismissedThisSession, routeName, alreadyOpen: popupOpen.value }) + ) { + popupOpen.value = true + } + } + + async function acknowledge(): Promise { + popupOpen.value = false + if (isSemverTag(version.value)) await saveLastSeen(version.value) + } + + function remindLater(): void { + dismissedThisSession = true + popupOpen.value = false + } + + async function markArchiveSeen(): Promise { + await loadAppInfo() + await loadPrefs().catch(() => {}) + const current = version.value + if (isSemverTag(current) && (lastSeenVersion.value === null || isNewer(current, lastSeenVersion.value))) { + await saveLastSeen(current) + } + } + + async function setPopupEnabled(enabled: boolean): Promise { + popupEnabled.value = enabled + const res = await persist({ popupEnabled: enabled }) + if (!res.ok) { + popupEnabled.value = !enabled + throw new Error(`HTTP ${res.status}`) + } + } + + return { + version, + releases, + hasMore, + hasUnseen, + popupOpen, + popupEnabled, + loading, + error, + evaluate, + syncPopup, + acknowledge, + remindLater, + markArchiveSeen, + setPopupEnabled, + loadPrefs, + } +} diff --git a/client/src/features/whats-new/lib/lightbox-state.ts b/client/src/features/whats-new/lib/lightbox-state.ts new file mode 100644 index 000000000..b0fa9ca34 --- /dev/null +++ b/client/src/features/whats-new/lib/lightbox-state.ts @@ -0,0 +1,13 @@ +import { computed, ref } from 'vue' + +const openCount = ref(0) + +/** Returns an unregister fn; lets the popup know not to also close on Escape. */ +export function registerLightbox(): () => void { + openCount.value++ + return () => { + openCount.value = Math.max(0, openCount.value - 1) + } +} + +export const anyLightboxOpen = computed(() => openCount.value > 0) diff --git a/client/src/features/whats-new/lib/markdown.test.ts b/client/src/features/whats-new/lib/markdown.test.ts new file mode 100644 index 000000000..5f2735979 --- /dev/null +++ b/client/src/features/whats-new/lib/markdown.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' + +import { renderChangelogMarkdown } from './markdown' + +describe('renderChangelogMarkdown', () => { + it('renders headings, lists, bold, code and links', () => { + const md = ['### Features', '- add **two-way** sync', '- speed up `jumpRail`', '', '[issue](https://example.com/1)'].join('\n') + const html = renderChangelogMarkdown(md) + expect(html).toContain('

Features

') + expect(html).toContain('