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; +}