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);
+ }
+ )
+ );
+}