mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
fix(recurring): prevent start date from reverting to 1970-01-01 (#6860)
When FormlyDatePickerComponent bound [min]="props.min" with an undefined value, Angular overrode the signal input default, causing DatePickerInputComponent.validateDate() to compare against Invalid Date. Every date failed validation, emitting null, which the formly parser getDbDateStr(null) converted to '1970-01-01' (Unix epoch). Fix at three layers: - FormlyDatePickerComponent: use ?? fallbacks so undefined never reaches the signal inputs - DatePickerInputComponent.validateDate(): handle undefined/invalid min/max with nullish checks and isNaN guards - Formly parser: only convert Date objects via getDbDateStr, pass null/string values through for the required validator to handle Also extract shared DATE_PICKER_MIN/MAX_DEFAULT constants and widen signal input types to include undefined for type-honest code.
This commit is contained in:
parent
945b8ed15c
commit
b7e369d512
6 changed files with 337 additions and 11 deletions
160
e2e/tests/recurring/recurring-start-date-epoch-bug-6860.spec.ts
Normal file
160
e2e/tests/recurring/recurring-start-date-epoch-bug-6860.spec.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
import { expect, test } from '../../fixtures/test.fixture';
|
||||
|
||||
/**
|
||||
* Bug: https://github.com/super-productivity/super-productivity/issues/6860
|
||||
*
|
||||
* When setting a date in the recurring task configuration, the value always
|
||||
* reverts to 01/01/1970 (Unix epoch). The root cause was that
|
||||
* FormlyDatePickerComponent passed undefined min/max to DatePickerInputComponent,
|
||||
* causing validateDate() to reject all dates via Invalid Date comparison,
|
||||
* which the formly parser then converted to '1970-01-01'.
|
||||
*/
|
||||
test.describe('Recurring Task - Start Date Epoch Bug (#6860)', () => {
|
||||
test('should preserve start date when configuring recurring task via calendar', async ({
|
||||
page,
|
||||
workViewPage,
|
||||
taskPage,
|
||||
testPrefix,
|
||||
}) => {
|
||||
await workViewPage.waitForTaskList();
|
||||
|
||||
// 1. Create a task
|
||||
const taskTitle = `${testPrefix}-EpochBug`;
|
||||
await workViewPage.addTask(taskTitle);
|
||||
|
||||
const task = taskPage.getTaskByText(taskTitle).first();
|
||||
await expect(task).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// 2. Open task detail panel and click the repeat item
|
||||
await task.hover();
|
||||
const detailBtn = page.getByRole('button', {
|
||||
name: 'Show/Hide additional info',
|
||||
});
|
||||
await detailBtn.click();
|
||||
|
||||
const recurItem = page
|
||||
.locator('task-detail-item')
|
||||
.filter({ has: page.locator('mat-icon[svgIcon="repeat"]') });
|
||||
await expect(recurItem).toBeVisible({ timeout: 5000 });
|
||||
await recurItem.click();
|
||||
|
||||
// 3. Wait for the repeat dialog to appear
|
||||
const repeatDialog = page.locator('mat-dialog-container');
|
||||
await repeatDialog.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// 4. Open the calendar popup and select first day of next month
|
||||
const calendarToggle = repeatDialog.locator('mat-datepicker-toggle button');
|
||||
await calendarToggle.click();
|
||||
|
||||
const calendar = page.locator('.mat-calendar');
|
||||
await expect(calendar).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Navigate to next month and select the first available day
|
||||
const nextMonthBtn = page.getByRole('button', { name: /next month/i });
|
||||
await nextMonthBtn.click();
|
||||
|
||||
const firstDay = page
|
||||
.locator('.mat-calendar-body-cell:not(.mat-calendar-body-disabled)')
|
||||
.first();
|
||||
await expect(firstDay).toBeVisible({ timeout: 5000 });
|
||||
await firstDay.click();
|
||||
|
||||
// 5. Verify the date input does not show epoch
|
||||
const dateInput = repeatDialog.getByRole('textbox', { name: /start date/i });
|
||||
await expect(dateInput).toBeVisible();
|
||||
const inputValue = await dateInput.inputValue();
|
||||
expect(inputValue).not.toBe('');
|
||||
expect(inputValue).not.toContain('1970');
|
||||
|
||||
// 6. Also verify via the model that it's not epoch
|
||||
const startDate = await page.evaluate(() => {
|
||||
const ng = (window as any).ng;
|
||||
const dialogEl = document.querySelector(
|
||||
'mat-dialog-container dialog-edit-task-repeat-cfg',
|
||||
);
|
||||
if (!ng || !dialogEl) throw new Error('Dialog component not found');
|
||||
const component = ng.getComponent(dialogEl);
|
||||
if (!component) throw new Error('Dialog component instance not found');
|
||||
return component.repeatCfg().startDate;
|
||||
});
|
||||
|
||||
expect(startDate).toBeDefined();
|
||||
expect(startDate).not.toBe('1970-01-01');
|
||||
|
||||
// The date should be in the future (next month)
|
||||
const today = new Date();
|
||||
const startDateObj = new Date(startDate + 'T00:00:00');
|
||||
expect(startDateObj.getTime()).toBeGreaterThan(today.getTime());
|
||||
|
||||
// 7. Save and verify the date survives persistence
|
||||
const saveBtn = repeatDialog.getByRole('button', { name: /Save/i });
|
||||
await saveBtn.click();
|
||||
await repeatDialog.waitFor({ state: 'hidden', timeout: 10000 });
|
||||
});
|
||||
|
||||
test('should preserve start date when typing date manually into input', async ({
|
||||
page,
|
||||
workViewPage,
|
||||
taskPage,
|
||||
testPrefix,
|
||||
}) => {
|
||||
await workViewPage.waitForTaskList();
|
||||
|
||||
// 1. Create a task
|
||||
const taskTitle = `${testPrefix}-EpochBugManual`;
|
||||
await workViewPage.addTask(taskTitle);
|
||||
|
||||
const task = taskPage.getTaskByText(taskTitle).first();
|
||||
await expect(task).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// 2. Open task detail panel and click the repeat item
|
||||
await task.hover();
|
||||
const detailBtn = page.getByRole('button', {
|
||||
name: 'Show/Hide additional info',
|
||||
});
|
||||
await detailBtn.click();
|
||||
|
||||
const recurItem = page
|
||||
.locator('task-detail-item')
|
||||
.filter({ has: page.locator('mat-icon[svgIcon="repeat"]') });
|
||||
await expect(recurItem).toBeVisible({ timeout: 5000 });
|
||||
await recurItem.click();
|
||||
|
||||
// 3. Wait for the repeat dialog to appear
|
||||
const repeatDialog = page.locator('mat-dialog-container');
|
||||
await repeatDialog.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// 4. Type a date directly into the date input field
|
||||
const dateInput = repeatDialog.getByRole('textbox', { name: /start date/i });
|
||||
await expect(dateInput).toBeVisible();
|
||||
await dateInput.clear();
|
||||
await dateInput.fill('6/15/2026');
|
||||
// Trigger change by pressing Tab to blur
|
||||
await dateInput.press('Tab');
|
||||
|
||||
// 5. Verify the input retained the typed date
|
||||
const inputValue = await dateInput.inputValue();
|
||||
expect(inputValue).not.toBe('');
|
||||
expect(inputValue).not.toContain('1970');
|
||||
|
||||
// 6. Verify via model that the date was preserved
|
||||
const startDate = await page.evaluate(() => {
|
||||
const ng = (window as any).ng;
|
||||
const dialogEl = document.querySelector(
|
||||
'mat-dialog-container dialog-edit-task-repeat-cfg',
|
||||
);
|
||||
if (!ng || !dialogEl) throw new Error('Dialog component not found');
|
||||
const component = ng.getComponent(dialogEl);
|
||||
if (!component) throw new Error('Dialog component instance not found');
|
||||
return component.repeatCfg().startDate;
|
||||
});
|
||||
|
||||
expect(startDate).toBeDefined();
|
||||
expect(startDate).not.toBe('1970-01-01');
|
||||
|
||||
// 7. Save
|
||||
const saveBtn = repeatDialog.getByRole('button', { name: /Save/i });
|
||||
await saveBtn.click();
|
||||
await repeatDialog.waitFor({ state: 'hidden', timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,42 @@
|
|||
import { TASK_REPEAT_CFG_ADVANCED_FORM_CFG } from './task-repeat-cfg-form.const';
|
||||
import {
|
||||
TASK_REPEAT_CFG_ADVANCED_FORM_CFG,
|
||||
TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG,
|
||||
} from './task-repeat-cfg-form.const';
|
||||
import { TaskReminderOptionId } from '../../tasks/task.model';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
|
||||
describe('TaskRepeatCfgFormConfig', () => {
|
||||
describe('startDate field parser (issue #6860)', () => {
|
||||
const startDateField = TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG.find(
|
||||
(field) => field.key === 'startDate',
|
||||
);
|
||||
const parser = startDateField?.parsers?.[0] as (val: unknown) => unknown;
|
||||
|
||||
it('should have a parser defined', () => {
|
||||
expect(parser).toBeDefined();
|
||||
});
|
||||
|
||||
it('should convert Date objects to date strings', () => {
|
||||
const date = new Date(2026, 2, 18);
|
||||
expect(parser(date)).toBe(getDbDateStr(date));
|
||||
});
|
||||
|
||||
it('should pass through string values unchanged', () => {
|
||||
expect(parser('2026-03-18')).toBe('2026-03-18');
|
||||
});
|
||||
|
||||
it('should NOT convert null to epoch date (1970-01-01)', () => {
|
||||
// This is the core regression test for issue #6860:
|
||||
// getDbDateStr(null) returns '1970-01-01', but the parser should
|
||||
// pass null through so the required validator can handle it
|
||||
expect(parser(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('should pass through undefined unchanged', () => {
|
||||
expect(parser(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('remindAt field', () => {
|
||||
const remindAtField = TASK_REPEAT_CFG_ADVANCED_FORM_CFG.flatMap((field) =>
|
||||
field.fieldGroup ? field.fieldGroup : [field],
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export const TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG: FormlyFieldConfig[] = [
|
|||
label: T.F.TASK_REPEAT.F.START_DATE,
|
||||
required: true,
|
||||
},
|
||||
parsers: [getDbDateStr],
|
||||
parsers: [(val: unknown) => (val instanceof Date ? getDbDateStr(val) : val)],
|
||||
},
|
||||
{
|
||||
key: 'quickSetting',
|
||||
|
|
|
|||
113
src/app/ui/date-picker-input/date-picker-input.component.spec.ts
Normal file
113
src/app/ui/date-picker-input/date-picker-input.component.spec.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { DatePickerInputComponent } from './date-picker-input.component';
|
||||
import { provideNativeDateAdapter } from '@angular/material/core';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { provideMockStore } from '@ngrx/store/testing';
|
||||
|
||||
describe('DatePickerInputComponent', () => {
|
||||
let component: DatePickerInputComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [DatePickerInputComponent, TranslateModule.forRoot()],
|
||||
providers: [provideNativeDateAdapter(), provideMockStore()],
|
||||
});
|
||||
const fixture = TestBed.createComponent(DatePickerInputComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('validateDate', () => {
|
||||
it('should return true for a valid date within default range', () => {
|
||||
const date = new Date(2026, 2, 18);
|
||||
expect(component.validateDate(date)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when min and max are explicitly undefined (issue #6860)', () => {
|
||||
// Reproduce the exact runtime condition: Angular passes undefined from
|
||||
// a template binding like [min]="props.min", overriding the signal default
|
||||
const fixture = TestBed.createComponent(DatePickerInputComponent);
|
||||
fixture.componentRef.setInput('min', undefined);
|
||||
fixture.componentRef.setInput('max', undefined);
|
||||
fixture.detectChanges();
|
||||
const comp = fixture.componentInstance;
|
||||
const date = new Date(2026, 2, 18);
|
||||
expect(comp.validateDate(date)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for a date before min', () => {
|
||||
const fixture = TestBed.createComponent(DatePickerInputComponent);
|
||||
fixture.componentRef.setInput('min', '2026-06-01');
|
||||
fixture.detectChanges();
|
||||
const comp = fixture.componentInstance;
|
||||
const date = new Date(2026, 0, 1);
|
||||
expect(comp.validateDate(date)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for a date after max', () => {
|
||||
const fixture = TestBed.createComponent(DatePickerInputComponent);
|
||||
fixture.componentRef.setInput('max', '2026-01-01');
|
||||
fixture.detectChanges();
|
||||
const comp = fixture.componentInstance;
|
||||
const date = new Date(2026, 5, 1);
|
||||
expect(comp.validateDate(date)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onValueChange', () => {
|
||||
it('should emit the date when validation passes', () => {
|
||||
const spy = jasmine.createSpy('onChange');
|
||||
component.registerOnChange(spy);
|
||||
const date = new Date(2026, 2, 18);
|
||||
component.onValueChange(date);
|
||||
expect(spy).toHaveBeenCalledWith(date);
|
||||
expect(component.innerValue).toEqual(date);
|
||||
});
|
||||
|
||||
it('should emit null when value is null', () => {
|
||||
const spy = jasmine.createSpy('onChange');
|
||||
component.registerOnChange(spy);
|
||||
component.onValueChange(null);
|
||||
expect(spy).toHaveBeenCalledWith(null);
|
||||
expect(component.innerValue).toBeNull();
|
||||
});
|
||||
|
||||
it('should NOT convert valid dates to epoch (issue #6860)', () => {
|
||||
const spy = jasmine.createSpy('onChange');
|
||||
component.registerOnChange(spy);
|
||||
const date = new Date(2026, 2, 18);
|
||||
component.onValueChange(date);
|
||||
// The bug was: valid dates were rejected by validateDate when min/max
|
||||
// were undefined, causing onChange(null), which the formly parser
|
||||
// converted to '1970-01-01'
|
||||
expect(spy).not.toHaveBeenCalledWith(null);
|
||||
expect(spy).toHaveBeenCalledWith(date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeValue', () => {
|
||||
it('should set innerValue to null for falsy values', () => {
|
||||
component.writeValue(null);
|
||||
expect(component.innerValue).toBeNull();
|
||||
});
|
||||
|
||||
it('should set innerValue for Date objects', () => {
|
||||
const date = new Date(2026, 2, 18);
|
||||
component.writeValue(date);
|
||||
expect(component.innerValue).toEqual(date);
|
||||
});
|
||||
|
||||
it('should parse valid date strings', () => {
|
||||
component.writeValue('2026-03-18');
|
||||
expect(component.innerValue).toBeTruthy();
|
||||
expect(component.innerValue!.getFullYear()).toBe(2026);
|
||||
expect(component.innerValue!.getMonth()).toBe(2);
|
||||
expect(component.innerValue!.getDate()).toBe(18);
|
||||
});
|
||||
|
||||
it('should set innerValue to null for non-string non-Date values', () => {
|
||||
component.writeValue(12345);
|
||||
expect(component.innerValue).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -21,6 +21,9 @@ import { dateStrToUtcDate } from 'src/app/util/date-str-to-utc-date';
|
|||
|
||||
type DateValue = Date | null;
|
||||
|
||||
export const DATE_PICKER_MIN_DEFAULT = '1900-01-01';
|
||||
export const DATE_PICKER_MAX_DEFAULT = '2999-12-31';
|
||||
|
||||
@Component({
|
||||
selector: 'date-picker-input',
|
||||
standalone: true,
|
||||
|
|
@ -51,8 +54,8 @@ export class DatePickerInputComponent implements ControlValueAccessor {
|
|||
dateTimeFormatService = inject(DateTimeFormatService);
|
||||
|
||||
label = input<string>('');
|
||||
min = input<Date | string>('1900-01-01');
|
||||
max = input<Date | string>('2999-12-31');
|
||||
min = input<Date | string | undefined>(DATE_PICKER_MIN_DEFAULT);
|
||||
max = input<Date | string | undefined>(DATE_PICKER_MAX_DEFAULT);
|
||||
|
||||
required = input<boolean>(false);
|
||||
isInvalid = input<boolean | undefined>(undefined); // boolean - validation control by parent, undefined - internal validation
|
||||
|
|
@ -70,9 +73,17 @@ export class DatePickerInputComponent implements ControlValueAccessor {
|
|||
}
|
||||
|
||||
validateDate(value: Date): boolean {
|
||||
const minDate = this.toDate(this.min());
|
||||
const maxDate = this.toDate(this.max());
|
||||
return value >= minDate && value <= maxDate;
|
||||
const minVal = this.min();
|
||||
const maxVal = this.max();
|
||||
if (minVal != null) {
|
||||
const minDate = this.toDate(minVal);
|
||||
if (!isNaN(minDate.getTime()) && value < minDate) return false;
|
||||
}
|
||||
if (maxVal != null) {
|
||||
const maxDate = this.toDate(maxVal);
|
||||
if (!isNaN(maxDate.getTime()) && value > maxDate) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
writeValue(value: unknown): void {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';
|
|||
import { FieldType } from '@ngx-formly/material';
|
||||
import { FormlyFieldConfig, FormlyModule } from '@ngx-formly/core';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { DatePickerInputComponent } from '../date-picker-input/date-picker-input.component';
|
||||
import {
|
||||
DATE_PICKER_MAX_DEFAULT,
|
||||
DATE_PICKER_MIN_DEFAULT,
|
||||
DatePickerInputComponent,
|
||||
} from '../date-picker-input/date-picker-input.component';
|
||||
|
||||
@Component({
|
||||
selector: 'formly-date-picker',
|
||||
|
|
@ -13,12 +17,15 @@ import { DatePickerInputComponent } from '../date-picker-input/date-picker-input
|
|||
[formControl]="formControl"
|
||||
[label]="props.label || ''"
|
||||
[required]="props.required || false"
|
||||
[min]="props.min"
|
||||
[max]="props.max"
|
||||
[min]="props.min ?? DATE_PICKER_MIN_DEFAULT"
|
||||
[max]="props.max ?? DATE_PICKER_MAX_DEFAULT"
|
||||
[errorMessage]="props.errorMessages?.required"
|
||||
[isInvalid]="showError"
|
||||
/>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FormlyDatePickerComponent extends FieldType<FormlyFieldConfig> {}
|
||||
export class FormlyDatePickerComponent extends FieldType<FormlyFieldConfig> {
|
||||
readonly DATE_PICKER_MIN_DEFAULT = DATE_PICKER_MIN_DEFAULT;
|
||||
readonly DATE_PICKER_MAX_DEFAULT = DATE_PICKER_MAX_DEFAULT;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue