mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-19 07:16:39 +00:00
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:
parent
b99a139f51
commit
6a15216104
@ -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;
|
||||
|
@ -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 || ""}
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user