feat(notices): bring the release notes back on every update

The release modal takes the 4.3.0 design: the release and its headline cards
on the left, the note and the support links on the right, at the design's own
size. The cards carry small drawings of the features instead of an icon.

The 4.0.0 entry is retired. In its place sits one entry, release-notes, that
every big release rewrites and keeps. It recurs per version with no upper
bound, so every user sees it once on each update, whatever they did with it
before, and a reload of the same version keeps it closed. An integration test
holds that promise through the real entry and the real dismiss route.

All 23 locales carry the new copy and drop the 4.0.0 strings.
This commit is contained in:
Maurice
2026-09-15 23:50:15 +02:00
parent 99783cc8da
commit 05ee3d50e4
34 changed files with 1562 additions and 1206 deletions
+51 -55
View File
@@ -31,92 +31,88 @@ export const RETIRED_NOTICE_IDS = [
'v3-mcp',
'v3-features',
'welcome-v1',
'release-4-0-0',
] as const;
export const SYSTEM_NOTICES: SystemNotice[] = [
// ── 4.0.0 release — what shipped, and a note from the maintainer ────────────
// Carries `release`, so it renders as the two-column release modal rather than
// the generic notice body. 4.0.0 was big enough that the whole 4.x line should
// carry it, so `recurring: 'per-version'` brings it back on each upgrade inside
// the window rather than the default one-time dismissal, which retired it for
// everyone who closed it once on 4.0.0. A 5.x release gets its own entry.
// ── Release notes: what the current release brought, and a note from the maintainer ──
// One entry for every release. Each big release swaps the copy (the release_notes
// keys) and the version below, and keeps this id. `recurring: 'per-version'` brings
// it back to every user on every upgrade, a patch release included, so nobody is
// ever left on a version with nothing to show. No upper bound for the same reason.
{
id: 'release-4-0-0',
id: 'release-notes',
recurring: 'per-version',
display: 'modal',
severity: 'info',
titleKey: 'system_notice.release_400.headline',
bodyKey: 'system_notice.release_400.intro',
titleKey: 'system_notice.release_notes.headline',
bodyKey: 'system_notice.release_notes.intro',
release: {
version: '4.0.0',
eyebrowKey: 'system_notice.release_400.eyebrow',
tagKey: 'system_notice.release_400.tag',
headlineKey: 'system_notice.release_400.headline',
introKey: 'system_notice.release_400.intro',
version: '4.3.0',
eyebrowKey: 'system_notice.release_notes.eyebrow',
headlineKey: 'system_notice.release_notes.headline',
introKey: 'system_notice.release_notes.intro',
featuresLabelKey: 'system_notice.release_notes.features_label',
featuresAsideKey: 'system_notice.release_notes.features_aside',
features: [
{
iconName: 'Smartphone',
titleKey: 'system_notice.release_400.feature_mobile_title',
bodyKey: 'system_notice.release_400.feature_mobile_body',
iconName: 'Database',
visual: 'places-api',
titleKey: 'system_notice.release_notes.feature_places_title',
bodyKey: 'system_notice.release_notes.feature_places_body',
},
{
iconName: 'BookOpen',
titleKey: 'system_notice.release_400.feature_studio_title',
bodyKey: 'system_notice.release_400.feature_studio_body',
badgeKey: 'system_notice.release_400.feature_studio_badge',
iconName: 'Route',
visual: 'roadtrip',
titleKey: 'system_notice.release_notes.feature_roadtrip_title',
bodyKey: 'system_notice.release_notes.feature_roadtrip_body',
},
{
iconName: 'CalendarDays',
titleKey: 'system_notice.release_400.feature_vacay_title',
bodyKey: 'system_notice.release_400.feature_vacay_body',
},
{
iconName: 'Image',
titleKey: 'system_notice.release_400.feature_places_title',
bodyKey: 'system_notice.release_400.feature_places_body',
iconName: 'MapPin',
visual: 'dawarich',
titleKey: 'system_notice.release_notes.feature_dawarich_title',
bodyKey: 'system_notice.release_notes.feature_dawarich_body',
},
],
// No stat row and no notes button here: the left column reads better short.
footnoteKey: 'system_notice.release_400.footnote',
note: {
eyebrowKey: 'system_notice.release_400.note_eyebrow',
titleKey: 'system_notice.release_400.note_title',
bodyKey: 'system_notice.release_400.note_body',
promiseLabelKey: 'system_notice.release_400.promise_label',
promiseTextKey: 'system_notice.release_400.promise_text',
bodyAfterKey: 'system_notice.release_400.note_body_after',
closingKey: 'system_notice.release_400.note_closing',
signatureKey: 'system_notice.release_400.note_signature',
footnoteKey: 'system_notice.release_notes.footnote',
notes: {
labelKey: 'system_notice.release_notes.notes_label',
href: 'https://github.com/liketrek/TREK/releases/tag/v4.3.0',
},
supportTextKey: 'system_notice.release_400.support_text',
note: {
eyebrowKey: 'system_notice.release_notes.note_eyebrow',
titleKey: 'system_notice.release_notes.note_title',
bodyKey: 'system_notice.release_notes.note_body',
promiseLabelKey: 'system_notice.release_notes.promise_label',
promiseLeadKey: 'system_notice.release_notes.promise_lead',
promiseTextKey: 'system_notice.release_notes.promise_text',
bodyAfterKey: 'system_notice.release_notes.note_body_after',
closingKey: 'system_notice.release_notes.note_closing',
},
supportLeadKey: 'system_notice.release_notes.support_lead',
supportTextKey: 'system_notice.release_notes.support_text',
},
cta: {
kind: 'link',
labelKey: 'system_notice.release_400.cta_bmc',
labelKey: 'system_notice.release_notes.cta_bmc',
href: 'https://buymeacoffee.com/mauriceboe',
},
secondaryCta: {
kind: 'link',
labelKey: 'system_notice.release_400.cta_kofi',
labelKey: 'system_notice.release_notes.cta_kofi',
href: 'https://ko-fi.com/mauriceboe',
},
dismissible: true,
// Desktop-only, like the thank-you modal it replaces: the two-column layout
// has no phone form, and the mobile release lands with its own onboarding.
// Desktop-only: the two-column layout has no phone form, and the phone has its
// own onboarding.
desktopOnly: true,
// Same reasoning as the thank-you notice below: it asks the reader to fund
// the project, and on a managed install they already pay whoever runs it.
// It asks the reader to fund the project, and on a managed install they already
// pay whoever runs it.
conditions: [{ kind: 'managed', is: false }],
publishedAt: '2026-08-22T00:00:00Z',
publishedAt: '2026-09-15T00:00:00Z',
priority: 110,
// The whole 4.x line. The copy is about what 4.0.0 brought, and that is still
// what somebody arriving anywhere on 4.x is being introduced to — the minor
// releases after it add to that picture rather than replace it. Held open to
// the 5.0.0 boundary (exclusive) so a 4.1/4.2 install is not left with no
// notice at all, which is what a per-release window did the moment 4.1.0
// shipped without an entry of its own.
// Where the thank-you notice below hands over; nothing on 3.x ships this copy.
minVersion: '4.0.0',
maxVersion: '5.0.0',
},
// ── Thank-you + support the project — shown once per install AND once per upgrade ──
+6 -10
View File
@@ -30,15 +30,10 @@ export type NoticeCta =
export interface NoticeReleaseFeature {
iconName: string;
/** A small illustration the client draws on the card; unknown names fall back to the icon. */
visual?: string;
titleKey: string;
bodyKey: string;
badgeKey?: string;
}
export interface NoticeReleaseStat {
/** The figure itself ("~150") — shown as-is, never translated. */
value: string;
labelKey: string;
}
/**
@@ -49,11 +44,11 @@ export interface NoticeReleaseStat {
export interface NoticeRelease {
version: string;
eyebrowKey: string;
tagKey: string;
headlineKey: string;
introKey: string;
featuresLabelKey: string;
featuresAsideKey?: string;
features: NoticeReleaseFeature[];
stats?: NoticeReleaseStat[];
notes?: { labelKey: string; href: string };
footnoteKey?: string;
note: {
@@ -61,11 +56,12 @@ export interface NoticeRelease {
titleKey: string;
bodyKey: string;
promiseLabelKey: string;
promiseLeadKey: string;
promiseTextKey: string;
bodyAfterKey: string;
closingKey: string;
signatureKey: string;
};
supportLeadKey: string;
supportTextKey: string;
}
+42 -1
View File
@@ -97,7 +97,7 @@ describe('GET /api/system-notices/active', () => {
// login_count > 1 means firstLogin does not match; first_seen_version >= 3.0.0 means
// existingUserBeforeVersion('3.0.0') does not match either. Notices that gate on
// nothing but the install being self-hosted still apply, and which ones those are
// changes every release — the thank-you modal handed over to release-4-0-0 at 4.0.0.
// changes every release (the thank-you modal handed over to the release notes at 4.0.0).
// Read the set out of the registry rather than naming them, or this ages out again.
testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id);
const alwaysOn = new Set(
@@ -226,6 +226,47 @@ describe('GET /api/system-notices/active', () => {
if (idx !== -1) SYSTEM_NOTICES.splice(idx, 1);
}
});
// The release notes carry the support links, so the promise is exact: once per
// update for every user, whatever they did with it before, and never again on a
// reload of the same version. Driven through the real registry entry and the real
// dismiss route, so neither half can drift away from the other unnoticed.
it('shows the release notes once per update, however often they were closed before', async () => {
const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id);
const shows = async () => {
const res = await request(app)
.get('/api/system-notices/active')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
return res.body.some((n: { id: string }) => n.id === 'release-notes');
};
const dismiss = async () => {
const res = await request(app)
.post('/api/system-notices/release-notes/dismiss')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(204);
};
expect(await shows()).toBe(true);
// Closed on this version: gone for every reload after it.
await dismiss();
expect(await shows()).toBe(false);
expect(await shows()).toBe(false);
// The next update brings it back, although it was closed before...
testDb.prepare(
'UPDATE user_notice_dismissals SET dismissed_app_version = ? WHERE user_id = ? AND notice_id = ?'
).run('4.0.0', user.id, 'release-notes');
expect(await shows()).toBe(true);
expect(await shows()).toBe(true);
// ...and closing it again holds until the one after that.
await dismiss();
expect(await shows()).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import semver from 'semver';
import { SYSTEM_NOTICES } from '../../../src/systemNotices/registry.js';
import { RETIRED_NOTICE_IDS, SYSTEM_NOTICES } from '../../../src/systemNotices/registry.js';
import { isNoticeVersionActive } from '../../../src/systemNotices/service.js';
/** Collect all actionIds registered via registerNoticeAction() in client source files. */
@@ -65,29 +65,28 @@ describe('registry integrity', () => {
}
});
it('the 4.0.0 release notice covers the whole 4.x line', () => {
const release = SYSTEM_NOTICES.find(n => n.id === 'release-4-0-0');
it('the release notes come back on every upgrade, with no upper bound', () => {
const release = SYSTEM_NOTICES.find(n => n.id === 'release-notes');
expect(release).toBeDefined();
// It must not greet somebody still on 3.x...
// Nothing on 3.x ships this copy, and the thank-you notice still covers it there.
expect(isNoticeVersionActive(release!, '3.4.1')).toBe(false);
expect(isNoticeVersionActive(release!, '4.0.0')).toBe(true);
expect(isNoticeVersionActive(release!, '4.0.7')).toBe(true);
expect(isNoticeVersionActive(release!, '4.0.12')).toBe(true);
// ...and it stays up across the minors, so no 4.x install is left without a
// notice the way 4.1.0 was under the old per-release window.
expect(isNoticeVersionActive(release!, '4.1.0')).toBe(true);
expect(isNoticeVersionActive(release!, '4.2.0')).toBe(true);
expect(isNoticeVersionActive(release!, '4.9.9')).toBe(true);
// The upper bound is exclusive: 5.0.0 gets its own notice.
expect(isNoticeVersionActive(release!, '5.0.0')).toBe(false);
// From there on every version carries it, a patch release included, so no
// install is left with nothing to show because nobody wrote a new window.
for (const version of ['4.0.0', '4.2.1', '4.3.0', '4.3.1', '4.9.9', '5.0.0', '12.4.0']) {
expect(isNoticeVersionActive(release!, version), version).toBe(true);
}
expect(release!.maxVersion).toBeUndefined();
// The window alone is not enough: a one-time dismissal would retire it for good
// the first time somebody closed it.
expect(release!.recurring).toBe('per-version');
});
it('the 4.0.0 release notice comes back on each upgrade inside that line', () => {
const release = SYSTEM_NOTICES.find(n => n.id === 'release-4-0-0')!;
// The version window above is only half of "covers the whole 4.x line". Without
// this, the default one-time dismissal retired the notice for everyone who
// closed it on 4.0.0, so none of them saw it again on 4.1, 4.2 or later.
expect(release.recurring).toBe('per-version');
it('the 4.0.0 release notice is retired and its id stays reserved', () => {
expect(SYSTEM_NOTICES.some(n => n.id === 'release-4-0-0')).toBe(false);
expect(RETIRED_NOTICE_IDS).toContain('release-4-0-0');
for (const id of RETIRED_NOTICE_IDS) {
expect(SYSTEM_NOTICES.some(n => n.id === id), id).toBe(false);
}
});
it('the thank-you notice hands over to the release modal at 4.0.0', () => {
@@ -98,10 +97,10 @@ describe('registry integrity', () => {
expect(isNoticeVersionActive(thankYou!, '3.4.1')).toBe(true);
expect(isNoticeVersionActive(thankYou!, '4.0.0')).toBe(false);
const release = SYSTEM_NOTICES.find(n => n.id === 'release-4-0-0')!;
for (const version of ['3.4.1', '4.0.0', '4.0.7', '4.1.0', '5.0.0']) {
const release = SYSTEM_NOTICES.find(n => n.id === 'release-notes')!;
for (const version of ['3.4.1', '4.0.0', '4.0.7', '4.1.0', '4.3.0', '5.0.0']) {
const active = [thankYou!, release].filter(n => isNoticeVersionActive(n, version));
expect(active.length, `both thank-you notices active at ${version}`).toBeLessThanOrEqual(1);
expect(active.length, `thank-you and release notes at ${version}`).toBe(1);
}
});
});