feat: add a send-invitations switch and custom reminder offsets, keeping alerts the picker cannot show

This commit is contained in:
Linus Rath
2026-08-29 18:53:58 +02:00
parent c705227611
commit 2abea9742e
8 changed files with 277 additions and 40 deletions
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { formatReminder, preservedAlerts, remindersToAlerts } from '../calendar-alerts';
describe('preservedAlerts / remindersToAlerts', () => {
const alerts = {
a: { trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' }, action: 'display' },
abs: { trigger: { '@type': 'AbsoluteTrigger', when: '2026-03-01T08:00:00Z' }, action: 'display' },
end: { trigger: { '@type': 'OffsetTrigger', offset: '-PT5M', relativeTo: 'end' }, action: 'display' },
mail: { trigger: { '@type': 'OffsetTrigger', offset: '-PT1H' }, action: 'email' },
};
it('keeps alerts the picker cannot represent', () => {
expect(Object.keys(preservedAlerts(alerts)).sort()).toEqual(['abs', 'end', 'mail']);
expect(preservedAlerts(undefined)).toEqual({});
});
it('rebuilds the picker alerts on top of the preserved ones without key clashes', () => {
const rebuilt = remindersToAlerts([{ minutesBefore: 10 }, { minutesBefore: 0 }], preservedAlerts(alerts))!;
expect(Object.keys(rebuilt).sort()).toEqual(['abs', 'end', 'mail', 'reminder-1', 'reminder-2']);
expect(rebuilt['reminder-1'].trigger).toEqual({ '@type': 'OffsetTrigger', offset: '-PT10M', relativeTo: 'start' });
expect(rebuilt['reminder-2'].trigger.offset).toBe('PT0S');
expect(rebuilt.abs).toBe(alerts.abs);
});
it('returns undefined only when nothing is left', () => {
expect(remindersToAlerts([], {})).toBeUndefined();
expect(Object.keys(remindersToAlerts([], preservedAlerts(alerts))!)).toHaveLength(3);
});
});
describe('formatReminder', () => {
it('falls back to English plurals', () => {
expect(formatReminder(0)).toBe('At time of event');
expect(formatReminder(1)).toBe('1 minute before');
expect(formatReminder(45)).toBe('45 minutes before');
expect(formatReminder(120)).toBe('2 hours before');
expect(formatReminder(60 * 24)).toBe('1 day before');
expect(formatReminder(60 * 24 * 14)).toBe('2 weeks before');
});
it('uses a plain translated string and ignores ICU plural templates it cannot expand', () => {
const t = (key: string, fallback?: string) =>
key === 'calendar.alerts.hours_before' ? '# Stunden vorher'
: key === 'calendar.alerts.minutes_before' ? '{count, plural, one {# minute} other {# minutes}}'
: fallback ?? key;
expect(formatReminder(120, t)).toBe('2 Stunden vorher');
expect(formatReminder(5, t)).toBe('5 minutes before');
});
});
+65 -14
View File
@@ -69,34 +69,85 @@ export function alertsToReminders(alerts: CalendarEvent['alerts']): Reminder[] {
.sort((a, b) => b.minutesBefore - a.minutesBefore);
}
export function remindersToAlerts(reminders: Reminder[]): Record<string, Alert> | undefined {
if (reminders.length === 0) return undefined;
const alerts: Record<string, Alert> = {};
reminders.forEach((r, i) => {
alerts[`reminder-${i + 1}`] = {
trigger: { '@type': 'OffsetTrigger', offset: minutesBeforeToOffset(r.minutesBefore) },
// Alerts the picker can't represent (absolute triggers, end-relative offsets,
// non-display actions) — kept as-is so a save doesn't drop them.
export function preservedAlerts(alerts: CalendarEvent['alerts']): Record<string, Alert> {
const out: Record<string, Alert> = {};
if (!alerts) return out;
for (const [id, alert] of Object.entries(alerts)) {
const trigger = alert?.trigger;
if (!trigger) continue;
const representable =
trigger.offset !== undefined &&
(trigger.relativeTo === undefined || trigger.relativeTo === 'start') &&
(!alert.action || alert.action === 'display') &&
offsetToMinutesBefore(trigger.offset) !== null;
if (!representable) out[id] = alert;
}
return out;
}
export function remindersToAlerts(
reminders: Reminder[],
preserved: Record<string, Alert> = {},
): Record<string, Alert> | undefined {
if (reminders.length === 0 && Object.keys(preserved).length === 0) return undefined;
const alerts: Record<string, Alert> = { ...preserved };
let n = 0;
for (const r of reminders) {
let key = `reminder-${++n}`;
while (key in alerts) key = `reminder-${++n}`;
alerts[key] = {
'@type': 'Alert',
trigger: { '@type': 'OffsetTrigger', offset: minutesBeforeToOffset(r.minutesBefore), relativeTo: 'start' },
action: 'display',
};
});
}
return alerts;
}
export function formatReminder(minutesBefore: number): string {
if (minutesBefore === 0) return 'At time of event';
if (minutesBefore < 0) return `${Math.abs(minutesBefore)} minutes after`;
if (minutesBefore < 60) return `${minutesBefore} minute${minutesBefore === 1 ? '' : 's'} before`;
type Translate = (key: string, fallback?: string) => string;
const plain: Translate = (_key, fallback) => fallback ?? _key;
function plural(t: Translate, count: number, key: string, one: string, many: string): string {
// Locale files carry ICU plurals ("{count, plural, one {# minute before} other {# minutes before}}")
// that the mobile t() can't expand, so pick the English-style form and only
// use the translation when it is a plain string.
const raw = t(key, '');
if (raw && !raw.includes('{')) return raw.replace('#', String(count));
return (count === 1 ? one : many).replace('#', String(count));
}
export function formatReminder(minutesBefore: number, t: Translate = plain): string {
if (minutesBefore === 0) return t('calendar.alerts.at_time', 'At time of event');
if (minutesBefore < 0) return `${Math.abs(minutesBefore)} ${t('calendar.alerts.unit_minutes_after', 'minutes after')}`;
if (minutesBefore < 60) {
return plural(t, minutesBefore, 'calendar.alerts.minutes_before', '# minute before', '# minutes before');
}
if (minutesBefore < 60 * 24) {
const h = minutesBefore / 60;
return Number.isInteger(h) ? `${h} hour${h === 1 ? '' : 's'} before` : `${minutesBefore} minutes before`;
if (Number.isInteger(h)) return plural(t, h, 'calendar.alerts.hours_before', '# hour before', '# hours before');
return plural(t, minutesBefore, 'calendar.alerts.minutes_before', '# minute before', '# minutes before');
}
if (minutesBefore < 60 * 24 * 7) {
const d = minutesBefore / (60 * 24);
return Number.isInteger(d) ? `${d} day${d === 1 ? '' : 's'} before` : `${(minutesBefore / 60).toFixed(0)} hours before`;
if (Number.isInteger(d)) return plural(t, d, 'calendar.alerts.days_before', '# day before', '# days before');
return plural(t, Math.round(minutesBefore / 60), 'calendar.alerts.hours_before', '# hour before', '# hours before');
}
const w = minutesBefore / (60 * 24 * 7);
return Number.isInteger(w) ? `${w} week${w === 1 ? '' : 's'} before` : `${(minutesBefore / (60 * 24)).toFixed(0)} days before`;
if (Number.isInteger(w)) return plural(t, w, 'calendar.alerts.weeks_before', '# week before', '# weeks before');
return plural(t, Math.round(minutesBefore / (60 * 24)), 'calendar.alerts.days_before', '# day before', '# days before');
}
export type ReminderUnit = 'minutes' | 'hours' | 'days' | 'weeks';
export const REMINDER_UNIT_MINUTES: Record<ReminderUnit, number> = {
minutes: 1,
hours: 60,
days: 60 * 24,
weeks: 60 * 24 * 7,
};
// Presets offered by the reminder picker (minutes before start).
export const REMINDER_PRESETS: number[] = [
0, 5, 10, 15, 30, 60, 120, 60 * 24, 60 * 24 * 2, 60 * 24 * 7,