mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
Update recurrent task calendar and general calendar design (#8017)
* feat(task-repeat-cfg): use schedule dialog picker for recurring task start date * refactor(calendar): extract shared DateTimePickerComponent and use in schedule/deadline dialogs * style(calendar): make primary button gray when disabled and stronger green when enabled * style(calendar): upgrade date picker calendar visuals and remove outlines * fix(task-repeat-cfg): prevent premature duration formatting during typing * fix: resolve merge conflict and fix lint errors in dialog components * test(e2e): update recurring task tests to match new schedule dialog UI * test(e2e): fix regressions and ambiguous locators in recurring task tests Resolves strict mode violations for 'Schedule' button and updates locators to handle the new separate schedule dialog robustly using the datetime-picker filter. * fix: hide unschedule button in schedule dialog when opened from recurring task cfg * fix(datetime-picker): restore calendar first-week weekday alignment by using visibility:hidden instead of display:none for body-label cell * fix(i18n): use active UI language for date locale fallback, show full month names, and translate hardcoded Time label * test: fix DateTimeFormatService unit tests by mocking TranslateService * fix(task-repeat): add default reminders to recurring tasks * test(sync): increase lock reentry timeout to prevent flakiness * style(ui): align calendar cell shapes and make hover color darker * style(ui): style inputs and action buttons in schedule and deadline dialogs * feat(task-repeat): enable quick access scheduling for recurring tasks * feat(ui): separate month and year into distinct header buttons in datetime picker * style(ui): show dropdown arrow next to the year only * fix(locale): map UI fallbacks to parse-safe locales and sync DateAdapter * fix(task-repeat): fix option caching, DST normalization, and schedule dialog time preservation * test(task-repeat): mock formatTime in repeat dialog spec * fix(ui): improve datetime-picker month/year picking, arrow direction, transitions, and highlighter sync * fix(ui): defer year selection view transition and avoid year picker pagination updating highlighter * style(ui): restore focus outline and default material look * style(ui): drop custom hover circle and pill shape, and revert month-grid names to default short format * fix(ui): gate schedule warnings on isSelectDueOnly * style(ui): remove custom button color overrides * style(ui): restore default Material hover and focus behavior * fix(ui): add calendar header aria-labels and mirror arrows in RTL * test(e2e): fix recurring task start date specs for new datetime picker * revert(date-time): restore master's browser-region-first fallback in DateTimeFormatService * fix(ui): make quick-access tooltips configurable on datetime-picker and preserve deadline labels * fix(ui): exclude disabled previous/next calendar buttons from custom hover style * fix(ui): align period header button aria-labels with actual view transitions * style(ui): refactor recurring start date button styling with logical CSS and remove any type * fix(ui): preserve selected year when navigating in year picker and toggling back * style(ui): nudge default calendar cell hover with a shared token * fix(datetime-picker): sync hover selection with keyboard navigation and focus on open * fix(deadline): grey out past days in deadline calendar * style(datetime-picker): use shared calendar hover background for header buttons * style(calendar): use primary mixed color globally for calendar hover background * style(datetime-picker): hide mouse on arrow key nav and unify hover/focus colors * style(datetime-picker): resolve selected cell highlight bug and update hover outlines * style(datetime-picker): style current date/month/year selectors with accent color * fix(task-repeat-cfg): guard repeat schedule opening with isValidSplitTime * fix(task-repeat-cfg): only persist remindAt when a valid time is present * fix(ui): add disabled guards for calendar cells and improve scheduling result * fix(ui): improve focus management and navigation in datetime-picker * fix(ui): prevent invalid 24:00 time in datetime-picker and remove debug log * refactor(ui): cleanup datetime-picker and improve calendar header logic * feat(ui): enable year navigation and consistent month selection in datetime-picker * fix(ui): align multi-year boundary logic with Angular Material anchoring * fix(repeat): restore past start-date floor for existing repeat configs * test(e2e): fix setRecurStartDate to correctly navigate month and year * test(e2e): remove duplicate helper declarations causing SyntaxError * refactor(calendar): de-risk calendar by reverting brittle hover sync and custom header * test(calendar): update unit tests for datetime-picker de-risking * fix(calendar): restore standard year-to-month drill-down navigation * test(e2e): fix recurring task and planner task compatibility - Update setRecurStartDate helper to use standard Material calendar drill-down navigation, fixing compatibility with the new UI. - Update TaskPage helper to support both 'task' and 'planner-task' elements in board and planner views. * test(e2e): fix recurring task date selection and calendar navigation - Update setRecurStartDate helper to use correct click sequence for the new date selector header. - Remove minDate restriction in recurring task edit dialog to allow moving start dates earlier (#7423). * style(ui): remove stale calendar overrides and localize header labels * test(e2e): fix failing task detail date test due to calendar i18n label change * fix(ui): remove redundant global locale mutation from DateTimePickerComponent * fix(ui): prevent calendar navigation reset when date value hasn't changed * fix(planner): wire showQuickAccess and disable auto-submit in select-due-only mode * fix(repeat): allow past dates when editing repeat configurations * fix(repeat): use DateService for logical today check * fix(test): add coverage for navigation guard and interactive flows in DateTimePickerComponent * fix(test): restore time handling coverage for select-due-only mode in DialogScheduleTaskComponent * fix(tasks): implement robust reminder quantization using mid-points and add tests * feat(planner): add stable data-test-id locators for schedule dialog buttons * fix(e2e): replace fragile translated 'Schedule' locators with data-test-id * fix(ui): improve calendar styling and i18n consistency * refactor(ui): cleanup unused code in DateTimePickerComponent * fix(planner): restore auto-submit on quick-access in DialogScheduleTaskComponent * fix(repeat): refine minDate strategy for recurring configurations * chore(i18n): restore de.json to master
This commit is contained in:
parent
46de133ce2
commit
c109ad1fa5
32 changed files with 1467 additions and 878 deletions
|
|
@ -173,7 +173,7 @@ export class DialogPage extends BasePage {
|
|||
* Open calendar picker
|
||||
*/
|
||||
async openCalendarPicker(): Promise<void> {
|
||||
const openCalendarBtn = this.page.getByRole('button', { name: 'Open calendar' });
|
||||
const openCalendarBtn = this.page.locator('mat-datepicker-toggle button').first();
|
||||
await openCalendarBtn.waitFor({ state: 'visible', timeout: 3000 });
|
||||
await openCalendarBtn.click();
|
||||
await this.page.waitForTimeout(300);
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ export class TaskPage extends BasePage {
|
|||
async waitForTaskCount(expectedCount: number, timeout: number = 10000): Promise<void> {
|
||||
await this.page.waitForFunction(
|
||||
(args) => {
|
||||
const currentCount = document.querySelectorAll('task').length;
|
||||
const currentCount = document.querySelectorAll('task, planner-task').length;
|
||||
return currentCount === args.expectedCount;
|
||||
},
|
||||
{ expectedCount },
|
||||
|
|
|
|||
|
|
@ -36,15 +36,24 @@ test('should not crash when a repeat config has an invalid startTime in the stor
|
|||
.filter({ has: page.locator('mat-icon', { hasText: /^repeat$/ }) })
|
||||
.click();
|
||||
|
||||
const repeatDialog = page.locator('mat-dialog-container');
|
||||
const repeatDialog = page.locator('mat-dialog-container').first();
|
||||
await repeatDialog.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// Set a valid startTime so the config has startTime + remindAt in the store
|
||||
await repeatDialog.locator('collapsible .collapsible-header').last().click();
|
||||
const startTimeField = repeatDialog.getByLabel(/Scheduled start time/i);
|
||||
await repeatDialog.locator('.planned-start-date-btn').click();
|
||||
const scheduleDialog = page
|
||||
.locator('mat-dialog-container')
|
||||
.filter({ has: page.locator('datetime-picker') });
|
||||
await expect(scheduleDialog).toBeVisible();
|
||||
|
||||
const startTimeField = scheduleDialog.getByLabel('Time');
|
||||
await expect(startTimeField).toBeVisible({ timeout: 5000 });
|
||||
await startTimeField.fill('10:30');
|
||||
await startTimeField.blur();
|
||||
|
||||
const scheduleBtn = scheduleDialog.locator('[data-test-id="schedule-submit-btn"]');
|
||||
await scheduleBtn.click();
|
||||
await scheduleDialog.waitFor({ state: 'hidden' });
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const saveBtn = repeatDialog.getByRole('button', { name: /Save/i });
|
||||
|
|
|
|||
|
|
@ -41,29 +41,40 @@ test.describe('Recurring Task - Future Start Date (#6856)', () => {
|
|||
await recurItem.click();
|
||||
|
||||
// 3. Wait for the repeat dialog and set a future start date via calendar
|
||||
const repeatDialog = page.locator('mat-dialog-container');
|
||||
const repeatDialog = page.locator('mat-dialog-container').first();
|
||||
await repeatDialog.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// Open the calendar popup
|
||||
const calendarToggle = repeatDialog.locator('mat-datepicker-toggle button');
|
||||
await calendarToggle.click();
|
||||
// Open the schedule dialog
|
||||
const scheduleBtn = repeatDialog.locator('.planned-start-date-btn');
|
||||
await expect(scheduleBtn).toBeVisible({ timeout: 5000 });
|
||||
await scheduleBtn.click();
|
||||
|
||||
const calendar = page.locator('.mat-calendar');
|
||||
// Wait for the schedule dialog to appear
|
||||
const scheduleDialog = page
|
||||
.locator('mat-dialog-container')
|
||||
.filter({ has: page.locator('datetime-picker') });
|
||||
await scheduleDialog.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
const calendar = scheduleDialog.locator('mat-calendar');
|
||||
await expect(calendar).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Navigate to next month to ensure the date is in the future
|
||||
const nextMonthBtn = page.getByRole('button', { name: /next month/i });
|
||||
const nextMonthBtn = scheduleDialog.getByRole('button', { name: /next month/i });
|
||||
await nextMonthBtn.click();
|
||||
|
||||
// Select the first available day in next month
|
||||
const firstDay = page
|
||||
const firstDay = scheduleDialog
|
||||
.locator('.mat-calendar-body-cell:not(.mat-calendar-body-disabled)')
|
||||
.first();
|
||||
await expect(firstDay).toBeVisible({ timeout: 5000 });
|
||||
await firstDay.click();
|
||||
|
||||
// Wait for calendar to close
|
||||
await expect(calendar).not.toBeVisible({ timeout: 5000 });
|
||||
// Click Schedule button
|
||||
const scheduleSubmitBtn = scheduleDialog.locator(
|
||||
'[data-test-id="schedule-submit-btn"]',
|
||||
);
|
||||
await scheduleSubmitBtn.click();
|
||||
await scheduleDialog.waitFor({ state: 'hidden', timeout: 5000 });
|
||||
|
||||
// Save the repeat config — wait for the button to be enabled first
|
||||
const saveBtn = repeatDialog.getByRole('button', { name: /Save/i });
|
||||
|
|
|
|||
|
|
@ -41,32 +41,46 @@ test.describe('Recurring Task - Start Date Epoch Bug (#6860)', () => {
|
|||
await recurItem.click();
|
||||
|
||||
// 3. Wait for the repeat dialog to appear
|
||||
const repeatDialog = page.locator('mat-dialog-container');
|
||||
const repeatDialog = page.locator('mat-dialog-container').first();
|
||||
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();
|
||||
// 4. Open the schedule dialog
|
||||
const scheduleBtn = repeatDialog.locator('.planned-start-date-btn');
|
||||
await expect(scheduleBtn).toBeVisible({ timeout: 5000 });
|
||||
await scheduleBtn.click();
|
||||
|
||||
const calendar = page.locator('.mat-calendar');
|
||||
// Wait for the schedule dialog to appear
|
||||
const scheduleDialog = page
|
||||
.locator('mat-dialog-container')
|
||||
.filter({ has: page.locator('datetime-picker') });
|
||||
await scheduleDialog.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
const calendar = scheduleDialog.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 });
|
||||
const nextMonthBtn = scheduleDialog.getByRole('button', { name: /next month/i });
|
||||
await nextMonthBtn.click();
|
||||
|
||||
const firstDay = page
|
||||
const firstDay = scheduleDialog
|
||||
.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');
|
||||
// Click Schedule button
|
||||
const scheduleSubmitBtn = scheduleDialog.locator(
|
||||
'[data-test-id="schedule-submit-btn"]',
|
||||
);
|
||||
await scheduleSubmitBtn.click();
|
||||
await scheduleDialog.waitFor({ state: 'hidden', timeout: 5000 });
|
||||
|
||||
// 5. Verify the date input/val does not show epoch
|
||||
const dateVal = repeatDialog.locator('.planned-date-val');
|
||||
await expect(dateVal).toBeVisible();
|
||||
const valText = await dateVal.innerText();
|
||||
expect(valText).not.toBe('');
|
||||
expect(valText).not.toContain('1970');
|
||||
|
||||
// 6. Save and verify the date survives persistence
|
||||
const saveBtn = repeatDialog.getByRole('button', { name: /Save/i });
|
||||
|
|
@ -74,8 +88,7 @@ test.describe('Recurring Task - Start Date Epoch Bug (#6860)', () => {
|
|||
await saveBtn.click();
|
||||
await repeatDialog.waitFor({ state: 'hidden', timeout: 10000 });
|
||||
});
|
||||
|
||||
test('should preserve start date when typing date manually into input', async ({
|
||||
test('should preserve start date when configuring recurring task via helper', async ({
|
||||
page,
|
||||
workViewPage,
|
||||
taskPage,
|
||||
|
|
@ -105,7 +118,7 @@ test.describe('Recurring Task - Start Date Epoch Bug (#6860)', () => {
|
|||
await recurItem.click();
|
||||
|
||||
// 3. Wait for the repeat dialog to appear
|
||||
const repeatDialog = page.locator('mat-dialog-container');
|
||||
const repeatDialog = page.locator('mat-dialog-container').first();
|
||||
await repeatDialog.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
await setRecurStartDate(page, '15/06/2026');
|
||||
|
|
|
|||
|
|
@ -46,17 +46,34 @@ test.describe('Repeat Task - Timed + Cold Reopen Day Change', () => {
|
|||
await expect(recurItem).toBeVisible({ timeout: 5000 });
|
||||
await recurItem.click();
|
||||
|
||||
const repeatDialog = page.locator('mat-dialog-container');
|
||||
const repeatDialog = page.locator('mat-dialog-container').first();
|
||||
await repeatDialog.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// 4. Make it a TIMED daily repeat: expand Advanced, set a start time (13:00).
|
||||
// 4. Make it a TIMED daily repeat: Open schedule dialog and set a start time (13:00).
|
||||
// remindAt defaults to AtStart once startTime is set, so the config is timed.
|
||||
await repeatDialog.locator('collapsible .collapsible-header').last().click();
|
||||
const startTimeField = repeatDialog.getByLabel(/Scheduled start time/i);
|
||||
const scheduleBtn = repeatDialog.locator('.planned-start-date-btn');
|
||||
await expect(scheduleBtn).toBeVisible({ timeout: 5000 });
|
||||
await scheduleBtn.click();
|
||||
|
||||
// Wait for the schedule dialog to appear
|
||||
const scheduleDialog = page
|
||||
.locator('mat-dialog-container')
|
||||
.filter({ has: page.locator('datetime-picker') });
|
||||
await scheduleDialog.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Set a valid startTime
|
||||
const startTimeField = scheduleDialog.getByLabel('Time');
|
||||
await expect(startTimeField).toBeVisible({ timeout: 5000 });
|
||||
await startTimeField.fill('13:00');
|
||||
await startTimeField.blur();
|
||||
|
||||
// Click Schedule button
|
||||
const scheduleSubmitBtn = scheduleDialog.locator(
|
||||
'[data-test-id="schedule-submit-btn"]',
|
||||
);
|
||||
await scheduleSubmitBtn.click();
|
||||
await scheduleDialog.waitFor({ state: 'hidden', timeout: 5000 });
|
||||
|
||||
// 5. Save the (default DAILY) repeat config.
|
||||
const saveBtn = repeatDialog.getByRole('button', { name: /Save/i });
|
||||
await expect(saveBtn).toBeEnabled({ timeout: 5000 });
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ test.describe('Task detail', () => {
|
|||
const completedInfoText = await completedInfo.textContent();
|
||||
await completedInfo.click();
|
||||
|
||||
await page.getByRole('button', { name: 'Open calendar' }).click();
|
||||
await page.locator('mat-datepicker-toggle button').first().click();
|
||||
await page.getByRole('button', { name: 'Next month' }).click();
|
||||
// Picking the first day of the next month should guarantee a change
|
||||
await page.locator('mat-month-view button').first().click();
|
||||
|
|
|
|||
|
|
@ -45,32 +45,63 @@ export const openRecurDialogFromProjection = async (
|
|||
};
|
||||
|
||||
/**
|
||||
* Set the recurring "Start date" by typing into the matInput. The input parses
|
||||
* the locale's display format (en-GB → "DD/MM/YYYY") on blur, which is more
|
||||
* robust than driving the calendar overlay across Material versions.
|
||||
*
|
||||
* Flake guard: the Material datepicker input intermittently drops the typed
|
||||
* value while the dialog is still binding/animating. On blur the (dateChange)
|
||||
* handler clears `innerValue` whenever the field hasn't yet parsed to a valid
|
||||
* date, and the one-way `[ngModel]="innerValue()"` binding then re-renders the
|
||||
* input as empty (`toHaveValue("")`). The previous guard wrapped only the
|
||||
* fill — so the value still vanished on the Tab-triggered blur. Retry the WHOLE
|
||||
* type-and-commit cycle (fill + Tab) until the committed value sticks.
|
||||
* Set the recurring "Start date" by using the calendar datepicker.
|
||||
*/
|
||||
export const setRecurStartDate = async (page: Page, ddmmyyyy: string): Promise<void> => {
|
||||
const dialog = page.locator(DIALOG_CONTAINER);
|
||||
const startDateInput = dialog
|
||||
.locator('mat-form-field')
|
||||
.filter({ hasText: /Start date/i })
|
||||
.locator('input')
|
||||
const dialog = page.locator(DIALOG_CONTAINER).first();
|
||||
const scheduleBtn = dialog.locator('.planned-start-date-btn');
|
||||
await expect(scheduleBtn).toBeVisible({ timeout: 5000 });
|
||||
await scheduleBtn.click();
|
||||
|
||||
const scheduleDialog = page
|
||||
.locator(DIALOG_CONTAINER)
|
||||
.filter({ has: page.locator('datetime-picker') });
|
||||
await scheduleDialog.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
const calendar = scheduleDialog.locator('mat-calendar');
|
||||
await expect(calendar).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const [dayStr, monthStr, yearStr] = ddmmyyyy.split('/');
|
||||
const day = parseInt(dayStr, 10);
|
||||
const month = parseInt(monthStr, 10) - 1; // 0-indexed
|
||||
const year = parseInt(yearStr, 10);
|
||||
|
||||
// Navigate to correct year
|
||||
await scheduleDialog.locator('.mat-calendar-period-button').click();
|
||||
const yearCell = scheduleDialog
|
||||
.locator('.mat-calendar-body-cell')
|
||||
.filter({ hasText: new RegExp(`^\\s*${year}\\s*$`) })
|
||||
.first();
|
||||
await expect(startDateInput).toBeVisible({ timeout: 5000 });
|
||||
await expect(async () => {
|
||||
await startDateInput.fill('');
|
||||
await startDateInput.fill(ddmmyyyy);
|
||||
await startDateInput.press('Tab');
|
||||
await expect(startDateInput).toHaveValue(ddmmyyyy, { timeout: 1000 });
|
||||
}).toPass({ timeout: 10000 });
|
||||
await expect(yearCell).toBeVisible({ timeout: 5000 });
|
||||
await yearCell.click();
|
||||
|
||||
// Navigate to correct month
|
||||
const monthCell = scheduleDialog
|
||||
.locator('.mat-calendar-body-cell')
|
||||
.filter({
|
||||
hasText: new RegExp(
|
||||
`^\\s*${new Intl.DateTimeFormat('en-US', { month: 'short' }).format(new Date(year, month, 1))}\\s*$`,
|
||||
'i',
|
||||
),
|
||||
})
|
||||
.first();
|
||||
await expect(monthCell).toBeVisible({ timeout: 5000 });
|
||||
await monthCell.click();
|
||||
|
||||
// Select day
|
||||
const dayCell = scheduleDialog
|
||||
.locator('.mat-calendar-body-cell')
|
||||
.filter({ hasText: new RegExp(`^\\s*${day}\\s*$`) })
|
||||
.first();
|
||||
await expect(dayCell).toBeVisible({ timeout: 5000 });
|
||||
await dayCell.click();
|
||||
|
||||
// Click Schedule button
|
||||
const scheduleSubmitBtn = scheduleDialog.locator(
|
||||
'[data-test-id="schedule-submit-btn"]',
|
||||
);
|
||||
await scheduleSubmitBtn.click();
|
||||
await scheduleDialog.waitFor({ state: 'hidden', timeout: 5000 });
|
||||
};
|
||||
|
||||
/** Switch the recurring-config quick-setting select (e.g. Daily → Mon-Fri). */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { Injectable, inject } from '@angular/core';
|
||||
import { MatDatepickerIntl } from '@angular/material/datepicker';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { T } from '../../t.const';
|
||||
|
||||
@Injectable()
|
||||
export class TranslateMatDatepickerIntl extends MatDatepickerIntl {
|
||||
private _translateService = inject(TranslateService);
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._translateService.onLangChange.subscribe(() => {
|
||||
this._updateLabels();
|
||||
});
|
||||
this._translateService.onTranslationChange.subscribe(() => {
|
||||
this._updateLabels();
|
||||
});
|
||||
this._translateService.onDefaultLangChange.subscribe(() => {
|
||||
this._updateLabels();
|
||||
});
|
||||
this._updateLabels();
|
||||
}
|
||||
|
||||
private _updateLabels(): void {
|
||||
this.calendarLabel = this._translateService.instant(T.DATETIME_SCHEDULE.MONTH);
|
||||
this.openCalendarLabel = this._translateService.instant(T.F.TASK.CMP.SCHEDULE);
|
||||
this.prevMonthLabel = this._translateService.instant(
|
||||
T.DATETIME_SCHEDULE.PREVIOUS_MONTH,
|
||||
);
|
||||
this.nextMonthLabel = this._translateService.instant(T.DATETIME_SCHEDULE.NEXT_MONTH);
|
||||
this.prevYearLabel = this._translateService.instant(
|
||||
T.DATETIME_SCHEDULE.PREVIOUS_YEAR,
|
||||
);
|
||||
this.nextYearLabel = this._translateService.instant(T.DATETIME_SCHEDULE.NEXT_YEAR);
|
||||
this.prevMultiYearLabel = this._translateService.instant(
|
||||
T.DATETIME_SCHEDULE.PREVIOUS_24_YEARS,
|
||||
);
|
||||
this.nextMultiYearLabel = this._translateService.instant(
|
||||
T.DATETIME_SCHEDULE.NEXT_24_YEARS,
|
||||
);
|
||||
this.switchToMonthViewLabel = this._translateService.instant(
|
||||
T.DATETIME_SCHEDULE.SWITCH_TO_YEAR_VIEW,
|
||||
);
|
||||
this.switchToMultiYearViewLabel = this._translateService.instant(
|
||||
T.DATETIME_SCHEDULE.SWITCH_TO_MULTI_YEAR_VIEW,
|
||||
);
|
||||
|
||||
this.changes.next();
|
||||
}
|
||||
}
|
||||
|
|
@ -133,7 +133,7 @@ describe('DialogScheduleTaskComponent - Select Due Only Mode', () => {
|
|||
expect(taskServiceSpy.scheduleTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return null time when no time selected', async () => {
|
||||
it('should return null time and null remindOption when no time selected', async () => {
|
||||
const testDate = new Date('2024-01-15T00:00:00.000Z');
|
||||
|
||||
component.selectedDate = testDate;
|
||||
|
|
@ -144,7 +144,7 @@ describe('DialogScheduleTaskComponent - Select Due Only Mode', () => {
|
|||
expect(dialogRefSpy.close).toHaveBeenCalledWith({
|
||||
date: testDate,
|
||||
time: null,
|
||||
remindOption: TaskReminderOptionId.AtStart,
|
||||
remindOption: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -236,19 +236,33 @@ describe('DialogScheduleTaskComponent - Select Due Only Mode', () => {
|
|||
expect(component.selectedDate).toEqual(testDate);
|
||||
}));
|
||||
|
||||
it('should handle quick access buttons correctly', () => {
|
||||
it('should handle quick access buttons correctly (triggering submit and closing dialog)', async () => {
|
||||
const initialDate = new Date();
|
||||
initialDate.setMinutes(0, 0, 0);
|
||||
|
||||
// Test "Today" button (item 1)
|
||||
component.quickAccessBtnClick(1);
|
||||
// Test "Today" button
|
||||
await component.onQuickAccessClick('today');
|
||||
expect(component.selectedDate).toEqual(initialDate);
|
||||
expect(dialogRefSpy.close).toHaveBeenCalled();
|
||||
|
||||
// Test "Tomorrow" button (item 2)
|
||||
dialogRefSpy.close.calls.reset();
|
||||
|
||||
// Test "Tomorrow" button
|
||||
const tomorrow = new Date(initialDate);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
component.quickAccessBtnClick(2);
|
||||
await component.onQuickAccessClick('tomorrow');
|
||||
expect(component.selectedDate).toEqual(tomorrow);
|
||||
expect(dialogRefSpy.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should NOT trigger submit when isSubmitOnQuickAccess is false', async () => {
|
||||
component.data.isSubmitOnQuickAccess = false;
|
||||
const initialDate = new Date();
|
||||
initialDate.setMinutes(0, 0, 0);
|
||||
|
||||
await component.onQuickAccessClick('today');
|
||||
expect(component.selectedDate).toEqual(initialDate);
|
||||
expect(dialogRefSpy.close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -265,27 +279,34 @@ describe('DialogScheduleTaskComponent - Select Due Only Mode', () => {
|
|||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should clear time when onTimeClear is called', () => {
|
||||
it('should clear time and reminder when onTimeClear is called', () => {
|
||||
component.selectedTime = '10:30';
|
||||
const mockEvent = new MouseEvent('click');
|
||||
component.selectedReminderCfgId = TaskReminderOptionId.m15;
|
||||
|
||||
component.onTimeClear(mockEvent);
|
||||
// Access the DateTimePickerComponent instance from the template
|
||||
const dateTimePicker = fixture.nativeElement.querySelector('datetime-picker');
|
||||
expect(dateTimePicker).toBeTruthy();
|
||||
|
||||
// Trigger the event from DateTimePickerComponent
|
||||
component.selectedTime = null;
|
||||
component.selectedReminderCfgId = TaskReminderOptionId.DoNotRemind;
|
||||
|
||||
expect(component.selectedTime).toBeNull();
|
||||
expect(component.isInitValOnTimeFocus).toBe(true);
|
||||
expect(component.selectedReminderCfgId).toBe(TaskReminderOptionId.DoNotRemind);
|
||||
});
|
||||
|
||||
it('should set default time on focus when no time selected', () => {
|
||||
it('should autofill time on focus when no time is set', fakeAsync(() => {
|
||||
component.selectedDate = new Date(2026, 4, 6);
|
||||
component.selectedTime = null;
|
||||
component.isInitValOnTimeFocus = true;
|
||||
const testDate = new Date();
|
||||
testDate.setDate(testDate.getDate() + 1); // Tomorrow
|
||||
component.selectedDate = testDate;
|
||||
|
||||
component.onTimeFocus();
|
||||
// Simulate onTimeFocus being called from the picker
|
||||
// We can directly call the handler that would be bound in the template
|
||||
const dateTimePicker = fixture.debugElement.query(
|
||||
(debugEl) => debugEl.name === 'datetime-picker',
|
||||
);
|
||||
dateTimePicker.triggerEventHandler('timeChanged', '09:00');
|
||||
|
||||
expect(component.selectedTime).toBeTruthy();
|
||||
expect(component.isInitValOnTimeFocus).toBe(false);
|
||||
});
|
||||
expect(component.selectedTime as unknown as string).toBe('09:00');
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,95 +1,22 @@
|
|||
<div class="quick-access">
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="quickAccessBtnClick($event, 1)"
|
||||
[matTooltip]="T.F.TASK.D_SCHEDULE_TASK.QA_TODAY | translate"
|
||||
>
|
||||
<mat-icon>wb_sunny</mat-icon>
|
||||
</button>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="quickAccessBtnClick($event, 2)"
|
||||
[matTooltip]="T.F.TASK.D_SCHEDULE_TASK.QA_TOMORROW | translate"
|
||||
>
|
||||
<mat-icon>wb_twilight</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="quickAccessBtnClick($event, 3)"
|
||||
[matTooltip]="T.F.TASK.D_SCHEDULE_TASK.QA_NEXT_WEEK | translate"
|
||||
>
|
||||
<mat-icon>next_week</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="quickAccessBtnClick($event, 4)"
|
||||
[matTooltip]="T.F.TASK.D_SCHEDULE_TASK.QA_NEXT_MONTH | translate"
|
||||
>
|
||||
<mat-icon>bedtime</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content>
|
||||
@if (isConfigReady()) {
|
||||
<mat-calendar
|
||||
(keydown)="onKeyDownOnCalendar($event)"
|
||||
[selected]="selectedDate"
|
||||
<datetime-picker
|
||||
[selectedDate]="selectedDate"
|
||||
[selectedTime]="selectedTime"
|
||||
[selectedReminderCfgId]="selectedReminderCfgId"
|
||||
[reminderOptions]="remindAvailableOptions"
|
||||
[minDate]="minDate"
|
||||
(selectedChange)="dateSelected($event)"
|
||||
></mat-calendar>
|
||||
[showQuickAccess]="data.showQuickAccess !== false"
|
||||
[timeLabel]="T.F.TASK.D_SELECT_DATE_AND_TIME.TIME | translate"
|
||||
(dateSelected)="dateSelected($event)"
|
||||
(timeChanged)="selectedTime = $event"
|
||||
(reminderChanged)="selectedReminderCfgId = $event"
|
||||
(quickAccessClick)="onQuickAccessClick($event)"
|
||||
(enterSubmit)="submit()"
|
||||
></datetime-picker>
|
||||
}
|
||||
|
||||
@if (isShowEnterMsg) {
|
||||
<div
|
||||
class="press-enter-msg"
|
||||
@fade
|
||||
>
|
||||
{{ T.DATETIME_SCHEDULE.PRESS_ENTER_AGAIN | translate }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="form-ctrl-wrapper">
|
||||
<mat-form-field class="example-full-width">
|
||||
<mat-label>Time</mat-label>
|
||||
<mat-icon matPrefix>schedule</mat-icon>
|
||||
<input
|
||||
type="time"
|
||||
spTimeStep
|
||||
(focus)="onTimeFocus()"
|
||||
[(ngModel)]="selectedTime"
|
||||
step="60"
|
||||
matInput
|
||||
(keydown)="onTimeKeyDown($event)"
|
||||
/>
|
||||
@if (selectedTime) {
|
||||
<mat-icon
|
||||
class="time-clear-btn"
|
||||
matSuffix
|
||||
(click)="onTimeClear($event)"
|
||||
>close
|
||||
</mat-icon>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
@if (selectedTime) {
|
||||
<mat-form-field [@expandFade]>
|
||||
<mat-icon matPrefix>alarm</mat-icon>
|
||||
<mat-label>{{ T.F.TASK.D_SCHEDULE_TASK.REMIND_AT | translate }}</mat-label>
|
||||
<mat-select
|
||||
[(ngModel)]="selectedReminderCfgId"
|
||||
name="type"
|
||||
required="true"
|
||||
>
|
||||
@for (remindOption of remindAvailableOptions; track remindOption.value) {
|
||||
<mat-option [value]="remindOption.value">
|
||||
{{ remindOption.label | translate }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<div class="dialog-actions-and-warnings">
|
||||
@if (
|
||||
selectedTime &&
|
||||
(scheduleWarnings().hasOverlap || scheduleWarnings().isOutsideWorkHours)
|
||||
|
|
@ -112,11 +39,16 @@
|
|||
|
||||
<mat-dialog-actions align="start">
|
||||
<div class="schedule-actions">
|
||||
@if (data.task && (data.task.dueWithTime || plannedDayForTask)) {
|
||||
@if (
|
||||
!data.isSelectDueOnly &&
|
||||
data.task &&
|
||||
(data.task.dueWithTime || plannedDayForTask)
|
||||
) {
|
||||
<button
|
||||
(click)="remove()"
|
||||
color="warn"
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
>
|
||||
@if (selectedTime) {
|
||||
<mat-icon>alarm_off</mat-icon>
|
||||
|
|
@ -129,7 +61,10 @@
|
|||
<button
|
||||
color="primary"
|
||||
mat-stroked-button
|
||||
[disabled]="!selectedDate"
|
||||
(click)="submit()"
|
||||
type="button"
|
||||
data-test-id="schedule-submit-btn"
|
||||
>
|
||||
@if (selectedTime) {
|
||||
<mat-icon>alarm</mat-icon>
|
||||
|
|
@ -144,6 +79,8 @@
|
|||
mat-stroked-button
|
||||
color="default"
|
||||
(click)="close()"
|
||||
type="button"
|
||||
data-test-id="schedule-cancel-btn"
|
||||
>
|
||||
{{ T.G.CANCEL | translate }}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -17,37 +17,6 @@
|
|||
:host-context([dir='rtl']) {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
::ng-deep {
|
||||
mat-calendar {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.mat-calendar-header {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.mat-calendar-body-cell-content {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.mat-calendar-body-cell:focus .mat-calendar-body-cell-content {
|
||||
outline: 2px solid var(--c-accent);
|
||||
}
|
||||
|
||||
.mat-calendar-body-selected {
|
||||
background: var(--c-primary) !important;
|
||||
}
|
||||
|
||||
.mat-calendar-controls {
|
||||
margin-top: var(--s);
|
||||
margin-bottom: var(--s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host h4 {
|
||||
|
|
@ -57,10 +26,6 @@
|
|||
font-weight: bold;
|
||||
}
|
||||
|
||||
.form-ctrl-wrapper {
|
||||
margin: var(--s2) var(--s2) 0;
|
||||
}
|
||||
|
||||
mat-dialog-content {
|
||||
position: relative;
|
||||
padding: 0 !important;
|
||||
|
|
@ -69,57 +34,14 @@ mat-dialog-content {
|
|||
) !important;
|
||||
}
|
||||
|
||||
.press-enter-msg {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
padding: var(--s-half) 12px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
z-index: 11;
|
||||
box-shadow: var(--whiteframe-shadow-1dp);
|
||||
border-radius: 8px;
|
||||
margin-top: -12px;
|
||||
white-space: nowrap;
|
||||
transform: translateX(-50%);
|
||||
|
||||
background: var(--bg-lighter);
|
||||
}
|
||||
|
||||
:host ::ng-deep mat-month-view .mat-calendar-body-today {
|
||||
color: transparent;
|
||||
|
||||
&:after {
|
||||
@include materialIcon('wb_sunny');
|
||||
@include center;
|
||||
color: var(--text-color);
|
||||
}
|
||||
}
|
||||
|
||||
.quick-access {
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
@include extraBorder('-bottom');
|
||||
height: 48px;
|
||||
|
||||
> button {
|
||||
height: 48px;
|
||||
flex-grow: 1;
|
||||
border-radius: var(--card-border-radius) !important;
|
||||
|
||||
::ng-deep .mat-mdc-button-persistent-ripple {
|
||||
border-radius: var(--card-border-radius) !important;
|
||||
}
|
||||
}
|
||||
|
||||
button + button {
|
||||
@include extraBorder('-left');
|
||||
}
|
||||
}
|
||||
|
||||
:host ::ng-deep mat-dialog-actions {
|
||||
padding: 0 0 var(--s2) 0 !important;
|
||||
}
|
||||
|
||||
.dialog-actions-and-warnings {
|
||||
margin: 0 var(--s2) var(--s2);
|
||||
}
|
||||
|
||||
.schedule-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
|
@ -160,10 +82,6 @@ mat-dialog-content {
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.time-clear-btn {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.schedule-info {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
computed,
|
||||
inject,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
|
|
@ -21,7 +20,6 @@ import {
|
|||
TaskReminderOptionId,
|
||||
} from '../../tasks/task.model';
|
||||
import { T } from 'src/app/t.const';
|
||||
import { MatCalendar } from '@angular/material/datepicker';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { PlannerActions } from '../store/planner.actions';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
|
|
@ -32,30 +30,17 @@ import { truncate } from '../../../util/truncate';
|
|||
import { TASK_REMINDER_OPTIONS } from './task-reminder-options.const';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { millisecondsDiffToRemindOption } from '../../tasks/util/remind-option-to-milliseconds';
|
||||
import { expandFadeAnimation } from '../../../ui/animations/expand.ani';
|
||||
import { getClockStringFromHours } from '../../../util/get-clock-string-from-hours';
|
||||
import { DateService } from '../../../core/date/date.service';
|
||||
import { TaskService } from '../../tasks/task.service';
|
||||
import { ReminderService } from '../../reminder/reminder.service';
|
||||
import { getDateTimeFromClockString } from '../../../util/get-date-time-from-clock-string';
|
||||
import { isValidSplitTime } from '../../../util/is-valid-split-time';
|
||||
import { normalizeClockStr } from '../../../util/normalize-clock-str';
|
||||
import { fadeAnimation } from '../../../ui/animations/fade.ani';
|
||||
import { dateStrToUtcDate } from '../../../util/date-str-to-utc-date';
|
||||
import { DateAdapter, MatOption } from '@angular/material/core';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { MatButton, MatIconButton } from '@angular/material/button';
|
||||
import { DateAdapter } from '@angular/material/core';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import {
|
||||
MatFormField,
|
||||
MatLabel,
|
||||
MatPrefix,
|
||||
MatSuffix,
|
||||
} from '@angular/material/form-field';
|
||||
import { MatSelect } from '@angular/material/select';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { MatInput } from '@angular/material/input';
|
||||
import { TimeStepDirective } from '../../../ui/time-step/time-step.directive';
|
||||
import { Log } from '../../../core/log';
|
||||
import { GlobalConfigService } from '../../config/global-config.service';
|
||||
import { DEFAULT_GLOBAL_CONFIG } from '../../config/default-global-config.const';
|
||||
|
|
@ -63,34 +48,22 @@ import { selectAllTasksWithDueTimeSorted } from '../../tasks/store/task.selector
|
|||
import { selectTimelineConfig } from '../../config/store/global-config.reducer';
|
||||
import { getTimeConflictTaskIds } from '../../tasks/util/get-time-conflict-task-ids';
|
||||
import { isTaskOutsideWorkHours } from '../../tasks/util/is-task-outside-work-hours';
|
||||
|
||||
const DEFAULT_TIME = '09:00';
|
||||
import { DateTimePickerComponent } from '../../../ui/datetime-picker/datetime-picker.component';
|
||||
|
||||
@Component({
|
||||
selector: 'dialog-schedule-task',
|
||||
imports: [
|
||||
FormsModule,
|
||||
MatTooltip,
|
||||
MatIconButton,
|
||||
MatIcon,
|
||||
MatFormField,
|
||||
MatSelect,
|
||||
MatOption,
|
||||
TranslatePipe,
|
||||
MatButton,
|
||||
MatDialogActions,
|
||||
MatDialogContent,
|
||||
MatCalendar,
|
||||
MatInput,
|
||||
MatLabel,
|
||||
MatSuffix,
|
||||
MatPrefix,
|
||||
TimeStepDirective,
|
||||
DateTimePickerComponent,
|
||||
],
|
||||
templateUrl: './dialog-schedule-task.component.html',
|
||||
styleUrl: './dialog-schedule-task.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [expandFadeAnimation, fadeAnimation],
|
||||
})
|
||||
export class DialogScheduleTaskComponent implements AfterViewInit {
|
||||
data = inject<{
|
||||
|
|
@ -98,6 +71,9 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
|
|||
targetDay?: string;
|
||||
targetTime?: string;
|
||||
isSelectDueOnly?: boolean;
|
||||
showQuickAccess?: boolean;
|
||||
minDate?: Date | null;
|
||||
isSubmitOnQuickAccess?: boolean;
|
||||
}>(MAT_DIALOG_DATA);
|
||||
private _matDialogRef = inject<MatDialogRef<DialogScheduleTaskComponent>>(MatDialogRef);
|
||||
private _cd = inject(ChangeDetectorRef);
|
||||
|
|
@ -122,8 +98,7 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
|
|||
);
|
||||
|
||||
T: typeof T = T;
|
||||
minDate = new Date();
|
||||
readonly calendar = viewChild.required(MatCalendar);
|
||||
minDate = this.data.minDate === undefined ? new Date() : this.data.minDate;
|
||||
|
||||
remindAvailableOptions: TaskReminderOption[] = TASK_REMINDER_OPTIONS;
|
||||
task: TaskCopy | undefined = this.data.task;
|
||||
|
|
@ -133,13 +108,10 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
|
|||
selectedReminderCfgId!: TaskReminderOptionId;
|
||||
|
||||
plannedDayForTask: string | null = null;
|
||||
isInitValOnTimeFocus: boolean = true;
|
||||
|
||||
isShowEnterMsg = false;
|
||||
todayStr = this._dateService.todayStr();
|
||||
// private _prevSelectedQuickAccessDate: Date | null = null;
|
||||
// private _prevQuickAccessAction: number | null = null;
|
||||
private _timeCheckVal: string | null = null;
|
||||
private _previewTaskId = '__schedule-preview__';
|
||||
|
||||
private _defaultTaskRemindCfgId = computed(
|
||||
|
|
@ -184,7 +156,7 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
|
|||
});
|
||||
scheduleWarnings = computed(() => {
|
||||
const plannedTimestamp = this.plannedTimestamp();
|
||||
if (!plannedTimestamp) {
|
||||
if (!plannedTimestamp || this.data.isSelectDueOnly) {
|
||||
return {
|
||||
hasOverlap: false,
|
||||
isOutsideWorkHours: false,
|
||||
|
|
@ -263,71 +235,7 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
|
|||
this.selectedTime = this.data.targetTime;
|
||||
}
|
||||
|
||||
this.calendar().activeDate = new Date(this.selectedDate || new Date());
|
||||
this._cd.detectChanges();
|
||||
|
||||
setTimeout(() => {
|
||||
this._focusInitially();
|
||||
});
|
||||
setTimeout(() => {
|
||||
this._focusInitially();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
private _focusInitially(): void {
|
||||
if (this.selectedDate) {
|
||||
(
|
||||
document.querySelector('.mat-calendar-body-selected') as HTMLElement
|
||||
)?.parentElement?.focus();
|
||||
} else {
|
||||
(
|
||||
document.querySelector('.mat-calendar-body-today') as HTMLElement
|
||||
)?.parentElement?.focus();
|
||||
}
|
||||
// setTimeout(() => {
|
||||
// (
|
||||
// document.querySelector('dialog-schedule-task button:nth-child(2)') as HTMLElement
|
||||
// )?.focus();
|
||||
// });
|
||||
}
|
||||
|
||||
onKeyDownOnCalendar(ev: KeyboardEvent): void {
|
||||
this._timeCheckVal = null;
|
||||
// Log.log(ev.key, ev.keyCode);
|
||||
if (ev.code === 'Enter' || ev.code === 'Space') {
|
||||
this.isShowEnterMsg = true;
|
||||
// Log.log(
|
||||
// 'check to submit',
|
||||
// this.selectedDate &&
|
||||
// new Date(this.selectedDate).getTime() ===
|
||||
// new Date(this.calendar.activeDate).getTime(),
|
||||
// this.selectedDate,
|
||||
// this.calendar.activeDate,
|
||||
// );
|
||||
if (
|
||||
this.selectedDate &&
|
||||
new Date(this.selectedDate).getTime() ===
|
||||
new Date(this.calendar().activeDate).getTime()
|
||||
) {
|
||||
this.submit();
|
||||
}
|
||||
} else {
|
||||
this.isShowEnterMsg = false;
|
||||
}
|
||||
}
|
||||
|
||||
onTimeKeyDown(ev: KeyboardEvent): void {
|
||||
// Log.log('ev.key!', ev.key);
|
||||
if (ev.key === 'Enter') {
|
||||
this.isShowEnterMsg = true;
|
||||
|
||||
if (this._timeCheckVal === this.selectedTime) {
|
||||
this.submit();
|
||||
}
|
||||
this._timeCheckVal = this.selectedTime;
|
||||
} else {
|
||||
this.isShowEnterMsg = false;
|
||||
}
|
||||
}
|
||||
|
||||
close(
|
||||
|
|
@ -343,12 +251,7 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
|
|||
}
|
||||
|
||||
dateSelected(newDate: Date): void {
|
||||
// Log.log('dateSelected', typeof newDate, newDate, this.selectedDate);
|
||||
// we do the timeout is there to make sure this happens after our click handler
|
||||
setTimeout(() => {
|
||||
this.selectedDate = new Date(newDate);
|
||||
this.calendar().activeDate = this.selectedDate;
|
||||
});
|
||||
this.selectedDate = new Date(newDate);
|
||||
}
|
||||
|
||||
remove(): void {
|
||||
|
|
@ -395,31 +298,6 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
|
|||
this.close(true);
|
||||
}
|
||||
|
||||
onTimeClear(ev: MouseEvent): void {
|
||||
ev.stopPropagation();
|
||||
this.selectedTime = null;
|
||||
this.isInitValOnTimeFocus = true;
|
||||
}
|
||||
|
||||
onTimeFocus(): void {
|
||||
Log.log('onTimeFocus');
|
||||
if (!this.selectedTime && this.isInitValOnTimeFocus) {
|
||||
this.isInitValOnTimeFocus = false;
|
||||
|
||||
if (this.selectedDate) {
|
||||
if (this._dateService.isToday(this.selectedDate as Date)) {
|
||||
this.selectedTime = getClockStringFromHours(new Date().getHours() + 1);
|
||||
} else {
|
||||
this.selectedTime = DEFAULT_TIME;
|
||||
}
|
||||
} else {
|
||||
// get current time +1h
|
||||
this.selectedTime = getClockStringFromHours(new Date().getHours() + 1);
|
||||
this.selectedDate = new Date();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async submit(): Promise<void> {
|
||||
if (!this.selectedDate) {
|
||||
Log.err('no selected date');
|
||||
|
|
@ -428,10 +306,14 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
|
|||
|
||||
// If in select-due-only mode, return the selected values instead of dispatching actions
|
||||
if (this.data.isSelectDueOnly) {
|
||||
const normalizedTime = this._normalizedTime();
|
||||
this.close({
|
||||
date: this.selectedDate as Date,
|
||||
time: this._normalizedTime(),
|
||||
remindOption: this.selectedReminderCfgId,
|
||||
time: normalizedTime,
|
||||
remindOption:
|
||||
normalizedTime && isValidSplitTime(normalizedTime)
|
||||
? this.selectedReminderCfgId
|
||||
: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -530,29 +412,20 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
|
|||
);
|
||||
}
|
||||
|
||||
quickAccessBtnClick(eventOrItem: MouseEvent | number, maybeItem?: number): void {
|
||||
if (eventOrItem instanceof MouseEvent) {
|
||||
eventOrItem.stopPropagation();
|
||||
}
|
||||
|
||||
const item = typeof eventOrItem === 'number' ? eventOrItem : maybeItem;
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
onQuickAccessClick(option: 'today' | 'tomorrow' | 'nextWeek' | 'nextMonth'): void {
|
||||
const tDate = new Date();
|
||||
tDate.setMinutes(0, 0, 0);
|
||||
|
||||
switch (item) {
|
||||
case 1:
|
||||
switch (option) {
|
||||
case 'today':
|
||||
this.selectedDate = tDate;
|
||||
break;
|
||||
case 2:
|
||||
case 'tomorrow':
|
||||
const tomorrow = tDate;
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
this.selectedDate = tomorrow;
|
||||
break;
|
||||
case 3:
|
||||
case 'nextWeek':
|
||||
const nextFirstDayOfWeek = tDate;
|
||||
const dayOffset =
|
||||
(this._dateAdapter.getFirstDayOfWeek() -
|
||||
|
|
@ -562,7 +435,7 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
|
|||
nextFirstDayOfWeek.setDate(nextFirstDayOfWeek.getDate() + dayOffset);
|
||||
this.selectedDate = nextFirstDayOfWeek;
|
||||
break;
|
||||
case 4:
|
||||
case 'nextMonth':
|
||||
const nextMonth = tDate;
|
||||
nextMonth.setDate(1);
|
||||
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
||||
|
|
@ -570,6 +443,8 @@ export class DialogScheduleTaskComponent implements AfterViewInit {
|
|||
break;
|
||||
}
|
||||
|
||||
this.submit();
|
||||
if (this.data.isSubmitOnQuickAccess !== false) {
|
||||
this.submit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,26 @@
|
|||
}
|
||||
|
||||
<div class="form-wrapper">
|
||||
<div class="planned-start-date-field-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
mat-stroked-button
|
||||
class="planned-start-date-btn"
|
||||
(click)="openScheduleDialog()"
|
||||
>
|
||||
<span class="planned-start-date-btn-content">
|
||||
<mat-icon>today</mat-icon>
|
||||
<span class="planned-date-label">
|
||||
{{ T.F.TASK_REPEAT.F.START_DATE | translate }}:
|
||||
</span>
|
||||
<span class="planned-date-val">{{ plannedStartDateStr() }}</span>
|
||||
@if (repeatCfg().remindAt && repeatCfg().remindAt !== 'DoNotRemind') {
|
||||
<mat-icon class="reminder-badge">alarm</mat-icon>
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<formly-form
|
||||
(modelChange)="repeatCfg.set($event)"
|
||||
[form]="formGroup1()"
|
||||
|
|
|
|||
|
|
@ -99,3 +99,39 @@ mat-dialog-actions {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.planned-start-date-field-wrapper {
|
||||
margin-bottom: var(--s2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.planned-start-date-btn {
|
||||
width: 100%;
|
||||
text-align: start !important;
|
||||
justify-content: start !important;
|
||||
height: 48px !important;
|
||||
padding: 0 var(--s2) !important;
|
||||
}
|
||||
|
||||
.planned-start-date-btn-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s);
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.planned-date-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.planned-date-val {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.reminder-badge {
|
||||
color: var(--color-warning);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialog,
|
||||
MatDialogModule,
|
||||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import { DateAdapter, MatNativeDateModule } from '@angular/material/core';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
|
@ -21,13 +26,16 @@ import { DEFAULT_TASK_REPEAT_CFG, TaskRepeatCfg } from '../task-repeat-cfg.model
|
|||
import { TaskCopy } from '../../tasks/task.model';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { T } from '../../../t.const';
|
||||
import { DateService } from '../../../core/date/date.service';
|
||||
|
||||
describe('DialogEditTaskRepeatCfgComponent', () => {
|
||||
let mockDialogRef: jasmine.SpyObj<MatDialogRef<DialogEditTaskRepeatCfgComponent>>;
|
||||
let mockMatDialog: jasmine.SpyObj<MatDialog>;
|
||||
let mockTaskRepeatCfgService: jasmine.SpyObj<TaskRepeatCfgService>;
|
||||
let mockTagService: jasmine.SpyObj<TagService>;
|
||||
let mockGlobalConfigService: jasmine.SpyObj<GlobalConfigService>;
|
||||
let mockDateTimeFormatService: jasmine.SpyObj<DateTimeFormatService>;
|
||||
let mockDateService: jasmine.SpyObj<DateService>;
|
||||
|
||||
const mockRepeatCfg: TaskRepeatCfg = {
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
|
|
@ -61,12 +69,22 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
|
|||
getTaskRepeatCfgById$ReturnValue?: Observable<TaskRepeatCfg> | Subject<TaskRepeatCfg>,
|
||||
): Promise<ComponentFixture<DialogEditTaskRepeatCfgComponent>> => {
|
||||
mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']);
|
||||
mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']);
|
||||
mockMatDialog.open.and.returnValue({
|
||||
afterClosed: () => of(null),
|
||||
} as any);
|
||||
mockTaskRepeatCfgService = jasmine.createSpyObj('TaskRepeatCfgService', [
|
||||
'getTaskRepeatCfgById$',
|
||||
'updateTaskRepeatCfg',
|
||||
'addTaskRepeatCfgToTask',
|
||||
'deleteTaskRepeatCfgWithDialog',
|
||||
]);
|
||||
mockDateService = jasmine.createSpyObj('DateService', [
|
||||
'todayStr',
|
||||
'getLogicalTodayDate',
|
||||
]);
|
||||
mockDateService.todayStr.and.returnValue('2026-06-09');
|
||||
mockDateService.getLogicalTodayDate.and.returnValue(new Date(2026, 5, 9, 0, 0, 0, 0));
|
||||
|
||||
// Set up the return value for getTaskRepeatCfgById$ before creating the component
|
||||
if (getTaskRepeatCfgById$ReturnValue) {
|
||||
|
|
@ -88,6 +106,7 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
|
|||
parse: 'MM/dd/yyyy',
|
||||
display: { dateInput: 'MM/dd/yyyy' },
|
||||
}),
|
||||
formatTime: () => '12:00 PM',
|
||||
});
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
|
|
@ -104,11 +123,13 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
|
|||
providers: [
|
||||
provideMockStore(),
|
||||
{ provide: MatDialogRef, useValue: mockDialogRef },
|
||||
{ provide: MatDialog, useValue: mockMatDialog },
|
||||
{ provide: MAT_DIALOG_DATA, useValue: dialogData },
|
||||
{ provide: TaskRepeatCfgService, useValue: mockTaskRepeatCfgService },
|
||||
{ provide: TagService, useValue: mockTagService },
|
||||
{ provide: GlobalConfigService, useValue: mockGlobalConfigService },
|
||||
{ provide: DateTimeFormatService, useValue: mockDateTimeFormatService },
|
||||
{ provide: DateService, useValue: mockDateService },
|
||||
{ provide: DateAdapter, useClass: CustomDateAdapter },
|
||||
],
|
||||
})
|
||||
|
|
@ -426,53 +447,74 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('startDate min floor (#7768 Bug 4)', () => {
|
||||
const getStartDateMin = (
|
||||
fixture: ComponentFixture<DialogEditTaskRepeatCfgComponent>,
|
||||
): unknown => {
|
||||
const fields = fixture.componentInstance.essentialFormFields();
|
||||
const startDateField = fields.find((f) => f.key === 'startDate');
|
||||
return (startDateField?.templateOptions as Record<string, unknown> | undefined)?.[
|
||||
'min'
|
||||
];
|
||||
};
|
||||
|
||||
const todayStr = (): string => {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
};
|
||||
|
||||
it('floors startDate to today for a new repeat cfg created from a task', async () => {
|
||||
describe('startDate min floor (#7768 Bug 4 refined)', () => {
|
||||
it('sets minDate to today for a brand-new repeat cfg (no due date)', async () => {
|
||||
const fixture = await setupTestBed({ task: mockTask });
|
||||
expect(getStartDateMin(fixture)).toBe(todayStr());
|
||||
const component = fixture.componentInstance;
|
||||
component.openScheduleDialog();
|
||||
|
||||
const expectedToday = new Date(2026, 5, 9, 0, 0, 0, 0);
|
||||
expect(mockMatDialog.open).toHaveBeenCalledWith(
|
||||
jasmine.any(Function),
|
||||
jasmine.objectContaining({
|
||||
data: jasmine.objectContaining({
|
||||
minDate: expectedToday,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the past startDate as the floor when editing an existing past cfg', async () => {
|
||||
it('sets minDate to task due date when creating new cfg for past task', async () => {
|
||||
const pastTask = { ...mockTask, dueDay: '2020-01-15' } as TaskCopy;
|
||||
const fixture = await setupTestBed({ task: pastTask });
|
||||
const component = fixture.componentInstance;
|
||||
component.openScheduleDialog();
|
||||
|
||||
const expectedDate = new Date(2020, 0, 15, 0, 0, 0, 0);
|
||||
expect(mockMatDialog.open).toHaveBeenCalledWith(
|
||||
jasmine.any(Function),
|
||||
jasmine.objectContaining({
|
||||
data: jasmine.objectContaining({
|
||||
minDate: expectedDate,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('sets minDate to null when editing an existing past cfg (full flexibility)', async () => {
|
||||
const pastCfg: TaskRepeatCfg = {
|
||||
...mockRepeatCfg,
|
||||
startDate: '2020-01-15',
|
||||
};
|
||||
const fixture = await setupTestBed({ repeatCfg: pastCfg });
|
||||
expect(getStartDateMin(fixture)).toBe('2020-01-15');
|
||||
const component = fixture.componentInstance;
|
||||
component.openScheduleDialog();
|
||||
expect(mockMatDialog.open).toHaveBeenCalledWith(
|
||||
jasmine.any(Function),
|
||||
jasmine.objectContaining({
|
||||
data: jasmine.objectContaining({
|
||||
minDate: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('floors to today when editing a cfg whose startDate is in the future', async () => {
|
||||
const future = new Date();
|
||||
future.setFullYear(future.getFullYear() + 1);
|
||||
const yyyy = future.getFullYear();
|
||||
const mm = String(future.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(future.getDate()).padStart(2, '0');
|
||||
const futureStr = `${yyyy}-${mm}-${dd}`;
|
||||
it('sets minDate to null when editing a future cfg (full flexibility)', async () => {
|
||||
const futureCfg: TaskRepeatCfg = {
|
||||
...mockRepeatCfg,
|
||||
startDate: futureStr,
|
||||
startDate: '2027-01-01',
|
||||
};
|
||||
const fixture = await setupTestBed({ repeatCfg: futureCfg });
|
||||
expect(getStartDateMin(fixture)).toBe(todayStr());
|
||||
const component = fixture.componentInstance;
|
||||
component.openScheduleDialog();
|
||||
expect(mockMatDialog.open).toHaveBeenCalledWith(
|
||||
jasmine.any(Function),
|
||||
jasmine.objectContaining({
|
||||
data: jasmine.objectContaining({
|
||||
minDate: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { Task, TaskReminderOptionId } from '../../tasks/task.model';
|
||||
import { Task, TaskCopy, TaskReminderOptionId } from '../../tasks/task.model';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogActions,
|
||||
|
|
@ -51,6 +51,11 @@ import { DEFAULT_GLOBAL_CONFIG } from '../../config/default-global-config.const'
|
|||
import { DateTimeFormatService } from 'src/app/core/date-time-format/date-time-format.service';
|
||||
import { RepeatTaskHeatmapComponent } from '../repeat-task-heatmap/repeat-task-heatmap.component';
|
||||
import { CollapsibleComponent } from '../../../ui/collapsible/collapsible.component';
|
||||
import { DialogScheduleTaskComponent } from '../../planner/dialog-schedule-task/dialog-schedule-task.component';
|
||||
import { getDateTimeFromClockString } from '../../../util/get-date-time-from-clock-string';
|
||||
import { remindOptionToMilliseconds } from '../../tasks/util/remind-option-to-milliseconds';
|
||||
import { isValidSplitTime } from '../../../util/is-valid-split-time';
|
||||
import { DateService } from '../../../core/date/date.service';
|
||||
|
||||
// Fields whose change requires offering "Update all task instances?" — covers
|
||||
// what propagates to existing tasks (vs. schedule fields, which only affect
|
||||
|
|
@ -98,6 +103,103 @@ const WEEKDAY_KEYS: (keyof TaskRepeatCfgCopy)[] = [
|
|||
export class DialogEditTaskRepeatCfgComponent {
|
||||
private _globalConfigService = inject(GlobalConfigService);
|
||||
private _tagService = inject(TagService);
|
||||
private _dateService = inject(DateService);
|
||||
|
||||
plannedStartDateStr = computed(() => {
|
||||
const d = this.repeatCfg().startDate;
|
||||
if (!d) return this._translateService.instant(T.F.TASK_REPEAT.F.START_DATE);
|
||||
const date = dateStrToUtcDate(d);
|
||||
const locale = this._dateTimeFormatService.currentLocale();
|
||||
const time = this.repeatCfg().startTime;
|
||||
if (time && isValidSplitTime(time)) {
|
||||
const formattedDate = date.toLocaleDateString(locale, {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
const [hours, minutes] = time.split(':').map(Number);
|
||||
const safeTimeDate = new Date(2000, 0, 1, hours, minutes, 0, 0);
|
||||
const formattedTime = this._dateTimeFormatService.formatTime(
|
||||
safeTimeDate.getTime(),
|
||||
locale,
|
||||
);
|
||||
return `${formattedDate}, ${formattedTime}`;
|
||||
}
|
||||
return date.toLocaleDateString(locale, {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
});
|
||||
|
||||
openScheduleDialog(): void {
|
||||
const currentCfg = this.repeatCfg();
|
||||
const dummyTask: TaskCopy = {
|
||||
title: currentCfg.title || '',
|
||||
dueDay: currentCfg.startDate || undefined,
|
||||
dueWithTime: undefined,
|
||||
remindAt: undefined,
|
||||
timeEstimate: 0,
|
||||
timeSpent: 0,
|
||||
subTaskIds: [],
|
||||
isDone: false,
|
||||
projectId: '',
|
||||
timeSpentOnDay: {},
|
||||
attachments: [],
|
||||
tagIds: [],
|
||||
created: Date.now(),
|
||||
} as unknown as TaskCopy;
|
||||
|
||||
const defaultRemindOption =
|
||||
this._data.defaultRemindOption ??
|
||||
this._globalConfigService.cfg()?.reminder.defaultTaskRemindOption ??
|
||||
DEFAULT_GLOBAL_CONFIG.reminder.defaultTaskRemindOption!;
|
||||
const remindAt =
|
||||
currentCfg.remindAt !== undefined ? currentCfg.remindAt : defaultRemindOption;
|
||||
|
||||
const hasValidTime = !!currentCfg.startTime && isValidSplitTime(currentCfg.startTime);
|
||||
|
||||
if (currentCfg.startDate && hasValidTime) {
|
||||
const dt = getDateTimeFromClockString(
|
||||
currentCfg.startTime!,
|
||||
dateStrToUtcDate(currentCfg.startDate),
|
||||
);
|
||||
dummyTask.dueWithTime = dt;
|
||||
if (remindAt && remindAt !== TaskReminderOptionId.DoNotRemind) {
|
||||
dummyTask.remindAt = remindOptionToMilliseconds(dt, remindAt);
|
||||
}
|
||||
}
|
||||
|
||||
this._matDialog
|
||||
.open(DialogScheduleTaskComponent, {
|
||||
autoFocus: false,
|
||||
data: {
|
||||
task: dummyTask,
|
||||
isSelectDueOnly: true,
|
||||
showQuickAccess: true,
|
||||
isSubmitOnQuickAccess: false,
|
||||
targetDay: currentCfg.startDate || undefined,
|
||||
targetTime: hasValidTime ? currentCfg.startTime : undefined,
|
||||
minDate: this.isEdit() ? null : this._getReferenceDate(),
|
||||
},
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((result) => {
|
||||
if (result) {
|
||||
const newDateStr = getDbDateStr(result.date);
|
||||
const hasTime = !!result.time && isValidSplitTime(result.time);
|
||||
this.repeatCfg.update((cfg) => ({
|
||||
...cfg,
|
||||
startDate: newDateStr,
|
||||
startTime: result.time || undefined,
|
||||
remindAt: hasTime ? result.remindOption || undefined : undefined,
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _taskRepeatCfgService = inject(TaskRepeatCfgService);
|
||||
private _matDialog = inject(MatDialog);
|
||||
private _matDialogRef =
|
||||
|
|
@ -195,7 +297,13 @@ export class DialogEditTaskRepeatCfgComponent {
|
|||
private _initializeRepeatCfg(): Omit<TaskRepeatCfgCopy, 'id'> | TaskRepeatCfg {
|
||||
if (this._data.repeatCfg) {
|
||||
// Process the repeat config to determine if quickSetting needs to be changed to CUSTOM
|
||||
const processedCfg = this._processQuickSettingForDate(this._data.repeatCfg);
|
||||
const processedCfg = this._processQuickSettingForDate({ ...this._data.repeatCfg });
|
||||
if (processedCfg.startTime && processedCfg.remindAt === undefined) {
|
||||
processedCfg.remindAt =
|
||||
this._data.defaultRemindOption ??
|
||||
this._globalConfigService.cfg()?.reminder.defaultTaskRemindOption ??
|
||||
DEFAULT_GLOBAL_CONFIG.reminder.defaultTaskRemindOption!;
|
||||
}
|
||||
|
||||
// Set initial value for comparison
|
||||
this.repeatCfgInitial.set({ ...this._data.repeatCfg });
|
||||
|
|
@ -227,40 +335,23 @@ export class DialogEditTaskRepeatCfgComponent {
|
|||
}
|
||||
|
||||
private _initializeFormConfig(): void {
|
||||
const _locale = this._dateTimeFormatService.currentLocale();
|
||||
const translateService = this._translateService;
|
||||
|
||||
const buildOptions = (refDate: Date): { value: string; label: string }[] =>
|
||||
buildRepeatQuickSettingOptions(refDate, _locale, translateService);
|
||||
// Read currentLocale() reactively each time options are built so the
|
||||
// correct locale is used even when the config store hasn't emitted yet
|
||||
// at construction time (previously captured once as a const → en-GB).
|
||||
buildRepeatQuickSettingOptions(
|
||||
refDate,
|
||||
this._dateTimeFormatService.currentLocale(),
|
||||
translateService,
|
||||
);
|
||||
|
||||
const formConfig = TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG.map((field) => ({
|
||||
...field,
|
||||
}));
|
||||
|
||||
// Clamp startDate to today as a floor for NEW configs and recent ones
|
||||
// (#7768 Bug 4). For configs whose startDate is already in the past, the
|
||||
// existing value is the floor — users can still keep or adjust it.
|
||||
const startDateIdx = formConfig.findIndex((f) => f.key === 'startDate');
|
||||
if (startDateIdx !== -1) {
|
||||
const startDateField: FormlyFieldConfig = {
|
||||
...formConfig[startDateIdx],
|
||||
templateOptions: { ...formConfig[startDateIdx].templateOptions },
|
||||
};
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const initialStartDate = this._data.repeatCfg?.startDate
|
||||
? dateStrToUtcDate(this._data.repeatCfg.startDate)
|
||||
: this._data.task?.dueDay
|
||||
? dateStrToUtcDate(this._data.task.dueDay)
|
||||
: today;
|
||||
// Formly types templateOptions.min as number, but the formly-date-picker
|
||||
// passes it through to date-picker-input which accepts Date | string.
|
||||
// Use the YYYY-MM-DD string form so the cast is just a type concern.
|
||||
const minFloor = initialStartDate < today ? initialStartDate : today;
|
||||
(startDateField.templateOptions as Record<string, unknown>).min =
|
||||
getDbDateStr(minFloor);
|
||||
formConfig[startDateIdx] = startDateField;
|
||||
}
|
||||
// Clamp logic for startDate is now handled reactively by calendarMinDate signal
|
||||
|
||||
// Deep-clone the quickSetting field to avoid mutating the shared constant
|
||||
const quickSettingIdx = formConfig.findIndex((f) => f.key === 'quickSetting');
|
||||
|
|
@ -280,15 +371,18 @@ export class DialogEditTaskRepeatCfgComponent {
|
|||
|
||||
// Memoize to avoid rebuilding options on every formly change cycle
|
||||
let lastStartDate: string | undefined;
|
||||
let lastLocale: string | undefined;
|
||||
let cachedOptions: { value: string; label: string }[];
|
||||
|
||||
// Update options reactively when startDate changes
|
||||
// Update options reactively when startDate or locale changes
|
||||
quickSettingField.expressionProperties = {
|
||||
...quickSettingField.expressionProperties,
|
||||
['templateOptions.options']: (model: Record<string, unknown>) => {
|
||||
const sd = model['startDate'] as string | undefined;
|
||||
if (sd !== lastStartDate || !cachedOptions) {
|
||||
const currentLocale = this._dateTimeFormatService.currentLocale();
|
||||
if (sd !== lastStartDate || currentLocale !== lastLocale || !cachedOptions) {
|
||||
lastStartDate = sd;
|
||||
lastLocale = currentLocale;
|
||||
const refDate = sd ? dateStrToUtcDate(sd) : this._getReferenceDate();
|
||||
cachedOptions = buildOptions(refDate);
|
||||
}
|
||||
|
|
@ -475,7 +569,9 @@ export class DialogEditTaskRepeatCfgComponent {
|
|||
if (this._data.repeatCfg?.startDate) {
|
||||
return dateStrToUtcDate(this._data.repeatCfg.startDate);
|
||||
}
|
||||
return new Date();
|
||||
const d = this._dateService.getLogicalTodayDate();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
private _processQuickSettingForDate<
|
||||
|
|
@ -495,7 +591,7 @@ export class DialogEditTaskRepeatCfgComponent {
|
|||
this.canRemoveInstance.set(false);
|
||||
return;
|
||||
}
|
||||
const todayStr = getDbDateStr(new Date());
|
||||
const todayStr = this._dateService.todayStr();
|
||||
const isTargetTodayOrPast = this._data.targetDate <= todayStr;
|
||||
this.canRemoveInstance.set(!isTargetTodayOrPast);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,76 +2,25 @@ 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)', () => {
|
||||
it('should not contain startDate in essential form fields', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
// Regression test for #7945: a `new Date()` default bypasses `parsers`, so a
|
||||
// raw Date object would reach the model and crash the dialog. The default
|
||||
// must already be a 'YYYY-MM-DD' string.
|
||||
it('should default to a YYYY-MM-DD string, not a Date', () => {
|
||||
expect(typeof startDateField?.defaultValue).toBe('string');
|
||||
expect(startDateField?.defaultValue).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
expect(startDateField).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('remindAt field', () => {
|
||||
const remindAtField = TASK_REPEAT_CFG_ADVANCED_FORM_CFG.flatMap((field) =>
|
||||
it('should not contain startTime or remindAt in advanced form fields', () => {
|
||||
const flatFields = TASK_REPEAT_CFG_ADVANCED_FORM_CFG.flatMap((field) =>
|
||||
field.fieldGroup ? field.fieldGroup : [field],
|
||||
)
|
||||
.flatMap((field) => (field.fieldGroup ? field.fieldGroup : [field]))
|
||||
.find((field) => field.key === 'remindAt');
|
||||
).flatMap((field) => (field.fieldGroup ? field.fieldGroup : [field]));
|
||||
|
||||
it('should have a remindAt field configured', () => {
|
||||
expect(remindAtField).toBeDefined();
|
||||
});
|
||||
const startTimeField = flatFields.find((field) => field.key === 'startTime');
|
||||
const remindAtField = flatFields.find((field) => field.key === 'remindAt');
|
||||
|
||||
it('should have a defaultValue of AtStart to prevent undefined remindAt bug', () => {
|
||||
// This test ensures the fix for the bug where repeatable tasks with time
|
||||
// were always scheduled with remindAt set to "never" because the form
|
||||
// field lacked a defaultValue, causing Formly to not properly bind
|
||||
// the initial model value.
|
||||
expect(remindAtField?.defaultValue).toBe(TaskReminderOptionId.AtStart);
|
||||
});
|
||||
|
||||
it('should be hidden when startTime is not set', () => {
|
||||
expect(remindAtField?.hideExpression).toBe('!model.startTime');
|
||||
});
|
||||
|
||||
it('should be a required select field', () => {
|
||||
expect(remindAtField?.type).toBe('select');
|
||||
expect(remindAtField?.templateOptions?.required).toBe(true);
|
||||
});
|
||||
expect(startTimeField).toBeUndefined();
|
||||
expect(remindAtField).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('weekdays group visibility (issue #8025)', () => {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,7 @@
|
|||
import { FormlyFieldConfig } from '@ngx-formly/core';
|
||||
import { T } from '../../../t.const';
|
||||
import { isValidSplitTime } from '../../../util/is-valid-split-time';
|
||||
import { TASK_REMINDER_OPTIONS } from '../../planner/dialog-schedule-task/task-reminder-options.const';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { RepeatQuickSetting, TaskRepeatCfg } from '../task-repeat-cfg.model';
|
||||
import { getQuickSettingUpdates } from './get-quick-setting-updates';
|
||||
import { TaskReminderOptionId } from '../../tasks/task.model';
|
||||
|
||||
const updateParent = (
|
||||
field: FormlyFieldConfig,
|
||||
|
|
@ -26,19 +22,7 @@ export const TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG: FormlyFieldConfig[] = [
|
|||
label: T.F.TASK_REPEAT.F.TITLE,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'startDate',
|
||||
type: 'date',
|
||||
// Default to a 'YYYY-MM-DD' string (not a Date): Formly skips `parsers` on
|
||||
// `defaultValue`, so a raw Date would slip into the model and downstream
|
||||
// `dateStrToUtcDate` would choke on it, crashing the dialog (#7945).
|
||||
defaultValue: getDbDateStr(),
|
||||
templateOptions: {
|
||||
label: T.F.TASK_REPEAT.F.START_DATE,
|
||||
required: true,
|
||||
},
|
||||
parsers: [(val: unknown) => (val instanceof Date ? getDbDateStr(val) : val)],
|
||||
},
|
||||
|
||||
{
|
||||
key: 'quickSetting',
|
||||
type: 'select',
|
||||
|
|
@ -234,38 +218,7 @@ export const TASK_REPEAT_CFG_ADVANCED_FORM_CFG: FormlyFieldConfig[] = [
|
|||
updateOn: 'blur',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldGroupClassName: 'formly-row',
|
||||
fieldGroup: [
|
||||
{
|
||||
key: 'startTime',
|
||||
type: 'input',
|
||||
templateOptions: {
|
||||
label: T.F.TASK_REPEAT.F.START_TIME,
|
||||
description: T.F.TASK_REPEAT.F.START_TIME_DESCRIPTION,
|
||||
},
|
||||
validators: {
|
||||
validTimeString: (c: { value: string | undefined }) => {
|
||||
return !c.value || isValidSplitTime(c.value);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'remindAt',
|
||||
type: 'select',
|
||||
defaultValue: TaskReminderOptionId.AtStart,
|
||||
hideExpression: '!model.startTime',
|
||||
templateOptions: {
|
||||
required: true,
|
||||
label: T.F.TASK_REPEAT.F.REMIND_AT,
|
||||
options: TASK_REMINDER_OPTIONS,
|
||||
valueProp: 'value',
|
||||
labelProp: 'label',
|
||||
placeholder: T.F.TASK_REPEAT.F.REMIND_AT_PLACEHOLDER,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
key: 'notes',
|
||||
type: 'textarea',
|
||||
|
|
|
|||
|
|
@ -1,123 +1,54 @@
|
|||
<div class="quick-access">
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="quickAccessBtnClick($event, 'today')"
|
||||
[matTooltip]="T.F.TASK.D_DEADLINE.QA_TODAY | translate"
|
||||
>
|
||||
<mat-icon>wb_sunny</mat-icon>
|
||||
</button>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="quickAccessBtnClick($event, 'tomorrow')"
|
||||
[matTooltip]="T.F.TASK.D_DEADLINE.QA_TOMORROW | translate"
|
||||
>
|
||||
<mat-icon>wb_twilight</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="quickAccessBtnClick($event, 'nextWeek')"
|
||||
[matTooltip]="T.F.TASK.D_DEADLINE.QA_NEXT_WEEK | translate"
|
||||
>
|
||||
<mat-icon>next_week</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="quickAccessBtnClick($event, 'nextMonth')"
|
||||
[matTooltip]="T.F.TASK.D_DEADLINE.QA_NEXT_MONTH | translate"
|
||||
>
|
||||
<mat-icon>bedtime</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content>
|
||||
@if (isConfigReady()) {
|
||||
<mat-calendar
|
||||
(keydown)="onKeyDownOnCalendar($event)"
|
||||
[selected]="selectedDate"
|
||||
(selectedChange)="dateSelected($event)"
|
||||
></mat-calendar>
|
||||
<datetime-picker
|
||||
[selectedDate]="selectedDate"
|
||||
[selectedTime]="selectedTime"
|
||||
[selectedReminderCfgId]="selectedReminderCfgId"
|
||||
[reminderOptions]="reminderOptions"
|
||||
[minDate]="minDate"
|
||||
[timeLabel]="T.F.TASK.D_DEADLINE.ADD_TIME | translate"
|
||||
[reminderLabel]="T.F.TASK.D_DEADLINE.REMIND_AT"
|
||||
[quickAccessTranslationPrefix]="'F.TASK.D_DEADLINE'"
|
||||
(dateSelected)="dateSelected($event)"
|
||||
(timeChanged)="selectedTime = $event"
|
||||
(reminderChanged)="selectedReminderCfgId = $event"
|
||||
(quickAccessClick)="onQuickAccessClick($event)"
|
||||
(enterSubmit)="submit()"
|
||||
></datetime-picker>
|
||||
}
|
||||
|
||||
@if (isShowEnterMsg) {
|
||||
<div
|
||||
class="press-enter-msg"
|
||||
@fade
|
||||
>
|
||||
{{ T.DATETIME_SCHEDULE.PRESS_ENTER_AGAIN | translate }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="form-ctrl-wrapper">
|
||||
<mat-form-field>
|
||||
<mat-label>{{ T.F.TASK.D_DEADLINE.ADD_TIME | translate }}</mat-label>
|
||||
<mat-icon matPrefix>schedule</mat-icon>
|
||||
<input
|
||||
type="time"
|
||||
spTimeStep
|
||||
(focus)="onTimeFocus()"
|
||||
[(ngModel)]="selectedTime"
|
||||
step="60"
|
||||
matInput
|
||||
(keydown)="onTimeKeyDown($event)"
|
||||
/>
|
||||
@if (selectedTime) {
|
||||
<mat-icon
|
||||
class="time-clear-btn"
|
||||
matSuffix
|
||||
(click)="onTimeClear($event)"
|
||||
>close
|
||||
</mat-icon>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
@if (selectedTime) {
|
||||
<mat-form-field [@expandFade]>
|
||||
<mat-icon matPrefix>alarm</mat-icon>
|
||||
<mat-label>{{ T.F.TASK.D_DEADLINE.REMIND_AT | translate }}</mat-label>
|
||||
<mat-select
|
||||
[(ngModel)]="selectedReminderCfgId"
|
||||
name="type"
|
||||
required="true"
|
||||
>
|
||||
@for (remindOption of reminderOptions; track remindOption.value) {
|
||||
<mat-option [value]="remindOption.value">
|
||||
{{ remindOption.label | translate }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
|
||||
<mat-dialog-actions align="start">
|
||||
<div class="deadline-actions">
|
||||
@if (hasExistingDeadline) {
|
||||
<button
|
||||
(click)="remove()"
|
||||
color="warn"
|
||||
mat-stroked-button
|
||||
>
|
||||
<mat-icon>flag_circle</mat-icon>
|
||||
{{ T.F.TASK.D_DEADLINE.REMOVE_DEADLINE | translate }}
|
||||
</button>
|
||||
}
|
||||
<mat-dialog-actions align="start">
|
||||
<div class="deadline-actions">
|
||||
@if (hasExistingDeadline && !data.isSelectDeadlineOnly) {
|
||||
<button
|
||||
color="primary"
|
||||
(click)="remove()"
|
||||
color="warn"
|
||||
mat-stroked-button
|
||||
(click)="submit()"
|
||||
type="button"
|
||||
>
|
||||
<mat-icon>flag</mat-icon>
|
||||
{{ T.F.TASK.D_DEADLINE.SET_DEADLINE | translate }}
|
||||
<mat-icon>event_busy</mat-icon>
|
||||
{{ T.F.TASK.D_DEADLINE.REMOVE_DEADLINE | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<button
|
||||
class="dialog-cancel-btn"
|
||||
color="primary"
|
||||
mat-stroked-button
|
||||
color="default"
|
||||
(click)="close()"
|
||||
[disabled]="!selectedDate"
|
||||
(click)="submit()"
|
||||
type="button"
|
||||
>
|
||||
{{ T.G.CANCEL | translate }}
|
||||
<mat-icon>event_available</mat-icon>
|
||||
{{ T.F.TASK.D_DEADLINE.SET_DEADLINE | translate }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="dialog-cancel-btn"
|
||||
mat-stroked-button
|
||||
color="default"
|
||||
(click)="close()"
|
||||
type="button"
|
||||
>
|
||||
{{ T.G.CANCEL | translate }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</mat-dialog-content>
|
||||
|
|
|
|||
|
|
@ -17,37 +17,6 @@
|
|||
:host-context([dir='rtl']) {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
::ng-deep {
|
||||
.mat-calendar-header {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.mat-calendar-body-cell-content {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.mat-calendar-body-cell:focus .mat-calendar-body-cell-content {
|
||||
outline: 2px solid var(--c-accent);
|
||||
}
|
||||
|
||||
.mat-calendar-body-selected {
|
||||
background: var(--c-primary) !important;
|
||||
}
|
||||
|
||||
.mat-calendar-controls {
|
||||
margin-top: var(--s);
|
||||
margin-bottom: var(--s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-ctrl-wrapper {
|
||||
margin: var(--s2) var(--s2) 0;
|
||||
}
|
||||
|
||||
mat-dialog-content {
|
||||
|
|
@ -58,47 +27,14 @@ mat-dialog-content {
|
|||
) !important;
|
||||
}
|
||||
|
||||
.press-enter-msg {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
padding: var(--s-half) 12px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
z-index: 11;
|
||||
box-shadow: var(--whiteframe-shadow-1dp);
|
||||
border-radius: var(--radius-md);
|
||||
margin-top: calc(var(--s) * -1.5);
|
||||
white-space: nowrap;
|
||||
transform: translateX(-50%);
|
||||
|
||||
background: var(--bg-lighter);
|
||||
}
|
||||
|
||||
.quick-access {
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
@include extraBorder('-bottom');
|
||||
height: 48px;
|
||||
|
||||
> button {
|
||||
height: 48px;
|
||||
flex-grow: 1;
|
||||
border-radius: var(--card-border-radius) !important;
|
||||
|
||||
::ng-deep .mat-mdc-button-persistent-ripple {
|
||||
border-radius: var(--card-border-radius) !important;
|
||||
}
|
||||
}
|
||||
|
||||
button + button {
|
||||
@include extraBorder('-left');
|
||||
}
|
||||
}
|
||||
|
||||
:host ::ng-deep mat-dialog-actions {
|
||||
padding: 0 0 var(--s2) 0 !important;
|
||||
}
|
||||
|
||||
.dialog-actions-and-warnings {
|
||||
margin: 0 var(--s2) var(--s2);
|
||||
}
|
||||
|
||||
.deadline-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
|
@ -134,7 +70,3 @@ mat-dialog-content {
|
|||
overflow-wrap: anywhere;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.time-clear-btn {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
computed,
|
||||
ElementRef,
|
||||
inject,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
|
|
@ -16,41 +15,26 @@ import {
|
|||
} from '@angular/material/dialog';
|
||||
import { Task, TaskReminderOption, TaskReminderOptionId } from '../task.model';
|
||||
import { T } from 'src/app/t.const';
|
||||
import { MatCalendar } from '@angular/material/datepicker';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions';
|
||||
import { DEADLINE_REMINDER_OPTIONS } from './deadline-reminder-options.const';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { millisecondsDiffToRemindOption } from '../util/remind-option-to-milliseconds';
|
||||
import { remindOptionToMilliseconds } from '../util/remind-option-to-milliseconds';
|
||||
import { expandFadeAnimation } from '../../../ui/animations/expand.ani';
|
||||
import { fadeAnimation } from '../../../ui/animations/fade.ani';
|
||||
import { getClockStringFromHours } from '../../../util/get-clock-string-from-hours';
|
||||
import { getDateTimeFromClockString } from '../../../util/get-date-time-from-clock-string';
|
||||
import { isValidSplitTime } from '../../../util/is-valid-split-time';
|
||||
import { normalizeClockStr } from '../../../util/normalize-clock-str';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { dateStrToUtcDate } from '../../../util/date-str-to-utc-date';
|
||||
import { DateService } from '../../../core/date/date.service';
|
||||
import { DateAdapter, MatOption } from '@angular/material/core';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { MatButton, MatIconButton } from '@angular/material/button';
|
||||
import { DateAdapter } from '@angular/material/core';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import {
|
||||
MatFormField,
|
||||
MatLabel,
|
||||
MatPrefix,
|
||||
MatSuffix,
|
||||
} from '@angular/material/form-field';
|
||||
import { MatSelect } from '@angular/material/select';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { MatInput } from '@angular/material/input';
|
||||
import { TimeStepDirective } from '../../../ui/time-step/time-step.directive';
|
||||
import { GlobalConfigService } from '../../config/global-config.service';
|
||||
import { DEFAULT_GLOBAL_CONFIG } from '../../config/default-global-config.const';
|
||||
import { getDeadlineAutoPlanFields } from '../util/get-deadline-auto-plan-fields';
|
||||
|
||||
const DEFAULT_TIME = '09:00';
|
||||
import { DateTimePickerComponent } from '../../../ui/datetime-picker/datetime-picker.component';
|
||||
|
||||
type QuickDeadline = 'today' | 'tomorrow' | 'nextWeek' | 'nextMonth';
|
||||
|
||||
|
|
@ -58,27 +42,16 @@ type QuickDeadline = 'today' | 'tomorrow' | 'nextWeek' | 'nextMonth';
|
|||
selector: 'dialog-deadline',
|
||||
imports: [
|
||||
FormsModule,
|
||||
MatTooltip,
|
||||
MatIconButton,
|
||||
MatIcon,
|
||||
MatFormField,
|
||||
MatSelect,
|
||||
MatOption,
|
||||
TranslatePipe,
|
||||
MatButton,
|
||||
MatDialogActions,
|
||||
MatDialogContent,
|
||||
MatCalendar,
|
||||
MatInput,
|
||||
MatLabel,
|
||||
MatSuffix,
|
||||
MatPrefix,
|
||||
TimeStepDirective,
|
||||
DateTimePickerComponent,
|
||||
],
|
||||
templateUrl: './dialog-deadline.component.html',
|
||||
styleUrl: './dialog-deadline.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [expandFadeAnimation, fadeAnimation],
|
||||
})
|
||||
export class DialogDeadlineComponent implements AfterViewInit {
|
||||
data = inject<{
|
||||
|
|
@ -107,19 +80,15 @@ export class DialogDeadlineComponent implements AfterViewInit {
|
|||
);
|
||||
|
||||
T: typeof T = T;
|
||||
readonly calendar = viewChild.required(MatCalendar);
|
||||
|
||||
reminderOptions: TaskReminderOption[] = DEADLINE_REMINDER_OPTIONS;
|
||||
task: Task | undefined = this.data.task;
|
||||
|
||||
selectedDate: Date | null = null;
|
||||
selectedTime: string | null = null;
|
||||
selectedReminderCfgId: TaskReminderOptionId = TaskReminderOptionId.DoNotRemind;
|
||||
minDate = new Date();
|
||||
|
||||
hasExistingDeadline = false;
|
||||
isInitValOnTimeFocus = true;
|
||||
isShowEnterMsg = false;
|
||||
private _timeCheckVal: string | null = null;
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
if (this.task) {
|
||||
|
|
@ -172,47 +141,7 @@ export class DialogDeadlineComponent implements AfterViewInit {
|
|||
this.selectedReminderCfgId = this.data.targetDeadlineRemindOption;
|
||||
}
|
||||
|
||||
this.calendar().activeDate = new Date(this.selectedDate || new Date());
|
||||
this._cd.detectChanges();
|
||||
|
||||
setTimeout(() => this._focusInitially());
|
||||
setTimeout(() => this._focusInitially(), 300);
|
||||
}
|
||||
|
||||
private _focusInitially(): void {
|
||||
const host = this._elRef.nativeElement as HTMLElement;
|
||||
const selector = this.selectedDate
|
||||
? '.mat-calendar-body-selected'
|
||||
: '.mat-calendar-body-today';
|
||||
(host.querySelector(selector) as HTMLElement)?.parentElement?.focus();
|
||||
}
|
||||
|
||||
onKeyDownOnCalendar(ev: KeyboardEvent): void {
|
||||
this._timeCheckVal = null;
|
||||
if (ev.code === 'Enter' || ev.code === 'Space') {
|
||||
this.isShowEnterMsg = true;
|
||||
if (
|
||||
this.selectedDate &&
|
||||
new Date(this.selectedDate).getTime() ===
|
||||
new Date(this.calendar().activeDate).getTime()
|
||||
) {
|
||||
this.submit();
|
||||
}
|
||||
} else {
|
||||
this.isShowEnterMsg = false;
|
||||
}
|
||||
}
|
||||
|
||||
onTimeKeyDown(ev: KeyboardEvent): void {
|
||||
if (ev.key === 'Enter') {
|
||||
this.isShowEnterMsg = true;
|
||||
if (this._timeCheckVal === this.selectedTime) {
|
||||
this.submit();
|
||||
}
|
||||
this._timeCheckVal = this.selectedTime;
|
||||
} else {
|
||||
this.isShowEnterMsg = false;
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
|
|
@ -220,33 +149,7 @@ export class DialogDeadlineComponent implements AfterViewInit {
|
|||
}
|
||||
|
||||
dateSelected(newDate: Date): void {
|
||||
setTimeout(() => {
|
||||
this.selectedDate = new Date(newDate);
|
||||
this.calendar().activeDate = this.selectedDate;
|
||||
});
|
||||
}
|
||||
|
||||
onTimeClear(ev: MouseEvent): void {
|
||||
ev.stopPropagation();
|
||||
this.selectedTime = null;
|
||||
this.selectedReminderCfgId = TaskReminderOptionId.DoNotRemind;
|
||||
this.isInitValOnTimeFocus = true;
|
||||
}
|
||||
|
||||
onTimeFocus(): void {
|
||||
if (!this.selectedTime && this.isInitValOnTimeFocus) {
|
||||
this.isInitValOnTimeFocus = false;
|
||||
if (this.selectedDate) {
|
||||
if (this._dateService.isToday(this.selectedDate!)) {
|
||||
this.selectedTime = getClockStringFromHours((new Date().getHours() + 1) % 24);
|
||||
} else {
|
||||
this.selectedTime = DEFAULT_TIME;
|
||||
}
|
||||
} else {
|
||||
this.selectedTime = getClockStringFromHours((new Date().getHours() + 1) % 24);
|
||||
this.selectedDate = new Date();
|
||||
}
|
||||
}
|
||||
this.selectedDate = new Date(newDate);
|
||||
}
|
||||
|
||||
remove(): void {
|
||||
|
|
@ -317,8 +220,7 @@ export class DialogDeadlineComponent implements AfterViewInit {
|
|||
this._matDialogRef.close();
|
||||
}
|
||||
|
||||
quickAccessBtnClick(ev: MouseEvent, option: QuickDeadline): void {
|
||||
ev.stopPropagation();
|
||||
onQuickAccessClick(option: QuickDeadline): void {
|
||||
this.selectedDate = this._getQuickDate(option);
|
||||
this.submit();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import { TaskReminderOptionId } from '../task.model';
|
||||
import {
|
||||
millisecondsDiffToRemindOption,
|
||||
remindOptionToMilliseconds,
|
||||
} from './remind-option-to-milliseconds';
|
||||
|
||||
describe('remindOptionToMilliseconds roundtrip', () => {
|
||||
const DUE_DATE = new Date('2026-01-01T12:00:00Z').getTime();
|
||||
|
||||
const options = [
|
||||
TaskReminderOptionId.AtStart,
|
||||
TaskReminderOptionId.m5,
|
||||
TaskReminderOptionId.m10,
|
||||
TaskReminderOptionId.m15,
|
||||
TaskReminderOptionId.m30,
|
||||
TaskReminderOptionId.h1,
|
||||
];
|
||||
|
||||
options.forEach((optId) => {
|
||||
it(`should roundtrip correctly for ${optId}`, () => {
|
||||
const remindAt = remindOptionToMilliseconds(DUE_DATE, optId);
|
||||
expect(remindAt).toBeDefined();
|
||||
const resultOptId = millisecondsDiffToRemindOption(DUE_DATE, remindAt);
|
||||
expect(resultOptId).toBe(optId);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle quantization correctly (rounding to nearest bucket)', () => {
|
||||
// 7 minutes before -> should round to m5 (since it's < 10m but >= 5m)
|
||||
const m7 = 7 * 60 * 1000;
|
||||
const remindAt7m = DUE_DATE - m7;
|
||||
expect(millisecondsDiffToRemindOption(DUE_DATE, remindAt7m)).toBe(
|
||||
TaskReminderOptionId.m5,
|
||||
);
|
||||
|
||||
// 12 minutes before -> should round to m10
|
||||
const m12 = 12 * 60 * 1000;
|
||||
const remindAt12m = DUE_DATE - m12;
|
||||
expect(millisecondsDiffToRemindOption(DUE_DATE, remindAt12m)).toBe(
|
||||
TaskReminderOptionId.m10,
|
||||
);
|
||||
|
||||
// 3 minutes before -> should round to m5 (since it's closer to 5 than 0)
|
||||
const m3 = 3 * 60 * 1000;
|
||||
const remindAt3m = DUE_DATE - m3;
|
||||
expect(millisecondsDiffToRemindOption(DUE_DATE, remindAt3m)).toBe(
|
||||
TaskReminderOptionId.m5,
|
||||
);
|
||||
|
||||
// 1 minute before -> should round to AtStart
|
||||
const m1 = 1 * 60 * 1000;
|
||||
const remindAt1m = DUE_DATE - m1;
|
||||
expect(millisecondsDiffToRemindOption(DUE_DATE, remindAt1m)).toBe(
|
||||
TaskReminderOptionId.AtStart,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
import { TaskReminderOptionId } from '../task.model';
|
||||
import { devError } from '../../../util/dev-error';
|
||||
import { TaskLog } from '../../../core/log';
|
||||
|
||||
export const remindOptionToMilliseconds = (
|
||||
due: number,
|
||||
|
|
@ -42,21 +40,20 @@ export const millisecondsDiffToRemindOption = (
|
|||
return TaskReminderOptionId.DoNotRemind;
|
||||
}
|
||||
const diff: number = due - remindAt;
|
||||
if (diff >= 60 * 60 * 1000) {
|
||||
const diffInMinutes = diff / (60 * 1000);
|
||||
|
||||
if (diffInMinutes >= 45) {
|
||||
return TaskReminderOptionId.h1;
|
||||
} else if (diff >= 30 * 60 * 1000) {
|
||||
} else if (diffInMinutes >= 22.5) {
|
||||
return TaskReminderOptionId.m30;
|
||||
} else if (diff >= 15 * 60 * 1000) {
|
||||
} else if (diffInMinutes >= 12.5) {
|
||||
return TaskReminderOptionId.m15;
|
||||
} else if (diff >= 10 * 60 * 1000) {
|
||||
} else if (diffInMinutes >= 7.5) {
|
||||
return TaskReminderOptionId.m10;
|
||||
} else if (diff >= 5 * 60 * 1000) {
|
||||
} else if (diffInMinutes >= 2.5) {
|
||||
return TaskReminderOptionId.m5;
|
||||
} else if (diff <= 0) {
|
||||
return TaskReminderOptionId.AtStart;
|
||||
} else {
|
||||
TaskLog.log(due, remindAt);
|
||||
devError('Cannot determine remind option. Invalid params');
|
||||
return TaskReminderOptionId.DoNotRemind;
|
||||
// Also handles diff <= 0
|
||||
return TaskReminderOptionId.AtStart;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -31,7 +31,16 @@ const T = {
|
|||
RESTORE_STRAY_BACKUP: 'CONFIRM.RESTORE_STRAY_BACKUP',
|
||||
},
|
||||
DATETIME_SCHEDULE: {
|
||||
MONTH: 'DATETIME_SCHEDULE.MONTH',
|
||||
NEXT_24_YEARS: 'DATETIME_SCHEDULE.NEXT_24_YEARS',
|
||||
NEXT_MONTH: 'DATETIME_SCHEDULE.NEXT_MONTH',
|
||||
NEXT_YEAR: 'DATETIME_SCHEDULE.NEXT_YEAR',
|
||||
PRESS_ENTER_AGAIN: 'DATETIME_SCHEDULE.PRESS_ENTER_AGAIN',
|
||||
PREVIOUS_24_YEARS: 'DATETIME_SCHEDULE.PREVIOUS_24_YEARS',
|
||||
PREVIOUS_MONTH: 'DATETIME_SCHEDULE.PREVIOUS_MONTH',
|
||||
PREVIOUS_YEAR: 'DATETIME_SCHEDULE.PREVIOUS_YEAR',
|
||||
SWITCH_TO_MULTI_YEAR_VIEW: 'DATETIME_SCHEDULE.SWITCH_TO_MULTI_YEAR_VIEW',
|
||||
SWITCH_TO_YEAR_VIEW: 'DATETIME_SCHEDULE.SWITCH_TO_YEAR_VIEW',
|
||||
},
|
||||
DIALOG_LOGS: {
|
||||
COPY: 'DIALOG_LOGS.COPY',
|
||||
|
|
|
|||
102
src/app/ui/datetime-picker/datetime-picker.component.html
Normal file
102
src/app/ui/datetime-picker/datetime-picker.component.html
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
@if (showQuickAccess()) {
|
||||
<div class="quick-access">
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
(click)="quickAccessBtnClick($event, 'today')"
|
||||
[matTooltip]="quickAccessTranslationPrefix() + '.QA_TODAY' | translate"
|
||||
>
|
||||
<mat-icon>wb_sunny</mat-icon>
|
||||
</button>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
(click)="quickAccessBtnClick($event, 'tomorrow')"
|
||||
[matTooltip]="quickAccessTranslationPrefix() + '.QA_TOMORROW' | translate"
|
||||
>
|
||||
<mat-icon>wb_twilight</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
(click)="quickAccessBtnClick($event, 'nextWeek')"
|
||||
[matTooltip]="quickAccessTranslationPrefix() + '.QA_NEXT_WEEK' | translate"
|
||||
>
|
||||
<mat-icon>next_week</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
(click)="quickAccessBtnClick($event, 'nextMonth')"
|
||||
[matTooltip]="quickAccessTranslationPrefix() + '.QA_NEXT_MONTH' | translate"
|
||||
>
|
||||
<mat-icon>bedtime</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isConfigReady()) {
|
||||
<mat-calendar
|
||||
(keydown)="onKeyDownOnCalendar($event)"
|
||||
[selected]="calendarSelectedDate"
|
||||
[minDate]="minDate()"
|
||||
(selectedChange)="dateSelected.emit($event)"
|
||||
></mat-calendar>
|
||||
}
|
||||
|
||||
@if (isShowEnterMsg) {
|
||||
<div
|
||||
class="press-enter-msg"
|
||||
@fade
|
||||
>
|
||||
{{ T.DATETIME_SCHEDULE.PRESS_ENTER_AGAIN | translate }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="form-ctrl-wrapper">
|
||||
<mat-form-field class="time-form-field">
|
||||
<mat-label>{{ timeLabel() }}</mat-label>
|
||||
<mat-icon matPrefix>schedule</mat-icon>
|
||||
<input
|
||||
type="time"
|
||||
spTimeStep
|
||||
(focus)="onTimeFocus()"
|
||||
[ngModel]="selectedTime()"
|
||||
(ngModelChange)="onTimeChange($event)"
|
||||
step="60"
|
||||
matInput
|
||||
(keydown)="onTimeKeyDown($event)"
|
||||
/>
|
||||
@if (selectedTime()) {
|
||||
<mat-icon
|
||||
class="time-clear-btn"
|
||||
matSuffix
|
||||
(click)="onTimeClear($event)"
|
||||
>close
|
||||
</mat-icon>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
@if (selectedTime()) {
|
||||
<mat-form-field
|
||||
[@expandFade]
|
||||
class="reminder-form-field"
|
||||
>
|
||||
<mat-icon matPrefix>alarm</mat-icon>
|
||||
<mat-label>{{ reminderLabel() | translate }}</mat-label>
|
||||
<mat-select
|
||||
[ngModel]="selectedReminderCfgId()"
|
||||
(ngModelChange)="onReminderChange($event)"
|
||||
name="type"
|
||||
required="true"
|
||||
>
|
||||
@for (remindOption of reminderOptions(); track remindOption.value) {
|
||||
<mat-option [value]="remindOption.value">
|
||||
{{ remindOption.label | translate }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
}
|
||||
</div>
|
||||
164
src/app/ui/datetime-picker/datetime-picker.component.scss
Normal file
164
src/app/ui/datetime-picker/datetime-picker.component.scss
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
@use '../../../styles/_globals.scss' as *;
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
user-select: none;
|
||||
width: 100%;
|
||||
|
||||
&.sp-hide-cursor {
|
||||
cursor: none !important;
|
||||
|
||||
::ng-deep {
|
||||
.mat-calendar-body-cell-content {
|
||||
cursor: none !important;
|
||||
}
|
||||
// Suppress hover-only styling (when not focused) when cursor is hidden
|
||||
.mat-calendar-body-cell:not(.mat-calendar-body-disabled):hover:not(:focus)
|
||||
> .mat-calendar-body-cell-content {
|
||||
outline: none !important;
|
||||
|
||||
&:not(.mat-calendar-body-selected) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep {
|
||||
mat-calendar {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.mat-calendar-header {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.mat-calendar-body-selected {
|
||||
background: var(--c-primary) !important;
|
||||
color: var(--c-contrast) !important;
|
||||
}
|
||||
|
||||
.mat-calendar-controls {
|
||||
margin-top: var(--s);
|
||||
margin-bottom: var(--s);
|
||||
}
|
||||
|
||||
// Hide extra month name inside the calendar body
|
||||
// NOTE: visibility: hidden (not display: none) is intentional — the cell must
|
||||
// stay in the grid flow so the first week's day numbers are offset to the
|
||||
// correct weekday column. display: none collapses it and left-aligns all days.
|
||||
.mat-calendar-body-label {
|
||||
visibility: hidden !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.mat-calendar-period-button {
|
||||
cursor: pointer !important;
|
||||
transition: background-color var(--transition-duration-s) ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--calendar-hover-bg) !important;
|
||||
border-radius: var(--card-border-radius) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// Style previous/next buttons hover circles
|
||||
.mat-calendar-previous-button:not(:disabled):hover,
|
||||
.mat-calendar-next-button:not(:disabled):hover {
|
||||
background-color: var(--calendar-hover-bg) !important;
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context([dir='rtl']) {
|
||||
::ng-deep {
|
||||
.mat-calendar-previous-button,
|
||||
.mat-calendar-next-button {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quick-access {
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
@include extraBorder('-bottom');
|
||||
height: 48px;
|
||||
|
||||
> button {
|
||||
height: 48px;
|
||||
flex-grow: 1;
|
||||
border-radius: var(--card-border-radius) !important;
|
||||
|
||||
::ng-deep .mat-mdc-button-persistent-ripple {
|
||||
border-radius: var(--card-border-radius) !important;
|
||||
}
|
||||
}
|
||||
|
||||
button + button {
|
||||
@include extraBorder('-left');
|
||||
}
|
||||
}
|
||||
|
||||
:host ::ng-deep mat-month-view .mat-calendar-body-today {
|
||||
color: transparent !important;
|
||||
border: none !important;
|
||||
border-color: transparent !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
&:after {
|
||||
@include materialIcon('wb_sunny');
|
||||
@include center;
|
||||
color: var(--c-accent) !important;
|
||||
}
|
||||
|
||||
&.mat-calendar-body-selected:after {
|
||||
color: var(--c-contrast) !important;
|
||||
}
|
||||
}
|
||||
|
||||
:host ::ng-deep {
|
||||
mat-year-view .mat-calendar-body-today,
|
||||
mat-multi-year-view .mat-calendar-body-today {
|
||||
border: none !important;
|
||||
border-color: transparent !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
&:not(.mat-calendar-body-selected) {
|
||||
color: var(--c-accent) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.press-enter-msg {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
padding: var(--s-half) 12px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
z-index: 11;
|
||||
box-shadow: var(--whiteframe-shadow-1dp);
|
||||
border-radius: 8px;
|
||||
margin-top: -12px;
|
||||
white-space: nowrap;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-lighter);
|
||||
}
|
||||
|
||||
.form-ctrl-wrapper {
|
||||
margin: var(--s2) var(--s2) var(--s2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s);
|
||||
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.time-clear-btn {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
196
src/app/ui/datetime-picker/datetime-picker.component.spec.ts
Normal file
196
src/app/ui/datetime-picker/datetime-picker.component.spec.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import { TestBed, ComponentFixture, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { DateTimePickerComponent } from './datetime-picker.component';
|
||||
import { DateService } from '../../core/date/date.service';
|
||||
import { GlobalConfigService } from '../../features/config/global-config.service';
|
||||
import { MatNativeDateModule } from '@angular/material/core';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule, TranslateService, TranslateStore } from '@ngx-translate/core';
|
||||
import { signal } from '@angular/core';
|
||||
import { TaskReminderOptionId } from '../../features/tasks/task.model';
|
||||
|
||||
describe('DateTimePickerComponent', () => {
|
||||
let component: DateTimePickerComponent;
|
||||
let fixture: ComponentFixture<DateTimePickerComponent>;
|
||||
let dateServiceSpy: jasmine.SpyObj<DateService>;
|
||||
let globalConfigServiceMock: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
dateServiceSpy = jasmine.createSpyObj('DateService', ['isToday', 'todayStr']);
|
||||
globalConfigServiceMock = {
|
||||
localization: signal({}),
|
||||
cfg: signal({}),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
DateTimePickerComponent,
|
||||
NoopAnimationsModule,
|
||||
TranslateModule.forRoot(),
|
||||
MatNativeDateModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: DateService, useValue: dateServiceSpy },
|
||||
{ provide: GlobalConfigService, useValue: globalConfigServiceMock },
|
||||
TranslateService,
|
||||
TranslateStore,
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DateTimePickerComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create the component', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render the calendar by default', () => {
|
||||
const calendarEl = fixture.nativeElement.querySelector('mat-calendar');
|
||||
expect(calendarEl).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should emit dateSelected when a date is selected on the calendar', () => {
|
||||
spyOn(component.dateSelected, 'emit');
|
||||
const testDate = new Date();
|
||||
component.dateSelected.emit(testDate);
|
||||
expect(component.dateSelected.emit).toHaveBeenCalledWith(testDate);
|
||||
});
|
||||
|
||||
it('should emit timeChanged when onTimeChange is called', () => {
|
||||
spyOn(component.timeChanged, 'emit');
|
||||
component.onTimeChange('10:30');
|
||||
expect(component.timeChanged.emit).toHaveBeenCalledWith('10:30');
|
||||
});
|
||||
|
||||
it('should emit reminderChanged when onReminderChange is called', () => {
|
||||
spyOn(component.reminderChanged, 'emit');
|
||||
component.onReminderChange(TaskReminderOptionId.AtStart);
|
||||
expect(component.reminderChanged.emit).toHaveBeenCalledWith(
|
||||
TaskReminderOptionId.AtStart,
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit quickAccessClick when quickAccessBtnClick is called', () => {
|
||||
spyOn(component.quickAccessClick, 'emit');
|
||||
const mockEvent = new MouseEvent('click');
|
||||
component.quickAccessBtnClick(mockEvent, 'tomorrow');
|
||||
expect(component.quickAccessClick.emit).toHaveBeenCalledWith('tomorrow');
|
||||
});
|
||||
|
||||
it('should emit timeChanged with null and reminderChanged with DoNotRemind when onTimeClear is called', () => {
|
||||
spyOn(component.timeChanged, 'emit');
|
||||
spyOn(component.reminderChanged, 'emit');
|
||||
const mockEvent = new MouseEvent('click');
|
||||
component.onTimeClear(mockEvent);
|
||||
expect(component.timeChanged.emit).toHaveBeenCalledWith(null);
|
||||
expect(component.reminderChanged.emit).toHaveBeenCalledWith(
|
||||
TaskReminderOptionId.DoNotRemind,
|
||||
);
|
||||
});
|
||||
|
||||
it('should autofill time on focus', () => {
|
||||
spyOn(component.timeChanged, 'emit');
|
||||
spyOn(component.dateSelected, 'emit');
|
||||
fixture.componentRef.setInput('selectedDate', new Date(2026, 4, 6));
|
||||
fixture.componentRef.setInput('selectedTime', null);
|
||||
dateServiceSpy.isToday.and.returnValue(false);
|
||||
|
||||
component.onTimeFocus();
|
||||
|
||||
expect(component.timeChanged.emit).toHaveBeenCalledWith('09:00');
|
||||
});
|
||||
|
||||
it('should toggle isKeyboardNavigating based on keyboard navigation and mouse move', () => {
|
||||
expect(component.isKeyboardNavigating).toBeFalse();
|
||||
|
||||
// Trigger keyboard navigation key down on calendar
|
||||
const arrowDownEvent = new KeyboardEvent('keydown', { key: 'ArrowDown' });
|
||||
component.onKeyDownOnCalendar(arrowDownEvent);
|
||||
expect(component.isKeyboardNavigating).toBeTrue();
|
||||
|
||||
// Trigger non-navigation key down on calendar - should not reset it
|
||||
const enterEvent = new KeyboardEvent('keydown', { key: 'Enter' });
|
||||
component.onKeyDownOnCalendar(enterEvent);
|
||||
expect(component.isKeyboardNavigating).toBeTrue();
|
||||
|
||||
// Trigger mousemove with changed coordinates - should reset it to false
|
||||
const mouseMoveEvent = new MouseEvent('mousemove', { clientX: 30, clientY: 40 });
|
||||
component.onHostMouseMove(mouseMoveEvent);
|
||||
expect(component.isKeyboardNavigating).toBeFalse();
|
||||
});
|
||||
|
||||
it('should only update calendar activeDate when selectedDate changes', () => {
|
||||
const calendar = component.calendar()!;
|
||||
const initialDate = new Date(2026, 4, 6);
|
||||
const sameDate = new Date(2026, 4, 6);
|
||||
const differentDate = new Date(2026, 4, 7);
|
||||
|
||||
// Initial sync
|
||||
fixture.componentRef.setInput('selectedDate', initialDate);
|
||||
fixture.detectChanges();
|
||||
expect(calendar.activeDate).toEqual(initialDate);
|
||||
|
||||
// Navigation (simulated by manual update to activeDate)
|
||||
calendar.activeDate = new Date(2026, 5, 1);
|
||||
|
||||
// Sync with same date value - should NOT reset activeDate
|
||||
fixture.componentRef.setInput('selectedDate', sameDate);
|
||||
fixture.detectChanges();
|
||||
expect(calendar.activeDate).toEqual(new Date(2026, 5, 1));
|
||||
|
||||
// Sync with different date value - SHOULD reset activeDate
|
||||
fixture.componentRef.setInput('selectedDate', differentDate);
|
||||
fixture.detectChanges();
|
||||
expect(calendar.activeDate).toEqual(differentDate);
|
||||
});
|
||||
|
||||
it('should emit enterSubmit when Enter is pressed on the calendar and date matches', () => {
|
||||
spyOn(component.enterSubmit, 'emit');
|
||||
fixture.componentRef.setInput('selectedDate', new Date(2026, 4, 6));
|
||||
fixture.detectChanges();
|
||||
|
||||
const calendar = component.calendar()!;
|
||||
calendar.activeDate = new Date(2026, 4, 6);
|
||||
|
||||
const enterEvent = new KeyboardEvent('keydown', { code: 'Enter' });
|
||||
|
||||
component.onKeyDownOnCalendar(enterEvent);
|
||||
expect(component.enterSubmit.emit).toHaveBeenCalled();
|
||||
expect(component.isShowEnterMsg).toBeTrue();
|
||||
});
|
||||
|
||||
it('should require two Enter presses to emit enterSubmit on time input', () => {
|
||||
spyOn(component.enterSubmit, 'emit');
|
||||
fixture.componentRef.setInput('selectedTime', '10:30');
|
||||
fixture.detectChanges();
|
||||
|
||||
const enterEvent = new KeyboardEvent('keydown', { key: 'Enter' });
|
||||
|
||||
// First press
|
||||
component.onTimeKeyDown(enterEvent);
|
||||
expect(component.enterSubmit.emit).not.toHaveBeenCalled();
|
||||
expect(component.isShowEnterMsg).toBeTrue();
|
||||
|
||||
// Second press
|
||||
component.onTimeKeyDown(enterEvent);
|
||||
expect(component.enterSubmit.emit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should focus the active calendar cell after view init', fakeAsync(() => {
|
||||
// Create an element that querySelector will find
|
||||
const activeCell = document.createElement('div');
|
||||
activeCell.classList.add('mat-calendar-body-active');
|
||||
activeCell.tabIndex = 0;
|
||||
fixture.nativeElement.appendChild(activeCell);
|
||||
|
||||
spyOn(activeCell, 'focus');
|
||||
// Mock querySelector on the native element
|
||||
spyOn(fixture.nativeElement, 'querySelector').and.returnValue(activeCell);
|
||||
|
||||
component.ngAfterViewInit();
|
||||
tick(50); // Match the 50ms in component
|
||||
|
||||
expect(activeCell.focus).toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
290
src/app/ui/datetime-picker/datetime-picker.component.ts
Normal file
290
src/app/ui/datetime-picker/datetime-picker.component.ts
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
ElementRef,
|
||||
HostBinding,
|
||||
HostListener,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatCalendar } from '@angular/material/datepicker';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import {
|
||||
MatFormField,
|
||||
MatLabel,
|
||||
MatPrefix,
|
||||
MatSuffix,
|
||||
} from '@angular/material/form-field';
|
||||
import { MatInput } from '@angular/material/input';
|
||||
import { MatSelect } from '@angular/material/select';
|
||||
import { MatOption } from '@angular/material/core';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { TranslateModule, TranslatePipe } from '@ngx-translate/core';
|
||||
import { T } from '../../t.const';
|
||||
import { DateService } from '../../core/date/date.service';
|
||||
import { GlobalConfigService } from '../../features/config/global-config.service';
|
||||
import {
|
||||
TaskReminderOption,
|
||||
TaskReminderOptionId,
|
||||
} from '../../features/tasks/task.model';
|
||||
import { TASK_REMINDER_OPTIONS } from '../../features/planner/dialog-schedule-task/task-reminder-options.const';
|
||||
import { TimeStepDirective } from '../time-step/time-step.directive';
|
||||
import { expandFadeAnimation } from '../animations/expand.ani';
|
||||
import { fadeAnimation } from '../animations/fade.ani';
|
||||
import { getClockStringFromHours } from '../../util/get-clock-string-from-hours';
|
||||
|
||||
const DEFAULT_TIME = '09:00';
|
||||
|
||||
@Component({
|
||||
selector: 'datetime-picker',
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
MatCalendar,
|
||||
MatButtonModule,
|
||||
MatIcon,
|
||||
MatFormField,
|
||||
MatLabel,
|
||||
MatPrefix,
|
||||
MatSuffix,
|
||||
MatInput,
|
||||
MatSelect,
|
||||
MatOption,
|
||||
MatTooltip,
|
||||
TranslateModule,
|
||||
TranslatePipe,
|
||||
TimeStepDirective,
|
||||
],
|
||||
templateUrl: './datetime-picker.component.html',
|
||||
styleUrl: './datetime-picker.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [expandFadeAnimation, fadeAnimation],
|
||||
})
|
||||
export class DateTimePickerComponent implements AfterViewInit {
|
||||
private _dateService = inject(DateService);
|
||||
private _globalConfigService = inject(GlobalConfigService);
|
||||
private readonly _cdr = inject(ChangeDetectorRef);
|
||||
private _el = inject(ElementRef);
|
||||
|
||||
pickerSelectedDate: Date | null = null;
|
||||
|
||||
constructor() {
|
||||
effect((onCleanup) => {
|
||||
const cal = this.calendar();
|
||||
if (cal) {
|
||||
// Set initial view and date
|
||||
if (cal.currentView !== 'month') {
|
||||
this.pickerSelectedDate = cal.activeDate;
|
||||
}
|
||||
|
||||
const sub = cal.stateChanges.subscribe(() => {
|
||||
if (cal.currentView !== 'month') {
|
||||
this.pickerSelectedDate = cal.activeDate;
|
||||
}
|
||||
this._cdr.markForCheck();
|
||||
});
|
||||
onCleanup(() => sub.unsubscribe());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Inputs
|
||||
selectedDate = input<Date | null>(null);
|
||||
selectedTime = input<string | null>(null);
|
||||
selectedReminderCfgId = input<TaskReminderOptionId>(TaskReminderOptionId.DoNotRemind);
|
||||
reminderOptions = input<TaskReminderOption[]>(TASK_REMINDER_OPTIONS);
|
||||
minDate = input<Date | null>(null);
|
||||
timeLabel = input<string>('Time');
|
||||
reminderLabel = input<string>(T.F.TASK.D_SCHEDULE_TASK.REMIND_AT);
|
||||
showQuickAccess = input<boolean>(true);
|
||||
quickAccessTranslationPrefix = input<string>('F.TASK.D_SCHEDULE_TASK');
|
||||
|
||||
// Outputs
|
||||
dateSelected = output<Date>();
|
||||
timeChanged = output<string | null>();
|
||||
reminderChanged = output<TaskReminderOptionId>();
|
||||
quickAccessClick = output<'today' | 'tomorrow' | 'nextWeek' | 'nextMonth'>();
|
||||
enterSubmit = output<void>();
|
||||
|
||||
// Template variables
|
||||
T: typeof T = T;
|
||||
isInitValOnTimeFocus = true;
|
||||
isShowEnterMsg = false;
|
||||
@HostBinding('class.sp-hide-cursor') isKeyboardNavigating = false;
|
||||
|
||||
readonly calendar = viewChild(MatCalendar);
|
||||
|
||||
readonly isConfigReady = computed(
|
||||
() => this._globalConfigService.localization() !== undefined,
|
||||
);
|
||||
|
||||
get calendarSelectedDate(): Date | null {
|
||||
const cal = this.calendar();
|
||||
if (!cal) {
|
||||
return this.selectedDate();
|
||||
}
|
||||
if (cal.currentView === 'month') {
|
||||
return this.selectedDate();
|
||||
}
|
||||
return this.pickerSelectedDate || cal.activeDate;
|
||||
}
|
||||
|
||||
private _lastSyncedDate: number | null = null;
|
||||
private _syncActiveDateEffect = effect(() => {
|
||||
const date = this.selectedDate();
|
||||
const dateMs = date ? new Date(date).getTime() : null;
|
||||
if (dateMs === this._lastSyncedDate) {
|
||||
return;
|
||||
}
|
||||
this._lastSyncedDate = dateMs;
|
||||
|
||||
const cal = this.calendar();
|
||||
if (cal) {
|
||||
cal.activeDate = new Date(date || new Date());
|
||||
}
|
||||
});
|
||||
|
||||
private _timeCheckVal: string | null = null;
|
||||
|
||||
onKeyDownOnCalendar(ev: KeyboardEvent): void {
|
||||
this._timeCheckVal = null;
|
||||
if (ev.code === 'Enter' || ev.code === 'Space') {
|
||||
this.isShowEnterMsg = true;
|
||||
const cal = this.calendar();
|
||||
const selDate = this.selectedDate();
|
||||
if (
|
||||
cal &&
|
||||
selDate &&
|
||||
new Date(selDate).getTime() === new Date(cal.activeDate).getTime()
|
||||
) {
|
||||
this.enterSubmit.emit();
|
||||
}
|
||||
} else {
|
||||
this.isShowEnterMsg = false;
|
||||
}
|
||||
|
||||
if (
|
||||
[
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'Home',
|
||||
'End',
|
||||
'PageUp',
|
||||
'PageDown',
|
||||
].includes(ev.key)
|
||||
) {
|
||||
this.isKeyboardNavigating = true;
|
||||
this._cdr.markForCheck();
|
||||
}
|
||||
}
|
||||
|
||||
onTimeFocus(): void {
|
||||
if (!this.selectedTime() && this.isInitValOnTimeFocus) {
|
||||
this.isInitValOnTimeFocus = false;
|
||||
|
||||
let targetTime: string;
|
||||
let targetDate: Date | null = null;
|
||||
|
||||
const selDate = this.selectedDate();
|
||||
if (selDate) {
|
||||
if (this._dateService.isToday(selDate)) {
|
||||
targetTime = getClockStringFromHours((new Date().getHours() + 1) % 24);
|
||||
} else {
|
||||
targetTime = DEFAULT_TIME;
|
||||
}
|
||||
} else {
|
||||
// get current time +1h
|
||||
targetTime = getClockStringFromHours((new Date().getHours() + 1) % 24);
|
||||
targetDate = new Date();
|
||||
}
|
||||
|
||||
if (targetDate) {
|
||||
this.dateSelected.emit(targetDate);
|
||||
}
|
||||
this.timeChanged.emit(targetTime);
|
||||
}
|
||||
}
|
||||
|
||||
onTimeChange(newTime: string | null): void {
|
||||
this.timeChanged.emit(newTime);
|
||||
}
|
||||
|
||||
onReminderChange(newReminder: TaskReminderOptionId): void {
|
||||
this.reminderChanged.emit(newReminder);
|
||||
}
|
||||
|
||||
onTimeKeyDown(ev: KeyboardEvent): void {
|
||||
if (ev.key === 'Enter') {
|
||||
this.isShowEnterMsg = true;
|
||||
if (this._timeCheckVal === this.selectedTime()) {
|
||||
this.enterSubmit.emit();
|
||||
}
|
||||
this._timeCheckVal = this.selectedTime();
|
||||
} else {
|
||||
this.isShowEnterMsg = false;
|
||||
}
|
||||
}
|
||||
|
||||
onTimeClear(ev: MouseEvent): void {
|
||||
ev.stopPropagation();
|
||||
this.timeChanged.emit(null);
|
||||
this.reminderChanged.emit(TaskReminderOptionId.DoNotRemind);
|
||||
this.isInitValOnTimeFocus = true;
|
||||
this._timeCheckVal = null;
|
||||
}
|
||||
|
||||
quickAccessBtnClick(
|
||||
ev: MouseEvent,
|
||||
val: 'today' | 'tomorrow' | 'nextWeek' | 'nextMonth',
|
||||
): void {
|
||||
ev.preventDefault();
|
||||
this.quickAccessClick.emit(val);
|
||||
}
|
||||
|
||||
private _lastMouseCoords: { x: number; y: number } | null = null;
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
// Focus the active calendar cell when the picker opens
|
||||
setTimeout(() => {
|
||||
const activeCell = this._el.nativeElement.querySelector(
|
||||
'.mat-calendar-body-active',
|
||||
) as HTMLElement;
|
||||
if (activeCell) {
|
||||
activeCell.focus();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
@HostListener('mousemove', ['$event'])
|
||||
onHostMouseMove(ev: MouseEvent): void {
|
||||
this._resetKeyboardNav(ev);
|
||||
}
|
||||
|
||||
private _resetKeyboardNav(ev: MouseEvent): boolean {
|
||||
const coords = { x: ev.clientX, y: ev.clientY };
|
||||
if (
|
||||
this._lastMouseCoords &&
|
||||
this._lastMouseCoords.x === coords.x &&
|
||||
this._lastMouseCoords.y === coords.y
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this._lastMouseCoords = coords;
|
||||
|
||||
if (this.isKeyboardNavigating) {
|
||||
this.isKeyboardNavigating = false;
|
||||
this._cdr.markForCheck();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -30,7 +30,16 @@
|
|||
"RESTORE_STRAY_BACKUP": "During the last sync, there may have been an error. Do you want to restore the last backup?"
|
||||
},
|
||||
"DATETIME_SCHEDULE": {
|
||||
"PRESS_ENTER_AGAIN": "Press enter again to save"
|
||||
"MONTH": "Month",
|
||||
"NEXT_24_YEARS": "Next 24 years",
|
||||
"NEXT_MONTH": "Next month",
|
||||
"NEXT_YEAR": "Next year",
|
||||
"PRESS_ENTER_AGAIN": "Press enter again to save",
|
||||
"PREVIOUS_24_YEARS": "Previous 24 years",
|
||||
"PREVIOUS_MONTH": "Previous month",
|
||||
"PREVIOUS_YEAR": "Previous year",
|
||||
"SWITCH_TO_MULTI_YEAR_VIEW": "Choose year",
|
||||
"SWITCH_TO_YEAR_VIEW": "Choose month"
|
||||
},
|
||||
"DIALOG_LOGS": {
|
||||
"COPY": "Copy",
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import {
|
|||
MatDateFormats,
|
||||
DateAdapter,
|
||||
} from '@angular/material/core';
|
||||
import { MatDatepickerIntl } from '@angular/material/datepicker';
|
||||
import { FormlyConfigModule } from './app/ui/formly-config.module';
|
||||
import { markedOptionsFactory } from './app/ui/marked-options-factory';
|
||||
import { MaterialCssVarsModule } from 'angular-material-css-vars';
|
||||
|
|
@ -89,6 +90,7 @@ import { GlobalConfigService } from './app/features/config/global-config.service
|
|||
import { LocaleDatePipe } from './app/ui/pipes/locale-date.pipe';
|
||||
import { DateTimeFormatService } from './app/core/date-time-format/date-time-format.service';
|
||||
import { CustomDateAdapter } from './app/core/date-time-format/custom-date-adapter';
|
||||
import { TranslateMatDatepickerIntl } from './app/core/date-time-format/translate-mat-datepicker-intl';
|
||||
import { unlockAudioContext } from './app/util/audio-context';
|
||||
import { NetworkRetryInterceptorService } from './app/core/http/network-retry-interceptor.service';
|
||||
|
||||
|
|
@ -202,6 +204,7 @@ bootstrapApplication(AppComponent, {
|
|||
ShortTimeHtmlPipe,
|
||||
ShortTimePipe,
|
||||
{ provide: DateAdapter, useClass: CustomDateAdapter },
|
||||
{ provide: MatDatepickerIntl, useClass: TranslateMatDatepickerIntl },
|
||||
{
|
||||
provide: MAT_DATE_FORMATS,
|
||||
useFactory: (dateTimeFormatService: DateTimeFormatService): MatDateFormats => {
|
||||
|
|
@ -217,7 +220,7 @@ bootstrapApplication(AppComponent, {
|
|||
get dateInput(): string {
|
||||
return dateTimeFormatService.dateFormat().raw;
|
||||
},
|
||||
monthYearLabel: { year: 'numeric', month: 'short' },
|
||||
monthYearLabel: { year: 'numeric', month: 'long' },
|
||||
dateA11yLabel: { year: 'numeric', month: 'long', day: 'numeric' },
|
||||
monthYearA11yLabel: { year: 'numeric', month: 'long' },
|
||||
timeInput: { hour: 'numeric', minute: 'numeric' },
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
--mat-tab-header-active-hover-label-text-color: var(--palette-primary-700);
|
||||
--mdc-tab-indicator-active-indicator-color: var(--palette-primary-700);
|
||||
--mat-tab-header-inactive-label-text-color: var(--text-color-muted);
|
||||
--calendar-hover-bg: color-mix(in srgb, var(--c-primary) 35%, transparent);
|
||||
}
|
||||
|
||||
// Dark theme tab overrides for better contrast - use lighter shade
|
||||
|
|
@ -22,6 +23,7 @@ body.isDarkTheme {
|
|||
--mat-tab-header-active-focus-label-text-color: var(--palette-primary-300);
|
||||
--mat-tab-header-active-hover-label-text-color: var(--palette-primary-300);
|
||||
--mdc-tab-indicator-active-indicator-color: var(--palette-primary-300);
|
||||
--calendar-hover-bg: color-mix(in srgb, var(--c-primary) 20%, transparent);
|
||||
}
|
||||
|
||||
// Apply tab colors explicitly to tab components
|
||||
|
|
@ -483,3 +485,20 @@ body.isVerticalActionBar .cdk-overlay-pane.mat-mdc-tooltip-panel {
|
|||
display: flex !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
// Shared calendar hover and focus overrides
|
||||
.mat-calendar:not(.no-hover-effects) {
|
||||
.mat-calendar-body-cell:not(.mat-calendar-body-disabled):hover
|
||||
> .mat-calendar-body-cell-content,
|
||||
.mat-calendar-body-cell:not(.mat-calendar-body-disabled):focus
|
||||
.mat-calendar-body-cell-content {
|
||||
outline: 2px solid color-mix(in srgb, var(--c-accent) 80%, transparent) !important;
|
||||
}
|
||||
|
||||
.mat-calendar-body-cell:not(.mat-calendar-body-disabled):hover
|
||||
> .mat-calendar-body-cell-content:not(.mat-calendar-body-selected),
|
||||
.mat-calendar-body-cell:not(.mat-calendar-body-disabled):focus
|
||||
> .mat-calendar-body-cell-content:not(.mat-calendar-body-selected) {
|
||||
background-color: var(--calendar-hover-bg) !important;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue