From 6a15216104f73bbcc0b3f020fe3880e7d80b4e71 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 28 Dec 2022 05:07:17 -0800 Subject: [PATCH] Add monthly variations for recurrence rules (#14849) * Add variations on monthly recurrence rules * Recurrence rule code simplificiation * Invalidate when the interval changes * update * Update ha-recurrence-rule-editor.ts Co-authored-by: Bram Kragten --- .../calendar/dialog-calendar-event-detail.ts | 50 +----- .../calendar/dialog-calendar-event-editor.ts | 2 + .../calendar/ha-recurrence-rule-editor.ts | 128 ++++++++++++-- src/panels/calendar/recurrence.ts | 166 +++++++++++++++++- 4 files changed, 282 insertions(+), 64 deletions(-) diff --git a/src/panels/calendar/dialog-calendar-event-detail.ts b/src/panels/calendar/dialog-calendar-event-detail.ts index 3f16f2c82c..8bc8f81512 100644 --- a/src/panels/calendar/dialog-calendar-event-detail.ts +++ b/src/panels/calendar/dialog-calendar-event-detail.ts @@ -4,15 +4,11 @@ import { addDays, isSameDay } from "date-fns/esm"; import { toDate } from "date-fns-tz"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { property, state } from "lit/decorators"; -import { RRule, Weekday } from "rrule"; import { formatDate } from "../../common/datetime/format_date"; import { formatDateTime } from "../../common/datetime/format_date_time"; import { formatTime } from "../../common/datetime/format_time"; import { fireEvent } from "../../common/dom/fire_event"; -import { capitalizeFirstLetter } from "../../common/string/capitalize-first-letter"; import { isDate } from "../../common/string/is_date"; -import { dayNames } from "../../common/translations/day_names"; -import { monthNames } from "../../common/translations/month_names"; import "../../components/entity/state-info"; import "../../components/ha-date-input"; import "../../components/ha-time-input"; @@ -23,10 +19,10 @@ import { import { haStyleDialog } from "../../resources/styles"; import { HomeAssistant } from "../../types"; import "../lovelace/components/hui-generic-entity-row"; -import "./ha-recurrence-rule-editor"; import { showConfirmEventDialog } from "./show-confirm-event-dialog-box"; import { CalendarEventDetailDialogParams } from "./show-dialog-calendar-event-detail"; import { showCalendarEventEditDialog } from "./show-dialog-calendar-event-editor"; +import { renderRRuleAsText } from "./recurrence"; class DialogCalendarEventDetail extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -137,54 +133,16 @@ class DialogCalendarEventDetail extends LitElement { return ""; } try { - const rule = RRule.fromString(`RRULE:${value}`); - if (rule.isFullyConvertibleToText()) { - return html`
- ${capitalizeFirstLetter( - rule.toText( - this._translateRRuleElement, - { - dayNames: dayNames(this.hass.locale), - monthNames: monthNames(this.hass.locale), - tokens: {}, - }, - this._formatDate - ) - )} -
`; + const ruleText = renderRRuleAsText(this.hass, value); + if (ruleText !== undefined) { + return html`
${ruleText}
`; } - return html`
Cannot convert recurrence rule
`; } catch (e) { return "Error while processing the rule"; } } - private _translateRRuleElement = (id: string | number | Weekday): string => { - if (typeof id === "string") { - return this.hass.localize(`ui.components.calendar.event.rrule.${id}`); - } - - return ""; - }; - - private _formatDate = (year: number, month: string, day: number): string => { - if (!year || !month || !day) { - return ""; - } - - // Build date so we can then format it - const date = new Date(); - date.setFullYear(year); - // As input we already get the localized month name, so we now unfortunately - // need to convert it back to something Date can work with. The already localized - // months names are a must in the RRule.Language structure (an empty string[] would - // mean we get undefined months input in this method here). - date.setMonth(monthNames(this.hass.locale).indexOf(month)); - date.setDate(day); - return formatDate(date, this.hass.locale); - }; - private _formatDateRange() { // Parse a dates in the browser timezone const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; diff --git a/src/panels/calendar/dialog-calendar-event-editor.ts b/src/panels/calendar/dialog-calendar-event-editor.ts index b7dacd23b3..613a048f7e 100644 --- a/src/panels/calendar/dialog-calendar-event-editor.ts +++ b/src/panels/calendar/dialog-calendar-event-editor.ts @@ -248,6 +248,8 @@ class DialogCalendarEventEditor extends LitElement { = new Set(); + @state() private _monthlyRepeat?: string; + + @state() private _monthlyRepeatWeekday?: Weekday; + + @state() private _monthday?: number; + @state() private _end: RepeatEnd = "never"; @state() private _count?: number; @state() private _until?: Date; + @query("#monthly") private _monthlyRepeatSelect!: HaSelect; + private _allWeekdays?: WeekdayStr[]; + private _monthlyRepeatItems: MonthlyRepeatItem[] = []; + protected willUpdate(changedProps: PropertyValues) { super.willUpdate(changedProps); @@ -61,12 +80,45 @@ export class RecurrenceRuleEditor extends LitElement { ); } - if (!changedProps.has("value") || this._computedRRule === this.value) { + if (changedProps.has("dtstart") || changedProps.has("_interval")) { + this._monthlyRepeatItems = this.dtstart + ? getMonthlyRepeatItems(this.hass, this._interval, this.dtstart) + : []; + this._computeWeekday(); + const selectElement = this._monthlyRepeatSelect; + if (selectElement) { + const oldSelected = selectElement.index; + selectElement.select(-1); + this.updateComplete.then(() => { + selectElement.select(changedProps.has("dtstart") ? 0 : oldSelected); + }); + } + } + + if ( + changedProps.has("timezone") || + changedProps.has("_freq") || + changedProps.has("_interval") || + changedProps.has("_weekday") || + changedProps.has("_monthlyRepeatWeekday") || + changedProps.has("_monthday") || + changedProps.has("_end") || + changedProps.has("_count") || + changedProps.has("_until") + ) { + this._updateRule(); + return; + } + + if (this._computedRRule === this.value) { return; } this._interval = 1; this._weekday.clear(); + this._monthlyRepeat = undefined; + this._monthday = undefined; + this._monthlyRepeatWeekday = undefined; this._end = "never"; this._count = undefined; this._until = undefined; @@ -88,6 +140,14 @@ export class RecurrenceRuleEditor extends LitElement { if (rrule.interval) { this._interval = rrule.interval; } + this._monthlyRepeatWeekday = getMonthlyRepeatWeekdayFromRule(rrule); + if (this._monthlyRepeatWeekday) { + this._monthlyRepeat = `BYDAY=${this._monthlyRepeatWeekday.toString()}`; + } + this._monthday = getMonthdayRepeatFromRule(rrule); + if (this._monthday) { + this._monthlyRepeat = `BYMONTHDAY=${this._monthday}`; + } if ( this._freq === "weekly" && rrule.byweekday && @@ -129,7 +189,28 @@ export class RecurrenceRuleEditor extends LitElement { } renderMonthly() { - return this.renderInterval(); + return html` + ${this.renderInterval()} + ${this._monthlyRepeatItems.length > 0 + ? html` + ${this._monthlyRepeatItems!.map( + (item) => html` + + ${item.label} + + ` + )} + ` + : html``} + `; } renderWeekly() { @@ -222,7 +303,6 @@ export class RecurrenceRuleEditor extends LitElement { private _onIntervalChange(e: Event) { this._interval = (e.target! as any).value; - this._updateRule(); } private _onRepeatSelected(e: CustomEvent>) { @@ -233,9 +313,20 @@ export class RecurrenceRuleEditor extends LitElement { } if (this._freq !== "weekly") { this._weekday.clear(); + this._computeWeekday(); } e.stopPropagation(); - this._updateRule(); + } + + private _onMonthlyDetailSelected(e: CustomEvent>) { + e.stopPropagation(); + const selectedItem = this._monthlyRepeatItems[e.detail.index]; + if (!selectedItem) { + return; + } + this._monthlyRepeat = selectedItem.value; + this._monthlyRepeatWeekday = selectedItem.byday; + this._monthday = selectedItem.bymonthday; } private _onWeekdayToggle(e: MouseEvent) { @@ -246,7 +337,6 @@ export class RecurrenceRuleEditor extends LitElement { } else { this._weekday.delete(value); } - this._updateRule(); } private _onEndSelected(e: CustomEvent>) { @@ -270,31 +360,47 @@ export class RecurrenceRuleEditor extends LitElement { this._until = undefined; } e.stopPropagation(); - this._updateRule(); } private _onCountChange(e: Event) { this._count = (e.target! as any).value; - this._updateRule(); } private _onUntilChange(e: CustomEvent) { e.stopPropagation(); this._until = new Date(e.detail.value); - this._updateRule(); + } + + // Reset the weekday selected when there is only a single value + private _computeWeekday() { + if (this.dtstart && this._weekday.size <= 1) { + const weekdayNum = getWeekday(this.dtstart); + this._weekday.clear(); + this._weekday.add(new Weekday(weekdayNum).toString() as WeekdayStr); + } } private _computeRRule() { if (this._freq === undefined || this._freq === "none") { return ""; } - const options = { + let byweekday: Weekday[] | undefined; + let bymonthday: number | undefined; + if (this._freq === "monthly" && this._monthlyRepeatWeekday !== undefined) { + byweekday = [this._monthlyRepeatWeekday]; + } else if (this._freq === "monthly" && this._monthday !== undefined) { + bymonthday = this._monthday; + } else if (this._freq === "weekly") { + byweekday = ruleByWeekDay(this._weekday); + } + const options: Partial = { freq: convertRepeatFrequency(this._freq!)!, interval: this._interval > 1 ? this._interval : undefined, - byweekday: ruleByWeekDay(this._weekday), count: this._count, until: this._until, tzid: this.timezone, + byweekday: byweekday, + bymonthday: bymonthday, }; const contentline = RRule.optionsToString(options); return contentline.slice(6); // Strip "RRULE:" prefix diff --git a/src/panels/calendar/recurrence.ts b/src/panels/calendar/recurrence.ts index dce91b7d6b..331a57cf5b 100644 --- a/src/panels/calendar/recurrence.ts +++ b/src/panels/calendar/recurrence.ts @@ -1,8 +1,22 @@ // Library for converting back and forth from values use by this webcomponent // and the values defined by rrule.js. -import { RRule, Frequency, Weekday } from "rrule"; -import type { WeekdayStr } from "rrule"; -import { addDays, addMonths, addWeeks, addYears } from "date-fns"; +import { + addDays, + addMonths, + addWeeks, + addYears, + getDate, + getDay, + isLastDayOfMonth, + isSameMonth, +} from "date-fns"; +import type { Options, WeekdayStr } from "rrule"; +import { Frequency, RRule, Weekday } from "rrule"; +import { formatDate } from "../../common/datetime/format_date"; +import { capitalizeFirstLetter } from "../../common/string/capitalize-first-letter"; +import { dayNames } from "../../common/translations/day_names"; +import { monthNames } from "../../common/translations/month_names"; +import { HomeAssistant } from "../../types"; export type RepeatFrequency = | "none" @@ -21,6 +35,13 @@ export const DEFAULT_COUNT = { daily: 30, }; +export interface MonthlyRepeatItem { + value: string; + byday?: Weekday; + bymonthday?: number; + label: string; +} + export function intervalSuffix(freq: RepeatFrequency) { if (freq === "monthly") { return "months"; @@ -101,7 +122,16 @@ export const WEEKDAYS = [ RRule.SA, ]; -export function getWeekdays(firstDay?: number) { +/** Return a weekday number compatible with rrule.js weekdays */ +export function getWeekday(dtstart: Date): number { + let weekDay = getDay(dtstart) - 1; + if (weekDay < 0) { + weekDay += 7; + } + return weekDay; +} + +export function getWeekdays(firstDay?: number): Weekday[] { if (firstDay === undefined || firstDay === 0) { return WEEKDAYS; } @@ -114,9 +144,7 @@ export function getWeekdays(firstDay?: number) { return weekDays; } -export function ruleByWeekDay( - weekdays: Set -): Weekday[] | undefined { +export function ruleByWeekDay(weekdays: Set): Weekday[] { return Array.from(weekdays).map((value: string) => { switch (value) { case "MO": @@ -138,3 +166,127 @@ export function ruleByWeekDay( } }); } + +/** + * Determine the recurrence options based on the day of the month. The + * return values are a Weekday object that represent a BYDAY for a + * particular week of the month like "first Saturday" or "last Friday". + */ +function getWeekydaysForMonth(dtstart: Date): Weekday[] { + const weekDay = getWeekday(dtstart); + const dayOfMonth = getDate(dtstart); + const nthWeekdayOfMonth = Math.floor((dayOfMonth - 1) / 7) + 1; + const isLastWeekday = !isSameMonth(dtstart, addDays(dtstart, 7)); + const byweekdays: Weekday[] = []; + if (!isLastWeekday || dayOfMonth <= 28) { + byweekdays.push(new Weekday(weekDay, nthWeekdayOfMonth)); + } + if (isLastWeekday) { + byweekdays.push(new Weekday(weekDay, -1)); + } + return byweekdays; +} + +/** + * Returns the list of repeat values available for the specified date. + */ +export function getMonthlyRepeatItems( + hass: HomeAssistant, + interval: number, + dtstart: Date +): MonthlyRepeatItem[] { + const getLabel = (repeatValue: string) => + renderRRuleAsText(hass, `FREQ=MONTHLY;INTERVAL=${interval};${repeatValue}`); + + const result: MonthlyRepeatItem[] = [ + // The default repeat rule is on day of month e.g. 3rd day of month + { + value: `BYMONTHDAY=${getDate(dtstart)}`, + label: getLabel(`BYMONTHDAY=${getDate(dtstart)}`)!, + }, + // Additional optional rules based on the week of month e.g. 2nd sunday of month + ...getWeekydaysForMonth(dtstart).map((item) => ({ + value: `BYDAY=${item.toString()}`, + byday: item, + label: getLabel(`BYDAY=${item.toString()}`)!, + })), + ]; + if (isLastDayOfMonth(dtstart)) { + result.push({ + value: "BYMONTHDAY=-1", + bymonthday: -1, + label: getLabel(`BYMONTHDAY=-1`)!, + }); + } + return result; +} + +export function getMonthlyRepeatWeekdayFromRule( + rrule: Partial +): Weekday | undefined { + if (rrule.freq !== Frequency.MONTHLY) { + return undefined; + } + if ( + rrule.byweekday && + Array.isArray(rrule.byweekday) && + rrule.byweekday.length === 1 && + rrule.byweekday[0] instanceof Weekday + ) { + return rrule.byweekday[0]; + } + return undefined; +} + +export function getMonthdayRepeatFromRule( + rrule: Partial +): number | undefined { + if (rrule.freq !== Frequency.MONTHLY || !rrule.bymonthday) { + return undefined; + } + if (Array.isArray(rrule.bymonthday)) { + return rrule.bymonthday[0]; + } + return rrule.bymonthday; +} + +/** + * A wrapper around RRule.toText that assists with translation. + */ +export function renderRRuleAsText(hass: HomeAssistant, value: string) { + const rule = RRule.fromString(`RRULE:${value}`); + if (!rule.isFullyConvertibleToText()) { + return undefined; + } + return capitalizeFirstLetter( + rule.toText( + (id: string | number | Weekday): string => { + if (typeof id === "string") { + return hass.localize(`ui.components.calendar.event.rrule.${id}`); + } + return ""; + }, + { + dayNames: dayNames(hass.locale), + monthNames: monthNames(hass.locale), + tokens: {}, + }, + // Format the date + (year: number, month: string, day: number): string => { + if (!year || !month || !day) { + return ""; + } + // Build date so we can then format it + const date = new Date(); + date.setFullYear(year); + // As input we already get the localized month name, so we now unfortunately + // need to convert it back to something Date can work with. The already localized + // months names are a must in the RRule.Language structure (an empty string[] would + // mean we get undefined months input in this method here). + date.setMonth(monthNames(hass.locale).indexOf(month)); + date.setDate(day); + return formatDate(date, hass.locale); + } + ) + ); +}