diff --git a/.changeset/meeting-card-place-headline.md b/.changeset/meeting-card-place-headline.md new file mode 100644 index 0000000..64541b2 --- /dev/null +++ b/.changeset/meeting-card-place-headline.md @@ -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 `` 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. diff --git a/packages/browser/README.md b/packages/browser/README.md index 9682ce0..a400b20 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -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 diff --git a/packages/browser/docs/i18n-keys.yaml b/packages/browser/docs/i18n-keys.yaml index aa0813e..9108abc 100644 --- a/packages/browser/docs/i18n-keys.yaml +++ b/packages/browser/docs/i18n-keys.yaml @@ -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 diff --git a/packages/browser/e2e/smoke.spec.ts b/packages/browser/e2e/smoke.spec.ts index 4379b36..85786f2 100644 --- a/packages/browser/e2e/smoke.spec.ts +++ b/packages/browser/e2e/smoke.spec.ts @@ -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') @@ -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)') @@ -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() @@ -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() @@ -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() @@ -329,8 +336,16 @@ 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 }) => { @@ -338,7 +353,6 @@ test.describe('fixture site — home, lists and data endpoints', () => { 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') @@ -346,17 +360,20 @@ test.describe('fixture site — home, lists and data endpoints', () => { 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() @@ -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() }) }) diff --git a/packages/browser/src/astro/assets/edoxen-logo-dark.svg b/packages/browser/src/astro/assets/edoxen-logo-dark.svg new file mode 100644 index 0000000..f0def02 --- /dev/null +++ b/packages/browser/src/astro/assets/edoxen-logo-dark.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-fe64e4c5-fd17-4ec2-899b-c282a2dc72d4" xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><path d="m32.95,14.17v67.65M63.05,14.17v67.65M2.87,48h90.27m-3-33.83H5.87c-1.66,0-3,1.34-3,3v61.65c0,1.66,1.34,3,3,3h84.27c1.66,0,3-1.34,3-3V17.17c0-1.66-1.34-3-3-3Z" style="fill:none; stroke:#bae6fd; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/><g style="isolation:isolate;"><path d="m14.6,21.22h3.32c2.04,0,4.2-.02,6.47-.05l.08.11-.79,4.22-.55.1-.85-3.16h-6.32v7.52h1.24c.95,0,2,0,3.17-.03.24,0,.39-.13.46-.36l.48-1.85.74-.03c-.03,1.28-.04,2.22-.04,2.83s.01,1.52.04,2.79l-.74.05-.49-1.87c-.07-.24-.21-.36-.42-.37-1.19-.02-2.26-.03-3.21-.03h-1.23v4.98c0,1.01,0,2.01.03,2.99h6.56l1.67-3.47.62-.11-.3,4.76-.1.1c-2.36-.03-4.54-.04-6.54-.04h-3.25c-1.1,0-2.23.01-3.39.04l-.05-.59,1.68-.51c.23-.06.35-.21.37-.42,0-.36.02-.77.02-1.22,0-.45,0-.95,0-1.5v-11.09c0-.46,0-.88,0-1.26,0-.37-.01-.71-.02-1.01,0-.24-.12-.39-.33-.46l-1.7-.53-.03-.59c1.18.04,2.31.05,3.39.05Z" style="fill:#bae6fd; stroke-width:0px;"/></g><g style="isolation:isolate;"><path d="m55.99,40.35c-1.49-.03-2.81-.04-3.96-.04h-8.56c-1.04,0-2.24.01-3.6.04l-.05-.59,7.83-18.68,1.39-.31,6.89,18.99.05.59Zm-8.3-15.89l-5.46,13.81h10.34l-4.88-13.81Z" style="fill:#bae6fd; stroke-width:0px;"/></g><g style="isolation:isolate;"><path d="m87.89,30.33c0,3.15-.9,5.69-2.68,7.61-1.79,1.92-4.29,2.88-7.51,2.88s-5.41-.89-7.08-2.66c-1.67-1.77-2.51-4.17-2.51-7.2s.9-5.65,2.7-7.49,4.29-2.75,7.48-2.75,5.43.86,7.1,2.58c1.67,1.72,2.51,4.06,2.51,7.03Zm-16.79.18c0,2.97.6,5.25,1.8,6.83s2.97,2.37,5.29,2.37,3.98-.72,5.07-2.15c1.09-1.44,1.63-3.69,1.63-6.76,0-2.88-.58-5.09-1.74-6.64-1.16-1.55-2.94-2.32-5.36-2.32s-4.04.72-5.11,2.15c-1.06,1.44-1.59,3.61-1.59,6.53Z" style="fill:#bae6fd; stroke-width:0px;"/></g><g style="isolation:isolate;"><path d="m25.38,71.07l.27,4.89-.1.1c-2.24-.03-4.39-.04-6.44-.04h-2.21c-2.04,0-4.2.01-6.47.04l-.08-.1.72-4.81.55-.08.55,2.71h11.91l.75-2.62.55-.08Zm-14.22-9.21l-.23-4.87.1-.11c2.24.04,4.38.05,6.43.05h1.07c2.05,0,4.21-.02,6.48-.05l.08.11-.71,4.77-.53.1-.6-2.8h-10.73l-.79,2.71-.55.1Zm6.84,5.41c-.82,0-1.67,0-2.56.03-.24,0-.39.13-.45.36l-.31,1.31-.74.04c.03-1.21.04-2.14.04-2.79s-.01-1.51-.04-2.65l.74-.07.31,1.27c.06.24.2.36.41.37.9.02,1.77.03,2.61.03s1.68,0,2.56-.03c.24,0,.39-.13.46-.36l.31-1.23.72-.04c-.03,1.15-.04,2.05-.04,2.71s.01,1.54.04,2.73l-.72.07-.33-1.35c-.07-.24-.21-.36-.42-.37-.89-.02-1.76-.03-2.6-.03Z" style="fill:#bae6fd; stroke-width:0px;"/></g><g style="isolation:isolate;"><path d="m44.6,56.94h3.32c2.04,0,4.2-.02,6.47-.05l.08.11-.79,4.22-.55.1-.85-3.16h-6.32v7.52h1.24c.95,0,2,0,3.17-.03.24,0,.39-.13.46-.36l.48-1.85.74-.03c-.03,1.28-.04,2.22-.04,2.83s.01,1.52.04,2.79l-.74.05-.49-1.87c-.07-.24-.21-.36-.42-.37-1.19-.02-2.26-.03-3.21-.03h-1.23v4.98c0,1.01,0,2.01.03,2.99h6.56l1.67-3.47.62-.11-.3,4.76-.1.1c-2.36-.03-4.54-.04-6.54-.04h-3.25c-1.1,0-2.23.01-3.39.04l-.05-.59,1.68-.51c.23-.06.35-.21.37-.42,0-.36.02-.77.02-1.22,0-.45,0-.95,0-1.5v-11.09c0-.46,0-.88,0-1.26,0-.37-.01-.71-.02-1.01,0-.24-.12-.39-.33-.46l-1.7-.53-.03-.59c1.18.04,2.31.05,3.39.05Z" style="fill:#bae6fd; stroke-width:0px;"/></g><g style="isolation:isolate;"><path d="m72.18,60.78v11.05c0,1.08,0,1.97.03,2.68,0,.25.12.41.34.46l1.63.48.03.62c-1.14-.03-2.07-.04-2.8-.04s-1.65.01-2.83.04l-.05-.59,1.68-.51c.24-.07.36-.21.37-.42.02-.74.03-1.65.03-2.72v-11.09c0-.91,0-1.67-.03-2.27,0-.24-.12-.39-.33-.46l-1.7-.53-.03-.59c1.18.04,2.11.05,2.79.05.41,0,.82-.02,1.24-.05l11.48,14.34v-10.54c0-.88,0-1.65-.03-2.28,0-.21-.13-.35-.36-.42l-1.61-.46-.03-.63c1.17.04,2.11.05,2.83.05s1.66-.02,2.8-.05l.05.59-1.72.53c-.21.07-.32.23-.33.46-.02.6-.03,1.36-.03,2.27v15.29l-1.15.11-12.29-15.35Z" style="fill:#bae6fd; stroke-width:0px;"/></g></svg> \ No newline at end of file diff --git a/packages/browser/src/astro/assets/edoxen-logo-light.svg b/packages/browser/src/astro/assets/edoxen-logo-light.svg new file mode 100644 index 0000000..3c3c283 --- /dev/null +++ b/packages/browser/src/astro/assets/edoxen-logo-light.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="UTF-8"?><svg id="uuid-fe64e4c5-fd17-4ec2-899b-c282a2dc72d4" xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><path d="m32.95,14.17v67.65M63.05,14.17v67.65M2.87,48h90.27m-3-33.83H5.87c-1.66,0-3,1.34-3,3v61.65c0,1.66,1.34,3,3,3h84.27c1.66,0,3-1.34,3-3V17.17c0-1.66-1.34-3-3-3Z" style="fill:none; stroke:#0c4a6e; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/><g style="isolation:isolate;"><path d="m14.6,21.22h3.32c2.04,0,4.2-.02,6.47-.05l.08.11-.79,4.22-.55.1-.85-3.16h-6.32v7.52h1.24c.95,0,2,0,3.17-.03.24,0,.39-.13.46-.36l.48-1.85.74-.03c-.03,1.28-.04,2.22-.04,2.83s.01,1.52.04,2.79l-.74.05-.49-1.87c-.07-.24-.21-.36-.42-.37-1.19-.02-2.26-.03-3.21-.03h-1.23v4.98c0,1.01,0,2.01.03,2.99h6.56l1.67-3.47.62-.11-.3,4.76-.1.1c-2.36-.03-4.54-.04-6.54-.04h-3.25c-1.1,0-2.23.01-3.39.04l-.05-.59,1.68-.51c.23-.06.35-.21.37-.42,0-.36.02-.77.02-1.22,0-.45,0-.95,0-1.5v-11.09c0-.46,0-.88,0-1.26,0-.37-.01-.71-.02-1.01,0-.24-.12-.39-.33-.46l-1.7-.53-.03-.59c1.18.04,2.31.05,3.39.05Z" style="fill:#0c4a6e; stroke-width:0px;"/></g><g style="isolation:isolate;"><path d="m55.99,40.35c-1.49-.03-2.81-.04-3.96-.04h-8.56c-1.04,0-2.24.01-3.6.04l-.05-.59,7.83-18.68,1.39-.31,6.89,18.99.05.59Zm-8.3-15.89l-5.46,13.81h10.34l-4.88-13.81Z" style="fill:#0c4a6e; stroke-width:0px;"/></g><g style="isolation:isolate;"><path d="m87.89,30.33c0,3.15-.9,5.69-2.68,7.61-1.79,1.92-4.29,2.88-7.51,2.88s-5.41-.89-7.08-2.66c-1.67-1.77-2.51-4.17-2.51-7.2s.9-5.65,2.7-7.49,4.29-2.75,7.48-2.75,5.43.86,7.1,2.58c1.67,1.72,2.51,4.06,2.51,7.03Zm-16.79.18c0,2.97.6,5.25,1.8,6.83s2.97,2.37,5.29,2.37,3.98-.72,5.07-2.15c1.09-1.44,1.63-3.69,1.63-6.76,0-2.88-.58-5.09-1.74-6.64-1.16-1.55-2.94-2.32-5.36-2.32s-4.04.72-5.11,2.15c-1.06,1.44-1.59,3.61-1.59,6.53Z" style="fill:#0c4a6e; stroke-width:0px;"/></g><g style="isolation:isolate;"><path d="m25.38,71.07l.27,4.89-.1.1c-2.24-.03-4.39-.04-6.44-.04h-2.21c-2.04,0-4.2.01-6.47.04l-.08-.1.72-4.81.55-.08.55,2.71h11.91l.75-2.62.55-.08Zm-14.22-9.21l-.23-4.87.1-.11c2.24.04,4.38.05,6.43.05h1.07c2.05,0,4.21-.02,6.48-.05l.08.11-.71,4.77-.53.1-.6-2.8h-10.73l-.79,2.71-.55.1Zm6.84,5.41c-.82,0-1.67,0-2.56.03-.24,0-.39.13-.45.36l-.31,1.31-.74.04c.03-1.21.04-2.14.04-2.79s-.01-1.51-.04-2.65l.74-.07.31,1.27c.06.24.2.36.41.37.9.02,1.77.03,2.61.03s1.68,0,2.56-.03c.24,0,.39-.13.46-.36l.31-1.23.72-.04c-.03,1.15-.04,2.05-.04,2.71s.01,1.54.04,2.73l-.72.07-.33-1.35c-.07-.24-.21-.36-.42-.37-.89-.02-1.76-.03-2.6-.03Z" style="fill:#0c4a6e; stroke-width:0px;"/></g><g style="isolation:isolate;"><path d="m44.6,56.94h3.32c2.04,0,4.2-.02,6.47-.05l.08.11-.79,4.22-.55.1-.85-3.16h-6.32v7.52h1.24c.95,0,2,0,3.17-.03.24,0,.39-.13.46-.36l.48-1.85.74-.03c-.03,1.28-.04,2.22-.04,2.83s.01,1.52.04,2.79l-.74.05-.49-1.87c-.07-.24-.21-.36-.42-.37-1.19-.02-2.26-.03-3.21-.03h-1.23v4.98c0,1.01,0,2.01.03,2.99h6.56l1.67-3.47.62-.11-.3,4.76-.1.1c-2.36-.03-4.54-.04-6.54-.04h-3.25c-1.1,0-2.23.01-3.39.04l-.05-.59,1.68-.51c.23-.06.35-.21.37-.42,0-.36.02-.77.02-1.22,0-.45,0-.95,0-1.5v-11.09c0-.46,0-.88,0-1.26,0-.37-.01-.71-.02-1.01,0-.24-.12-.39-.33-.46l-1.7-.53-.03-.59c1.18.04,2.31.05,3.39.05Z" style="fill:#0c4a6e; stroke-width:0px;"/></g><g style="isolation:isolate;"><path d="m72.18,60.78v11.05c0,1.08,0,1.97.03,2.68,0,.25.12.41.34.46l1.63.48.03.62c-1.14-.03-2.07-.04-2.8-.04s-1.65.01-2.83.04l-.05-.59,1.68-.51c.24-.07.36-.21.37-.42.02-.74.03-1.65.03-2.72v-11.09c0-.91,0-1.67-.03-2.27,0-.24-.12-.39-.33-.46l-1.7-.53-.03-.59c1.18.04,2.11.05,2.79.05.41,0,.82-.02,1.24-.05l11.48,14.34v-10.54c0-.88,0-1.65-.03-2.28,0-.21-.13-.35-.36-.42l-1.61-.46-.03-.63c1.17.04,2.11.05,2.83.05s1.66-.02,2.8-.05l.05.59-1.72.53c-.21.07-.32.23-.33.46-.02.6-.03,1.36-.03,2.27v15.29l-1.15.11-12.29-15.35Z" style="fill:#0c4a6e; stroke-width:0px;"/></g></svg> \ No newline at end of file diff --git a/packages/browser/src/astro/components/EdoxenBrandMark.astro b/packages/browser/src/astro/components/EdoxenBrandMark.astro new file mode 100644 index 0000000..a62f220 --- /dev/null +++ b/packages/browser/src/astro/components/EdoxenBrandMark.astro @@ -0,0 +1,54 @@ +--- +// The Edoxen brand mark — small wordmark used in the footer attribution. +// Two variants are inlined and toggled by `data-theme` on <html>: the +// light variant (dark ink, for light backgrounds) shows in light mode; +// the dark variant (light ink, for dark backgrounds) shows in dark mode. +// Both inline so the mark works on subpath hosts without asset-path +// juggling and survives consumer basePath changes. +import lightSvg from '../assets/edoxen-logo-light.svg?raw' +import darkSvg from '../assets/edoxen-logo-dark.svg?raw' + +interface Props { + /** Optional alt text for screen readers; defaults to "Edoxen". */ + alt?: string +} +const { alt = 'Edoxen' } = Astro.props +--- + +<span class="edoxen-brand-mark" role="img" aria-label={alt}> + <span class="edoxen-brand-mark__svg edoxen-brand-mark__svg--light" set:html={lightSvg} aria-hidden="true"></span> + <span class="edoxen-brand-mark__svg edoxen-brand-mark__svg--dark" set:html={darkSvg} aria-hidden="true"></span> +</span> + +<style> +.edoxen-brand-mark { + display: inline-flex; + align-items: middle; + vertical-align: middle; +} +.edoxen-brand-mark__svg { + display: none; + width: 1.5em; + height: 1.5em; + line-height: 0; +} +.edoxen-brand-mark__svg :global(svg) { + width: 100%; + height: 100%; + display: block; +} +/* Default (no data-theme or data-theme=auto → light): show light-on-dark variant */ +.edoxen-brand-mark__svg--light { + display: inline-block; +} +.edoxen-brand-mark__svg--dark { + display: none; +} +/* Dark theme: flip the visible variant */ +:global([data-theme="dark"]) .edoxen-brand-mark__svg--light { + display: none; +} +:global([data-theme="dark"]) .edoxen-brand-mark__svg--dark { + display: inline-block; +} +</style> diff --git a/packages/browser/src/astro/components/MeetingCard.astro b/packages/browser/src/astro/components/MeetingCard.astro index 4903140..fa2d0d9 100644 --- a/packages/browser/src/astro/components/MeetingCard.astro +++ b/packages/browser/src/astro/components/MeetingCard.astro @@ -1,7 +1,7 @@ --- import type { MeetingListItem } from '../../data/index.js' import { isVirtualMeeting } from '../../data/index.js' -import { pickLocalizedValue, formatDateRange, regionName, t } from '../../i18n/index.js' +import { pickLocalizedValue, formatDateRange, regionName, t, meetingTypeLabel } from '../../i18n/index.js' import { urnToPath } from '../../urn.js' import { countryFlag } from '@edoxen/edoxen' import cfg from 'virtual:edoxen-config' @@ -12,7 +12,7 @@ interface Props { urlPrefix?: string } const { item: m, lang = 'en', urlPrefix: prefix = '/' } = Astro.props -const title = pickLocalizedValue(m.title, lang, m.urn) +const fullTitle = pickLocalizedValue(m.title, lang, m.urn) const dateLabel = m.startDate ? formatDateRange(m.startDate, m.endDate, lang) : '' const virtual = isVirtualMeeting(m) const flag = !virtual && m.countryCode ? countryFlag(m.countryCode) : '' @@ -20,6 +20,14 @@ const flag = !virtual && m.countryCode ? countryFlag(m.countryCode) : '' // the country renders as a localized name, not the raw alpha-2 code. const city = m.cityNames?.[lang] ?? m.cityNames?.[lang.slice(0, 2)] ?? m.cityNames?.en ?? m.city ?? '' const country = !virtual && m.countryCode ? regionName(m.countryCode, lang) : '' +// Browse views (index, home) compose the headline from place — matches the +// original venue+year browse model and avoids restating dates/committee the +// meta row already carries. Fall back to the entity title when no place. +const placeLabel = virtual + ? t('meeting.virtual', lang, cfg.uiStrings, cfg.terminology) + : [city, country].filter(Boolean).join(', ') +const headline = placeLabel || fullTitle +const typeLabel = meetingTypeLabel(m.type, lang, cfg.terminology, cfg.uiStrings) --- <li class="edoxen-meeting-card edoxen-card"> @@ -29,6 +37,9 @@ const country = !virtual && m.countryCode ? regionName(m.countryCode, lang) : '' <time datetime={m.startDate}>{dateLabel}</time> </span> )} + {typeLabel && ( + <span class="edoxen-meeting-card__type">{typeLabel}</span> + )} {m.status && ( <span class="edoxen-badge edoxen-badge--status" data-status={m.status}>{m.status}</span> )} @@ -41,18 +52,11 @@ const country = !virtual && m.countryCode ? regionName(m.countryCode, lang) : '' </span> )} </div> - <a class="edoxen-meeting-card__title" href={`${prefix}meetings/${urnToPath(m.urn)}`}>{title}</a> - {virtual ? ( - <p class="edoxen-meeting-card__place"> - <span class="edoxen-meeting-card__flag" aria-hidden="true">🌐</span> - <span>{t('meeting.virtual', lang, cfg.uiStrings, cfg.terminology)}</span> - </p> - ) : (city || country) && ( - <p class="edoxen-meeting-card__place"> - {flag && <span class="edoxen-meeting-card__flag" aria-hidden="true">{flag}</span>} - <span>{[city, country].filter(Boolean).join(', ')}</span> - </p> - )} + <a class="edoxen-meeting-card__title" href={`${prefix}meetings/${urnToPath(m.urn)}`} title={fullTitle}> + {virtual && <span class="edoxen-meeting-card__flag" aria-hidden="true">🌐</span>} + {!virtual && flag && <span class="edoxen-meeting-card__flag" aria-hidden="true">{flag}</span>} + <span class="edoxen-meeting-card__headline">{headline}</span> + </a> </li> <style> @@ -71,6 +75,14 @@ const country = !virtual && m.countryCode ? regionName(m.countryCode, lang) : '' font-variant-numeric: tabular-nums; white-space: nowrap; } +.edoxen-meeting-card__type { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--edoxen-color-muted); + white-space: nowrap; +} .edoxen-meeting-card__committee { font-family: var(--edoxen-font-mono, ui-monospace, "SF Mono", Menlo, monospace); font-size: 0.75rem; @@ -89,6 +101,9 @@ const country = !virtual && m.countryCode ? regionName(m.countryCode, lang) : '' white-space: nowrap; } .edoxen-meeting-card__title { + display: flex; + align-items: baseline; + gap: 0.375rem; font-family: var(--edoxen-font-display-resolved, "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, "Times New Roman", serif); font-size: 1.125rem; @@ -102,20 +117,12 @@ const country = !virtual && m.countryCode ? regionName(m.countryCode, lang) : '' color: var(--edoxen-color-accent); text-decoration: none; } -.edoxen-meeting-card__place { - margin: 0; - display: flex; - align-items: center; - gap: 0.375rem; - font-size: 0.9375rem; - color: var(--edoxen-color-muted); +.edoxen-meeting-card__headline { + flex: 1 1 auto; } .edoxen-meeting-card__flag { + flex: 0 0 auto; font-size: 1.1em; line-height: 1; } -.edoxen-meeting-card__country { - font-family: var(--edoxen-font-mono, ui-monospace, monospace); - font-size: 0.8125rem; -} </style> diff --git a/packages/browser/src/astro/components/MeetingDetail.astro b/packages/browser/src/astro/components/MeetingDetail.astro index 81a6083..7cffe17 100644 --- a/packages/browser/src/astro/components/MeetingDetail.astro +++ b/packages/browser/src/astro/components/MeetingDetail.astro @@ -18,6 +18,7 @@ import { formatDate, formatDateRange, humanizeVerb, + meetingTypeLabel, regionName, t, urlPrefix, @@ -121,7 +122,7 @@ function hostLabel(h: HostRef): string { <header class="edoxen-meeting__header edoxen-enter" style="--nth: 2"> <div class="edoxen-meta-row"> - {meeting.type && <span class="edoxen-badge">{meeting.type}</span>} + {meeting.type && <span class="edoxen-badge">{meetingTypeLabel(meeting.type, locale, cfg.terminology, cfg.uiStrings)}</span>} {meeting.status && ( <span class="edoxen-badge edoxen-badge--status" data-status={meeting.status}>{meeting.status}</span> )} diff --git a/packages/browser/src/astro/layouts/BaseLayout.astro b/packages/browser/src/astro/layouts/BaseLayout.astro index eb45715..89d6a63 100644 --- a/packages/browser/src/astro/layouts/BaseLayout.astro +++ b/packages/browser/src/astro/layouts/BaseLayout.astro @@ -4,6 +4,7 @@ import { generateCssTokens, resolveFooter } from '../../config/index.js' import { urlPrefix, pickLocalizedValue } from '../../i18n/index.js' import payloads from 'virtual:edoxen-payloads' import '../../../styles/base.css' +import EdoxenBrandMark from '../components/EdoxenBrandMark.astro' // Print styles are gated on features.printStyles: imported as a raw // string and inlined only when the flag is on, so `false` omits the // print CSS from the output entirely. @@ -212,7 +213,7 @@ const customCssImports = (config as EdoxenConfig & { customCssImports?: string[] <p class="edoxen-footer__copy">© {year} {committeeName}</p> {footer.showEdoxenAttribution && ( <p class="edoxen-footer__attribution"> - Powered by <a href="https://edoxen.github.io">Edoxen</a>. + Powered by <a href="https://www.edoxen.org" class="edoxen-footer__edoxen-link"><EdoxenBrandMark /></a> </p> )} </div> @@ -231,7 +232,7 @@ const customCssImports = (config as EdoxenConfig & { customCssImports?: string[] <p class="edoxen-footer__copyright">{footer.copyright}</p> {footer.showEdoxenAttribution && ( <p class="edoxen-footer__attribution"> - Powered by <a href="https://edoxen.github.io">Edoxen</a>. + Powered by <a href="https://www.edoxen.org" class="edoxen-footer__edoxen-link"><EdoxenBrandMark /></a> </p> )} </> diff --git a/packages/browser/src/config/schema.spec.ts b/packages/browser/src/config/schema.spec.ts index 0bb995c..4235b35 100644 --- a/packages/browser/src/config/schema.spec.ts +++ b/packages/browser/src/config/schema.spec.ts @@ -270,6 +270,7 @@ describe('EdoxenConfigSchema', () => { decisions: 'decisions', meeting: 'meeting', meetings: 'meetings', + meetingTypes: {}, }) }) diff --git a/packages/browser/src/config/schema.ts b/packages/browser/src/config/schema.ts index d5d116d..405031e 100644 --- a/packages/browser/src/config/schema.ts +++ b/packages/browser/src/config/schema.ts @@ -116,11 +116,18 @@ export type NavItem = z.infer<typeof NavItemSchema> // English string (nav, page titles, section headings, stat strip, // breadcrumbs, empty states, search placeholder) follows — see t() in // src/i18n/ui.ts for the resolution order. +// +// `meetingTypes` overrides the display label for a MeetingType enum value +// (plenary, working_group, …) per locale. Keys are UI locale codes +// (eng, fra, …); values map the enum value → display label. Unlisted +// locales or enum values fall back to the built-in i18n table, then to a +// humanized form of the enum value. export const TerminologySchema = z.object({ decision: z.string().min(1).default('decision'), decisions: z.string().min(1).default('decisions'), meeting: z.string().min(1).default('meeting'), meetings: z.string().min(1).default('meetings'), + meetingTypes: z.record(z.string(), z.record(z.string(), z.string())).default({}), }) export type Terminology = z.infer<typeof TerminologySchema> diff --git a/packages/browser/src/data/prepare.ts b/packages/browser/src/data/prepare.ts index 6f4f62d..304fbb2 100644 --- a/packages/browser/src/data/prepare.ts +++ b/packages/browser/src/data/prepare.ts @@ -58,6 +58,7 @@ export interface DecisionListItem { readonly meetingPageUrn?: string } +// Model-level facets — see MeetingListFacets comment above. export interface DecisionListFacets { readonly years: readonly number[] readonly kinds: readonly string[] @@ -80,6 +81,8 @@ export interface MeetingListItem { readonly endDate?: string readonly year?: number readonly bodyType?: string + /** MeetingType enum value (plenary, working_group, …) — drives the type badge + facet. */ + readonly type?: string readonly city?: string readonly countryCode?: string readonly status?: string @@ -91,10 +94,15 @@ export interface MeetingListItem { readonly cityNames?: Readonly<Record<string, string>> } +// Model-level facets — used for SSR (decade sections on /meetings, the +// About-page committee facts, JSON-LD, etc.). The /data/*.json endpoints +// do NOT project these on the wire; the search island derives its chip +// counts client-side from items[] (single source of truth, no drift). export interface MeetingListFacets { readonly decades: readonly number[] readonly bodies: readonly string[] readonly countries: readonly string[] + readonly types: readonly string[] } export interface MeetingListPayload { @@ -238,6 +246,7 @@ function toMeetingListItem(m: Meeting, unlocodes?: UnlocodeNames): MeetingListIt endDate: m.scheduled_date_range?.end, year: year ?? undefined, bodyType: m.body_type, + type: m.type, city: m.city, countryCode: m.country_code, status: m.status, @@ -280,6 +289,7 @@ export function prepareMeetingsList(project: EdoxenProject, unlocodes?: Unlocode decades: uniqSortedNumbers(decadesSet), bodies: uniqSorted(project.meetings.map((m) => m.body_type ?? '')), countries: uniqSorted(project.meetings.map((m) => m.country_code ?? '')), + types: uniqSorted(project.meetings.map((m) => m.type ?? '')), }, } } diff --git a/packages/browser/src/i18n/index.ts b/packages/browser/src/i18n/index.ts index e45bb41..1345f0f 100644 --- a/packages/browser/src/i18n/index.ts +++ b/packages/browser/src/i18n/index.ts @@ -87,6 +87,7 @@ export { isRtl, availableUiLocales, applyTerminology, + meetingTypeLabel, DEFAULT_TERMINOLOGY, SUPPORTED_UI_LOCALES, LOCALE_LABELS, diff --git a/packages/browser/src/i18n/meeting-types.spec.ts b/packages/browser/src/i18n/meeting-types.spec.ts new file mode 100644 index 0000000..a15f638 --- /dev/null +++ b/packages/browser/src/i18n/meeting-types.spec.ts @@ -0,0 +1,80 @@ +// Drift detection: every canonical MeetingType enum value (mirrored from +// Edoxen::Enums::MEETING_TYPE) must have a `meeting.type.<value>` entry +// in the built-in English i18n table. When the gem adds a new value, +// this spec fails until the i18n table catches up. +// +// The canonical list lives in +// /Users/mulgogi/src/edoxen/edoxen-model/models/meeting_type.lutaml and +// propagates: gem (lib/edoxen/enums.rb) → schema ($defs/MeetingType) → +// generated TS type (MeetingType, in @edoxen/edoxen). The i18n table +// here is hand-maintained; this spec is the guardrail. + +import { describe, expect, it } from 'vitest' + +import { t, meetingTypeLabel } from './ui.js' + +const CANONICAL_MEETING_TYPES = [ + 'plenary', + 'working_group', + 'task_group', + 'ad_hoc', + 'joint', + 'general_assembly', + 'committee', + 'subcommittee', + 'conference', + 'workshop', + 'seminar', + 'webinar', + 'hearing', + 'markup', + 'board_meeting', + 'annual_general_meeting', + 'other', +] as const + +describe('MeetingType i18n coverage', () => { + it('every canonical enum value has an English meeting.type.* string', () => { + const missing = CANONICAL_MEETING_TYPES.filter((type) => { + const label = t(`meeting.type.${type}`, 'eng') + return !label || label === `meeting.type.${type}` + }) + expect(missing, `Missing English i18n for: ${missing.join(', ')}`).toEqual([]) + }) + + it('French locale has at least the common subset translated', () => { + const requiredFra = ['plenary', 'committee', 'subcommittee', 'other'] + const missing = requiredFra.filter((type) => { + const label = t(`meeting.type.${type}`, 'fra') + return !label || label === `meeting.type.${type}` || label === t(`meeting.type.${type}`, 'eng') + }) + expect(missing, `Missing French translations for: ${missing.join(', ')}`).toEqual([]) + }) +}) + +describe('meetingTypeLabel resolution', () => { + it('returns the curated English label for known values', () => { + expect(meetingTypeLabel('plenary', 'eng')).toBe('Plenary') + expect(meetingTypeLabel('annual_general_meeting', 'eng')).toBe('Annual General Meeting') + }) + + it('returns empty string for nullish input', () => { + expect(meetingTypeLabel(undefined, 'eng')).toBe('') + expect(meetingTypeLabel('', 'eng')).toBe('') + }) + + it('falls back to humanize for unknown values', () => { + expect(meetingTypeLabel('some_future_type', 'eng')).toBe('Some Future Type') + }) + + it('honors terminology.meetingTypes overrides per locale', () => { + const terminology = { + meetingTypes: { + eng: { plenary: 'Plénière CIML' }, + }, + } + expect(meetingTypeLabel('plenary', 'eng', terminology)).toBe('Plénière CIML') + // Non-overridden values still resolve normally. + expect(meetingTypeLabel('committee', 'eng', terminology)).toBe('Committee') + }) +}) diff --git a/packages/browser/src/i18n/ui.ts b/packages/browser/src/i18n/ui.ts index 512d7a3..040936a 100644 --- a/packages/browser/src/i18n/ui.ts +++ b/packages/browser/src/i18n/ui.ts @@ -88,6 +88,24 @@ const STRINGS: Readonly<Record<string, Readonly<Record<string, string>>>> = { 'search.groupBody': 'Body', 'meeting.virtual': 'Virtual', + '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', + 'nav.prev': '← Previous', 'nav.next': 'Next →', @@ -194,6 +212,24 @@ const STRINGS: Readonly<Record<string, Readonly<Record<string, string>>>> = { 'search.groupBody': 'Organe', 'meeting.virtual': 'Virtuel', + 'meeting.type.plenary': 'Plénière', + 'meeting.type.working_group': 'Groupe de travail', + 'meeting.type.task_group': 'Groupe de travail ad hoc', + 'meeting.type.ad_hoc': 'Ad hoc', + 'meeting.type.joint': 'Conjointe', + 'meeting.type.general_assembly': 'Assemblée générale', + 'meeting.type.committee': 'Comité', + 'meeting.type.subcommittee': 'Sous-comité', + 'meeting.type.conference': 'Conférence', + 'meeting.type.workshop': 'Atelier', + 'meeting.type.seminar': 'Séminaire', + 'meeting.type.webinar': 'Webinaire', + 'meeting.type.hearing': 'Audience', + 'meeting.type.markup': 'Markup', + 'meeting.type.board_meeting': 'Réunion du conseil', + 'meeting.type.annual_general_meeting': 'Assemblée générale annuelle', + 'meeting.type.other': 'Autre', + 'nav.prev': '← Précédent', 'nav.next': 'Suivant →', @@ -566,6 +602,7 @@ export const DEFAULT_TERMINOLOGY: Terminology = { decisions: 'decisions', meeting: 'meeting', meetings: 'meetings', + meetingTypes: {}, } function capitalize(s: string): string { @@ -647,3 +684,30 @@ export function t( export function availableUiLocales(configuredLocales: readonly { code: string }[]): string[] { return configuredLocales.map((l) => normalizeUiLocale(l.code)) } + +// Display label for a MeetingType enum value (plenary, working_group, …). +// Resolution order: terminology.meetingTypes[locale][type] → built-in +// `meeting.type.<value>` i18n string → humanized enum value. Consumers +// override per-locale via terminology.meetingTypes when their committee +// uses a different word (e.g. CIML meetings styled as "Plénière CIML"). +export function meetingTypeLabel( + type: string | undefined, + locale: string, + terminology?: Partial<Terminology>, + customStrings?: CustomUiStrings, +): string { + if (!type) return '' + const code = normalizeUiLocale(locale) + const override = terminology?.meetingTypes?.[code]?.[type] + if (override) return override + const i18n = t(`meeting.type.${type}`, locale, customStrings, terminology) + if (i18n && i18n !== `meeting.type.${type}`) return i18n + return humanizeEnum(type) +} + +function humanizeEnum(value: string): string { + return value + .split('_') + .map((part) => (part.length === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1))) + .join(' ') +} diff --git a/packages/browser/src/integration.ts b/packages/browser/src/integration.ts index 0592c46..acf2447 100644 --- a/packages/browser/src/integration.ts +++ b/packages/browser/src/integration.ts @@ -208,6 +208,11 @@ function dataEndpointPayload(cache: IntegrationCache, name: DataEndpointName): s // filters on. `subject` is NOT sent (the island's result card uses // title+snippet; the subject is on the server-rendered card and // the detail page). Snippets are clamped server-side. + // + // The island computes all facet counts client-side from items[] + // (single source of truth — see renderFacets in search-filter.ts). + // Do NOT add `facet<Foo>` projection arrays here without wiring + // them into the island; half-baked facets mislead callers. items: cache.payloads.decisionsList.items.map((d) => ({ urn: d.urn, identifier: d.identifier, @@ -224,11 +229,6 @@ function dataEndpointPayload(cache: IntegrationCache, name: DataEndpointName): s // without one there is no meeting page to link to. meetingUrn: d.meetingPageUrn ?? undefined, })), - facetBodies: [...cache.payloads.decisionsList.facets.bodies], - facetKinds: [...cache.payloads.decisionsList.facets.kinds], - facetYears: [...cache.payloads.decisionsList.facets.years], - facetActions: [...cache.payloads.decisionsList.facets.actionTypes], - facetStatuses: [...cache.payloads.decisionsList.facets.statuses], }) } if (name === 'registers') { @@ -243,9 +243,6 @@ function dataEndpointPayload(cache: IntegrationCache, name: DataEndpointName): s ...m, title: pickLocalizedValue(m.title, locale), })), - facetDecades: [...cache.payloads.meetingsList.facets.decades], - facetBodies: [...cache.payloads.meetingsList.facets.bodies], - facetCountries: [...cache.payloads.meetingsList.facets.countries], }) } diff --git a/packages/browser/src/islands/search-filter-core.spec.ts b/packages/browser/src/islands/search-filter-core.spec.ts index f7744b6..83d6a82 100644 --- a/packages/browser/src/islands/search-filter-core.spec.ts +++ b/packages/browser/src/islands/search-filter-core.spec.ts @@ -206,6 +206,7 @@ describe('encodeState / decodeState', () => { actions: new Set(['approves']), decades: new Set([2020]), countries: new Set(['DE', 'CH']), + types: new Set(['plenary']), dateFrom: '2023', dateTo: '2024-12-31', } @@ -218,6 +219,7 @@ describe('encodeState / decodeState', () => { expect([...restored.actions]).toEqual(['approves']) expect([...restored.decades]).toEqual([2020]) expect([...restored.countries].sort()).toEqual(['CH', 'DE']) + expect([...restored.types]).toEqual(['plenary']) expect(restored.dateFrom).toBe('2023') expect(restored.dateTo).toBe('2024-12-31') }) @@ -228,5 +230,6 @@ describe('encodeState / decodeState', () => { expect(restored.bodies.size).toBe(0) expect(restored.decades.size).toBe(0) expect(restored.countries.size).toBe(0) + expect(restored.types.size).toBe(0) }) }) diff --git a/packages/browser/src/islands/search-filter-core.ts b/packages/browser/src/islands/search-filter-core.ts index 869eba8..0ae3330 100644 --- a/packages/browser/src/islands/search-filter-core.ts +++ b/packages/browser/src/islands/search-filter-core.ts @@ -20,6 +20,8 @@ export interface SearchableItem { /** Meeting start (or only) ISO date. */ readonly startDate?: string readonly endDate?: string + /** MeetingType enum value (plenary, working_group, …). */ + readonly type?: string readonly city?: string readonly countryCode?: string /** Localized names for the UN/LOCODE in `city` (from data.unlocodes). */ @@ -37,6 +39,8 @@ export interface FilterState { readonly actions: ReadonlySet<string> readonly decades: ReadonlySet<number> readonly countries: ReadonlySet<string> + /** MeetingType enum values (plenary, working_group, …). */ + readonly types: ReadonlySet<string> /** Inclusive range bounds: an ISO date ('2024-06-15') or a bare year ('2024'). Either side may be omitted. */ readonly dateFrom?: string @@ -51,6 +55,7 @@ export const EMPTY_STATE: FilterState = { actions: new Set(), decades: new Set(), countries: new Set(), + types: new Set(), } export function decadeOfYear(year: number): number { @@ -109,9 +114,10 @@ export function filterItems<T extends SearchableItem>( if (state.decades.size > 0 && !state.decades.has(typeof item.year === 'number' ? decadeOfYear(item.year) : -1)) return false // Items with no country code (online meetings) match the Virtual chip. if (state.countries.size > 0 && !state.countries.has(item.countryCode ?? VIRTUAL_COUNTRY)) return false + if (state.types.size > 0 && !state.types.has(item.type ?? '')) return false if (!inDateRange(item, state)) return false if (q) { - const hay = `${item.title} ${item.urn} ${item.identifier ?? ''} ${item.snippet ?? ''} ${item.committeeCode ?? ''} ${item.city ?? ''}`.toLowerCase() + const hay = `${item.title} ${item.urn} ${item.identifier ?? ''} ${item.snippet ?? ''} ${item.committeeCode ?? ''} ${item.city ?? ''} ${item.type ?? ''}`.toLowerCase() if (!hay.includes(q)) return false } return true @@ -125,6 +131,7 @@ export interface FacetCounts { readonly actions: ReadonlyMap<string, number> readonly decades: ReadonlyMap<number, number> readonly countries: ReadonlyMap<string, number> + readonly types: ReadonlyMap<string, number> } export function countFacets<T extends SearchableItem>(items: readonly T[]): FacetCounts { @@ -134,6 +141,7 @@ export function countFacets<T extends SearchableItem>(items: readonly T[]): Face const actions = new Map<string, number>() const decades = new Map<number, number>() const countries = new Map<string, number>() + const types = new Map<string, number>() for (const item of items) { if (item.bodyType) bodies.set(item.bodyType, (bodies.get(item.bodyType) ?? 0) + 1) if (item.kind) kinds.set(item.kind, (kinds.get(item.kind) ?? 0) + 1) @@ -144,9 +152,10 @@ export function countFacets<T extends SearchableItem>(items: readonly T[]): Face } const country = item.countryCode ?? (item.startDate ? VIRTUAL_COUNTRY : undefined) if (country) countries.set(country, (countries.get(country) ?? 0) + 1) + if (item.type) types.set(item.type, (types.get(item.type) ?? 0) + 1) for (const a of item.actionTypes ?? []) actions.set(a, (actions.get(a) ?? 0) + 1) } - return { bodies, kinds, years, actions, decades, countries } + return { bodies, kinds, years, actions, decades, countries, types } } export function toggle<K>(set: ReadonlySet<K>, key: K): Set<K> { @@ -165,6 +174,7 @@ export function encodeState(state: FilterState): string { if (state.actions.size > 0) params.set('actions', [...state.actions].sort().join(',')) if (state.decades.size > 0) params.set('decades', [...state.decades].sort().map(String).join(',')) if (state.countries.size > 0) params.set('countries', [...state.countries].sort().join(',')) + if (state.types.size > 0) params.set('types', [...state.types].sort().join(',')) if (state.dateFrom) params.set('from', state.dateFrom) if (state.dateTo) params.set('to', state.dateTo) const s = params.toString() @@ -181,6 +191,7 @@ export function decodeState(hash: string): FilterState { const actions = (params.get('actions') ?? '').split(',').filter(Boolean) const decades = (params.get('decades') ?? '').split(',').filter(Boolean).map(Number).filter(Number.isFinite) const countries = (params.get('countries') ?? '').split(',').filter(Boolean) + const types = (params.get('types') ?? '').split(',').filter(Boolean) return { query: params.get('q') ?? '', bodies: new Set(bodies), @@ -189,6 +200,7 @@ export function decodeState(hash: string): FilterState { actions: new Set(actions), decades: new Set(decades), countries: new Set(countries), + types: new Set(types), dateFrom: params.get('from') ?? undefined, dateTo: params.get('to') ?? undefined, } @@ -203,5 +215,6 @@ function freshEmptyState(): FilterState { actions: new Set(), decades: new Set(), countries: new Set(), + types: new Set(), } } diff --git a/packages/browser/src/islands/search-filter.ts b/packages/browser/src/islands/search-filter.ts index 8960f92..b42d855 100644 --- a/packages/browser/src/islands/search-filter.ts +++ b/packages/browser/src/islands/search-filter.ts @@ -11,12 +11,6 @@ import { interface FetchResponse { items: SearchableItem[] - facetBodies?: string[] - facetKinds?: string[] - facetYears?: number[] - facetActions?: string[] - facetDecades?: number[] - facetCountries?: string[] } type SearchMode = 'decisions' | 'meetings' @@ -155,27 +149,31 @@ function buildMeetingListItem( if (item.identifier) meta.appendChild(makeBadge(item.identifier, 'edoxen-search-filter__result-id')) const dates = formatRange(item.startDate, item.endDate, lang) if (dates) meta.appendChild(makeBadge(dates, 'edoxen-search-filter__result-date')) + if (item.type) meta.appendChild(makeBadge(humanize(item.type), 'edoxen-search-filter__result-meeting-type')) if (item.committeeCode) meta.appendChild(makeBadge(item.committeeCode, 'edoxen-badge')) - // Online meeting (no city/country): globe + localized "Virtual"; - // otherwise flag + "City, Country" with UN/LOCODE resolved to a name. - if (!item.city && !item.countryCode) { - meta.appendChild(makeBadge(`\u{1F310} ${virtualLabel}`, 'edoxen-search-filter__result-date')) - } else { - const place = [cityName(item, lang), item.countryCode ? regionName(item.countryCode, lang) : ''] - .filter(Boolean) - .join(', ') - const flag = item.countryCode ? flagEmoji(item.countryCode) : '' - if (place) meta.appendChild(makeBadge(`${flag ? `${flag} ` : ''}${place}`, 'edoxen-search-filter__result-date')) - } if (item.decisionCount != null && item.decisionCount > 0) { meta.appendChild(makeBadge(`${item.decisionCount} ${decisionsLabel}`, 'edoxen-badge')) } if (meta.childElementCount > 0) main.appendChild(meta) + // Headline mirrors MeetingCard: place (City, Country) when known, else + // localized "Virtual", else the entity title. The meta row already shows + // dates and committee — don't repeat them in the headline. const link = document.createElement('a') link.className = 'edoxen-search-filter__result-title' link.href = `${basePath}/${urnToPath(item.urn)}` - link.textContent = item.title || item.urn + const city = cityName(item, lang) + const country = item.countryCode ? regionName(item.countryCode, lang) : '' + const place = [city, country].filter(Boolean).join(', ') + const flag = item.countryCode ? flagEmoji(item.countryCode) : '' + if (!item.city && !item.countryCode) { + link.textContent = `\u{1F310} ${virtualLabel}` + } else if (place) { + link.textContent = flag ? `${flag} ${place}` : place + } else { + link.textContent = item.title || item.urn + } + if (item.title) link.title = item.title main.appendChild(link) li.appendChild(main) @@ -372,6 +370,7 @@ class SearchFilter extends HTMLElement { const actions = new Map<string, number>() const years = new Map<number, number>() const countries = new Map<string, number>() + const types = new Map<string, number>() for (const item of this.items) { if (item.bodyType) bodies.set(item.bodyType, (bodies.get(item.bodyType) ?? 0) + 1) if (item.kind) kinds.set(item.kind, (kinds.get(item.kind) ?? 0) + 1) @@ -380,6 +379,7 @@ class SearchFilter extends HTMLElement { // Meetings without a country code are online — the Virtual chip. const country = item.countryCode ?? (this.mode === 'meetings' ? VIRTUAL_COUNTRY : undefined) if (country) countries.set(country, (countries.get(country) ?? 0) + 1) + if (item.type) types.set(item.type, (types.get(item.type) ?? 0) + 1) } const lang = document.documentElement.lang || 'en' const groups: HTMLElement[] = [] @@ -395,6 +395,16 @@ class SearchFilter extends HTMLElement { chip.classList.add('edoxen-search-filter__facet--year') return chip }) + // Meeting-type chips: humanize the enum value (plenary → Plenary). + const typeChips = [...types.keys()].sort().map((type) => { + const chip = makeFacetChip(humanize(type), types.get(type) ?? 0, this.state.types.has(type), () => { + this.state = { ...this.state, types: toggle(this.state.types, type) } + this.syncHash() + this.render() + }) + chip.classList.add('edoxen-search-filter__facet--meeting-type') + return chip + }) // Location chips: flag + country name, "🌐 Virtual" sorted last. const countryChips = [...countries.keys()] .sort((a, b) => (a === VIRTUAL_COUNTRY ? 1 : b === VIRTUAL_COUNTRY ? -1 : regionName(a, lang).localeCompare(regionName(b, lang)))) @@ -412,8 +422,10 @@ class SearchFilter extends HTMLElement { return chip }) const yearGroup = this.makeFacetGroup(this.groupYearLabel, yearChips) + const typeGroup = this.makeFacetGroup(this.groupTypeLabel, typeChips) const locationGroup = this.makeFacetGroup(this.groupLocationLabel, countryChips) if (yearGroup) groups.push(yearGroup) + if (typeGroup) groups.push(typeGroup) if (locationGroup) groups.push(locationGroup) }