diff --git a/calendar-agenda/calendar-agenda.zip b/calendar-agenda/calendar-agenda.zip index 5a82da2..f444ffc 100644 Binary files a/calendar-agenda/calendar-agenda.zip and b/calendar-agenda/calendar-agenda.zip differ diff --git a/calendar-agenda/manifest.json b/calendar-agenda/manifest.json index 3ac4e3f..903b0d8 100644 --- a/calendar-agenda/manifest.json +++ b/calendar-agenda/manifest.json @@ -1,7 +1,7 @@ { "id": "calendar-agenda", "name": "Calendar Agenda", - "version": "1.4.3", + "version": "1.4.4", "author": "Bulwark Mail Community", "description": "Shows an agenda of your upcoming calendar events in the sidebar.", "type": "sidebar-app", @@ -12,7 +12,7 @@ "ui:app-top-banner" ], "entrypoint": "index.js", - "minAppVersion": "1.15.0", + "minAppVersion": "1.18.0", "icon": "media/icon.svg", "banner": "media/banner.svg", "settingsSchema": { diff --git a/calendar-agenda/package.json b/calendar-agenda/package.json index c6f19de..f8a8dbe 100644 --- a/calendar-agenda/package.json +++ b/calendar-agenda/package.json @@ -1,6 +1,6 @@ { "name": "bulwark-plugin-calendar-agenda", - "version": "1.4.3", + "version": "1.4.4", "private": true, "type": "module", "scripts": { diff --git a/calendar-agenda/src/lib/agenda.js b/calendar-agenda/src/lib/agenda.js index 1d77497..a5ab153 100644 --- a/calendar-agenda/src/lib/agenda.js +++ b/calendar-agenda/src/lib/agenda.js @@ -52,7 +52,7 @@ export function durationMs(duration) { * event's `start` is wall-clock time in its own zone, so its UTC start * places it; an all-day event starts at local midnight. */ -export function toAgendaEvent(view, untitled, colors = new Map()) { +export function toAgendaEvent(view, untitled = '', colors = new Map()) { const allDay = !!view.isAllDay; const start = !allDay && view.utcStart ? new Date(view.utcStart) : new Date(view.start); const end = !allDay && view.utcEnd ? new Date(view.utcEnd) : new Date(start.getTime() + durationMs(view.duration)); @@ -117,17 +117,23 @@ export function groupByDay(events, now = new Date()) { export function makeFormat(api, now = () => new Date()) { const t = (key, vars) => api.i18n.t(key, vars); - const locale = () => api.i18n.locale || (api.i18n.getLocale && api.i18n.getLocale()) || undefined; + const locale = () => api.i18n.dateLocale || api.i18n.locale || (api.i18n.getLocale && api.i18n.getLocale()) || undefined; function dayLabel(date) { const diffDays = Math.round((startOfDay(date).getTime() - startOfDay(now()).getTime()) / 86_400_000); if (diffDays === 0) return t('day.today'); if (diffDays === 1) return t('day.tomorrow'); + if (typeof api.i18n?.formatDate === 'function') { + return api.i18n.formatDate(date, { weekday: 'short', month: 'short', day: 'numeric' }); + } return date.toLocaleDateString(locale(), { weekday: 'short', month: 'short', day: 'numeric' }); } function timeLabel(ev) { if (ev.allDay) return t('event.all_day'); + if (typeof api.i18n?.formatTime === 'function') { + return api.i18n.formatTime(ev.start, { hour: '2-digit', minute: '2-digit' }); + } return new Date(ev.start).toLocaleTimeString(locale(), { hour: '2-digit', minute: '2-digit' }); } @@ -147,9 +153,10 @@ export function makeAgenda(React, api, { slot = DEFAULT_SLOT, now } = {}) { const fmt = makeFormat(api, now); const { t } = fmt; - // A cancelled event keeps its row, struck through. - const titleOf = (ev) => (ev.status === 'cancelled' ? h('del', null, ev.title) : ev.title); - const tooltip = (ev) => [ev.title, fmt.when(ev), ev.location].filter(Boolean).join(' · '); + // A cancelled event keeps its row, struck through. Fall back to "(No title)" if title is blank. + const titleTextOf = (ev) => ev.title || t('event.untitled'); + const titleOf = (ev) => (ev.status === 'cancelled' ? h('del', null, titleTextOf(ev)) : titleTextOf(ev)); + const tooltip = (ev) => [titleTextOf(ev), fmt.when(ev), ev.location].filter(Boolean).join(' · '); function useAgenda() { const [events, setEvents] = useState([]); @@ -167,7 +174,7 @@ export function makeAgenda(React, api, { slot = DEFAULT_SLOT, now } = {}) { if (inFlightRef.current) return; inFlightRef.current = true; try { - setEvents(await loadAgenda(api, { days, limit, now: now ? now() : new Date(), untitled: t('event.untitled') })); + setEvents(await loadAgenda(api, { days, limit, now: now ? now() : new Date() })); setState('ready'); } catch (err) { const text = String((err && err.message) || err); @@ -268,7 +275,7 @@ export function makeAgenda(React, api, { slot = DEFAULT_SLOT, now } = {}) { const { events, message } = useAgenda(); return h(InfoBar, { compact: true, tone: 'primary', icon: 'calendar', eyebrow: t('header.title'), title: message || undefined }, ...(message ? [] : events.slice(0, BANNER_EVENTS).map((ev) => h(Chip, { - key: ev.id, label: h(React.Fragment, null, chipDot(ev), ev.title), detail: fmt.when(ev), title: tooltip(ev), + key: ev.id, label: h(React.Fragment, null, chipDot(ev), titleOf(ev)), detail: fmt.when(ev), title: tooltip(ev), }))), !message && events.length > BANNER_EVENTS ? h(Text, { key: 'more', size: 'xs', tone: 'muted' }, `+${events.length - BANNER_EVENTS}`) : null); } diff --git a/calendar-agenda/test/agenda.test.mjs b/calendar-agenda/test/agenda.test.mjs index 1eeb1bc..569b4fd 100644 --- a/calendar-agenda/test/agenda.test.mjs +++ b/calendar-agenda/test/agenda.test.mjs @@ -41,9 +41,22 @@ const events = [ const calendars = [{ id: 'cal', name: 'Work', color: '#3b82f6', isVisible: true, isDefault: true, isTasksOnly: false }]; -function setup(slot, { getEvents = async () => events, listCalendars = async () => calendars, settings = { daysAhead: 7 } } = {}) { +function setup(slot, { + getEvents = async () => events, + listCalendars = async () => calendars, + settings = { daysAhead: 7 }, + locale = 'en', + dateLocale, + timeFormat, + timeZone, +} = {}) { const api = fakeApi({ - translations: manifest.locales, settings, + translations: manifest.locales, + settings, + locale, + dateLocale, + timeFormat, + timeZone, handlers: { 'calendar.getEvents': getEvents, 'calendar.listCalendars': listCalendars }, }); const Agenda = makeAgenda(React, api, { slot, now: NOW }); @@ -224,3 +237,43 @@ describe('reading pane and top banner', () => { expect(bar.querySelectorAll('span[title]')).toHaveLength(3); }); }); + +describe('regional formatting and locale settings', () => { + it('formats times in 24h when timeFormat is 24h', async () => { + const { Agenda } = setup('sidebar-section', { + timeFormat: '24h', + getEvents: async () => [timed('a', 'Afternoon Meeting', new Date(2026, 9, 14, 14, 30), 30)], + }); + await show(Agenda); + expect(document.body.textContent).toContain('Afternoon Meeting14:30'); + }); + + it('formats times in 12h when timeFormat is 12h', async () => { + const { Agenda } = setup('sidebar-section', { + timeFormat: '12h', + getEvents: async () => [timed('a', 'Afternoon Meeting', new Date(2026, 9, 14, 14, 30), 30)], + }); + await show(Agenda); + expect(document.body.textContent).toMatch(/Afternoon Meeting(2:30|02:30)\s*PM/); + }); + + it('formats date headings using dateLocale when provided', async () => { + const { Agenda } = setup('sidebar-section', { + dateLocale: 'de', + getEvents: async () => [allDay('n', 'Future', '2026-10-18')], + }); + await show(Agenda); + const headings = dayHeadings(); + // 'de' locale short weekday for Sunday is 'So.' + expect(headings.some((h) => h.includes('So.'))).toBe(true); + }); + + it('resolves untitled fallback dynamically in the active locale', async () => { + const { Agenda } = setup('sidebar-section', { + locale: 'de', + getEvents: async () => [timed('x', '', new Date(2026, 9, 14, 11, 0), 30)], + }); + await show(Agenda); + expect(rowTitles()).toEqual(['(Kein Titel)']); + }); +}); diff --git a/test-utils/fake-api.mjs b/test-utils/fake-api.mjs index 199ccc9..a85c066 100644 --- a/test-utils/fake-api.mjs +++ b/test-utils/fake-api.mjs @@ -12,12 +12,27 @@ // the call and returns what `handlers['anything.method']` returns (undefined // without a handler). storage, i18n and plugin.settings work for real. -export function fakeApi({ id = 'test-plugin', version = '1.0.0', settings = {}, locale = 'en', translations = {}, handlers = {} } = {}) { +export function fakeApi({ + id = 'test-plugin', + version = '1.0.0', + settings = {}, + locale = 'en', + dateLocale, + timeFormat, + timeZone, + firstDayOfWeek = 1, + translations = {}, + handlers = {}, +} = {}) { const log = []; const store = new Map(); const settingsListeners = []; const localeListeners = []; let currentLocale = locale; + let currentDateLocale = dateLocale; + let currentTimeFormat = timeFormat; + let currentTimeZone = timeZone; + let currentFirstDayOfWeek = firstDayOfWeek; const call = (method, args) => { log.push({ method, args }); @@ -34,6 +49,20 @@ export function fakeApi({ id = 'test-plugin', version = '1.0.0', settings = {}, return undefined; }; + const formatIntl = (date, options = {}) => { + try { + const d = date instanceof Date ? date : new Date(date); + if (isNaN(d.getTime())) return ''; + const opts = { + ...(currentTimeZone ? { timeZone: currentTimeZone } : {}), + ...options, + }; + return new Intl.DateTimeFormat(currentDateLocale ?? currentLocale, opts).format(d); + } catch { + return ''; + } + }; + const known = { plugin: { // Same shape as the real api.plugin: no permission list. @@ -49,6 +78,19 @@ export function fakeApi({ id = 'test-plugin', version = '1.0.0', settings = {}, keys: async () => [...store.keys()], }, i18n: { + get locale() { return currentLocale; }, + get dateLocale() { return currentDateLocale ?? currentLocale; }, + get timeFormat() { return currentTimeFormat ?? (new Intl.DateTimeFormat(currentDateLocale ?? currentLocale, { hour: 'numeric' }).resolvedOptions().hour12 ? '12h' : '24h'); }, + get hour12() { return currentTimeFormat ? currentTimeFormat === '12h' : (new Intl.DateTimeFormat(currentDateLocale ?? currentLocale, { hour: 'numeric' }).resolvedOptions().hour12 ?? false); }, + get timeZone() { return currentTimeZone ?? (Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'); }, + get firstDayOfWeek() { return currentFirstDayOfWeek; }, + formatTime: (date, options = {}) => formatIntl(date, { + hour: '2-digit', + minute: '2-digit', + ...(currentTimeFormat ? { hour12: currentTimeFormat === '12h' } : {}), + ...options, + }), + formatDate: (date, options = {}) => formatIntl(date, options), t(key, vars) { const text = lookup(key) ?? key; return text.replace(/\{(\w+)\}/g, (m, name) => (vars && name in vars ? String(vars[name]) : m)); @@ -70,7 +112,15 @@ export function fakeApi({ id = 'test-plugin', version = '1.0.0', settings = {}, const api = new Proxy(known, { get(target, name) { if (name === 'calls') return (method) => (method ? log.filter((c) => c.method === method) : [...log]); - if (name === 'setLocale') return (l) => { currentLocale = l; for (const fn of localeListeners) fn(l); }; + if (name === 'setLocale') return (l, regional = {}) => { + currentLocale = l; + if (regional.dateLocale !== undefined) currentDateLocale = regional.dateLocale; + if (regional.hour12 !== undefined) currentTimeFormat = regional.hour12 ? '12h' : '24h'; + if (regional.timeFormat !== undefined) currentTimeFormat = regional.timeFormat; + if (regional.timeZone !== undefined) currentTimeZone = regional.timeZone; + if (regional.firstDayOfWeek !== undefined) currentFirstDayOfWeek = regional.firstDayOfWeek; + for (const fn of localeListeners) fn(l); + }; if (name === 'changeSettings') return (next) => { Object.assign(target.plugin.settings, next); for (const fn of settingsListeners) fn({ ...target.plugin.settings }); }; if (name === 'storageSnapshot') return () => Object.fromEntries(store); if (typeof name !== 'string') return undefined;