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
Binary file modified calendar-agenda/calendar-agenda.zip
Binary file not shown.
4 changes: 2 additions & 2 deletions calendar-agenda/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion calendar-agenda/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "bulwark-plugin-calendar-agenda",
"version": "1.4.3",
"version": "1.4.4",
"private": true,
"type": "module",
"scripts": {
Expand Down
21 changes: 14 additions & 7 deletions calendar-agenda/src/lib/agenda.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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' });
}

Expand All @@ -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([]);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down
57 changes: 55 additions & 2 deletions calendar-agenda/test/agenda.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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)']);
});
});
54 changes: 52 additions & 2 deletions test-utils/fake-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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.
Expand All @@ -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));
Expand All @@ -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;
Expand Down