# System Notices — Technical Documentation & Dev Guide System notices are server-evaluated, user-targeted messages shown in the TREK UI as modals, banners, or toasts. They are used for onboarding, upgrade announcements, breaking change warnings, and time-boxed campaigns. Every aspect — targeting, display, copy, and dismissal — is controlled from one place: the server-side registry. --- ## Table of Contents 1. [Architecture overview](#1-architecture-overview) 2. [Data flow](#2-data-flow) 3. [Database schema](#3-database-schema) 4. [The notice registry](#4-the-notice-registry) 5. [Notice fields reference](#5-notice-fields-reference) 6. [Condition system](#6-condition-system) 7. [Display types](#7-display-types) 8. [CTAs (call to action)](#8-ctas-call-to-action) 9. [i18n — translation keys](#9-i18n--translation-keys) 10. [Client store & dismissal](#10-client-store--dismissal) 11. [Sorting & priority](#11-sorting--priority) 12. [How-to recipes](#12-how-to-recipes) 13. [Testing](#13-testing) 14. [Rules & constraints](#14-rules--constraints) --- ## 1. Architecture overview ``` server/src/systemNotices/ ├── types.ts — TypeScript types (SystemNotice, NoticeCondition, …) ├── registry.ts — Authoritative list of all notices (edit here to add/change/remove) ├── conditions.ts — Condition evaluators + custom predicate registry └── service.ts — Queries DB, evaluates conditions, sorts, strips server-only fields server/src/routes/systemNotices.ts — REST endpoints client/src/store/systemNoticeStore.ts — Zustand store (fetch + optimistic dismiss) client/src/components/SystemNotices/ ├── SystemNoticeHost.tsx — Renders all three channels (modal / banner / toast) ├── SystemNoticeModal.tsx — Modal renderer (pager, animations, keyboard nav) ├── SystemNoticeBanner.tsx — Banner + toast renderers └── noticeActions.ts — Client-side action registry for action-kind CTAs client/src/pages/Trips/noticeActions.ts — Example domain action registration ``` There are **no database rows for notice definitions**. The registry is code-only. The database only stores which notices a user has dismissed. --- ## 2. Data flow ``` 1. User authenticates │ ▼ 2. authStore.loadUser() completes │ ▼ 3. SystemNoticeHost mounts → calls useSystemNoticeStore.fetch() │ (also triggered on cold page reload if store not yet loaded) ▼ 4. GET /api/system-notices/active │ ▼ 5. service.getActiveNoticesFor(userId) ├── reads user row (login_count, first_seen_version, role) ├── counts user trips ├── reads user_notice_dismissals ├── filters SYSTEM_NOTICES: │ – not dismissed │ – not expired (expiresAt) │ – all conditions pass (AND logic) ├── sorts by priority → severity → publishedAt (desc) └── strips server-only fields (conditions, publishedAt, expiresAt, priority) │ ▼ 6. Client receives SystemNoticeDTO[] │ ▼ 7. SystemNoticeHost partitions by display type ├── modal → ModalRenderer (multi-page pager, slide transitions) ├── banner → BannerRenderer (sticky top bar, max 2) └── toast → ToastRenderer (fires window.__addToast, auto-dismisses) │ ▼ 8. User dismisses → POST /api/system-notices/:id/dismiss ├── Server: INSERT OR IGNORE into user_notice_dismissals └── Client: optimistic remove from store (retry once on failure) ``` --- ## 3. Database schema Added in **migration 101** (`server/src/db/migrations.ts`). ### `users` columns (added by migration 101) | Column | Type | Default | Purpose | |---|---|---|---| | `first_seen_version` | `TEXT` | `'0.0.0'` | App version at account creation. Used by `existingUserBeforeVersion` condition. Backfilled users get `'0.0.0'`. | | `login_count` | `INTEGER` | `0` | Incremented on each successful login. Used by `firstLogin` condition. | ### `user_notice_dismissals` | Column | Type | Notes | |---|---|---| | `user_id` | `INTEGER` | FK → `users.id` CASCADE DELETE | | `notice_id` | `TEXT` | Matches `SystemNotice.id` from registry | | `dismissed_at` | `INTEGER` | Unix ms timestamp | Primary key: `(user_id, notice_id)` — dismissals are idempotent. --- ## 4. The notice registry **`server/src/systemNotices/registry.ts`** is the single source of truth. Add, change, or retire notices here. ```typescript export const SYSTEM_NOTICES: SystemNotice[] = [ { id: 'my-notice', // ← globally unique, never reuse display: 'modal', severity: 'info', titleKey: 'system_notice.my_notice.title', bodyKey: 'system_notice.my_notice.body', dismissible: true, conditions: [{ kind: 'firstLogin' }], publishedAt: '2026-05-01T00:00:00Z', priority: 50, }, ]; ``` ### The golden rule for IDs **Never remove or renumber an entry. Never reuse an ID.** Dismissals are stored in the database keyed by `id`. Removing an entry means dismissed users would see it again if you ever add a notice with the same ID. If a notice is no longer needed, add `expiresAt` to stop it from being shown — do not delete the entry. --- ## 5. Notice fields reference ### Required fields | Field | Type | Description | |---|---|---| | `id` | `string` | Globally unique, stable identifier. Use kebab-case, descriptive, version-scoped when appropriate (`v3-photos`, `welcome-v1`). Max recommended length: 40 chars. | | `display` | `'modal' \| 'banner' \| 'toast'` | How the notice is rendered. See [§7 Display types](#7-display-types). | | `severity` | `'info' \| 'warn' \| 'critical'` | Affects colour scheme and accessibility role. `critical` notices cannot be toasts. | | `titleKey` | `string` | i18n key for the title. | | `bodyKey` | `string` | i18n key for the body. Markdown supported in modals; plain text only in banners/toasts. | | `dismissible` | `boolean` | If `false`, the X button and ESC key are hidden/blocked. Use only for `critical` notices that require action before proceeding. | | `conditions` | `NoticeCondition[]` | Empty array (`[]`) means always shown (same as `[{ kind: 'always' }]`). All conditions must pass (AND logic). | | `publishedAt` | `string` | ISO 8601 date. Used as a tiebreaker in sorting. Set to the deployment date. | ### Optional fields | Field | Type | Description | |---|---|---| | `priority` | `number` | Higher number = shown first. Primary sort key. Default: `0`. | | `expiresAt` | `string` | ISO 8601 date. Notice is automatically hidden after this date. Preferred over deleting entries. | | `icon` | `string` | Lucide icon name (e.g. `'Sparkles'`, `'ImageOff'`). Shown in the modal's severity icon circle. Falls back to the severity default icon if absent or unrecognised. | | `bodyParams` | `Record` | Interpolation parameters for `bodyKey`. Values replace `{key}` placeholders in the translated string. **Never hardcode version numbers or dates directly in translation strings — use this instead.** | | `media` | `NoticeMedia` | Image to display in the modal. See below. | | `highlights` | `Array<{ labelKey: string; iconName?: string }>` | Bullet-point feature list rendered below the body in modals. Each entry is a translation key + optional Lucide icon name. | | `cta` | `NoticeCta` | Primary action button. See [§8 CTAs](#8-ctas-call-to-action). | ### `NoticeMedia` ```typescript interface NoticeMedia { src: string; // URL or path srcDark?: string; // Optional dark-mode variant altKey: string; // i18n key for alt text placement?: 'hero' | 'inline'; // default: 'hero' (full-width above body) aspectRatio?: string; // CSS aspect-ratio value, default '16/9' } ``` ### Character limits | Field | Modal | Banner | Toast | |---|---|---|---| | Title | ≤ 40 chars | ≤ 40 chars | ≤ 40 chars | | Body | ≤ 400 chars (markdown) | ≤ 140 chars (plain) | ≤ 80 chars (plain) | | CTA label | ≤ 20 chars, a verb | ≤ 20 chars | ≤ 20 chars | --- ## 6. Condition system Conditions are evaluated **server-side** on every `GET /api/system-notices/active` call. The client never sees conditions — only the filtered result. All conditions in `conditions[]` must pass (AND logic). To implement OR logic, create multiple notices with overlapping IDs is not possible — instead use a `custom` predicate with internal OR logic. ### Built-in conditions #### `always` ```typescript { kind: 'always' } ``` Always passes. Equivalent to an empty `conditions` array. --- #### `firstLogin` ```typescript { kind: 'firstLogin' } ``` Passes when `users.login_count <= 1`. The counter is incremented during login, so this fires on the first fetch after the very first login. Useful for onboarding notices. --- #### `noTrips` ```typescript { kind: 'noTrips' } ``` Passes when the user has zero trips. Often combined with `firstLogin`. --- #### `existingUserBeforeVersion` ```typescript { kind: 'existingUserBeforeVersion', version: '3.0.0' } ``` Passes when: - `users.first_seen_version < version` (user existed before this version) - AND the running app version `>= version` (the version has been deployed) Backfilled/legacy users have `first_seen_version = '0.0.0'` and always pass the first condition. Use this for upgrade announcements targeting users who were around before a breaking change. --- #### `dateWindow` ```typescript { kind: 'dateWindow', startsAt: '2026-06-01T00:00:00Z', endsAt: '2026-07-01T00:00:00Z' } ``` Passes when the current server time is inside `[startsAt, endsAt]`. `endsAt` is optional (open-ended). Use for campaigns, maintenance banners, and time-limited promotions. --- #### `role` ```typescript { kind: 'role', roles: ['admin'] } // or both roles: { kind: 'role', roles: ['admin', 'user'] } ``` Passes when the user's role is in the given list. --- #### `addonEnabled` ```typescript { kind: 'addonEnabled', addonId: 'journey' } ``` Passes when the named addon is enabled in admin settings. Addon IDs are the string values in `server/src/addons.ts` (`ADDON_IDS`). Use this to gate notices that promote features behind an addon. --- #### `custom` ```typescript { kind: 'custom', id: 'my-predicate-id' } ``` Delegates evaluation to a predicate registered server-side with `registerPredicate`. This is the escape hatch for logic not covered by the built-in conditions. **Registering a custom predicate:** ```typescript // server/src/systemNotices/conditions.ts exports registerPredicate import { registerPredicate } from '../systemNotices/conditions.js'; registerPredicate('has-immich-configured', (ctx) => { // ctx.user = { login_count, first_seen_version, role, noTrips } // ctx.currentAppVersion = string // ctx.now = Date return someDbCheck(ctx.user); }); ``` Register predicates at application startup before the first `getActiveNoticesFor` call. --- ### Combining conditions (AND) ```typescript conditions: [ { kind: 'existingUserBeforeVersion', version: '3.0.0' }, { kind: 'addonEnabled', addonId: 'journey' }, ] // Only shows to pre-3.0 users AND only if the journey addon is enabled. ``` --- ## 7. Display types ### `modal` Full-screen overlay with backdrop. On mobile: bottom sheet with drag-to-dismiss. On desktop: centered card. **Features:** - Markdown body (via `react-markdown` + `remark-gfm` + `rehype-sanitize`) - Optional hero or inline image - Optional highlights list (icon + label bullets) - Optional CTA button + "Not now" link - OK button when no CTA is defined - **Multi-page pager**: when multiple modal notices are active simultaneously, they are rendered as a paginated single modal with prev/next arrows, dot indicators, `N / M` counter, and keyboard arrow navigation - Slide transition between pages - ESC to dismiss all (if current notice is dismissible) - CTA and OK dismiss **all** active modal notices, not just the current page - "Not now" dismisses only the current page **Non-dismissible modals** (`dismissible: false`): X button, ESC key, and pager navigation are all disabled until the user acts on the CTA. Use only for `critical` severity. --- ### `banner` Sticky top bar below the navigation. Slides in with a translate-Y animation. **Constraints:** - Maximum 2 banners shown simultaneously (the 2 highest-priority active banners) - Plain text only (no markdown) - RTL-aware left-border accent - Reports its height via a CSS variable `--banner-stack-h` for layout reflow --- ### `toast` Fires the global `window.__addToast` toast system. Auto-dismisses after 6 s (`info`) or 9 s (`warn`). The notice is dismissed from the store after the toast expires. **Constraints:** - `critical` severity is not allowed as a toast — the renderer logs a warning and auto-dismisses it instead - Plain text only - No interaction (no CTA rendered via toast) --- ## 8. CTAs (call to action) A CTA renders as the primary blue button in modals and as an underline link in banners. There are two kinds. ### `nav` — navigate to a route ```typescript cta: { kind: 'nav', labelKey: 'system_notice.my_notice.cta_label', href: '/journey', } ``` On click: navigates to `href` using React Router, then **dismisses all active modal notices** (or the current banner notice). The label is resolved through the i18n system. --- ### `action` — run a registered client-side handler ```typescript cta: { kind: 'action', labelKey: 'system_notice.my_notice.cta_label', actionId: 'open:trip-create', dismissOnAction: true, // default true — set false to keep notice open after action } ``` On click: looks up `actionId` in the client-side action registry and calls the handler, then **dismisses all active modal notices**. **To add a new action:** 1. Create (or extend) a `noticeActions.ts` file in the relevant feature directory: ```typescript // client/src/pages/MyFeature/noticeActions.ts import { registerNoticeAction } from '../../components/SystemNotices/noticeActions.js'; registerNoticeAction('open:my-feature', ({ navigate }) => { navigate('/my-feature?from=notice'); }); ``` 2. Import it as a side-effect in `client/src/App.tsx`: ```typescript import './pages/MyFeature/noticeActions.js' ``` 3. The registry integrity test (`server/tests/unit/systemNotices/registry.test.ts`) automatically scans all `noticeActions.ts` files and verifies that every `actionId` in the registry is registered. The test will fail if you add an `actionId` to the registry without registering it on the client. **Action handler signature:** ```typescript (ctx: NoticeActionContext) => void | Promise interface NoticeActionContext { navigate: NavigateFunction; // React Router navigate function } ``` ### Dismiss behaviour summary | Trigger | What is dismissed | |---|---| | X button (modal) | All active modal notices | | ESC key | All active modal notices (if current is dismissible) | | CTA button | All active modal notices | | OK button (no CTA) | All active modal notices | | "Not now" link | Current page only | | Banner dismiss (X) | That banner only | | Backdrop click (modal) | Current page only | | Swipe down (mobile) | Current page only | | Toast expires | That toast only | --- ## 9. i18n — translation keys Every notice field that is user-visible (`titleKey`, `bodyKey`, CTA `labelKey`, highlight `labelKey`, media `altKey`) is an i18n key resolved through `useTranslation().t()`. The key string is what gets stored in the registry; the display value lives in the translation files. **Translation files location:** `client/src/i18n/translations/` (15 files: `en`, `de`, `fr`, `es`, `it`, `nl`, `pl`, `cs`, `hu`, `ru`, `zh`, `zhTw`, `ar`, `br`, `id`) ### Key naming convention ``` system_notice.. ``` Examples: ``` system_notice.welcome_v1.title system_notice.welcome_v1.body system_notice.welcome_v1.cta_label system_notice.welcome_v1.highlight_plan system_notice.welcome_v1.hero_alt ``` ### Adding keys Add the English key to `client/src/i18n/translations/en.ts` first, then replicate to the other 14 files. Group related notice keys together with a comment: ```typescript // System notices — my feature 'system_notice.my_notice.title': 'My feature is here', 'system_notice.my_notice.body': 'Here is what changed.', 'system_notice.my_notice.cta_label': 'Explore', ``` ### `bodyParams` interpolation For values that vary at runtime (version numbers, dates, counts), use `{placeholder}` syntax in the translation string and pass `bodyParams` in the registry entry: ```typescript // In registry: bodyKey: 'system_notice.my_notice.body', bodyParams: { version: '3.1.0', date: '1 May 2026' }, // In en.ts: 'system_notice.my_notice.body': 'TREK {version} was released on {date}.', ``` **Never hardcode dynamic values directly in translation strings.** The interpolation runs client-side in `ModalRenderer` before rendering. ### Multiline bodies (modals only) Use `\n\n` (escaped, not literal newlines) for paragraph breaks in modal body strings: ```typescript 'system_notice.my_notice.body': 'First paragraph.\n\nSecond paragraph.', ``` Literal newlines in single-quoted TypeScript strings cause a parse error. ### Pager i18n keys The pager UI uses its own keys (already present in all 15 files): ``` system_notice.pager.prev → "Previous notice" system_notice.pager.next → "Next notice" system_notice.pager.counter → "{current} / {total}" system_notice.pager.goto → "Go to notice {n}" system_notice.pager.position → "Notice {current} of {total}" (aria-live) ``` --- ## 10. Client store & dismissal `client/src/store/systemNoticeStore.ts` (Zustand, no persistence). | Action | Behaviour | |---|---| | `fetch()` | `GET /api/system-notices/active`. Fails silently (non-critical). Sets `loaded = true` regardless. | | `dismiss(id)` | Optimistic: removes notice from store immediately. POSTs to `/api/system-notices/{id}/dismiss` in background with one retry on failure. | `SystemNoticeHost` triggers `fetch()` on mount if `loaded === false`. Auth store also triggers it after login, so on a fresh login the fetch happens exactly once. --- ## 11. Sorting & priority Notices are sorted before being sent to the client. The sort order is: 1. **`priority`** (descending) — primary key. Higher number appears first. 2. **`severity`** (descending) — tiebreaker: `critical` (2) > `warn` (1) > `info` (0). 3. **`publishedAt`** (descending) — final tiebreaker: more recent notices first. This means `priority` always wins over severity. Assign priorities deliberately so the intended reading order is preserved when multiple notices are active simultaneously. Current priority allocations in the registry: | Range | Use | |---|---| | 100 | Onboarding / first-login | | 80–90 | Major version upgrade notices | | 50–70 | Feature announcements | | 10–40 | Campaigns, banners | | 0 (default) | Miscellaneous | --- ## 12. How-to recipes ### Add a new modal notice 1. **Registry** — add an entry to `SYSTEM_NOTICES` in `server/src/systemNotices/registry.ts`: ```typescript { id: 'my-feature-v2', display: 'modal', severity: 'info', icon: 'Zap', titleKey: 'system_notice.my_feature_v2.title', bodyKey: 'system_notice.my_feature_v2.body', highlights: [ { labelKey: 'system_notice.my_feature_v2.highlight_one', iconName: 'Check' }, ], cta: { kind: 'nav', labelKey: 'system_notice.my_feature_v2.cta_label', href: '/my-feature', }, dismissible: true, conditions: [{ kind: 'existingUserBeforeVersion', version: '2.0.0' }], publishedAt: '2026-06-01T00:00:00Z', priority: 60, }, ``` 2. **i18n** — add keys to `client/src/i18n/translations/en.ts` and the 14 other language files. 3. **Test** — run `cd server && npx vitest run tests/unit/systemNotices/` to verify registry integrity. --- ### Add a notice with an action CTA 1. Create the action handler in the relevant feature directory: ```typescript // client/src/pages/MyFeature/noticeActions.ts import { registerNoticeAction } from '../../components/SystemNotices/noticeActions.js'; registerNoticeAction('open:my-feature-dialog', ({ navigate }) => { navigate('/my-feature?dialog=welcome'); }); ``` 2. Import it in `client/src/App.tsx`: ```typescript import './pages/MyFeature/noticeActions.js' ``` 3. Reference the `actionId` in the registry: ```typescript cta: { kind: 'action', labelKey: 'system_notice.my_notice.cta_label', actionId: 'open:my-feature-dialog', }, ``` The registry integrity test will catch any `actionId` that appears in the registry but lacks a `registerNoticeAction` call. --- ### Retire a notice (stop showing it) **Do not delete the entry.** Set `expiresAt`: ```typescript { id: 'old-campaign', // ... all existing fields unchanged ... expiresAt: '2026-07-01T00:00:00Z', } ``` After the expiry date the service filters it out automatically. The database row for dismissed users remains harmless. --- ### Show a notice only during a campaign window Combine `dateWindow` with any other targeting conditions: ```typescript conditions: [ { kind: 'dateWindow', startsAt: '2026-06-15T00:00:00Z', endsAt: '2026-06-30T23:59:59Z' }, { kind: 'role', roles: ['admin'] }, ], ``` --- ### Show a notice only if an addon is enabled ```typescript conditions: [ { kind: 'addonEnabled', addonId: 'journey' }, ], ``` Addon IDs are the string values in `server/src/addons.ts` → `ADDON_IDS`. --- ### Add a custom condition ```typescript // server/src/startup.ts (or wherever your bootstrap code runs) import { registerPredicate } from './systemNotices/conditions.js'; registerPredicate('has-no-profile-photo', (ctx) => { const row = db.prepare('SELECT avatar FROM users WHERE id = ?').get(ctx.user.id); return !row?.avatar; }); ``` Then reference it in the registry: ```typescript conditions: [{ kind: 'custom', id: 'has-no-profile-photo' }], ``` --- ### Create a multipage upgrade announcement Give multiple notices the same `conditions` and adjacent `priority` values. The pager groups all active modal notices together automatically — no extra wiring required. ```typescript // Page 1 — breaking change (higher priority, warn severity) { id: 'v4-breaking', priority: 90, severity: 'warn', conditions: [{ kind: 'existingUserBeforeVersion', version: '4.0.0' }], ... }, // Page 2 — new feature (lower priority, info severity) { id: 'v4-feature', priority: 80, severity: 'info', conditions: [{ kind: 'existingUserBeforeVersion', version: '4.0.0' }], ... }, ``` Users who have already dismissed page 1 will only see page 2 on their next session. --- ## 13. Testing ### Server unit tests **`server/tests/unit/systemNotices/conditions.test.ts`** Tests each condition kind in isolation using `evaluate()` directly. No DB required. **`server/tests/unit/systemNotices/registry.test.ts`** Validates registry integrity: - No duplicate `id` values - All `action` CTA `actionId`s have a corresponding `registerNoticeAction()` call in the client source (scanned via regex — no JSON file needed) - All `publishedAt` values parse as valid ISO dates Run: `cd server && npx vitest run tests/unit/systemNotices/` **`server/tests/integration/systemNotices.test.ts`** Integration tests against a real in-memory SQLite database: - `GET /api/system-notices/active` returns 401 without auth, returns correct notices per user state - `POST /api/system-notices/:id/dismiss` stores the dismissal and filters on subsequent requests - Dismissing an unknown ID returns 404 Run: `cd server && npx vitest run tests/integration/systemNotices.test.ts` --- ### Client unit tests **`client/src/components/SystemNotices/SystemNoticeModal.test.tsx`** Tests `ModalRenderer` with fake timers (`vi.useFakeTimers()`) and MSW for the dismiss endpoint. Key helpers: ```typescript // Flush the 500 ms grace delay that gates the modal's visible state async function flushGraceDelay() { await act(async () => { vi.runAllTimers(); }); } // Minimal notice factory function makeNotice(overrides?: Partial): SystemNoticeDTO ``` Covered cases (FE-SN-MODAL-001 to 018): - Grace delay before visibility - Dismiss button, X button, ESC key - Non-dismissible notices (all affordances blocked) - CTA nav button — dismisses all notices - Body param interpolation - Pager: counter, dots, prev/next buttons, keyboard arrows, dot click, non-dismissible lock - Dismiss-does-not-skip regression - X and ESC dismiss all in multipage scenario - Last notice close Run: `cd client && npm run test -- SystemNoticeModal` --- ### Running all notice tests ```bash cd server && npx vitest run tests/unit/systemNotices/ tests/integration/systemNotices.test.ts cd client && npm run test -- SystemNoticeModal ``` --- ## 14. Rules & constraints | Rule | Reason | |---|---| | Never delete or reuse a notice `id` | Dismissal records are keyed by `id`. Deletion causes dismissed users to see the notice again. | | Never use literal newlines in translation strings | Single-quoted TS strings with literal newlines cause esbuild parse errors. Use `\n\n` (escaped). | | Never hardcode version numbers or dates in translation strings | Use `bodyParams` so strings stay translatable without retranslation per release. | | `critical` severity must have `dismissible: false` | `critical` toasts are auto-dismissed with a warning; a dismissible critical modal is inconsistent UX. | | `critical` must not use `display: 'toast'` | The toast renderer logs a warning and auto-dismisses critical toasts rather than showing them. | | CTA labels ≤ 20 chars, sentence case, a verb | Consistent button copy across the app. | | Priorities must be set explicitly for upgrade notices | Adjacent notices form a multipage group; ordering matters for the reading flow. | | `action` CTA `actionId` must be registered client-side | The registry integrity test enforces this. Add both the registry entry and the `registerNoticeAction` call in the same PR. | | `expiresAt` over deletion for retiring notices | See above. |