From b7e369d5124f644dc09fa57caccff35fed7e7daa Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Wed, 18 Mar 2026 13:13:03 +0100 Subject: [PATCH] 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. --- ...ecurring-start-date-epoch-bug-6860.spec.ts | 160 ++++++++++++++++++ .../task-repeat-cfg-form.const.spec.ts | 37 +++- .../task-repeat-cfg-form.const.ts | 2 +- .../date-picker-input.component.spec.ts | 113 +++++++++++++ .../date-picker-input.component.ts | 21 ++- .../formly-date-picker.component.ts | 15 +- 6 files changed, 337 insertions(+), 11 deletions(-) create mode 100644 e2e/tests/recurring/recurring-start-date-epoch-bug-6860.spec.ts create mode 100644 src/app/ui/date-picker-input/date-picker-input.component.spec.ts diff --git a/e2e/tests/recurring/recurring-start-date-epoch-bug-6860.spec.ts b/e2e/tests/recurring/recurring-start-date-epoch-bug-6860.spec.ts new file mode 100644 index 0000000000..4f40a2b098 --- /dev/null +++ b/e2e/tests/recurring/recurring-start-date-epoch-bug-6860.spec.ts @@ -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 }); + }); +}); diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts index d7caba5f09..88ad63efb6 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts @@ -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], diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts index fe64e9c443..45747bcf68 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts @@ -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', diff --git a/src/app/ui/date-picker-input/date-picker-input.component.spec.ts b/src/app/ui/date-picker-input/date-picker-input.component.spec.ts new file mode 100644 index 0000000000..a48e5f6669 --- /dev/null +++ b/src/app/ui/date-picker-input/date-picker-input.component.spec.ts @@ -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(); + }); + }); +}); diff --git a/src/app/ui/date-picker-input/date-picker-input.component.ts b/src/app/ui/date-picker-input/date-picker-input.component.ts index bc9aa7c621..b9f95a90b9 100644 --- a/src/app/ui/date-picker-input/date-picker-input.component.ts +++ b/src/app/ui/date-picker-input/date-picker-input.component.ts @@ -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(''); - min = input('1900-01-01'); - max = input('2999-12-31'); + min = input(DATE_PICKER_MIN_DEFAULT); + max = input(DATE_PICKER_MAX_DEFAULT); required = input(false); isInvalid = input(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 { diff --git a/src/app/ui/formly-date-picker/formly-date-picker.component.ts b/src/app/ui/formly-date-picker/formly-date-picker.component.ts index 3a72401857..3fa70e978d 100644 --- a/src/app/ui/formly-date-picker/formly-date-picker.component.ts +++ b/src/app/ui/formly-date-picker/formly-date-picker.component.ts @@ -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 {} +export class FormlyDatePickerComponent extends FieldType { + readonly DATE_PICKER_MIN_DEFAULT = DATE_PICKER_MIN_DEFAULT; + readonly DATE_PICKER_MAX_DEFAULT = DATE_PICKER_MAX_DEFAULT; +}