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<
+ {t('admin.updates.newFeatures', 'New features include:')} +
+