From 500cf8522e556575bd74d4c71d38a83fb2596b5e Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 30 Jun 2026 02:12:35 +0200 Subject: [PATCH] feat(updates): "What's New" highlights after update + pre-update teaser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces release highlights to admins, sourced from the GitHub release notes (no AI at runtime). Bullets are written once per release in CI via GitHub Models (see docs/ci/whatsnew-highlights.yml) into a block; the app reads that block and falls back to the changelog's "### Features" for releases without it — so it works against today's releases immediately. - backend utils/whatsNew.parseWhatsNew(body): curated block else Features section, strips scope/PR-links, de-dups, caps at 8 (tested). - GET /admin/system/updates/whatsnew: highlights for every version moved through since the per-instance marker (whatsnew_last_seen_version); fresh installs self-anchor silently. Best-effort, never errors. - POST /admin/system/updates/whatsnew/seen: advance the marker (per-instance). - /admin/system/updates also returns latestHighlights for the teaser. - Frontend: WhatsNewBanner (green bar -> modal with "Full changelog" link) on the dashboard via adminService; UpdateNotification shows a "New features include:" teaser. i18n de/en. No migration (uses app_settings). --- backend/__tests__/utils/whatsNew.test.js | 54 ++++++++ backend/src/routes/adminSystem.js | 80 ++++++++++- backend/src/utils/whatsNew.js | 68 +++++++++ docs/ci/whatsnew-highlights.yml | 76 ++++++++++ .../components/admin/UpdateNotification.tsx | 12 ++ .../src/components/admin/WhatsNewBanner.tsx | 130 ++++++++++++++++++ frontend/src/i18n/locales/de.json | 8 ++ frontend/src/i18n/locales/en.json | 8 ++ frontend/src/pages/admin/AdminDashboard.tsx | 3 + frontend/src/services/admin.service.ts | 26 ++++ 10 files changed, 463 insertions(+), 2 deletions(-) create mode 100644 backend/__tests__/utils/whatsNew.test.js create mode 100644 backend/src/utils/whatsNew.js create mode 100644 docs/ci/whatsnew-highlights.yml create mode 100644 frontend/src/components/admin/WhatsNewBanner.tsx diff --git a/backend/__tests__/utils/whatsNew.test.js b/backend/__tests__/utils/whatsNew.test.js new file mode 100644 index 00000000..6e91a61a --- /dev/null +++ b/backend/__tests__/utils/whatsNew.test.js @@ -0,0 +1,54 @@ +const { parseWhatsNew } = require('../../src/utils/whatsNew'); + +describe('parseWhatsNew', () => { + it('prefers the curated block', () => { + const body = [ + '', + '- Invoice drafts in list', + '- Bank transfer payments', + '', + '', + '### Features', + '* **invoices:** something long that should be ignored ([#1](http://x))', + ].join('\n'); + expect(parseWhatsNew(body)).toEqual(['Invoice drafts in list', 'Bank transfer payments']); + }); + + it('falls back to the Features section, stripping scope + commit links', () => { + const body = [ + '## [3.73.0-beta.0](http://x) (2026-06-29)', + '', + '### Features', + '', + '* **dashboard:** revenue tile toggles 365 days ([d1c9e02](http://c))', + '* **invoices:** surface monthly drafts in the Bills list ([e457656](http://c))', + '', + '### Bug Fixes', + '', + '* **invoices:** add bank transfer ([e96ef4c](http://c))', + ].join('\n'); + expect(parseWhatsNew(body)).toEqual([ + 'revenue tile toggles 365 days', + 'surface monthly drafts in the Bills list', + ]); + }); + + it('excludes Bug Fixes from the fallback', () => { + const body = '### Features\n* **a:** feature one\n### Bug Fixes\n* **b:** fix one'; + expect(parseWhatsNew(body)).toEqual(['feature one']); + }); + + it('caps at 8 bullets and de-dups', () => { + const lines = Array.from({ length: 12 }, (_, i) => `- bullet ${i % 9}`); + const body = `\n${lines.join('\n')}\n`; + const out = parseWhatsNew(body); + expect(out.length).toBe(8); + expect(new Set(out).size).toBe(8); + }); + + it('returns [] for empty / non-string input', () => { + expect(parseWhatsNew('')).toEqual([]); + expect(parseWhatsNew(null)).toEqual([]); + expect(parseWhatsNew(undefined)).toEqual([]); + }); +}); diff --git a/backend/src/routes/adminSystem.js b/backend/src/routes/adminSystem.js index aeb6fb1f..95aa9f26 100644 --- a/backend/src/routes/adminSystem.js +++ b/backend/src/routes/adminSystem.js @@ -7,7 +7,9 @@ const path = require('path'); const os = require('os'); const { formatBoolean } = require('../utils/dbCompat'); const logger = require('../utils/logger'); -const { checkForUpdates, getCurrentChannel, getReleasesSince } = require('../services/updateCheckService'); +const { checkForUpdates, getCurrentChannel, getCurrentVersion, getReleasesSince, compareVersions } = require('../services/updateCheckService'); +const { getAppSetting, upsertAppSetting } = require('../utils/appSettings'); +const { parseWhatsNew } = require('../utils/whatsNew'); const { detectEnvironment, generateUpdateInstructions } = require('../services/environmentService'); const { checkAndNotifyUpdates, @@ -61,9 +63,20 @@ router.get('/updates', adminAuth, requirePermission('settings.view'), async (req const forceRefresh = req.query.refresh === 'true'; const updateInfo = await checkForUpdates(forceRefresh); + // Pre-update teaser: the target version's top highlights, so the + // "Update Available" banner can show "New features include …". + let latestHighlights = []; + if (updateInfo.updateAvailable) { + try { + const newer = await getReleasesSince(updateInfo.current, updateInfo.channel); + if (newer[0]) latestHighlights = parseWhatsNew(newer[0].body); + } catch (_) { /* teaser is best-effort */ } + } + res.json({ enabled: true, - ...updateInfo + ...updateInfo, + latestHighlights }); } catch (error) { logger.error('Error checking for updates:', error); @@ -71,6 +84,69 @@ router.get('/updates', adminAuth, requirePermission('settings.view'), async (req } }); +// What's New — after-update highlights. Returns the curated bullets for +// every release the instance moved THROUGH since it last acknowledged one +// (lastSeen < version <= running). Seen-tracking is per-INSTANCE: the first +// admin to dismiss clears it for everyone (a single app_settings row). A +// brand-new install initialises the marker silently so it never pops +// "what's new" with nothing to compare against. Best-effort: any failure +// (GitHub unreachable, etc.) returns hasNews:false, never errors. +router.get('/updates/whatsnew', adminAuth, requirePermission('settings.view'), async (req, res) => { + try { + if (process.env.UPDATE_CHECK_ENABLED === 'false') { + return res.json({ enabled: false, hasNews: false }); + } + const running = await getCurrentVersion(); + const channel = getCurrentChannel(running); + const lastSeen = await getAppSetting('whatsnew_last_seen_version', null); + + if (!lastSeen) { + await upsertAppSetting('whatsnew_last_seen_version', JSON.stringify(running), 'system'); + return res.json({ enabled: true, hasNews: false, running }); + } + if (compareVersions(running, lastSeen) <= 0) { + return res.json({ enabled: true, hasNews: false, running }); + } + + // Releases in (lastSeen, running], newest-first, with their highlights. + const releases = (await getReleasesSince(lastSeen, channel)) + .filter((r) => compareVersions(r.version, running) <= 0); + const versions = releases + .map((r) => ({ + version: r.version, + name: r.name, + publishedAt: r.publishedAt, + htmlUrl: r.htmlUrl, + bullets: parseWhatsNew(r.body), + })) + .filter((v) => v.bullets.length > 0); + + return res.json({ + enabled: true, + hasNews: versions.length > 0, + fromVersion: lastSeen, + toVersion: running, + versions, + }); + } catch (error) { + logger.error('Error building what\'s-new:', error); + res.json({ enabled: true, hasNews: false }); + } +}); + +// Acknowledge the What's New — advance the per-instance marker to the +// running version so it stops showing for every admin. +router.post('/updates/whatsnew/seen', adminAuth, requirePermission('settings.view'), async (req, res) => { + try { + const running = await getCurrentVersion(); + await upsertAppSetting('whatsnew_last_seen_version', JSON.stringify(running), 'system'); + res.json({ ok: true, lastSeen: running }); + } catch (error) { + logger.error('Error marking what\'s-new seen:', error); + res.status(500).json({ error: 'Failed to update marker' }); + } +}); + // Aggregated changelog — every release between current and latest in // the user's channel. Powers the update-available modal (#567) so the // admin can read release notes for ALL versions they're behind on, not diff --git a/backend/src/utils/whatsNew.js b/backend/src/utils/whatsNew.js new file mode 100644 index 00000000..cf16b34e --- /dev/null +++ b/backend/src/utils/whatsNew.js @@ -0,0 +1,68 @@ +'use strict'; + +/** + * Extract user-facing "What's New" bullets from a GitHub release body. + * + * Prefers the curated block the release CI injects via GitHub Models: + * \n- bullet\n- bullet\n + * and falls back to the release-please "### Features" section for releases + * created before that CI step existed — so the feature works against + * today's releases too (just with longer, auto-cleaned lines). + * + * Returns at most MAX_BULLETS cleaned strings. + */ + +const MAX_BULLETS = 8; + +/** Strip list markers, conventional-commit scope, and trailing PR/sha links. */ +function cleanBullet(line) { + return line + .replace(/^\s*[-*]\s+/, '') // "- " / "* " marker + .replace(/^\*\*([^:*]+):\*\*\s*/, '') // "**scope:** " prefix + .replace(/\s*\(\[[^\]]*\]\([^)]*\)\)/g, '') // " ([#41](url))" / " ([sha](url))" + .replace(/\s*\(#\d+\)/g, '') // bare " (#41)" + .replace(/`/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +/** Pull the lines under a "## Features" / "### Features" heading. */ +function extractFeaturesSection(body) { + const out = []; + let inFeatures = false; + for (const raw of body.split('\n')) { + const line = raw.trimEnd(); + if (/^#{2,4}\s+Features\b/i.test(line)) { inFeatures = true; continue; } + if (inFeatures && /^#{1,4}\s+\S/.test(line)) break; // next heading ends the section + if (inFeatures) out.push(line); + } + return out.join('\n'); +} + +/** + * @param {string} body release notes markdown + * @returns {string[]} up to 8 user-facing bullet strings + */ +function parseWhatsNew(body) { + if (!body || typeof body !== 'string') return []; + const block = body.match(/([\s\S]*?)/i); + const src = block ? block[1] : extractFeaturesSection(body); + const bullets = src + .split('\n') + .map((l) => l.trim()) + .filter((l) => /^[-*]\s+/.test(l)) + .map(cleanBullet) + .filter((l) => l.length > 0); + // De-dup while preserving order, then cap. + const seen = new Set(); + const unique = []; + for (const b of bullets) { + const key = b.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + unique.push(b); + } + return unique.slice(0, MAX_BULLETS); +} + +module.exports = { parseWhatsNew }; diff --git a/docs/ci/whatsnew-highlights.yml b/docs/ci/whatsnew-highlights.yml new file mode 100644 index 00000000..c08c41c5 --- /dev/null +++ b/docs/ci/whatsnew-highlights.yml @@ -0,0 +1,76 @@ +# What's New highlights — GitHub Models release step +# +# DRAFT — move this into `.github/workflows/` on the repo that publishes the +# canonical releases the app reads (PicPeak/picpeak). It is kept under docs/ +# here so it does NOT auto-run on the fork. +# +# What it does: when a release is published (release-please), it condenses that +# release's "### Features" into <=8 short bullets via GitHub Models (free tier, +# `models: read`) and injects a `` block at the top of the +# release notes. The picpeak app reads that block (utils/whatsNew.parseWhatsNew), +# and falls back to the raw Features list for releases without it — so this is +# purely a quality upgrade, never a hard dependency. +# +# Validated end-to-end on a fork (extract -> openai/gpt-4o-mini -> inject into +# real release notes; app parseWhatsNew() reads the block back). The one thing +# that can't be checked from a fork: GitHub Models must be enabled for the +# PicPeak ORG (owner setting). Failure is isolated anyway by `continue-on-error` +# + the deterministic fallback below, so it can never break a release. + +name: What's New highlights + +on: + release: + types: [published] + +permissions: + contents: write # to edit the release body + models: read # GitHub Models (free tier) + +jobs: + highlights: + runs-on: ubuntu-latest + steps: + - name: Extract Features from the published release + id: feat + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.event.release.tag_name }} + run: | + BODY=$(gh release view "$TAG" --json body -q .body) + FEATURES=$(printf '%s\n' "$BODY" | awk '/^#{2,4} +Features/{f=1;next} /^#{1,4} +\S/{f=0} f') + { echo "features<> "$GITHUB_OUTPUT" + + - name: Summarize with GitHub Models + if: ${{ steps.feat.outputs.features != '' }} + id: ai + uses: actions/ai-inference@v1 + with: + model: openai/gpt-4o-mini # catalog id (verified present); openai/gpt-4.1-mini or openai/gpt-5-nano also work + system-prompt: > + You write release highlights for the admins of a self-hosted + photo-gallery + CRM app. Given raw changelog "Features" lines, output + AT MOST 8 markdown bullets, each 3-4 words, user-facing, no scopes, + no jargon, no issue numbers. One bullet per distinct user-visible + feature. Output ONLY "- " bullets, nothing else. + prompt: ${{ steps.feat.outputs.features }} + + - name: Inject the What's New block + if: ${{ steps.feat.outputs.features != '' }} + continue-on-error: true # never let highlights break a release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.event.release.tag_name }} + AI: ${{ steps.ai.outputs.response }} + FEATURES: ${{ steps.feat.outputs.features }} + run: | + BULLETS="$AI" + # Deterministic fallback if the model returned nothing. + if [ -z "$BULLETS" ]; then + BULLETS=$(printf '%s\n' "$FEATURES" | head -8 \ + | sed -E 's/^\* \*\*[^:]+:\*\* */- /; s/ \(\[[^]]*\]\([^)]*\)\)//g') + fi + BODY=$(gh release view "$TAG" --json body -q .body) + # Idempotent: strip any prior block before re-injecting. + BODY=$(printf '%s' "$BODY" | perl -0pe 's/.*?\n*//is') + gh release edit "$TAG" --notes "$(printf '\n%s\n\n\n%s' "$BULLETS" "$BODY")" diff --git a/frontend/src/components/admin/UpdateNotification.tsx b/frontend/src/components/admin/UpdateNotification.tsx index 45c8c9b2..248c3eb5 100644 --- a/frontend/src/components/admin/UpdateNotification.tsx +++ b/frontend/src/components/admin/UpdateNotification.tsx @@ -19,6 +19,8 @@ interface UpdateInfo { lastChecked: string; error?: string; message?: string; + /** Top highlights of the target version (pre-update teaser). */ + latestHighlights?: string[]; } async function fetchUpdateInfo(): Promise { @@ -81,6 +83,16 @@ export const UpdateNotification: React.FC = ({ onDismis channel: channelLabel })}

+ {Array.isArray(updateInfo.latestHighlights) && updateInfo.latestHighlights.length > 0 && ( +
+

+ {t('admin.updates.newFeatures', 'New features include:')} +

+
    + {updateInfo.latestHighlights.slice(0, 4).map((h, i) =>
  • {h}
  • )} +
+
+ )}
+
+ + + + + + + {open && ( +
setOpen(false)} + > +
e.stopPropagation()} + > +
+

+ + {t('admin.whatsnew.modalTitle', "What's new")} +

+ +
+
+ {data.versions.map((v) => ( +
+ +
    + {v.bullets.map((b, i) =>
  • {b}
  • )} +
+
+ ))} +
+
+ +
+
+
+ )} + + ); +}; diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index b369a272..3d5890bd 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -2295,10 +2295,18 @@ "warning": "Warnung", "error": "Fehler" }, + "whatsnew": { + "title": "Neu in {{version}}", + "viewAll": "Was ist neu", + "modalTitle": "Was ist neu", + "fullChangelog": "Vollständiges Änderungsprotokoll", + "gotIt": "Verstanden" + }, "updates": { "available": "Update verfügbar", "newVersion": "Version {{version}} ist verfügbar", "currentVersion": "Aktuell: {{version}}", + "newFeatures": "Neue Funktionen:", "channel": "Kanal: {{channel}}", "channelStable": "Stabil", "channelBeta": "Beta", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index a36136bf..f5af8100 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1880,10 +1880,18 @@ "error": "Error", "checking": "Checking..." }, + "whatsnew": { + "title": "What's new in {{version}}", + "viewAll": "What's new", + "modalTitle": "What's new", + "fullChangelog": "Full changelog", + "gotIt": "Got it" + }, "updates": { "available": "Update Available", "newVersion": "Version {{version}} is available", "currentVersion": "Current: {{version}}", + "newFeatures": "New features include:", "channel": "Channel: {{channel}}", "channelStable": "Stable", "channelBeta": "Beta", diff --git a/frontend/src/pages/admin/AdminDashboard.tsx b/frontend/src/pages/admin/AdminDashboard.tsx index d03aca71..49def4ab 100644 --- a/frontend/src/pages/admin/AdminDashboard.tsx +++ b/frontend/src/pages/admin/AdminDashboard.tsx @@ -22,6 +22,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { Button, Card, Loading } from '../../components/common'; import { UpdateNotification } from '../../components/admin/UpdateNotification'; +import { WhatsNewBanner } from '../../components/admin/WhatsNewBanner'; import { CrmOverviewSection } from '../../components/admin/CrmOverviewSection'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { eventsService } from '../../services/events.service'; @@ -184,6 +185,8 @@ export const AdminDashboard: React.FC = () => { return (
+ {/* After-update "What's New" highlights (above the update banner) */} + {/* Update Notification */} diff --git a/frontend/src/services/admin.service.ts b/frontend/src/services/admin.service.ts index b3f9b5f7..d2dfcfc6 100644 --- a/frontend/src/services/admin.service.ts +++ b/frontend/src/services/admin.service.ts @@ -341,6 +341,22 @@ export interface AnalyticsData { devicesSource?: 'umami' | 'access_logs'; } +export interface WhatsNewVersion { + version: string; + name: string; + htmlUrl: string; + publishedAt: string; + bullets: string[]; +} + +export interface WhatsNewResponse { + enabled: boolean; + hasNews: boolean; + fromVersion?: string; + toVersion?: string; + versions?: WhatsNewVersion[]; +} + export const adminService = { // Dashboard statistics async getDashboardStats(): Promise { @@ -370,6 +386,16 @@ export const adminService = { return response.data; }, + // After-update "What's New" highlights (per-instance acknowledgement). + async getWhatsNew(): Promise { + const response = await api.get('/admin/system/updates/whatsnew'); + return response.data; + }, + + async markWhatsNewSeen(): Promise { + await api.post('/admin/system/updates/whatsnew/seen'); + }, + // Backup-integrity verifier (read-only diagnostic). `scope` filters // which document classes to walk; omit for a full scan. async getBackupIntegrity(