Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/meeting-card-place-headline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@edoxen/browser': minor
---

Expose Meeting.type as an i18n'd badge + filterable Type facet.

- `MeetingListItem` carries `type` (MeetingType enum) and `MeetingListFacets` exposes a `types[]` list. `/data/meetings.json` now includes the type per item.
- `MeetingCard` renders a type badge in the meta row (small caps muted label), resolved through the new `meetingTypeLabel()` helper.
- `MeetingDetail` badge row uses the helper instead of the raw enum value.
- The search island adds a Type facet (between Year and Location, meetings mode) — chip labels are humanized enum values; selection round-trips via `#types=plenary,working_group` in the URL hash. The facet text is also part of the search haystack so typing "plenary" filters meetings.
- Card headline is composed from place (flag + "City, Country" or "🌐 Virtual"), with the entity title carried as the link's `title` attribute tooltip. Detail h1, JSON-LD, sitemaps, and `<title>` continue to use the entity title. Falls back to the title when no place data is present.
- New config knob: `terminology.meetingTypes` — per-locale overrides (`{ eng: { plenary: 'Plénière CIML' } }`). Resolution order: consumer override → built-in i18n table (`meeting.type.<value>`) → humanized enum value. The built-in table ships English + French for all 17 MeetingType values; other locales fall back to English until they're translated.

`@edoxen/edoxen` (separate package) gains a symmetric `meetingTypeLabel(type)` helper + `MEETING_TYPE_LABELS` constant, mirroring the existing `actionTypeLabel` pattern.
76 changes: 76 additions & 0 deletions packages/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,82 @@ When `nav` is not configured, the default nav (Meetings / Decisions /
About) is derived from `terminology` + `decisionsSlug` — the example
above yields Meetings / Resolutions / About with a `/resolutions` link.

#### `terminology.meetingTypes` — translating meeting type labels

