Replace the 3.0 upgrade notices with a thank-you / support modal

The 3.0 "what's new" notices have served their purpose, so swap them for a single thank-you notice that comes back once on every fresh install and version bump. It carries Buy Me a Coffee and Ko-fi buttons and only shows on desktop. Adds a per-version recurring mode (new dismissed_app_version column) plus external-link CTAs to support it; the 3.0.14 whitespace-collision admin notice stays active.
This commit is contained in:
Maurice
2026-06-27 19:18:01 +02:00
committed by Maurice
parent aa91f009ad
commit f0877a2e7d
30 changed files with 420 additions and 207 deletions
+9
View File
@@ -3062,6 +3062,15 @@ function runMigrations(db: Database.Database): void {
if (!err.message?.includes('duplicate column name')) throw err;
}
},
// Remember the app version a notice was dismissed at, so per-version recurring
// notices (e.g. the thank-you) re-appear on the next install/upgrade.
() => {
try {
db.exec('ALTER TABLE user_notice_dismissals ADD COLUMN dismissed_app_version TEXT');
} catch (err: any) {
if (!err.message?.includes('duplicate column name')) throw err;
}
},
];
if (currentVersion < migrations.length) {
+45 -133
View File
@@ -11,128 +11,65 @@ registerPredicate('whitespace-collision-detected', () => {
* SYSTEM NOTICE REGISTRY
*
* Rules for authoring:
* - NEVER remove or renumber entries — dismissal tracking is keyed by `id`.
* - NEVER reuse a retired `id` — dismissal tracking is keyed by `id`. Retired ids are
* listed in RETIRED_NOTICE_IDS so they're never accidentally re-used.
* - `id` must be globally unique and stable across deployments.
* - Title: ≤40 chars, sentence case, no trailing punctuation.
* - Body: markdown (modal) or plain text (banner/toast). ≤400/140/80 chars.
* - CTA label: ≤20 chars, a verb.
* - CTA label: ≤20 chars.
* - Never hardcode version numbers/dates in translated strings — use bodyParams.
* - See plans/system-notices/00-overview.md for full authoring guidelines.
*/
/**
* Retired notices. Kept out of the active list but their ids stay reserved so a future
* notice never reuses one (dismissals are keyed by id). Do not re-add these ids.
*/
export const RETIRED_NOTICE_IDS = [
'v3-thankyou',
'v3-photos',
'v3-journey',
'v3-mcp',
'v3-features',
'welcome-v1',
] as const;
export const SYSTEM_NOTICES: SystemNotice[] = [
// ── 3.0.0 upgrade notices (shown as a multipage modal to pre-3.0 users) ─────
// ── Thank-you + support the project — shown once per install AND once per upgrade ──
// `recurring: 'per-version'` re-surfaces it whenever the app version moves up.
{
// Page 1 — breaking change first (warn → sorts before the two info notices)
id: 'v3-photos',
display: 'modal',
severity: 'warn',
icon: 'ImageOff',
titleKey: 'system_notice.v3_photos.title',
bodyKey: 'system_notice.v3_photos.body',
dismissible: true,
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
publishedAt: '2026-04-16T00:00:00Z',
priority: 90,
minVersion: '3.0.0',
maxVersion: '4.0.0',
},
{
// Page 2 — flagship feature (only when Journey addon is enabled)
id: 'v3-journey',
display: 'modal',
severity: 'info',
icon: 'BookOpen',
titleKey: 'system_notice.v3_journey.title',
bodyKey: 'system_notice.v3_journey.body',
highlights: [
{ labelKey: 'system_notice.v3_journey.highlight_timeline', iconName: 'CalendarDays' },
{ labelKey: 'system_notice.v3_journey.highlight_photos', iconName: 'Images' },
{ labelKey: 'system_notice.v3_journey.highlight_share', iconName: 'Globe' },
{ labelKey: 'system_notice.v3_journey.highlight_export', iconName: 'FileText' },
],
cta: {
kind: 'nav',
labelKey: 'system_notice.v3_journey.cta_label',
href: '/journey',
},
dismissible: true,
conditions: [
{ kind: 'existingUserBeforeVersion', version: '3.0.0' },
{ kind: 'addonEnabled', addonId: 'journey' },
],
publishedAt: '2026-04-16T00:00:00Z',
priority: 80,
minVersion: '3.0.0',
maxVersion: '4.0.0',
},
{
// Page 3 — MCP OAuth 2.1 upgrade (only when MCP addon is enabled)
id: 'v3-mcp',
display: 'modal',
severity: 'warn',
icon: 'Bot',
titleKey: 'system_notice.v3_mcp.title',
bodyKey: 'system_notice.v3_mcp.body',
highlights: [
{ labelKey: 'system_notice.v3_mcp.highlight_oauth', iconName: 'KeyRound' },
{ labelKey: 'system_notice.v3_mcp.highlight_scopes', iconName: 'ShieldCheck' },
{ labelKey: 'system_notice.v3_mcp.highlight_deprecated', iconName: 'AlertTriangle' },
{ labelKey: 'system_notice.v3_mcp.highlight_tools', iconName: 'Wrench' },
],
dismissible: true,
conditions: [
{ kind: 'existingUserBeforeVersion', version: '3.0.0' },
{ kind: 'addonEnabled', addonId: 'mcp' },
],
publishedAt: '2026-04-16T00:00:00Z',
priority: 75,
minVersion: '3.0.0',
maxVersion: '4.0.0',
},
{
// Page 4 — other highlights
id: 'v3-features',
display: 'modal',
severity: 'info',
icon: 'Sparkles',
titleKey: 'system_notice.v3_features.title',
bodyKey: 'system_notice.v3_features.body',
highlights: [
{ labelKey: 'system_notice.v3_features.highlight_dashboard', iconName: 'LayoutDashboard' },
{ labelKey: 'system_notice.v3_features.highlight_offline', iconName: 'WifiOff' },
{ labelKey: 'system_notice.v3_features.highlight_search', iconName: 'Search' },
{ labelKey: 'system_notice.v3_features.highlight_import', iconName: 'FileInput' },
],
dismissible: true,
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
publishedAt: '2026-04-16T00:00:00Z',
priority: 70,
minVersion: '3.0.0',
maxVersion: '4.0.0',
},
{
// Page 1 — personal thank-you from the creator (shown first)
id: 'v3-thankyou',
id: 'thank-you-support',
display: 'modal',
severity: 'info',
icon: 'Heart',
titleKey: 'system_notice.v3_thankyou.title',
bodyKey: 'system_notice.v3_thankyou.body',
titleKey: 'system_notice.thank_you_support.title',
bodyKey: 'system_notice.thank_you_support.body',
highlights: [
{ labelKey: 'system_notice.thank_you_support.highlight_opensource', iconName: 'Github' },
{ labelKey: 'system_notice.thank_you_support.highlight_free', iconName: 'Infinity' },
{ labelKey: 'system_notice.thank_you_support.highlight_community', iconName: 'Users' },
],
cta: {
kind: 'link',
labelKey: 'system_notice.thank_you_support.cta_bmc',
href: 'https://buymeacoffee.com/mauriceboe',
},
secondaryCta: {
kind: 'link',
labelKey: 'system_notice.thank_you_support.cta_kofi',
href: 'https://ko-fi.com/mauriceboe',
},
dismissible: true,
conditions: [{ kind: 'existingUserBeforeVersion', version: '3.0.0' }],
publishedAt: '2026-04-16T00:00:00Z',
priority: 95,
minVersion: '3.0.0',
maxVersion: '4.0.0',
// Desktop-only: the support modal is suppressed on small/mobile viewports.
desktopOnly: true,
conditions: [],
publishedAt: '2026-06-27T00:00:00Z',
priority: 100,
recurring: 'per-version',
},
// ── 3.0.14 admin notice — whitespace migration collision ───────────────────
// Operational alert (not promo): shown only to admins who upgraded across the
// 3.0.14 boundary AND only when the migration actually renamed colliding accounts.
{
id: 'v3014-whitespace-collision',
display: 'banner',
@@ -150,29 +87,4 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
priority: 85,
minVersion: '3.0.14',
},
// ── Onboarding ─────────────────────────────────────────────────────────────
{
id: 'welcome-v1',
display: 'modal',
severity: 'info',
icon: 'Sparkles',
titleKey: 'system_notice.welcome_v1.title',
bodyKey: 'system_notice.welcome_v1.body',
highlights: [
{ labelKey: 'system_notice.welcome_v1.highlight_plan', iconName: 'Map' },
{ labelKey: 'system_notice.welcome_v1.highlight_share', iconName: 'Users' },
{ labelKey: 'system_notice.welcome_v1.highlight_offline', iconName: 'WifiOff' },
],
cta: {
kind: 'action',
labelKey: 'system_notice.welcome_v1.cta_label',
actionId: 'open:trip-create',
},
dismissible: true,
conditions: [{ kind: 'firstLogin' }],
publishedAt: '2026-04-16T00:00:00Z',
priority: 100,
},
];
+27 -9
View File
@@ -46,19 +46,32 @@ export function getActiveNoticesFor(userId: number): SystemNoticeDTO[] {
'SELECT COUNT(*) AS count FROM trips WHERE user_id = ?'
).get(userId) as { count: number };
const dismissedIds = new Set<string>(
(db.prepare('SELECT notice_id FROM user_notice_dismissals WHERE user_id = ?')
.all(userId) as Array<{ notice_id: string }>)
.map(r => r.notice_id)
// Dismissals mapped to the app version they were dismissed at (used by per-version notices).
const dismissals = new Map<string, string | null>(
(db.prepare('SELECT notice_id, dismissed_app_version FROM user_notice_dismissals WHERE user_id = ?')
.all(userId) as Array<{ notice_id: string; dismissed_app_version: string | null }>)
.map(r => [r.notice_id, r.dismissed_app_version])
);
const now = new Date();
const currentAppVersion = getCurrentAppVersion();
const ctx = { user: { ...user, noTrips: tripCount }, currentAppVersion, now };
const appVer = semver.coerce(currentAppVersion)?.version ?? '0.0.0';
const isStillDismissed = (n: SystemNotice): boolean => {
if (!dismissals.has(n.id)) return false;
if (n.recurring === 'per-version') {
// Re-show once the running app version moves past the version it was last dismissed at,
// so a per-version notice surfaces again on each install/upgrade.
const dismissedVer = semver.coerce(dismissals.get(n.id) ?? '0.0.0')?.version ?? '0.0.0';
return semver.gte(dismissedVer, appVer);
}
return true; // default: permanent one-time dismissal
};
return SYSTEM_NOTICES
.filter(n => {
if (dismissedIds.has(n.id)) return false;
if (isStillDismissed(n)) return false;
if (!isNoticeVersionActive(n, currentAppVersion)) return false;
return evaluate(n, ctx);
})
@@ -69,15 +82,20 @@ export function getActiveNoticesFor(userId: number): SystemNoticeDTO[] {
if (sw !== 0) return sw;
return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime();
})
.map(({ conditions: _c, publishedAt: _p, minVersion: _mn, maxVersion: _mx, priority: _pr, ...dto }) => dto);
.map(({ conditions: _c, publishedAt: _p, minVersion: _mn, maxVersion: _mx, priority: _pr, recurring: _rc, ...dto }) => dto);
}
export function dismissNotice(userId: number, noticeId: string): boolean {
const exists = SYSTEM_NOTICES.some(n => n.id === noticeId);
if (!exists) return false;
// Record the app version at dismissal so per-version notices can re-appear on the next
// upgrade. Upsert (not INSERT OR IGNORE) so re-dismissing after a bump refreshes the version.
db.prepare(`
INSERT OR IGNORE INTO user_notice_dismissals (user_id, notice_id, dismissed_at)
VALUES (?, ?, ?)
`).run(userId, noticeId, Date.now());
INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at, dismissed_app_version)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, notice_id) DO UPDATE SET
dismissed_at = excluded.dismissed_at,
dismissed_app_version = excluded.dismissed_app_version
`).run(userId, noticeId, Date.now(), getCurrentAppVersion());
return true;
}
+8 -1
View File
@@ -21,6 +21,7 @@ export interface NoticeMedia {
export type NoticeCta =
| { kind: 'nav'; labelKey: string; href: string }
| { kind: 'link'; labelKey: string; href: string } // external URL, opens in a new tab
| { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean };
export interface SystemNotice {
@@ -34,13 +35,19 @@ export interface SystemNotice {
media?: NoticeMedia;
highlights?: Array<{ labelKey: string; iconName?: string }>;
cta?: NoticeCta;
secondaryCta?: NoticeCta;
// Hide this notice on small/mobile viewports (evaluated client-side).
desktopOnly?: boolean;
dismissible: boolean;
conditions: NoticeCondition[];
publishedAt: string;
minVersion?: string;
maxVersion?: string;
priority?: number;
// 'per-version': re-show on every app version bump (each install + upgrade) instead of
// the default permanent one-time dismissal.
recurring?: 'per-version';
}
// DTO sent to client (same shape minus the conditions — server evaluates those)
export type SystemNoticeDTO = Omit<SystemNotice, 'conditions' | 'publishedAt' | 'minVersion' | 'maxVersion' | 'priority'>;
export type SystemNoticeDTO = Omit<SystemNotice, 'conditions' | 'publishedAt' | 'minVersion' | 'maxVersion' | 'priority' | 'recurring'>;