Announcements
Backend-controlled announcements tell users about scheduled maintenance and deprecated app versions or features. Both apps show them in the same dialog. The native iOS app reads the same feed, so one backend row can reach all three clients.
Not to be confused with the portal's hand-written launch cards
(apps/portal/src/components/announcements/) — those are static UI, not
backend data.
Backend contract
GET /api/system/announcements?client=&platform=&version= in enode_backend_v1
(SystemController). Unauthenticated, so it works before login. Missing or
unknown parameters return { items: [] }, never an error.
| Parameter | Tracking app | Portal |
|---|---|---|
client | tracking | portal |
platform | ios, ipados, android (web in the browser build) | web |
version | installed native version (App.getInfo), native builds only | not sent |
The backend filters by clients, platforms, is_active, the
published_at / expires_at window and min_app_version / max_app_version
(a missing version matches every gate). It sorts blocking first, then by
priority. Copy is localized by Accept-Language.
Each item carries title, message, ctas (label + url), dismissLabel,
condition, trigger, blocking, displayInterval (seconds), priority and
expiresAt (epoch ms). Validation:
schema.ts.
There is no admin UI. Rows are created with SQL or a seed function in
enode_backend_v1/Tests/AppTestsV2/Scripts/CreateAnnouncementScript.swift.
Presentation
AnnouncementHost is mounted once in each root layout
(tracking,
portal). It shows one
AnnouncementDialog
at a time: title, message, one button per CTA, and the dismiss button.
- CTAs open outside the app (
window.open; on a Capacitor build the OS opens the URL, so a store link lands in the App Store / Play Store). Onlyhttps:,http:andmailto:links become buttons. - A blocking announcement has no dismiss button, ignores backdrop / Escape / Back, and stays open after a CTA. A blocking row without a working CTA is shown as a normal, dismissible one.
- The dialog sits below
BlockingErrorDialog.
Triggers and anchors
trigger decides when a row is evaluated:
launch— after each fetch: app start, return to the foreground, reconnect, language switch.screen:<anchor>— when the user lands on a view that declares the anchor withuseAnnouncementAnchor(anchor, active?).
Anchor ids mean the same thing in every app, so one row can target several
clients. The native app uses training, dashboard and profile.
| Anchor | Tracking app | Portal |
|---|---|---|
training | /workouts/today | – |
dashboard | – | /dashboard |
settings | Settings drawer (shared) | Settings drawer (shared) |
insights | – | /dashboard/data/insights |
export | – | /dashboard/data/export |
workouts | – | /dashboard/workouts |
A row with an anchor that a build doesn't declare never fires there. Use
launch for anything that has to reach old builds, such as a version
deprecation.
Display rules
Implemented in rules.ts, a port
of the native ENAnnouncementService.buildQueue:
- Frequency —
displayInterval: nullshows once per device,0on every evaluation,nagain afternseconds. Blocking ignores frequency. - Expiry —
expiresAtin the past hides the row, also from the offline cache. - Condition —
privilege=<key>,license=<name|uuid>, with!=and|alternatives (condition.ts). Anything unparsable evaluates to false. Signed out means no privileges and no license. While a signed-in profile is still loading, conditioned rows wait. - Order — blocking first, then by descending priority.
Refresh, cache and hold
store.ts fetches on start,
foreground / reconnect (store-kit revalidation) and language switch. Triggers
evaluate the cached list, never the network. Nothing is evaluated before the
first fetch has settled, so a withdrawn row doesn't flash from the cache.
The last response and the per-announcement lastShownAt live in
localStorage (enode.announcements.cache,
enode.announcements.displayStates). A blocking deprecation therefore keeps
blocking offline.
useHoldAnnouncements(active) holds new dialogs back. The tracking app holds
them while a training runs. Releasing the last hold evaluates launch plus the
anchors on screen, so a held-back announcement shows right after the training.
Authoring examples
| Purpose | clients | platforms | Version | Window | blocking | display_interval_seconds | CTA |
|---|---|---|---|---|---|---|---|
| Scheduled maintenance | enode,tracking,portal | – | – | published_at = announce from, expires_at = end of maintenance | no | 86400 | optional status page |
| Retire an old tracking version | tracking | ios (second row for android) | max_app_version = last retired version | – | yes | – | store link |
| Retire a feature | per app | – | – | expires_at = switch-off date | no | 259200 | explanation page |
Limitations
- Only builds that contain this feature see announcements. The first version deprecation can only reach versions released after it.
- A row has one set of CTA links, so platform-specific store links need one row per platform.
- The message is static text. Write maintenance times with their time zone; the client doesn't convert them.
- A missing translation falls back to the row's original text.