[AI] Add per-schedule custom upcoming length override (#7434)

* [AI] Add per-schedule custom upcoming length override

* [AI] Add release notes for PR #7434

* [AI] Add custom length option to per-schedule upcoming length selector

* [AI] Deduplicate preset values and guard against malformed custom upcoming length

* [autofix.ci] apply automated fixes

* [AI] Retrigger CI (flaky accounts E2E test)

* [AI] Improve schedule editor layout for upcoming length field

Restructure the custom upcoming length from a checkbox + conditional
dropdown into a proper FormField with a single Select that includes
"Use global default" as the null option. Place it in a responsive
side-by-side row with the auto-post checkbox, consistent with the
form's existing layout patterns.

* [AI] Address CodeRabbit review feedback

Tighten custom upcoming length validation with a proper regex
instead of a loose hyphen check. Replace fixed height with minHeight
on the auto-post checkbox row to avoid clipping translated labels.

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #7434

* [AI] Fix custom_upcoming_length not persisting on mobile

The mobile schedule save handler was missing the custom_upcoming_length
field from the payload sent to schedule/create and schedule/update.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: youngcw <[email protected]>
This commit is contained in:
Jaime R Calzada
2026-04-22 16:33:21 +00:00
committed by GitHub
co-authored by autofix-ci[bot] github-actions[bot] youngcw
parent 9d91da77ec
commit c3717e7036
15 changed files with 176 additions and 52 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

@@ -101,6 +101,7 @@ export function MobileScheduleEditPage() {
schedule: {
id: state.schedule.id,
posts_transaction: state.fields.posts_transaction,
custom_upcoming_length: state.fields.custom_upcoming_length,
name: state.fields.name,
},
conditions,
@@ -6,6 +6,7 @@ import { Button } from '@actual-app/components/button';
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
import { InitialFocus } from '@actual-app/components/initial-focus';
import { Input } from '@actual-app/components/input';
import { Select } from '@actual-app/components/select';
import { SpaceBetween } from '@actual-app/components/space-between';
import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
@@ -20,6 +21,7 @@ import type {
import { Checkbox, FormField, FormLabel } from '#components/forms';
import { OpSelect } from '#components/rules/RuleEditor';
import { CustomUpcomingLength } from '#components/schedules/CustomUpcomingLength';
import { DateSelect } from '#components/select/DateSelect';
import { RecurringSchedulePicker } from '#components/select/RecurringSchedulePicker';
import { SelectedItemsButton } from '#components/table';
@@ -38,6 +40,7 @@ export type ScheduleFormFields = {
amountOp: null | string;
date: null | string | RecurConfig;
posts_transaction: boolean;
custom_upcoming_length: null | string;
name: null | string;
};
@@ -67,6 +70,11 @@ export type ScheduleEditFormDispatch =
field: 'posts_transaction';
value: boolean;
}
| {
type: 'set-field';
field: 'custom_upcoming_length';
value: string | null;
}
| {
type: 'set-repeats';
repeats: boolean;
@@ -309,57 +317,146 @@ export function ScheduleEditForm({
</View>
</SpaceBetween>
<SpaceBetween
style={{
marginTop: 20,
display: isNarrowWidth ? 'grid' : 'flex',
gridTemplateColumns: '1fr 1fr',
}}
>
<FormField style={{ flex: 1 }}>
<FormLabel
title={t('Upcoming length')}
htmlFor="upcoming-length-field"
/>
{(() => {
const presetValues = ['1', '7', '14', 'oneMonth', 'currentMonth'];
const isPreset =
fields.custom_upcoming_length != null &&
presetValues.includes(fields.custom_upcoming_length);
const isCustomValue =
fields.custom_upcoming_length != null && !isPreset;
const isValidCustomFormat =
fields.custom_upcoming_length != null &&
/^[1-9]\d*-(day|week|month|year)$/.test(
fields.custom_upcoming_length,
);
const safeCustomValue =
isCustomValue && isValidCustomFormat
? fields.custom_upcoming_length
: '1-day';
return (
<>
<Select
id="upcoming-length-field"
options={[
['', t('Use global default')],
['1', t('1 day')],
['7', t('1 week')],
['14', t('2 weeks')],
['oneMonth', t('1 month')],
['currentMonth', t('End of the current month')],
['custom', t('Custom length')],
]}
value={
fields.custom_upcoming_length == null
? ''
: isPreset
? fields.custom_upcoming_length
: 'custom'
}
onChange={value => {
dispatch({
type: 'set-field',
field: 'custom_upcoming_length',
value:
value === ''
? null
: value === 'custom'
? '1-day'
: value,
});
}}
/>
{isCustomValue && (
<CustomUpcomingLength
tempValue={safeCustomValue}
onChange={value => {
dispatch({
type: 'set-field',
field: 'custom_upcoming_length',
value,
});
}}
/>
)}
</>
);
})()}
<Text
style={{
color: theme.pageTextLight,
fontSize: 12,
marginTop: 3,
}}
>
<Trans>
How far in advance this schedule appears as upcoming
</Trans>
</Text>
</FormField>
<FormField style={{ flex: 1 }}>
<FormLabel title=" " />
<View
style={{
flexDirection: 'row',
alignItems: 'center',
userSelect: 'none',
minHeight: 28,
}}
>
<Checkbox
id="form_posts_transaction"
checked={fields.posts_transaction}
onChange={e => {
dispatch({
type: 'set-field',
field: 'posts_transaction',
value: e.target.checked,
});
}}
/>
<label
htmlFor="form_posts_transaction"
style={{ userSelect: 'none' }}
>
<Trans>Automatically add transaction</Trans>
</label>
</View>
<Text
style={{
color: theme.pageTextLight,
fontSize: 12,
marginTop: 3,
}}
>
<Trans>
Automatically create transactions in the specified account
</Trans>
</Text>
</FormField>
</SpaceBetween>
<SpaceBetween
gap={5}
direction="vertical"
align="end"
style={{ marginTop: 10 }}
>
<View
style={{
marginTop: 5,
flexDirection: 'row',
alignItems: 'center',
userSelect: 'none',
justifyContent: 'flex-end',
}}
>
<Checkbox
id="form_posts_transaction"
checked={fields.posts_transaction}
onChange={e => {
dispatch({
type: 'set-field',
field: 'posts_transaction',
value: e.target.checked,
});
}}
/>
<label
htmlFor="form_posts_transaction"
style={{ userSelect: 'none' }}
>
<Trans>Automatically add transaction</Trans>
</label>
</View>
<Text
style={{
maxWidth: 350,
textAlign: 'right',
color: theme.pageTextLight,
fontSize: 13,
lineHeight: '1.4em',
}}
>
<Trans>
If checked, the schedule will automatically create transactions
for you in the specified account
</Trans>
</Text>
{!adding && schedule.rule && (
<SpaceBetween style={{ marginTop: 20, alignItems: 'center' }}>
<SpaceBetween style={{ marginTop: 10, alignItems: 'center' }}>
{isCustom && (
<Text
style={{
@@ -127,6 +127,7 @@ export function ScheduleEditModal({ id, transaction }: ScheduleEditModalProps) {
schedule: {
id: state.schedule.id,
posts_transaction: state.fields.posts_transaction,
custom_upcoming_length: state.fields.custom_upcoming_length,
name: state.fields.name,
},
conditions,
@@ -59,6 +59,11 @@ type ScheduleEditAction =
field: 'posts_transaction';
value: boolean;
}
| {
type: 'set-field';
field: 'custom_upcoming_length';
value: string | null;
}
| {
type: 'set-transactions';
transactions: TransactionEntity[];
@@ -109,6 +114,8 @@ function createScheduleEditReducer(useGetScheduledAmount: boolean = false) {
amountOp: schedule._amountOp || 'isapprox',
date: schedule._date ?? null,
posts_transaction: action.schedule.posts_transaction ?? false,
custom_upcoming_length:
action.schedule.custom_upcoming_length ?? null,
name: schedule.name ?? null,
},
};
@@ -255,6 +262,7 @@ export function useScheduleEdit({
amountOp: null,
date: null,
posts_transaction: false,
custom_upcoming_length: null,
name: null,
},
transactions: [],
@@ -42,7 +42,7 @@ function loadStatuses(
s.next_date,
s.completed,
hasTrans.has(s.id),
upcomingLength,
s.custom_upcoming_length ?? upcomingLength,
),
]),
) as ScheduleStatuses;
@@ -0,0 +1,5 @@
BEGIN TRANSACTION;
ALTER TABLE schedules ADD COLUMN custom_upcoming_length TEXT DEFAULT NULL;
COMMIT;
@@ -104,6 +104,7 @@ export const schema = {
next_date: f('date'),
completed: f('boolean'),
posts_transaction: f('boolean'),
custom_upcoming_length: f('string'),
tombstone: f('boolean'),
// These are special fields that are actually pulled from the
@@ -120,6 +120,7 @@ export type DbSchedule = {
active: 1 | 0;
completed: 1 | 0;
posts_transaction: 1 | 0;
custom_upcoming_length: string | null;
tombstone: 1 | 0;
};
@@ -323,6 +324,7 @@ export type DbViewSchedule = {
active: DbSchedule['active'];
completed: DbSchedule['completed'];
posts_transaction: DbSchedule['posts_transaction'];
custom_upcoming_length: DbSchedule['custom_upcoming_length'];
tombstone: DbSchedule['tombstone'];
_payee: DbPayeeMapping['targetId'];
_account: DbAccount['id'];
@@ -539,7 +539,7 @@ async function advanceSchedulesService(syncSuccess) {
schedule.next_date,
schedule.completed,
hasTrans.has(schedule.id),
upcomingLength[0]?.value ?? '7',
schedule.custom_upcoming_length ?? upcomingLength[0]?.value ?? '7',
);
if (status === 'paid') {
+8 -6
View File
@@ -495,14 +495,16 @@ export function computeSchedulePreviewTransactions(
const today = d.startOfDay(monthUtils.parseDate(monthUtils.currentDay()));
const upcomingPeriodEnd = d.startOfDay(
monthUtils.parseDate(
monthUtils.addDays(today, getUpcomingDays(upcomingLength)),
),
);
return schedulesForPreview
.flatMap(schedule => {
const effectiveUpcomingLength =
schedule.custom_upcoming_length ?? upcomingLength;
const upcomingPeriodEnd = d.startOfDay(
monthUtils.parseDate(
monthUtils.addDays(today, getUpcomingDays(effectiveUpcomingLength)),
),
);
const { date: dateConditions } = extractScheduleConds(
schedule._conditions,
);
@@ -26,6 +26,7 @@ export type ScheduleEntity = {
next_date: string;
completed: boolean;
posts_transaction: boolean;
custom_upcoming_length?: string | null;
tombstone: boolean;
// These are special fields that are actually pulled from the
+6
View File
@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [jreniel]
---
Add per-schedule custom upcoming length override, allowing each schedule to have its own "upcoming" notification window instead of using only the global setting.