Files
actual/packages/desktop-client/src/components/select/RecurringSchedulePicker.tsx

591 lines
15 KiB
TypeScript

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<RecurConfig>): 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<RecurConfig, 'patterns' | 'endDate' | 'weekendSolveMode'>,
'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<T extends keyof StateConfig> = {
type: 'change-field';
field: T;
value: StateConfig[T];
};
type ReducerAction =
| { type: 'replace-config'; config: StateConfig }
| ChangeFieldAction<keyof StateConfig>
| 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 = <Text>{previewDates}</Text>;
} else {
content = (
<View>
<Text style={{ fontWeight: 600 }}>Upcoming dates</Text>
<Stack direction="row" spacing={4} style={{ marginTop: 10 }}>
{previewDates.map((d, idx) => (
<View key={idx}>
<Text>{monthUtils.format(d, dateFormat)}</Text>
<Text>{monthUtils.format(d, 'EEEE')}</Text>
</View>
))}
</Stack>
</View>
);
}
return (
<Stack
direction="column"
spacing={1}
style={{ marginTop: 15, color: theme.tableText }}
>
{content}
</Stack>
);
}
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<ReducerAction>;
}) {
return (
<Stack spacing={2} style={{ marginTop: 10 }}>
{config.patterns.map((recurrence, idx) => (
<View
key={idx}
style={{
display: 'flex',
flexDirection: 'row',
}}
>
<Select
options={[
[-1, 'Last'],
Menu.line,
...DAY_OF_MONTH_OPTIONS.map(opt => [opt, String(opt)] as const),
]}
value={recurrence.value}
onChange={value =>
dispatch({
type: 'update-recurrence',
recurrence,
field: 'value',
value: parsePatternValue(value),
})
}
style={{ flex: 1, marginRight: 10 }}
/>
<Select
options={[
['day', 'Day'],
Menu.line,
...DAY_OF_WEEK_OPTIONS.map(opt => [opt.id, opt.name] as const),
]}
value={recurrence.type}
onChange={value => {
dispatch({
type: 'update-recurrence',
recurrence,
field: 'type',
value,
});
}}
style={{ flex: 1, marginRight: 10 }}
/>
<Button
variant="bare"
aria-label="Remove recurrence"
style={{ padding: 7 }}
onPress={() =>
dispatch({
type: 'remove-recurrence',
recurrence,
})
}
>
<SvgSubtract style={{ width: 8, height: 8 }} />
</Button>
<Button
variant="bare"
aria-label="Add recurrence"
style={{ padding: 7, marginLeft: 5 }}
onPress={() => dispatch({ type: 'add-recurrence' })}
>
<SvgAdd style={{ width: 10, height: 10 }} />
</Button>
</View>
))}
</Stack>
);
}
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 extends keyof RecurConfig>(
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 (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<label htmlFor="start">From</label>
<DateSelect
id="start"
inputProps={{ placeholder: 'Start Date' }}
value={config.start}
onSelect={value => updateField('start', value)}
containerProps={{ style: { width: 100 } }}
dateFormat={dateFormat}
autoFocus
autoSelect
/>
<Select
id="repeat_end_dropdown"
options={[
['never', 'indefinitely'],
['after_n_occurrences', 'for'],
['on_date', 'until'],
]}
value={config.endMode}
onChange={value => updateField('endMode', value)}
/>
{config.endMode === 'after_n_occurrences' && (
<>
<Input
id="end_occurrences"
style={{ width: 40 }}
type="number"
min={1}
onChangeValue={value => updateField('endOccurrences', value)}
defaultValue={config.endOccurrences || 1}
/>
<Text>occurrence{config.endOccurrences === '1' ? '' : 's'}</Text>
</>
)}
{config.endMode === 'on_date' && (
<DateSelect
id="end_date"
inputProps={{ placeholder: 'End Date' }}
value={config.endDate}
onSelect={value => updateField('endDate', value)}
containerProps={{ style: { width: 100 } }}
dateFormat={dateFormat}
/>
)}
</div>
<Stack
direction="row"
align="center"
justify="flex-start"
style={{ marginTop: 10 }}
spacing={1}
>
<Text style={{ whiteSpace: 'nowrap' }}>Repeat every</Text>
<Input
id="interval"
style={{ width: 40 }}
type="number"
min={1}
onChangeValue={value => updateField('interval', value)}
defaultValue={config.interval || 1}
/>
<Select
options={FREQUENCY_OPTIONS.map(opt => [opt.id, opt.name])}
value={config.frequency}
onChange={value => updateField('frequency', value)}
style={{ marginRight: 5 }}
/>
{config.frequency === 'monthly' &&
(config.patterns == null || config.patterns.length === 0) ? (
<Button
style={{
backgroundColor: theme.tableBackground,
}}
onPress={() => dispatch({ type: 'add-recurrence' })}
>
Add specific days
</Button>
) : null}
</Stack>
{config.frequency === 'monthly' &&
config.patterns &&
config.patterns.length > 0 && (
<MonthlyPatterns config={config} dispatch={dispatch} />
)}
<Stack direction="column" style={{ marginTop: 5 }}>
<View
style={{
marginTop: 5,
flex: 1,
flexDirection: 'row',
alignItems: 'center',
userSelect: 'none',
}}
>
<Checkbox
id="form_skipwe"
checked={skipWeekend}
onChange={e => {
dispatch({
type: 'set-skip-weekend',
skipWeekend: e.target.checked,
});
}}
/>
<label
htmlFor="form_skipwe"
style={{
userSelect: 'none',
marginRight: 5,
}}
>
Move schedule{' '}
</label>
<Select
id="solve_dropdown"
options={[
['before', 'before'],
['after', 'after'],
]}
value={state.config.weekendSolveMode}
onChange={value => dispatch({ type: 'set-weekend-solve', value })}
disabled={!skipWeekend}
/>
<label
htmlFor="solve_dropdown"
style={{ userSelect: 'none', marginLeft: 5 }}
>
{' '}
weekend
</label>
</View>
</Stack>
<SchedulePreview previewDates={previewDates} />
<div
style={{ display: 'flex', marginTop: 15, justifyContent: 'flex-end' }}
>
<Button onPress={onClose}>Cancel</Button>
<Button
variant="primary"
onPress={() => onSave(unparseConfig(config))}
style={{ marginLeft: 10 }}
>
Apply
</Button>
</div>
</>
);
}
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 (
<View>
<Button
ref={triggerRef}
style={{ textAlign: 'left', ...buttonStyle }}
onPress={() => setIsOpen(true)}
>
{value
? getRecurringDescription(value, dateFormat)
: 'No recurring date'}
</Button>
<Popover
triggerRef={triggerRef}
style={{ padding: 10, width: 380 }}
placement="bottom start"
isOpen={isOpen}
onOpenChange={() => setIsOpen(false)}
>
<RecurringScheduleTooltip
config={value}
onClose={() => setIsOpen(false)}
onSave={onSave}
/>
</Popover>
</View>
);
}