From 65370ae64ea749e883b435f36df1637872cea84c Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Thu, 29 Oct 2026 20:05:25 -0400 Subject: [PATCH 1/4] OpenConceptLab/ocl_online#425 | Announcement banner shows the deployment's ANNOUNCEMENT setting The banner's text and links come from an optional ANNOUNCEMENT environment variable (JSON), which start-prod.sh writes into env-config.js as an escaped string when the container starts. The app parses and checks it: plain text, https links only, the user's language with en as the fallback, hidden after `until`, and dismissed by `id`. Unset or invalid, no banner shows. Removes the announcement written into the app: its copy, link constants and locale strings. README documents the setting. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AaZ4qVYj2Kwzt3oKZf1AwU --- README.md | 19 +++ docker-compose.yml | 1 + src/common/__tests__/announcement.test.js | 153 ++++++++++++++++++++++ src/common/announcement.js | 81 ++++++++++++ src/common/constants.js | 2 - src/components/app/AnnouncementBanner.jsx | 106 ++++++--------- src/i18n/locales/en/translations.json | 7 - src/i18n/locales/es/translations.json | 7 - src/i18n/locales/zh/translations.json | 7 - start-prod.sh | 24 ++++ 10 files changed, 320 insertions(+), 87 deletions(-) create mode 100644 src/common/__tests__/announcement.test.js create mode 100644 src/common/announcement.js diff --git a/README.md b/README.md index da23f4ef..93bc433f 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,25 @@ OCL TermBrowser v2, an open-source ReactJS-based web client for terminology mana 1. docker-compose -f docker-compose.yml up -d 2. Visit http://localhost:4000 +### Announcement banner +To show a banner above the app, for example for planned maintenance, set the optional `ANNOUNCEMENT` environment variable to JSON: + +```json +{ + "id": "maintenance-2026-11", + "text": {"en": "Planned maintenance on Saturday, 02:00-04:00 UTC.", "es": "Mantenimiento programado el sábado, de 02:00 a 04:00 UTC."}, + "links": [{"label": {"en": "Details", "es": "Detalles"}, "url": "https://status.example.org/"}], + "until": "2026-11-09T00:00:00Z" +} +``` + +- `id`: dismissing the banner hides it in that browser until `id` changes. +- `text` and each link's `label`: plain text for each language code (`en`, `es`, `zh`). `en` is required, and shows for any language that has no entry. +- `links` (optional): `https:` URLs, shown after the text. +- `until` (optional): an ISO 8601 time. The banner stops showing after it, without a restart. + +`start-prod.sh` writes the setting into `env-config.js` when the container starts, so a change shows after a restart. When it's unset, or isn't valid (bad JSON, a missing field, a link that isn't `https:`), no banner shows. With `npm start`, put `var ANNOUNCEMENT = '';` in `public/env-config.js` instead, and don't commit it. + ### Eslint docker exec -it bash -c "eslint src/ --ext=.js*" diff --git a/docker-compose.yml b/docker-compose.yml index f370c939..e74bf383 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,3 +19,4 @@ services: - ERRBIT_URL - ERRBIT_KEY - HOTJAR_ID + - ANNOUNCEMENT diff --git a/src/common/__tests__/announcement.test.js b/src/common/__tests__/announcement.test.js new file mode 100644 index 00000000..4f24d5f6 --- /dev/null +++ b/src/common/__tests__/announcement.test.js @@ -0,0 +1,153 @@ +/* global globalThis */ +import test from 'node:test' +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import vm from 'node:vm' + +import { getAnnouncement, isDismissed, rememberDismissal } from '../announcement.js' + +const SETTING = { + id: 'maintenance-2026-11', + text: {en: 'Maintenance on Saturday.', es: 'Saturday maintenance, in Spanish.', 'pt-BR': 'Saturday maintenance, in Portuguese.'}, + links: [ + {label: {en: 'Details', es: 'Details, in Spanish'}, url: 'https://status.example.org/maintenance'}, + {label: {en: 'Contact us'}, url: 'https://example.org/contact'}, + ], + until: '2026-11-09T00:00:00Z', +} +const BEFORE = Date.parse('2026-11-01T12:00:00Z') + +const announce = (setting, language = 'en', now = BEFORE) => getAnnouncement(JSON.stringify(setting), language, now) + +test('no setting: no banner', () => { + assert.equal(getAnnouncement(undefined, 'en', BEFORE), null) + assert.equal(getAnnouncement('', 'en', BEFORE), null) + assert.equal(getAnnouncement(' ', 'en', BEFORE), null) +}) + +test('a valid setting shows its text and links', () => { + assert.deepEqual(announce(SETTING), { + id: 'maintenance-2026-11', + text: 'Maintenance on Saturday.', + links: [ + {label: 'Details', url: 'https://status.example.org/maintenance'}, + {label: 'Contact us', url: 'https://example.org/contact'}, + ], + }) +}) + +test('links and until are optional', () => { + assert.deepEqual(announce({id: 'a', text: {en: 'Hello'}}), {id: 'a', text: 'Hello', links: []}) +}) + +test('text is returned as it was given, for the banner to show as plain text', () => { + assert.equal(announce({id: 'a', text: {en: 'Hi'}}).text, 'Hi') +}) + +test('invalid JSON: no banner', () => { + assert.equal(getAnnouncement('{"id": "a", "text": {"en": "Hi"}', 'en', BEFORE), null) + assert.equal(getAnnouncement('not json', 'en', BEFORE), null) +}) + +test('JSON that is not an object: no banner', () => { + ['null', '[]', '"Hello"', '42', 'true'].forEach(raw => assert.equal(getAnnouncement(raw, 'en', BEFORE), null, raw)) +}) + +test('a missing or empty id: no banner', () => { + assert.equal(announce({...SETTING, id: undefined}), null) + assert.equal(announce({...SETTING, id: ' '}), null) + assert.equal(announce({...SETTING, id: 7}), null) +}) + +test('missing or malformed text: no banner', () => { + assert.equal(announce({...SETTING, text: undefined}), null) + assert.equal(announce({...SETTING, text: 'Maintenance on Saturday.'}), null) + assert.equal(announce({...SETTING, text: {es: 'Saturday maintenance, in Spanish.'}}), null) + assert.equal(announce({...SETTING, text: {en: ''}}), null) + assert.equal(announce({...SETTING, text: {en: 'Hi', es: 3}}), null) +}) + +test('a malformed link: no banner', () => { + assert.equal(announce({...SETTING, links: {label: {en: 'Details'}, url: 'https://example.org'}}), null) + assert.equal(announce({...SETTING, links: ['https://example.org']}), null) + assert.equal(announce({...SETTING, links: [{url: 'https://example.org'}]}), null) + assert.equal(announce({...SETTING, links: [{label: {es: 'Details, in Spanish'}, url: 'https://example.org'}]}), null) + assert.equal(announce({...SETTING, links: [{label: {en: 'Details'}}]}), null) +}) + +test('a link that is not https: no banner', () => { + ['http://example.org', 'javascript:alert(1)', 'data:text/html,', '/blog/post', '//example.org', 'example.org'].forEach(url => { // eslint-disable-line spellcheck/spell-checker + assert.equal(announce({...SETTING, links: [{label: {en: 'Details'}, url}]}), null, url) + }) +}) + +test('hidden after until', () => { + assert.equal(announce(SETTING, 'en', Date.parse('2026-11-09T00:00:00Z')), null) + assert.equal(announce(SETTING, 'en', Date.parse('2026-12-01T00:00:00Z')), null) + assert.equal(announce(SETTING, 'en', Date.parse('2026-11-08T23:59:59Z')).id, 'maintenance-2026-11') +}) + +test('an until that is not a date: no banner', () => { + assert.equal(announce({...SETTING, until: 'next week'}), null) + assert.equal(announce({...SETTING, until: 1762646400000}), null) +}) + +test('shows the user\'s language, or its base language, or en', () => { + assert.deepEqual(announce(SETTING, 'es'), { + id: 'maintenance-2026-11', + text: 'Saturday maintenance, in Spanish.', + links: [ + {label: 'Details, in Spanish', url: 'https://status.example.org/maintenance'}, + {label: 'Contact us', url: 'https://example.org/contact'}, + ], + }) + assert.equal(announce(SETTING, 'pt-BR').text, 'Saturday maintenance, in Portuguese.') + assert.equal(announce(SETTING, 'es-MX').text, 'Saturday maintenance, in Spanish.') + assert.equal(announce(SETTING, 'zh').text, 'Maintenance on Saturday.') + assert.equal(announce(SETTING, undefined).text, 'Maintenance on Saturday.') + assert.equal(announce(SETTING, 'constructor').text, 'Maintenance on Saturday.') +}) + +test('a dismissed announcement stays dismissed, and a new id shows again', () => { + const items = {} + globalThis.localStorage = {getItem: key => key in items ? items[key] : null, setItem: (key, value) => { items[key] = String(value) }} + assert.equal(isDismissed('maintenance-2026-11'), false) + rememberDismissal('maintenance-2026-11') + assert.equal(isDismissed('maintenance-2026-11'), true) + assert.equal(isDismissed('maintenance-2026-12'), false) +}) + +test('without storage, the banner shows and dismissing it does not throw', () => { + globalThis.localStorage = {getItem: () => { throw new Error('blocked') }, setItem: () => { throw new Error('blocked') }} + assert.equal(isDismissed('maintenance-2026-11'), false) + assert.doesNotThrow(() => rememberDismissal('maintenance-2026-11')) +}) + +// start-prod.sh writes the setting into env-config.js with its js_string function. +const START_PROD = readFileSync(new URL('../../../start-prod.sh', import.meta.url), 'utf8') +const JS_STRING = START_PROD.match(/^js_string\(\) \{\n[\s\S]*?^\}$/m)[0] +const envConfigLine = value => execFileSync('bash', ['-c', `${JS_STRING}\necho "var ANNOUNCEMENT = $(js_string "$1");"`, 'bash', value], {encoding: 'utf8'}) + +test('start-prod.sh writes the setting as one string literal that reads back unchanged', () => { + [ + JSON.stringify(SETTING), + '{"id": "a", "text": {"en": "Say \\"hi\\"\\n to C:\\\\ and 中文 ✓"}}', + 'line one\nline two\r\nline three', + 'a\\', '\\"', 'tab\there', '\u2028\u2029', '', + ].forEach(value => { + const context = {} + vm.runInNewContext(envConfigLine(value), context) + assert.deepEqual(Object.keys(context), ['ANNOUNCEMENT'], value) + assert.equal(context.ANNOUNCEMENT, value) + }) +}) + +test('a setting cannot end the string and run script in env-config.js', () => { + ['"; globalThis.ran = true; "', '\\"; globalThis.ran = true; //', '"\nglobalThis.ran = true\n"', '"\rglobalThis.ran = true\r"'].forEach(value => { + const context = {} + vm.runInNewContext(envConfigLine(value), context) + assert.equal(context.ran, undefined, value) + assert.equal(context.ANNOUNCEMENT, value) + }) +}) diff --git a/src/common/announcement.js b/src/common/announcement.js new file mode 100644 index 00000000..3a1fd96b --- /dev/null +++ b/src/common/announcement.js @@ -0,0 +1,81 @@ +// The deployment's announcement banner, from its optional ANNOUNCEMENT setting +// (README, "Announcement banner"). start-prod.sh writes the setting into +// env-config.js as a string, and it's parsed and checked here, so a malformed +// setting shows no banner instead of breaking the page. text and each link +// label need an "en" entry, the fallback for other languages. + +const DISMISSED_KEY = 'announcementDismissed' + +const isObject = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value) + +const has = (object, key) => Object.prototype.hasOwnProperty.call(object, key) + +// The entry for language, then for its base language (es for es-MX), then en. +const localize = (strings, language) => { + if(!isObject(strings) || !Object.values(strings).every(value => typeof value === 'string') || !has(strings, 'en') || !strings.en.trim()) + return null + const code = [language, language?.split('-')[0]].find(tag => tag && has(strings, tag) && strings[tag].trim()) + return strings[code || 'en'] +} + +const httpsURL = url => { + try { + const parsed = new URL(url) + return parsed.protocol === 'https:' ? parsed.href : null + } catch { + return null + } +} + +// What the banner shows in language at now: {id, text, links: [{label, url}]}, +// or null when the setting is unset, malformed or past its until. +export const getAnnouncement = (raw, language, now = Date.now()) => { + if(typeof raw !== 'string' || !raw.trim()) + return null + let setting + try { + setting = JSON.parse(raw) + } catch { + return null + } + if(!isObject(setting) || typeof setting.id !== 'string' || !setting.id.trim()) + return null + + const text = localize(setting.text, language) + const links = setting.links === undefined ? [] : setting.links + if(!text || !Array.isArray(links)) + return null + + const shown = [] + for(const link of links) { + const label = isObject(link) ? localize(link.label, language) : null + const url = isObject(link) ? httpsURL(link.url) : null + if(!label || !url) + return null + shown.push({label, url}) + } + + if(setting.until !== undefined) { + const until = typeof setting.until === 'string' ? Date.parse(setting.until) : NaN + if(Number.isNaN(until) || now >= until) + return null + } + + return {id: setting.id, text, links: shown} +} + +export const isDismissed = id => { + try { + return localStorage.getItem(DISMISSED_KEY) === id + } catch { + return false + } +} + +export const rememberDismissal = id => { + try { + localStorage.setItem(DISMISSED_KEY, id) + } catch { + // storage unavailable (private mode, blocked cookies); the banner simply reappears + } +} diff --git a/src/common/constants.js b/src/common/constants.js index a5111dfb..6f459b1e 100644 --- a/src/common/constants.js +++ b/src/common/constants.js @@ -81,7 +81,5 @@ export const DEFAULT_CASCADE_PARAMS = { export const ALL = '*'; -export const TBV2_ANNOUNCEMENT_LEARN_MORE_URL = 'https://openconceptlab.org/blog/letter-to-the-ocl-community?utm_source=tbv2&utm_medium=banner&utm_campaign=subscription_dates' -export const TBV2_ANNOUNCEMENT_PRICING_URL = 'https://openconceptlab.org/pricing?utm_source=tbv2&utm_medium=banner&utm_campaign=subscription_dates' export const TERMS_OF_USE_URL = 'https://openconceptlab.org/terms-of-use' export const PRIVACY_POLICY_URL = 'https://openconceptlab.org/privacy-policy' diff --git a/src/components/app/AnnouncementBanner.jsx b/src/components/app/AnnouncementBanner.jsx index bb83368c..c6094bb5 100644 --- a/src/components/app/AnnouncementBanner.jsx +++ b/src/components/app/AnnouncementBanner.jsx @@ -6,22 +6,12 @@ import Link from '@mui/material/Link'; import Typography from '@mui/material/Typography'; import CampaignOutlinedIcon from '@mui/icons-material/CampaignOutlined'; import CloseIcon from '@mui/icons-material/Close'; -import { TBV2_ANNOUNCEMENT_LEARN_MORE_URL, TBV2_ANNOUNCEMENT_PRICING_URL } from '../../common/constants'; +import { getAnnouncement, isDismissed, rememberDismissal } from '../../common/announcement'; -// OCL Online-wide announcement strip, fixed above the app bar and styled like -// the community site's AnnouncementBanner, so it reads as sitting above the -// tool rather than inside it. Same component in TBv3 and the Mapper. Update -// announcement.* in the locale bundles (and bump ANNOUNCEMENT_ID) to re-show a -// new announcement to visitors who dismissed a previous one. -const ANNOUNCEMENT_ID = 'subscription-dates-2026-10'; - -// Shown in this order after the text, separated by " · ". -const ANNOUNCEMENT_LINKS = [ - { labelKey: 'announcement.link_label', href: TBV2_ANNOUNCEMENT_LEARN_MORE_URL }, - { labelKey: 'announcement.pricing_label', href: TBV2_ANNOUNCEMENT_PRICING_URL }, -]; - -const DISMISSED_KEY = 'announcementDismissed'; +// This deployment's announcement, from its ANNOUNCEMENT setting (README, +// "Announcement banner"), fixed above the app bar. Same component in TBv3 and +// the Mapper. Unset, malformed or past its until, nothing shows. Text and +// labels show as plain text. A dismissal lasts until the setting's id changes. // The banner's height while it shows. Header moves the app bar, the left menu // and the content down by it, and so does anything fixed below the app bar. @@ -29,28 +19,14 @@ const HEIGHT_VAR = '--announcement-height'; // OCL v3 design tokens (primary.95, primary.main, surface.dark, // surface.contrastText). TBv2's theme predates them, and the strip has to match -// the other OCL Online apps. +// TBv3 and the Mapper. const V3 = { background: '#f2efff', primary: '#4836ff', title: '#1c1b1f', text: '#47464f' }; -const isDismissed = () => { - try { - return localStorage.getItem(DISMISSED_KEY) === ANNOUNCEMENT_ID; - } catch { - return false; - } -}; - -const rememberDismissal = () => { - try { - localStorage.setItem(DISMISSED_KEY, ANNOUNCEMENT_ID); - } catch { - // storage unavailable (private mode, blocked cookies); the banner simply reappears - } -}; - const AnnouncementBanner = () => { - const { t } = useTranslation(); - const [open, setOpen] = React.useState(!isDismissed()); + const { t, i18n } = useTranslation(); + const announcement = getAnnouncement(window.ANNOUNCEMENT, i18n.language); + const [dismissed, setDismissed] = React.useState(() => Boolean(announcement) && isDismissed(announcement.id)); + const open = Boolean(announcement) && !dismissed; const ref = React.useRef(null); // Publish the banner's height (it wraps on narrow screens and in longer @@ -71,8 +47,8 @@ const AnnouncementBanner = () => { }, [open]); const onClose = () => { - rememberDismissal(); - setOpen(false); + rememberDismissal(announcement.id); + setDismissed(true); }; if (!open) @@ -101,36 +77,38 @@ const AnnouncementBanner = () => { - {t('announcement.title')} - - - {t('announcement.text')} - {ANNOUNCEMENT_LINKS.map(({ labelKey, href }, i) => ( - - {i ? ' · ' : ' '} - - {t(labelKey)} - - - ))} + {announcement.text} + { + announcement.links.length > 0 && + + {announcement.links.map(({ label, url }, i) => ( + + {i ? ' · ' : ''} + + {label} + + + ))} + + } - + diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 9e0b3f31..28471288 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -176,12 +176,5 @@ "confirmation_message": "Please type <1>{{resourceId}} to confirm.", "confirmation_button_label": "I understand the consequences, delete this {{resourceType}}" } - }, - "announcement": { - "title": "Subscriptions open November 1.", - "text": "Existing accounts using premium features have a grace period until December 1.", - "link_label": "Learn more", - "pricing_label": "See pricing", - "dismiss": "Dismiss" } } diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index e5b4f34a..d3115e54 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -175,12 +175,5 @@ "confirmation_message": "Escribe <1>{{resourceId}} para confirmar.", "confirmation_button_label": "Entiendo las consecuencias, eliminar este {{resourceType}}" } - }, - "announcement": { - "title": "Las suscripciones abren el 1 de noviembre.", - "text": "Las cuentas existentes que usan funciones premium tienen un periodo de gracia hasta el 1 de diciembre.", - "link_label": "Más información", - "pricing_label": "Ver precios", - "dismiss": "Descartar" } } diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json index fb6fd12b..39b249c2 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -175,12 +175,5 @@ "confirmation_message": "请键入 <1>{{resourceId}} 来确认操作。", "confirmation_button_label": "我明白这些后果,请删除这一 {{resourceType}}" } - }, - "announcement": { - "title": "订阅将于 11 月 1 日开放。", - "text": "使用高级功能的现有账户可享受宽限期至 12 月 1 日。", - "link_label": "了解更多", - "pricing_label": "查看价格", - "dismiss": "关闭" } } diff --git a/start-prod.sh b/start-prod.sh index 4e81449a..f0866bb1 100755 --- a/start-prod.sh +++ b/start-prod.sh @@ -38,6 +38,30 @@ fi if [[ ! -z "${OIDC_REALM}" ]]; then echo "var OIDC_REALM = \"${OIDC_REALM}\";" >> ${ENV_FILE} fi +# $1 as a double-quoted JavaScript string literal. Escapes the characters that +# could end the string (", \ and line breaks), so a value can't add script to +# env-config.js. One character at a time, which behaves the same in every bash. +js_string() { + local s=$1 out= c i + for ((i = 0; i < ${#s}; i++)); do + c=${s:i:1} + case $c in + \\) out+='\\' ;; + \") out+='\"' ;; + $'\n') out+='\n' ;; + $'\r') out+='\r' ;; + *) out+=$c ;; + esac + done + printf '"%s"' "$out" +} + +# This deployment's announcement banner, as JSON (README, "Announcement +# banner"). The app parses and checks it; unset, no banner shows. +if [[ ! -z "${ANNOUNCEMENT}" ]]; then + echo "var ANNOUNCEMENT = $(js_string "${ANNOUNCEMENT}");" >> ${ENV_FILE} +fi + echo "Adjusting nginx configuration" envsubst '$WEB_PORT' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf From f624636b6525b40049d34780a763b5b68d216bdc Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Thu, 29 Oct 2026 20:10:52 -0400 Subject: [PATCH 2/4] OpenConceptLab/ocl_online#425 | The banner hides at its until on a page left open, and links is required The banner checks the setting again when `until` arrives, so it goes away without a reload. `links` is required, as in the setting's shape; `[]` means no links. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AaZ4qVYj2Kwzt3oKZf1AwU --- README.md | 2 +- src/common/__tests__/announcement.test.js | 32 +++++++++++++++++++---- src/common/announcement.js | 27 ++++++++++++------- src/components/app/AnnouncementBanner.jsx | 14 ++++++++-- 4 files changed, 58 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 93bc433f..c37e7659 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ To show a banner above the app, for example for planned maintenance, set the opt - `id`: dismissing the banner hides it in that browser until `id` changes. - `text` and each link's `label`: plain text for each language code (`en`, `es`, `zh`). `en` is required, and shows for any language that has no entry. -- `links` (optional): `https:` URLs, shown after the text. +- `links`: `https:` URLs, shown after the text. Use `[]` for none. - `until` (optional): an ISO 8601 time. The banner stops showing after it, without a restart. `start-prod.sh` writes the setting into `env-config.js` when the container starts, so a change shows after a restart. When it's unset, or isn't valid (bad JSON, a missing field, a link that isn't `https:`), no banner shows. With `npm start`, put `var ANNOUNCEMENT = '';` in `public/env-config.js` instead, and don't commit it. diff --git a/src/common/__tests__/announcement.test.js b/src/common/__tests__/announcement.test.js index 4f24d5f6..cf533351 100644 --- a/src/common/__tests__/announcement.test.js +++ b/src/common/__tests__/announcement.test.js @@ -5,7 +5,7 @@ import { execFileSync } from 'node:child_process' import { readFileSync } from 'node:fs' import vm from 'node:vm' -import { getAnnouncement, isDismissed, rememberDismissal } from '../announcement.js' +import { getAnnouncement, isDismissed, msUntilExpiry, rememberDismissal } from '../announcement.js' const SETTING = { id: 'maintenance-2026-11', @@ -34,15 +34,16 @@ test('a valid setting shows its text and links', () => { {label: 'Details', url: 'https://status.example.org/maintenance'}, {label: 'Contact us', url: 'https://example.org/contact'}, ], + until: Date.parse('2026-11-09T00:00:00Z'), }) }) -test('links and until are optional', () => { - assert.deepEqual(announce({id: 'a', text: {en: 'Hello'}}), {id: 'a', text: 'Hello', links: []}) +test('until is optional, and links may be empty', () => { + assert.deepEqual(announce({id: 'a', text: {en: 'Hello'}, links: []}), {id: 'a', text: 'Hello', links: [], until: null}) }) test('text is returned as it was given, for the banner to show as plain text', () => { - assert.equal(announce({id: 'a', text: {en: 'Hi'}}).text, 'Hi') + assert.equal(announce({id: 'a', text: {en: 'Hi'}, links: []}).text, 'Hi') }) test('invalid JSON: no banner', () => { @@ -68,7 +69,9 @@ test('missing or malformed text: no banner', () => { assert.equal(announce({...SETTING, text: {en: 'Hi', es: 3}}), null) }) -test('a malformed link: no banner', () => { +test('missing or malformed links: no banner', () => { + assert.equal(announce({...SETTING, links: undefined}), null) + assert.equal(announce({...SETTING, links: null}), null) assert.equal(announce({...SETTING, links: {label: {en: 'Details'}, url: 'https://example.org'}}), null) assert.equal(announce({...SETTING, links: ['https://example.org']}), null) assert.equal(announce({...SETTING, links: [{url: 'https://example.org'}]}), null) @@ -88,6 +91,24 @@ test('hidden after until', () => { assert.equal(announce(SETTING, 'en', Date.parse('2026-11-08T23:59:59Z')).id, 'maintenance-2026-11') }) +test('the banner is checked again when its until arrives', () => { + assert.equal(msUntilExpiry(announce(SETTING), Date.parse('2026-11-08T23:00:00Z')), 60 * 60 * 1000) + assert.equal(msUntilExpiry(announce({...SETTING, until: undefined})), null) + assert.equal(msUntilExpiry(null), null) +}) + +test('checking again at each delay hides a banner at its until, even weeks away', () => { + const setting = {...SETTING, until: '2027-03-01T00:00:00Z'} + let now = BEFORE, checks = 0 + for(let shown = announce(setting, 'en', now); shown; shown = announce(setting, 'en', now)) { + const delay = msUntilExpiry(shown, now) + assert.ok(delay > 0 && delay <= 2 ** 31 - 1, `a delay setTimeout can wait: ${delay}`) + now += delay + assert.ok(++checks < 10) + } + assert.equal(now, Date.parse('2027-03-01T00:00:00Z')) +}) + test('an until that is not a date: no banner', () => { assert.equal(announce({...SETTING, until: 'next week'}), null) assert.equal(announce({...SETTING, until: 1762646400000}), null) @@ -101,6 +122,7 @@ test('shows the user\'s language, or its base language, or en', () => { {label: 'Details, in Spanish', url: 'https://status.example.org/maintenance'}, {label: 'Contact us', url: 'https://example.org/contact'}, ], + until: Date.parse('2026-11-09T00:00:00Z'), }) assert.equal(announce(SETTING, 'pt-BR').text, 'Saturday maintenance, in Portuguese.') assert.equal(announce(SETTING, 'es-MX').text, 'Saturday maintenance, in Spanish.') diff --git a/src/common/announcement.js b/src/common/announcement.js index 3a1fd96b..dff34fec 100644 --- a/src/common/announcement.js +++ b/src/common/announcement.js @@ -6,6 +6,9 @@ const DISMISSED_KEY = 'announcementDismissed' +// setTimeout's longest delay; it runs a longer one at once. +const MAX_DELAY = 2 ** 31 - 1 + const isObject = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value) const has = (object, key) => Object.prototype.hasOwnProperty.call(object, key) @@ -27,8 +30,9 @@ const httpsURL = url => { } } -// What the banner shows in language at now: {id, text, links: [{label, url}]}, -// or null when the setting is unset, malformed or past its until. +// What the banner shows in language at now: {id, text, links: [{label, url}], +// until} (until in ms, or null), or null when the setting is unset, malformed +// or past its until. export const getAnnouncement = (raw, language, now = Date.now()) => { if(typeof raw !== 'string' || !raw.trim()) return null @@ -42,28 +46,33 @@ export const getAnnouncement = (raw, language, now = Date.now()) => { return null const text = localize(setting.text, language) - const links = setting.links === undefined ? [] : setting.links - if(!text || !Array.isArray(links)) + if(!text || !Array.isArray(setting.links)) return null - const shown = [] - for(const link of links) { + const links = [] + for(const link of setting.links) { const label = isObject(link) ? localize(link.label, language) : null const url = isObject(link) ? httpsURL(link.url) : null if(!label || !url) return null - shown.push({label, url}) + links.push({label, url}) } + let until = null if(setting.until !== undefined) { - const until = typeof setting.until === 'string' ? Date.parse(setting.until) : NaN + until = typeof setting.until === 'string' ? Date.parse(setting.until) : NaN if(Number.isNaN(until) || now >= until) return null } - return {id: setting.id, text, links: shown} + return {id: setting.id, text, links, until} } +// How long until a shown banner must be checked again, so that it hides at its +// until while the page stays open: null when it has no until. +export const msUntilExpiry = (announcement, now = Date.now()) => + announcement?.until ? Math.min(announcement.until - now, MAX_DELAY) : null + export const isDismissed = id => { try { return localStorage.getItem(DISMISSED_KEY) === id diff --git a/src/components/app/AnnouncementBanner.jsx b/src/components/app/AnnouncementBanner.jsx index c6094bb5..beab6866 100644 --- a/src/components/app/AnnouncementBanner.jsx +++ b/src/components/app/AnnouncementBanner.jsx @@ -6,7 +6,7 @@ import Link from '@mui/material/Link'; import Typography from '@mui/material/Typography'; import CampaignOutlinedIcon from '@mui/icons-material/CampaignOutlined'; import CloseIcon from '@mui/icons-material/Close'; -import { getAnnouncement, isDismissed, rememberDismissal } from '../../common/announcement'; +import { getAnnouncement, isDismissed, msUntilExpiry, rememberDismissal } from '../../common/announcement'; // This deployment's announcement, from its ANNOUNCEMENT setting (README, // "Announcement banner"), fixed above the app bar. Same component in TBv3 and @@ -24,11 +24,21 @@ const V3 = { background: '#f2efff', primary: '#4836ff', title: '#1c1b1f', text: const AnnouncementBanner = () => { const { t, i18n } = useTranslation(); - const announcement = getAnnouncement(window.ANNOUNCEMENT, i18n.language); + const [now, setNow] = React.useState(Date.now); + const announcement = getAnnouncement(window.ANNOUNCEMENT, i18n.language, now); const [dismissed, setDismissed] = React.useState(() => Boolean(announcement) && isDismissed(announcement.id)); const open = Boolean(announcement) && !dismissed; const ref = React.useRef(null); + // Check again when until arrives, so the banner hides on a page left open. + const delay = open ? msUntilExpiry(announcement, now) : null; + React.useEffect(() => { + if (delay === null) + return; + const timer = setTimeout(() => setNow(Date.now()), delay); + return () => clearTimeout(timer); + }, [delay, now]); + // Publish the banner's height (it wraps on narrow screens and in longer // translations) and clear it once dismissed. React.useLayoutEffect(() => { From 94d8d3bd53e5e80e04dc9d404271260d5d1bd000 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Thu, 29 Oct 2026 20:26:17 -0400 Subject: [PATCH 3/4] OpenConceptLab/ocl_online#425 | Simplify the announcement setting's checks From the simplification review: JSON.parse's catch covers an unset or empty setting, the links check maps and tests in one pass, the unused `now` defaults go, and the language test and the component comment are shorter. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AaZ4qVYj2Kwzt3oKZf1AwU --- src/common/__tests__/announcement.test.js | 12 ++---------- src/common/announcement.js | 19 ++++++------------- src/components/app/AnnouncementBanner.jsx | 6 ++---- 3 files changed, 10 insertions(+), 27 deletions(-) diff --git a/src/common/__tests__/announcement.test.js b/src/common/__tests__/announcement.test.js index cf533351..c1f7acee 100644 --- a/src/common/__tests__/announcement.test.js +++ b/src/common/__tests__/announcement.test.js @@ -94,7 +94,6 @@ test('hidden after until', () => { test('the banner is checked again when its until arrives', () => { assert.equal(msUntilExpiry(announce(SETTING), Date.parse('2026-11-08T23:00:00Z')), 60 * 60 * 1000) assert.equal(msUntilExpiry(announce({...SETTING, until: undefined})), null) - assert.equal(msUntilExpiry(null), null) }) test('checking again at each delay hides a banner at its until, even weeks away', () => { @@ -115,15 +114,8 @@ test('an until that is not a date: no banner', () => { }) test('shows the user\'s language, or its base language, or en', () => { - assert.deepEqual(announce(SETTING, 'es'), { - id: 'maintenance-2026-11', - text: 'Saturday maintenance, in Spanish.', - links: [ - {label: 'Details, in Spanish', url: 'https://status.example.org/maintenance'}, - {label: 'Contact us', url: 'https://example.org/contact'}, - ], - until: Date.parse('2026-11-09T00:00:00Z'), - }) + assert.equal(announce(SETTING, 'es').text, 'Saturday maintenance, in Spanish.') + assert.deepEqual(announce(SETTING, 'es').links.map(link => link.label), ['Details, in Spanish', 'Contact us']) assert.equal(announce(SETTING, 'pt-BR').text, 'Saturday maintenance, in Portuguese.') assert.equal(announce(SETTING, 'es-MX').text, 'Saturday maintenance, in Spanish.') assert.equal(announce(SETTING, 'zh').text, 'Maintenance on Saturday.') diff --git a/src/common/announcement.js b/src/common/announcement.js index dff34fec..a6ce5df1 100644 --- a/src/common/announcement.js +++ b/src/common/announcement.js @@ -33,9 +33,7 @@ const httpsURL = url => { // What the banner shows in language at now: {id, text, links: [{label, url}], // until} (until in ms, or null), or null when the setting is unset, malformed // or past its until. -export const getAnnouncement = (raw, language, now = Date.now()) => { - if(typeof raw !== 'string' || !raw.trim()) - return null +export const getAnnouncement = (raw, language, now) => { let setting try { setting = JSON.parse(raw) @@ -49,14 +47,9 @@ export const getAnnouncement = (raw, language, now = Date.now()) => { if(!text || !Array.isArray(setting.links)) return null - const links = [] - for(const link of setting.links) { - const label = isObject(link) ? localize(link.label, language) : null - const url = isObject(link) ? httpsURL(link.url) : null - if(!label || !url) - return null - links.push({label, url}) - } + const links = setting.links.map(link => ({label: localize(link?.label, language), url: httpsURL(link?.url)})) + if(links.some(link => !link.label || !link.url)) + return null let until = null if(setting.until !== undefined) { @@ -70,8 +63,8 @@ export const getAnnouncement = (raw, language, now = Date.now()) => { // How long until a shown banner must be checked again, so that it hides at its // until while the page stays open: null when it has no until. -export const msUntilExpiry = (announcement, now = Date.now()) => - announcement?.until ? Math.min(announcement.until - now, MAX_DELAY) : null +export const msUntilExpiry = (announcement, now) => + announcement.until ? Math.min(announcement.until - now, MAX_DELAY) : null export const isDismissed = id => { try { diff --git a/src/components/app/AnnouncementBanner.jsx b/src/components/app/AnnouncementBanner.jsx index beab6866..8273ba40 100644 --- a/src/components/app/AnnouncementBanner.jsx +++ b/src/components/app/AnnouncementBanner.jsx @@ -8,10 +8,8 @@ import CampaignOutlinedIcon from '@mui/icons-material/CampaignOutlined'; import CloseIcon from '@mui/icons-material/Close'; import { getAnnouncement, isDismissed, msUntilExpiry, rememberDismissal } from '../../common/announcement'; -// This deployment's announcement, from its ANNOUNCEMENT setting (README, -// "Announcement banner"), fixed above the app bar. Same component in TBv3 and -// the Mapper. Unset, malformed or past its until, nothing shows. Text and -// labels show as plain text. A dismissal lasts until the setting's id changes. +// This deployment's announcement (common/announcement.js; README, "Announcement +// banner"), fixed above the app bar. Same component in TBv3 and the Mapper. // The banner's height while it shows. Header moves the app bar, the left menu // and the content down by it, and so does anything fixed below the app bar. From edec8f65976e2038fec0757e6c1a1ece9898f9e9 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Thu, 29 Oct 2026 20:46:27 -0400 Subject: [PATCH 4/4] OpenConceptLab/ocl_online#425 | Long banner text wraps, and an ended banner goes within a minute From the adversarial review: a long label (a pasted URL, say) no longer pushes the close button off a phone screen, because the text wraps anywhere and the button keeps its size. The banner checks `until` at least once a minute, so a timer that a sleeping laptop or a clock change delays can't keep it up. README: `until` needs Z or an offset, and the npm start example works with an apostrophe in the text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AaZ4qVYj2Kwzt3oKZf1AwU --- README.md | 4 ++-- src/common/__tests__/announcement.test.js | 17 +++++++++-------- src/common/announcement.js | 5 +++-- src/components/app/AnnouncementBanner.jsx | 6 ++---- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index c37e7659..2e26898d 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,9 @@ To show a banner above the app, for example for planned maintenance, set the opt - `id`: dismissing the banner hides it in that browser until `id` changes. - `text` and each link's `label`: plain text for each language code (`en`, `es`, `zh`). `en` is required, and shows for any language that has no entry. - `links`: `https:` URLs, shown after the text. Use `[]` for none. -- `until` (optional): an ISO 8601 time. The banner stops showing after it, without a restart. +- `until` (optional): an ISO 8601 time with `Z` or an offset, such as `2026-11-09T00:00:00Z`. The banner stops showing after it, without a restart. -`start-prod.sh` writes the setting into `env-config.js` when the container starts, so a change shows after a restart. When it's unset, or isn't valid (bad JSON, a missing field, a link that isn't `https:`), no banner shows. With `npm start`, put `var ANNOUNCEMENT = '';` in `public/env-config.js` instead, and don't commit it. +`start-prod.sh` writes the setting into `env-config.js` when the container starts, so a change shows after a restart. When it's unset, or isn't valid (bad JSON, a missing field, a link that isn't `https:`), no banner shows. With `npm start`, put `var ANNOUNCEMENT = ;` in `public/env-config.js` instead (`node -p 'JSON.stringify(process.argv[1])' ''` prints one), and don't commit it. ### Eslint diff --git a/src/common/__tests__/announcement.test.js b/src/common/__tests__/announcement.test.js index c1f7acee..663c60f2 100644 --- a/src/common/__tests__/announcement.test.js +++ b/src/common/__tests__/announcement.test.js @@ -91,21 +91,22 @@ test('hidden after until', () => { assert.equal(announce(SETTING, 'en', Date.parse('2026-11-08T23:59:59Z')).id, 'maintenance-2026-11') }) -test('the banner is checked again when its until arrives', () => { - assert.equal(msUntilExpiry(announce(SETTING), Date.parse('2026-11-08T23:00:00Z')), 60 * 60 * 1000) - assert.equal(msUntilExpiry(announce({...SETTING, until: undefined})), null) +test('the banner is checked again when its until arrives, or within a minute', () => { + assert.equal(msUntilExpiry(announce(SETTING), Date.parse('2026-11-08T23:59:30Z')), 30 * 1000) + assert.equal(msUntilExpiry(announce(SETTING), BEFORE), 60 * 1000) + assert.equal(msUntilExpiry(announce({...SETTING, until: undefined}), BEFORE), null) }) -test('checking again at each delay hides a banner at its until, even weeks away', () => { - const setting = {...SETTING, until: '2027-03-01T00:00:00Z'} +test('checking again at each delay hides a banner at its until', () => { + const setting = {...SETTING, until: '2026-11-01T12:10:00Z'} let now = BEFORE, checks = 0 for(let shown = announce(setting, 'en', now); shown; shown = announce(setting, 'en', now)) { const delay = msUntilExpiry(shown, now) - assert.ok(delay > 0 && delay <= 2 ** 31 - 1, `a delay setTimeout can wait: ${delay}`) + assert.ok(delay > 0 && delay <= 60 * 1000, `a delay of at most a minute: ${delay}`) now += delay - assert.ok(++checks < 10) + assert.ok(++checks <= 10) } - assert.equal(now, Date.parse('2027-03-01T00:00:00Z')) + assert.equal(now, Date.parse('2026-11-01T12:10:00Z')) }) test('an until that is not a date: no banner', () => { diff --git a/src/common/announcement.js b/src/common/announcement.js index a6ce5df1..079ce9b6 100644 --- a/src/common/announcement.js +++ b/src/common/announcement.js @@ -6,8 +6,9 @@ const DISMISSED_KEY = 'announcementDismissed' -// setTimeout's longest delay; it runs a longer one at once. -const MAX_DELAY = 2 ** 31 - 1 +// The longest wait between checks of until, so a timer that a sleeping laptop +// or a clock change delays can't keep an ended banner up for long. +const MAX_DELAY = 60 * 1000 const isObject = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value) diff --git a/src/components/app/AnnouncementBanner.jsx b/src/components/app/AnnouncementBanner.jsx index 8273ba40..bbdbdfd3 100644 --- a/src/components/app/AnnouncementBanner.jsx +++ b/src/components/app/AnnouncementBanner.jsx @@ -82,7 +82,7 @@ const AnnouncementBanner = () => { px: { xs: 2, sm: 3 }, }} > - + {announcement.text} @@ -102,8 +102,6 @@ const AnnouncementBanner = () => { fontWeight: 600, color: V3.primary, textDecorationColor: 'rgba(72, 54, 255, 0.4)', - // The text may wrap between the links, never inside one. - whiteSpace: 'nowrap', '&:hover, &:focus': { color: V3.primary }, // App.scss removes the focus outline from every link. '&:focus-visible': { outline: `2px solid ${V3.primary} !important`, outlineOffset: '2px' }, @@ -116,7 +114,7 @@ const AnnouncementBanner = () => { } - +