import { type CSSProperties, type Dispatch, useEffect, useReducer, useRef, useState, } from 'react'; import { sendCatch } from 'loot-core/src/platform/client/fetch'; import * as monthUtils from 'loot-core/src/shared/months'; import { getRecurringDescription } from 'loot-core/src/shared/schedules'; import { type RecurConfig, type RecurPattern } from 'loot-core/types/models'; import { type WithRequired } from 'loot-core/types/util'; import { useDateFormat } from '../../hooks/useDateFormat'; import { SvgAdd, SvgSubtract } from '../../icons/v0'; import { theme } from '../../style'; import { Button } from '../common/Button2'; import { Input } from '../common/Input'; import { Menu } from '../common/Menu'; import { Popover } from '../common/Popover'; import { Select } from '../common/Select'; import { Stack } from '../common/Stack'; import { Text } from '../common/Text'; import { View } from '../common/View'; import { Checkbox } from '../forms'; import { DateSelect } from './DateSelect'; // ex: There is no 6th Friday of the Month const MAX_DAY_OF_WEEK_INTERVAL = 5; const FREQUENCY_OPTIONS = [ { id: 'daily', name: 'Days' }, { id: 'weekly', name: 'Weeks' }, { id: 'monthly', name: 'Months' }, { id: 'yearly', name: 'Years' }, ] as const; const DAY_OF_MONTH_OPTIONS = [...Array(31).keys()].map(day => day + 1); const DAY_OF_WEEK_OPTIONS = [ { id: 'SU', name: 'Sunday' }, { id: 'MO', name: 'Monday' }, { id: 'TU', name: 'Tuesday' }, { id: 'WE', name: 'Wednesday' }, { id: 'TH', name: 'Thursday' }, { id: 'FR', name: 'Friday' }, { id: 'SA', name: 'Saturday' }, ] as const; function parsePatternValue(value: string | number) { if (value === 'last') { return -1; } return Number(value); } function parseConfig(config: Partial): StateConfig { return { start: monthUtils.currentDay(), interval: 1, frequency: 'monthly', patterns: [createMonthlyRecurrence(monthUtils.currentDay())], skipWeekend: false, weekendSolveMode: 'before', endMode: 'never', endOccurrences: 1, endDate: monthUtils.currentDay(), ...config, }; } function unparseConfig(parsed: StateConfig): RecurConfig { return { ...parsed, interval: validInterval(parsed.interval), endOccurrences: validInterval(parsed.endOccurrences), }; } function createMonthlyRecurrence(startDate: string) { return { value: parseInt(monthUtils.format(startDate, 'd')), type: 'day' as const, }; } function boundedRecurrence({ field, value, recurrence, }: { recurrence: RecurPattern; } & ( | { field: 'type'; value: RecurPattern['type'] } | { field: 'value'; value: RecurPattern['value'] } )) { if ( (field === 'value' && recurrence.type !== 'day' && value > MAX_DAY_OF_WEEK_INTERVAL) || (field === 'type' && value !== 'day' && recurrence.value > MAX_DAY_OF_WEEK_INTERVAL) ) { return { [field]: value, value: MAX_DAY_OF_WEEK_INTERVAL }; } return { [field]: value }; } type StateConfig = Omit< WithRequired, 'interval' | 'endOccurrences' > & { interval: number | string; endOccurrences: number | string; }; type ReducerState = { config: StateConfig; }; type UpdateRecurrenceAction = | { type: 'update-recurrence'; recurrence: RecurPattern; field: 'type'; value: RecurPattern['type']; } | { type: 'update-recurrence'; recurrence: RecurPattern; field: 'value'; value: RecurPattern['value']; }; type ChangeFieldAction = { type: 'change-field'; field: T; value: StateConfig[T]; }; type ReducerAction = | { type: 'replace-config'; config: StateConfig } | ChangeFieldAction | UpdateRecurrenceAction | { type: 'add-recurrence' } | { type: 'remove-recurrence'; recurrence: RecurPattern } | { type: 'set-skip-weekend'; skipWeekend: boolean } | { type: 'set-weekend-solve'; value: StateConfig['weekendSolveMode'] }; function reducer(state: ReducerState, action: ReducerAction): ReducerState { switch (action.type) { case 'replace-config': return { ...state, config: action.config }; case 'change-field': return { ...state, config: { ...state.config, [action.field]: action.value, patterns: state.config.frequency !== 'monthly' ? [] : state.config.patterns, }, }; case 'update-recurrence': return { ...state, config: { ...state.config, patterns: state.config.patterns.map(p => p === action.recurrence ? { ...action.recurrence, ...boundedRecurrence(action) } : p, ), }, }; case 'add-recurrence': return { ...state, config: { ...state.config, patterns: [ ...(state.config.patterns || []), createMonthlyRecurrence(state.config.start), ], }, }; case 'remove-recurrence': return { ...state, config: { ...state.config, patterns: state.config.patterns.filter(p => p !== action.recurrence), }, }; case 'set-skip-weekend': return { ...state, config: { ...state.config, skipWeekend: action.skipWeekend, }, }; case 'set-weekend-solve': return { ...state, config: { ...state.config, weekendSolveMode: action.value, }, }; default: return state; } } function SchedulePreview({ previewDates }: { previewDates: Date[] }) { const dateFormat = (useDateFormat() || 'MM/dd/yyyy') .replace('MM', 'M') .replace('dd', 'd'); if (!previewDates) { return null; } let content = null; if (typeof previewDates === 'string') { content = {previewDates}; } else { content = ( Upcoming dates {previewDates.map((d, idx) => ( {monthUtils.format(d, dateFormat)} {monthUtils.format(d, 'EEEE')} ))} ); } return ( {content} ); } function validInterval(interval: string | number) { const intInterval = Number(interval); return Number.isInteger(intInterval) && intInterval > 0 ? intInterval : 1; } function MonthlyPatterns({ config, dispatch, }: { config: StateConfig; dispatch: Dispatch; }) { return ( {config.patterns.map((recurrence, idx) => ( [opt.id, opt.name] as const), ]} value={recurrence.type} onChange={value => { dispatch({ type: 'update-recurrence', recurrence, field: 'type', value, }); }} style={{ flex: 1, marginRight: 10 }} /> ))} ); } function RecurringScheduleTooltip({ config: currentConfig, onClose, onSave, }: { config: RecurConfig; onClose: () => void; onSave: (config: RecurConfig) => void; }) { const [previewDates, setPreviewDates] = useState(null); const [state, dispatch] = useReducer(reducer, { config: parseConfig(currentConfig), }); const skipWeekend = state.config.hasOwnProperty('skipWeekend') ? state.config.skipWeekend : false; const dateFormat = useDateFormat() || 'MM/dd/yyyy'; useEffect(() => { dispatch({ type: 'replace-config', config: parseConfig(currentConfig), }); }, [currentConfig]); const { config } = state; const updateField = ( field: Field, value: StateConfig[Field], ) => dispatch({ type: 'change-field', field, value }); useEffect(() => { async function run() { const { data, error } = await sendCatch('schedule/get-upcoming-dates', { config: unparseConfig(config), count: 4, }); setPreviewDates(error ? 'Invalid rule' : data); } run(); }, [config]); if (previewDates == null) { return null; } return ( <>
updateField('start', value)} containerProps={{ style: { width: 100 } }} dateFormat={dateFormat} autoFocus autoSelect /> updateField('endOccurrences', value)} defaultValue={config.endOccurrences || 1} /> occurrence{config.endOccurrences === '1' ? '' : 's'} )} {config.endMode === 'on_date' && ( updateField('endDate', value)} containerProps={{ style: { width: 100 } }} dateFormat={dateFormat} /> )}
Repeat every updateField('interval', value)} defaultValue={config.interval || 1} /> dispatch({ type: 'set-weekend-solve', value })} disabled={!skipWeekend} />
); } type RecurringSchedulePickerProps = { value: RecurConfig; buttonStyle?: CSSProperties; onChange: (config: RecurConfig) => void; }; export function RecurringSchedulePicker({ value, buttonStyle, onChange, }: RecurringSchedulePickerProps) { const triggerRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const dateFormat = useDateFormat() || 'MM/dd/yyyy'; function onSave(config: RecurConfig) { onChange(config); setIsOpen(false); } return ( setIsOpen(false)} > setIsOpen(false)} onSave={onSave} /> ); }