Every meeting carries a `type` from the
[`MeetingType` enum](https://github.com/edoxen/edoxen-model/blob/main/models/meeting_type.lutaml)
(`plenary`, `working_group`, `task_group`, …, 17 values total). The
browser renders this as a small-caps badge on meeting cards + the detail
page header, and as a `Type` facet chip in the search island.

Built-in labels cover **English + French**. Other locales (中文, Español,
العربية, Русский) fall back to English until you translate them.

Translate or override per-locale via `terminology.meetingTypes`:

```ts
terminology: {
// …decision / decisions / meeting / meetings as above…
meetingTypes: {
eng: {
plenary: 'Plenary', // override an English label
working_group: 'Working Group', // committee-specific phrasing
},
fra: {
plenary: 'Plénière CIML', // French committee style
working_group: 'Groupe de travail CIML',
},
zho: { // Simplified Chinese (简体中文)
plenary: '全体会议',
working_group: '工作组',
subcommittee: '分技术委员会',
task_group: '任务组',
ad_hoc: '特设组',
joint: '联席会议',
other: '其他',
// …translate as many of the 17 values as your committee uses
},
'zho-Hant': { // Traditional Chinese (繁體中文)
plenary: '全體會議',
working_group: '工作組',
subcommittee: '分技術委員會',
task_group: '任務組',
ad_hoc: '特設組',
joint: '聯席會議',
other: '其他',
},
},
},
```

Resolution order for each type value:

1. **`terminology.meetingTypes[locale][type]`** — your per-locale override
2. **Built-in `meeting.type.<value>` string** — English + French ship
out of the box; partial coverage for other locales
3. **Humanized enum value** — `'working_group'` → `'Working Group'`,
never throws on data the gem hasn't categorized yet

So a committee whose meetings are commonly styled "Plenary Session" can
override just that one value and inherit the rest. The
[`src/i18n/meeting-types.spec.ts`](packages/browser/src/i18n/meeting-types.spec.ts)
drift guardrail fails the build if the gem adds a `MeetingType` value
that neither your override nor the built-in table covers.

> **Script variants note (e.g. 简体中文 vs 繁體中文)**: the built-in UI
> locale table uses ISO 639-3 codes and `normalizeUiLocale` currently
> truncates any code longer than 3 chars to its first 3 — so `zho-Hant`
> resolves into the same `zho` bucket as Simplified. The translation
> blocks above illustrate the labels you'd supply; serving both
> simultaneously as distinct routed locales needs a small change to
> `normalizeUiLocale` (preserve the script subtag) plus a `UiLocale`
> type widening. File a feature request if you need both served as
> separate `/zho-Hans/…` and `/zho-Hant/…` routes.

The same data drives the `Type` facet in the search island (chip labels
humanize on the client; pass localized labels to the island via
`data-*` attributes if you need them in non-English UIs).

## Theming

The default look is **elegant professional warm**: a warm paper canvas
Expand Down
26 changes: 26 additions & 0 deletions packages/browser/docs/i18n-keys.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,32 @@
'decisions.empty': No decisions. # Shown when the decisions list is empty
'meetings.empty': No meetings. # Shown when the meetings list is empty

# ── Meetings — virtual + type labels ────────────────────────────────
# `meeting.virtual` is the "Virtual" badge shown on cards/detail for
# meetings with no physical venue. The `meeting.type.*` keys map each
# MeetingType enum value (plenary, working_group, …, 17 total) to a
# display label — see README §Terminology › meetingTypes for the
# per-locale override API. Built-in English + French values ship; the
# humanize fallback covers anything missing.
'meeting.virtual': Virtual # Online / no-place meeting
'meeting.type.plenary': Plenary
'meeting.type.working_group': Working Group
'meeting.type.task_group': Task Group
'meeting.type.ad_hoc': Ad Hoc
'meeting.type.joint': Joint
'meeting.type.general_assembly': General Assembly
'meeting.type.committee': Committee
'meeting.type.subcommittee': Subcommittee
'meeting.type.conference': Conference
'meeting.type.workshop': Workshop
'meeting.type.seminar': Seminar
'meeting.type.webinar': Webinar
'meeting.type.hearing': Hearing
'meeting.type.markup': Markup
'meeting.type.board_meeting': Board Meeting
'meeting.type.annual_general_meeting': Annual General Meeting
'meeting.type.other': Other

# ── Decade timeline ─────────────────────────────────────────────────
'decade.browseLabel': Browse by decade # Accessible label for the decade nav

Expand Down
41 changes: 32 additions & 9 deletions packages/browser/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ test.describe('fixture site — home, lists and data endpoints', () => {
await page.goto('/meetings')
await expect(page.locator('section#decade-2020')).toBeVisible()
const card = page.locator('.edoxen-meeting-card', { has: page.locator('a[href="/meetings/urn:test:meeting:2025"]') })
await expect(card.locator('.edoxen-meeting-card__type')).toHaveText('Plenary')
await expect(card.locator('.edoxen-meeting-card__committee')).toHaveText('sc-1')
await expect(card.locator('.edoxen-meeting-card__count')).toContainText('1 Resolutions')
await expect(page.locator('.edoxen-decade-timeline__link').first()).toHaveAttribute('href', '#decade-2020')
Expand Down Expand Up @@ -280,6 +281,9 @@ test.describe('fixture site — home, lists and data endpoints', () => {
// Facets are grouped under labels — years never mix with locations.
await expect(island.locator('.edoxen-search-filter__facet-label', { hasText: 'Year' })).toBeVisible()
await expect(island.locator('.edoxen-search-filter__facet-label', { hasText: 'Location' })).toBeVisible()
await expect(island.locator('.edoxen-search-filter__facet-label', { hasText: 'Type' })).toBeVisible()
// Meeting-type facet chips carry the humanized enum value.
await expect(island.locator('.edoxen-search-filter__facet--meeting-type', { hasText: 'Plenary' })).toContainText('(3)')
// Country chips carry flag + localized country name; Virtual is last.
await expect(island.locator('.edoxen-search-filter__facet--country', { hasText: '🇩🇪 Germany' })).toContainText('(1)')
await expect(island.locator('.edoxen-search-filter__facet--country', { hasText: '🇨🇭 Switzerland' })).toContainText('(1)')
Expand All @@ -295,10 +299,11 @@ test.describe('fixture site — home, lists and data endpoints', () => {
const input = island.locator('input[type="search"]')
await input.fill('sc-1')
await expect(results).toHaveCount(1)
await expect(results.first()).toContainText('2025 Refs Plenary')
// Headline is composed from place — full entity title rides along as a tooltip.
await expect(results.first().locator('a.edoxen-search-filter__result-title'))
.toHaveAttribute('title', '2025 Refs Plenary')
await expect(results.first().locator('a.edoxen-search-filter__result-title'))
.toHaveAttribute('href', '/meetings/urn:test:meeting:2025')
// UN/LOCODE resolved to a place name on the result card.
await expect(results.first()).toContainText('Berlin, Germany')
await expect(island).toHaveAttribute('data-filtering', 'true')
await expect(page.locator('section#decade-2020')).toBeHidden()
Expand All @@ -310,7 +315,8 @@ test.describe('fixture site — home, lists and data endpoints', () => {
const chChip = island.locator('.edoxen-search-filter__facet--country', { hasText: 'Switzerland' })
await chChip.click()
await expect(results).toHaveCount(1)
await expect(results.first()).toContainText('2026 Register Refs Plenary')
await expect(results.first().locator('a.edoxen-search-filter__result-title'))
.toHaveAttribute('title', '2026 Register Refs Plenary')
await expect(results.first()).toContainText('Geneva, Switzerland')
await expect(page).toHaveURL(/#.*countries=CH/)
await chChip.click()
Expand All @@ -319,7 +325,8 @@ test.describe('fixture site — home, lists and data endpoints', () => {
// The Virtual chip matches the online meeting (no city/country).
await virtualChip.click()
await expect(results).toHaveCount(1)
await expect(results.first()).toContainText('2027 Virtual Plenary')
await expect(results.first().locator('a.edoxen-search-filter__result-title'))
.toHaveAttribute('title', '2027 Virtual Plenary')
await expect(results.first()).toContainText('🌐 Virtual')
await expect(page).toHaveURL(/#.*countries=virtual/)
await virtualChip.click()
Expand All @@ -329,34 +336,44 @@ test.describe('fixture site — home, lists and data endpoints', () => {
await expect(y2026).toContainText('(1)')
await y2026.click()
await expect(results).toHaveCount(1)
await expect(results.first()).toContainText('2026 Register Refs Plenary')
await expect(results.first()).toContainText('Geneva, Switzerland')
await y2026.click()

// Meeting-type facet narrows by enum value and round-trips via hash.
// All three fixture meetings are type=plenary, so the chip matches all 3.
const plenaryChip = island.locator('.edoxen-search-filter__facet--meeting-type', { hasText: 'Plenary' })
await plenaryChip.click()
await expect(results).toHaveCount(3)
await expect(page).toHaveURL(/#.*types=plenary/)
await plenaryChip.click()
})

test('JSON data endpoints are served from the built site', async ({ request }) => {
const decisions = await request.get('/data/decisions.json')
expect(decisions.ok()).toBeTruthy()
const decisionsBody = (await decisions.json()) as {
items: Array<Record<string, unknown>>
facetActions: string[]
}
expect(decisionsBody.items.length).toBeGreaterThan(0)
const first = decisionsBody.items.find((i) => i['urn'] === 'urn:test:resolution:1')
expect(first?.['actionTypes']).toEqual(['publishes', 'thanks'])
expect(first?.['date']).toBe('2024-06-15')
expect(first?.['snippet']).toContain('Publishes the test standard')
expect(first?.['meetingUrn']).toBe('urn:test:meeting:2025')
expect(decisionsBody.facetActions).toContain('publishes')

const meetings = await request.get('/data/meetings.json')
expect(meetings.ok()).toBeTruthy()
const meetingsBody = (await meetings.json()) as { items: Array<Record<string, unknown>> }
const meetingsBody = (await meetings.json()) as {
items: Array<Record<string, unknown>>
}
const m2025 = meetingsBody.items.find((i) => i['urn'] === 'urn:test:meeting:2025')
// The island searches the flattened title + committee code + city.
// The island searches the flattened title + committee code + city + type
// and computes all facet counts client-side from items[].
expect(typeof m2025?.['title']).toBe('string')
expect(m2025?.['committeeCode']).toBe('sc-1')
expect(m2025?.['city']).toBe('DEBER')
expect(m2025?.['countryCode']).toBe('DE')
expect(m2025?.['type']).toBe('plenary')

const registers = await request.get('/data/registers.json')
expect(registers.ok()).toBeTruthy()
Expand Down Expand Up @@ -467,5 +484,11 @@ test.describe('fixture site — home, lists and data endpoints', () => {
await expect(footer.locator('.edoxen-footer__links a', { hasText: 'Committee home' })).toHaveAttribute('href', 'https://example.org/committee')
// Bottom bar
await expect(footer.locator('.edoxen-footer__copy')).toContainText(`© ${new Date().getFullYear()} TEST/TC 1`)
// Edoxen attribution carries the brand mark (both theme variants inlined,
// CSS toggles visibility) and links to edoxen.org.
const edoxenLink = footer.locator('a.edoxen-footer__edoxen-link')
await expect(edoxenLink).toHaveAttribute('href', 'https://www.edoxen.org')
await expect(edoxenLink.locator('.edoxen-brand-mark__svg--light svg')).toBeVisible()
await expect(edoxenLink.locator('.edoxen-brand-mark__svg--dark svg')).toBeHidden()
})
})
1 change: 1 addition & 0 deletions packages/browser/src/astro/assets/edoxen-logo-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading