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 <mail@bramkragten.nl>
This commit is contained in:
Allen Porter 2022-12-28 05:07:17 -08:00 committed by GitHub
parent b99a139f51
commit 6a15216104
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 282 additions and 64 deletions

View File

@ -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`<div id="text">
${capitalizeFirstLetter(
rule.toText(
this._translateRRuleElement,
{
dayNames: dayNames(this.hass.locale),
monthNames: monthNames(this.hass.locale),
tokens: {},
},
this._formatDate
)
)}
</div>`;
const ruleText = renderRRuleAsText(this.hass, value);
if (ruleText !== undefined) {
return html`<div id="text">${ruleText}</div>`;
}
return html`<div id="text">Cannot convert recurrence rule</div>`;
} 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;

View File

@ -248,6 +248,8 @@ class DialogCalendarEventEditor extends LitElement {
</div>
</div>
<ha-recurrence-rule-editor
.hass=${this.hass}
.dtstart=${this._dtstart}
.locale=${this.hass.locale}
.timezone=${this.hass.config.time_zone}
.value=${this._rrule || ""}

View File

@ -1,6 +1,6 @@
import type { SelectedDetail } from "@material/mwc-list";
import { css, html, LitElement, PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { Options, WeekdayStr } from "rrule";
import { ByWeekday, RRule, Weekday } from "rrule";
@ -16,22 +16,31 @@ import {
convertFrequency,
convertRepeatFrequency,
DEFAULT_COUNT,
getWeekday,
getWeekdays,
getMonthlyRepeatItems,
intervalSuffix,
RepeatEnd,
RepeatFrequency,
ruleByWeekDay,
untilValue,
WEEKDAY_NAME,
MonthlyRepeatItem,
getMonthlyRepeatWeekdayFromRule,
getMonthdayRepeatFromRule,
} from "./recurrence";
import "../../components/ha-date-input";
@customElement("ha-recurrence-rule-editor")
export class RecurrenceRuleEditor extends LitElement {
@property() public hass!: HomeAssistant;
@property() public disabled = false;
@property() public value = "";
@property() public dtstart?: Date;
@property({ attribute: false }) public locale!: HomeAssistant["locale"];
@property() public timezone?: string;
@ -44,14 +53,24 @@ export class RecurrenceRuleEditor extends LitElement {
@state() private _weekday: Set<WeekdayStr> = new Set<WeekdayStr>();
@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`<ha-select
id="monthly"
label="Repeat Monthly"
@selected=${this._onMonthlyDetailSelected}
.value=${this._monthlyRepeat || this._monthlyRepeatItems[0]?.value}
@closed=${stopPropagation}
fixedMenuPosition
naturalMenuWidth
>
${this._monthlyRepeatItems!.map(
(item) => html`
<ha-list-item .value=${item.value} .item=${item}>
${item.label}
</ha-list-item>
`
)}
</ha-select>`
: 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<SelectedDetail<number>>) {
@ -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<SelectedDetail<number>>) {
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<SelectedDetail<number>>) {
@ -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<Options> = {
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

View File

@ -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<WeekdayStr>
): Weekday[] | undefined {
export function ruleByWeekDay(weekdays: Set<WeekdayStr>): 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<Options>
): 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<Options>
): 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);
}
)
);
}