mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 08:07:29 +00:00
Every save in the event editor rebuilt the participants with new ids and put every attendee back to needs-action, the user's own answer on an invitation included. Saves now keep each attendee's id and answer; new attendees are asked. As on the web, only an organizer who moves an event (its start, end, all-day or recurrence rule; not a time zone rewrite, and not a zero-length event) asks the others again. The series scopes did not fit that: - "All events" wrote the opened occurrence's start, length, zone and participants onto the series: a stretched occurrence stretched every one, and one day's decline spread to the whole series. The series now keeps its own list and takes only the attendees the user added or removed, and its times change only when the occurrence moved (by the same delta), its rule only when it changed. - "This and following" makes a new series, which Stalwart sends only to attendees who have not answered: its attendees are asked again. - Stalwart sends a new occurrence override, the first change to one occurrence, only to attendees who have not answered (0.16.6 to 1.0). A change that keeps answers now goes out in two writes: the override as the occurrence stands without scheduling, then the change as an update of it, to the occurrence looked up again. Checked live on 1.0 and 0.16.23. - A save of an invitation someone else organizes writes no participants while the user left them alone, and never asks for scheduling, so the organizer gets no reply for a changed alert. The switch to send invitations is hidden there. Also: - a reminder change on one occurrence of a series that uses the calendar's default alerts turns the defaults off for the series first (keeping what it shows under ids of its own); 1.0 kept showing the defaults - all-day and floating occurrences expanded on the device no longer carry the master's utcStart/utcEnd, so their reminders are scheduled for each day; master instants that are not the master's own (0.16.23's year 32548, or the next occurrence's) are ignored - the editor warns when the attendees and the organizer go over the calendars capability's maxParticipantsPerEvent, and a refusal for it (Stalwart 1.0 enforces it, answers included) is explained in the editor, the series dialogs, the detail sheet and the invitation banner, in all languages; a failed save used to fail silently - refused event creates, updates and answers keep their SetError type - Calendar/get asks for its properties by name
307 lines
12 KiB
TypeScript
307 lines
12 KiB
TypeScript
/**
|
|
* Custom recurrence rule helpers for the event editor. Mobile port of
|
|
* webmail's components/calendar/recurrence-editor.tsx logic (rule detection,
|
|
* construction, and the human-readable summary).
|
|
*/
|
|
|
|
import { format, type Locale } from 'date-fns';
|
|
import type { RecurrenceRule } from '../api/types';
|
|
import type { TranslateFn } from '../stores/locale-store';
|
|
|
|
export type EditorFrequency = 'daily' | 'weekly' | 'monthly' | 'yearly';
|
|
export type MonthlyMode = 'day' | 'nth';
|
|
export type EndsMode = 'never' | 'on' | 'after';
|
|
|
|
export const EDITOR_FREQUENCIES: EditorFrequency[] = ['daily', 'weekly', 'monthly', 'yearly'];
|
|
|
|
// JSCalendar weekday ids in display order (week starts Monday, matching the
|
|
// firstDayOfWeek the built rules declare).
|
|
export const WEEKDAYS: string[] = ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'];
|
|
const DAY_TO_REF_DATE: Record<string, number> = { mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6, su: 7 };
|
|
export const INDEX_TO_DAY = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'];
|
|
|
|
// The editor's unit picker (webmail UNIT_LABEL_KEYS): key and English text.
|
|
export const UNIT_LABEL_KEYS: Record<EditorFrequency, [string, string]> = {
|
|
daily: ['calendar.recurrence.editor_unit_days', 'day(s)'],
|
|
weekly: ['calendar.recurrence.editor_unit_weeks', 'week(s)'],
|
|
monthly: ['calendar.recurrence.editor_unit_months', 'month(s)'],
|
|
yearly: ['calendar.recurrence.editor_unit_years', 'year(s)'],
|
|
};
|
|
|
|
// 2024-01-01 is a Monday — used to render weekday names via date-fns.
|
|
export function weekdayName(day: string, style: 'long' | 'short' = 'long', locale?: Locale): string {
|
|
const ref = new Date(2024, 0, DAY_TO_REF_DATE[day] ?? 1);
|
|
return format(ref, style === 'long' ? 'EEEE' : 'EEE', { locale });
|
|
}
|
|
|
|
export function monthName(month: number, locale?: Locale): string {
|
|
return format(new Date(2024, month - 1, 1), 'LLLL', { locale });
|
|
}
|
|
|
|
export function nthLabel(nth: number, t: TranslateFn): string {
|
|
switch (nth) {
|
|
case 1: return t('calendar.recurrence.nth_1', 'first');
|
|
case 2: return t('calendar.recurrence.nth_2', 'second');
|
|
case 3: return t('calendar.recurrence.nth_3', 'third');
|
|
case 4: return t('calendar.recurrence.nth_4', 'fourth');
|
|
case -1: return t('calendar.recurrence.nth_last', 'last');
|
|
default: return String(nth);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* "Daily" / "Every 3 weeks" for a rule's frequency and interval, or null for
|
|
* a frequency without a label (hourly etc.).
|
|
*/
|
|
export function recurrenceIntervalLabel(frequency: string, interval: number, t: TranslateFn): string | null {
|
|
const every = interval > 1;
|
|
switch (frequency) {
|
|
case 'daily':
|
|
return every
|
|
? t('calendar.recurrence.every_count_days', '{count, plural, one {Every # day} other {Every # days}}', { count: interval })
|
|
: t('calendar.recurrence.daily', 'Daily');
|
|
case 'weekly':
|
|
return every
|
|
? t('calendar.recurrence.every_count_weeks', '{count, plural, one {Every # week} other {Every # weeks}}', { count: interval })
|
|
: t('calendar.recurrence.weekly', 'Weekly');
|
|
case 'monthly':
|
|
return every
|
|
? t('calendar.recurrence.every_count_months', '{count, plural, one {Every # month} other {Every # months}}', { count: interval })
|
|
: t('calendar.recurrence.monthly', 'Monthly');
|
|
case 'yearly':
|
|
return every
|
|
? t('calendar.recurrence.every_count_years', '{count, plural, one {Every # year} other {Every # years}}', { count: interval })
|
|
: t('calendar.recurrence.yearly', 'Yearly');
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** "12 occurrences" / "Until Mar 1, 2027" for a rule that ends, else null. */
|
|
export function recurrenceEndLabel(rule: RecurrenceRule, t: TranslateFn, locale?: Locale): string | null {
|
|
if (rule.count) {
|
|
return t('calendar.recurrence.occurrence_count', '{count, plural, one {# occurrence} other {# occurrences}}', { count: rule.count });
|
|
}
|
|
if (rule.until) {
|
|
const d = new Date(rule.until);
|
|
if (!isNaN(d.getTime())) {
|
|
return t('calendar.recurrence.until_date', 'Until {date}', { date: format(d, 'MMM d, yyyy', { locale }) });
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function capitalize(s: string): string {
|
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
}
|
|
|
|
/**
|
|
* Extract an "nth weekday" pattern from a rule, accepting both the
|
|
* byDay+nthOfPeriod encoding and the byDay+bySetPosition encoding.
|
|
*/
|
|
export function getNthDay(rule: RecurrenceRule): { day: string; nth: number } | null {
|
|
if (rule.byDay?.length === 1) {
|
|
const nd = rule.byDay[0];
|
|
if (nd.nthOfPeriod) return { day: nd.day, nth: nd.nthOfPeriod };
|
|
if (rule.bySetPosition?.length === 1) return { day: nd.day, nth: rule.bySetPosition[0] };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* True when the rule is exactly what the plain Daily/Weekly/Monthly/Yearly
|
|
* dropdown presets produce, i.e. it needs no custom editor to represent.
|
|
*/
|
|
export function isSimpleRecurrenceRule(rule: RecurrenceRule): boolean {
|
|
return (
|
|
(EDITOR_FREQUENCIES as string[]).includes(rule.frequency) &&
|
|
(!rule.interval || rule.interval === 1) &&
|
|
!rule.byDay?.length &&
|
|
!rule.byMonthDay?.length &&
|
|
!rule.byMonth?.length &&
|
|
!rule.byYearDay?.length &&
|
|
!rule.byWeekNo?.length &&
|
|
!rule.bySetPosition?.length &&
|
|
!rule.count &&
|
|
!rule.until
|
|
);
|
|
}
|
|
|
|
// A rule as the occurrences it makes: JSCalendar defaults filled in, empty
|
|
// lists left out, so an editor's rewrite of an unchanged rule compares equal
|
|
// to the server's normalised copy.
|
|
function ruleKey(rule: RecurrenceRule): string {
|
|
const list = <T,>(values: T[] | undefined) => (values?.length ? values : undefined);
|
|
return JSON.stringify({
|
|
frequency: rule.frequency.toLowerCase(),
|
|
interval: rule.interval || 1,
|
|
until: rule.until || undefined,
|
|
count: rule.count || undefined,
|
|
byDay: list(rule.byDay?.map((d) => `${d.nthOfPeriod ?? ''}${d.day.toLowerCase()}`)),
|
|
byMonth: list(rule.byMonth),
|
|
byMonthDay: list(rule.byMonthDay),
|
|
byYearDay: list(rule.byYearDay),
|
|
byWeekNo: list(rule.byWeekNo),
|
|
byHour: list(rule.byHour),
|
|
byMinute: list(rule.byMinute),
|
|
bySecond: list(rule.bySecond),
|
|
bySetPosition: list(rule.bySetPosition),
|
|
firstDayOfWeek: (rule.firstDayOfWeek || 'mo').toLowerCase(),
|
|
rscale: (rule.rscale || 'gregorian').toLowerCase(),
|
|
skip: (rule.skip || 'omit').toLowerCase(),
|
|
});
|
|
}
|
|
|
|
/** Whether two recurrence rule lists make the same occurrences (none equals none). */
|
|
export function recurrenceRulesEqual(
|
|
a: RecurrenceRule[] | null | undefined,
|
|
b: RecurrenceRule[] | null | undefined,
|
|
): boolean {
|
|
const left = (a ?? []).map(ruleKey);
|
|
const right = (b ?? []).map(ruleKey);
|
|
return left.length === right.length && left.every((key, i) => key === right[i]);
|
|
}
|
|
|
|
/**
|
|
* Human-readable summary of a recurrence rule, e.g.
|
|
* "Every 2 months on the third Thursday · 12 occurrences", in the app
|
|
* language (from the same catalog fragments the webmail builds it from).
|
|
* Returns null for frequencies the UI cannot describe (hourly etc.).
|
|
*/
|
|
export function buildRecurrenceSummary(rule: RecurrenceRule, t: TranslateFn, locale?: Locale): string | null {
|
|
const base = recurrenceIntervalLabel(rule.frequency, rule.interval || 1, t);
|
|
if (!base) return null;
|
|
|
|
const parts = [base];
|
|
|
|
if (rule.frequency === 'weekly' && rule.byDay?.length) {
|
|
const days = rule.byDay
|
|
.filter((d) => WEEKDAYS.includes(d.day))
|
|
.sort((a, b) => WEEKDAYS.indexOf(a.day) - WEEKDAYS.indexOf(b.day))
|
|
.map((d) => weekdayName(d.day, 'short', locale))
|
|
.join(', ');
|
|
if (days) parts.push(t('calendar.recurrence.on_days', 'on {days}', { days }));
|
|
}
|
|
|
|
if (rule.frequency === 'monthly' || rule.frequency === 'yearly') {
|
|
if (rule.frequency === 'yearly' && rule.byMonth?.length) {
|
|
const m = parseInt(rule.byMonth[0], 10);
|
|
if (m >= 1 && m <= 12) parts.push(t('calendar.recurrence.in_month', 'in {month}', { month: monthName(m, locale) }));
|
|
}
|
|
const nthDay = getNthDay(rule);
|
|
if (nthDay) {
|
|
parts.push(t('calendar.recurrence.on_the_nth', 'on the {nth} {day}', {
|
|
nth: nthLabel(nthDay.nth, t),
|
|
day: weekdayName(nthDay.day, 'long', locale),
|
|
}));
|
|
} else if (rule.byMonthDay?.length) {
|
|
parts.push(t('calendar.recurrence.on_day_n', 'on day {day}', { day: rule.byMonthDay[0] }));
|
|
}
|
|
}
|
|
|
|
const summary = parts.join(' ');
|
|
const end = recurrenceEndLabel(rule, t, locale);
|
|
return end ? `${summary} · ${end}` : summary;
|
|
}
|
|
|
|
export interface RecurrenceEditorValue {
|
|
frequency: EditorFrequency;
|
|
interval: number;
|
|
weekDays: string[];
|
|
monthlyMode: MonthlyMode;
|
|
monthDay: number;
|
|
nth: number;
|
|
nthDay: string;
|
|
month: number;
|
|
endsMode: EndsMode;
|
|
untilDate: string; // yyyy-MM-dd
|
|
count: number;
|
|
}
|
|
|
|
/** Build the editor's initial state from an existing rule (or defaults). */
|
|
export function editorValueFromRule(
|
|
rule: RecurrenceRule | null,
|
|
eventStart: Date,
|
|
): RecurrenceEditorValue {
|
|
const startDay = INDEX_TO_DAY[eventStart.getDay()];
|
|
const nthDayInfo = rule ? getNthDay(rule) : null;
|
|
|
|
const weekDays = (() => {
|
|
if (rule?.frequency === 'weekly' && rule.byDay?.length) {
|
|
const days = rule.byDay.map((d) => d.day).filter((d) => WEEKDAYS.includes(d));
|
|
if (days.length) return days;
|
|
}
|
|
return [startDay];
|
|
})();
|
|
|
|
const monthFromRule = rule?.byMonth?.length ? parseInt(rule.byMonth[0], 10) : NaN;
|
|
const untilFromRule = (() => {
|
|
if (rule?.until) {
|
|
const d = new Date(rule.until);
|
|
if (!isNaN(d.getTime())) return format(d, 'yyyy-MM-dd');
|
|
}
|
|
const inAYear = new Date(eventStart);
|
|
inAYear.setFullYear(inAYear.getFullYear() + 1);
|
|
return format(inAYear, 'yyyy-MM-dd');
|
|
})();
|
|
|
|
return {
|
|
frequency: rule && (EDITOR_FREQUENCIES as string[]).includes(rule.frequency)
|
|
? (rule.frequency as EditorFrequency)
|
|
: 'weekly',
|
|
interval: rule?.interval || 1,
|
|
weekDays,
|
|
monthlyMode: nthDayInfo ? 'nth' : 'day',
|
|
monthDay: rule?.byMonthDay?.[0] && rule.byMonthDay[0] >= 1 && rule.byMonthDay[0] <= 31
|
|
? rule.byMonthDay[0]
|
|
: eventStart.getDate(),
|
|
nth: nthDayInfo && (nthDayInfo.nth === -1 || (nthDayInfo.nth >= 1 && nthDayInfo.nth <= 4))
|
|
? nthDayInfo.nth
|
|
: Math.min(4, Math.floor((eventStart.getDate() - 1) / 7) + 1),
|
|
nthDay: nthDayInfo && WEEKDAYS.includes(nthDayInfo.day) ? nthDayInfo.day : startDay,
|
|
month: monthFromRule >= 1 && monthFromRule <= 12 ? monthFromRule : eventStart.getMonth() + 1,
|
|
endsMode: rule?.count ? 'after' : rule?.until ? 'on' : 'never',
|
|
untilDate: untilFromRule,
|
|
count: rule?.count ?? 12,
|
|
};
|
|
}
|
|
|
|
/** Build the JSCalendar rule the editor state describes. */
|
|
export function buildRuleFromEditorValue(
|
|
value: RecurrenceEditorValue,
|
|
eventStart: Date,
|
|
): RecurrenceRule {
|
|
const startDay = INDEX_TO_DAY[eventStart.getDay()];
|
|
const built: RecurrenceRule = {
|
|
'@type': 'RecurrenceRule',
|
|
frequency: value.frequency,
|
|
interval: Math.max(1, value.interval),
|
|
// No rscale/skip: Stalwart serialises them into the RRULE as
|
|
// RSCALE=GREGORIAN;SKIP=OMIT, which DAVx5 rejects and which breaks
|
|
// Android CalDAV sync for the whole calendar (#805).
|
|
firstDayOfWeek: 'mo',
|
|
};
|
|
|
|
if (value.endsMode === 'after') {
|
|
built.count = Math.max(1, value.count);
|
|
} else if (value.endsMode === 'on' && value.untilDate) {
|
|
built.until = `${value.untilDate}T23:59:59`;
|
|
}
|
|
|
|
if (value.frequency === 'weekly') {
|
|
const days = value.weekDays.length ? value.weekDays : [startDay];
|
|
built.byDay = WEEKDAYS.filter((d) => days.includes(d)).map((day) => ({ day }));
|
|
} else if (value.frequency === 'monthly' || value.frequency === 'yearly') {
|
|
if (value.monthlyMode === 'nth') {
|
|
built.byDay = [{ day: value.nthDay, nthOfPeriod: value.nth }];
|
|
} else {
|
|
built.byMonthDay = [Math.min(31, Math.max(1, value.monthDay))];
|
|
}
|
|
if (value.frequency === 'yearly') {
|
|
built.byMonth = [String(value.month)];
|
|
}
|
|
}
|
|
|
|
return built;
|
|
}
|