mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
chore(task-repeat): revert RRULE Phase 1 from master (#7948) Develop the full RFC-5545 RRULE epic on the long-running feat/rrule-epic branch and merge once complete and testable in final form, rather than landing 13 phases into master one half-state at a time. Work preserved on feat/rrule-epic. Not yet shipped (post-v18.9.1), so no user impact.
This commit is contained in:
parent
ccbd66d3d9
commit
3d2c811e78
67 changed files with 691 additions and 8511 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -14,10 +14,6 @@ src/app/config/env.generated.ts
|
|||
/app-builds
|
||||
/tmp
|
||||
/logs
|
||||
# local dev-server / test-run logs at repo root
|
||||
/.ng*.log
|
||||
/.e2e*.log
|
||||
/.electron.log
|
||||
packages/plugin-api/dist/**
|
||||
docs/ai/tmp
|
||||
|
||||
|
|
|
|||
|
|
@ -101,7 +101,3 @@ When you enable **"Wait for completion"** on a repeating task, the app will not
|
|||
- **Habit tracking**: Tasks where you want to ensure completion before moving to the next occurrence
|
||||
|
||||
This option works independently from "Repeat from completion date" (which re-anchors the schedule to the completion day). You can enable both if you want the task to repeat N days after completion AND wait for completion before creating the next instance.
|
||||
|
||||
## Missed Occurrences
|
||||
|
||||
If you open the app after several scheduled occurrences were missed (for example, you were away for a week), only a **single** instance is created for the most recent occurrence. The occurrences in between are skipped—you don't come back to a wall of overdue chores. The schedule pointer advances past the missed dates so they aren't re-evaluated.
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
import { type Page } from '@playwright/test';
|
||||
import { expect, test } from '../../fixtures/test.fixture';
|
||||
import { ensureGlobalAddTaskBarOpen } from '../../utils/element-helpers';
|
||||
|
||||
const ADD_TASK_BAR = 'add-task-bar.global';
|
||||
const ADD_TASK_INPUT = `${ADD_TASK_BAR} input`;
|
||||
const REPEAT_BUTTON = `${ADD_TASK_BAR} [data-test="add-task-bar-repeat-btn"]`;
|
||||
|
||||
/** Read the persisted rrule for a task (by title substring) from the store. */
|
||||
const getPersistedRRule = async (page: Page, titlePart: string): Promise<string | null> =>
|
||||
page.evaluate((title) => {
|
||||
type TaskLike = { title?: string; repeatCfgId?: string | null };
|
||||
type CfgLike = { rrule?: string | null };
|
||||
type StoreState = {
|
||||
tasks?: { entities?: Record<string, TaskLike | undefined> };
|
||||
taskRepeatCfg?: { entities?: Record<string, CfgLike | undefined> };
|
||||
};
|
||||
type StoreLike = {
|
||||
subscribe: (next: (s: StoreState) => void) => { unsubscribe: () => void };
|
||||
};
|
||||
const helpers = (window as unknown as { __e2eTestHelpers?: { store?: StoreLike } })
|
||||
.__e2eTestHelpers;
|
||||
const store = helpers?.store;
|
||||
if (!store) throw new Error('__e2eTestHelpers.store missing');
|
||||
let state: StoreState | undefined;
|
||||
store.subscribe((s) => (state = s)).unsubscribe();
|
||||
const task = Object.values(state?.tasks?.entities ?? {}).find((t) =>
|
||||
t?.title?.includes(title),
|
||||
);
|
||||
if (!task?.repeatCfgId) return null;
|
||||
return (state?.taskRepeatCfg?.entities ?? {})[task.repeatCfgId]?.rrule ?? null;
|
||||
}, titlePart);
|
||||
|
||||
test.describe('Add Task Bar custom recurring option', () => {
|
||||
// Regression: the menu emits the 'RRULE' quick-setting value, but the
|
||||
// add-task-bar used to branch on the legacy 'CUSTOM' value only — picking
|
||||
// "Custom recurring config" then silently created a weekly-fallback cfg
|
||||
// instead of opening the repeat dialog.
|
||||
test('picking "Custom recurring config" opens the repeat dialog after task creation', async ({
|
||||
page,
|
||||
workViewPage,
|
||||
dialogPage,
|
||||
testPrefix,
|
||||
}) => {
|
||||
await workViewPage.waitForTaskList();
|
||||
await ensureGlobalAddTaskBarOpen(page);
|
||||
|
||||
const title = `${testPrefix}-CustomRepeat`;
|
||||
const input = page.locator(ADD_TASK_INPUT).first();
|
||||
await input.fill(title);
|
||||
|
||||
// Pick "Custom recurring config" from the repeat menu.
|
||||
const repeatBtn = page.locator(REPEAT_BUTTON).first();
|
||||
await repeatBtn.waitFor({ state: 'visible', timeout: 10000 });
|
||||
await repeatBtn.click();
|
||||
await page
|
||||
.getByRole('menuitem', { name: /custom recurring config/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Submit the task → the full repeat dialog must open for it.
|
||||
await input.press('Enter');
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||
await expect(dialog.locator('dialog-edit-task-repeat-cfg')).toBeVisible();
|
||||
|
||||
// Builder mode is preselected for the custom option; the live result band
|
||||
// shows an assembled rule which saves as the task's rrule.
|
||||
const expr = dialog.locator('.rrule-result__expr');
|
||||
await expect(expr).toBeVisible({ timeout: 5000 });
|
||||
const builtRule = (await expr.textContent())!.trim();
|
||||
expect(builtRule).toMatch(/^FREQ=/);
|
||||
|
||||
await dialogPage.clickSaveButton();
|
||||
await dialogPage.waitForDialogToClose();
|
||||
|
||||
await expect
|
||||
.poll(async () => getPersistedRRule(page, 'CustomRepeat'), { timeout: 10000 })
|
||||
.toBe(builtRule);
|
||||
});
|
||||
});
|
||||
|
|
@ -64,22 +64,19 @@ test.describe('Recurring Task - preserve past dueDay (#7344)', () => {
|
|||
const repeatDialog = page.locator('mat-dialog-container');
|
||||
await repeatDialog.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// 4. Convert to recurring via "Custom recurring config" (the RRULE builder;
|
||||
// the legacy Custom → repeat-cycle UI was removed).
|
||||
// 4. Switch to CUSTOM so repeatCycle becomes editable without overriding
|
||||
// startDate (picking YEARLY_CURRENT_DATE would reset startDate to today).
|
||||
const quickSettingSelect = repeatDialog.locator('mat-select').first();
|
||||
await quickSettingSelect.click();
|
||||
const rruleOption = page
|
||||
.locator('mat-option')
|
||||
.filter({ hasText: /Custom recurring config/i });
|
||||
await expect(rruleOption).toBeVisible({ timeout: 5000 });
|
||||
await rruleOption.click();
|
||||
const customOption = page.locator('mat-option').filter({ hasText: /Custom/i });
|
||||
await customOption.click();
|
||||
|
||||
// 5. Set the builder frequency to Yearly. The exact rule is irrelevant to
|
||||
// this regression — what matters is that converting a past-dated task to
|
||||
// recurring must NOT advance its dueDay (which would drop it from Today).
|
||||
const freqSelect = repeatDialog.locator('rrule-builder select').first();
|
||||
await expect(freqSelect).toBeVisible({ timeout: 5000 });
|
||||
await freqSelect.selectOption('YEARLY');
|
||||
// 5. Set repeat cycle to YEARLY (default is WEEKLY under CUSTOM).
|
||||
const repeatCycleSelect = repeatDialog.locator('.repeat-cycle mat-select').first();
|
||||
await expect(repeatCycleSelect).toBeVisible({ timeout: 5000 });
|
||||
await repeatCycleSelect.click();
|
||||
const yearlyOption = page.locator('mat-option').filter({ hasText: /Year/i });
|
||||
await yearlyOption.click();
|
||||
|
||||
// 6. Save the repeat config.
|
||||
const saveBtn = repeatDialog.getByRole('button', { name: /Save/i });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,192 @@
|
|||
import { expect, test } from '../../fixtures/test.fixture';
|
||||
import { type Page } from '@playwright/test';
|
||||
import { openRecurDialog, saveRecurDialog } from '../../utils/recurring-task-helpers';
|
||||
|
||||
/**
|
||||
* Bug: https://github.com/super-productivity/super-productivity/issues/8025
|
||||
*
|
||||
* In the CUSTOM recurring config, switching the "Recur cycle" away from Weekly
|
||||
* (to Month/Year) and back to Weekly leaves the weekday checkboxes rendered but
|
||||
* non-interactive: they "look enabled but are actually disabled". A click no
|
||||
* longer updates the model, so the user's weekday selection is silently lost.
|
||||
*
|
||||
* Expected: after the Week -> Month -> Week round-trip the weekday checkboxes
|
||||
* remain fully interactive: un-checking Monday persists, exactly as
|
||||
* it would without the round-trip.
|
||||
*
|
||||
* This test reproduces the defect by toggling Monday off AFTER the round-trip
|
||||
* and asserting the change survives a save (read back from the NgRx store). The
|
||||
* weekday group is configured with `resetOnHide: false`, so preservation is the
|
||||
* intended design; the failure is the lost interactivity, not a deliberate
|
||||
* reset.
|
||||
*/
|
||||
|
||||
const DIALOG = 'mat-dialog-container';
|
||||
|
||||
/** Switch the quick-setting select (first select in the dialog). */
|
||||
const setQuickSetting = async (page: Page, optionLabel: RegExp): Promise<void> => {
|
||||
const dialog = page.locator(DIALOG);
|
||||
await dialog.locator('mat-select').first().click();
|
||||
const option = page.locator('mat-option').filter({ hasText: optionLabel });
|
||||
await expect(option).toBeVisible({ timeout: 5000 });
|
||||
await option.click();
|
||||
await expect(page.locator('mat-option')).toHaveCount(0, { timeout: 5000 });
|
||||
};
|
||||
|
||||
/** Switch the CUSTOM "Recur cycle" select (Day / Week / Month / Year). */
|
||||
const setRepeatCycle = async (page: Page, optionLabel: RegExp): Promise<void> => {
|
||||
const dialog = page.locator(DIALOG);
|
||||
const cycleSelect = dialog.locator('.repeat-cycle mat-select').first();
|
||||
await expect(cycleSelect).toBeVisible({ timeout: 5000 });
|
||||
await cycleSelect.click();
|
||||
const option = page.locator('mat-option').filter({ hasText: optionLabel });
|
||||
await expect(option).toBeVisible({ timeout: 5000 });
|
||||
await option.click();
|
||||
await expect(page.locator('mat-option')).toHaveCount(0, { timeout: 5000 });
|
||||
};
|
||||
|
||||
type WeekdayKey =
|
||||
| 'monday'
|
||||
| 'tuesday'
|
||||
| 'wednesday'
|
||||
| 'thursday'
|
||||
| 'friday'
|
||||
| 'saturday'
|
||||
| 'sunday';
|
||||
|
||||
type PersistedRepeatCfgSnapshot = {
|
||||
quickSetting: string | null;
|
||||
repeatCycle: string | null;
|
||||
weekday: boolean | null;
|
||||
};
|
||||
|
||||
/** Read a recurrence snapshot for the repeat config created for the given title. */
|
||||
const getPersistedRepeatCfgSnapshot = async (
|
||||
page: Page,
|
||||
title: string,
|
||||
weekday: WeekdayKey,
|
||||
): Promise<PersistedRepeatCfgSnapshot> =>
|
||||
page.evaluate(
|
||||
({ taskTitle, weekdayKey }: { taskTitle: string; weekdayKey: WeekdayKey }) => {
|
||||
type RepeatCfgLike = {
|
||||
quickSetting?: string | null;
|
||||
repeatCycle?: string | null;
|
||||
title?: string | null;
|
||||
} & Partial<Record<WeekdayKey, boolean>>;
|
||||
type StoreState = {
|
||||
taskRepeatCfg?: { entities?: Record<string, RepeatCfgLike | undefined> };
|
||||
};
|
||||
type StoreLike = {
|
||||
subscribe: (next: (s: StoreState) => void) => { unsubscribe: () => void };
|
||||
};
|
||||
const store = (window as unknown as { __e2eTestHelpers?: { store?: StoreLike } })
|
||||
.__e2eTestHelpers?.store;
|
||||
if (!store) {
|
||||
throw new Error('__e2eTestHelpers.store missing');
|
||||
}
|
||||
let latest: StoreState | undefined;
|
||||
store
|
||||
.subscribe((s) => {
|
||||
latest = s;
|
||||
})
|
||||
.unsubscribe();
|
||||
const cfg = Object.values(latest?.taskRepeatCfg?.entities ?? {}).find((c) =>
|
||||
c?.title?.includes(taskTitle),
|
||||
);
|
||||
return {
|
||||
quickSetting: cfg?.quickSetting ?? null,
|
||||
repeatCycle: cfg?.repeatCycle ?? null,
|
||||
weekday: cfg ? (cfg[weekdayKey] ?? null) : null,
|
||||
};
|
||||
},
|
||||
{ taskTitle: title, weekdayKey: weekday },
|
||||
);
|
||||
|
||||
test.describe('Recurring Task - Custom weekday checkboxes frozen after cycle round-trip (#8025)', () => {
|
||||
test('should keep weekday checkboxes interactive after switching cycle to Month and back to Week', async ({
|
||||
page,
|
||||
workViewPage,
|
||||
taskPage,
|
||||
testPrefix,
|
||||
}) => {
|
||||
const taskTitle = `${testPrefix}-WeekdayFrozen8025`;
|
||||
|
||||
await workViewPage.waitForTaskList();
|
||||
await workViewPage.addTask(taskTitle);
|
||||
const task = taskPage.getTaskByText(taskTitle).first();
|
||||
await expect(task).toBeVisible({ timeout: 10000 });
|
||||
await taskPage.openTaskDetail(task);
|
||||
const dialog = await openRecurDialog(page);
|
||||
|
||||
// Enter CUSTOM mode; repeatCycle defaults to WEEKLY with Mon-Fri checked.
|
||||
await setQuickSetting(page, /Custom recurring config/i);
|
||||
|
||||
const mondayCheckbox = dialog
|
||||
.locator('.weekdays mat-checkbox')
|
||||
.filter({ hasText: /Monday/i });
|
||||
const mondayInput = mondayCheckbox.locator('input[type="checkbox"]');
|
||||
await expect(mondayInput).toBeChecked(); // sanity: default Weekly has Monday on
|
||||
|
||||
// The round-trip that triggers the bug: Week -> Month -> Week.
|
||||
await setRepeatCycle(page, /^Month$/);
|
||||
await setRepeatCycle(page, /^Week$/);
|
||||
|
||||
// After the round-trip the checkbox is re-rendered. Try to un-check Monday.
|
||||
await expect(mondayCheckbox).toBeVisible({ timeout: 5000 });
|
||||
await mondayCheckbox.click();
|
||||
|
||||
// Save and read the persisted config back from the store. With the bug the
|
||||
// click never reached the model, so `monday` is still `true`.
|
||||
await saveRecurDialog(page);
|
||||
|
||||
await expect
|
||||
.poll(() => getPersistedRepeatCfgSnapshot(page, taskTitle, 'monday'), {
|
||||
timeout: 10000,
|
||||
})
|
||||
.toEqual({
|
||||
quickSetting: 'CUSTOM',
|
||||
repeatCycle: 'WEEKLY',
|
||||
weekday: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('should keep weekday checkboxes interactive after switching away from CUSTOM and back', async ({
|
||||
page,
|
||||
workViewPage,
|
||||
taskPage,
|
||||
testPrefix,
|
||||
}) => {
|
||||
const taskTitle = `${testPrefix}-CustomRoundTrip8025`;
|
||||
|
||||
await workViewPage.waitForTaskList();
|
||||
await workViewPage.addTask(taskTitle);
|
||||
const task = taskPage.getTaskByText(taskTitle).first();
|
||||
await expect(task).toBeVisible({ timeout: 10000 });
|
||||
await taskPage.openTaskDetail(task);
|
||||
const dialog = await openRecurDialog(page);
|
||||
|
||||
await setQuickSetting(page, /Custom recurring config/i);
|
||||
await setQuickSetting(page, /Every day/i);
|
||||
await setQuickSetting(page, /Custom recurring config/i);
|
||||
await setRepeatCycle(page, /^Week$/);
|
||||
|
||||
const tuesdayCheckbox = dialog
|
||||
.locator('.weekdays mat-checkbox')
|
||||
.filter({ hasText: /Tuesday/i });
|
||||
const tuesdayInput = tuesdayCheckbox.locator('input[type="checkbox"]');
|
||||
await expect(tuesdayInput).toBeChecked();
|
||||
|
||||
await tuesdayCheckbox.click();
|
||||
await saveRecurDialog(page);
|
||||
|
||||
await expect
|
||||
.poll(() => getPersistedRepeatCfgSnapshot(page, taskTitle, 'tuesday'), {
|
||||
timeout: 10000,
|
||||
})
|
||||
.toEqual({
|
||||
quickSetting: 'CUSTOM',
|
||||
repeatCycle: 'WEEKLY',
|
||||
weekday: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -12,7 +12,7 @@ import { expect, test } from '../../fixtures/test.fixture';
|
|||
* verify the task is NOT in Today but IS scheduled for Monday.
|
||||
*/
|
||||
test.describe('Issue #5594: First repeat occurrence should not always be today', () => {
|
||||
test('weekly weekday repeat created on Saturday should schedule for Monday', async ({
|
||||
test('weekly Mon/Wed/Fri repeat created on Saturday should schedule for Monday', async ({
|
||||
page,
|
||||
workViewPage,
|
||||
taskPage,
|
||||
|
|
@ -42,19 +42,48 @@ test.describe('Issue #5594: First repeat occurrence should not always be today',
|
|||
const repeatDialog = page.locator('mat-dialog-container');
|
||||
await repeatDialog.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// 5. Pick the "Every Monday through Friday" quick setting. Saturday is not in
|
||||
// Mon–Fri, so a task created on Saturday first fires on Monday — the same
|
||||
// assertion the original Mon/Wed/Fri custom rule made (the legacy Custom
|
||||
// weekday-picker UI was removed in favour of the RRULE builder).
|
||||
// 5. Change quickSetting to CUSTOM
|
||||
const quickSettingSelect = repeatDialog.locator('mat-select').first();
|
||||
await quickSettingSelect.click();
|
||||
const mfOption = page
|
||||
.locator('mat-option')
|
||||
.filter({ hasText: /Monday through Friday/i });
|
||||
await expect(mfOption).toBeVisible({ timeout: 5000 });
|
||||
await mfOption.click();
|
||||
const customOption = page.locator('mat-option').filter({ hasText: /Custom/i });
|
||||
await customOption.click();
|
||||
|
||||
// 6. Save the repeat config
|
||||
// 6. Wait for the custom config section to be visible
|
||||
const repeatCycleSelect = repeatDialog.locator('.repeat-cycle mat-select').first();
|
||||
await expect(repeatCycleSelect).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Ensure repeat cycle is WEEKLY (should be default)
|
||||
const repeatCycleText = await repeatCycleSelect.textContent();
|
||||
if (!repeatCycleText?.includes('Week')) {
|
||||
await repeatCycleSelect.click();
|
||||
const weeklyOption = page.locator('mat-option').filter({ hasText: /Week/i });
|
||||
await weeklyOption.click();
|
||||
}
|
||||
|
||||
// 7. Set weekday checkboxes: enable Mon/Wed/Fri, disable Tue/Thu/Sat/Sun
|
||||
// The default has Mon-Fri enabled. We need to uncheck Tue and Thu.
|
||||
const weekdaysContainer = repeatDialog.locator('.weekdays');
|
||||
await expect(weekdaysContainer).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Get all checkbox fields
|
||||
const tuesdayCheckbox = weekdaysContainer
|
||||
.locator('formly-field')
|
||||
.nth(1)
|
||||
.locator('input[type="checkbox"]');
|
||||
const thursdayCheckbox = weekdaysContainer
|
||||
.locator('formly-field')
|
||||
.nth(3)
|
||||
.locator('input[type="checkbox"]');
|
||||
|
||||
// Uncheck Tuesday and Thursday
|
||||
if (await tuesdayCheckbox.isChecked()) {
|
||||
await tuesdayCheckbox.click();
|
||||
}
|
||||
if (await thursdayCheckbox.isChecked()) {
|
||||
await thursdayCheckbox.click();
|
||||
}
|
||||
|
||||
// 8. Save the repeat config
|
||||
const saveBtn = repeatDialog.getByRole('button', { name: /Save/i });
|
||||
await expect(saveBtn).toBeEnabled({ timeout: 5000 });
|
||||
await saveBtn.click();
|
||||
|
|
|
|||
|
|
@ -1,226 +0,0 @@
|
|||
import { test, expect } from '../../fixtures/test.fixture';
|
||||
import { type Locator, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Round-trip e2e coverage for the newer RRULE builder widgets: custom nth
|
||||
* ordinals, the BYSETPOS multi-select toggles, mode-switch hygiene, and the
|
||||
* yearly BYMONTH seeding. The dialog's live result band is the oracle:
|
||||
* - `.rrule-result__expr` shows the exact assembled rrule string
|
||||
* - `.rrule-result__next` shows engine-computed upcoming dates
|
||||
* so recurrence semantics are assertable without waiting for real time to
|
||||
* pass. Each test also reopens the dialog after save to verify the parsed
|
||||
* rule renders the same widget state (parse/display round-trip).
|
||||
*/
|
||||
|
||||
const openRepeatDialog = async (page: Page): Promise<Locator> => {
|
||||
const repeatItem = page
|
||||
.locator('task-detail-item')
|
||||
.filter({ has: page.locator('mat-icon', { hasText: 'repeat' }) })
|
||||
.first();
|
||||
await expect(repeatItem).toBeVisible({ timeout: 5000 });
|
||||
await repeatItem.click();
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||
return dialog;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the persisted rrule for a task (by title substring) from the live
|
||||
* NgRx store. Saving a recurring cfg re-plans the task onto the rule's first
|
||||
* occurrence — which is usually NOT today — so the task leaves the work view
|
||||
* and a UI reopen is date-dependent. The store read is the date-independent
|
||||
* persistence oracle; the parse→widget rendering side of the round-trip is
|
||||
* covered by the dialog/builder unit specs.
|
||||
*/
|
||||
const getPersistedRRule = async (page: Page, titlePart: string): Promise<string | null> =>
|
||||
page.evaluate((title) => {
|
||||
type TaskLike = { title?: string; repeatCfgId?: string | null };
|
||||
type CfgLike = { rrule?: string | null };
|
||||
type StoreState = {
|
||||
tasks?: { entities?: Record<string, TaskLike | undefined> };
|
||||
taskRepeatCfg?: { entities?: Record<string, CfgLike | undefined> };
|
||||
};
|
||||
type StoreLike = {
|
||||
subscribe: (next: (s: StoreState) => void) => { unsubscribe: () => void };
|
||||
};
|
||||
const helpers = (window as unknown as { __e2eTestHelpers?: { store?: StoreLike } })
|
||||
.__e2eTestHelpers;
|
||||
const store = helpers?.store;
|
||||
if (!store) throw new Error('__e2eTestHelpers.store missing');
|
||||
let state: StoreState | undefined;
|
||||
store.subscribe((s) => (state = s)).unsubscribe();
|
||||
const task = Object.values(state?.tasks?.entities ?? {}).find((t) =>
|
||||
t?.title?.includes(title),
|
||||
);
|
||||
if (!task?.repeatCfgId) return null;
|
||||
return (state?.taskRepeatCfg?.entities ?? {})[task.repeatCfgId]?.rrule ?? null;
|
||||
}, titlePart);
|
||||
|
||||
const switchToBuilderMode = async (page: Page, dialog: Locator): Promise<void> => {
|
||||
await dialog.locator('mat-select').first().click();
|
||||
await page
|
||||
.getByRole('option', { name: /custom recurring config/i })
|
||||
.first()
|
||||
.click();
|
||||
await expect(dialog.locator('.rrule-result__expr')).toBeVisible({ timeout: 5000 });
|
||||
};
|
||||
|
||||
const addTaskWithDetailOpen = async (
|
||||
page: Page,
|
||||
workViewPage: {
|
||||
waitForTaskList: () => Promise<void>;
|
||||
addTask: (t: string) => Promise<void>;
|
||||
},
|
||||
taskPage: {
|
||||
getTaskByText: (t: string) => Locator;
|
||||
openTaskDetail: (t: Locator) => Promise<void>;
|
||||
},
|
||||
title: string,
|
||||
): Promise<Locator> => {
|
||||
await workViewPage.waitForTaskList();
|
||||
await workViewPage.addTask(title);
|
||||
const task = taskPage.getTaskByText(title).first();
|
||||
await expect(task).toBeVisible({ timeout: 10000 });
|
||||
await taskPage.openTaskDetail(task);
|
||||
return task;
|
||||
};
|
||||
|
||||
test.describe('RRULE builder round-trips', () => {
|
||||
test('custom nth ordinal: build → save → reopen shows the custom input', async ({
|
||||
page,
|
||||
workViewPage,
|
||||
taskPage,
|
||||
dialogPage,
|
||||
testPrefix,
|
||||
}) => {
|
||||
await addTaskWithDetailOpen(page, workViewPage, taskPage, `${testPrefix}-NthCustom`);
|
||||
const dialog = await openRepeatDialog(page);
|
||||
await switchToBuilderMode(page, dialog);
|
||||
|
||||
const builder = dialog.locator('rrule-builder');
|
||||
const expr = dialog.locator('.rrule-result__expr');
|
||||
|
||||
// MONTHLY → nth-weekday mode → switch the row's ordinal to custom → -2.
|
||||
await builder.locator('select').nth(0).selectOption('MONTHLY');
|
||||
await builder.locator('select').nth(1).selectOption('NTH_WEEKDAY');
|
||||
await builder.locator('select').nth(2).selectOption('CUSTOM');
|
||||
// (the plain interval field is .rb-num too — match the titled custom input)
|
||||
const customPos = builder.getByRole('spinbutton', { name: /occurrence number/i });
|
||||
await expect(customPos).toBeVisible();
|
||||
await customPos.fill('-2');
|
||||
await customPos.blur();
|
||||
|
||||
// 2nd-to-last <start weekday> of the month.
|
||||
await expect(expr).toHaveText(/^FREQ=MONTHLY;BYDAY=-2(MO|TU|WE|TH|FR|SA|SU)$/);
|
||||
// The engine resolves it to concrete upcoming dates (rule is alive).
|
||||
await expect(dialog.locator('.rrule-result__next')).toBeVisible();
|
||||
|
||||
const savedExpr = (await expr.textContent())!.trim();
|
||||
await dialogPage.clickSaveButton();
|
||||
await dialogPage.waitForDialogToClose();
|
||||
|
||||
// The exact custom-ordinal rule is persisted verbatim.
|
||||
await expect
|
||||
.poll(async () => getPersistedRRule(page, 'NthCustom'), { timeout: 10000 })
|
||||
.toBe(savedExpr);
|
||||
});
|
||||
|
||||
test('BYSETPOS multi-select: first + last weekday round-trips with both toggles active', async ({
|
||||
page,
|
||||
workViewPage,
|
||||
taskPage,
|
||||
dialogPage,
|
||||
testPrefix,
|
||||
}) => {
|
||||
await addTaskWithDetailOpen(page, workViewPage, taskPage, `${testPrefix}-SetPos`);
|
||||
const dialog = await openRepeatDialog(page);
|
||||
await switchToBuilderMode(page, dialog);
|
||||
|
||||
const builder = dialog.locator('rrule-builder');
|
||||
const expr = dialog.locator('.rrule-result__expr');
|
||||
|
||||
// MONTHLY → weekday-set mode (byDay pre-seeded with the start weekday).
|
||||
await builder.locator('select').nth(0).selectOption('MONTHLY');
|
||||
await builder.locator('select').nth(1).selectOption('WEEKDAYS');
|
||||
|
||||
await builder.getByRole('button', { name: 'first', exact: true }).click();
|
||||
await builder.getByRole('button', { name: 'last', exact: true }).click();
|
||||
await expect(expr).toHaveText(/;BYSETPOS=1,-1$/);
|
||||
|
||||
const savedExpr = (await expr.textContent())!.trim();
|
||||
await dialogPage.clickSaveButton();
|
||||
await dialogPage.waitForDialogToClose();
|
||||
|
||||
// The multi-value BYSETPOS rule is persisted verbatim.
|
||||
await expect
|
||||
.poll(async () => getPersistedRRule(page, 'SetPos'), { timeout: 10000 })
|
||||
.toBe(savedExpr);
|
||||
});
|
||||
|
||||
test('mode switch clears BYSETPOS: weekday-set narrowing must not leak into day-of-month', async ({
|
||||
page,
|
||||
workViewPage,
|
||||
taskPage,
|
||||
dialogPage,
|
||||
testPrefix,
|
||||
}) => {
|
||||
await addTaskWithDetailOpen(page, workViewPage, taskPage, `${testPrefix}-ModeLeak`);
|
||||
const dialog = await openRepeatDialog(page);
|
||||
await switchToBuilderMode(page, dialog);
|
||||
|
||||
const builder = dialog.locator('rrule-builder');
|
||||
const expr = dialog.locator('.rrule-result__expr');
|
||||
|
||||
// Weekday-set mode with a 'second' narrowing…
|
||||
await builder.locator('select').nth(0).selectOption('MONTHLY');
|
||||
await builder.locator('select').nth(1).selectOption('WEEKDAYS');
|
||||
await builder.getByRole('button', { name: 'second', exact: true }).click();
|
||||
await expect(expr).toHaveText(/;BYSETPOS=2$/);
|
||||
|
||||
// …then switching to day-of-month must DROP the BYSETPOS: leaked it would
|
||||
// silently narrow the day list (BYMONTHDAY=n;BYSETPOS=2 never fires).
|
||||
await builder.locator('select').nth(1).selectOption('DAY_OF_MONTH');
|
||||
await expect(expr).toHaveText(/^FREQ=MONTHLY;BYMONTHDAY=[\d,-]+$/);
|
||||
await expect(expr).not.toHaveText(/BYSETPOS/);
|
||||
// Rule is alive — engine produces upcoming dates.
|
||||
await expect(dialog.locator('.rrule-result__next')).toBeVisible();
|
||||
|
||||
await dialogPage.clickSaveButton();
|
||||
await dialogPage.waitForDialogToClose();
|
||||
});
|
||||
|
||||
test('switching to YEARLY seeds BYMONTH so the rule fires once a year', async ({
|
||||
page,
|
||||
workViewPage,
|
||||
taskPage,
|
||||
dialogPage,
|
||||
testPrefix,
|
||||
}) => {
|
||||
await addTaskWithDetailOpen(page, workViewPage, taskPage, `${testPrefix}-Yearly`);
|
||||
const dialog = await openRepeatDialog(page);
|
||||
await switchToBuilderMode(page, dialog);
|
||||
|
||||
const builder = dialog.locator('rrule-builder');
|
||||
const expr = dialog.locator('.rrule-result__expr');
|
||||
|
||||
await builder.locator('select').nth(0).selectOption('YEARLY');
|
||||
// Without BYMONTH a bare yearly BYMONTHDAY would fire EVERY month
|
||||
// (RFC 5545 expansion) — the seed pins it to the start month.
|
||||
await expect(expr).toHaveText(/^FREQ=YEARLY;BYMONTH=\d+;BYMONTHDAY=\d+$/);
|
||||
|
||||
// The upcoming dates must be one year apart (same month), not monthly.
|
||||
const nextBand = dialog.locator('.rrule-result__next');
|
||||
await expect(nextBand).toBeVisible();
|
||||
const dates = await nextBand
|
||||
.locator('span:not(.rrule-result__sep)')
|
||||
.allTextContents();
|
||||
const years = dates
|
||||
.map((d) => new Date(d).getFullYear())
|
||||
.filter((y) => !Number.isNaN(y));
|
||||
expect(years.length).toBeGreaterThan(1);
|
||||
expect(new Set(years).size).toBe(years.length); // strictly one per year
|
||||
|
||||
await dialogPage.clickSaveButton();
|
||||
await dialogPage.waitForDialogToClose();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
import { test, expect } from '../../fixtures/test.fixture';
|
||||
import { type Page } from '@playwright/test';
|
||||
|
||||
type RepeatSnapshot = {
|
||||
taskTitle: string;
|
||||
repeatCfgId: string | null;
|
||||
repeatCycle: string | null;
|
||||
rrule: string | null;
|
||||
};
|
||||
|
||||
// Reads the live NgRx store via the e2e helper to inspect the repeat cfg
|
||||
// attached to a task (matched by a substring of its title).
|
||||
const getRepeatCfgForTask = async (
|
||||
page: Page,
|
||||
titlePart: string,
|
||||
): Promise<RepeatSnapshot | null> =>
|
||||
page.evaluate((title) => {
|
||||
type TaskLike = { title?: string; repeatCfgId?: string | null };
|
||||
type CfgLike = { repeatCycle?: string; rrule?: string | null };
|
||||
type StoreState = {
|
||||
tasks?: { entities?: Record<string, TaskLike | undefined> };
|
||||
taskRepeatCfg?: { entities?: Record<string, CfgLike | undefined> };
|
||||
};
|
||||
type StoreLike = {
|
||||
subscribe: (next: (s: StoreState) => void) => { unsubscribe: () => void };
|
||||
};
|
||||
const helpers = (window as unknown as { __e2eTestHelpers?: { store?: StoreLike } })
|
||||
.__e2eTestHelpers;
|
||||
const store = helpers?.store;
|
||||
if (!store) throw new Error('__e2eTestHelpers.store missing');
|
||||
|
||||
let state: StoreState | undefined;
|
||||
store.subscribe((s) => (state = s)).unsubscribe();
|
||||
|
||||
const task = Object.values(state?.tasks?.entities ?? {}).find((t) =>
|
||||
t?.title?.includes(title),
|
||||
);
|
||||
if (!task) return null;
|
||||
const cfg = task.repeatCfgId
|
||||
? (state?.taskRepeatCfg?.entities ?? {})[task.repeatCfgId]
|
||||
: undefined;
|
||||
return {
|
||||
taskTitle: task.title ?? '',
|
||||
repeatCfgId: task.repeatCfgId ?? null,
|
||||
repeatCycle: cfg?.repeatCycle ?? null,
|
||||
rrule: cfg?.rrule ?? null,
|
||||
};
|
||||
}, titlePart);
|
||||
|
||||
// NOTE: the `@+` natural-language short syntax is deferred to a later phase
|
||||
// (see the EPIC on #7948); its e2e coverage will return with that phase. This
|
||||
// file now only exercises the Phase-1 dialog builder flow.
|
||||
|
||||
test.describe('RRULE recurring tasks', () => {
|
||||
test('full dialog flow: Custom recurring config builder → live preview → save', async ({
|
||||
page,
|
||||
workViewPage,
|
||||
taskPage,
|
||||
dialogPage,
|
||||
testPrefix,
|
||||
}) => {
|
||||
await workViewPage.waitForTaskList();
|
||||
|
||||
const title = `${testPrefix}-Water Plants`;
|
||||
await workViewPage.addTask(title);
|
||||
const task = taskPage.getTaskByText(title).first();
|
||||
await expect(task).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await taskPage.openTaskDetail(task);
|
||||
const repeatItem = page
|
||||
.locator('task-detail-item')
|
||||
.filter({ has: page.locator('mat-icon', { hasText: 'repeat' }) })
|
||||
.first();
|
||||
await expect(repeatItem).toBeVisible({ timeout: 5000 });
|
||||
await repeatItem.click();
|
||||
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Pick the "Custom recurring config" quick setting → the dropdown builder appears.
|
||||
await dialog.locator('mat-select').first().click();
|
||||
await page
|
||||
.getByRole('option', { name: /custom recurring config/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// The live result band (pinned above the actions in RRULE mode) shows the
|
||||
// humanized reading of the assembled rule.
|
||||
const preview = dialog.locator('.rrule-result');
|
||||
await expect(preview).toBeVisible({ timeout: 5000 });
|
||||
await expect(preview).toContainText(/week/i);
|
||||
|
||||
await dialogPage.clickSaveButton();
|
||||
await dialogPage.waitForDialogToClose();
|
||||
|
||||
// An rrule-backed cfg is persisted, with a FREQ-derived legacy repeatCycle.
|
||||
await expect
|
||||
.poll(async () => (await getRepeatCfgForTask(page, title))?.rrule ?? null, {
|
||||
timeout: 10000,
|
||||
})
|
||||
.toMatch(/^FREQ=WEEKLY/);
|
||||
const snap = await getRepeatCfgForTask(page, title);
|
||||
expect(snap!.repeatCycle).toBe('WEEKLY');
|
||||
});
|
||||
});
|
||||
|
|
@ -11,9 +11,7 @@ import {
|
|||
type TestUser,
|
||||
} from '../../utils/supersync-helpers';
|
||||
import { execSync } from 'child_process';
|
||||
import { readFileSync, unlinkSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { writeFileSync } from 'fs';
|
||||
|
||||
/** Default encryption password used by setupSuperSync's mandatory encryption dialog */
|
||||
const ENCRYPTION_PASSWORD = 'e2e-default-encryption-pw';
|
||||
|
|
@ -86,8 +84,7 @@ const USER_SYNC_TABLES = ['operations', 'user_sync_state', 'sync_devices'] as co
|
|||
|
||||
/**
|
||||
* Snapshot a SINGLE user's sync data to a restorable SQL script and return its
|
||||
* host path (in the OS temp dir — no POSIX-only /tmp, so it works on Windows
|
||||
* hosts too).
|
||||
* host path.
|
||||
*
|
||||
* Replaces a previous full-database `pg_dump` + `DROP SCHEMA public CASCADE`
|
||||
* restore. That restore wiped EVERY user's rows on the shared test database;
|
||||
|
|
@ -102,7 +99,7 @@ const USER_SYNC_TABLES = ['operations', 'user_sync_state', 'sync_devices'] as co
|
|||
* SYNC_IMPORT_EXISTS scenario these tests exercise.
|
||||
*/
|
||||
const dumpUserData = (userId: number): string => {
|
||||
const dumpPath = join(tmpdir(), `supersync_e2e_userdump_${userId}_${Date.now()}.sql`);
|
||||
const dumpPath = `/tmp/supersync_e2e_userdump_${userId}_${Date.now()}.sql`;
|
||||
let script = 'BEGIN;\n';
|
||||
for (const table of USER_SYNC_TABLES) {
|
||||
script += `DELETE FROM ${table} WHERE user_id = ${userId};\n`;
|
||||
|
|
@ -120,7 +117,7 @@ const dumpUserData = (userId: number): string => {
|
|||
script += `COPY ${table} FROM STDIN;\n${copyData}\\.\n`;
|
||||
}
|
||||
script += 'COMMIT;\n';
|
||||
writeFileSync(dumpPath, script, 'utf-8');
|
||||
writeFileSync(dumpPath, script);
|
||||
return dumpPath;
|
||||
};
|
||||
|
||||
|
|
@ -128,18 +125,14 @@ const dumpUserData = (userId: number): string => {
|
|||
* Restore a user's sync data from a snapshot created by dumpUserData().
|
||||
* Atomic (ON_ERROR_STOP aborts the transaction) and scoped to the snapshotted
|
||||
* user — it never touches other users' rows.
|
||||
* Cross-platform: feeds the script via stdin instead of `cat | …`, which is
|
||||
* unavailable on Windows hosts.
|
||||
*/
|
||||
const restoreUserData = (dumpPath: string): void => {
|
||||
execSync(
|
||||
'docker compose -f docker-compose.yaml exec -T db ' +
|
||||
'psql -U supersync supersync_db -v ON_ERROR_STOP=1',
|
||||
`cat ${dumpPath} | docker compose -f docker-compose.yaml exec -T db ` +
|
||||
`psql -U supersync supersync_db -v ON_ERROR_STOP=1`,
|
||||
{
|
||||
input: readFileSync(dumpPath, 'utf-8'),
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000,
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
@ -149,7 +142,7 @@ const restoreUserData = (dumpPath: string): void => {
|
|||
*/
|
||||
const cleanupDump = (dumpPath: string): void => {
|
||||
try {
|
||||
unlinkSync(dumpPath);
|
||||
execSync(`rm -f ${dumpPath}`);
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
|
|
|
|||
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -27,7 +27,6 @@
|
|||
"hash-wasm": "^4.12.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"rrule": "^2.8.1",
|
||||
"uuidv7": "^1.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -22955,15 +22954,6 @@
|
|||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/rrule": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz",
|
||||
"integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/run-applescript": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -193,7 +193,6 @@
|
|||
"hash-wasm": "^4.12.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"rrule": "^2.8.1",
|
||||
"uuidv7": "^1.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -2128,474 +2128,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@vitest/mocker": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
|
||||
|
|
@ -2623,50 +2155,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/esbuild": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.0",
|
||||
"@esbuild/android-arm": "0.28.0",
|
||||
"@esbuild/android-arm64": "0.28.0",
|
||||
"@esbuild/android-x64": "0.28.0",
|
||||
"@esbuild/darwin-arm64": "0.28.0",
|
||||
"@esbuild/darwin-x64": "0.28.0",
|
||||
"@esbuild/freebsd-arm64": "0.28.0",
|
||||
"@esbuild/freebsd-x64": "0.28.0",
|
||||
"@esbuild/linux-arm": "0.28.0",
|
||||
"@esbuild/linux-arm64": "0.28.0",
|
||||
"@esbuild/linux-ia32": "0.28.0",
|
||||
"@esbuild/linux-loong64": "0.28.0",
|
||||
"@esbuild/linux-mips64el": "0.28.0",
|
||||
"@esbuild/linux-ppc64": "0.28.0",
|
||||
"@esbuild/linux-riscv64": "0.28.0",
|
||||
"@esbuild/linux-s390x": "0.28.0",
|
||||
"@esbuild/linux-x64": "0.28.0",
|
||||
"@esbuild/netbsd-arm64": "0.28.0",
|
||||
"@esbuild/netbsd-x64": "0.28.0",
|
||||
"@esbuild/openbsd-arm64": "0.28.0",
|
||||
"@esbuild/openbsd-x64": "0.28.0",
|
||||
"@esbuild/openharmony-arm64": "0.28.0",
|
||||
"@esbuild/sunos-x64": "0.28.0",
|
||||
"@esbuild/win32-arm64": "0.28.0",
|
||||
"@esbuild/win32-ia32": "0.28.0",
|
||||
"@esbuild/win32-x64": "0.28.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/vite": {
|
||||
"version": "8.0.16",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||
|
|
|
|||
|
|
@ -1653,474 +1653,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/@vitest/mocker": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
|
||||
|
|
@ -2148,50 +1680,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/esbuild": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.0",
|
||||
"@esbuild/android-arm": "0.28.0",
|
||||
"@esbuild/android-arm64": "0.28.0",
|
||||
"@esbuild/android-x64": "0.28.0",
|
||||
"@esbuild/darwin-arm64": "0.28.0",
|
||||
"@esbuild/darwin-x64": "0.28.0",
|
||||
"@esbuild/freebsd-arm64": "0.28.0",
|
||||
"@esbuild/freebsd-x64": "0.28.0",
|
||||
"@esbuild/linux-arm": "0.28.0",
|
||||
"@esbuild/linux-arm64": "0.28.0",
|
||||
"@esbuild/linux-ia32": "0.28.0",
|
||||
"@esbuild/linux-loong64": "0.28.0",
|
||||
"@esbuild/linux-mips64el": "0.28.0",
|
||||
"@esbuild/linux-ppc64": "0.28.0",
|
||||
"@esbuild/linux-riscv64": "0.28.0",
|
||||
"@esbuild/linux-s390x": "0.28.0",
|
||||
"@esbuild/linux-x64": "0.28.0",
|
||||
"@esbuild/netbsd-arm64": "0.28.0",
|
||||
"@esbuild/netbsd-x64": "0.28.0",
|
||||
"@esbuild/openbsd-arm64": "0.28.0",
|
||||
"@esbuild/openbsd-x64": "0.28.0",
|
||||
"@esbuild/openharmony-arm64": "0.28.0",
|
||||
"@esbuild/sunos-x64": "0.28.0",
|
||||
"@esbuild/win32-arm64": "0.28.0",
|
||||
"@esbuild/win32-ia32": "0.28.0",
|
||||
"@esbuild/win32-x64": "0.28.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/vite": {
|
||||
"version": "8.0.16",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||
|
|
|
|||
|
|
@ -399,7 +399,6 @@ export class LocalRestApiHandlerService {
|
|||
}
|
||||
|
||||
const taskId = this._taskService.add(title, false, additionalFields);
|
||||
|
||||
const createdTask = await this._getTaskById(taskId);
|
||||
|
||||
return createSuccessResponse(requestId, 201, createdTask);
|
||||
|
|
|
|||
|
|
@ -41,15 +41,12 @@ describe('ArchiveService timezone test', () => {
|
|||
|
||||
// In LA (UTC-8): 2025-01-16 at 11 PM -> '2025-01-16'
|
||||
// In Berlin (UTC+1): 2025-01-17 at 8 AM -> '2025-01-17'
|
||||
// Use the offset AT the test instant (January) — `new Date()` breaks
|
||||
// every summer in DST zones (New York: EST 300 in Jan vs EDT 240 in
|
||||
// June). 07:00 UTC is the previous local day only west of UTC-7.
|
||||
const tzOffset = new Date(testTime).getTimezoneOffset();
|
||||
if (tzOffset > 420) {
|
||||
// west of UTC-7 (e.g. LA)
|
||||
const tzOffset = new Date().getTimezoneOffset();
|
||||
if (tzOffset > 0) {
|
||||
// LA
|
||||
expect(todayStr).toBe('2025-01-16');
|
||||
} else {
|
||||
// east of UTC-7 (e.g. New York, Berlin)
|
||||
// Berlin
|
||||
expect(todayStr).toBe('2025-01-17');
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,15 +48,12 @@ describe('metric.util timezone test', () => {
|
|||
// The start should be the local date when the task was created
|
||||
// In LA (UTC-8): 2025-01-16 at 10 PM local -> '2025-01-16'
|
||||
// In Berlin (UTC+1): 2025-01-17 at 7 AM local -> '2025-01-17'
|
||||
// Use the offset AT the test instant (January) — `new Date()` breaks
|
||||
// every summer in DST zones. 06:00 UTC is the previous local day only
|
||||
// west of UTC-6.
|
||||
const tzOffset = new Date(taskCreatedTime).getTimezoneOffset();
|
||||
if (tzOffset > 360) {
|
||||
// west of UTC-6 (e.g. LA)
|
||||
const tzOffset = new Date().getTimezoneOffset();
|
||||
if (tzOffset > 0) {
|
||||
// LA
|
||||
expect(result.start).toBe('2025-01-16');
|
||||
} else {
|
||||
// east of UTC-6 (e.g. New York, Berlin)
|
||||
// Berlin
|
||||
expect(result.start).toBe('2025-01-17');
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,21 +30,14 @@ describe('buildRepeatQuickSettingOptions', () => {
|
|||
);
|
||||
expect(options.map((o) => o.value)).toEqual([
|
||||
'DAILY',
|
||||
'EVERY_OTHER_DAY',
|
||||
'MONDAY_TO_FRIDAY',
|
||||
'WEEKENDS',
|
||||
'WEEKLY_CURRENT_WEEKDAY',
|
||||
'BIWEEKLY_CURRENT_WEEKDAY',
|
||||
'MONTHLY_CURRENT_DATE',
|
||||
'MONTHLY_FIRST_DAY',
|
||||
'MONTHLY_LAST_DAY',
|
||||
'MONTHLY_NTH_WEEKDAY',
|
||||
'MONTHLY_LAST_WEEKDAY',
|
||||
'QUARTERLY_CURRENT_DATE',
|
||||
'SEMIANNUALLY_CURRENT_DATE',
|
||||
'YEARLY_CURRENT_DATE',
|
||||
'EVERY_OTHER_YEAR_CURRENT_DATE',
|
||||
'RRULE',
|
||||
'CUSTOM',
|
||||
]);
|
||||
|
||||
const monthlyNthWeekdayOption = options.find(
|
||||
|
|
@ -71,7 +64,7 @@ describe('buildRepeatQuickSettingOptions', () => {
|
|||
'en-US',
|
||||
translateService,
|
||||
);
|
||||
expect(options.length).toBe(16);
|
||||
expect(options.length).toBe(9);
|
||||
options.forEach((o) => expect(o.label).toBeTruthy());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,30 +34,16 @@ export const buildRepeatQuickSettingOptions = (
|
|||
value: 'DAILY',
|
||||
label: translateService.instant(T.F.TASK_REPEAT.F.Q_DAILY),
|
||||
},
|
||||
{
|
||||
value: 'EVERY_OTHER_DAY',
|
||||
label: translateService.instant(T.F.TASK_REPEAT.F.Q_EVERY_OTHER_DAY),
|
||||
},
|
||||
{
|
||||
value: 'MONDAY_TO_FRIDAY',
|
||||
label: translateService.instant(T.F.TASK_REPEAT.F.Q_MONDAY_TO_FRIDAY),
|
||||
},
|
||||
{
|
||||
value: 'WEEKENDS',
|
||||
label: translateService.instant(T.F.TASK_REPEAT.F.Q_WEEKENDS),
|
||||
},
|
||||
{
|
||||
value: 'WEEKLY_CURRENT_WEEKDAY',
|
||||
label: translateService.instant(T.F.TASK_REPEAT.F.Q_WEEKLY_CURRENT_WEEKDAY, {
|
||||
weekdayStr: refWeekdayStr,
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'BIWEEKLY_CURRENT_WEEKDAY',
|
||||
label: translateService.instant(T.F.TASK_REPEAT.F.Q_BIWEEKLY_CURRENT_WEEKDAY, {
|
||||
weekdayStr: refWeekdayStr,
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'MONTHLY_CURRENT_DATE',
|
||||
label: translateService.instant(T.F.TASK_REPEAT.F.Q_MONTHLY_CURRENT_DATE, {
|
||||
|
|
@ -79,24 +65,6 @@ export const buildRepeatQuickSettingOptions = (
|
|||
weekdayStr: refWeekdayStr,
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'MONTHLY_LAST_WEEKDAY',
|
||||
label: translateService.instant(T.F.TASK_REPEAT.F.Q_MONTHLY_LAST_WEEKDAY, {
|
||||
weekdayStr: refWeekdayStr,
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'QUARTERLY_CURRENT_DATE',
|
||||
label: translateService.instant(T.F.TASK_REPEAT.F.Q_QUARTERLY_CURRENT_DATE, {
|
||||
dateDayStr: refDayStr,
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'SEMIANNUALLY_CURRENT_DATE',
|
||||
label: translateService.instant(T.F.TASK_REPEAT.F.Q_SEMIANNUALLY_CURRENT_DATE, {
|
||||
dateDayStr: refDayStr,
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'YEARLY_CURRENT_DATE',
|
||||
label: translateService.instant(T.F.TASK_REPEAT.F.Q_YEARLY_CURRENT_DATE, {
|
||||
|
|
@ -104,14 +72,8 @@ export const buildRepeatQuickSettingOptions = (
|
|||
}),
|
||||
},
|
||||
{
|
||||
value: 'EVERY_OTHER_YEAR_CURRENT_DATE',
|
||||
label: translateService.instant(T.F.TASK_REPEAT.F.Q_EVERY_OTHER_YEAR_CURRENT_DATE, {
|
||||
dayAndMonthStr: refDayAndMonthStr,
|
||||
}),
|
||||
},
|
||||
{
|
||||
value: 'RRULE',
|
||||
label: translateService.instant(T.F.TASK_REPEAT.F.Q_RRULE),
|
||||
value: 'CUSTOM',
|
||||
label: translateService.instant(T.F.TASK_REPEAT.F.Q_CUSTOM),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -32,14 +32,14 @@
|
|||
[model]="repeatCfg()"
|
||||
></formly-form>
|
||||
|
||||
@if (isRRuleMode()) {
|
||||
<rrule-builder
|
||||
[rrule]="repeatCfg().rrule ?? ''"
|
||||
[startDate]="repeatCfg().startDate"
|
||||
[repeatFromCompletion]="repeatCfg().repeatFromCompletionDate ?? false"
|
||||
(rruleChange)="onRRuleChange($event)"
|
||||
(repeatFromCompletionChange)="onRepeatFromCompletionChange($event)"
|
||||
></rrule-builder>
|
||||
@if (isWeekdaySelectionInvalid()) {
|
||||
<div
|
||||
id="weekday-error"
|
||||
class="weekday-error"
|
||||
role="alert"
|
||||
>
|
||||
{{ T.F.TASK_REPEAT.F.WEEKDAY_REQUIRED | translate }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<collapsible [title]="T.F.TASK_REPEAT.D_EDIT.ADVANCED_CFG | translate">
|
||||
|
|
@ -62,32 +62,6 @@
|
|||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<!-- Live result/preview — pinned above the action buttons in RRULE mode. -->
|
||||
@if (isRRuleMode() && rrulePreview(); as p) {
|
||||
<div class="rrule-result">
|
||||
<div class="rrule-result__human">→ {{ p.human }}</div>
|
||||
@if (repeatCfg().repeatFromCompletionDate) {
|
||||
@if (p.completionExample; as ex) {
|
||||
<div class="rrule-result__next">
|
||||
{{ T.F.TASK_REPEAT.F.RRULE_NEXT_AFTER_COMPLETION | translate }}:
|
||||
{{ ex.done | date: 'mediumDate' }} → {{ ex.next | date: 'mediumDate' }}
|
||||
</div>
|
||||
}
|
||||
} @else if (p.upcoming.length) {
|
||||
<div class="rrule-result__next">
|
||||
{{ T.F.TASK_REPEAT.F.RRULE_NEXT | translate }}:
|
||||
@for (d of p.upcoming; track d.getTime(); let last = $last) {
|
||||
<span>{{ d | date: 'mediumDate' }}</span>
|
||||
@if (!last) {
|
||||
<span class="rrule-result__sep">·</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="rrule-result__expr">{{ p.rrule }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button
|
||||
(click)="close()"
|
||||
|
|
@ -129,8 +103,14 @@
|
|||
}
|
||||
|
||||
<button
|
||||
[disabled]="!formGroup1().valid || !formGroup2().valid || isLoading()"
|
||||
[disabled]="
|
||||
!formGroup1().valid ||
|
||||
!formGroup2().valid ||
|
||||
isLoading() ||
|
||||
isWeekdaySelectionInvalid()
|
||||
"
|
||||
[attr.aria-label]="T.G.SAVE | translate"
|
||||
[attr.aria-describedby]="isWeekdaySelectionInvalid() ? 'weekday-error' : null"
|
||||
class="icon-on-small"
|
||||
color="primary"
|
||||
mat-raised-button
|
||||
|
|
|
|||
|
|
@ -2,23 +2,6 @@ h1 mat-icon {
|
|||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
// Bound the whole dialog to the viewport and lay it out as a flex column so
|
||||
// the scrollable form and the pinned result band share the available height
|
||||
// instead of overflowing past the action buttons.
|
||||
.dialog-help-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 85vh;
|
||||
}
|
||||
|
||||
mat-dialog-content {
|
||||
flex: 1 1 auto;
|
||||
// Let the flex parent govern height instead of Material's fixed 65vh so the
|
||||
// form region shrinks to make room for the result band.
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
h1,
|
||||
mat-dialog-actions {
|
||||
:host-context([dir='rtl']) & {
|
||||
|
|
@ -58,37 +41,6 @@ mat-dialog-actions {
|
|||
max-width: 620px;
|
||||
}
|
||||
|
||||
// Live RRULE result/preview, pinned above the dialog action buttons.
|
||||
.rrule-result {
|
||||
margin: var(--s) 0;
|
||||
padding: var(--s) var(--s2);
|
||||
border-radius: var(--card-border-radius);
|
||||
background: var(--extra-border-color);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
|
||||
&__human {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&__next {
|
||||
margin-top: var(--s-quarter, 2px);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&__sep {
|
||||
margin: 0 var(--s-quarter, 4px);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&__expr {
|
||||
margin-top: var(--s-quarter, 2px);
|
||||
font-family: monospace;
|
||||
opacity: 0.7;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
:host collapsible {
|
||||
margin-top: var(--s2);
|
||||
margin-bottom: var(--s2);
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ 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 { SnackService } from '../../../core/snack/snack.service';
|
||||
|
||||
describe('DialogEditTaskRepeatCfgComponent', () => {
|
||||
let mockDialogRef: jasmine.SpyObj<MatDialogRef<DialogEditTaskRepeatCfgComponent>>;
|
||||
|
|
@ -111,7 +110,6 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
|
|||
{ provide: GlobalConfigService, useValue: mockGlobalConfigService },
|
||||
{ provide: DateTimeFormatService, useValue: mockDateTimeFormatService },
|
||||
{ provide: DateAdapter, useClass: CustomDateAdapter },
|
||||
{ provide: SnackService, useValue: { open: () => undefined } },
|
||||
],
|
||||
})
|
||||
.overrideComponent(DialogEditTaskRepeatCfgComponent, {
|
||||
|
|
@ -382,7 +380,7 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
|
|||
expect(component.repeatCfg().quickSetting).toBe('WEEKLY_CURRENT_WEEKDAY');
|
||||
});
|
||||
|
||||
it('migrates a startDate-less legacy cfg to RRULE (Custom UI removed)', async () => {
|
||||
it('should still fall back to CUSTOM when startDate is missing', async () => {
|
||||
const cfgNoDate: TaskRepeatCfg = {
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
id: 'repeat-cfg-nodate',
|
||||
|
|
@ -395,33 +393,7 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
|
|||
const fixture = await setupTestBed({ repeatCfg: cfgNoDate });
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
expect(component.repeatCfg().quickSetting).toBe('RRULE');
|
||||
expect(component.repeatCfg().rrule).toContain('FREQ=MONTHLY');
|
||||
});
|
||||
|
||||
it('migrates a legacy CUSTOM cfg to an equivalent RRULE on open', async () => {
|
||||
const customCfg: TaskRepeatCfg = {
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
id: 'repeat-cfg-custom',
|
||||
title: 'Legacy custom',
|
||||
quickSetting: 'CUSTOM',
|
||||
startDate: '2024-06-03',
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 2,
|
||||
monday: true,
|
||||
wednesday: true,
|
||||
tuesday: false,
|
||||
thursday: false,
|
||||
friday: false,
|
||||
saturday: false,
|
||||
sunday: false,
|
||||
};
|
||||
|
||||
const fixture = await setupTestBed({ repeatCfg: customCfg });
|
||||
const cfg = fixture.componentInstance.repeatCfg();
|
||||
|
||||
expect(cfg.quickSetting).toBe('RRULE');
|
||||
expect(cfg.rrule).toBe('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE');
|
||||
expect(component.repeatCfg().quickSetting).toBe('CUSTOM');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -452,15 +424,6 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
|
|||
});
|
||||
expect(normalized.monthlyWeekOfMonth).toBeUndefined();
|
||||
});
|
||||
|
||||
it('converts the null sentinel for RRULE cfgs too (null is not master-safe)', async () => {
|
||||
const fixture = await setupTestBed({ task: mockTask });
|
||||
const normalized = (fixture.componentInstance as any)._normalizeMonthlyAnchor({
|
||||
quickSetting: 'RRULE',
|
||||
monthlyWeekOfMonth: null,
|
||||
});
|
||||
expect(normalized.monthlyWeekOfMonth).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startDate min floor (#7768 Bug 4)', () => {
|
||||
|
|
@ -573,474 +536,86 @@ describe('DialogEditTaskRepeatCfgComponent', () => {
|
|||
}));
|
||||
});
|
||||
|
||||
describe('RRULE builder mode', () => {
|
||||
it('_processQuickSettingForDate forces RRULE mode for a non-preset rrule cfg', async () => {
|
||||
describe('isWeekdaySelectionInvalid (issue #8025)', () => {
|
||||
const baseCfg = {
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
monday: false,
|
||||
tuesday: false,
|
||||
wednesday: false,
|
||||
thursday: false,
|
||||
friday: false,
|
||||
saturday: false,
|
||||
sunday: false,
|
||||
};
|
||||
|
||||
it('should be true for a CUSTOM weekly config with no weekday selected', async () => {
|
||||
const fixture = await setupTestBed({ task: mockTask });
|
||||
const component = fixture.componentInstance as any;
|
||||
const out = component._processQuickSettingForDate({
|
||||
rrule: 'FREQ=DAILY',
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
component.repeatCfg.set({
|
||||
...baseCfg,
|
||||
quickSetting: 'CUSTOM',
|
||||
startDate: '2024-06-01',
|
||||
});
|
||||
expect(out.quickSetting).toBe('RRULE');
|
||||
});
|
||||
|
||||
it('_processQuickSettingForDate keeps the preset label for a faithful rrule preset', async () => {
|
||||
const fixture = await setupTestBed({ task: mockTask });
|
||||
const component = fixture.componentInstance as any;
|
||||
// rrule matches what the DAILY preset produces → stays a labelled preset.
|
||||
const out = component._processQuickSettingForDate({
|
||||
rrule: 'FREQ=DAILY',
|
||||
quickSetting: 'DAILY',
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2024-06-01',
|
||||
});
|
||||
expect(out.quickSetting).toBe('DAILY');
|
||||
});
|
||||
|
||||
it('_processQuickSettingForDate opens the builder when the rrule diverges from its preset label', async () => {
|
||||
const fixture = await setupTestBed({ task: mockTask });
|
||||
const component = fixture.componentInstance as any;
|
||||
// Labelled DAILY but the rule is biweekly Monday → not a faithful preset.
|
||||
const out = component._processQuickSettingForDate({
|
||||
rrule: 'FREQ=WEEKLY;INTERVAL=2;BYDAY=MO',
|
||||
quickSetting: 'DAILY',
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2024-06-03',
|
||||
});
|
||||
expect(out.quickSetting).toBe('RRULE');
|
||||
});
|
||||
|
||||
it('editing an rrule cfg opens in RRULE mode with the rule preserved', async () => {
|
||||
const rruleCfg: TaskRepeatCfg = {
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
id: 'rr-cfg',
|
||||
title: 'Biweekly',
|
||||
startDate: '2024-06-03',
|
||||
repeatCycle: 'WEEKLY',
|
||||
rrule: 'FREQ=WEEKLY;INTERVAL=2;BYDAY=MO',
|
||||
};
|
||||
const fixture = await setupTestBed({ repeatCfg: rruleCfg });
|
||||
const cfg = fixture.componentInstance.repeatCfg();
|
||||
expect(cfg.quickSetting).toBe('RRULE');
|
||||
expect(cfg.rrule).toBe('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO');
|
||||
});
|
||||
|
||||
expect(component.isWeekdaySelectionInvalid()).toBe(true);
|
||||
});
|
||||
|
||||
it('onRRuleChange stores the rule and derives the legacy repeatCycle', async () => {
|
||||
it('should be false once at least one weekday is selected', async () => {
|
||||
const fixture = await setupTestBed({ task: mockTask });
|
||||
const component = fixture.componentInstance;
|
||||
component.onRRuleChange('FREQ=MONTHLY;BYMONTHDAY=15');
|
||||
const cfg = component.repeatCfg();
|
||||
expect(cfg.rrule).toBe('FREQ=MONTHLY;BYMONTHDAY=15');
|
||||
expect(cfg.repeatCycle).toBe('MONTHLY');
|
||||
|
||||
component.repeatCfg.set({
|
||||
...baseCfg,
|
||||
quickSetting: 'CUSTOM',
|
||||
repeatCycle: 'WEEKLY',
|
||||
wednesday: true,
|
||||
});
|
||||
|
||||
expect(component.isWeekdaySelectionInvalid()).toBe(false);
|
||||
});
|
||||
|
||||
it('result preview reflects onRRuleChange freq/interval updates', async () => {
|
||||
it('should be false for non-weekly cycles even with no weekday selected', async () => {
|
||||
const fixture = await setupTestBed({ task: mockTask });
|
||||
const component = fixture.componentInstance;
|
||||
component.repeatCfg.update((c) => ({ ...c, quickSetting: 'RRULE' }) as any);
|
||||
component.onRRuleChange('FREQ=MONTHLY;BYMONTHDAY=15');
|
||||
expect(component.rrulePreview()?.rrule).toBe('FREQ=MONTHLY;BYMONTHDAY=15');
|
||||
component.onRRuleChange('FREQ=YEARLY;INTERVAL=2;BYMONTHDAY=15');
|
||||
expect(component.rrulePreview()?.rrule).toBe(
|
||||
'FREQ=YEARLY;INTERVAL=2;BYMONTHDAY=15',
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes a live rrule result/preview in RRULE mode (dialog-level)', async () => {
|
||||
const fixture = await setupTestBed({ task: mockTask });
|
||||
const component = fixture.componentInstance;
|
||||
component.repeatCfg.update(
|
||||
(c) =>
|
||||
({
|
||||
...c,
|
||||
quickSetting: 'RRULE',
|
||||
rrule: 'FREQ=WEEKLY;BYDAY=MO',
|
||||
}) as any,
|
||||
);
|
||||
expect(component.rrulePreview()?.human.toLowerCase()).toContain('week');
|
||||
expect(component.rrulePreview()?.rrule).toBe('FREQ=WEEKLY;BYDAY=MO');
|
||||
});
|
||||
|
||||
it('save() persists the rrule the builder produced', async () => {
|
||||
const fixture = await setupTestBed({ task: mockTask });
|
||||
const component = fixture.componentInstance;
|
||||
component.repeatCfg.update((c) => ({ ...c, quickSetting: 'RRULE' }) as any);
|
||||
component.onRRuleChange('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE');
|
||||
component.save();
|
||||
expect(mockTaskRepeatCfgService.addTaskRepeatCfgToTask).toHaveBeenCalled();
|
||||
const savedCfg = mockTaskRepeatCfgService.addTaskRepeatCfgToTask.calls.mostRecent()
|
||||
.args[2] as any;
|
||||
expect(savedCfg.rrule).toBe('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE');
|
||||
expect(savedCfg.repeatCycle).toBe('WEEKLY');
|
||||
});
|
||||
|
||||
it('onRRuleChange clears stale monthly anchors from a previous rule', async () => {
|
||||
const fixture = await setupTestBed({ task: mockTask });
|
||||
const component = fixture.componentInstance;
|
||||
// Simulate a leftover nth-weekday anchor (e.g. from MONTHLY_NTH_WEEKDAY).
|
||||
component.repeatCfg.update(
|
||||
(c) => ({ ...c, monthlyWeekOfMonth: 2, monthlyWeekday: 2 }) as any,
|
||||
);
|
||||
component.onRRuleChange('FREQ=MONTHLY;BYMONTHDAY=15');
|
||||
const cfg = component.repeatCfg() as any;
|
||||
// undefined (not null!) — released clients' typia schema allows the
|
||||
// anchors only absent-or-numeric, so null must never be persisted.
|
||||
expect(cfg.monthlyWeekOfMonth).toBeUndefined();
|
||||
expect(cfg.monthlyWeekday).toBeUndefined();
|
||||
});
|
||||
|
||||
it('onRRuleChange does NOT touch startDate (alignment happens at save only)', async () => {
|
||||
const fixture = await setupTestBed({ task: mockTask });
|
||||
const component = fixture.componentInstance;
|
||||
component.repeatCfg.update((c) => ({ ...c, startDate: '2024-06-03' }) as any);
|
||||
component.onRRuleChange('FREQ=MONTHLY;BYMONTHDAY=15');
|
||||
// Aligning live would silently rewrite the visible start-date field on
|
||||
// every builder interaction.
|
||||
expect(component.repeatCfg().startDate).toBe('2024-06-03');
|
||||
});
|
||||
|
||||
it('save() aligns startDate onto the rule day for a new date-anchored cfg', async () => {
|
||||
const fixture = await setupTestBed({ task: mockTask });
|
||||
const component = fixture.componentInstance;
|
||||
component.repeatCfg.update(
|
||||
(c) => ({ ...c, quickSetting: 'RRULE', startDate: '2024-06-03' }) as any,
|
||||
);
|
||||
component.onRRuleChange('FREQ=MONTHLY;BYMONTHDAY=15');
|
||||
component.save();
|
||||
const savedCfg = mockTaskRepeatCfgService.addTaskRepeatCfgToTask.calls.mostRecent()
|
||||
.args[2] as any;
|
||||
// Old clients read the monthly day from startDate — must sit on the 15th.
|
||||
expect(savedCfg.startDate).toBe('2024-06-15');
|
||||
});
|
||||
|
||||
it('save() keeps monthlyLastDay as the old-client fallback for BYMONTHDAY=-1', async () => {
|
||||
const fixture = await setupTestBed({ task: mockTask });
|
||||
const component = fixture.componentInstance;
|
||||
component.repeatCfg.update((c) => ({ ...c, quickSetting: 'RRULE' }) as any);
|
||||
component.onRRuleChange('FREQ=MONTHLY;BYMONTHDAY=-1');
|
||||
component.save();
|
||||
const savedCfg = mockTaskRepeatCfgService.addTaskRepeatCfgToTask.calls.mostRecent()
|
||||
.args[2] as any;
|
||||
expect(savedCfg.rrule).toBe('FREQ=MONTHLY;BYMONTHDAY=-1');
|
||||
// Regression: _normalizeMonthlyAnchor used to strip this for RRULE saves,
|
||||
// losing month-end semantics on old clients.
|
||||
expect(savedCfg.monthlyLastDay).toBe(true);
|
||||
});
|
||||
|
||||
it('save() realigns startDate edited after the last builder change', async () => {
|
||||
const fixture = await setupTestBed({ task: mockTask });
|
||||
const component = fixture.componentInstance;
|
||||
component.repeatCfg.update((c) => ({ ...c, quickSetting: 'RRULE' }) as any);
|
||||
component.onRRuleChange('FREQ=MONTHLY;BYMONTHDAY=15');
|
||||
// User edits the start date afterwards — off-occurrence again.
|
||||
component.repeatCfg.update((c) => ({ ...c, startDate: '2024-07-03' }) as any);
|
||||
component.save();
|
||||
const savedCfg = mockTaskRepeatCfgService.addTaskRepeatCfgToTask.calls.mostRecent()
|
||||
.args[2] as any;
|
||||
expect(savedCfg.startDate).toBe('2024-07-15');
|
||||
});
|
||||
|
||||
it('save() re-derives the legacy weekday fallback when startDate changed after the last builder emit', async () => {
|
||||
const fixture = await setupTestBed({ task: mockTask });
|
||||
const component = fixture.componentInstance;
|
||||
component.repeatCfg.update(
|
||||
(c) => ({ ...c, quickSetting: 'RRULE', startDate: '2024-06-12' }) as any, // a Wednesday
|
||||
);
|
||||
// BYDAY-less weekly rule — the legacy fallback maps onto the start weekday.
|
||||
component.onRRuleChange('FREQ=WEEKLY');
|
||||
// User edits the start date afterwards; no alignment applies for weekly,
|
||||
// but the stale Wednesday flag must still be re-derived to Thursday —
|
||||
// else old clients fire on a different weekday than the saved dtstart.
|
||||
component.repeatCfg.update((c) => ({ ...c, startDate: '2024-06-13' }) as any); // a Thursday
|
||||
component.save();
|
||||
const savedCfg = mockTaskRepeatCfgService.addTaskRepeatCfgToTask.calls.mostRecent()
|
||||
.args[2] as any;
|
||||
expect(savedCfg.startDate).toBe('2024-06-13');
|
||||
expect(savedCfg.thursday).toBe(true);
|
||||
expect(savedCfg.wednesday).toBe(false);
|
||||
});
|
||||
|
||||
it('save() does NOT realign startDate on an edit when the schedule was not touched', async () => {
|
||||
// Regression (#7373 class): a stored cfg whose startDate is off-occurrence
|
||||
// (imported / pre-alignment) must not get a startDate change — a
|
||||
// SCHEDULE_AFFECTING_FIELD — injected by save() when the user only edited
|
||||
// the title; that would reschedule today's live instance.
|
||||
const storedCfg: TaskRepeatCfg = {
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
id: 'rr-unaligned',
|
||||
title: 'Unaligned',
|
||||
startDate: '2024-06-03',
|
||||
component.repeatCfg.set({
|
||||
...baseCfg,
|
||||
quickSetting: 'CUSTOM',
|
||||
repeatCycle: 'MONTHLY',
|
||||
quickSetting: 'RRULE' as any,
|
||||
rrule: 'FREQ=MONTHLY;BYMONTHDAY=15',
|
||||
};
|
||||
const fixture = await setupTestBed({ repeatCfg: storedCfg });
|
||||
const component = fixture.componentInstance;
|
||||
component.repeatCfg.update((c) => ({ ...c, title: 'Renamed' }) as any);
|
||||
component.save();
|
||||
expect(mockTaskRepeatCfgService.updateTaskRepeatCfg).toHaveBeenCalled();
|
||||
const changes = mockTaskRepeatCfgService.updateTaskRepeatCfg.calls.mostRecent()
|
||||
.args[1] as any;
|
||||
expect(changes.title).toBe('Renamed');
|
||||
expect('startDate' in changes).toBe(false);
|
||||
});
|
||||
|
||||
expect(component.isWeekdaySelectionInvalid()).toBe(false);
|
||||
});
|
||||
|
||||
it('save() blocks a parseable rule that can never produce an occurrence', async () => {
|
||||
// FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=30 parses fine (and isRRuleValid is
|
||||
// true) but Feb 30 never exists — persisting it would create a silently
|
||||
// dead recurrence with the legacy fallback bypassed.
|
||||
it('should be false for non-CUSTOM quick settings (e.g. DAILY)', async () => {
|
||||
const fixture = await setupTestBed({ task: mockTask });
|
||||
const component = fixture.componentInstance;
|
||||
component.repeatCfg.update(
|
||||
(c) => ({ ...c, quickSetting: 'RRULE', startDate: '2024-06-03' }) as any,
|
||||
);
|
||||
component.onRRuleChange('FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=30');
|
||||
component.save();
|
||||
expect(mockTaskRepeatCfgService.addTaskRepeatCfgToTask).not.toHaveBeenCalled();
|
||||
|
||||
component.repeatCfg.set({
|
||||
...baseCfg,
|
||||
quickSetting: 'DAILY',
|
||||
repeatCycle: 'WEEKLY',
|
||||
});
|
||||
|
||||
expect(component.isWeekdaySelectionInvalid()).toBe(false);
|
||||
});
|
||||
|
||||
it('save() blocks sub-daily frequencies (raw override FREQ=HOURLY)', async () => {
|
||||
// The engine is day-granular: a sub-daily rule would be accepted but
|
||||
// silently collapse to ~daily firing, and it has no legacy repeatCycle
|
||||
// for old clients (rruleToLegacyTaskRepeatCfg returns {}).
|
||||
it('should block direct save when a CUSTOM weekly config has no weekday selected', async () => {
|
||||
const fixture = await setupTestBed({ task: mockTask });
|
||||
const component = fixture.componentInstance;
|
||||
component.repeatCfg.update(
|
||||
(c) => ({ ...c, quickSetting: 'RRULE', startDate: '2024-06-03' }) as any,
|
||||
);
|
||||
component.onRRuleChange('FREQ=HOURLY');
|
||||
|
||||
component.repeatCfg.set({
|
||||
...baseCfg,
|
||||
quickSetting: 'CUSTOM',
|
||||
repeatCycle: 'WEEKLY',
|
||||
});
|
||||
|
||||
component.save();
|
||||
|
||||
expect(mockTaskRepeatCfgService.addTaskRepeatCfgToTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('save() blocks COUNT combined with repeat-from-completion (count never finishes)', async () => {
|
||||
// Completing an instance re-anchors startDate + lastTaskCreationDay to
|
||||
// the completion day, which restarts the COUNT window — the series would
|
||||
// never terminate.
|
||||
const fixture = await setupTestBed({ task: mockTask });
|
||||
const component = fixture.componentInstance;
|
||||
component.repeatCfg.update(
|
||||
(c) => ({ ...c, quickSetting: 'RRULE', startDate: '2024-06-03' }) as any,
|
||||
);
|
||||
component.onRRuleChange('FREQ=DAILY;COUNT=5');
|
||||
component.onRepeatFromCompletionChange(true);
|
||||
component.save();
|
||||
expect(mockTaskRepeatCfgService.addTaskRepeatCfgToTask).not.toHaveBeenCalled();
|
||||
|
||||
// The same rule WITHOUT the completion anchor saves fine.
|
||||
component.onRepeatFromCompletionChange(false);
|
||||
component.save();
|
||||
expect(mockTaskRepeatCfgService.addTaskRepeatCfgToTask).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reopens a wire-clamped preset cfg under its preset label (not the raw builder)', async () => {
|
||||
// Non-master presets persist as quickSetting='CUSTOM' (sync clamp); the
|
||||
// rrule is the only thing identifying them on reopen — infer it back.
|
||||
const weekendsCfg: TaskRepeatCfg = {
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
id: 'rr-weekends',
|
||||
title: 'Weekends',
|
||||
quickSetting: 'CUSTOM',
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2024-06-08', // a Saturday
|
||||
monday: false,
|
||||
tuesday: false,
|
||||
wednesday: false,
|
||||
thursday: false,
|
||||
friday: false,
|
||||
saturday: true,
|
||||
sunday: true,
|
||||
rrule: 'FREQ=WEEKLY;BYDAY=SA,SU',
|
||||
};
|
||||
const fixture = await setupTestBed({ repeatCfg: weekendsCfg });
|
||||
expect(fixture.componentInstance.repeatCfg().quickSetting).toBe('WEEKENDS');
|
||||
});
|
||||
|
||||
it('opens completion-relative cfgs in builder mode even when the rrule matches a preset', async () => {
|
||||
// The schedule-type toggle ("from completion") only exists in the RRULE
|
||||
// builder — a preset label (e.g. EVERY_OTHER_DAY) would hide it.
|
||||
const completionCfg: TaskRepeatCfg = {
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
id: 'rr-completion',
|
||||
title: 'Every other day after done',
|
||||
quickSetting: 'CUSTOM',
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 2,
|
||||
repeatFromCompletionDate: true,
|
||||
startDate: '2024-06-03',
|
||||
rrule: 'FREQ=DAILY;INTERVAL=2',
|
||||
};
|
||||
const fixture = await setupTestBed({ repeatCfg: completionCfg });
|
||||
expect(fixture.componentInstance.repeatCfg().quickSetting).toBe('RRULE');
|
||||
});
|
||||
|
||||
it('opens a completion-relative FAITHFUL preset cfg in builder mode too', async () => {
|
||||
const faithful: TaskRepeatCfg = {
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
id: 'rr-completion-2',
|
||||
title: 'Daily after done',
|
||||
quickSetting: 'DAILY',
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
repeatFromCompletionDate: true,
|
||||
startDate: '2024-06-03',
|
||||
rrule: 'FREQ=DAILY',
|
||||
};
|
||||
const fixture = await setupTestBed({ repeatCfg: faithful });
|
||||
expect(fixture.componentInstance.repeatCfg().quickSetting).toBe('RRULE');
|
||||
});
|
||||
|
||||
it('clears repeatFromCompletionDate when switching a completion-relative cfg to a preset', async () => {
|
||||
// The "from completion" toggle lives only in the RRULE builder, so picking
|
||||
// a preset (which hides it) must clear the flag — otherwise it persists
|
||||
// with no visible control and keeps firing relative to completion.
|
||||
const completionCfg: TaskRepeatCfg = {
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
id: 'rr-switch',
|
||||
title: 'After done daily',
|
||||
quickSetting: 'DAILY',
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
repeatFromCompletionDate: true,
|
||||
startDate: '2024-06-03',
|
||||
rrule: 'FREQ=DAILY',
|
||||
};
|
||||
const fixture = await setupTestBed({ repeatCfg: completionCfg });
|
||||
const component = fixture.componentInstance;
|
||||
// Completion-relative cfgs open in the builder regardless of preset match.
|
||||
expect(component.repeatCfg().quickSetting).toBe('RRULE');
|
||||
// User picks a plain preset, hiding the from-completion control.
|
||||
component.repeatCfg.update((c) => ({ ...c, quickSetting: 'DAILY' }) as any);
|
||||
component.save();
|
||||
expect(mockTaskRepeatCfgService.updateTaskRepeatCfg).toHaveBeenCalled();
|
||||
const changes = mockTaskRepeatCfgService.updateTaskRepeatCfg.calls.mostRecent()
|
||||
.args[1] as any;
|
||||
expect(changes.repeatFromCompletionDate).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT inject repeatFromCompletionDate on a preset save when it was never set', async () => {
|
||||
// Guards the conditional clear: an untouched (falsy) flag must stay out of
|
||||
// the change set so a title-only preset save stays an empty-diff no-op
|
||||
// (#7373 class) instead of dispatching a spurious undefined→false op.
|
||||
const presetCfg: TaskRepeatCfg = {
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
id: 'rr-preset-plain',
|
||||
title: 'Plain daily',
|
||||
quickSetting: 'DAILY',
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2024-06-03',
|
||||
rrule: 'FREQ=DAILY',
|
||||
};
|
||||
const fixture = await setupTestBed({ repeatCfg: presetCfg });
|
||||
const component = fixture.componentInstance;
|
||||
component.repeatCfg.update((c) => ({ ...c, title: 'Renamed' }) as any);
|
||||
component.save();
|
||||
const changes = mockTaskRepeatCfgService.updateTaskRepeatCfg.calls.mostRecent()
|
||||
.args[1] as any;
|
||||
expect(changes.title).toBe('Renamed');
|
||||
expect('repeatFromCompletionDate' in changes).toBe(false);
|
||||
});
|
||||
|
||||
it('still opens the builder for a CUSTOM cfg whose rrule matches no preset', async () => {
|
||||
const handBuilt: TaskRepeatCfg = {
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
id: 'rr-hand',
|
||||
title: 'Hand built',
|
||||
quickSetting: 'CUSTOM',
|
||||
repeatCycle: 'WEEKLY',
|
||||
startDate: '2024-06-03',
|
||||
rrule: 'FREQ=WEEKLY;INTERVAL=3;BYDAY=MO,FR',
|
||||
};
|
||||
const fixture = await setupTestBed({ repeatCfg: handBuilt });
|
||||
expect(fixture.componentInstance.repeatCfg().quickSetting).toBe('RRULE');
|
||||
});
|
||||
|
||||
it('does NOT persist the lazy rrule migration on a title-only edit (no reschedule)', async () => {
|
||||
// Opening a legacy CUSTOM cfg migrates it to rrule IN MEMORY. `rrule` is
|
||||
// a SCHEDULE_AFFECTING_FIELD — leaking it into the change set of an
|
||||
// unrelated edit would relocate today's live instance (#7373 class).
|
||||
const legacyCfg: TaskRepeatCfg = {
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
id: 'legacy-migrate',
|
||||
title: 'Old custom',
|
||||
quickSetting: 'CUSTOM',
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 2,
|
||||
startDate: '2024-06-03',
|
||||
monday: true,
|
||||
tuesday: false,
|
||||
wednesday: false,
|
||||
thursday: false,
|
||||
friday: false,
|
||||
saturday: false,
|
||||
sunday: false,
|
||||
};
|
||||
const fixture = await setupTestBed({ repeatCfg: legacyCfg });
|
||||
const component = fixture.componentInstance;
|
||||
// Migrated in-memory:
|
||||
expect(component.repeatCfg().rrule).toBe('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO');
|
||||
component.repeatCfg.update((c) => ({ ...c, title: 'Renamed' }) as any);
|
||||
component.save();
|
||||
const changes = mockTaskRepeatCfgService.updateTaskRepeatCfg.calls.mostRecent()
|
||||
.args[1] as any;
|
||||
expect(changes.title).toBe('Renamed');
|
||||
expect('rrule' in changes).toBe(false);
|
||||
expect('quickSetting' in changes).toBe(false);
|
||||
expect('startDate' in changes).toBe(false);
|
||||
});
|
||||
|
||||
it('skips the update dispatch entirely when nothing changed', async () => {
|
||||
const storedCfg: TaskRepeatCfg = {
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
id: 'rr-noop',
|
||||
title: 'Unchanged',
|
||||
startDate: '2024-06-03',
|
||||
repeatCycle: 'WEEKLY',
|
||||
quickSetting: 'CUSTOM',
|
||||
rrule: 'FREQ=WEEKLY;INTERVAL=3;BYDAY=MO',
|
||||
monday: true,
|
||||
tuesday: false,
|
||||
wednesday: false,
|
||||
thursday: false,
|
||||
friday: false,
|
||||
saturday: false,
|
||||
sunday: false,
|
||||
};
|
||||
const fixture = await setupTestBed({ repeatCfg: storedCfg });
|
||||
fixture.componentInstance.save();
|
||||
expect(mockTaskRepeatCfgService.updateTaskRepeatCfg).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('save() blocks when the rrule is missing/invalid in RRULE mode', async () => {
|
||||
const fixture = await setupTestBed({ task: mockTask });
|
||||
const component = fixture.componentInstance;
|
||||
component.repeatCfg.update(
|
||||
(c) => ({ ...c, quickSetting: 'RRULE', rrule: undefined }) as any,
|
||||
);
|
||||
component.save();
|
||||
expect(mockTaskRepeatCfgService.addTaskRepeatCfgToTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('save() replaces a stale builder rrule with the preset canonical rule (presets stay rrule-backed)', async () => {
|
||||
// Switching from builder mode to a preset must NOT strip the rrule —
|
||||
// getQuickSettingUpdates overwrites it with the preset's canonical rule.
|
||||
// (Clearing via `rrule: undefined` would also be dropped by the JSON
|
||||
// wire, leaving remote clients on the old rule.)
|
||||
const fixture = await setupTestBed({ task: mockTask });
|
||||
const component = fixture.componentInstance;
|
||||
component.repeatCfg.update(
|
||||
(c) => ({ ...c, quickSetting: 'DAILY', rrule: 'FREQ=WEEKLY' }) as any,
|
||||
);
|
||||
component.save();
|
||||
const savedCfg = mockTaskRepeatCfgService.addTaskRepeatCfgToTask.calls.mostRecent()
|
||||
.args[2] as any;
|
||||
expect(savedCfg.rrule).toBe('FREQ=DAILY');
|
||||
expect(savedCfg.repeatCycle).toBe('DAILY');
|
||||
expect(mockDialogRef.close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,10 +18,8 @@ import { MatDialog } from '@angular/material/dialog';
|
|||
import { TaskRepeatCfgService } from '../task-repeat-cfg.service';
|
||||
import {
|
||||
DEFAULT_TASK_REPEAT_CFG,
|
||||
QUICK_SETTING_PRESETS,
|
||||
TaskRepeatCfg,
|
||||
TaskRepeatCfgCopy,
|
||||
toSyncSafeQuickSetting,
|
||||
} from '../task-repeat-cfg.model';
|
||||
import { FormlyFieldConfig, FormlyModule } from '@ngx-formly/core';
|
||||
import { UntypedFormGroup } from '@angular/forms';
|
||||
|
|
@ -41,17 +39,6 @@ import { dateStrToUtcDate } from '../../../util/date-str-to-utc-date';
|
|||
import { first } from 'rxjs/operators';
|
||||
import { getQuickSettingUpdates } from './get-quick-setting-updates';
|
||||
import { getTaskRepeatCfgChanges } from './get-task-repeat-cfg-changes';
|
||||
import { SnackService } from '../../../core/snack/snack.service';
|
||||
import { getFirstRRuleOccurrence, isRRuleValid } from '../store/rrule-occurrence.util';
|
||||
import { FREQ_TO_CYCLE, safeParseRRuleOptions } from '../util/rrule-parse.util';
|
||||
import {
|
||||
getAlignedStartDate,
|
||||
legacyTaskRepeatCfgToRRule,
|
||||
rruleToLegacyTaskRepeatCfg,
|
||||
} from '../util/legacy-cfg-to-rrule.util';
|
||||
import { RruleBuilderComponent } from './rrule-builder/rrule-builder.component';
|
||||
import { buildRRuleHumanizeOpts, getRRulePreview } from '../util/rrule-preview.util';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { clockStringFromDate } from '../../../ui/duration/clock-string-from-date';
|
||||
import { ChipListInputComponent } from '../../../ui/chip-list-input/chip-list-input.component';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
|
|
@ -77,10 +64,17 @@ const RELEVANT_KEYS_FOR_UPDATE_ALL_TASKS: (keyof TaskRepeatCfgCopy)[] = [
|
|||
'tagIds',
|
||||
];
|
||||
|
||||
// The RRULE builder is a dedicated child component (rrule-builder) that owns its
|
||||
// own form state and emits the assembled `rrule` string; the dialog only stores
|
||||
// that string on the working cfg.
|
||||
type RepeatCfgWorking = Omit<TaskRepeatCfgCopy, 'id'> | TaskRepeatCfg;
|
||||
// A CUSTOM weekly recurrence with no weekday checked never produces an
|
||||
// occurrence, so it must be blocked at save time (#8025).
|
||||
const WEEKDAY_KEYS: (keyof TaskRepeatCfgCopy)[] = [
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
'sunday',
|
||||
];
|
||||
|
||||
// TASK_REPEAT_CFG_FORM_CFG
|
||||
@Component({
|
||||
|
|
@ -99,8 +93,6 @@ type RepeatCfgWorking = Omit<TaskRepeatCfgCopy, 'id'> | TaskRepeatCfg;
|
|||
MatIcon,
|
||||
RepeatTaskHeatmapComponent,
|
||||
CollapsibleComponent,
|
||||
RruleBuilderComponent,
|
||||
DatePipe,
|
||||
],
|
||||
})
|
||||
export class DialogEditTaskRepeatCfgComponent {
|
||||
|
|
@ -112,22 +104,20 @@ export class DialogEditTaskRepeatCfgComponent {
|
|||
inject<MatDialogRef<DialogEditTaskRepeatCfgComponent>>(MatDialogRef);
|
||||
private _translateService = inject(TranslateService);
|
||||
private _dateTimeFormatService = inject(DateTimeFormatService);
|
||||
private _snackService = inject(SnackService);
|
||||
private _data = inject<{
|
||||
task?: Task;
|
||||
repeatCfg?: TaskRepeatCfg;
|
||||
targetDate?: string;
|
||||
defaultRemindOption?: TaskReminderOptionId;
|
||||
/** Preselect a quick setting for a NEW cfg — e.g. the add-task-bar's
|
||||
* "Custom recurring config" entry opens straight into the RRULE builder. */
|
||||
initialQuickSetting?: TaskRepeatCfgCopy['quickSetting'];
|
||||
}>(MAT_DIALOG_DATA);
|
||||
|
||||
T: typeof T = T;
|
||||
isHeatmapExpanded = false;
|
||||
|
||||
repeatCfgInitial = signal<TaskRepeatCfgCopy | undefined>(undefined);
|
||||
repeatCfg = signal<RepeatCfgWorking>(this._initializeRepeatCfg());
|
||||
repeatCfg = signal<Omit<TaskRepeatCfgCopy, 'id'> | TaskRepeatCfg>(
|
||||
this._initializeRepeatCfg(),
|
||||
);
|
||||
isLoading = signal<boolean>(false);
|
||||
isEdit = computed(() => {
|
||||
if (this._data.repeatCfg) return true;
|
||||
|
|
@ -135,6 +125,17 @@ export class DialogEditTaskRepeatCfgComponent {
|
|||
return false;
|
||||
});
|
||||
|
||||
// A CUSTOM weekly config with zero weekdays selected would never recur;
|
||||
// surface it as a blocking validation error (#8025). Derived from the
|
||||
// `repeatCfg` signal so it re-evaluates on every checkbox toggle.
|
||||
isWeekdaySelectionInvalid = computed(() => {
|
||||
const cfg = this.repeatCfg();
|
||||
if (cfg.quickSetting !== 'CUSTOM' || cfg.repeatCycle !== 'WEEKLY') {
|
||||
return false;
|
||||
}
|
||||
return !WEEKDAY_KEYS.some((day) => cfg[day]);
|
||||
});
|
||||
|
||||
repeatCfgId = computed(() => {
|
||||
const cfg = this.repeatCfg();
|
||||
if ('id' in cfg && cfg.id) {
|
||||
|
|
@ -149,30 +150,6 @@ export class DialogEditTaskRepeatCfgComponent {
|
|||
formGroup1 = signal(new UntypedFormGroup({}));
|
||||
formGroup2 = signal(new UntypedFormGroup({}));
|
||||
tagSuggestions = toSignal(this._tagService.tagsNoMyDayAndNoList$, { initialValue: [] });
|
||||
|
||||
// The RRULE builder (shown when quickSetting === 'RRULE') is a child component
|
||||
// with its own live preview; the dialog only needs to know when to render it.
|
||||
private _formValue = toSignal(this.formGroup1().valueChanges, {
|
||||
initialValue: null as { quickSetting?: string } | null,
|
||||
});
|
||||
isRRuleMode = computed(
|
||||
() => (this._formValue()?.quickSetting ?? this.repeatCfg().quickSetting) === 'RRULE',
|
||||
);
|
||||
// Live result/preview shown at the dialog bottom in RRULE mode. The builder
|
||||
// keeps `repeatCfg().rrule` up to date via onRRuleChange, so this stays live.
|
||||
private _humanize = buildRRuleHumanizeOpts(
|
||||
(k) => this._translateService.instant(k) as string,
|
||||
);
|
||||
rrulePreview = computed(() =>
|
||||
this.isRRuleMode()
|
||||
? getRRulePreview(
|
||||
this.repeatCfg().rrule,
|
||||
this.repeatCfg().startDate,
|
||||
this._humanize,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
|
||||
canRemoveInstance = signal<boolean>(false);
|
||||
skipInstanceButtonText = computed(() => {
|
||||
if (!this._data.targetDate) {
|
||||
|
|
@ -215,18 +192,13 @@ export class DialogEditTaskRepeatCfgComponent {
|
|||
});
|
||||
}
|
||||
|
||||
private _initializeRepeatCfg(): RepeatCfgWorking {
|
||||
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);
|
||||
|
||||
// Diff against the PROCESSED cfg, not the stored one: open-time
|
||||
// adjustments (lazy legacy→rrule migration, preset inference) must not
|
||||
// leak into the change set of an unrelated edit — `rrule` is a
|
||||
// SCHEDULE_AFFECTING_FIELD, so persisting it from a title-only save
|
||||
// would relocate today's live instance. The migration only persists
|
||||
// once the user actually touches the schedule.
|
||||
this.repeatCfgInitial.set({ ...processedCfg });
|
||||
// Set initial value for comparison
|
||||
this.repeatCfgInitial.set({ ...this._data.repeatCfg });
|
||||
return processedCfg;
|
||||
} else if (this._data.task) {
|
||||
const startTime = this._data.task.dueWithTime
|
||||
|
|
@ -234,9 +206,6 @@ export class DialogEditTaskRepeatCfgComponent {
|
|||
: undefined;
|
||||
return {
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
...(this._data.initialQuickSetting
|
||||
? { quickSetting: this._data.initialQuickSetting }
|
||||
: {}),
|
||||
startDate:
|
||||
this._data.task.dueDay ??
|
||||
getDbDateStr(this._data.task.dueWithTime || undefined),
|
||||
|
|
@ -257,28 +226,6 @@ export class DialogEditTaskRepeatCfgComponent {
|
|||
}
|
||||
}
|
||||
|
||||
/** The RRULE builder emits a new rule string; store it + keep repeatCycle in sync. */
|
||||
onRRuleChange(rrule: string): void {
|
||||
this.repeatCfg.update((cfg) => ({
|
||||
...cfg,
|
||||
rrule,
|
||||
// Keep the legacy schedule fields (cycle / interval / weekday flags /
|
||||
// monthly anchors) in sync so older sync clients — which ignore `rrule` —
|
||||
// fall back to a faithful recurrence. Pass startDate so a BYDAY-less
|
||||
// weekly rule maps onto the start weekday (else old clients never fire).
|
||||
// startDate alignment intentionally happens at SAVE only (see save()) —
|
||||
// doing it here would silently rewrite the visible start-date field on
|
||||
// every builder interaction.
|
||||
...rruleToLegacyTaskRepeatCfg(rrule, cfg.startDate),
|
||||
}));
|
||||
}
|
||||
|
||||
// Schedule-type toggle lives in the rrule-builder (RRULE mode). It's separate
|
||||
// from the rrule string — re-anchors the interval to the completion day.
|
||||
onRepeatFromCompletionChange(repeatFromCompletionDate: boolean): void {
|
||||
this.repeatCfg.update((cfg) => ({ ...cfg, repeatFromCompletionDate }));
|
||||
}
|
||||
|
||||
private _initializeFormConfig(): void {
|
||||
const _locale = this._dateTimeFormatService.currentLocale();
|
||||
const translateService = this._translateService;
|
||||
|
|
@ -368,10 +315,15 @@ export class DialogEditTaskRepeatCfgComponent {
|
|||
return;
|
||||
}
|
||||
|
||||
// Enter-key submit bypasses the disabled Save button, so re-check here (#8025).
|
||||
if (this.isWeekdaySelectionInvalid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRepeatCfg = this.repeatCfg();
|
||||
|
||||
// workaround for formly not always updating hidden fields correctly (in time??)
|
||||
if (currentRepeatCfg.quickSetting !== 'RRULE') {
|
||||
if (currentRepeatCfg.quickSetting !== 'CUSTOM') {
|
||||
// Pass startDate to use correct weekday for WEEKLY_CURRENT_WEEKDAY (fixes #5806)
|
||||
const referenceDate = currentRepeatCfg.startDate
|
||||
? dateStrToUtcDate(currentRepeatCfg.startDate)
|
||||
|
|
@ -381,117 +333,12 @@ export class DialogEditTaskRepeatCfgComponent {
|
|||
referenceDate,
|
||||
);
|
||||
if (updatesForQuickSetting) {
|
||||
this.repeatCfg.update((cfg) => ({
|
||||
...cfg,
|
||||
...updatesForQuickSetting,
|
||||
// A preset is always start-date-relative: the "from completion"
|
||||
// toggle lives ONLY inside the RRULE builder, which a preset hides.
|
||||
// So a stale `repeatFromCompletionDate` left over from a previous
|
||||
// RRULE cfg would persist with no visible control and silently keep
|
||||
// firing relative to completion. Clear it — but only when actually
|
||||
// set, so an untouched preset save stays an empty-diff no-op (#7373)
|
||||
// instead of dispatching a spurious undefined→false change.
|
||||
...(cfg.repeatFromCompletionDate ? { repeatFromCompletionDate: false } : {}),
|
||||
}));
|
||||
this.repeatCfg.update((cfg) => ({ ...cfg, ...updatesForQuickSetting }));
|
||||
}
|
||||
}
|
||||
|
||||
// RRULE mode: the rrule string is already on the cfg (kept live by the
|
||||
// rrule-builder child via onRRuleChange). Just guard it before persisting.
|
||||
const working = this.repeatCfg();
|
||||
if (working.quickSetting === 'RRULE') {
|
||||
if (!isRRuleValid(working.rrule)) {
|
||||
this._snackService.open({ type: 'ERROR', msg: T.F.TASK_REPEAT.F.RRULE_INVALID });
|
||||
formGroup1.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
const parsedRule = safeParseRRuleOptions(working.rrule);
|
||||
// The engine is day-granular (every occurrence resolves to local noon),
|
||||
// so a sub-daily FREQ (HOURLY/…, reachable via the raw override) would
|
||||
// be accepted but silently collapse to ~daily firing — and it has no
|
||||
// legacy repeatCycle equivalent for old clients. Reject until sub-daily
|
||||
// support actually exists.
|
||||
if (!parsedRule || FREQ_TO_CYCLE[parsedRule.freq] == null) {
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: T.F.TASK_REPEAT.F.RRULE_FREQ_UNSUPPORTED,
|
||||
});
|
||||
formGroup1.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
// COUNT has no stable origin together with "repeat from completion":
|
||||
// completing an instance re-anchors startDate AND lastTaskCreationDay to
|
||||
// the completion day (task-repeat-cfg.effects), which restarts the COUNT
|
||||
// window — the series would never terminate. Reject the combination.
|
||||
if (working.repeatFromCompletionDate && parsedRule.count != null) {
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: T.F.TASK_REPEAT.F.RRULE_COUNT_WITH_COMPLETION,
|
||||
});
|
||||
formGroup1.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
// Align startDate for date-anchored rules: old clients read the monthly
|
||||
// day (and yearly month+day) from startDate, so it must sit on the
|
||||
// rule's day. Done once at save — and ONLY when the schedule actually
|
||||
// changed in this dialog session: realigning an untouched stored cfg
|
||||
// would put startDate into the change diff, and startDate is a
|
||||
// SCHEDULE_AFFECTING_FIELD that makes rescheduleTaskOnRepeatCfgUpdate$
|
||||
// move today's live instance on an unrelated title/notes edit (#7373).
|
||||
const initialForAlign = this.repeatCfgInitial();
|
||||
const scheduleTouched =
|
||||
!this.isEdit() ||
|
||||
!initialForAlign ||
|
||||
initialForAlign.rrule !== working.rrule ||
|
||||
initialForAlign.startDate !== working.startDate;
|
||||
if (scheduleTouched && working.startDate) {
|
||||
const aligned = getAlignedStartDate(working.rrule as string, working.startDate);
|
||||
const finalStartDate = aligned ?? working.startDate;
|
||||
// A rule can parse fine yet match no real date (e.g. raw override
|
||||
// FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=30) — persisting it would create a
|
||||
// recurrence that silently never fires, with the legacy fallback
|
||||
// bypassed because the rule IS valid. Probe the first occurrence
|
||||
// against the startDate actually being persisted.
|
||||
if (
|
||||
!getFirstRRuleOccurrence({
|
||||
rrule: working.rrule as string,
|
||||
startDate: finalStartDate,
|
||||
})
|
||||
) {
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: T.F.TASK_REPEAT.F.RRULE_NO_OCCURRENCE,
|
||||
});
|
||||
formGroup1.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
// ALWAYS re-derive the legacy fallback fields against the final
|
||||
// startDate — not only when alignment moved it. The builder emits on
|
||||
// rule edits only, so a startDate change made after the last builder
|
||||
// emit (e.g. a BYDAY-less weekly rule, where no alignment applies)
|
||||
// would otherwise persist a new dtstart alongside legacy weekday
|
||||
// booleans still derived from the old start date.
|
||||
this.repeatCfg.update((cfg) => ({
|
||||
...cfg,
|
||||
startDate: finalStartDate,
|
||||
...rruleToLegacyTaskRepeatCfg(cfg.rrule as string, finalStartDate),
|
||||
}));
|
||||
}
|
||||
}
|
||||
// NOTE: switching from builder mode to a preset needs no rrule cleanup —
|
||||
// every preset's getQuickSettingUpdates() OVERWRITES `rrule` with its own
|
||||
// canonical rule (applied above), so presets stay rrule-backed. Clearing
|
||||
// it here would (a) break the "every saved cfg carries its rrule" contract
|
||||
// and (b) not even propagate: an `rrule: undefined` change is dropped by
|
||||
// the op-log's JSON wire, leaving remote clients scheduling from the old
|
||||
// rule.
|
||||
|
||||
// Normalize the monthly anchor fields at the boundary: convert the form's
|
||||
// `null` sentinel to `undefined`, and strip a stale `monthlyLastDay` flag.
|
||||
// The in-memory quickSetting (incl. 'RRULE' / newer presets) is left as-is;
|
||||
// the addTaskRepeatCfgToTask / updateTaskRepeatCfg action creators clamp it
|
||||
// to a sync-safe value at the persist boundary, so the op payload that
|
||||
// old/mobile clients replay never carries an out-of-union value.
|
||||
const finalRepeatCfg = this._normalizeMonthlyAnchor(this.repeatCfg());
|
||||
|
||||
if (this.isEdit()) {
|
||||
|
|
@ -504,12 +351,6 @@ export class DialogEditTaskRepeatCfgComponent {
|
|||
// filter checks `field in changes`), pushing today's task to tomorrow
|
||||
// when only the time was edited (issue #7373).
|
||||
const changes = getTaskRepeatCfgChanges(initial, finalRepeatCfg);
|
||||
if (Object.keys(changes).length === 0) {
|
||||
// Nothing changed (e.g. a migrated legacy cfg opened and saved as-is)
|
||||
// — don't dispatch an empty update that would still create a sync op.
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
const isRelevantChangesForUpdateAllTasks = RELEVANT_KEYS_FOR_UPDATE_ALL_TASKS.some(
|
||||
(k) => k in changes,
|
||||
);
|
||||
|
|
@ -538,27 +379,18 @@ export class DialogEditTaskRepeatCfgComponent {
|
|||
},
|
||||
>(cfg: T): T {
|
||||
let result = cfg;
|
||||
// Legacy form models used `null` as the "(Day of month)" sentinel on the
|
||||
// The form uses `null` as the "(Day of month)" sentinel on the
|
||||
// monthlyWeekOfMonth select. Persisted cfgs use `undefined` for absent
|
||||
// optional fields — and `null` is NOT master-safe for this field (released
|
||||
// clients' typia schema only allows absent-or-numeric), so it must never
|
||||
// be persisted. Normalizing also keeps existing day-of-month cfgs from
|
||||
// producing spurious change diffs.
|
||||
// optional fields (project convention). Normalizing here keeps existing
|
||||
// day-of-month cfgs from producing spurious change diffs.
|
||||
if (result.monthlyWeekOfMonth === null) {
|
||||
result = { ...result, monthlyWeekOfMonth: undefined };
|
||||
}
|
||||
// `monthlyLastDay` has no CUSTOM-mode form control, so a flag left over
|
||||
// from the MONTHLY_LAST_DAY preset would silently override the
|
||||
// day-of-month a CUSTOM cfg shows. Strip it for other quick settings
|
||||
// (#7726) — EXCEPT 'RRULE', where it is derived from the rule itself
|
||||
// (rruleToLegacyTaskRepeatCfg, BYMONTHDAY=-1) as the old-client fallback
|
||||
// for month-end semantics; stripping it would make old clients fall back
|
||||
// to the startDate's numeric day.
|
||||
if (
|
||||
result.monthlyLastDay &&
|
||||
result.quickSetting !== 'MONTHLY_LAST_DAY' &&
|
||||
result.quickSetting !== 'RRULE'
|
||||
) {
|
||||
// day-of-month a CUSTOM cfg shows. It is only ever valid for that
|
||||
// preset — strip it for any other quick setting (#7726).
|
||||
if (result.monthlyLastDay && result.quickSetting !== 'MONTHLY_LAST_DAY') {
|
||||
result = { ...result, monthlyLastDay: undefined };
|
||||
}
|
||||
return result;
|
||||
|
|
@ -633,8 +465,7 @@ export class DialogEditTaskRepeatCfgComponent {
|
|||
private _setRepeatCfgInitiallyForEditOnly(repeatCfg: TaskRepeatCfg): void {
|
||||
const processedCfg = this._processQuickSettingForDate(repeatCfg);
|
||||
this.repeatCfg.set(processedCfg);
|
||||
// Processed, not stored — see _initializeRepeatCfg for why.
|
||||
this.repeatCfgInitial.set({ ...processedCfg });
|
||||
this.repeatCfgInitial.set({ ...repeatCfg });
|
||||
}
|
||||
|
||||
private _getReferenceDate(): Date {
|
||||
|
|
@ -647,64 +478,14 @@ export class DialogEditTaskRepeatCfgComponent {
|
|||
return new Date();
|
||||
}
|
||||
|
||||
private _processQuickSettingForDate<TCfg extends RepeatCfgWorking>(cfg: TCfg): TCfg {
|
||||
// Presets now carry an rrule too (rrule presets), so an rrule alone no longer
|
||||
// means "builder mode". Keep the friendly preset label only while its rrule
|
||||
// still matches what that preset produces; a builder- / @+- / migration-built
|
||||
// or otherwise diverged rule opens the dedicated 'RRULE' builder.
|
||||
if (cfg.rrule) {
|
||||
// Completion-relative schedules must open in builder mode regardless of
|
||||
// any matching preset: the schedule-type toggle ("from completion") only
|
||||
// exists inside the RRULE builder, so a preset label would hide the one
|
||||
// control that explains — and can change — how the cfg actually fires.
|
||||
if (cfg.repeatFromCompletionDate) {
|
||||
return cfg.quickSetting === 'RRULE' ? cfg : { ...cfg, quickSetting: 'RRULE' };
|
||||
private _processQuickSettingForDate<
|
||||
TCfg extends { quickSetting?: string; startDate?: string },
|
||||
>(cfg: TCfg): TCfg {
|
||||
const SETTINGS_WITHOUT_START_DATE = new Set(['DAILY', 'MONDAY_TO_FRIDAY', 'CUSTOM']);
|
||||
if (cfg.quickSetting && !SETTINGS_WITHOUT_START_DATE.has(cfg.quickSetting)) {
|
||||
if (!cfg.startDate) {
|
||||
return { ...cfg, quickSetting: 'CUSTOM' };
|
||||
}
|
||||
const qs = cfg.quickSetting;
|
||||
const isFaithfulPreset =
|
||||
!!qs &&
|
||||
qs !== 'RRULE' &&
|
||||
qs !== 'CUSTOM' &&
|
||||
legacyTaskRepeatCfgToRRule(cfg as TaskRepeatCfg) === cfg.rrule;
|
||||
if (isFaithfulPreset) {
|
||||
return cfg;
|
||||
}
|
||||
// The persist boundary clamps non-master presets (Weekends, Every other
|
||||
// day, …) to 'CUSTOM' for old-client sync safety — only the rrule
|
||||
// identifies them on reopen. Infer the preset back by matching the
|
||||
// stored rule against what each clamped preset would produce for this
|
||||
// start date (each yields a distinct rule per date), so the friendly
|
||||
// label survives a save/reopen round-trip instead of degrading to the
|
||||
// generic builder.
|
||||
if (qs === 'CUSTOM') {
|
||||
const refDate = cfg.startDate
|
||||
? dateStrToUtcDate(cfg.startDate)
|
||||
: this._getReferenceDate();
|
||||
const inferred = QUICK_SETTING_PRESETS.filter(
|
||||
(p) => toSyncSafeQuickSetting(p) === 'CUSTOM',
|
||||
).find((p) => getQuickSettingUpdates(p, refDate)?.rrule === cfg.rrule);
|
||||
if (inferred) {
|
||||
return { ...cfg, quickSetting: inferred };
|
||||
}
|
||||
}
|
||||
return { ...cfg, quickSetting: 'RRULE' };
|
||||
}
|
||||
// The legacy "Custom" recurrence UI has been removed. Migrate such cfgs (and
|
||||
// any cfg that no longer maps to a kept preset) to an equivalent RRULE so they
|
||||
// open in the builder. This is lazy: the occurrence engine still fires
|
||||
// un-opened legacy cfgs via their repeatCycle path, so the conversion only
|
||||
// persists if the user saves.
|
||||
const PRESETS_WITHOUT_START_DATE = new Set(['DAILY', 'MONDAY_TO_FRIDAY']);
|
||||
const needsMigration =
|
||||
cfg.quickSetting === 'CUSTOM' ||
|
||||
!cfg.quickSetting ||
|
||||
(!PRESETS_WITHOUT_START_DATE.has(cfg.quickSetting) && !cfg.startDate);
|
||||
if (needsMigration) {
|
||||
return {
|
||||
...cfg,
|
||||
rrule: legacyTaskRepeatCfgToRRule(cfg as TaskRepeatCfg),
|
||||
quickSetting: 'RRULE',
|
||||
};
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,15 +47,12 @@ describe('DialogEditTaskRepeatCfgComponent timezone test', () => {
|
|||
// This creates a repeat config based on local interpretation of the timestamp
|
||||
// In LA: starts on 2025-01-16
|
||||
// In Berlin: starts on 2025-01-17
|
||||
// Use the offset AT the test instant (January) — `new Date()` breaks
|
||||
// every summer in DST zones. 07:00 UTC (= 11 PM PST) is the previous
|
||||
// local day only west of UTC-7.
|
||||
const tzOffset = new Date(taskDueWithTime).getTimezoneOffset();
|
||||
if (tzOffset > 420) {
|
||||
// west of UTC-7 (e.g. LA)
|
||||
const tzOffset = new Date().getTimezoneOffset();
|
||||
if (tzOffset > 0) {
|
||||
// LA
|
||||
expect(startDate).toBe('2025-01-16');
|
||||
} else {
|
||||
// east of UTC-7 (e.g. New York, Berlin)
|
||||
// Berlin
|
||||
expect(startDate).toBe('2025-01-17');
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -242,29 +242,21 @@ describe('getQuickSettingUpdates', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// The numeric anchors reset via PRESENT-but-undefined keys so the
|
||||
// spread-merge overwrites stale values in memory. NOT null: released
|
||||
// clients' typia schema allows these fields only absent-or-numeric, so a
|
||||
// null must never reach the wire.
|
||||
describe('day-of-month presets clear NTH_WEEKDAY anchors', () => {
|
||||
it('MONTHLY_CURRENT_DATE explicitly clears the Nth-weekday anchor fields', () => {
|
||||
const result = getQuickSettingUpdates('MONTHLY_CURRENT_DATE');
|
||||
expect('monthlyWeekOfMonth' in result!).toBe(true);
|
||||
expect('monthlyWeekday' in result!).toBe(true);
|
||||
expect(result!.monthlyWeekOfMonth).toBeUndefined();
|
||||
expect(result!.monthlyWeekday).toBeUndefined();
|
||||
});
|
||||
|
||||
it('MONTHLY_FIRST_DAY explicitly clears the Nth-weekday anchor fields', () => {
|
||||
const result = getQuickSettingUpdates('MONTHLY_FIRST_DAY');
|
||||
expect('monthlyWeekOfMonth' in result!).toBe(true);
|
||||
expect(result!.monthlyWeekOfMonth).toBeUndefined();
|
||||
expect(result!.monthlyWeekday).toBeUndefined();
|
||||
});
|
||||
|
||||
it('MONTHLY_LAST_DAY explicitly clears the Nth-weekday anchor fields', () => {
|
||||
const result = getQuickSettingUpdates('MONTHLY_LAST_DAY');
|
||||
expect('monthlyWeekOfMonth' in result!).toBe(true);
|
||||
expect(result!.monthlyWeekOfMonth).toBeUndefined();
|
||||
expect(result!.monthlyWeekday).toBeUndefined();
|
||||
});
|
||||
|
|
@ -272,18 +264,20 @@ describe('getQuickSettingUpdates', () => {
|
|||
|
||||
describe('monthlyLastDay anchor is mutually exclusive with other presets', () => {
|
||||
it('MONTHLY_CURRENT_DATE clears monthlyLastDay', () => {
|
||||
expect(getQuickSettingUpdates('MONTHLY_CURRENT_DATE')!.monthlyLastDay).toBe(false);
|
||||
expect(
|
||||
getQuickSettingUpdates('MONTHLY_CURRENT_DATE')!.monthlyLastDay,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('MONTHLY_FIRST_DAY clears monthlyLastDay', () => {
|
||||
expect(getQuickSettingUpdates('MONTHLY_FIRST_DAY')!.monthlyLastDay).toBe(false);
|
||||
expect(getQuickSettingUpdates('MONTHLY_FIRST_DAY')!.monthlyLastDay).toBeUndefined();
|
||||
});
|
||||
|
||||
it('MONTHLY_NTH_WEEKDAY clears monthlyLastDay', () => {
|
||||
const ref = new Date(2026, 0, 12);
|
||||
expect(getQuickSettingUpdates('MONTHLY_NTH_WEEKDAY', ref)!.monthlyLastDay).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
getQuickSettingUpdates('MONTHLY_NTH_WEEKDAY', ref)!.monthlyLastDay,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -293,119 +287,4 @@ describe('getQuickSettingUpdates', () => {
|
|||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rrule presets (every preset emits a canonical rrule)', () => {
|
||||
it('DAILY → FREQ=DAILY', () => {
|
||||
expect(getQuickSettingUpdates('DAILY')!.rrule).toBe('FREQ=DAILY');
|
||||
});
|
||||
|
||||
it('MONDAY_TO_FRIDAY → weekday rule', () => {
|
||||
expect(getQuickSettingUpdates('MONDAY_TO_FRIDAY')!.rrule).toBe(
|
||||
'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR',
|
||||
);
|
||||
});
|
||||
|
||||
it('WEEKLY_CURRENT_WEEKDAY → BYDAY for the reference weekday', () => {
|
||||
const monday = new Date(2025, 11, 29); // a Monday
|
||||
expect(getQuickSettingUpdates('WEEKLY_CURRENT_WEEKDAY', monday)!.rrule).toBe(
|
||||
'FREQ=WEEKLY;BYDAY=MO',
|
||||
);
|
||||
});
|
||||
|
||||
it('MONTHLY_CURRENT_DATE → BYMONTHDAY for the reference day', () => {
|
||||
const ref = new Date(2024, 5, 15);
|
||||
expect(getQuickSettingUpdates('MONTHLY_CURRENT_DATE', ref)!.rrule).toBe(
|
||||
'FREQ=MONTHLY;BYMONTHDAY=15',
|
||||
);
|
||||
});
|
||||
|
||||
it('MONTHLY_LAST_DAY → BYMONTHDAY=-1', () => {
|
||||
expect(getQuickSettingUpdates('MONTHLY_LAST_DAY')!.rrule).toBe(
|
||||
'FREQ=MONTHLY;BYMONTHDAY=-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('MONTHLY_NTH_WEEKDAY → BYDAY=<pos><weekday> (3rd Saturday)', () => {
|
||||
const ref = new Date(2024, 5, 15); // 3rd Saturday of June 2024
|
||||
expect(getQuickSettingUpdates('MONTHLY_NTH_WEEKDAY', ref)!.rrule).toBe(
|
||||
'FREQ=MONTHLY;BYDAY=3SA',
|
||||
);
|
||||
});
|
||||
|
||||
it('YEARLY_CURRENT_DATE → BYMONTH;BYMONTHDAY', () => {
|
||||
const ref = new Date(2024, 2, 17);
|
||||
expect(getQuickSettingUpdates('YEARLY_CURRENT_DATE', ref)!.rrule).toBe(
|
||||
'FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=17',
|
||||
);
|
||||
});
|
||||
|
||||
it('RRULE baseline does not set rrule (the builder assembles it)', () => {
|
||||
expect(getQuickSettingUpdates('RRULE')!.rrule).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('interval & weekend presets', () => {
|
||||
it('EVERY_OTHER_DAY → DAILY every 2, no startDate', () => {
|
||||
const result = getQuickSettingUpdates('EVERY_OTHER_DAY');
|
||||
expect(result!.repeatCycle).toBe('DAILY');
|
||||
expect(result!.repeatEvery).toBe(2);
|
||||
expect(result!.startDate).toBeUndefined();
|
||||
expect(result!.rrule).toBe('FREQ=DAILY;INTERVAL=2');
|
||||
});
|
||||
|
||||
it('BIWEEKLY_CURRENT_WEEKDAY → WEEKLY every 2 on the reference weekday', () => {
|
||||
const monday = new Date(2025, 11, 29); // a Monday
|
||||
const result = getQuickSettingUpdates('BIWEEKLY_CURRENT_WEEKDAY', monday);
|
||||
expect(result!.repeatCycle).toBe('WEEKLY');
|
||||
expect(result!.repeatEvery).toBe(2);
|
||||
expect((result as any).monday).toBe(true);
|
||||
expect(result!.rrule).toBe('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO');
|
||||
});
|
||||
|
||||
it('WEEKENDS → WEEKLY on Saturday & Sunday only', () => {
|
||||
const result = getQuickSettingUpdates('WEEKENDS');
|
||||
expect(result!.repeatCycle).toBe('WEEKLY');
|
||||
expect(result!.repeatEvery).toBe(1);
|
||||
expect((result as any).saturday).toBe(true);
|
||||
expect((result as any).sunday).toBe(true);
|
||||
expect((result as any).monday).toBe(false);
|
||||
expect((result as any).friday).toBe(false);
|
||||
expect(result!.startDate).toBeUndefined();
|
||||
expect(result!.rrule).toBe('FREQ=WEEKLY;BYDAY=SA,SU');
|
||||
});
|
||||
|
||||
it('QUARTERLY_CURRENT_DATE → MONTHLY every 3 on the reference day', () => {
|
||||
const ref = new Date(2024, 5, 15);
|
||||
const result = getQuickSettingUpdates('QUARTERLY_CURRENT_DATE', ref);
|
||||
expect(result!.repeatCycle).toBe('MONTHLY');
|
||||
expect(result!.repeatEvery).toBe(3);
|
||||
expect(result!.startDate).toBe(getDbDateStr(ref));
|
||||
expect(result!.rrule).toBe('FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15');
|
||||
});
|
||||
|
||||
it('SEMIANNUALLY_CURRENT_DATE → MONTHLY every 6 on the reference day', () => {
|
||||
const ref = new Date(2024, 5, 15);
|
||||
const result = getQuickSettingUpdates('SEMIANNUALLY_CURRENT_DATE', ref);
|
||||
expect(result!.repeatCycle).toBe('MONTHLY');
|
||||
expect(result!.repeatEvery).toBe(6);
|
||||
expect(result!.rrule).toBe('FREQ=MONTHLY;INTERVAL=6;BYMONTHDAY=15');
|
||||
});
|
||||
|
||||
it('MONTHLY_LAST_WEEKDAY → BYDAY=-1<weekday> for the reference weekday', () => {
|
||||
const friday = new Date(2026, 5, 26); // last Friday of June 2026
|
||||
const result = getQuickSettingUpdates('MONTHLY_LAST_WEEKDAY', friday);
|
||||
expect(result!.repeatCycle).toBe('MONTHLY');
|
||||
expect(result!.monthlyWeekOfMonth).toBe(-1);
|
||||
expect(result!.monthlyWeekday).toBe(5); // Friday
|
||||
expect(result!.rrule).toBe('FREQ=MONTHLY;BYDAY=-1FR');
|
||||
});
|
||||
|
||||
it('EVERY_OTHER_YEAR_CURRENT_DATE → YEARLY every 2 on the reference day/month', () => {
|
||||
const ref = new Date(2024, 2, 17);
|
||||
const result = getQuickSettingUpdates('EVERY_OTHER_YEAR_CURRENT_DATE', ref);
|
||||
expect(result!.repeatCycle).toBe('YEARLY');
|
||||
expect(result!.repeatEvery).toBe(2);
|
||||
expect(result!.rrule).toBe('FREQ=YEARLY;INTERVAL=2;BYMONTH=3;BYMONTHDAY=17');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
TaskRepeatCfg,
|
||||
} from '../task-repeat-cfg.model';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
import { legacyTaskRepeatCfgToRRule } from '../util/legacy-cfg-to-rrule.util';
|
||||
|
||||
const _buildWeeklyForDay = (date: Date): Partial<TaskRepeatCfg> => {
|
||||
const weekdayStr = TASK_REPEAT_WEEKDAY_MAP[date.getDay()];
|
||||
|
|
@ -26,29 +25,15 @@ const _buildWeeklyForDay = (date: Date): Partial<TaskRepeatCfg> => {
|
|||
|
||||
// Switching between monthly presets must clear every monthly anchor —
|
||||
// anchor presence is the discriminator, so a stale Nth-weekday or last-day
|
||||
// field would silently take effect. The numeric anchors clear via `undefined`
|
||||
// (NOT `null` — released clients' typia schema only allows absent-or-numeric,
|
||||
// so null must never reach the wire); remote clients keep a stale anchor on
|
||||
// the update path (JSON drops the key) — inert on rrule-aware clients (the
|
||||
// engine routes on the `rrule` every preset also carries), an inherent and
|
||||
// unfixable gap on PRE-rrule clients (see the op-log round-trip spec).
|
||||
// `monthlyLastDay` clears via `false`, a master-safe value that DOES survive
|
||||
// the JSON wire.
|
||||
// field would silently take effect.
|
||||
const MONTHLY_ANCHOR_RESET: Partial<TaskRepeatCfg> = {
|
||||
monthlyWeekOfMonth: undefined,
|
||||
monthlyWeekday: undefined,
|
||||
monthlyLastDay: false,
|
||||
monthlyLastDay: undefined,
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns partial TaskRepeatCfg updates based on the quick setting.
|
||||
*
|
||||
* Every preset is now an RRULE preset: it sets the legacy fields (kept populated
|
||||
* for old-client forward-compat) AND a canonical `rrule` derived from them via
|
||||
* `legacyTaskRepeatCfgToRRule`, so new configs are rrule-backed and run on the
|
||||
* single rrule engine path. The 'RRULE' builder mode assembles its own rrule, so
|
||||
* it returns the legacy baseline only (no rrule here).
|
||||
*
|
||||
* @param quickSetting The quick setting to apply
|
||||
* @param referenceDate Optional date to use for weekday calculation (fixes #5806).
|
||||
* If not provided, uses current date.
|
||||
|
|
@ -59,55 +44,20 @@ export const getQuickSettingUpdates = (
|
|||
): Partial<TaskRepeatCfg> | undefined => {
|
||||
const today = new Date();
|
||||
|
||||
// Attach the canonical rrule derived from the same legacy fields the preset sets.
|
||||
const withRRule = (u: Partial<TaskRepeatCfg>): Partial<TaskRepeatCfg> => ({
|
||||
...u,
|
||||
rrule: legacyTaskRepeatCfgToRRule(u as TaskRepeatCfg),
|
||||
});
|
||||
|
||||
switch (quickSetting) {
|
||||
case 'DAILY': {
|
||||
return withRRule({
|
||||
return {
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
});
|
||||
}
|
||||
|
||||
case 'EVERY_OTHER_DAY': {
|
||||
return withRRule({
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 2,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
case 'WEEKLY_CURRENT_WEEKDAY': {
|
||||
return withRRule(_buildWeeklyForDay(referenceDate || today));
|
||||
}
|
||||
|
||||
case 'BIWEEKLY_CURRENT_WEEKDAY': {
|
||||
// Same single-weekday anchor as WEEKLY_CURRENT_WEEKDAY, but every 2nd week.
|
||||
return withRRule({
|
||||
..._buildWeeklyForDay(referenceDate || today),
|
||||
repeatEvery: 2,
|
||||
});
|
||||
}
|
||||
|
||||
case 'WEEKENDS': {
|
||||
return withRRule({
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
monday: false,
|
||||
tuesday: false,
|
||||
wednesday: false,
|
||||
thursday: false,
|
||||
friday: false,
|
||||
saturday: true,
|
||||
sunday: true,
|
||||
});
|
||||
return _buildWeeklyForDay(referenceDate || today);
|
||||
}
|
||||
|
||||
case 'MONDAY_TO_FRIDAY': {
|
||||
return withRRule({
|
||||
return {
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
monday: true,
|
||||
|
|
@ -117,36 +67,16 @@ export const getQuickSettingUpdates = (
|
|||
friday: true,
|
||||
saturday: false,
|
||||
sunday: false,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
case 'MONTHLY_CURRENT_DATE': {
|
||||
return withRRule({
|
||||
return {
|
||||
repeatCycle: 'MONTHLY',
|
||||
repeatEvery: 1,
|
||||
startDate: getDbDateStr(referenceDate || today),
|
||||
...MONTHLY_ANCHOR_RESET,
|
||||
});
|
||||
}
|
||||
|
||||
case 'QUARTERLY_CURRENT_DATE': {
|
||||
// Same day-of-month anchor as MONTHLY_CURRENT_DATE, every 3rd month.
|
||||
return withRRule({
|
||||
repeatCycle: 'MONTHLY',
|
||||
repeatEvery: 3,
|
||||
startDate: getDbDateStr(referenceDate || today),
|
||||
...MONTHLY_ANCHOR_RESET,
|
||||
});
|
||||
}
|
||||
|
||||
case 'SEMIANNUALLY_CURRENT_DATE': {
|
||||
// Day-of-month anchor, every 6th month.
|
||||
return withRRule({
|
||||
repeatCycle: 'MONTHLY',
|
||||
repeatEvery: 6,
|
||||
startDate: getDbDateStr(referenceDate || today),
|
||||
...MONTHLY_ANCHOR_RESET,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
case 'MONTHLY_FIRST_DAY': {
|
||||
|
|
@ -157,12 +87,12 @@ export const getQuickSettingUpdates = (
|
|||
today.getDate() === 1
|
||||
? new Date(today.getFullYear(), today.getMonth(), 1)
|
||||
: new Date(today.getFullYear(), today.getMonth() + 1, 1);
|
||||
return withRRule({
|
||||
return {
|
||||
repeatCycle: 'MONTHLY',
|
||||
repeatEvery: 1,
|
||||
startDate: getDbDateStr(firstDay),
|
||||
...MONTHLY_ANCHOR_RESET,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
case 'MONTHLY_LAST_DAY': {
|
||||
|
|
@ -171,13 +101,13 @@ export const getQuickSettingUpdates = (
|
|||
// occurrence engine to clamp to month-end every month, so `startDate`'s
|
||||
// day-of-month no longer needs to be a hardcoded 31 (#7726).
|
||||
const lastDayThisMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
return withRRule({
|
||||
return {
|
||||
repeatCycle: 'MONTHLY',
|
||||
repeatEvery: 1,
|
||||
startDate: getDbDateStr(lastDayThisMonth),
|
||||
...MONTHLY_ANCHOR_RESET,
|
||||
monthlyLastDay: true,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
case 'MONTHLY_NTH_WEEKDAY': {
|
||||
|
|
@ -188,59 +118,25 @@ export const getQuickSettingUpdates = (
|
|||
const rawWeekOfMonth = Math.floor((ref.getDate() - 1) / 7) + 1;
|
||||
const weekOfMonth = Math.min(rawWeekOfMonth, 4) as MonthlyWeekOfMonth;
|
||||
const weekday = ref.getDay() as MonthlyWeekday;
|
||||
return withRRule({
|
||||
return {
|
||||
repeatCycle: 'MONTHLY',
|
||||
repeatEvery: 1,
|
||||
startDate: getDbDateStr(ref),
|
||||
monthlyWeekOfMonth: weekOfMonth,
|
||||
monthlyWeekday: weekday,
|
||||
monthlyLastDay: false,
|
||||
});
|
||||
}
|
||||
|
||||
case 'MONTHLY_LAST_WEEKDAY': {
|
||||
// "Last <weekday> of every month" for the reference date's weekday, e.g.
|
||||
// 2026-06-26 (a Friday) → last Friday each month (monthlyWeekOfMonth -1).
|
||||
const ref = referenceDate || today;
|
||||
return withRRule({
|
||||
repeatCycle: 'MONTHLY',
|
||||
repeatEvery: 1,
|
||||
startDate: getDbDateStr(ref),
|
||||
monthlyWeekOfMonth: -1,
|
||||
monthlyWeekday: ref.getDay() as MonthlyWeekday,
|
||||
monthlyLastDay: false,
|
||||
});
|
||||
}
|
||||
|
||||
case 'YEARLY_CURRENT_DATE': {
|
||||
return withRRule({
|
||||
repeatCycle: 'YEARLY',
|
||||
repeatEvery: 1,
|
||||
startDate: getDbDateStr(referenceDate || today),
|
||||
});
|
||||
}
|
||||
|
||||
case 'EVERY_OTHER_YEAR_CURRENT_DATE': {
|
||||
// Same day/month anchor as YEARLY_CURRENT_DATE, every 2nd year.
|
||||
return withRRule({
|
||||
repeatCycle: 'YEARLY',
|
||||
repeatEvery: 2,
|
||||
startDate: getDbDateStr(referenceDate || today),
|
||||
});
|
||||
}
|
||||
|
||||
case 'RRULE': {
|
||||
// Advanced RRULE builder. The opaque `rrule` string is assembled by the
|
||||
// dialog from the builder dropdowns; here we just set a clean baseline —
|
||||
// a default WEEKLY repeatCycle (older-client fallback) and a start date.
|
||||
return {
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
startDate: getDbDateStr(referenceDate || today),
|
||||
...MONTHLY_ANCHOR_RESET,
|
||||
monthlyLastDay: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
case 'YEARLY_CURRENT_DATE': {
|
||||
return {
|
||||
repeatCycle: 'YEARLY',
|
||||
repeatEvery: 1,
|
||||
startDate: getDbDateStr(referenceDate || today),
|
||||
};
|
||||
}
|
||||
|
||||
case 'CUSTOM':
|
||||
default:
|
||||
}
|
||||
return undefined;
|
||||
|
|
|
|||
|
|
@ -1,486 +0,0 @@
|
|||
<div class="rb">
|
||||
<!-- Frequency + interval, read as a sentence -->
|
||||
<div class="rb-row">
|
||||
<span class="rb-lbl">{{ T.F.TASK_REPEAT.F.RRULE_INTERVAL | translate }}</span>
|
||||
<input
|
||||
#intervalIn
|
||||
class="rb-num"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000"
|
||||
[value]="model().interval"
|
||||
(input)="setInterval(intervalIn.value)"
|
||||
/>
|
||||
<select
|
||||
#freqSel
|
||||
class="rb-sel"
|
||||
(change)="setFreq(freqSel.value)"
|
||||
>
|
||||
@for (o of freqOpts; track o.value) {
|
||||
<option
|
||||
[value]="o.value"
|
||||
[selected]="o.value === model().freq"
|
||||
>
|
||||
{{ o.label | translate }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- WEEKLY: weekday toggle buttons -->
|
||||
@if (model().freq === 'WEEKLY') {
|
||||
<div class="rb-field">
|
||||
<label>{{ T.F.TASK_REPEAT.F.RRULE_BYDAY | translate }}</label>
|
||||
<div class="rb-toggles">
|
||||
@for (w of weekdays; track w.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="rb-tgl"
|
||||
[class.active]="model().byDay.includes(w.value)"
|
||||
[title]="w.full"
|
||||
(click)="toggleDay(w.value)"
|
||||
>
|
||||
{{ w.short }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- MONTHLY -->
|
||||
@if (model().freq === 'MONTHLY') {
|
||||
<div class="rb-row">
|
||||
<span class="rb-lbl">{{ T.F.TASK_REPEAT.F.RRULE_MONTHLY_MODE | translate }}</span>
|
||||
<select
|
||||
#mMode
|
||||
class="rb-sel rb-sel--wide"
|
||||
(change)="setMonthlyMode(mMode.value)"
|
||||
>
|
||||
@for (o of monthlyModeOpts; track o.value) {
|
||||
<option
|
||||
[value]="o.value"
|
||||
[selected]="o.value === model().monthlyMode"
|
||||
>
|
||||
{{ o.label | translate }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (model().monthlyMode === 'DAY_OF_MONTH') {
|
||||
<div class="rb-field">
|
||||
<label>{{ T.F.TASK_REPEAT.F.RRULE_BYMONTHDAY | translate }}</label>
|
||||
<div class="rb-toggles rb-toggles--days">
|
||||
@for (d of dayGrid; track d) {
|
||||
<button
|
||||
type="button"
|
||||
class="rb-tgl"
|
||||
[class.active]="model().monthDays.includes(d)"
|
||||
(click)="toggleMonthDay(d)"
|
||||
>
|
||||
{{ d }}
|
||||
</button>
|
||||
}
|
||||
@for (nd of negativeDays; track nd.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="rb-tgl rb-tgl--wide"
|
||||
[class.active]="model().monthDays.includes(nd.value)"
|
||||
(click)="toggleMonthDay(nd.value)"
|
||||
>
|
||||
{{ nd.label | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<input
|
||||
class="rb-text"
|
||||
type="text"
|
||||
[placeholder]="T.F.TASK_REPEAT.F.RRULE_BYMONTHDAY_PLACEHOLDER | translate"
|
||||
[value]="model().monthDays.join(',')"
|
||||
(change)="setMonthDays($any($event.target).value)"
|
||||
/>
|
||||
<div class="rb-hint">
|
||||
{{ T.F.TASK_REPEAT.F.RRULE_BYMONTHDAY_DESCRIPTION | translate }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- nth-weekday rows render in the shared block below (monthly + yearly). -->
|
||||
@if (model().monthlyMode === 'WEEKDAYS') {
|
||||
<ng-container *ngTemplateOutlet="weekdaySetBlock"></ng-container>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- YEARLY -->
|
||||
@if (model().freq === 'YEARLY') {
|
||||
<div class="rb-row">
|
||||
<span class="rb-lbl">{{ T.F.TASK_REPEAT.F.RRULE_YEARLY_MODE | translate }}</span>
|
||||
<select
|
||||
#yMode
|
||||
class="rb-sel rb-sel--wide"
|
||||
(change)="setYearlyMode(yMode.value)"
|
||||
>
|
||||
@for (o of yearlyModeOpts; track o.value) {
|
||||
<option
|
||||
[value]="o.value"
|
||||
[selected]="o.value === model().yearlyMode"
|
||||
>
|
||||
{{ o.label | translate }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="rb-hint">
|
||||
{{ T.F.TASK_REPEAT.F.RRULE_YEARLY_MODE_DESCRIPTION | translate }}
|
||||
</div>
|
||||
@if (model().yearlyMode === 'DAY_OF_MONTH') {
|
||||
<div class="rb-field">
|
||||
<label>{{ T.F.TASK_REPEAT.F.RRULE_BYMONTHDAY | translate }}</label>
|
||||
<div class="rb-toggles rb-toggles--days">
|
||||
@for (d of dayGrid; track d) {
|
||||
<button
|
||||
type="button"
|
||||
class="rb-tgl"
|
||||
[class.active]="model().monthDays.includes(d)"
|
||||
(click)="toggleMonthDay(d)"
|
||||
>
|
||||
{{ d }}
|
||||
</button>
|
||||
}
|
||||
@for (nd of negativeDays; track nd.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="rb-tgl rb-tgl--wide"
|
||||
[class.active]="model().monthDays.includes(nd.value)"
|
||||
(click)="toggleMonthDay(nd.value)"
|
||||
>
|
||||
{{ nd.label | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<input
|
||||
class="rb-text"
|
||||
type="text"
|
||||
[placeholder]="T.F.TASK_REPEAT.F.RRULE_BYMONTHDAY_PLACEHOLDER | translate"
|
||||
[value]="model().monthDays.join(',')"
|
||||
(change)="setMonthDays($any($event.target).value)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
@if (model().yearlyMode === 'WEEKDAYS') {
|
||||
<ng-container *ngTemplateOutlet="weekdaySetBlock"></ng-container>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Weekday set + "which occurrence" (BYSETPOS) toggles — shared by the
|
||||
monthly and yearly WEEKDAYS modes (all bindings are frequency-agnostic). -->
|
||||
<ng-template #weekdaySetBlock>
|
||||
<div class="rb-row">
|
||||
<div class="rb-toggles">
|
||||
<button
|
||||
type="button"
|
||||
class="rb-tgl rb-tgl--wide"
|
||||
[class.active]="isSetPosEvery()"
|
||||
(click)="clearSetPos()"
|
||||
>
|
||||
{{ T.F.TASK_REPEAT.F.RRULE_SETPOS_EVERY | translate }}
|
||||
</button>
|
||||
@for (o of ordinalOpts; track o.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="rb-tgl rb-tgl--wide"
|
||||
[class.active]="isSetPosActive(o.value)"
|
||||
(click)="toggleSetPos(o.value)"
|
||||
>
|
||||
{{ o.label | translate }}
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="rb-tgl rb-tgl--wide"
|
||||
[class.active]="isSetPosCustom()"
|
||||
(click)="toggleSetPosCustomMode()"
|
||||
>
|
||||
{{ T.F.TASK_REPEAT.F.ORD_CUSTOM | translate }}
|
||||
</button>
|
||||
</div>
|
||||
@if (isSetPosCustom()) {
|
||||
<input
|
||||
#customWhich
|
||||
class="rb-text rb-text--inline"
|
||||
type="text"
|
||||
[placeholder]="T.F.TASK_REPEAT.F.RRULE_BYSETPOS_PLACEHOLDER | translate"
|
||||
[value]="model().bySetPos"
|
||||
(change)="setCustomBySetPos(customWhich.value)"
|
||||
/>
|
||||
}
|
||||
<div class="rb-toggles">
|
||||
@for (w of weekdays; track w.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="rb-tgl"
|
||||
[class.active]="model().byDay.includes(w.value)"
|
||||
[title]="w.full"
|
||||
(click)="toggleDay(w.value)"
|
||||
>
|
||||
{{ w.short }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rb-hint">
|
||||
{{ T.F.TASK_REPEAT.F.RRULE_BYSETPOS_DESCRIPTION | translate }}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- nth-weekday rows (monthly + yearly): per-weekday ordinals, e.g. the 3rd
|
||||
Monday and 4th Sunday → BYDAY=3MO,4SU. One ordinal+weekday per line, + adds more. -->
|
||||
@if (isNthMode()) {
|
||||
<div class="rb-field">
|
||||
<label>{{ T.F.TASK_REPEAT.F.RRULE_NTH_WEEKDAY | translate }}</label>
|
||||
@for (nd of model().nthDays; track $index; let rowIdx = $index) {
|
||||
<div class="rb-row">
|
||||
<select
|
||||
#pos
|
||||
class="rb-sel"
|
||||
(change)="setNthDayPos(rowIdx, pos.value)"
|
||||
>
|
||||
@for (o of availableOrdinalOpts(rowIdx); track o.value) {
|
||||
<option
|
||||
[value]="o.value"
|
||||
[selected]="!isNthRowCustom(rowIdx) && o.value === nd.pos"
|
||||
>
|
||||
{{ o.label | translate }}
|
||||
</option>
|
||||
}
|
||||
<option
|
||||
[value]="ORD_CUSTOM"
|
||||
[selected]="isNthRowCustom(rowIdx)"
|
||||
>
|
||||
{{ T.F.TASK_REPEAT.F.ORD_CUSTOM | translate }}
|
||||
</option>
|
||||
</select>
|
||||
@if (isNthRowCustom(rowIdx)) {
|
||||
<input
|
||||
#customPos
|
||||
class="rb-num"
|
||||
type="number"
|
||||
[min]="-nthPosBound()"
|
||||
[max]="nthPosBound()"
|
||||
[value]="nd.pos"
|
||||
[title]="T.F.TASK_REPEAT.F.ORD_CUSTOM_DESCRIPTION | translate"
|
||||
(change)="setNthDayCustomPos(rowIdx, customPos.value)"
|
||||
/>
|
||||
}
|
||||
<div class="rb-toggles">
|
||||
@for (w of weekdays; track w.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="rb-tgl"
|
||||
[class.active]="nd.days.includes(w.value)"
|
||||
[title]="w.full"
|
||||
(click)="toggleNthDayWeekday(rowIdx, w.value)"
|
||||
>
|
||||
{{ w.short }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (model().nthDays.length > 1) {
|
||||
<button
|
||||
type="button"
|
||||
class="rb-tgl"
|
||||
[title]="T.G.DELETE | translate"
|
||||
(click)="removeNthDay(rowIdx)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (canAddNthDay()) {
|
||||
<button
|
||||
type="button"
|
||||
class="rb-tgl rb-tgl--wide"
|
||||
(click)="addNthDay()"
|
||||
>
|
||||
+ {{ T.F.TASK_REPEAT.F.RRULE_ADD_NTH | translate }}
|
||||
</button>
|
||||
}
|
||||
<div class="rb-hint">
|
||||
{{ T.F.TASK_REPEAT.F.RRULE_NTH_WEEKDAY_DESCRIPTION | translate }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- In months (optional seasonal constraint, any frequency) -->
|
||||
<div class="rb-field">
|
||||
<label>{{ T.F.TASK_REPEAT.F.RRULE_BYMONTH | translate }}</label>
|
||||
<div class="rb-toggles rb-toggles--months">
|
||||
@for (m of months; track m.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="rb-tgl"
|
||||
[class.active]="model().byMonth.includes(m.value)"
|
||||
[title]="m.full"
|
||||
(click)="toggleMonth(m.value)"
|
||||
>
|
||||
{{ m.short }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="rb-hint">
|
||||
{{ T.F.TASK_REPEAT.F.RRULE_BYMONTH_DESCRIPTION | translate }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ends -->
|
||||
<div class="rb-row">
|
||||
<span class="rb-lbl">{{ T.F.TASK_REPEAT.F.RRULE_END | translate }}</span>
|
||||
<select
|
||||
#endSel
|
||||
class="rb-sel rb-sel--wide"
|
||||
(change)="setEndType(endSel.value)"
|
||||
>
|
||||
@for (o of endOpts; track o.value) {
|
||||
<option
|
||||
[value]="o.value"
|
||||
[selected]="o.value === model().endType"
|
||||
>
|
||||
{{ o.label | translate }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
@if (model().endType === 'COUNT') {
|
||||
<input
|
||||
#countIn
|
||||
class="rb-num"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000"
|
||||
[value]="model().count"
|
||||
(input)="setCount(countIn.value)"
|
||||
/>
|
||||
}
|
||||
@if (model().endType === 'UNTIL') {
|
||||
<input
|
||||
#untilIn
|
||||
class="rb-date"
|
||||
type="date"
|
||||
[value]="model().until"
|
||||
(input)="setUntil(untilIn.value)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Schedule type: fixed calendar dates vs. count the interval from completion -->
|
||||
<div class="rb-field">
|
||||
<label>{{ T.F.TASK_REPEAT.F.RRULE_SCHEDULE_TYPE | translate }}</label>
|
||||
<div class="rb-toggles">
|
||||
<button
|
||||
type="button"
|
||||
class="rb-tgl rb-tgl--wide"
|
||||
[class.active]="!fromCompletion()"
|
||||
(click)="setRepeatFromCompletion(false)"
|
||||
>
|
||||
{{ T.F.TASK_REPEAT.F.RRULE_SCHEDULE_FROM_START | translate }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rb-tgl rb-tgl--wide"
|
||||
[class.active]="fromCompletion()"
|
||||
(click)="setRepeatFromCompletion(true)"
|
||||
>
|
||||
{{ T.F.TASK_REPEAT.F.RRULE_SCHEDULE_FROM_COMPLETION | translate }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="rb-hint">
|
||||
{{ T.F.TASK_REPEAT.F.RRULE_SCHEDULE_TYPE_DESCRIPTION | translate }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NOTE: the live result/preview lives at the dialog bottom (just above the
|
||||
action buttons), not here — see DialogEditTaskRepeatCfgComponent. -->
|
||||
|
||||
<!-- Advanced -->
|
||||
<collapsible
|
||||
[title]="T.F.TASK_REPEAT.F.RRULE_ADVANCED | translate"
|
||||
[isExpanded]="model().showAdvanced"
|
||||
(isExpandedChange)="setShowAdvanced($event)"
|
||||
>
|
||||
<div class="rb-adv">
|
||||
<div class="rb-field">
|
||||
<label>{{ T.F.TASK_REPEAT.F.RRULE_WKST | translate }}</label>
|
||||
<select
|
||||
#wkstSel
|
||||
class="rb-sel rb-sel--wide"
|
||||
(change)="setWkst(wkstSel.value)"
|
||||
>
|
||||
<option
|
||||
value=""
|
||||
[selected]="model().wkst === ''"
|
||||
>
|
||||
{{ T.F.TASK_REPEAT.F.RRULE_WKST_DEFAULT | translate }}
|
||||
</option>
|
||||
@for (o of weekdaySelectOpts; track o.value) {
|
||||
<option
|
||||
[value]="o.value"
|
||||
[selected]="o.value === model().wkst"
|
||||
>
|
||||
{{ o.label }}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
<div class="rb-hint">
|
||||
{{ T.F.TASK_REPEAT.F.RRULE_WKST_DESCRIPTION | translate }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Week numbers / days of year — only meaningful for YEARLY (RFC 5545 §3.3.10) -->
|
||||
@if (model().freq === 'YEARLY') {
|
||||
<div class="rb-field">
|
||||
<label>{{ T.F.TASK_REPEAT.F.RRULE_BYWEEKNO | translate }}</label>
|
||||
<input
|
||||
#bwn
|
||||
class="rb-text"
|
||||
type="text"
|
||||
[placeholder]="T.F.TASK_REPEAT.F.RRULE_BYWEEKNO_PLACEHOLDER | translate"
|
||||
[value]="model().byWeekNo"
|
||||
(input)="setByWeekNo(bwn.value)"
|
||||
/>
|
||||
<div class="rb-hint">
|
||||
{{ T.F.TASK_REPEAT.F.RRULE_BYWEEKNO_DESCRIPTION | translate }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rb-field">
|
||||
<label>{{ T.F.TASK_REPEAT.F.RRULE_BYYEARDAY | translate }}</label>
|
||||
<input
|
||||
#byd
|
||||
class="rb-text"
|
||||
type="text"
|
||||
[placeholder]="T.F.TASK_REPEAT.F.RRULE_BYYEARDAY_PLACEHOLDER | translate"
|
||||
[value]="model().byYearDay"
|
||||
(input)="setByYearDay(byd.value)"
|
||||
/>
|
||||
<div class="rb-hint">
|
||||
{{ T.F.TASK_REPEAT.F.RRULE_BYYEARDAY_DESCRIPTION | translate }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="rb-field">
|
||||
<label>{{ T.F.TASK_REPEAT.F.RRULE_RAW | translate }}</label>
|
||||
<input
|
||||
#raw
|
||||
class="rb-text rb-text--mono"
|
||||
type="text"
|
||||
[placeholder]="T.F.TASK_REPEAT.F.RRULE_RAW_PLACEHOLDER | translate"
|
||||
[value]="model().rawOverride"
|
||||
(input)="setRawOverride(raw.value)"
|
||||
/>
|
||||
<div class="rb-hint">
|
||||
{{ T.F.TASK_REPEAT.F.RRULE_RAW_DESCRIPTION | translate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</collapsible>
|
||||
</div>
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rb {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s2);
|
||||
}
|
||||
|
||||
// A sentence-style row: label + inline controls.
|
||||
.rb-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s);
|
||||
}
|
||||
|
||||
.rb-lbl {
|
||||
font-weight: bold;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.rb-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-quarter, 4px);
|
||||
|
||||
> label {
|
||||
font-weight: bold;
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
// Shared control styling (native select / input themed to the app).
|
||||
.rb-sel,
|
||||
.rb-num,
|
||||
.rb-text,
|
||||
.rb-date {
|
||||
height: 40px; // touch-friendly
|
||||
padding: 0 var(--s);
|
||||
color: var(--text-color);
|
||||
background: var(--bg, transparent);
|
||||
border: 1px solid var(--extra-border-color);
|
||||
border-radius: var(--card-border-radius);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--c-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.rb-num {
|
||||
width: 64px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rb-sel--wide {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.rb-text {
|
||||
width: 100%;
|
||||
|
||||
&--mono {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
// Sits inline next to a select within a row (e.g. the custom BYSETPOS input).
|
||||
&--inline {
|
||||
width: 110px;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle buttons for weekdays / months.
|
||||
.rb-toggles {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-quarter, 4px);
|
||||
|
||||
&--months {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
// Calendar-style day-of-month grid (~7 per row).
|
||||
&--days {
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.rb-tgl--wide {
|
||||
min-width: auto;
|
||||
padding: 0 var(--s2);
|
||||
}
|
||||
|
||||
.rb-tgl {
|
||||
min-width: 40px;
|
||||
height: 40px; // touch-friendly tap target
|
||||
padding: 0 var(--s);
|
||||
touch-action: manipulation;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-color);
|
||||
background: transparent;
|
||||
border: 1px solid var(--extra-border-color);
|
||||
border-radius: var(--card-border-radius);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.1s ease,
|
||||
color 0.1s ease,
|
||||
border-color 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--c-primary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--c-primary);
|
||||
border-color: var(--c-primary);
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.rb-hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.rb-adv {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s2);
|
||||
padding-top: var(--s);
|
||||
}
|
||||
|
|
@ -1,323 +0,0 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { RruleBuilderComponent } from './rrule-builder.component';
|
||||
|
||||
describe('RruleBuilderComponent', () => {
|
||||
let fixture: ComponentFixture<RruleBuilderComponent>;
|
||||
let component: RruleBuilderComponent;
|
||||
|
||||
const setup = async (
|
||||
rrule = '',
|
||||
startDate = '2024-06-03',
|
||||
repeatFromCompletion = false,
|
||||
): Promise<void> => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [RruleBuilderComponent, TranslateModule.forRoot(), NoopAnimationsModule],
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(RruleBuilderComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('rrule', rrule);
|
||||
fixture.componentRef.setInput('startDate', startDate);
|
||||
fixture.componentRef.setInput('repeatFromCompletion', repeatFromCompletion);
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
it('parses an existing rrule into the model (nth-weekday)', async () => {
|
||||
await setup('FREQ=MONTHLY;BYDAY=2TU');
|
||||
expect(component.model().freq).toBe('MONTHLY');
|
||||
expect(component.model().monthlyMode).toBe('NTH_WEEKDAY');
|
||||
expect(component.model().nthDays).toEqual([{ pos: 2, days: ['TU'] }]);
|
||||
});
|
||||
|
||||
it('adds a second nth-weekday row and emits combined BYDAY (3MO,4SU)', async () => {
|
||||
await setup('FREQ=MONTHLY;BYDAY=3MO');
|
||||
const emitted: string[] = [];
|
||||
component.rruleChange.subscribe((r) => emitted.push(r));
|
||||
component.addNthDay(); // new row defaults to the first unused ordinal, no weekdays yet
|
||||
component.setNthDayPos(1, '4');
|
||||
component.toggleNthDayWeekday(1, 'SU');
|
||||
expect(emitted[emitted.length - 1]).toBe('FREQ=MONTHLY;BYDAY=3MO,4SU');
|
||||
component.removeNthDay(1);
|
||||
expect(emitted[emitted.length - 1]).toBe('FREQ=MONTHLY;BYDAY=3MO');
|
||||
});
|
||||
|
||||
it('selects multiple weekdays on one ordinal row (first Mon + first Tue → 1MO,1TU)', async () => {
|
||||
await setup('FREQ=MONTHLY;BYDAY=1MO');
|
||||
const emitted: string[] = [];
|
||||
component.rruleChange.subscribe((r) => emitted.push(r));
|
||||
component.toggleNthDayWeekday(0, 'TU');
|
||||
expect(component.model().nthDays).toEqual([{ pos: 1, days: ['MO', 'TU'] }]);
|
||||
expect(emitted[emitted.length - 1]).toBe('FREQ=MONTHLY;BYDAY=1MO,1TU');
|
||||
// toggling it off again removes just that weekday
|
||||
component.toggleNthDayWeekday(0, 'MO');
|
||||
expect(emitted[emitted.length - 1]).toBe('FREQ=MONTHLY;BYDAY=1TU');
|
||||
});
|
||||
|
||||
it('switching a row to a custom ordinal keeps the pos and shows the input', async () => {
|
||||
await setup('FREQ=MONTHLY;BYDAY=3MO');
|
||||
expect(component.isNthRowCustom(0)).toBe(false);
|
||||
component.setNthDayPos(0, component.ORD_CUSTOM);
|
||||
expect(component.isNthRowCustom(0)).toBe(true);
|
||||
// pos unchanged until the user types a number
|
||||
expect(component.model().nthDays).toEqual([{ pos: 3, days: ['MO'] }]);
|
||||
// switching back to a predefined ordinal clears the custom state
|
||||
component.setNthDayPos(0, '2');
|
||||
expect(component.isNthRowCustom(0)).toBe(false);
|
||||
expect(component.model().nthDays).toEqual([{ pos: 2, days: ['MO'] }]);
|
||||
});
|
||||
|
||||
it('custom ordinal input emits any non-zero value, clamped per frequency', async () => {
|
||||
await setup('FREQ=MONTHLY;BYDAY=3MO');
|
||||
const emitted: string[] = [];
|
||||
component.rruleChange.subscribe((r) => emitted.push(r));
|
||||
component.setNthDayCustomPos(0, '-2');
|
||||
expect(emitted[emitted.length - 1]).toBe('FREQ=MONTHLY;BYDAY=-2MO');
|
||||
component.setNthDayCustomPos(0, '5');
|
||||
expect(emitted[emitted.length - 1]).toBe('FREQ=MONTHLY;BYDAY=5MO');
|
||||
// zero and garbage are ignored
|
||||
component.setNthDayCustomPos(0, '0');
|
||||
component.setNthDayCustomPos(0, 'abc');
|
||||
expect(component.model().nthDays).toEqual([{ pos: 5, days: ['MO'] }]);
|
||||
// MONTHLY clamps to ±5 — a month has at most 5 of any weekday; values past
|
||||
// that emit valid-but-dead rules (BYDAY=10MO matches nothing, ever).
|
||||
component.setNthDayCustomPos(0, '99');
|
||||
expect(component.model().nthDays).toEqual([{ pos: 5, days: ['MO'] }]);
|
||||
component.setNthDayCustomPos(0, '-99');
|
||||
expect(component.model().nthDays).toEqual([{ pos: -5, days: ['MO'] }]);
|
||||
});
|
||||
|
||||
it('custom ordinal input clamps to ±53 for YEARLY', async () => {
|
||||
await setup('FREQ=YEARLY;BYMONTH=6;BYDAY=3MO');
|
||||
component.setNthDayCustomPos(0, '99');
|
||||
expect(component.model().nthDays).toEqual([{ pos: 53, days: ['MO'] }]);
|
||||
});
|
||||
|
||||
it('custom ordinal input rejects a pos another row already anchors', async () => {
|
||||
// Two rows resolving to the same ordinal collapse into one on reload —
|
||||
// BYDAY cannot represent them separately.
|
||||
await setup('FREQ=MONTHLY;BYDAY=2MO,4SU');
|
||||
component.setNthDayCustomPos(1, '2');
|
||||
expect(component.model().nthDays).toEqual([
|
||||
{ pos: 2, days: ['MO'] },
|
||||
{ pos: 4, days: ['SU'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('a parsed ordinal outside the dropdown set renders as custom', async () => {
|
||||
await setup('FREQ=MONTHLY;BYDAY=-2MO');
|
||||
expect(component.model().monthlyMode).toBe('NTH_WEEKDAY');
|
||||
expect(component.model().nthDays).toEqual([{ pos: -2, days: ['MO'] }]);
|
||||
expect(component.isNthRowCustom(0)).toBe(true);
|
||||
});
|
||||
|
||||
it('removing a row remaps the custom flags of the rows after it', async () => {
|
||||
await setup('FREQ=MONTHLY;BYDAY=3MO');
|
||||
component.addNthDay(); // row 1
|
||||
component.toggleNthDayWeekday(1, 'SU');
|
||||
component.setNthDayPos(1, component.ORD_CUSTOM); // row 1 = custom
|
||||
expect(component.isNthRowCustom(1)).toBe(true);
|
||||
component.removeNthDay(0); // row 1 becomes row 0
|
||||
expect(component.isNthRowCustom(0)).toBe(true);
|
||||
});
|
||||
|
||||
it('the ordinal dropdown for a new row omits positions already used', async () => {
|
||||
await setup('FREQ=MONTHLY;BYDAY=1MO');
|
||||
component.addNthDay(); // row 1
|
||||
// row 0 uses pos 1, so row 1's options must exclude "first" (1)…
|
||||
expect(component.availableOrdinalOpts(1).map((o) => o.value)).not.toContain(1);
|
||||
// …while row 0's own options still include its current pos.
|
||||
expect(component.availableOrdinalOpts(0).map((o) => o.value)).toContain(1);
|
||||
});
|
||||
|
||||
it('clicking a weekday button patches the correct nth row (nested @for $index)', async () => {
|
||||
// Regression: the per-row weekday buttons live inside a nested @for whose
|
||||
// own $index (the weekday index) shadowed the outer row index, so clicking
|
||||
// anything but the first weekday targeted a wrong/non-existent row.
|
||||
await setup('FREQ=MONTHLY;BYDAY=3MO');
|
||||
const emitted: string[] = [];
|
||||
component.rruleChange.subscribe((r) => emitted.push(r));
|
||||
|
||||
// The single nth-weekday row renders exactly 7 weekday toggle buttons.
|
||||
const group = Array.from(
|
||||
fixture.nativeElement.querySelectorAll('.rb-toggles') as NodeListOf<Element>,
|
||||
).find((el) => el.querySelectorAll('button.rb-tgl').length === 7) as Element;
|
||||
const buttons = group.querySelectorAll('button.rb-tgl');
|
||||
(buttons[2] as HTMLButtonElement).click(); // Wednesday (3rd weekday)
|
||||
fixture.detectChanges();
|
||||
|
||||
// Multi-select: Wednesday is added to the row's existing Monday (Mon-first).
|
||||
expect(component.model().nthDays).toEqual([{ pos: 3, days: ['MO', 'WE'] }]);
|
||||
expect(emitted[emitted.length - 1]).toBe('FREQ=MONTHLY;BYDAY=3MO,3WE');
|
||||
});
|
||||
|
||||
it('emits an assembled rrule when a weekday is toggled', async () => {
|
||||
await setup('FREQ=WEEKLY;BYDAY=MO');
|
||||
const emitted: string[] = [];
|
||||
component.rruleChange.subscribe((r) => emitted.push(r));
|
||||
component.toggleDay('WE');
|
||||
expect(emitted[emitted.length - 1]).toBe('FREQ=WEEKLY;BYDAY=MO,WE');
|
||||
component.toggleDay('MO'); // toggling off removes it
|
||||
expect(emitted[emitted.length - 1]).toBe('FREQ=WEEKLY;BYDAY=WE');
|
||||
});
|
||||
|
||||
it('toggling a month off removes it from BYMONTH', async () => {
|
||||
await setup('FREQ=DAILY;BYMONTH=1,2');
|
||||
const emitted: string[] = [];
|
||||
component.rruleChange.subscribe((r) => emitted.push(r));
|
||||
component.toggleMonth(1);
|
||||
expect(emitted[emitted.length - 1]).toBe('FREQ=DAILY;BYMONTH=2');
|
||||
});
|
||||
|
||||
it('changing frequency emits the new rule', async () => {
|
||||
await setup('FREQ=WEEKLY;BYDAY=MO');
|
||||
const emitted: string[] = [];
|
||||
component.rruleChange.subscribe((r) => emitted.push(r));
|
||||
component.setFreq('DAILY');
|
||||
expect(emitted[emitted.length - 1]).toBe('FREQ=DAILY');
|
||||
});
|
||||
|
||||
it('switching to YEARLY seeds BYMONTH from the start month (else the rule fires monthly)', async () => {
|
||||
await setup('', '2024-06-03'); // fresh builder, June start
|
||||
const emitted: string[] = [];
|
||||
component.rruleChange.subscribe((r) => emitted.push(r));
|
||||
component.setFreq('YEARLY');
|
||||
expect(component.model().byMonth).toEqual([6]);
|
||||
// default yearly mode = on date → must carry BYMONTH to mean "once a year"
|
||||
expect(emitted[emitted.length - 1]).toBe('FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=3');
|
||||
});
|
||||
|
||||
it('switching to YEARLY keeps an existing month selection', async () => {
|
||||
await setup('FREQ=DAILY;BYMONTH=1,2');
|
||||
component.setFreq('YEARLY');
|
||||
expect(component.model().byMonth).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('leaving YEARLY drops the auto-seeded month but keeps user-picked months', async () => {
|
||||
await setup('', '2024-06-03');
|
||||
component.setFreq('YEARLY'); // seeds byMonth=[6]
|
||||
component.setFreq('MONTHLY');
|
||||
// The seed would otherwise constrain the monthly rule to June only.
|
||||
expect(component.model().byMonth).toEqual([]);
|
||||
|
||||
component.setFreq('YEARLY'); // seeds [6] again
|
||||
component.toggleMonth(7); // user takes ownership → [6, 7]
|
||||
component.setFreq('WEEKLY');
|
||||
expect(component.model().byMonth).toEqual([6, 7]);
|
||||
});
|
||||
|
||||
it('mode and frequency switches clear a leftover BYSETPOS', async () => {
|
||||
// A bySetPos set in WEEKDAYS mode would silently narrow a day-of-month
|
||||
// rule (BYMONTHDAY=15;BYSETPOS=2 = never fires) with no UI to clear it.
|
||||
await setup('FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=2');
|
||||
const emitted: string[] = [];
|
||||
component.rruleChange.subscribe((r) => emitted.push(r));
|
||||
component.setMonthlyMode('DAY_OF_MONTH');
|
||||
expect(component.model().bySetPos).toBe('');
|
||||
expect(emitted[emitted.length - 1]).not.toContain('BYSETPOS');
|
||||
|
||||
// Same on a frequency switch.
|
||||
component.setMonthlyMode('WEEKDAYS');
|
||||
component.toggleSetPos(2);
|
||||
expect(component.model().bySetPos).toBe('2');
|
||||
component.setFreq('YEARLY');
|
||||
expect(component.model().bySetPos).toBe('');
|
||||
});
|
||||
|
||||
it('a predefined set-position toggle closes the explicitly opened custom input', async () => {
|
||||
await setup('FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR');
|
||||
component.toggleSetPosCustomMode(); // open custom input (empty value)
|
||||
expect(component.isSetPosCustom()).toBe(true);
|
||||
component.toggleSetPos(1);
|
||||
// Without this, the custom input and the 'first' toggle would both render
|
||||
// active with contradictory state.
|
||||
expect(component.isSetPosCustom()).toBe(false);
|
||||
expect(component.isSetPosActive(1)).toBe(true);
|
||||
});
|
||||
|
||||
it('builds "last weekday of month" (weekday-set mode + set-position toggle)', async () => {
|
||||
await setup('FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR');
|
||||
expect(component.model().monthlyMode).toBe('WEEKDAYS');
|
||||
const emitted: string[] = [];
|
||||
component.rruleChange.subscribe((r) => emitted.push(r));
|
||||
component.toggleSetPos(-1);
|
||||
expect(emitted[emitted.length - 1]).toBe(
|
||||
'FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('set-position toggles multi-select (first + last) and toggle off again', async () => {
|
||||
await setup('FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1');
|
||||
const emitted: string[] = [];
|
||||
component.rruleChange.subscribe((r) => emitted.push(r));
|
||||
component.toggleSetPos(1); // add "first" → kept in dropdown order (1,-1)
|
||||
expect(emitted[emitted.length - 1]).toBe(
|
||||
'FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=1,-1',
|
||||
);
|
||||
expect(component.isSetPosActive(1)).toBe(true);
|
||||
expect(component.isSetPosActive(-1)).toBe(true);
|
||||
component.toggleSetPos(-1); // toggle "last" off
|
||||
expect(emitted[emitted.length - 1]).toBe(
|
||||
'FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=1',
|
||||
);
|
||||
});
|
||||
|
||||
it('"Every" clears all set positions and the custom mode', async () => {
|
||||
await setup('FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=1,-1');
|
||||
expect(component.isSetPosEvery()).toBe(false);
|
||||
const emitted: string[] = [];
|
||||
component.rruleChange.subscribe((r) => emitted.push(r));
|
||||
component.clearSetPos();
|
||||
expect(component.isSetPosEvery()).toBe(true);
|
||||
expect(emitted[emitted.length - 1]).toBe('FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR');
|
||||
});
|
||||
|
||||
it('custom "which occurrence" input emits arbitrary BYSETPOS lists', async () => {
|
||||
await setup('FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR');
|
||||
const emitted: string[] = [];
|
||||
component.rruleChange.subscribe((r) => emitted.push(r));
|
||||
component.setCustomBySetPos('2,-1');
|
||||
expect(emitted[emitted.length - 1]).toBe(
|
||||
'FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=2,-1',
|
||||
);
|
||||
// invalid tokens and zeros are dropped, values clamped to ±366
|
||||
component.setCustomBySetPos('abc, 0, 5, -999');
|
||||
expect(component.model().bySetPos).toBe('5,-366');
|
||||
});
|
||||
|
||||
it('a parsed BYSETPOS with no predefined toggle renders as custom', async () => {
|
||||
await setup('FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=5');
|
||||
expect(component.model().monthlyMode).toBe('WEEKDAYS');
|
||||
expect(component.model().bySetPos).toBe('5');
|
||||
expect(component.isSetPosCustom()).toBe(true);
|
||||
// editing down to a toggle-representable list leaves custom mode (the
|
||||
// input was only open implicitly) and lights up the matching toggles
|
||||
component.setCustomBySetPos('2,-1');
|
||||
expect(component.isSetPosCustom()).toBe(false);
|
||||
expect(component.isSetPosActive(2)).toBe(true);
|
||||
expect(component.isSetPosActive(-1)).toBe(true);
|
||||
});
|
||||
|
||||
it('custom day input accepts arbitrary values like -5', async () => {
|
||||
await setup('FREQ=MONTHLY;BYMONTHDAY=15');
|
||||
const emitted: string[] = [];
|
||||
component.rruleChange.subscribe((r) => emitted.push(r));
|
||||
component.setMonthDays('1,15,-5');
|
||||
expect(emitted[emitted.length - 1]).toBe('FREQ=MONTHLY;BYMONTHDAY=1,15,-5');
|
||||
});
|
||||
|
||||
it('initializes the schedule-type toggle from the repeatFromCompletion input', async () => {
|
||||
await setup('FREQ=DAILY;INTERVAL=3', '2024-06-03', true);
|
||||
expect(component.fromCompletion()).toBe(true);
|
||||
});
|
||||
|
||||
it('emits repeatFromCompletionChange when the schedule type is toggled', async () => {
|
||||
await setup('FREQ=DAILY;INTERVAL=3');
|
||||
expect(component.fromCompletion()).toBe(false);
|
||||
const emitted: boolean[] = [];
|
||||
component.repeatFromCompletionChange.subscribe((v) => emitted.push(v));
|
||||
component.setRepeatFromCompletion(true);
|
||||
expect(component.fromCompletion()).toBe(true);
|
||||
expect(emitted[emitted.length - 1]).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,435 +0,0 @@
|
|||
import {
|
||||
afterNextRender,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
OnInit,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { CollapsibleComponent } from '../../../../ui/collapsible/collapsible.component';
|
||||
import { T } from '../../../../t.const';
|
||||
import { dateStrToUtcDate } from '../../../../util/date-str-to-utc-date';
|
||||
import {
|
||||
defaultRRuleFormModel,
|
||||
formModelToRRule,
|
||||
RRULE_WEEKDAYS,
|
||||
RRuleFormModel,
|
||||
RRuleNthDay,
|
||||
RRuleSetPos,
|
||||
RRuleWeekday,
|
||||
rruleToFormModel,
|
||||
} from '../../util/rrule-form.util';
|
||||
|
||||
interface SelectOpt<V> {
|
||||
value: V;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Parse a comma-separated integer list: trims and truncs each token, drops
|
||||
* invalid/zero ones, clamps the rest to ±bound. Shared by every RFC 5545
|
||||
* int-list input (BYMONTHDAY, BYSETPOS, nth ordinals) so token handling and
|
||||
* clamping can't drift between fields. */
|
||||
const parseIntList = (v: string, bound: number): number[] =>
|
||||
v
|
||||
.split(',')
|
||||
.map((s) => Math.trunc(+s.trim()))
|
||||
.filter((n) => Number.isInteger(n) && n !== 0)
|
||||
.map((n) => Math.max(-bound, Math.min(bound, n)));
|
||||
|
||||
// Translation keys ordered to match RRULE_WEEKDAYS (MO..SU) and months 1..12.
|
||||
const WEEKDAY_T_KEYS = [
|
||||
T.F.TASK_REPEAT.F.MONDAY,
|
||||
T.F.TASK_REPEAT.F.TUESDAY,
|
||||
T.F.TASK_REPEAT.F.WEDNESDAY,
|
||||
T.F.TASK_REPEAT.F.THURSDAY,
|
||||
T.F.TASK_REPEAT.F.FRIDAY,
|
||||
T.F.TASK_REPEAT.F.SATURDAY,
|
||||
T.F.TASK_REPEAT.F.SUNDAY,
|
||||
];
|
||||
const MONTH_T_KEYS = [
|
||||
T.F.TASK_REPEAT.F.RRULE_MONTH_1,
|
||||
T.F.TASK_REPEAT.F.RRULE_MONTH_2,
|
||||
T.F.TASK_REPEAT.F.RRULE_MONTH_3,
|
||||
T.F.TASK_REPEAT.F.RRULE_MONTH_4,
|
||||
T.F.TASK_REPEAT.F.RRULE_MONTH_5,
|
||||
T.F.TASK_REPEAT.F.RRULE_MONTH_6,
|
||||
T.F.TASK_REPEAT.F.RRULE_MONTH_7,
|
||||
T.F.TASK_REPEAT.F.RRULE_MONTH_8,
|
||||
T.F.TASK_REPEAT.F.RRULE_MONTH_9,
|
||||
T.F.TASK_REPEAT.F.RRULE_MONTH_10,
|
||||
T.F.TASK_REPEAT.F.RRULE_MONTH_11,
|
||||
T.F.TASK_REPEAT.F.RRULE_MONTH_12,
|
||||
];
|
||||
|
||||
/**
|
||||
* Purpose-built, readable RRULE editor. Renders weekdays/months as toggle
|
||||
* buttons and arranges the rest as short labelled rows. All recurrence logic
|
||||
* lives in `rrule-form.util` (shared with the @+ parser and the engine); this
|
||||
* component only owns the view + emits the assembled rrule string.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'rrule-builder',
|
||||
templateUrl: './rrule-builder.component.html',
|
||||
styleUrls: ['./rrule-builder.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TranslatePipe, CollapsibleComponent, NgTemplateOutlet],
|
||||
})
|
||||
export class RruleBuilderComponent implements OnInit {
|
||||
private _translateService = inject(TranslateService);
|
||||
T: typeof T = T;
|
||||
|
||||
rrule = input<string>('');
|
||||
startDate = input<string | undefined>(undefined);
|
||||
repeatFromCompletion = input<boolean>(false);
|
||||
rruleChange = output<string>();
|
||||
repeatFromCompletionChange = output<boolean>();
|
||||
|
||||
private _model = signal<RRuleFormModel>(defaultRRuleFormModel());
|
||||
model = this._model.asReadonly();
|
||||
// Orthogonal to the rrule string: re-anchors the interval to the completion
|
||||
// day. Owned here (not in the rrule body) and surfaced to the dialog via output.
|
||||
private _fromCompletion = signal(false);
|
||||
fromCompletion = this._fromCompletion.asReadonly();
|
||||
|
||||
// Toggle data — short label for the button, full name for the tooltip.
|
||||
weekdays = RRULE_WEEKDAYS.map((value, i) => {
|
||||
const full = this._translateService.instant(WEEKDAY_T_KEYS[i]) as string;
|
||||
return { value: value as RRuleWeekday, full, short: full.slice(0, 2) };
|
||||
});
|
||||
months = MONTH_T_KEYS.map((key, i) => {
|
||||
const full = this._translateService.instant(key) as string;
|
||||
return { value: i + 1, full, short: full.slice(0, 3) };
|
||||
});
|
||||
|
||||
freqOpts: SelectOpt<RRuleFormModel['freq']>[] = [
|
||||
{ value: 'DAILY', label: T.F.TASK_REPEAT.F.C_DAY },
|
||||
{ value: 'WEEKLY', label: T.F.TASK_REPEAT.F.C_WEEK },
|
||||
{ value: 'MONTHLY', label: T.F.TASK_REPEAT.F.C_MONTH },
|
||||
{ value: 'YEARLY', label: T.F.TASK_REPEAT.F.C_YEAR },
|
||||
];
|
||||
monthlyModeOpts: SelectOpt<RRuleFormModel['monthlyMode']>[] = [
|
||||
{ value: 'DAY_OF_MONTH', label: T.F.TASK_REPEAT.F.RRULE_MODE_DAY_OF_MONTH },
|
||||
{ value: 'NTH_WEEKDAY', label: T.F.TASK_REPEAT.F.RRULE_MODE_NTH_WEEKDAY },
|
||||
{ value: 'WEEKDAYS', label: T.F.TASK_REPEAT.F.RRULE_MODE_WEEKDAYS },
|
||||
];
|
||||
// Day-of-month grid (1..31) plus "from the end" toggles.
|
||||
dayGrid = Array.from({ length: 31 }, (_, i) => i + 1);
|
||||
negativeDays: SelectOpt<number>[] = [
|
||||
{ value: -1, label: T.F.TASK_REPEAT.F.RRULE_DAY_LAST },
|
||||
{ value: -2, label: T.F.TASK_REPEAT.F.RRULE_DAY_2ND_LAST },
|
||||
{ value: -3, label: T.F.TASK_REPEAT.F.RRULE_DAY_3RD_LAST },
|
||||
];
|
||||
yearlyModeOpts: SelectOpt<RRuleFormModel['yearlyMode']>[] = [
|
||||
{ value: 'DAY_OF_MONTH', label: T.F.TASK_REPEAT.F.RRULE_MODE_ON_DATE },
|
||||
{ value: 'NTH_WEEKDAY', label: T.F.TASK_REPEAT.F.RRULE_MODE_NTH_WEEKDAY },
|
||||
{ value: 'WEEKDAYS', label: T.F.TASK_REPEAT.F.RRULE_MODE_ON_WEEKDAYS },
|
||||
];
|
||||
ordinalOpts: SelectOpt<RRuleSetPos>[] = [
|
||||
{ value: 1, label: T.F.TASK_REPEAT.F.ORD_FIRST },
|
||||
{ value: 2, label: T.F.TASK_REPEAT.F.ORD_SECOND },
|
||||
{ value: 3, label: T.F.TASK_REPEAT.F.ORD_THIRD },
|
||||
{ value: 4, label: T.F.TASK_REPEAT.F.ORD_FOURTH },
|
||||
{ value: -1, label: T.F.TASK_REPEAT.F.ORD_LAST },
|
||||
];
|
||||
/** Sentinel select value for the per-row custom ordinal input. */
|
||||
readonly ORD_CUSTOM = 'CUSTOM';
|
||||
endOpts: SelectOpt<RRuleFormModel['endType']>[] = [
|
||||
{ value: 'NEVER', label: T.F.TASK_REPEAT.F.RRULE_END_NEVER },
|
||||
{ value: 'UNTIL', label: T.F.TASK_REPEAT.F.RRULE_END_UNTIL },
|
||||
{ value: 'COUNT', label: T.F.TASK_REPEAT.F.RRULE_END_COUNT },
|
||||
];
|
||||
weekdaySelectOpts: SelectOpt<RRuleWeekday>[] = this.weekdays.map((w) => ({
|
||||
value: w.value,
|
||||
label: w.full,
|
||||
}));
|
||||
|
||||
constructor() {
|
||||
// Emit the assembled rule once after the first render (outputs are wired,
|
||||
// no change-after-checked) so a fresh builder gives the dialog a valid rrule
|
||||
// even before the user touches anything.
|
||||
afterNextRender(() => this.rruleChange.emit(formModelToRRule(this._model())));
|
||||
}
|
||||
|
||||
// Start month (1..12) — seeds BYMONTH when switching to YEARLY (see setFreq).
|
||||
private _refMonth = new Date().getMonth() + 1;
|
||||
|
||||
ngOnInit(): void {
|
||||
const ref = this.startDate()
|
||||
? dateStrToUtcDate(this.startDate() as string)
|
||||
: new Date();
|
||||
this._refMonth = ref.getMonth() + 1;
|
||||
this._model.set(rruleToFormModel(this.rrule(), ref));
|
||||
this._fromCompletion.set(this.repeatFromCompletion());
|
||||
}
|
||||
|
||||
private _patch(patch: Partial<RRuleFormModel>): void {
|
||||
this._model.update((m) => ({ ...m, ...patch }));
|
||||
this.rruleChange.emit(formModelToRRule(this._model()));
|
||||
}
|
||||
|
||||
// Month auto-seeded by the last switch to YEARLY — cleared once the user
|
||||
// touches the month toggles (they own byMonth from then on).
|
||||
private _seededMonth: number | null = null;
|
||||
|
||||
/** The CURRENT start month — read fresh so an in-dialog startDate edit is
|
||||
* honored (ngOnInit's `_refMonth` would be stale). */
|
||||
private _currentStartMonth(): number {
|
||||
const sd = this.startDate();
|
||||
return sd ? dateStrToUtcDate(sd).getMonth() + 1 : this._refMonth;
|
||||
}
|
||||
|
||||
// --- field setters (kept out of the template for type-safety) ---
|
||||
setFreq(v: string): void {
|
||||
const freq = v as RRuleFormModel['freq'];
|
||||
const prev = this._model().freq;
|
||||
if (freq === prev) return;
|
||||
// Frequency switch starts the occurrence narrowing clean — a BYSETPOS left
|
||||
// over from another frequency's weekday-set mode would silently narrow the
|
||||
// new rule with no UI to see or clear it.
|
||||
this._customSetPos.set(false);
|
||||
const patch: Partial<RRuleFormModel> = { freq, bySetPos: '' };
|
||||
if (freq === 'YEARLY' && !this._model().byMonth.length) {
|
||||
// YEARLY date/weekday modes need BYMONTH: per RFC 5545, FREQ=YEARLY with
|
||||
// a bare BYMONTHDAY expands across every month — i.e. fires monthly.
|
||||
// Seed the start month so a fresh yearly rule means "once a year" (the
|
||||
// serializer also refuses to emit a bare yearly BYMONTHDAY).
|
||||
patch.byMonth = [this._currentStartMonth()];
|
||||
this._seededMonth = patch.byMonth[0];
|
||||
} else if (prev === 'YEARLY') {
|
||||
// Leaving YEARLY: drop a byMonth WE auto-seeded — it would silently
|
||||
// constrain the new rule to that one month. User-picked months stay.
|
||||
const cur = this._model().byMonth;
|
||||
if (this._seededMonth != null && cur.length === 1 && cur[0] === this._seededMonth) {
|
||||
patch.byMonth = [];
|
||||
}
|
||||
this._seededMonth = null;
|
||||
}
|
||||
if (freq === 'MONTHLY') {
|
||||
// Monthly nth ordinals only reach ±5 — clamp custom poses set in YEARLY.
|
||||
const cur = this._model().nthDays;
|
||||
const clamped = cur.map((d) => ({
|
||||
...d,
|
||||
pos: Math.max(-5, Math.min(5, d.pos)),
|
||||
}));
|
||||
if (clamped.some((d, idx) => d.pos !== cur[idx].pos)) {
|
||||
patch.nthDays = clamped;
|
||||
}
|
||||
}
|
||||
this._patch(patch);
|
||||
}
|
||||
setInterval(v: string): void {
|
||||
this._patch({ interval: Math.max(1, Math.floor(+v) || 1) });
|
||||
}
|
||||
toggleDay(d: RRuleWeekday): void {
|
||||
const cur = this._model().byDay;
|
||||
this._patch({ byDay: cur.includes(d) ? cur.filter((x) => x !== d) : [...cur, d] });
|
||||
}
|
||||
toggleMonth(m: number): void {
|
||||
this._seededMonth = null; // user owns byMonth from now on
|
||||
const cur = this._model().byMonth;
|
||||
this._patch({ byMonth: cur.includes(m) ? cur.filter((x) => x !== m) : [...cur, m] });
|
||||
}
|
||||
// Mode switches start the occurrence narrowing clean: a BYSETPOS left over
|
||||
// from the weekday-set mode would silently narrow a day-of-month rule (e.g.
|
||||
// BYMONTHDAY=15;BYSETPOS=2 = never fires) with no UI to see or clear it.
|
||||
setMonthlyMode(v: string): void {
|
||||
this._customSetPos.set(false);
|
||||
this._patch({ monthlyMode: v as RRuleFormModel['monthlyMode'], bySetPos: '' });
|
||||
}
|
||||
setYearlyMode(v: string): void {
|
||||
this._customSetPos.set(false);
|
||||
this._patch({ yearlyMode: v as RRuleFormModel['yearlyMode'], bySetPos: '' });
|
||||
}
|
||||
// --- nth-weekday rows (per-weekday ordinals → BYDAY=3MO,4SU) ---
|
||||
/** True while either the monthly or yearly nth-weekday mode is active. */
|
||||
isNthMode(): boolean {
|
||||
const m = this._model();
|
||||
return (
|
||||
(m.freq === 'MONTHLY' && m.monthlyMode === 'NTH_WEEKDAY') ||
|
||||
(m.freq === 'YEARLY' && m.yearlyMode === 'NTH_WEEKDAY')
|
||||
);
|
||||
}
|
||||
// Rows explicitly switched to the custom ordinal input (kept out of the form
|
||||
// model — pure view state). Rows whose pos has no predefined option are
|
||||
// custom implicitly (e.g. a parsed `BYDAY=-2MO`).
|
||||
private _customNthRows = signal<ReadonlySet<number>>(new Set());
|
||||
|
||||
private _patchNthDay(i: number, patch: Partial<RRuleNthDay>): void {
|
||||
this._patch({
|
||||
nthDays: this._model().nthDays.map((d, idx) =>
|
||||
idx === i ? { ...d, ...patch } : d,
|
||||
),
|
||||
});
|
||||
}
|
||||
isNthRowCustom(i: number): boolean {
|
||||
if (this._customNthRows().has(i)) return true;
|
||||
const pos = this._model().nthDays[i]?.pos;
|
||||
return pos != null && !this.ordinalOpts.some((o) => o.value === pos);
|
||||
}
|
||||
setNthDayPos(i: number, v: string): void {
|
||||
if (v === this.ORD_CUSTOM) {
|
||||
// Switch the row to the free-form ordinal input; keep the current pos as
|
||||
// its starting value.
|
||||
this._customNthRows.update((s) => new Set(s).add(i));
|
||||
return;
|
||||
}
|
||||
this._customNthRows.update((s) => {
|
||||
const next = new Set(s);
|
||||
next.delete(i);
|
||||
return next;
|
||||
});
|
||||
this._patchNthDay(i, { pos: +v });
|
||||
}
|
||||
/** Max meaningful nth ordinal for the current frequency: a month has at most
|
||||
* 5 of any weekday; a year at most 53 (RFC 5545). Values past the bound are
|
||||
* structurally valid but match nothing → silently dead rules. */
|
||||
nthPosBound(): number {
|
||||
return this._model().freq === 'MONTHLY' ? 5 : 53;
|
||||
}
|
||||
/** Free-form ordinal (custom input): any non-zero integer within ±nthPosBound. */
|
||||
setNthDayCustomPos(i: number, v: string): void {
|
||||
const n = Math.trunc(+v);
|
||||
if (!Number.isInteger(n) || n === 0) return; // ignore invalid/zero input
|
||||
const bound = this.nthPosBound();
|
||||
const pos = Math.max(-bound, Math.min(bound, n));
|
||||
// Reject a pos another row already anchors — two rows with the same
|
||||
// ordinal collapse into one on reload (BYDAY can't represent them apart).
|
||||
if (this._model().nthDays.some((d, idx) => idx !== i && d.pos === pos)) return;
|
||||
this._patchNthDay(i, { pos });
|
||||
}
|
||||
/** Multi-select a weekday within an ordinal row; keep it Mon-first. */
|
||||
toggleNthDayWeekday(i: number, d: RRuleWeekday): void {
|
||||
const cur = this._model().nthDays[i]?.days ?? [];
|
||||
const next = cur.includes(d)
|
||||
? cur.filter((x) => x !== d)
|
||||
: RRULE_WEEKDAYS.filter((w) => cur.includes(w) || w === d);
|
||||
this._patchNthDay(i, { days: next });
|
||||
}
|
||||
/** Ordinal options for a row = all minus the positions used by OTHER rows, so
|
||||
* each ordinal ("first", "last", …) anchors at most one row. */
|
||||
availableOrdinalOpts(i: number): SelectOpt<RRuleSetPos>[] {
|
||||
const usedElsewhere = new Set(
|
||||
this._model()
|
||||
.nthDays.filter((_, idx) => idx !== i)
|
||||
.map((d) => d.pos),
|
||||
);
|
||||
return this.ordinalOpts.filter((o) => !usedElsewhere.has(o.value));
|
||||
}
|
||||
/** A new row can be added while an unused ordinal position remains. */
|
||||
canAddNthDay(): boolean {
|
||||
return this._model().nthDays.length < this.ordinalOpts.length;
|
||||
}
|
||||
addNthDay(): void {
|
||||
const used = new Set(this._model().nthDays.map((d) => d.pos));
|
||||
const next = this.ordinalOpts.map((o) => o.value).find((p) => !used.has(p));
|
||||
if (next == null) return; // every ordinal already anchors a row
|
||||
this._patch({ nthDays: [...this._model().nthDays, { pos: next, days: [] }] });
|
||||
}
|
||||
removeNthDay(i: number): void {
|
||||
const cur = this._model().nthDays;
|
||||
if (cur.length <= 1) return; // keep at least one row
|
||||
// Row indices shift down past the removed row — remap the custom-row set.
|
||||
this._customNthRows.update(
|
||||
(s) => new Set([...s].filter((x) => x !== i).map((x) => (x > i ? x - 1 : x))),
|
||||
);
|
||||
this._patch({ nthDays: cur.filter((_, idx) => idx !== i) });
|
||||
}
|
||||
toggleMonthDay(n: number): void {
|
||||
const cur = this._model().monthDays;
|
||||
this._patch({
|
||||
monthDays: cur.includes(n) ? cur.filter((x) => x !== n) : [...cur, n],
|
||||
});
|
||||
}
|
||||
/** Custom override for the day list — accepts any valid day, e.g. "1,15,-5". */
|
||||
setMonthDays(v: string): void {
|
||||
this._patch({ monthDays: parseIntList(v, 31) });
|
||||
}
|
||||
setEndType(v: string): void {
|
||||
this._patch({ endType: v as RRuleFormModel['endType'] });
|
||||
}
|
||||
setCount(v: string): void {
|
||||
this._patch({ count: Math.max(1, Math.floor(+v) || 1) });
|
||||
}
|
||||
setUntil(v: string): void {
|
||||
this._patch({ until: v });
|
||||
}
|
||||
setShowAdvanced(v: boolean): void {
|
||||
this._patch({ showAdvanced: v });
|
||||
}
|
||||
setWkst(v: string): void {
|
||||
this._patch({ wkst: v as RRuleFormModel['wkst'] });
|
||||
}
|
||||
// --- weekday-set "which occurrence" (BYSETPOS, multi-select toggles) ---
|
||||
// The custom input was explicitly opened. Values with no predefined toggle
|
||||
// (e.g. a parsed "5") keep it visible implicitly — see isSetPosCustom().
|
||||
private _customSetPos = signal(false);
|
||||
|
||||
// Parsed once per model change (computed) — the template reads this several
|
||||
// times per change-detection cycle via the toggle bindings.
|
||||
private _setPosValues = computed(() => parseIntList(this._model().bySetPos, 366));
|
||||
|
||||
/** The current BYSETPOS values (parsed from the comma-separated model field). */
|
||||
setPosValues(): number[] {
|
||||
return this._setPosValues();
|
||||
}
|
||||
isSetPosActive(v: number): boolean {
|
||||
return this.setPosValues().includes(v);
|
||||
}
|
||||
/** "Every" = no BYSETPOS narrowing at all. */
|
||||
isSetPosEvery(): boolean {
|
||||
return !this.setPosValues().length && !this._customSetPos();
|
||||
}
|
||||
isSetPosCustom(): boolean {
|
||||
return (
|
||||
this._customSetPos() ||
|
||||
this.setPosValues().some((v) => !this.ordinalOpts.some((o) => o.value === v))
|
||||
);
|
||||
}
|
||||
/** Keep toggles tidy: predefined positions in dropdown order, then customs. */
|
||||
private _normalizeSetPos(vals: number[]): number[] {
|
||||
const pre = this.ordinalOpts
|
||||
.map((o) => o.value as number)
|
||||
.filter((v) => vals.includes(v));
|
||||
const rest = vals.filter((v) => !pre.includes(v)).sort((a, b) => a - b);
|
||||
return [...pre, ...rest];
|
||||
}
|
||||
toggleSetPos(v: number): void {
|
||||
// A predefined toggle closes the explicitly-opened custom input — else both
|
||||
// would render active with contradictory state. (The input stays visible
|
||||
// implicitly while a non-predefined value is present.)
|
||||
this._customSetPos.set(false);
|
||||
const cur = this.setPosValues();
|
||||
const next = cur.includes(v) ? cur.filter((x) => x !== v) : [...cur, v];
|
||||
this._patch({ bySetPos: this._normalizeSetPos(next).join(',') });
|
||||
}
|
||||
clearSetPos(): void {
|
||||
this._customSetPos.set(false);
|
||||
this._patch({ bySetPos: '' });
|
||||
}
|
||||
toggleSetPosCustomMode(): void {
|
||||
this._customSetPos.update((v) => !v);
|
||||
}
|
||||
/** Free-form BYSETPOS (custom input): comma-separated non-zero integers,
|
||||
* each clamped to ±366 (RFC 5545). Invalid tokens are dropped. */
|
||||
setCustomBySetPos(v: string): void {
|
||||
this._patch({ bySetPos: parseIntList(v, 366).join(',') });
|
||||
}
|
||||
setByWeekNo(v: string): void {
|
||||
this._patch({ byWeekNo: v });
|
||||
}
|
||||
setByYearDay(v: string): void {
|
||||
this._patch({ byYearDay: v });
|
||||
}
|
||||
setRawOverride(v: string): void {
|
||||
this._patch({ rawOverride: v });
|
||||
}
|
||||
setRepeatFromCompletion(v: boolean): void {
|
||||
this._fromCompletion.set(v);
|
||||
this.repeatFromCompletionChange.emit(v);
|
||||
}
|
||||
}
|
||||
|
|
@ -74,52 +74,58 @@ describe('TaskRepeatCfgFormConfig', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// NOTE: the 'repeatFromCompletionDate' Formly select was removed along with the
|
||||
// legacy Custom UI — the RRULE builder owns that toggle now (covered by
|
||||
// rrule-builder.component.spec).
|
||||
describe('weekdays group visibility (issue #8025)', () => {
|
||||
const repeatContainer = TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG.find(
|
||||
(field) => field.fieldGroupClassName === 'repeat-config-container',
|
||||
);
|
||||
const weekdaysGroup = repeatContainer?.fieldGroup?.find(
|
||||
(field) => field.fieldGroupClassName === 'weekdays',
|
||||
);
|
||||
const WEEKDAY_KEYS = [
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
'sunday',
|
||||
];
|
||||
|
||||
describe('quickSetting change handler', () => {
|
||||
const getChangeHandler = (): ((field: unknown, event: unknown) => void) => {
|
||||
const field = TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG.find(
|
||||
(f) => f.key === 'quickSetting',
|
||||
);
|
||||
return field!.templateOptions!.change as (field: unknown, event: unknown) => void;
|
||||
};
|
||||
|
||||
const callWith = (
|
||||
startDate: string | undefined,
|
||||
quickSetting: string,
|
||||
): Record<string, unknown> => {
|
||||
let patched: Record<string, unknown> = {};
|
||||
const field = {
|
||||
model: { startDate },
|
||||
form: {
|
||||
patchValue: (v: Record<string, unknown>) => {
|
||||
patched = v;
|
||||
},
|
||||
},
|
||||
};
|
||||
getChangeHandler()(field, { value: quickSetting });
|
||||
return patched;
|
||||
};
|
||||
|
||||
it('uses the selected start date for date-writing presets (not today)', () => {
|
||||
// Regression: without the reference date, MONTHLY_CURRENT_DATE stamped
|
||||
// *today* into the model, overwriting a user-picked future anchor.
|
||||
const patched = callWith('2099-09-15', 'MONTHLY_CURRENT_DATE');
|
||||
expect(patched['startDate']).toBe('2099-09-15');
|
||||
it('should locate the weekdays group', () => {
|
||||
expect(weekdaysGroup).toBeDefined();
|
||||
expect(weekdaysGroup?.fieldGroup?.length).toBe(7);
|
||||
});
|
||||
|
||||
it('uses the selected start date for the weekday flags of weekly presets', () => {
|
||||
// 2099-09-14 is a Monday.
|
||||
const patched = callWith('2099-09-14', 'WEEKLY_CURRENT_WEEKDAY');
|
||||
expect(patched['monday']).toBe(true);
|
||||
expect(patched['tuesday']).toBe(false);
|
||||
// Regression guard for #8025: a `hideExpression` makes formly destroy and
|
||||
// recreate the checkbox views on every cycle switch, which breaks their
|
||||
// wiring to the FormControls (clicks stop updating the model after a
|
||||
// Week -> Month -> Week round-trip). Visibility must be driven by CSS instead.
|
||||
it('should NOT use hideExpression (it would re-freeze the checkboxes)', () => {
|
||||
expect(weekdaysGroup?.hideExpression).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to today when no start date is set', () => {
|
||||
const patched = callWith(undefined, 'MONTHLY_CURRENT_DATE');
|
||||
expect(typeof patched['startDate']).toBe('string');
|
||||
it('should toggle visibility via a dynamic className bound to repeatCycle', () => {
|
||||
const className = weekdaysGroup?.expressionProperties?.['className'] as (model: {
|
||||
repeatCycle: string;
|
||||
}) => string;
|
||||
expect(className).toEqual(jasmine.any(Function));
|
||||
expect(className({ repeatCycle: 'WEEKLY' })).toBe('');
|
||||
expect(className({ repeatCycle: 'MONTHLY' })).toBe('repeat-cfg-hidden');
|
||||
expect(className({ repeatCycle: 'YEARLY' })).toBe('repeat-cfg-hidden');
|
||||
expect(className({ repeatCycle: 'DAILY' })).toBe('repeat-cfg-hidden');
|
||||
});
|
||||
|
||||
// The group stays mounted, but the CUSTOM container above it still hides via
|
||||
// hideExpression. Each checkbox needs resetOnHide:false so the selection
|
||||
// survives a quickSetting != CUSTOM round-trip instead of being wiped.
|
||||
it('should keep every weekday checkbox value across hide (resetOnHide:false)', () => {
|
||||
WEEKDAY_KEYS.forEach((key) => {
|
||||
const field = weekdaysGroup?.fieldGroup?.find((f) => f.key === key);
|
||||
expect(field).withContext(`weekday field "${key}" exists`).toBeDefined();
|
||||
expect(field?.resetOnHide)
|
||||
.withContext(`weekday field "${key}" resetOnHide`)
|
||||
.toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ 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 { dateStrToUtcDate } from '../../../util/date-str-to-utc-date';
|
||||
import { RepeatQuickSetting, TaskRepeatCfg } from '../task-repeat-cfg.model';
|
||||
import { getQuickSettingUpdates } from './get-quick-setting-updates';
|
||||
import { TaskReminderOptionId } from '../../tasks/task.model';
|
||||
|
|
@ -50,16 +49,8 @@ export const TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG: FormlyFieldConfig[] = [
|
|||
// NOTE replaced in component to allow for dynamic translation
|
||||
options: [],
|
||||
change: (field, event) => {
|
||||
// Pass the selected start date as the reference, else date-writing
|
||||
// presets (MONTHLY_CURRENT_DATE etc.) stamp *today* into the model and
|
||||
// silently overwrite a user-picked future anchor (save-time recompute
|
||||
// then reads the overwritten value).
|
||||
const sd = (field.model as { startDate?: string | Date } | undefined)?.startDate;
|
||||
const referenceDate =
|
||||
sd instanceof Date ? sd : sd ? dateStrToUtcDate(sd) : undefined;
|
||||
const updatesForQuickSetting = getQuickSettingUpdates(
|
||||
event.value as RepeatQuickSetting,
|
||||
referenceDate,
|
||||
);
|
||||
if (updatesForQuickSetting) {
|
||||
// NOTE: for some reason this doesn't update the model value, just the view value :(
|
||||
|
|
@ -69,11 +60,165 @@ export const TASK_REPEAT_CFG_ESSENTIAL_FORM_CFG: FormlyFieldConfig[] = [
|
|||
},
|
||||
},
|
||||
|
||||
// REPEAT CUSTOM CFG - Wrapped in container
|
||||
{
|
||||
fieldGroupClassName: 'repeat-config-container',
|
||||
resetOnHide: false,
|
||||
hideExpression: (model: any) => model.quickSetting !== 'CUSTOM',
|
||||
fieldGroup: [
|
||||
{
|
||||
fieldGroupClassName: 'repeat-cycle',
|
||||
fieldGroup: [
|
||||
{
|
||||
key: 'repeatEvery',
|
||||
type: 'input',
|
||||
defaultValue: 1,
|
||||
templateOptions: {
|
||||
label: T.F.TASK_REPEAT.F.REPEAT_EVERY,
|
||||
required: true,
|
||||
min: 1,
|
||||
max: 1000,
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'repeatCycle',
|
||||
type: 'select',
|
||||
defaultValue: 'WEEKLY',
|
||||
templateOptions: {
|
||||
required: true,
|
||||
label: T.F.TASK_REPEAT.F.REPEAT_CYCLE,
|
||||
options: [
|
||||
{ value: 'DAILY', label: T.F.TASK_REPEAT.F.C_DAY },
|
||||
{ value: 'WEEKLY', label: T.F.TASK_REPEAT.F.C_WEEK },
|
||||
{ value: 'MONTHLY', label: T.F.TASK_REPEAT.F.C_MONTH },
|
||||
{ value: 'YEARLY', label: T.F.TASK_REPEAT.F.C_YEAR },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fieldGroupClassName: 'monthly-anchor',
|
||||
resetOnHide: false,
|
||||
hideExpression: (model: any) => model.repeatCycle !== 'MONTHLY',
|
||||
fieldGroup: [
|
||||
{
|
||||
key: 'monthlyWeekOfMonth',
|
||||
type: 'select',
|
||||
// Picking the "Day of month" sentinel clears the anchor; the
|
||||
// gatekeeper falls back to legacy day-of-month behavior.
|
||||
defaultValue: null,
|
||||
templateOptions: {
|
||||
label: T.F.TASK_REPEAT.F.WEEK_OF_MONTH,
|
||||
options: [
|
||||
{ value: null, label: T.F.TASK_REPEAT.F.MONTHLY_MODE_DAY_OF_MONTH },
|
||||
{ value: 1, label: T.F.TASK_REPEAT.F.ORD_FIRST },
|
||||
{ value: 2, label: T.F.TASK_REPEAT.F.ORD_SECOND },
|
||||
{ value: 3, label: T.F.TASK_REPEAT.F.ORD_THIRD },
|
||||
{ value: 4, label: T.F.TASK_REPEAT.F.ORD_FOURTH },
|
||||
{ value: -1, label: T.F.TASK_REPEAT.F.ORD_LAST },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'monthlyWeekday',
|
||||
type: 'select',
|
||||
defaultValue: 1,
|
||||
resetOnHide: false,
|
||||
hideExpression: (model: any) => model.monthlyWeekOfMonth == null,
|
||||
templateOptions: {
|
||||
label: T.F.TASK_REPEAT.F.WEEKDAY,
|
||||
options: [
|
||||
{ value: 1, label: T.F.TASK_REPEAT.F.MONDAY },
|
||||
{ value: 2, label: T.F.TASK_REPEAT.F.TUESDAY },
|
||||
{ value: 3, label: T.F.TASK_REPEAT.F.WEDNESDAY },
|
||||
{ value: 4, label: T.F.TASK_REPEAT.F.THURSDAY },
|
||||
{ value: 5, label: T.F.TASK_REPEAT.F.FRIDAY },
|
||||
{ value: 6, label: T.F.TASK_REPEAT.F.SATURDAY },
|
||||
{ value: 0, label: T.F.TASK_REPEAT.F.SUNDAY },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Hide via a dynamic CSS class instead of `hideExpression`. With formly's
|
||||
// default `lazyRender`, hiding a field group destroys its child views and
|
||||
// recreates them on re-show, and the recreated mat-checkboxes lose their
|
||||
// wiring to the (re-registered) FormControls. After a cycle round-trip
|
||||
// (Week -> Month -> Week) the checkboxes then look enabled but are inert:
|
||||
// clicks no longer update the model (#8025). Keeping the group mounted and
|
||||
// toggling only its visibility preserves the control/view binding.
|
||||
// `resetOnHide: false` on each checkbox keeps the selection when the
|
||||
// CUSTOM container itself is hidden (quickSetting != CUSTOM).
|
||||
fieldGroupClassName: 'weekdays',
|
||||
expressionProperties: {
|
||||
className: (model: TaskRepeatCfg) =>
|
||||
model.repeatCycle === 'WEEKLY' ? '' : 'repeat-cfg-hidden',
|
||||
},
|
||||
fieldGroup: [
|
||||
{
|
||||
key: 'monday',
|
||||
type: 'checkbox',
|
||||
resetOnHide: false,
|
||||
templateOptions: {
|
||||
label: T.F.TASK_REPEAT.F.MONDAY,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'tuesday',
|
||||
type: 'checkbox',
|
||||
resetOnHide: false,
|
||||
templateOptions: {
|
||||
label: T.F.TASK_REPEAT.F.TUESDAY,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'wednesday',
|
||||
type: 'checkbox',
|
||||
resetOnHide: false,
|
||||
templateOptions: {
|
||||
label: T.F.TASK_REPEAT.F.WEDNESDAY,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'thursday',
|
||||
type: 'checkbox',
|
||||
resetOnHide: false,
|
||||
templateOptions: {
|
||||
label: T.F.TASK_REPEAT.F.THURSDAY,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'friday',
|
||||
type: 'checkbox',
|
||||
resetOnHide: false,
|
||||
templateOptions: {
|
||||
label: T.F.TASK_REPEAT.F.FRIDAY,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'saturday',
|
||||
type: 'checkbox',
|
||||
resetOnHide: false,
|
||||
templateOptions: {
|
||||
label: T.F.TASK_REPEAT.F.SATURDAY,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'sunday',
|
||||
type: 'checkbox',
|
||||
resetOnHide: false,
|
||||
templateOptions: {
|
||||
label: T.F.TASK_REPEAT.F.SUNDAY,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
// REPEAT CFG END
|
||||
// NOTE: the legacy "Custom" recurrence container (repeat-every / cycle /
|
||||
// weekday / monthly-anchor controls) was removed — the RRULE builder replaces
|
||||
// it. Existing legacy cfgs are migrated to RRULE on open
|
||||
// (legacyTaskRepeatCfgToRRule + _processQuickSettingForDate).
|
||||
];
|
||||
|
||||
export const TASK_REPEAT_CFG_ADVANCED_FORM_CFG: FormlyFieldConfig[] = [
|
||||
|
|
@ -129,6 +274,47 @@ export const TASK_REPEAT_CFG_ADVANCED_FORM_CFG: FormlyFieldConfig[] = [
|
|||
rows: 4,
|
||||
},
|
||||
},
|
||||
// Schedule type: from due date or from completion
|
||||
{
|
||||
key: 'repeatFromCompletionDate',
|
||||
type: 'select',
|
||||
defaultValue: false,
|
||||
resetOnHide: false,
|
||||
hideExpression: (model: any) => {
|
||||
// Only show for custom settings with intervals > 1
|
||||
if (model.quickSetting !== 'CUSTOM') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
templateOptions: {
|
||||
label: T.F.TASK_REPEAT.F.SCHEDULE_TYPE_LABEL,
|
||||
options: [],
|
||||
},
|
||||
expressionProperties: {
|
||||
['templateOptions.options']: (model: any) => {
|
||||
const repeatEvery = model.repeatEvery || 1;
|
||||
const cycleMap: Record<string, string> = {
|
||||
DAILY: repeatEvery === 1 ? 'day' : 'days',
|
||||
WEEKLY: repeatEvery === 1 ? 'week' : 'weeks',
|
||||
MONTHLY: repeatEvery === 1 ? 'month' : 'months',
|
||||
YEARLY: repeatEvery === 1 ? 'year' : 'years',
|
||||
};
|
||||
const cycleName = cycleMap[model.repeatCycle] || 'days';
|
||||
|
||||
return [
|
||||
{
|
||||
value: false,
|
||||
label: `Fixed schedule (every ${repeatEvery} ${cycleName} from start date)`,
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
label: `After completion (${repeatEvery} ${cycleName} after I finish)`,
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'shouldInheritSubtasks',
|
||||
type: 'checkbox',
|
||||
|
|
@ -159,9 +345,6 @@ export const TASK_REPEAT_CFG_ADVANCED_FORM_CFG: FormlyFieldConfig[] = [
|
|||
description: T.F.TASK_REPEAT.F.WAIT_FOR_COMPLETION_DESCRIPTION,
|
||||
},
|
||||
},
|
||||
// NOTE: the "Schedule type" (repeatFromCompletionDate) select was removed from
|
||||
// here along with the legacy Custom UI — the RRULE builder owns that toggle now
|
||||
// (RruleBuilderComponent.repeatFromCompletion). #5326 / #5388.
|
||||
{
|
||||
key: 'skipOverdue',
|
||||
type: 'checkbox',
|
||||
|
|
@ -171,8 +354,4 @@ export const TASK_REPEAT_CFG_ADVANCED_FORM_CFG: FormlyFieldConfig[] = [
|
|||
description: T.F.TASK_REPEAT.F.SKIP_OVERDUE_DESCRIPTION,
|
||||
},
|
||||
},
|
||||
// NOTE: a 'createForEachMissed' (backfill one task per missed occurrence)
|
||||
// checkbox was removed from this slice — the scheduling engine has no support
|
||||
// for it yet (occurrence engine creates the newest missed only). Re-add the
|
||||
// field together with the engine behavior.
|
||||
];
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import {
|
|||
findMonthlyNthWeekdayOccurrence,
|
||||
hasNthWeekdayAnchor,
|
||||
} from './get-nth-weekday-of-month.util';
|
||||
import { getFirstRRuleOccurrence, isRRuleValid } from './rrule-occurrence.util';
|
||||
import { taskRepeatCfgToRRuleInput } from './task-repeat-cfg-to-rrule-input.util';
|
||||
|
||||
/**
|
||||
* Returns the first valid repeat occurrence on or after `cfg.startDate`.
|
||||
|
|
@ -31,12 +29,6 @@ export const getFirstRepeatOccurrence = (taskRepeatCfg: TaskRepeatCfg): Date | n
|
|||
return null;
|
||||
}
|
||||
|
||||
// Only defer to the engine for a parseable rule; a malformed raw-override
|
||||
// falls through to the legacy weekday/cycle calc below.
|
||||
if (taskRepeatCfg.rrule && isRRuleValid(taskRepeatCfg.rrule)) {
|
||||
return getFirstRRuleOccurrence(taskRepeatCfgToRRuleInput(taskRepeatCfg));
|
||||
}
|
||||
|
||||
// Noon avoids DST transitions
|
||||
const checkDate = dateStrToUtcDate(taskRepeatCfg.startDate);
|
||||
checkDate.setHours(12, 0, 0, 0);
|
||||
|
|
|
|||
|
|
@ -11,8 +11,6 @@ import {
|
|||
hasNthWeekdayAnchor,
|
||||
} from './get-nth-weekday-of-month.util';
|
||||
import { Log } from '../../../core/log';
|
||||
import { getNewestPossibleRRuleDueDate, isRRuleValid } from './rrule-occurrence.util';
|
||||
import { taskRepeatCfgToRRuleInput } from './task-repeat-cfg-to-rrule-input.util';
|
||||
|
||||
export const getNewestPossibleDueDate = (
|
||||
taskRepeatCfg: TaskRepeatCfg,
|
||||
|
|
@ -21,13 +19,6 @@ export const getNewestPossibleDueDate = (
|
|||
// FOR DEBUG
|
||||
// return new Date();
|
||||
|
||||
// A malformed raw-override rule falls through to the legacy schedule fields
|
||||
// instead of stopping the task (only defer to the engine when it parses).
|
||||
if (taskRepeatCfg.rrule && isRRuleValid(taskRepeatCfg.rrule)) {
|
||||
// RRULE ignores repeatEvery/weekday flags; defer entirely to the engine.
|
||||
return getNewestPossibleRRuleDueDate(taskRepeatCfgToRRuleInput(taskRepeatCfg), today);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(taskRepeatCfg.repeatEvery) || taskRepeatCfg.repeatEvery < 1) {
|
||||
Log.warn(
|
||||
`Invalid repeatEvery value "${taskRepeatCfg.repeatEvery}" for TaskRepeatCfg "${taskRepeatCfg.id}"`,
|
||||
|
|
|
|||
|
|
@ -928,23 +928,6 @@ describe('getNextRepeatOccurrence()', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('malformed rrule falls back to the legacy fields', () => {
|
||||
it('uses the legacy weekly schedule when the rrule is unparseable', () => {
|
||||
const cfg = dummyRepeatable('ID1', {
|
||||
rrule: 'this is not a valid rrule',
|
||||
repeatCycle: 'WEEKLY',
|
||||
monday: true,
|
||||
startDate: getDbDateStr(FAKE_MONDAY_THE_10TH),
|
||||
lastTaskCreationDay: getDbDateStr(FAKE_MONDAY_THE_10TH),
|
||||
});
|
||||
// From Mon Jan 10 2022 the next Monday is Jan 17 — NOT null (it would be
|
||||
// null if the bad rrule silently stopped the task instead of falling back).
|
||||
const next = getNextRepeatOccurrence(cfg, new Date(2022, 0, 10, 12));
|
||||
expect(next).not.toBeNull();
|
||||
expect(getDbDateStr(next!)).toBe('2022-01-17');
|
||||
});
|
||||
});
|
||||
|
||||
// Issue #7951: the inclusive variant is used when relocating an existing live
|
||||
// instance on a schedule edit — `fromDate` (today) must be considered instead
|
||||
// of being skipped to the day after the last creation.
|
||||
|
|
@ -1100,22 +1083,5 @@ describe('getNextRepeatOccurrence()', () => {
|
|||
|
||||
expect(getNextRepeatOccurrence(cfg, today, { inclusive: true })).toEqual(tomorrow);
|
||||
});
|
||||
|
||||
it('honors inclusive on the RRULE engine path (today valid → today, not tomorrow)', () => {
|
||||
// All new cfgs are rrule-backed — the inclusive relocation (#7951) must
|
||||
// not silently degrade to strictly-future when the rrule engine routes.
|
||||
const today = noon(new Date());
|
||||
const tomorrow = noon(new Date(today.getTime() + DAY));
|
||||
const cfg = dummyRepeatable('ID1', {
|
||||
rrule: 'FREQ=DAILY',
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 1,
|
||||
startDate: getDbDateStr(today),
|
||||
lastTaskCreationDay: getDbDateStr(today),
|
||||
});
|
||||
|
||||
expect(getNextRepeatOccurrence(cfg, today)).toEqual(tomorrow);
|
||||
expect(getNextRepeatOccurrence(cfg, today, { inclusive: true })).toEqual(today);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,8 +11,6 @@ import {
|
|||
hasNthWeekdayAnchor,
|
||||
} from './get-nth-weekday-of-month.util';
|
||||
import { Log } from '../../../core/log';
|
||||
import { getNextRRuleOccurrence, isRRuleValid } from './rrule-occurrence.util';
|
||||
import { taskRepeatCfgToRRuleInput } from './task-repeat-cfg-to-rrule-input.util';
|
||||
|
||||
export const getNextRepeatOccurrence = (
|
||||
taskRepeatCfg: TaskRepeatCfg,
|
||||
|
|
@ -25,15 +23,6 @@ export const getNextRepeatOccurrence = (
|
|||
// scheduled-list and heatmap.
|
||||
{ inclusive = false }: { inclusive?: boolean } = {},
|
||||
): Date | null => {
|
||||
// Only defer to the RRULE engine when the rule actually parses — a malformed
|
||||
// raw-override rule must fall through to the (kept) legacy schedule fields
|
||||
// rather than silently stopping the task.
|
||||
if (taskRepeatCfg.rrule && isRRuleValid(taskRepeatCfg.rrule)) {
|
||||
return getNextRRuleOccurrence(taskRepeatCfgToRRuleInput(taskRepeatCfg), fromDate, {
|
||||
inclusive,
|
||||
});
|
||||
}
|
||||
|
||||
if (!Number.isInteger(taskRepeatCfg.repeatEvery) || taskRepeatCfg.repeatEvery < 1) {
|
||||
Log.warn(
|
||||
`Invalid repeatEvery value "${taskRepeatCfg.repeatEvery}" for TaskRepeatCfg "${taskRepeatCfg.id}"`,
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
import { getNextRepeatOccurrence } from './get-next-repeat-occurrence.util';
|
||||
import { getFirstRepeatOccurrence } from './get-first-repeat-occurrence.util';
|
||||
import { getNewestPossibleDueDate } from './get-newest-possible-due-date.util';
|
||||
import { DEFAULT_TASK_REPEAT_CFG, TaskRepeatCfg } from '../task-repeat-cfg.model';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
|
||||
// Integration: complex RRULE strings routed through the cfg → engine adapter
|
||||
// (taskRepeatCfgToRRuleInput), crossed with cfg-level settings
|
||||
// (deletedInstanceDates → EXDATE, startDate, lastTaskCreationDay), plus the
|
||||
// validity guard that keeps a malformed rule from stopping a synced task.
|
||||
const rruleCfg = (rrule: string, over: Partial<TaskRepeatCfg> = {}): TaskRepeatCfg => ({
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
id: 'RR',
|
||||
rrule,
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2024-06-01',
|
||||
lastTaskCreationDay: '1970-01-01',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('cfg → RRULE engine routing — complex rules × cfg settings', () => {
|
||||
it('routes MONTHLY per-day ordinals (1st & 3rd Monday) for the first occurrence', () => {
|
||||
const r = getFirstRepeatOccurrence(
|
||||
rruleCfg('FREQ=MONTHLY;BYDAY=1MO,3MO', { startDate: '2024-06-01' }),
|
||||
);
|
||||
expect(getDbDateStr(r!)).toBe('2024-06-03');
|
||||
});
|
||||
|
||||
it('applies deletedInstanceDates as EXDATE on a complex monthly rule', () => {
|
||||
const r = getFirstRepeatOccurrence(
|
||||
rruleCfg('FREQ=MONTHLY;BYDAY=1MO,3MO', {
|
||||
startDate: '2024-06-01',
|
||||
deletedInstanceDates: ['2024-06-03'],
|
||||
}),
|
||||
);
|
||||
// 1st Monday Jun 3 skipped → first becomes the 3rd Monday, Jun 17.
|
||||
expect(getDbDateStr(r!)).toBe('2024-06-17');
|
||||
});
|
||||
|
||||
it('routes last-weekday (BYSETPOS=-1) for the newest-possible due date', () => {
|
||||
const r = getNewestPossibleDueDate(
|
||||
rruleCfg('FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1', {
|
||||
startDate: '2024-05-01',
|
||||
}),
|
||||
new Date(2024, 5, 20, 12),
|
||||
);
|
||||
// Newest last-weekday on/before Jun 20 is May 31 (June's is Jun 28).
|
||||
expect(getDbDateStr(r!)).toBe('2024-05-31');
|
||||
});
|
||||
|
||||
it('routes quarterly (MONTHLY;INTERVAL=3) for the next occurrence', () => {
|
||||
const r = getNextRepeatOccurrence(
|
||||
rruleCfg('FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15', { startDate: '2024-01-15' }),
|
||||
new Date(2024, 1, 1, 12),
|
||||
);
|
||||
expect(getDbDateStr(r!)).toBe('2024-04-15');
|
||||
});
|
||||
|
||||
it('a valid rrule takes precedence over the legacy weekday fields', () => {
|
||||
// rrule = daily; legacy says weekly-Monday. The engine (daily) must win.
|
||||
const r = getNextRepeatOccurrence(
|
||||
rruleCfg('FREQ=DAILY', { startDate: '2024-06-01', monday: true }),
|
||||
new Date(2024, 5, 15, 12),
|
||||
);
|
||||
expect(getDbDateStr(r!)).toBe('2024-06-16');
|
||||
});
|
||||
|
||||
it('a malformed rrule falls back to the legacy schedule instead of stopping (§2a)', () => {
|
||||
const r = getNextRepeatOccurrence(
|
||||
rruleCfg('totally-broken-rule', {
|
||||
startDate: '2024-06-03',
|
||||
monday: true,
|
||||
}),
|
||||
new Date(2024, 5, 15, 12),
|
||||
);
|
||||
// Legacy WEEKLY-Monday from a Saturday → next Monday Jun 17, not null.
|
||||
expect(r).not.toBeNull();
|
||||
expect(getDbDateStr(r!)).toBe('2024-06-17');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import { getNextRepeatOccurrence } from './get-next-repeat-occurrence.util';
|
||||
import { getFirstRepeatOccurrence } from './get-first-repeat-occurrence.util';
|
||||
import { getNewestPossibleDueDate } from './get-newest-possible-due-date.util';
|
||||
import { DEFAULT_TASK_REPEAT_CFG, TaskRepeatCfg } from '../task-repeat-cfg.model';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
|
||||
// Integration: the three routing utils must defer to the RRULE engine whenever
|
||||
// `cfg.rrule` is set (via taskRepeatCfgToRRuleInput), bypassing the legacy
|
||||
// repeatCycle calculation entirely.
|
||||
const rruleCfg = (rrule: string, over: Partial<TaskRepeatCfg> = {}): TaskRepeatCfg => ({
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
id: 'RR',
|
||||
rrule,
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2024-06-01',
|
||||
lastTaskCreationDay: '1970-01-01',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('repeat occurrence routing on cfg.rrule', () => {
|
||||
it('getNextRepeatOccurrence → weekly Monday', () => {
|
||||
// Sat Jun 15 2024 → next Monday is Jun 17.
|
||||
const r = getNextRepeatOccurrence(
|
||||
rruleCfg('FREQ=WEEKLY;BYDAY=MO'),
|
||||
new Date(2024, 5, 15, 12),
|
||||
);
|
||||
expect(getDbDateStr(r!)).toBe('2024-06-17');
|
||||
});
|
||||
|
||||
it('getFirstRepeatOccurrence → first Monday on/after a Saturday start', () => {
|
||||
const r = getFirstRepeatOccurrence(
|
||||
rruleCfg('FREQ=WEEKLY;BYDAY=MO', { startDate: '2024-06-01' }),
|
||||
);
|
||||
expect(getDbDateStr(r!)).toBe('2024-06-03');
|
||||
});
|
||||
|
||||
it('getNewestPossibleDueDate → daily, newest on/before today', () => {
|
||||
const r = getNewestPossibleDueDate(rruleCfg('FREQ=DAILY'), new Date(2024, 5, 15, 12));
|
||||
expect(getDbDateStr(r!)).toBe('2024-06-15');
|
||||
});
|
||||
|
||||
it('routes every-other-week (a pattern the legacy path could not express)', () => {
|
||||
// dtstart Mon Jun 3; occurrences Jun 3, 17, Jul 1 … next after Jun 4 → Jun 17.
|
||||
const r = getNextRepeatOccurrence(
|
||||
rruleCfg('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO', { startDate: '2024-06-03' }),
|
||||
new Date(2024, 5, 4, 12),
|
||||
);
|
||||
expect(getDbDateStr(r!)).toBe('2024-06-17');
|
||||
});
|
||||
|
||||
it('respects COUNT termination through the routing layer', () => {
|
||||
const cfg = rruleCfg('FREQ=DAILY;COUNT=3', { startDate: '2024-06-01' });
|
||||
// Jun 1,2,3 then done → nothing after Jun 3.
|
||||
expect(getNextRepeatOccurrence(cfg, new Date(2024, 5, 3, 12))).toBeNull();
|
||||
});
|
||||
|
||||
it('applies deletedInstanceDates as EXDATEs through the routing layer', () => {
|
||||
const cfg = rruleCfg('FREQ=WEEKLY;BYDAY=MO', {
|
||||
startDate: '2024-06-01',
|
||||
deletedInstanceDates: ['2024-06-03'],
|
||||
});
|
||||
// First Monday Jun 3 is skipped → first occurrence is Jun 10.
|
||||
expect(getDbDateStr(getFirstRepeatOccurrence(cfg)!)).toBe('2024-06-10');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,274 +0,0 @@
|
|||
import {
|
||||
getFirstRRuleOccurrence,
|
||||
getNewestPossibleRRuleDueDate,
|
||||
getNextRRuleOccurrence,
|
||||
getRRuleOccurrencesInRange,
|
||||
isRRuleValid,
|
||||
RRuleOccurrenceInput,
|
||||
} from './rrule-occurrence.util';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
|
||||
// Complex RFC 5545 RRULE coverage at the engine level: a broad matrix of rule
|
||||
// shapes (per-day ordinals, BYSETPOS, BYMONTHDAY=-1, seasonal BYMONTH,
|
||||
// BYWEEKNO/BYYEARDAY, leap years) crossed with the engine's settings
|
||||
// (startDate anchoring, lastTaskCreationDay, EXDATE, COUNT, UNTIL).
|
||||
//
|
||||
// The engine returns occurrences at LOCAL noon, so getDbDateStr() yields the
|
||||
// firing calendar day and the assertions are timezone-stable.
|
||||
|
||||
/** Local-noon Date for a YYYY-MM-DD day (matches engine seed/return semantics). */
|
||||
const D = (s: string): Date => {
|
||||
const [y, m, d] = s.split('-').map(Number);
|
||||
return new Date(y, m - 1, d, 12, 0, 0);
|
||||
};
|
||||
|
||||
const inp = (
|
||||
rrule: string,
|
||||
startDate: string,
|
||||
over: Partial<RRuleOccurrenceInput> = {},
|
||||
): RRuleOccurrenceInput => ({ rrule, startDate, ...over });
|
||||
|
||||
const range = (
|
||||
rrule: string,
|
||||
startDate: string,
|
||||
fromS: string,
|
||||
toS: string,
|
||||
over: Partial<RRuleOccurrenceInput> = {},
|
||||
): string[] =>
|
||||
getRRuleOccurrencesInRange(inp(rrule, startDate, over), D(fromS), D(toS)).map(
|
||||
getDbDateStr,
|
||||
);
|
||||
|
||||
describe('rrule-occurrence engine — complex variants × settings', () => {
|
||||
describe('getRRuleOccurrencesInRange — frequency / BY* shapes', () => {
|
||||
it('WEEKLY multi-weekday BYDAY=MO,WE,FR', () => {
|
||||
expect(
|
||||
range('FREQ=WEEKLY;BYDAY=MO,WE,FR', '2024-06-03', '2024-06-03', '2024-06-09'),
|
||||
).toEqual(['2024-06-03', '2024-06-05', '2024-06-07']);
|
||||
});
|
||||
|
||||
it('WEEKLY INTERVAL=2 (every other Monday)', () => {
|
||||
expect(
|
||||
range(
|
||||
'FREQ=WEEKLY;INTERVAL=2;BYDAY=MO',
|
||||
'2024-06-03',
|
||||
'2024-06-01',
|
||||
'2024-07-15',
|
||||
),
|
||||
).toEqual(['2024-06-03', '2024-06-17', '2024-07-01', '2024-07-15']);
|
||||
});
|
||||
|
||||
it('MONTHLY BYMONTHDAY=-1 (last day, leap-Feb aware)', () => {
|
||||
expect(
|
||||
range('FREQ=MONTHLY;BYMONTHDAY=-1', '2024-01-31', '2024-01-01', '2024-06-30'),
|
||||
).toEqual([
|
||||
'2024-01-31',
|
||||
'2024-02-29',
|
||||
'2024-03-31',
|
||||
'2024-04-30',
|
||||
'2024-05-31',
|
||||
'2024-06-30',
|
||||
]);
|
||||
});
|
||||
|
||||
it('MONTHLY nth-weekday BYDAY=2TU (2nd Tuesday)', () => {
|
||||
expect(
|
||||
range('FREQ=MONTHLY;BYDAY=2TU', '2024-01-01', '2024-01-01', '2024-06-30'),
|
||||
).toEqual([
|
||||
'2024-01-09',
|
||||
'2024-02-13',
|
||||
'2024-03-12',
|
||||
'2024-04-09',
|
||||
'2024-05-14',
|
||||
'2024-06-11',
|
||||
]);
|
||||
});
|
||||
|
||||
it('MONTHLY per-day ordinals BYDAY=1MO,3MO (1st and 3rd Monday)', () => {
|
||||
expect(
|
||||
range('FREQ=MONTHLY;BYDAY=1MO,3MO', '2024-06-01', '2024-06-01', '2024-06-30'),
|
||||
).toEqual(['2024-06-03', '2024-06-17']);
|
||||
});
|
||||
|
||||
it('MONTHLY last weekday BYDAY=MO..FR;BYSETPOS=-1', () => {
|
||||
expect(
|
||||
range(
|
||||
'FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1',
|
||||
'2024-05-01',
|
||||
'2024-05-01',
|
||||
'2024-06-30',
|
||||
),
|
||||
).toEqual(['2024-05-31', '2024-06-28']);
|
||||
});
|
||||
|
||||
it('DAILY INTERVAL=3', () => {
|
||||
expect(
|
||||
range('FREQ=DAILY;INTERVAL=3', '2024-06-01', '2024-06-01', '2024-06-10'),
|
||||
).toEqual(['2024-06-01', '2024-06-04', '2024-06-07', '2024-06-10']);
|
||||
});
|
||||
|
||||
it('MONTHLY INTERVAL=3 + BYMONTHDAY (quarterly on the 15th)', () => {
|
||||
expect(
|
||||
range(
|
||||
'FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15',
|
||||
'2024-01-15',
|
||||
'2024-01-01',
|
||||
'2024-12-31',
|
||||
),
|
||||
).toEqual(['2024-01-15', '2024-04-15', '2024-07-15', '2024-10-15']);
|
||||
});
|
||||
|
||||
it('YEARLY BYMONTH+BYMONTHDAY=29 only fires in leap years', () => {
|
||||
expect(
|
||||
range(
|
||||
'FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29',
|
||||
'2020-02-29',
|
||||
'2020-01-01',
|
||||
'2028-12-31',
|
||||
),
|
||||
).toEqual(['2020-02-29', '2024-02-29', '2028-02-29']);
|
||||
});
|
||||
|
||||
it('YEARLY BYYEARDAY=1 (Jan 1 each year)', () => {
|
||||
expect(
|
||||
range('FREQ=YEARLY;BYYEARDAY=1', '2024-01-01', '2024-01-01', '2026-12-31'),
|
||||
).toEqual(['2024-01-01', '2025-01-01', '2026-01-01']);
|
||||
});
|
||||
|
||||
it('seasonal DAILY;BYMONTH=1 fires every day of January only', () => {
|
||||
const occ = range('FREQ=DAILY;BYMONTH=1', '2024-01-01', '2024-01-01', '2024-02-05');
|
||||
expect(occ.length).toBe(31);
|
||||
expect(occ[0]).toBe('2024-01-01');
|
||||
expect(occ[occ.length - 1]).toBe('2024-01-31');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRRuleOccurrencesInRange — mixed with end conditions / EXDATE', () => {
|
||||
it('BYDAY multi + COUNT=5 terminates after the 5th instance', () => {
|
||||
expect(
|
||||
range(
|
||||
'FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=5',
|
||||
'2024-06-03',
|
||||
'2024-06-01',
|
||||
'2024-06-30',
|
||||
),
|
||||
).toEqual(['2024-06-03', '2024-06-05', '2024-06-07', '2024-06-10', '2024-06-12']);
|
||||
});
|
||||
|
||||
it('BYDAY multi + UNTIL is inclusive of the until day', () => {
|
||||
expect(
|
||||
range(
|
||||
'FREQ=WEEKLY;BYDAY=TU,TH;UNTIL=20240613T120000Z',
|
||||
'2024-06-04',
|
||||
'2024-06-01',
|
||||
'2024-06-30',
|
||||
),
|
||||
).toEqual(['2024-06-04', '2024-06-06', '2024-06-11', '2024-06-13']);
|
||||
});
|
||||
|
||||
it('EXDATE removes exactly the skipped occurrence', () => {
|
||||
expect(
|
||||
range('FREQ=WEEKLY;BYDAY=MO', '2024-06-03', '2024-06-01', '2024-06-30', {
|
||||
exdates: ['2024-06-10'],
|
||||
}),
|
||||
).toEqual(['2024-06-03', '2024-06-17', '2024-06-24']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextRRuleOccurrence — strict-after + start + lastCreation + EXDATE', () => {
|
||||
it('combines startDate, lastTaskCreationDay and EXDATE', () => {
|
||||
// lowerBound = max(from+1=06-06, lastCreation+1=06-11, start=06-03) = 06-11;
|
||||
// first Monday >= 06-11 is 06-17 but it is an EXDATE → 06-24.
|
||||
const r = getNextRRuleOccurrence(
|
||||
inp('FREQ=WEEKLY;BYDAY=MO', '2024-06-03', {
|
||||
lastTaskCreationDay: '2024-06-10',
|
||||
exdates: ['2024-06-17'],
|
||||
}),
|
||||
D('2024-06-05'),
|
||||
);
|
||||
expect(getDbDateStr(r!)).toBe('2024-06-24');
|
||||
});
|
||||
|
||||
it('returns null once a COUNT-bounded rule is exhausted (valid rule, not malformed)', () => {
|
||||
const r = getNextRRuleOccurrence(
|
||||
inp('FREQ=DAILY;COUNT=3', '2024-06-01'),
|
||||
D('2024-06-03'),
|
||||
);
|
||||
expect(r).toBeNull();
|
||||
});
|
||||
|
||||
it('honors an INTERVAL anchored to startDate', () => {
|
||||
// every 3rd day from 06-01: 06-01,06-04,06-07… next strictly after 06-05 → 06-07.
|
||||
const r = getNextRRuleOccurrence(
|
||||
inp('FREQ=DAILY;INTERVAL=3', '2024-06-01'),
|
||||
D('2024-06-05'),
|
||||
);
|
||||
expect(getDbDateStr(r!)).toBe('2024-06-07');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNewestPossibleRRuleDueDate', () => {
|
||||
it('newest on/before today, strictly after lastTaskCreationDay', () => {
|
||||
const r = getNewestPossibleRRuleDueDate(
|
||||
inp('FREQ=WEEKLY;BYDAY=MO', '2024-06-03', { lastTaskCreationDay: '2024-06-10' }),
|
||||
D('2024-06-20'),
|
||||
);
|
||||
expect(getDbDateStr(r!)).toBe('2024-06-17');
|
||||
});
|
||||
|
||||
it('null when the rule starts after today', () => {
|
||||
const r = getNewestPossibleRRuleDueDate(
|
||||
inp('FREQ=DAILY', '2024-07-01'),
|
||||
D('2024-06-20'),
|
||||
);
|
||||
expect(r).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFirstRRuleOccurrence', () => {
|
||||
it('first firing on/after startDate, ignoring lastTaskCreationDay', () => {
|
||||
const r = getFirstRRuleOccurrence(
|
||||
inp('FREQ=WEEKLY;BYDAY=FR', '2024-06-03', { lastTaskCreationDay: '2025-01-01' }),
|
||||
);
|
||||
expect(getDbDateStr(r!)).toBe('2024-06-07');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRRuleValid', () => {
|
||||
it('accepts well-formed complex rules', () => {
|
||||
[
|
||||
'FREQ=DAILY',
|
||||
'FREQ=MONTHLY;BYDAY=2TU',
|
||||
'FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29',
|
||||
'FREQ=WEEKLY;BYDAY=MO,WE,FR;WKST=SU',
|
||||
'FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1',
|
||||
'FREQ=YEARLY;BYWEEKNO=20;BYDAY=MO',
|
||||
].forEach((r) => expect(isRRuleValid(r)).toBe(true));
|
||||
});
|
||||
|
||||
it('rejects empty / FREQ-less / garbage', () => {
|
||||
[undefined, '', ' ', 'not an rrule', 'BYDAY=MO'].forEach((r) =>
|
||||
expect(isRRuleValid(r as string | undefined)).toBe(false),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fail-soft', () => {
|
||||
it('a malformed rule yields [] / null, never throws', () => {
|
||||
expect(
|
||||
getRRuleOccurrencesInRange(
|
||||
inp('NONSENSE', '2024-06-01'),
|
||||
D('2024-06-01'),
|
||||
D('2024-06-30'),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
getNextRRuleOccurrence(inp('NONSENSE', '2024-06-01'), D('2024-06-01')),
|
||||
).toBeNull();
|
||||
expect(getFirstRRuleOccurrence(inp('NONSENSE', '2024-06-01'))).toBeNull();
|
||||
expect(
|
||||
getNewestPossibleRRuleDueDate(inp('NONSENSE', '2024-06-01'), D('2024-06-30')),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
import { getNewestPossibleDueDate } from './get-newest-possible-due-date.util';
|
||||
import { getRRuleOccurrencesInRange } from './rrule-occurrence.util';
|
||||
import { taskRepeatCfgToRRuleInput } from './task-repeat-cfg-to-rrule-input.util';
|
||||
import { DEFAULT_TASK_REPEAT_CFG, TaskRepeatCfg } from '../task-repeat-cfg.model';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
|
||||
// "Day-march" simulation — the highest-fidelity unit mirror of what the running
|
||||
// app does as real days pass. Production (task-repeat-cfg.service.ts) creates an
|
||||
// instance by calling getNewestPossibleDueDate(cfg, today) and then advancing
|
||||
// lastTaskCreationDay to the created day. Here we step `today` forward one day
|
||||
// at a time, feed lastTaskCreationDay back exactly like the service, and assert
|
||||
// the *stream* of created days — catching rolling-loop bugs (double-create,
|
||||
// skipped day, stalled anchor) that a single point-in-time call can't.
|
||||
//
|
||||
// Local-noon dates + getDbDateStr keep everything timezone-stable.
|
||||
|
||||
const D = (s: string): Date => {
|
||||
const [y, m, d] = s.split('-').map(Number);
|
||||
return new Date(y, m - 1, d, 12, 0, 0);
|
||||
};
|
||||
const nextDay = (d: Date): Date =>
|
||||
new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1, 12, 0, 0);
|
||||
|
||||
const rruleCfg = (rrule: string, over: Partial<TaskRepeatCfg> = {}): TaskRepeatCfg => ({
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
id: 'RR',
|
||||
rrule,
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 1,
|
||||
startDate: '2024-06-01',
|
||||
lastTaskCreationDay: '1970-01-01',
|
||||
...over,
|
||||
});
|
||||
|
||||
/**
|
||||
* Walk `today` from startDay for `days` steps. On each open day, do exactly what
|
||||
* the service does: ask for the newest due day and, if one comes back, "create"
|
||||
* it and advance lastTaskCreationDay. `closedDays` simulates the app not running
|
||||
* (no day-change fires that day).
|
||||
*/
|
||||
const march = (
|
||||
cfg: TaskRepeatCfg,
|
||||
startDay: string,
|
||||
days: number,
|
||||
closedDays: Set<string> = new Set(),
|
||||
): string[] => {
|
||||
let lastCreation = cfg.lastTaskCreationDay || '1970-01-01';
|
||||
const created: string[] = [];
|
||||
let today = D(startDay);
|
||||
for (let i = 0; i < days; i++) {
|
||||
const todayStr = getDbDateStr(today);
|
||||
if (!closedDays.has(todayStr)) {
|
||||
const due = getNewestPossibleDueDate(
|
||||
{ ...cfg, lastTaskCreationDay: lastCreation },
|
||||
today,
|
||||
);
|
||||
if (due) {
|
||||
const ds = getDbDateStr(due);
|
||||
created.push(ds);
|
||||
lastCreation = ds; // service advances the anchor to the created day
|
||||
}
|
||||
}
|
||||
today = nextDay(today);
|
||||
}
|
||||
return created;
|
||||
};
|
||||
|
||||
/** Ground truth: the rule's own occurrences across the same window. */
|
||||
const occurrencesIn = (cfg: TaskRepeatCfg, startDay: string, days: number): string[] => {
|
||||
const last = (): Date => {
|
||||
let d = D(startDay);
|
||||
for (let i = 1; i < days; i++) d = nextDay(d);
|
||||
return d;
|
||||
};
|
||||
return getRRuleOccurrencesInRange(
|
||||
taskRepeatCfgToRRuleInput(cfg),
|
||||
D(startDay),
|
||||
last(),
|
||||
).map(getDbDateStr);
|
||||
};
|
||||
|
||||
const hasNoDupes = (xs: string[]): boolean => new Set(xs).size === xs.length;
|
||||
|
||||
describe('RRULE day-march — driving the create loop one day at a time', () => {
|
||||
describe('open every day → created stream equals the occurrence set, no dupes/skips', () => {
|
||||
const cases: { name: string; rrule: string; start: string; days: number }[] = [
|
||||
{ name: 'DAILY', rrule: 'FREQ=DAILY', start: '2024-06-01', days: 30 },
|
||||
{
|
||||
name: 'WEEKLY multi-weekday',
|
||||
rrule: 'FREQ=WEEKLY;BYDAY=MO,WE,FR',
|
||||
start: '2024-06-03',
|
||||
days: 28,
|
||||
},
|
||||
{
|
||||
name: 'every-other-week',
|
||||
rrule: 'FREQ=WEEKLY;INTERVAL=2;BYDAY=MO',
|
||||
start: '2024-06-03',
|
||||
days: 60,
|
||||
},
|
||||
{
|
||||
name: 'MONTHLY 2nd Tuesday',
|
||||
rrule: 'FREQ=MONTHLY;BYDAY=2TU',
|
||||
start: '2024-01-01',
|
||||
days: 160,
|
||||
},
|
||||
{
|
||||
name: 'MONTHLY last weekday (BYSETPOS=-1)',
|
||||
rrule: 'FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1',
|
||||
start: '2024-05-01',
|
||||
days: 150,
|
||||
},
|
||||
{
|
||||
name: 'MONTHLY last day',
|
||||
rrule: 'FREQ=MONTHLY;BYMONTHDAY=-1',
|
||||
start: '2024-01-31',
|
||||
days: 200,
|
||||
},
|
||||
];
|
||||
|
||||
cases.forEach(({ name, rrule, start, days }) => {
|
||||
it(name, () => {
|
||||
const cfg = rruleCfg(rrule, { startDate: start });
|
||||
const created = march(cfg, start, days);
|
||||
expect(created).toEqual(occurrencesIn(cfg, start, days));
|
||||
expect(hasNoDupes(created)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('honors EXDATE across the loop — never creates the deleted day, never stalls', () => {
|
||||
const cfg = rruleCfg('FREQ=WEEKLY;BYDAY=MO', {
|
||||
startDate: '2024-06-03',
|
||||
deletedInstanceDates: ['2024-06-17'],
|
||||
});
|
||||
const created = march(cfg, '2024-06-03', 35);
|
||||
expect(created).not.toContain('2024-06-17');
|
||||
// The Mondays around the hole still fire, in order.
|
||||
expect(created).toEqual(['2024-06-03', '2024-06-10', '2024-06-24', '2024-07-01']);
|
||||
expect(occurrencesIn(cfg, '2024-06-03', 35)).toEqual(created);
|
||||
});
|
||||
|
||||
it('stops creating once a COUNT-bounded rule is exhausted', () => {
|
||||
const cfg = rruleCfg('FREQ=DAILY;COUNT=5', { startDate: '2024-06-01' });
|
||||
const created = march(cfg, '2024-06-01', 30);
|
||||
expect(created).toEqual([
|
||||
'2024-06-01',
|
||||
'2024-06-02',
|
||||
'2024-06-03',
|
||||
'2024-06-04',
|
||||
'2024-06-05',
|
||||
]);
|
||||
});
|
||||
|
||||
it('is idempotent within a day — re-evaluating after a create yields null (no duplicate)', () => {
|
||||
// Anchor already at today → the same day must not produce a second instance.
|
||||
const cfg = rruleCfg('FREQ=DAILY', {
|
||||
startDate: '2024-06-01',
|
||||
lastTaskCreationDay: '2024-06-10',
|
||||
});
|
||||
expect(getNewestPossibleDueDate(cfg, D('2024-06-10'))).toBeNull();
|
||||
});
|
||||
|
||||
describe('app-closed gaps (Phase-1 catch-up = newest missed only, no backfill)', () => {
|
||||
it('a single missed weekly occurrence is caught up one day late', () => {
|
||||
// App closed exactly on Monday Jun 10; reopened Tue Jun 11.
|
||||
const cfg = rruleCfg('FREQ=WEEKLY;BYDAY=MO', { startDate: '2024-06-03' });
|
||||
const created = march(cfg, '2024-06-03', 28, new Set(['2024-06-10']));
|
||||
// Jun 10 is still created (caught up) — just on the day the app reopened.
|
||||
expect(created).toContain('2024-06-10');
|
||||
expect(created).toEqual(['2024-06-03', '2024-06-10', '2024-06-17', '2024-06-24']);
|
||||
});
|
||||
|
||||
it('multiple missed occurrences in one closed gap → only the newest is created', () => {
|
||||
// App closed across BOTH Jun 10 and Jun 17 Mondays; reopened Jun 22.
|
||||
const closed = new Set<string>();
|
||||
let d = D('2024-06-08');
|
||||
for (let i = 0; i < 14; i++) {
|
||||
closed.add(getDbDateStr(d));
|
||||
d = nextDay(d);
|
||||
} // closed Jun 8..21
|
||||
const cfg = rruleCfg('FREQ=WEEKLY;BYDAY=MO', { startDate: '2024-06-03' });
|
||||
const created = march(cfg, '2024-06-03', 35, closed);
|
||||
// Jun 3 created before the gap; on reopen (Jun 22) only the *newest* missed
|
||||
// Monday (Jun 17) is created — Jun 10 is dropped (backfill-each is Phase 5).
|
||||
// The loop then continues correctly from the new anchor.
|
||||
expect(created).toContain('2024-06-17');
|
||||
expect(created).not.toContain('2024-06-10');
|
||||
expect(created).toEqual(['2024-06-03', '2024-06-17', '2024-06-24', '2024-07-01']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,348 +0,0 @@
|
|||
import {
|
||||
getFirstRRuleOccurrence,
|
||||
getNewestPossibleRRuleDueDate,
|
||||
getNextRRuleOccurrence,
|
||||
getRRuleOccurrencesInRange,
|
||||
isRRuleValid,
|
||||
RRuleOccurrenceInput,
|
||||
} from './rrule-occurrence.util';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
|
||||
// Property / invariant + calendar-edge tests for the RRULE occurrence engine.
|
||||
// Every invariant is grammar-agnostic. Local-time Date construction +
|
||||
// getDbDateStr comparisons keep these timezone-independent; ISO `YYYY-MM-DD`
|
||||
// strings compare correctly with < / >.
|
||||
|
||||
const inp = (
|
||||
rrule: string,
|
||||
fields: Partial<RRuleOccurrenceInput> = {},
|
||||
): RRuleOccurrenceInput => ({
|
||||
rrule,
|
||||
startDate: '1970-01-01',
|
||||
lastTaskCreationDay: '1970-01-01',
|
||||
...fields,
|
||||
});
|
||||
|
||||
const VALID_RRULES = [
|
||||
'FREQ=DAILY', // every day
|
||||
'FREQ=WEEKLY;BYDAY=MO', // weekly Monday
|
||||
'FREQ=MONTHLY;BYMONTHDAY=15', // monthly day-15
|
||||
'FREQ=YEARLY;BYMONTH=3,4,5,6,7,8,9,10,11;BYDAY=SA', // Saturdays Mar–Nov
|
||||
'FREQ=MONTHLY;BYMONTHDAY=-1', // last day of month (cron `L`)
|
||||
'FREQ=MONTHLY;BYDAY=3SA', // 3rd Saturday (cron `SAT#3`)
|
||||
'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', // weekdays
|
||||
'FREQ=MONTHLY;BYMONTHDAY=1', // monthly day-1
|
||||
'FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1', // Jan 1 yearly
|
||||
// --- patterns cron CANNOT express ---
|
||||
'FREQ=WEEKLY;INTERVAL=2;BYDAY=MO', // every other Monday
|
||||
'FREQ=DAILY;COUNT=10', // end after 10
|
||||
'FREQ=DAILY;UNTIL=20240630T120000Z', // end on a date
|
||||
];
|
||||
|
||||
const INVALID_RRULES = [
|
||||
'',
|
||||
' ',
|
||||
'nope',
|
||||
'totally bogus text',
|
||||
'INTERVAL=2', // no FREQ
|
||||
'FREQ=BOGUS', // bad frequency
|
||||
'FREQ=WEEKLY;BYDAY=99XX', // bad weekday token
|
||||
];
|
||||
// NB: rrule.js does NOT range-check BYMONTHDAY, so `BYMONTHDAY=99` parses as a
|
||||
// valid-but-never-firing rule (handled like "Feb 30" below: occurrence = null),
|
||||
// not as a parse error — hence it is intentionally absent from INVALID_RRULES.
|
||||
|
||||
const BASE = new Date(2024, 5, 15, 12, 0, 0); // Sat Jun 15 2024, noon
|
||||
const BASE_STR = getDbDateStr(BASE);
|
||||
|
||||
describe('rrule-occurrence invariants', () => {
|
||||
describe('getNextRRuleOccurrence — for every valid rrule', () => {
|
||||
VALID_RRULES.forEach((rrule) => {
|
||||
it(`"${rrule}" returns a Date strictly after fromDate's day, never throws`, () => {
|
||||
// UNTIL/COUNT rules may legitimately have no occurrence after BASE; use a
|
||||
// start aligned with BASE so unbounded rules still produce one.
|
||||
const cfg = inp(rrule, { startDate: '2024-06-01' });
|
||||
// A throw would fail this test; null is allowed (COUNT/UNTIL may be spent).
|
||||
const r = getNextRRuleOccurrence(cfg, BASE);
|
||||
if (r !== null) {
|
||||
expect(getDbDateStr(r) > BASE_STR)
|
||||
.withContext(`${rrule} → ${r}`)
|
||||
.toBe(true);
|
||||
expect(r.getHours()).toBe(12);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('an unbounded rule always returns a later day', () => {
|
||||
const r = getNextRRuleOccurrence(inp('FREQ=DAILY'), BASE);
|
||||
expect(r).not.toBeNull();
|
||||
expect(getDbDateStr(r!) > BASE_STR).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNewestPossibleRRuleDueDate — for every valid rrule', () => {
|
||||
VALID_RRULES.forEach((rrule) => {
|
||||
it(`"${rrule}" returns null or a day on/before today and >= startDate`, () => {
|
||||
const r = getNewestPossibleRRuleDueDate(inp(rrule), BASE);
|
||||
if (r !== null) {
|
||||
expect(getDbDateStr(r) <= BASE_STR)
|
||||
.withContext(`${rrule} → ${r}`)
|
||||
.toBe(true);
|
||||
expect(getDbDateStr(r) >= '1970-01-01').toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid expressions are rejected gracefully (null, no throw)', () => {
|
||||
INVALID_RRULES.forEach((rrule) => {
|
||||
it(`"${rrule}"`, () => {
|
||||
expect(isRRuleValid(rrule)).toBe(false);
|
||||
expect(getNextRRuleOccurrence(inp(rrule), BASE)).toBeNull();
|
||||
expect(getNewestPossibleRRuleDueDate(inp(rrule), BASE)).toBeNull();
|
||||
expect(getFirstRRuleOccurrence(inp(rrule))).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('getNextRRuleOccurrence is deterministic', () => {
|
||||
const a = getNextRRuleOccurrence(inp('FREQ=WEEKLY;BYDAY=MO'), BASE);
|
||||
const b = getNextRRuleOccurrence(inp('FREQ=WEEKLY;BYDAY=MO'), BASE);
|
||||
expect(getDbDateStr(a!)).toBe(getDbDateStr(b!));
|
||||
});
|
||||
|
||||
it('advancing fromDate yields strictly increasing occurrence days (monotonic)', () => {
|
||||
const days: string[] = [];
|
||||
let from = BASE;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const next = getNextRRuleOccurrence(inp('FREQ=WEEKLY;BYDAY=MO'), from);
|
||||
expect(next).not.toBeNull();
|
||||
const s = getDbDateStr(next!);
|
||||
if (days.length) expect(s > days[days.length - 1]).toBe(true);
|
||||
days.push(s);
|
||||
from = next!;
|
||||
}
|
||||
expect(days.length).toBe(6);
|
||||
});
|
||||
|
||||
describe('calendar edges', () => {
|
||||
it('leap day: next Feb 29 from a leap-year start', () => {
|
||||
const cfg = inp('FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29', { startDate: '2024-01-01' });
|
||||
expect(getDbDateStr(getNextRRuleOccurrence(cfg, new Date(2024, 0, 1, 12))!)).toBe(
|
||||
'2024-02-29',
|
||||
);
|
||||
// After Feb 29 2024 the next leap-day is 2028.
|
||||
expect(getDbDateStr(getNextRRuleOccurrence(cfg, new Date(2024, 2, 1, 12))!)).toBe(
|
||||
'2028-02-29',
|
||||
);
|
||||
});
|
||||
|
||||
it('leap day: due today on Feb 29, null on Feb 28 (no fire yet this year)', () => {
|
||||
const cfg = inp('FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29', { startDate: '2024-01-01' });
|
||||
expect(
|
||||
getDbDateStr(getNewestPossibleRRuleDueDate(cfg, new Date(2024, 1, 29, 12))!),
|
||||
).toBe('2024-02-29');
|
||||
expect(getNewestPossibleRRuleDueDate(cfg, new Date(2024, 1, 28, 12))).toBeNull();
|
||||
});
|
||||
|
||||
it('year rollover: monthly day-1 from mid-December → Jan 1 next year', () => {
|
||||
const r = getNextRRuleOccurrence(
|
||||
inp('FREQ=MONTHLY;BYMONTHDAY=1'),
|
||||
new Date(2024, 11, 15, 12),
|
||||
);
|
||||
expect(getDbDateStr(r!)).toBe('2025-01-01');
|
||||
});
|
||||
|
||||
it('yearly Jan 1 from February → Jan 1 next year', () => {
|
||||
const r = getNextRRuleOccurrence(
|
||||
inp('FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1'),
|
||||
new Date(2024, 1, 10, 12),
|
||||
);
|
||||
expect(getDbDateStr(r!)).toBe('2025-01-01');
|
||||
});
|
||||
|
||||
it('pathological "Feb 30" never fires → null, returns promptly (no hang)', () => {
|
||||
const cfg = inp('FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=30');
|
||||
expect(getNextRRuleOccurrence(cfg, BASE)).toBeNull();
|
||||
expect(getNewestPossibleRRuleDueDate(cfg, BASE)).toBeNull();
|
||||
});
|
||||
|
||||
it('DST spring-forward: next() AND newest both resolve the exact day', () => {
|
||||
// US spring-forward 2024-03-10. UTC-space math is structurally DST-immune.
|
||||
expect(
|
||||
getDbDateStr(
|
||||
getNextRRuleOccurrence(inp('FREQ=DAILY'), new Date(2024, 2, 9, 12))!,
|
||||
),
|
||||
).toBe('2024-03-10');
|
||||
expect(
|
||||
getDbDateStr(
|
||||
getNewestPossibleRRuleDueDate(inp('FREQ=DAILY'), new Date(2024, 2, 10, 12))!,
|
||||
),
|
||||
).toBe('2024-03-10');
|
||||
});
|
||||
|
||||
it('DST fall-back: next() and newest both resolve the exact day', () => {
|
||||
// US fall-back 2024-11-03.
|
||||
expect(
|
||||
getDbDateStr(
|
||||
getNextRRuleOccurrence(inp('FREQ=DAILY'), new Date(2024, 10, 2, 12))!,
|
||||
),
|
||||
).toBe('2024-11-03');
|
||||
expect(
|
||||
getDbDateStr(
|
||||
getNewestPossibleRRuleDueDate(inp('FREQ=DAILY'), new Date(2024, 10, 3, 12))!,
|
||||
),
|
||||
).toBe('2024-11-03');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFirstRRuleOccurrence', () => {
|
||||
it('daily: first occurrence is the start day itself', () => {
|
||||
const r = getFirstRRuleOccurrence(inp('FREQ=DAILY', { startDate: '2024-06-01' }));
|
||||
expect(getDbDateStr(r!)).toBe('2024-06-01');
|
||||
});
|
||||
|
||||
it('weekly Monday: first Monday on/after a Saturday start', () => {
|
||||
// 2024-06-01 is a Saturday → first Monday is 2024-06-03.
|
||||
const r = getFirstRRuleOccurrence(
|
||||
inp('FREQ=WEEKLY;BYDAY=MO', { startDate: '2024-06-01' }),
|
||||
);
|
||||
expect(getDbDateStr(r!)).toBe('2024-06-03');
|
||||
});
|
||||
|
||||
it('returns null for an invalid RRULE', () => {
|
||||
expect(
|
||||
getFirstRRuleOccurrence(inp('nope', { startDate: '2024-06-01' })),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Metamorphic relations — these have NO cron analogue; they exercise exactly
|
||||
// the capabilities (true intervals, finite series, exception dates) that
|
||||
// motivated the move off cron.
|
||||
describe('metamorphic relations (beyond cron)', () => {
|
||||
it('INTERVAL=2 occurrences are a subset of INTERVAL=1', () => {
|
||||
const every = inp('FREQ=WEEKLY;BYDAY=MO', { startDate: '2024-06-01' });
|
||||
const other = inp('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO', { startDate: '2024-06-01' });
|
||||
const everySet = new Set<string>();
|
||||
let from = new Date(2024, 5, 1, 12);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const n = getNextRRuleOccurrence(every, from)!;
|
||||
everySet.add(getDbDateStr(n));
|
||||
from = n;
|
||||
}
|
||||
from = new Date(2024, 5, 1, 12);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const n = getNextRRuleOccurrence(other, from)!;
|
||||
expect(everySet.has(getDbDateStr(n)))
|
||||
.withContext(`every-other Monday ${getDbDateStr(n)} must be a weekly Monday`)
|
||||
.toBe(true);
|
||||
from = n;
|
||||
}
|
||||
});
|
||||
|
||||
it('COUNT=N terminates: no occurrence past the Nth', () => {
|
||||
const cfg = inp('FREQ=DAILY;COUNT=3', { startDate: '2024-06-01' });
|
||||
// Occurrences: Jun 1, 2, 3. Asking after Jun 3 → null.
|
||||
expect(getDbDateStr(getNextRRuleOccurrence(cfg, new Date(2024, 5, 2, 12))!)).toBe(
|
||||
'2024-06-03',
|
||||
);
|
||||
expect(getNextRRuleOccurrence(cfg, new Date(2024, 5, 3, 12))).toBeNull();
|
||||
});
|
||||
|
||||
it('UNTIL bounds the series', () => {
|
||||
const cfg = inp('FREQ=DAILY;UNTIL=20240603T120000Z', { startDate: '2024-06-01' });
|
||||
expect(getNextRRuleOccurrence(cfg, new Date(2024, 5, 3, 12))).toBeNull();
|
||||
});
|
||||
|
||||
it('EXDATE removes exactly the skipped day, keeps the rest', () => {
|
||||
const base = inp('FREQ=WEEKLY;BYDAY=MO', { startDate: '2024-06-01' });
|
||||
// First Monday after a Sat-Jun-1 start is Jun 3; skip it → next is Jun 10.
|
||||
const withSkip = inp('FREQ=WEEKLY;BYDAY=MO', {
|
||||
startDate: '2024-06-01',
|
||||
exdates: ['2024-06-03'],
|
||||
});
|
||||
expect(getDbDateStr(getFirstRRuleOccurrence(base)!)).toBe('2024-06-03');
|
||||
expect(getDbDateStr(getFirstRRuleOccurrence(withSkip)!)).toBe('2024-06-10');
|
||||
});
|
||||
});
|
||||
|
||||
describe('serialization integrity (sync / backup)', () => {
|
||||
it('an RRULE input survives a JSON round-trip with identical occurrence behavior', () => {
|
||||
const cfg = inp('FREQ=WEEKLY;BYDAY=MO', { startDate: '2024-06-01' });
|
||||
const roundTripped = JSON.parse(JSON.stringify(cfg)) as RRuleOccurrenceInput;
|
||||
expect(roundTripped.rrule).toBe(cfg.rrule);
|
||||
expect(getDbDateStr(getNextRRuleOccurrence(roundTripped, BASE)!)).toBe(
|
||||
getDbDateStr(getNextRRuleOccurrence(cfg, BASE)!),
|
||||
);
|
||||
expect(getDbDateStr(getFirstRRuleOccurrence(roundTripped)!)).toBe(
|
||||
getDbDateStr(getFirstRRuleOccurrence(cfg)!),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRRuleOccurrencesInRange (heatmap projection)', () => {
|
||||
const fmt = (ds: Date[]): string[] => ds.map(getDbDateStr);
|
||||
|
||||
it('returns every daily occurrence within an inclusive range', () => {
|
||||
const out = getRRuleOccurrencesInRange(
|
||||
inp('FREQ=DAILY', { startDate: '2024-06-01' }),
|
||||
new Date(2024, 5, 10, 12),
|
||||
new Date(2024, 5, 14, 12),
|
||||
);
|
||||
expect(fmt(out)).toEqual([
|
||||
'2024-06-10',
|
||||
'2024-06-11',
|
||||
'2024-06-12',
|
||||
'2024-06-13',
|
||||
'2024-06-14',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns the weekly occurrences in a month window', () => {
|
||||
const out = getRRuleOccurrencesInRange(
|
||||
inp('FREQ=WEEKLY;BYDAY=MO', { startDate: '2024-06-01' }),
|
||||
new Date(2024, 5, 1, 12),
|
||||
new Date(2024, 5, 30, 12),
|
||||
);
|
||||
expect(fmt(out)).toEqual(['2024-06-03', '2024-06-10', '2024-06-17', '2024-06-24']);
|
||||
});
|
||||
|
||||
it('honors EXDATEs', () => {
|
||||
const out = getRRuleOccurrencesInRange(
|
||||
inp('FREQ=WEEKLY;BYDAY=MO', {
|
||||
startDate: '2024-06-01',
|
||||
exdates: ['2024-06-10'],
|
||||
}),
|
||||
new Date(2024, 5, 1, 12),
|
||||
new Date(2024, 5, 30, 12),
|
||||
);
|
||||
expect(fmt(out)).toEqual(['2024-06-03', '2024-06-17', '2024-06-24']);
|
||||
});
|
||||
|
||||
it('returns [] for a malformed rule', () => {
|
||||
expect(
|
||||
getRRuleOccurrencesInRange(
|
||||
inp('nope'),
|
||||
new Date(2024, 5, 1, 12),
|
||||
new Date(2024, 5, 30, 12),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('Friday the 13th (BYMONTHDAY=13;BYDAY=FR) fires only on Friday-13ths', () => {
|
||||
const out = getRRuleOccurrencesInRange(
|
||||
inp('FREQ=MONTHLY;BYMONTHDAY=13;BYDAY=FR', { startDate: '2024-01-01' }),
|
||||
new Date(2024, 0, 1, 12),
|
||||
new Date(2024, 11, 31, 12),
|
||||
);
|
||||
// 2024's only Friday-the-13ths are September and December.
|
||||
expect(out.map(getDbDateStr)).toEqual(['2024-09-13', '2024-12-13']);
|
||||
// Every occurrence is both a Friday and the 13th of its month.
|
||||
out.forEach((d) => {
|
||||
expect(d.getDay()).toBe(5); // Friday
|
||||
expect(d.getDate()).toBe(13);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,238 +0,0 @@
|
|||
import { RRule, RRuleSet } from 'rrule';
|
||||
import { Log } from '../../../core/log';
|
||||
import { safeParseRRuleOptions } from '../util/rrule-parse.util';
|
||||
|
||||
/**
|
||||
* Day-granular, DST-safe occurrence engine for RFC 5545 RRULE strings.
|
||||
*
|
||||
* Provides four contracts (next / newest / first / validity) that the
|
||||
* day-granular recurrence machinery routes to whenever a cfg carries an
|
||||
* `rrule` string, in place of the legacy repeatCycle calculation.
|
||||
*
|
||||
* Two properties that make this engine robust, both *structural* here:
|
||||
*
|
||||
* 1. DST-safety. All recurrence math runs in pure UTC, which has no DST. The
|
||||
* resolved calendar day is only re-expressed at LOCAL noon at the very end
|
||||
* (`toLocalNoon`). Local noon is invariant under DST (transitions happen
|
||||
* ~02:00–03:00), so `getDbDateStr()` of the result is timezone-stable. The
|
||||
* cron engine had to avoid `prev()` because it skipped the spring-forward
|
||||
* midnight; in UTC space `.before()` is safe, so we can use it directly.
|
||||
* 2. Fail-soft. A malformed RRULE never throws out of these functions: it logs
|
||||
* (id/expression only — never user content) and returns `null`, exactly
|
||||
* like `safeParse` in the cron engine.
|
||||
*
|
||||
* RRULE is stored as an opaque string, so adopting it never grows the
|
||||
* `repeatCycle` enum — older sync clients that validate against a fixed enum
|
||||
* set keep accepting the data (forward-compatible, unlike a new `'CRON'` value).
|
||||
*/
|
||||
|
||||
export interface RRuleOccurrenceInput {
|
||||
/** RFC 5545 RRULE body, e.g. `"FREQ=WEEKLY;BYDAY=MO"` (no `RRULE:` prefix needed). */
|
||||
rrule: string;
|
||||
/** Effective recurrence start, `YYYY-MM-DD`. Anchors INTERVAL / COUNT / UNTIL. */
|
||||
startDate: string;
|
||||
/** Last day a task was created, `YYYY-MM-DD`; occurrences must be strictly after it. */
|
||||
lastTaskCreationDay?: string;
|
||||
/** Skipped dates (`YYYY-MM-DD`), RFC 5545 EXDATE. Removed from the occurrence set. */
|
||||
exdates?: string[];
|
||||
}
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const FALLBACK_LAST_CREATION = '1970-01-01';
|
||||
|
||||
/** Noon UTC instant for a `YYYY-MM-DD` string — the canonical occurrence time. */
|
||||
export const noonUtc = (dateStr: string): Date => new Date(`${dateStr}T12:00:00Z`);
|
||||
|
||||
/** UTC-midnight instant for a `YYYY-MM-DD` string. */
|
||||
const _midnightUtc = (dateStr: string): Date => new Date(`${dateStr}T00:00:00Z`);
|
||||
|
||||
/** A Date's LOCAL calendar day pinned to UTC midnight (drops time + tz for clean UTC math). */
|
||||
const _localDayAsUtc = (d: Date): Date =>
|
||||
new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
||||
|
||||
/**
|
||||
* Re-express a UTC occurrence (noon UTC of the intended day) as that same
|
||||
* calendar day at LOCAL noon, matching the cron engine's day-granular output.
|
||||
* Exported (with `noonUtc`) so the preview util anchors occurrences on the
|
||||
* exact same instants as the engine — they must never diverge.
|
||||
*/
|
||||
export const toLocalNoon = (utcOcc: Date): Date =>
|
||||
new Date(utcOcc.getUTCFullYear(), utcOcc.getUTCMonth(), utcOcc.getUTCDate(), 12, 0, 0);
|
||||
|
||||
/** Parse + anchor an RRULE into a set (with EXDATEs), or null if malformed. */
|
||||
const _buildRuleSet = (input: RRuleOccurrenceInput): RRuleSet | null => {
|
||||
const options = safeParseRRuleOptions(input.rrule);
|
||||
if (!options) return null;
|
||||
try {
|
||||
const set = new RRuleSet();
|
||||
set.rrule(new RRule({ ...options, dtstart: noonUtc(input.startDate) }));
|
||||
for (const ex of input.exdates ?? []) {
|
||||
set.exdate(noonUtc(ex));
|
||||
}
|
||||
return set;
|
||||
} catch (e) {
|
||||
// Never log the rule body — the raw-override field makes it free-text user
|
||||
// input, and the log history is exportable.
|
||||
Log.warn('Invalid RRULE', (e as Error)?.name);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// isRRuleValid is called as a routing guard for EVERY repeat cfg on every
|
||||
// overdue/day-change scan; the construct + probe below is the expensive part.
|
||||
// Rule strings are few and immutable, so a tiny memo makes repeat calls free.
|
||||
const _validityCache = new Map<string, boolean>();
|
||||
|
||||
/**
|
||||
* True when `rrule` is a parseable RFC 5545 recurrence with a FREQ. Cheap,
|
||||
* throws nothing, used as a guard everywhere.
|
||||
*/
|
||||
export const isRRuleValid = (rrule: string | undefined): rrule is string => {
|
||||
if (!rrule || !rrule.trim()) return false;
|
||||
const cached = _validityCache.get(rrule);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
let valid = false;
|
||||
const options = safeParseRRuleOptions(rrule);
|
||||
if (options) {
|
||||
try {
|
||||
// Construct + probe once so deeper invalids (bad BYDAY etc.) surface here.
|
||||
new RRule({ ...options, dtstart: noonUtc('2020-01-01') }).after(
|
||||
_midnightUtc('2019-01-01'),
|
||||
false,
|
||||
);
|
||||
valid = true;
|
||||
} catch {
|
||||
// construct/probe failed → invalid
|
||||
}
|
||||
}
|
||||
if (_validityCache.size > 200) _validityCache.clear();
|
||||
_validityCache.set(rrule, valid);
|
||||
return valid;
|
||||
};
|
||||
|
||||
/**
|
||||
* Next occurrence strictly after `fromDate`'s day, on/after `startDate`, and
|
||||
* strictly after `lastTaskCreationDay`. Returned at local noon, or null.
|
||||
*
|
||||
* With `inclusive` the occurrence may fall ON `fromDate`'s day and the
|
||||
* prior-creation gating is ignored — mirroring the legacy engine's inclusive
|
||||
* mode used when relocating an existing live instance on a schedule edit
|
||||
* (#7951): today may still be a valid occurrence and must not be skipped.
|
||||
*/
|
||||
export const getNextRRuleOccurrence = (
|
||||
input: RRuleOccurrenceInput,
|
||||
fromDate: Date,
|
||||
{ inclusive = false }: { inclusive?: boolean } = {},
|
||||
): Date | null => {
|
||||
const set = _buildRuleSet(input);
|
||||
if (!set) return null;
|
||||
|
||||
const startDay = _midnightUtc(input.startDate);
|
||||
const lastCreation = _midnightUtc(input.lastTaskCreationDay || FALLBACK_LAST_CREATION);
|
||||
|
||||
// Earliest eligible DAY: strictly after fromDate's day and the last-created
|
||||
// day, and on/after the start day (whole-day reasoning, like the cron engine).
|
||||
// Inclusive keeps fromDate's own day eligible and drops the creation gate.
|
||||
let lowerBound = inclusive
|
||||
? _localDayAsUtc(fromDate)
|
||||
: new Date(_localDayAsUtc(fromDate).getTime() + DAY_MS);
|
||||
if (!inclusive) {
|
||||
const afterLastCreation = new Date(lastCreation.getTime() + DAY_MS);
|
||||
if (afterLastCreation > lowerBound) lowerBound = afterLastCreation;
|
||||
}
|
||||
if (startDay > lowerBound) lowerBound = startDay;
|
||||
|
||||
try {
|
||||
// `.after()` is exclusive of the seed; step back 1 ms so an occurrence at
|
||||
// noon ON the lower-bound day stays eligible (parity with the cron engine's
|
||||
// midnight-boundary seeding).
|
||||
const occ = set.after(new Date(lowerBound.getTime() - 1), false);
|
||||
return occ ? toLocalNoon(occ) : null;
|
||||
} catch (e) {
|
||||
Log.warn(`RRULE next() failed`, (e as Error)?.name);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Most recent firing day on/before `today`, on/after `startDate`, and strictly
|
||||
* after `lastTaskCreationDay` — the day a task should be created for if not yet
|
||||
* created. Returned at local noon, or null.
|
||||
*/
|
||||
export const getNewestPossibleRRuleDueDate = (
|
||||
input: RRuleOccurrenceInput,
|
||||
today: Date,
|
||||
): Date | null => {
|
||||
const set = _buildRuleSet(input);
|
||||
if (!set) return null;
|
||||
|
||||
const startDay = _midnightUtc(input.startDate);
|
||||
const lastCreation = _midnightUtc(input.lastTaskCreationDay || FALLBACK_LAST_CREATION);
|
||||
const todayDay = _localDayAsUtc(today);
|
||||
|
||||
if (startDay > todayDay) return null;
|
||||
|
||||
try {
|
||||
// Newest occurrence strictly before tomorrow's midnight = on/before today.
|
||||
// `.before()` is DST-safe here because the whole set lives in UTC.
|
||||
const occ = set.before(new Date(todayDay.getTime() + DAY_MS), false);
|
||||
if (!occ) return null;
|
||||
|
||||
const occDay = new Date(
|
||||
Date.UTC(occ.getUTCFullYear(), occ.getUTCMonth(), occ.getUTCDate()),
|
||||
);
|
||||
if (occDay < startDay) return null;
|
||||
// Strictly after the last created day — otherwise it was already created.
|
||||
if (occDay <= lastCreation) return null;
|
||||
return toLocalNoon(occ);
|
||||
} catch (e) {
|
||||
Log.warn(`RRULE before() failed`, (e as Error)?.name);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* First firing on/after `startDate` (ignoring `lastTaskCreationDay`) — used to
|
||||
* decide when a recurring task's first instance should be scheduled. Returned at
|
||||
* local noon, or null.
|
||||
*/
|
||||
export const getFirstRRuleOccurrence = (input: RRuleOccurrenceInput): Date | null => {
|
||||
const set = _buildRuleSet(input);
|
||||
if (!set) return null;
|
||||
|
||||
const startDay = _midnightUtc(input.startDate);
|
||||
try {
|
||||
// Seed 1 ms before the start-day midnight so a fire at start-day noon counts.
|
||||
const occ = set.after(new Date(startDay.getTime() - 1), false);
|
||||
return occ ? toLocalNoon(occ) : null;
|
||||
} catch (e) {
|
||||
Log.warn(`RRULE first() failed`, (e as Error)?.name);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* All occurrences whose calendar day falls within `[from, to]` (inclusive),
|
||||
* returned at local noon. EXDATEs are honored. Empty for a malformed rule.
|
||||
* No production caller yet — exercised by the engine invariant/day-march specs
|
||||
* and intended for a future calendar/heatmap projection of recurring series.
|
||||
*/
|
||||
export const getRRuleOccurrencesInRange = (
|
||||
input: RRuleOccurrenceInput,
|
||||
from: Date,
|
||||
to: Date,
|
||||
): Date[] => {
|
||||
const set = _buildRuleSet(input);
|
||||
if (!set) return [];
|
||||
// Whole-day bounds: from-day start … to-day end, so noon-UTC occurrences on
|
||||
// both boundary days are included regardless of timezone.
|
||||
const lower = _localDayAsUtc(from);
|
||||
const upper = new Date(_localDayAsUtc(to).getTime() + DAY_MS);
|
||||
try {
|
||||
return set.between(lower, upper, true).map(toLocalNoon);
|
||||
} catch (e) {
|
||||
Log.warn(`RRULE between() failed`, (e as Error)?.name);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import { taskRepeatCfgToRRuleInput } from './task-repeat-cfg-to-rrule-input.util';
|
||||
import { getEffectiveRepeatStartDate } from './get-effective-repeat-start-date.util';
|
||||
import { getEffectiveLastTaskCreationDay } from './get-effective-last-task-creation-day.util';
|
||||
import { DEFAULT_TASK_REPEAT_CFG, TaskRepeatCfg } from '../task-repeat-cfg.model';
|
||||
|
||||
const cfg = (over: Partial<TaskRepeatCfg> = {}): TaskRepeatCfg => ({
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
id: 'A',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('taskRepeatCfgToRRuleInput', () => {
|
||||
it('passes the rrule string through verbatim', () => {
|
||||
const c = cfg({ rrule: 'FREQ=WEEKLY;BYDAY=MO', startDate: '2024-06-01' });
|
||||
expect(taskRepeatCfgToRRuleInput(c).rrule).toBe('FREQ=WEEKLY;BYDAY=MO');
|
||||
});
|
||||
|
||||
it('sources startDate / lastTaskCreationDay from the effective helpers', () => {
|
||||
const c = cfg({
|
||||
rrule: 'FREQ=DAILY',
|
||||
startDate: '2024-06-01',
|
||||
lastTaskCreationDay: '2024-06-10',
|
||||
});
|
||||
const input = taskRepeatCfgToRRuleInput(c);
|
||||
expect(input.startDate).toBe(getEffectiveRepeatStartDate(c));
|
||||
expect(input.lastTaskCreationDay).toBe(
|
||||
getEffectiveLastTaskCreationDay(c) || undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('maps deletedInstanceDates onto exdates', () => {
|
||||
const c = cfg({
|
||||
rrule: 'FREQ=DAILY',
|
||||
startDate: '2024-06-01',
|
||||
deletedInstanceDates: ['2024-06-03', '2024-06-05'],
|
||||
});
|
||||
expect(taskRepeatCfgToRRuleInput(c).exdates).toEqual(['2024-06-03', '2024-06-05']);
|
||||
});
|
||||
|
||||
it('leaves exdates undefined when there are no deleted instances', () => {
|
||||
const c = cfg({ rrule: 'FREQ=DAILY', startDate: '2024-06-01' });
|
||||
expect(taskRepeatCfgToRRuleInput(c).exdates).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import { TaskRepeatCfg } from '../task-repeat-cfg.model';
|
||||
import { RRuleOccurrenceInput } from './rrule-occurrence.util';
|
||||
import { getEffectiveRepeatStartDate } from './get-effective-repeat-start-date.util';
|
||||
import { getEffectiveLastTaskCreationDay } from './get-effective-last-task-creation-day.util';
|
||||
|
||||
/**
|
||||
* Adapts a `TaskRepeatCfg` to the decoupled RRULE occurrence engine input.
|
||||
* Only meaningful when `cfg.rrule` is set; callers guard on that first.
|
||||
* Skipped instances (`deletedInstanceDates`) map onto RFC 5545 EXDATEs.
|
||||
*/
|
||||
export const taskRepeatCfgToRRuleInput = (cfg: TaskRepeatCfg): RRuleOccurrenceInput => ({
|
||||
rrule: cfg.rrule as string,
|
||||
startDate: getEffectiveRepeatStartDate(cfg),
|
||||
lastTaskCreationDay: getEffectiveLastTaskCreationDay(cfg) || undefined,
|
||||
exdates: cfg.deletedInstanceDates,
|
||||
});
|
||||
|
|
@ -1,256 +0,0 @@
|
|||
import {
|
||||
addTaskRepeatCfgToTask,
|
||||
updateTaskRepeatCfg,
|
||||
updateTaskRepeatCfgs,
|
||||
} from './task-repeat-cfg.actions';
|
||||
import { DEFAULT_TASK_REPEAT_CFG, TaskRepeatCfg } from '../task-repeat-cfg.model';
|
||||
import { taskRepeatCfgReducer } from './task-repeat-cfg.reducer';
|
||||
|
||||
// The op-log replays action *payloads* (operation-capture.service.ts), so the
|
||||
// action creators are the single boundary that keeps an out-of-union
|
||||
// quickSetting off the wire. These guard that clamp directly.
|
||||
const cfg = (over: Partial<TaskRepeatCfg>): TaskRepeatCfg =>
|
||||
({ ...DEFAULT_TASK_REPEAT_CFG, id: 'r1', ...over }) as TaskRepeatCfg;
|
||||
|
||||
describe('task-repeat-cfg actions — quickSetting persist clamp', () => {
|
||||
describe('addTaskRepeatCfgToTask', () => {
|
||||
it('clamps a newer preset literal to CUSTOM', () => {
|
||||
const action = addTaskRepeatCfgToTask({
|
||||
taskId: 't1',
|
||||
taskRepeatCfg: cfg({
|
||||
quickSetting: 'WEEKENDS',
|
||||
rrule: 'FREQ=WEEKLY;BYDAY=SA,SU',
|
||||
}),
|
||||
});
|
||||
expect(action.taskRepeatCfg.quickSetting).toBe('CUSTOM');
|
||||
// the opaque rule and other fields survive untouched
|
||||
expect(action.taskRepeatCfg.rrule).toBe('FREQ=WEEKLY;BYDAY=SA,SU');
|
||||
});
|
||||
|
||||
it('clamps the in-memory RRULE literal to CUSTOM', () => {
|
||||
const action = addTaskRepeatCfgToTask({
|
||||
taskId: 't1',
|
||||
taskRepeatCfg: cfg({ quickSetting: 'RRULE', rrule: 'FREQ=DAILY' }),
|
||||
});
|
||||
expect(action.taskRepeatCfg.quickSetting).toBe('CUSTOM');
|
||||
});
|
||||
|
||||
it('passes a released (master) value through unchanged', () => {
|
||||
const action = addTaskRepeatCfgToTask({
|
||||
taskId: 't1',
|
||||
taskRepeatCfg: cfg({ quickSetting: 'DAILY' }),
|
||||
});
|
||||
expect(action.taskRepeatCfg.quickSetting).toBe('DAILY');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTaskRepeatCfg', () => {
|
||||
it('clamps quickSetting in the Update changes', () => {
|
||||
const action = updateTaskRepeatCfg({
|
||||
taskRepeatCfg: {
|
||||
id: 'r1',
|
||||
changes: { quickSetting: 'RRULE', rrule: 'FREQ=DAILY' },
|
||||
},
|
||||
});
|
||||
expect(action.taskRepeatCfg.changes.quickSetting).toBe('CUSTOM');
|
||||
expect(action.taskRepeatCfg.changes.rrule).toBe('FREQ=DAILY');
|
||||
expect(action.taskRepeatCfg.id).toBe('r1');
|
||||
});
|
||||
|
||||
it('leaves changes without a quickSetting untouched (never invents one)', () => {
|
||||
const action = updateTaskRepeatCfg({
|
||||
taskRepeatCfg: { id: 'r1', changes: { startTime: '09:00' } },
|
||||
});
|
||||
expect('quickSetting' in action.taskRepeatCfg.changes).toBe(false);
|
||||
expect(action.taskRepeatCfg.changes.startTime).toBe('09:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTaskRepeatCfgs', () => {
|
||||
it('clamps quickSetting in the bulk changes', () => {
|
||||
const action = updateTaskRepeatCfgs({
|
||||
ids: ['r1', 'r2'],
|
||||
changes: { quickSetting: 'QUARTERLY_CURRENT_DATE' },
|
||||
});
|
||||
expect(action.changes.quickSetting).toBe('CUSTOM');
|
||||
expect(action.ids).toEqual(['r1', 'r2']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('task-repeat-cfg actions — monthly anchor null strip', () => {
|
||||
// Released clients' typia schema allows the numeric anchors only
|
||||
// absent-or-numeric: a `null` on the wire would trip their validation /
|
||||
// repair flow. The creators normalize a null leaking in from an untyped
|
||||
// path (formly model, import) to `undefined`, which JSON.stringify drops.
|
||||
it('normalizes null anchors to undefined in a create payload', () => {
|
||||
const action = addTaskRepeatCfgToTask({
|
||||
taskId: 't1',
|
||||
taskRepeatCfg: cfg({
|
||||
monthlyWeekOfMonth: null,
|
||||
monthlyWeekday: null,
|
||||
} as unknown as Partial<TaskRepeatCfg>),
|
||||
});
|
||||
expect(action.taskRepeatCfg.monthlyWeekOfMonth).toBeUndefined();
|
||||
expect(action.taskRepeatCfg.monthlyWeekday).toBeUndefined();
|
||||
expect(
|
||||
JSON.parse(JSON.stringify(action.taskRepeatCfg)).monthlyWeekOfMonth,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('normalizes null anchors to undefined in Update changes', () => {
|
||||
const action = updateTaskRepeatCfg({
|
||||
taskRepeatCfg: {
|
||||
id: 'r1',
|
||||
changes: {
|
||||
monthlyWeekOfMonth: null,
|
||||
monthlyWeekday: null,
|
||||
} as unknown as Partial<TaskRepeatCfg>,
|
||||
},
|
||||
});
|
||||
expect(action.taskRepeatCfg.changes.monthlyWeekOfMonth).toBeUndefined();
|
||||
expect(action.taskRepeatCfg.changes.monthlyWeekday).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes numeric anchors through unchanged', () => {
|
||||
const action = updateTaskRepeatCfg({
|
||||
taskRepeatCfg: {
|
||||
id: 'r1',
|
||||
changes: { monthlyWeekOfMonth: 2, monthlyWeekday: 3 },
|
||||
},
|
||||
});
|
||||
expect(action.taskRepeatCfg.changes.monthlyWeekOfMonth).toBe(2);
|
||||
expect(action.taskRepeatCfg.changes.monthlyWeekday).toBe(3);
|
||||
});
|
||||
|
||||
it('strips out-of-union anchor numbers (released clients typia-reject them)', () => {
|
||||
// e.g. a BYDAY=5MO / -2MO ordinal a converter bug let through, or a
|
||||
// foreign import — must never reach the wire.
|
||||
const action = updateTaskRepeatCfg({
|
||||
taskRepeatCfg: {
|
||||
id: 'r1',
|
||||
changes: {
|
||||
monthlyWeekOfMonth: 5,
|
||||
monthlyWeekday: 9,
|
||||
} as unknown as Partial<TaskRepeatCfg>,
|
||||
},
|
||||
});
|
||||
expect(action.taskRepeatCfg.changes.monthlyWeekOfMonth).toBeUndefined();
|
||||
expect(action.taskRepeatCfg.changes.monthlyWeekday).toBeUndefined();
|
||||
|
||||
const negative = addTaskRepeatCfgToTask({
|
||||
taskId: 't1',
|
||||
taskRepeatCfg: cfg({
|
||||
monthlyWeekOfMonth: -2,
|
||||
monthlyWeekday: 1.5,
|
||||
} as unknown as Partial<TaskRepeatCfg>),
|
||||
});
|
||||
expect(negative.taskRepeatCfg.monthlyWeekOfMonth).toBeUndefined();
|
||||
expect(negative.taskRepeatCfg.monthlyWeekday).toBeUndefined();
|
||||
expect(
|
||||
JSON.parse(JSON.stringify(negative.taskRepeatCfg)).monthlyWeekOfMonth,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the -1 (last) anchor and boundary weekday values', () => {
|
||||
const action = updateTaskRepeatCfg({
|
||||
taskRepeatCfg: {
|
||||
id: 'r1',
|
||||
changes: { monthlyWeekOfMonth: -1, monthlyWeekday: 0 },
|
||||
},
|
||||
});
|
||||
expect(action.taskRepeatCfg.changes.monthlyWeekOfMonth).toBe(-1);
|
||||
expect(action.taskRepeatCfg.changes.monthlyWeekday).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('task-repeat-cfg op-log JSON round-trip (remote-apply durability)', () => {
|
||||
// The op-log serializes action payloads with JSON.stringify, which DROPS
|
||||
// `undefined` keys — a remote client's reducer then merges a partial update
|
||||
// that never contained the cleared field. These tests pin which schedule
|
||||
// transitions ARE durable over the wire and which are a documented gap.
|
||||
const wireRoundTrip = <T>(v: T): T => JSON.parse(JSON.stringify(v));
|
||||
|
||||
const baseEntity = cfg({
|
||||
quickSetting: 'CUSTOM',
|
||||
repeatCycle: 'MONTHLY',
|
||||
startDate: '2024-06-11',
|
||||
// Stored as an nth-weekday cfg ("2nd Tuesday"):
|
||||
monthlyWeekOfMonth: 2,
|
||||
monthlyWeekday: 2,
|
||||
monthlyLastDay: false,
|
||||
rrule: 'FREQ=MONTHLY;BYDAY=2TU',
|
||||
});
|
||||
|
||||
const applyRemotely = (
|
||||
changes: Partial<TaskRepeatCfg>,
|
||||
base: TaskRepeatCfg = baseEntity,
|
||||
): TaskRepeatCfg | undefined => {
|
||||
// Same creator the local client dispatches; the whole ACTION is then
|
||||
// wire-round-tripped (like the op-log payload) and replayed through the
|
||||
// real reducer — exactly what a remote client does.
|
||||
const replayed = wireRoundTrip(
|
||||
updateTaskRepeatCfg({ taskRepeatCfg: { id: base.id, changes } }),
|
||||
);
|
||||
const state = taskRepeatCfgReducer(
|
||||
{
|
||||
ids: [base.id],
|
||||
entities: { [base.id]: base },
|
||||
} as never,
|
||||
replayed,
|
||||
);
|
||||
return state.entities[base.id];
|
||||
};
|
||||
|
||||
it('rrule REPLACEMENT (preset switch) survives the wire', () => {
|
||||
const remote = applyRemotely({
|
||||
rrule: 'FREQ=MONTHLY;BYMONTHDAY=15',
|
||||
monthlyLastDay: false,
|
||||
});
|
||||
expect(remote?.rrule).toBe('FREQ=MONTHLY;BYMONTHDAY=15');
|
||||
});
|
||||
|
||||
it('monthlyLastDay clearing via `false` survives the wire', () => {
|
||||
// Start from `true` so this proves `false` actually overwrites the stored
|
||||
// flag over the wire — against an already-false base the assertion would
|
||||
// pass even if the update were dropped/ignored.
|
||||
const lastDayBase = cfg({
|
||||
...baseEntity,
|
||||
// A coherent "last day of month" base (no Nth-weekday anchor).
|
||||
monthlyWeekOfMonth: undefined,
|
||||
monthlyWeekday: undefined,
|
||||
monthlyLastDay: true,
|
||||
rrule: 'FREQ=MONTHLY;BYMONTHDAY=-1',
|
||||
});
|
||||
const remote = applyRemotely({ monthlyLastDay: false }, lastDayBase);
|
||||
expect(remote?.monthlyLastDay).toBe(false);
|
||||
});
|
||||
|
||||
it('INHERENT LIMITATION: anchor clearing via `undefined` does NOT survive the wire', () => {
|
||||
// This is unfixable for the affected clients, not a deferred TODO:
|
||||
// - The clear can only reach a remote client as a concrete in-schema
|
||||
// value. `hasNthWeekdayAnchor` accepts only {1,2,3,4,-1} × {0..6}, so no
|
||||
// in-schema value means "no anchor" (0 is a valid weekday). `undefined`
|
||||
// is dropped by the op-log's JSON partial-update merge; `null`/`0` trip
|
||||
// released clients' blocking data-repair dialog on every sync.
|
||||
// - The only clients that misfire here are those with the #6040 anchor but
|
||||
// PRE-rrule. They run released code, so no payload we send disables their
|
||||
// anchor path — the engine routes on the anchor, ignoring `rrule`.
|
||||
// - A `| null` migration does NOT help: it would only benefit a client
|
||||
// that is null-aware yet rrule-UNAWARE, but `| null` can only ship
|
||||
// with-or-after rrule (rrule lands first, here), so that band is empty —
|
||||
// any client new enough to accept a null clear already routes on `rrule`,
|
||||
// where the stale anchor is inert. Don't spend a release on it.
|
||||
// Only the UPDATE path is affected; ADD sends the field absent (no anchor
|
||||
// remotely), which is already durable.
|
||||
const remote = applyRemotely({
|
||||
rrule: 'FREQ=MONTHLY;BYMONTHDAY=15',
|
||||
monthlyWeekOfMonth: undefined,
|
||||
monthlyWeekday: undefined,
|
||||
});
|
||||
expect(remote?.monthlyWeekOfMonth).toBe(2); // stale — see comment above
|
||||
expect(remote?.monthlyWeekday).toBe(2);
|
||||
// But the rule itself DID replace, which is what rrule-aware clients use.
|
||||
expect(remote?.rrule).toBe('FREQ=MONTHLY;BYMONTHDAY=15');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,50 +1,9 @@
|
|||
import { createAction, props } from '@ngrx/store';
|
||||
import { Update } from '@ngrx/entity';
|
||||
import { TaskRepeatCfg, toSyncSafeQuickSetting } from '../task-repeat-cfg.model';
|
||||
import { TaskRepeatCfg } from '../task-repeat-cfg.model';
|
||||
import { PersistentActionMeta } from '../../../op-log/core/persistent-action.interface';
|
||||
import { OpType } from '../../../op-log/core/operation.types';
|
||||
|
||||
// Forward/mobile-compat clamp. The op-log replays the action *payload* (not the
|
||||
// reduced state — see operation-capture.service.ts), so a reducer-level clamp
|
||||
// would not keep an out-of-union quickSetting off the wire. Clamping in the
|
||||
// action creators is the single boundary that does: every dispatcher (dialog,
|
||||
// add-task-bar, future @+/REST paths) emits a payload old/mobile clients can
|
||||
// typia-validate. The rich literal (incl. 'RRULE' / newer presets) stays in the
|
||||
// dialog form only. Only touch a defined quickSetting, never invent one.
|
||||
//
|
||||
// Same boundary guards the monthly anchors: released clients typia-validate
|
||||
// monthlyWeekOfMonth against 1|2|3|4|-1 and monthlyWeekday against 0..6
|
||||
// (absent allowed). A `null` or out-of-union number leaking in from an
|
||||
// untyped path (formly model, import, a converter bug) must never reach the
|
||||
// wire — it would trip old clients' validation/repair flow. Normalize to
|
||||
// `undefined`, which JSON.stringify drops.
|
||||
const _isValidWeekOfMonth = (v: unknown): boolean =>
|
||||
v === -1 || (Number.isInteger(v) && (v as number) >= 1 && (v as number) <= 4);
|
||||
const _isValidWeekday = (v: unknown): boolean =>
|
||||
Number.isInteger(v) && (v as number) >= 0 && (v as number) <= 6;
|
||||
|
||||
const _sanitizeAnchors = <T extends Partial<TaskRepeatCfg>>(cfg: T): T => {
|
||||
const w = cfg.monthlyWeekOfMonth as unknown;
|
||||
const d = cfg.monthlyWeekday as unknown;
|
||||
const wBad = w !== undefined && !_isValidWeekOfMonth(w);
|
||||
const dBad = d !== undefined && !_isValidWeekday(d);
|
||||
if (!wBad && !dBad) return cfg;
|
||||
return {
|
||||
...cfg,
|
||||
...(wBad ? { monthlyWeekOfMonth: undefined } : {}),
|
||||
...(dBad ? { monthlyWeekday: undefined } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
// One persist-boundary transform for full cfgs and partial Update changes —
|
||||
// two copies of this body would inevitably drift.
|
||||
const _toPersisted = <T extends Partial<TaskRepeatCfg>>(cfg: T): T => {
|
||||
const out = _sanitizeAnchors(cfg);
|
||||
if (!out.quickSetting) return out;
|
||||
const safe = toSyncSafeQuickSetting(out.quickSetting);
|
||||
return safe === out.quickSetting ? out : { ...out, quickSetting: safe };
|
||||
};
|
||||
|
||||
export const addTaskRepeatCfgToTask = createAction(
|
||||
'[TaskRepeatCfg][Task] Add TaskRepeatCfg to Task',
|
||||
(cfgProps: {
|
||||
|
|
@ -54,7 +13,6 @@ export const addTaskRepeatCfgToTask = createAction(
|
|||
remindAt?: string;
|
||||
}) => ({
|
||||
...cfgProps,
|
||||
taskRepeatCfg: _toPersisted(cfgProps.taskRepeatCfg),
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'TASK_REPEAT_CFG',
|
||||
|
|
@ -71,10 +29,6 @@ export const updateTaskRepeatCfg = createAction(
|
|||
isAskToUpdateAllTaskInstances?: boolean;
|
||||
}) => ({
|
||||
...cfgProps,
|
||||
taskRepeatCfg: {
|
||||
...cfgProps.taskRepeatCfg,
|
||||
changes: _toPersisted(cfgProps.taskRepeatCfg.changes),
|
||||
},
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'TASK_REPEAT_CFG',
|
||||
|
|
@ -88,7 +42,6 @@ export const updateTaskRepeatCfgs = createAction(
|
|||
'[TaskRepeatCfg] Update multiple TaskRepeatCfgs',
|
||||
(cfgProps: { ids: string[]; changes: Partial<TaskRepeatCfg> }) => ({
|
||||
...cfgProps,
|
||||
changes: _toPersisted(cfgProps.changes),
|
||||
meta: {
|
||||
isPersistent: true,
|
||||
entityType: 'TASK_REPEAT_CFG',
|
||||
|
|
|
|||
|
|
@ -51,11 +51,6 @@ const SCHEDULE_AFFECTING_FIELDS: (keyof TaskRepeatCfgCopy)[] = [
|
|||
'startDate',
|
||||
'repeatCycle',
|
||||
'repeatEvery',
|
||||
// RRULE: the rule string alone determines which days fire, so editing it
|
||||
// must re-anchor lastTaskCreationDay like any other schedule change. Omitting
|
||||
// it left a stale (often future) anchor that silently suppressed all
|
||||
// occurrences after an RRULE edit.
|
||||
'rrule',
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
|
|
@ -711,13 +706,8 @@ export class TaskRepeatCfgEffects {
|
|||
),
|
||||
tap(([isConfirm, completeCfg]) => {
|
||||
if (isConfirm) {
|
||||
// Log keys/ids only — `changes` carries user content (title,
|
||||
// notes, rrule body) and the log history is exportable.
|
||||
Log.log({
|
||||
changedKeys: Object.keys(changes),
|
||||
todayTaskIds: todayTasks.map((t) => t.id),
|
||||
archiveTaskIds: archiveTasks.map((t) => t.id),
|
||||
});
|
||||
Log.log(changes);
|
||||
Log.log(todayTasks, archiveTasks);
|
||||
// NOTE: keep in mind that it's very likely that there will be only one task for today
|
||||
// TODO update reminders if given
|
||||
todayTasks.forEach((task) =>
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
import { toSyncSafeQuickSetting } from './task-repeat-cfg.model';
|
||||
|
||||
describe('toSyncSafeQuickSetting (forward-compat)', () => {
|
||||
it('passes through values present in the released (master) union', () => {
|
||||
for (const safe of [
|
||||
'DAILY',
|
||||
'WEEKLY_CURRENT_WEEKDAY',
|
||||
'MONTHLY_CURRENT_DATE',
|
||||
'MONTHLY_FIRST_DAY',
|
||||
'MONTHLY_LAST_DAY',
|
||||
'MONTHLY_NTH_WEEKDAY',
|
||||
'MONDAY_TO_FRIDAY',
|
||||
'YEARLY_CURRENT_DATE',
|
||||
'CUSTOM',
|
||||
] as const) {
|
||||
expect(toSyncSafeQuickSetting(safe)).toBe(safe);
|
||||
}
|
||||
});
|
||||
|
||||
it('maps newer literals (and RRULE) to CUSTOM so old clients can validate', () => {
|
||||
for (const unsafe of [
|
||||
'RRULE',
|
||||
'EVERY_OTHER_DAY',
|
||||
'BIWEEKLY_CURRENT_WEEKDAY',
|
||||
'WEEKENDS',
|
||||
'MONTHLY_LAST_WEEKDAY',
|
||||
'QUARTERLY_CURRENT_DATE',
|
||||
'SEMIANNUALLY_CURRENT_DATE',
|
||||
'EVERY_OTHER_YEAR_CURRENT_DATE',
|
||||
] as const) {
|
||||
expect(toSyncSafeQuickSetting(unsafe)).toBe('CUSTOM');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -15,71 +15,15 @@ export const TASK_REPEAT_WEEKDAY_MAP: (keyof TaskRepeatCfg)[] = [
|
|||
export type RepeatCycleOption = 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
|
||||
export type RepeatQuickSetting =
|
||||
| 'DAILY'
|
||||
| 'EVERY_OTHER_DAY'
|
||||
| 'WEEKLY_CURRENT_WEEKDAY'
|
||||
| 'BIWEEKLY_CURRENT_WEEKDAY'
|
||||
| 'WEEKENDS'
|
||||
| 'MONTHLY_CURRENT_DATE'
|
||||
| 'MONTHLY_FIRST_DAY'
|
||||
| 'MONTHLY_LAST_DAY'
|
||||
| 'MONTHLY_NTH_WEEKDAY'
|
||||
| 'MONTHLY_LAST_WEEKDAY'
|
||||
| 'QUARTERLY_CURRENT_DATE'
|
||||
| 'SEMIANNUALLY_CURRENT_DATE'
|
||||
| 'MONDAY_TO_FRIDAY'
|
||||
| 'YEARLY_CURRENT_DATE'
|
||||
| 'EVERY_OTHER_YEAR_CURRENT_DATE'
|
||||
| 'RRULE'
|
||||
// Legacy persisted value only — the "Custom" UI was removed; such cfgs are
|
||||
// migrated to 'RRULE' on open (legacyTaskRepeatCfgToRRule). Kept in the union
|
||||
// because existing stored data and data-repair still produce it.
|
||||
| 'CUSTOM';
|
||||
|
||||
// Every concrete preset, in menu order — excludes the 'RRULE' builder mode and
|
||||
// the legacy 'CUSTOM' persistence value. Single source for preset-driven logic
|
||||
// (dialog preset detection + preset inference); a preset missing here would
|
||||
// silently reopen as the generic RRULE builder.
|
||||
export const QUICK_SETTING_PRESETS: readonly RepeatQuickSetting[] = [
|
||||
'DAILY',
|
||||
'EVERY_OTHER_DAY',
|
||||
'MONDAY_TO_FRIDAY',
|
||||
'WEEKENDS',
|
||||
'WEEKLY_CURRENT_WEEKDAY',
|
||||
'BIWEEKLY_CURRENT_WEEKDAY',
|
||||
'MONTHLY_CURRENT_DATE',
|
||||
'MONTHLY_FIRST_DAY',
|
||||
'MONTHLY_LAST_DAY',
|
||||
'MONTHLY_NTH_WEEKDAY',
|
||||
'MONTHLY_LAST_WEEKDAY',
|
||||
'QUARTERLY_CURRENT_DATE',
|
||||
'SEMIANNUALLY_CURRENT_DATE',
|
||||
'YEARLY_CURRENT_DATE',
|
||||
'EVERY_OTHER_YEAR_CURRENT_DATE',
|
||||
];
|
||||
|
||||
// The quickSetting values present in the RELEASED (master) RepeatQuickSetting
|
||||
// union — the ONLY values safe to PERSIST/sync. typia sync-validation on an
|
||||
// older/mobile client rejects any out-of-union value on this required field, so
|
||||
// the newer literals (incl. 'RRULE' and the extra presets) must never reach a
|
||||
// stored cfg. They drive the dialog UI in-memory only; persisted cfgs use
|
||||
// 'CUSTOM' and the builder reconstructs from the `rrule` string on open.
|
||||
export const MASTER_SAFE_QUICK_SETTINGS: ReadonlySet<RepeatQuickSetting> =
|
||||
new Set<RepeatQuickSetting>([
|
||||
'DAILY',
|
||||
'WEEKLY_CURRENT_WEEKDAY',
|
||||
'MONTHLY_CURRENT_DATE',
|
||||
'MONTHLY_FIRST_DAY',
|
||||
'MONTHLY_LAST_DAY',
|
||||
'MONTHLY_NTH_WEEKDAY',
|
||||
'MONDAY_TO_FRIDAY',
|
||||
'YEARLY_CURRENT_DATE',
|
||||
'CUSTOM',
|
||||
]);
|
||||
|
||||
/** Map a quick-setting to a value safe to persist (non-master → 'CUSTOM'). */
|
||||
export const toSyncSafeQuickSetting = (qs: RepeatQuickSetting): RepeatQuickSetting =>
|
||||
MASTER_SAFE_QUICK_SETTINGS.has(qs) ? qs : 'CUSTOM';
|
||||
|
||||
// MONTHLY Nth-weekday anchor (issue #6040). Both fields together form an
|
||||
// anchor like "first Thursday" or "last Monday"; either field absent /
|
||||
// out-of-range falls back to legacy day-of-month recurrence.
|
||||
|
|
@ -124,21 +68,7 @@ export interface TaskRepeatCfgCopy {
|
|||
// MONTHLY-only: when both fields are set and in range, the recurrence
|
||||
// anchors to the Nth weekday of each month instead of the numeric day.
|
||||
// Anchor presence is the discriminator — there is no separate mode field.
|
||||
// ONLY absent-or-numeric is sync-safe: released clients' typia schema has no
|
||||
// `null` here, so a null must never be persisted (clearing happens via
|
||||
// `undefined`; a stale anchor on remote clients is inert once `rrule` is
|
||||
// set, since the occurrence engine routes on it). Issue #6040.
|
||||
// INHERENT GAP (not fixable, don't try): an `undefined` clear is dropped by
|
||||
// the op-log's JSON partial-update merge, so a remote PRE-rrule client (which
|
||||
// ignores `rrule`) keeps scheduling from a stale anchor after an nth-weekday →
|
||||
// day-of-month switch. There is no in-schema value meaning "no anchor" to send
|
||||
// instead (0 is a valid weekday; null/0 trip released clients' repair dialog).
|
||||
// A `| null` migration would NOT help: it only benefits a client that is
|
||||
// null-aware yet rrule-UNAWARE, but null can only ship with-or-after rrule, so
|
||||
// that band is empty — any client new enough to accept the null clear already
|
||||
// routes on `rrule`, where the stale anchor is inert. Only the UPDATE path is
|
||||
// affected (ADD sends the field absent → no anchor remotely). See the op-log
|
||||
// JSON round-trip spec pinning this behavior.
|
||||
// Issue #6040.
|
||||
monthlyWeekOfMonth?: MonthlyWeekOfMonth;
|
||||
monthlyWeekday?: MonthlyWeekday;
|
||||
|
||||
|
|
@ -169,16 +99,6 @@ export interface TaskRepeatCfgCopy {
|
|||
deletedInstanceDates?: string[];
|
||||
// When true, missed/overdue instances are silently skipped instead of being created
|
||||
skipOverdue?: boolean;
|
||||
|
||||
// Advanced recurrence: an RFC 5545 RRULE body (e.g. `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO`),
|
||||
// stored WITHOUT the `RRULE:` prefix. When set it wins over the legacy schedule
|
||||
// fields (repeatEvery, weekday flags, monthly anchors) — the occurrence engine
|
||||
// routes on its presence. Stored as an opaque string so it never grows the
|
||||
// `repeatCycle` enum, keeping older sync clients forward-compatible: they ignore
|
||||
// the unknown field and fall back to `repeatCycle` (kept populated with the
|
||||
// FREQ-derived legacy cycle as a best-effort approximation). Lets users express
|
||||
// "every other Saturday, March–November, 10 times" in one config.
|
||||
rrule?: string;
|
||||
}
|
||||
|
||||
export type TaskRepeatCfg = Readonly<TaskRepeatCfgCopy>;
|
||||
|
|
|
|||
|
|
@ -1,462 +0,0 @@
|
|||
import {
|
||||
getAlignedStartDate,
|
||||
legacyTaskRepeatCfgToRRule,
|
||||
rruleToLegacyTaskRepeatCfg,
|
||||
} from './legacy-cfg-to-rrule.util';
|
||||
import { TaskRepeatCfg } from '../task-repeat-cfg.model';
|
||||
import { isRRuleValid } from '../store/rrule-occurrence.util';
|
||||
|
||||
const cfg = (over: Partial<TaskRepeatCfg>): TaskRepeatCfg =>
|
||||
({
|
||||
id: 'r1',
|
||||
repeatEvery: 1,
|
||||
repeatCycle: 'WEEKLY',
|
||||
startDate: '2024-06-03', // a Monday
|
||||
...over,
|
||||
}) as TaskRepeatCfg;
|
||||
|
||||
describe('legacyTaskRepeatCfgToRRule', () => {
|
||||
it('DAILY with interval', () => {
|
||||
expect(
|
||||
legacyTaskRepeatCfgToRRule(cfg({ repeatCycle: 'DAILY', repeatEvery: 3 })),
|
||||
).toBe('FREQ=DAILY;INTERVAL=3');
|
||||
});
|
||||
|
||||
it('DAILY interval 1 omits INTERVAL', () => {
|
||||
expect(legacyTaskRepeatCfgToRRule(cfg({ repeatCycle: 'DAILY' }))).toBe('FREQ=DAILY');
|
||||
});
|
||||
|
||||
it('WEEKLY with selected weekdays in Mon-first order', () => {
|
||||
expect(
|
||||
legacyTaskRepeatCfgToRRule(
|
||||
cfg({
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 2,
|
||||
monday: true,
|
||||
wednesday: true,
|
||||
friday: true,
|
||||
}),
|
||||
),
|
||||
).toBe('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR');
|
||||
});
|
||||
|
||||
it('WEEKLY with no weekdays falls back to the start-date weekday', () => {
|
||||
// 2024-06-03 is a Monday.
|
||||
expect(legacyTaskRepeatCfgToRRule(cfg({ repeatCycle: 'WEEKLY' }))).toBe(
|
||||
'FREQ=WEEKLY;BYDAY=MO',
|
||||
);
|
||||
});
|
||||
|
||||
it('MONTHLY day-of-month from the start date', () => {
|
||||
expect(
|
||||
legacyTaskRepeatCfgToRRule(
|
||||
cfg({ repeatCycle: 'MONTHLY', startDate: '2024-06-15' }),
|
||||
),
|
||||
).toBe('FREQ=MONTHLY;BYMONTHDAY=15');
|
||||
});
|
||||
|
||||
it('MONTHLY day > 28 emits the clamp idiom (legacy clamps, plain BYMONTHDAY skips)', () => {
|
||||
expect(
|
||||
legacyTaskRepeatCfgToRRule(
|
||||
cfg({ repeatCycle: 'MONTHLY', startDate: '2024-01-31' }),
|
||||
),
|
||||
).toBe('FREQ=MONTHLY;BYMONTHDAY=31,-1;BYSETPOS=1');
|
||||
});
|
||||
|
||||
it('YEARLY Feb-29 anchor emits the clamp idiom (legacy clamps to Feb 28)', () => {
|
||||
expect(
|
||||
legacyTaskRepeatCfgToRRule(cfg({ repeatCycle: 'YEARLY', startDate: '2024-02-29' })),
|
||||
).toBe('FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29,-1;BYSETPOS=1');
|
||||
});
|
||||
|
||||
it('MONTHLY last day', () => {
|
||||
expect(
|
||||
legacyTaskRepeatCfgToRRule(cfg({ repeatCycle: 'MONTHLY', monthlyLastDay: true })),
|
||||
).toBe('FREQ=MONTHLY;BYMONTHDAY=-1');
|
||||
});
|
||||
|
||||
it('MONTHLY nth-weekday (2nd Tuesday)', () => {
|
||||
expect(
|
||||
legacyTaskRepeatCfgToRRule(
|
||||
cfg({ repeatCycle: 'MONTHLY', monthlyWeekOfMonth: 2, monthlyWeekday: 2 }),
|
||||
),
|
||||
).toBe('FREQ=MONTHLY;BYDAY=2TU');
|
||||
});
|
||||
|
||||
it('MONTHLY last-weekday (last Monday)', () => {
|
||||
expect(
|
||||
legacyTaskRepeatCfgToRRule(
|
||||
cfg({ repeatCycle: 'MONTHLY', monthlyWeekOfMonth: -1, monthlyWeekday: 1 }),
|
||||
),
|
||||
).toBe('FREQ=MONTHLY;BYDAY=-1MO');
|
||||
});
|
||||
|
||||
it('YEARLY from the start date month/day', () => {
|
||||
expect(
|
||||
legacyTaskRepeatCfgToRRule(cfg({ repeatCycle: 'YEARLY', startDate: '2024-03-17' })),
|
||||
).toBe('FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=17');
|
||||
});
|
||||
|
||||
it('every conversion produces a valid RRULE', () => {
|
||||
const samples: Partial<TaskRepeatCfg>[] = [
|
||||
{ repeatCycle: 'DAILY', repeatEvery: 2 },
|
||||
{ repeatCycle: 'WEEKLY', tuesday: true, thursday: true },
|
||||
{ repeatCycle: 'MONTHLY', monthlyLastDay: true },
|
||||
{ repeatCycle: 'MONTHLY', monthlyWeekOfMonth: 3, monthlyWeekday: 5 },
|
||||
{ repeatCycle: 'MONTHLY', startDate: '2024-01-31' },
|
||||
{ repeatCycle: 'YEARLY', startDate: '2024-12-25' },
|
||||
{ repeatCycle: 'YEARLY', startDate: '2024-02-29' },
|
||||
];
|
||||
samples.forEach((s) =>
|
||||
expect(isRRuleValid(legacyTaskRepeatCfgToRRule(cfg(s)))).toBe(true),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses UTC weekday so a negative-offset timezone does not shift the day', () => {
|
||||
// 2024-06-03 is a Monday in UTC; must map to MO regardless of host tz.
|
||||
expect(legacyTaskRepeatCfgToRRule(cfg({ repeatCycle: 'WEEKLY' }))).toContain(
|
||||
'BYDAY=MO',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rruleToLegacyTaskRepeatCfg', () => {
|
||||
// Every result carries explicit monthly-anchor resets so a spread-merge
|
||||
// clears stale values from a previous preset/rule. The numeric anchors reset
|
||||
// to `undefined` — `null` is NOT master-safe (released clients' typia schema
|
||||
// allows only absent-or-numeric); `monthlyLastDay` resets to `false`, which
|
||||
// is master-safe AND survives the JSON wire.
|
||||
const ANCHOR_RESETS = {
|
||||
monthlyWeekOfMonth: undefined,
|
||||
monthlyWeekday: undefined,
|
||||
monthlyLastDay: false,
|
||||
};
|
||||
|
||||
it('DAILY with interval', () => {
|
||||
expect(rruleToLegacyTaskRepeatCfg('FREQ=DAILY;INTERVAL=3')).toEqual({
|
||||
repeatCycle: 'DAILY',
|
||||
repeatEvery: 3,
|
||||
...ANCHOR_RESETS,
|
||||
});
|
||||
});
|
||||
|
||||
it('WEEKLY sets exactly the rule weekdays, clearing the rest', () => {
|
||||
const out = rruleToLegacyTaskRepeatCfg('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR');
|
||||
expect(out.repeatCycle).toBe('WEEKLY');
|
||||
expect(out.repeatEvery).toBe(2);
|
||||
expect(out.monday).toBe(true);
|
||||
expect(out.wednesday).toBe(true);
|
||||
expect(out.friday).toBe(true);
|
||||
expect(out.tuesday).toBe(false);
|
||||
expect(out.thursday).toBe(false);
|
||||
expect(out.saturday).toBe(false);
|
||||
expect(out.sunday).toBe(false);
|
||||
});
|
||||
|
||||
it('MONTHLY nth-weekday → anchor fields (legacy 0=Sun weekday)', () => {
|
||||
expect(rruleToLegacyTaskRepeatCfg('FREQ=MONTHLY;BYDAY=2TU')).toEqual({
|
||||
repeatCycle: 'MONTHLY',
|
||||
repeatEvery: 1,
|
||||
...ANCHOR_RESETS,
|
||||
monthlyWeekOfMonth: 2,
|
||||
monthlyWeekday: 2, // Tuesday (Sun=0)
|
||||
});
|
||||
});
|
||||
|
||||
it('MONTHLY last weekday', () => {
|
||||
expect(rruleToLegacyTaskRepeatCfg('FREQ=MONTHLY;BYDAY=-1MO')).toEqual({
|
||||
repeatCycle: 'MONTHLY',
|
||||
repeatEvery: 1,
|
||||
...ANCHOR_RESETS,
|
||||
monthlyWeekOfMonth: -1,
|
||||
monthlyWeekday: 1, // Monday
|
||||
});
|
||||
});
|
||||
|
||||
it('maps single-weekday BYSETPOS monthly rules onto the nth-weekday anchor', () => {
|
||||
// The builder's weekday-set form (BYDAY=FR;BYSETPOS=-1 = "last Friday") is
|
||||
// losslessly equivalent to BYDAY=-1FR — without the mapping old clients
|
||||
// would fall back to startDate's day-of-month, a WRONG recurrence.
|
||||
expect(rruleToLegacyTaskRepeatCfg('FREQ=MONTHLY;BYDAY=FR;BYSETPOS=-1')).toEqual({
|
||||
repeatCycle: 'MONTHLY',
|
||||
repeatEvery: 1,
|
||||
...ANCHOR_RESETS,
|
||||
monthlyWeekOfMonth: -1,
|
||||
monthlyWeekday: 5, // Friday (Sun=0)
|
||||
});
|
||||
const second = rruleToLegacyTaskRepeatCfg('FREQ=MONTHLY;BYDAY=TU;BYSETPOS=2');
|
||||
expect(second.monthlyWeekOfMonth).toBe(2);
|
||||
expect(second.monthlyWeekday).toBe(2); // Tuesday
|
||||
});
|
||||
|
||||
it('never emits an out-of-union monthlyWeekOfMonth for out-of-range BYDAY ordinals', () => {
|
||||
// The builder's custom ordinal input allows ±5 for monthly rules, but the
|
||||
// synced model (and released clients' typia schema) only allows 1..4 | -1.
|
||||
// An out-of-range ordinal must leave the anchor unset — the rrule engine
|
||||
// still fires correctly; old clients fall back to day-of-month.
|
||||
const fifth = rruleToLegacyTaskRepeatCfg('FREQ=MONTHLY;BYDAY=5MO');
|
||||
expect(fifth.repeatCycle).toBe('MONTHLY');
|
||||
expect(fifth.monthlyWeekOfMonth).toBeUndefined();
|
||||
expect(fifth.monthlyWeekday).toBeUndefined();
|
||||
|
||||
const secondToLast = rruleToLegacyTaskRepeatCfg('FREQ=MONTHLY;BYDAY=-2MO');
|
||||
expect(secondToLast.monthlyWeekOfMonth).toBeUndefined();
|
||||
expect(secondToLast.monthlyWeekday).toBeUndefined();
|
||||
|
||||
// In-range ordinals keep mapping.
|
||||
expect(rruleToLegacyTaskRepeatCfg('FREQ=MONTHLY;BYDAY=4MO').monthlyWeekOfMonth).toBe(
|
||||
4,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns {} for sub-daily rules (never a repeatCycle of undefined)', () => {
|
||||
const out = rruleToLegacyTaskRepeatCfg('FREQ=HOURLY');
|
||||
expect(out).toEqual({});
|
||||
expect('repeatCycle' in out).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves multi-weekday or out-of-range BYSETPOS rules unmapped (no single anchor)', () => {
|
||||
expect(
|
||||
rruleToLegacyTaskRepeatCfg('FREQ=MONTHLY;BYDAY=MO,TU;BYSETPOS=-1')
|
||||
.monthlyWeekOfMonth,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
rruleToLegacyTaskRepeatCfg('FREQ=MONTHLY;BYDAY=MO;BYSETPOS=5').monthlyWeekOfMonth,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
rruleToLegacyTaskRepeatCfg('FREQ=MONTHLY;BYDAY=MO;BYSETPOS=1,-1')
|
||||
.monthlyWeekOfMonth,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('MONTHLY last day → monthlyLastDay', () => {
|
||||
expect(rruleToLegacyTaskRepeatCfg('FREQ=MONTHLY;BYMONTHDAY=-1')).toEqual({
|
||||
repeatCycle: 'MONTHLY',
|
||||
repeatEvery: 1,
|
||||
...ANCHOR_RESETS,
|
||||
monthlyLastDay: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('YEARLY', () => {
|
||||
expect(rruleToLegacyTaskRepeatCfg('FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=17')).toEqual({
|
||||
repeatCycle: 'YEARLY',
|
||||
repeatEvery: 1,
|
||||
...ANCHOR_RESETS,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns {} for garbage / sub-daily', () => {
|
||||
expect(rruleToLegacyTaskRepeatCfg('not an rrule')).toEqual({});
|
||||
expect(rruleToLegacyTaskRepeatCfg('FREQ=HOURLY')).toEqual({});
|
||||
});
|
||||
|
||||
// A BYDAY-less FREQ=WEEKLY would otherwise leave all weekday flags false, and
|
||||
// the legacy WEEKLY engine needs one set — so old clients would never fire.
|
||||
it('maps a BYDAY-less weekly rule onto the start weekday', () => {
|
||||
// 2024-06-12 is a Wednesday.
|
||||
const out = rruleToLegacyTaskRepeatCfg('FREQ=WEEKLY', '2024-06-12');
|
||||
expect(out.repeatCycle).toBe('WEEKLY');
|
||||
expect(out.wednesday).toBe(true);
|
||||
expect(out.monday).toBe(false);
|
||||
expect(out.tuesday).toBe(false);
|
||||
expect(out.thursday).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves weekday flags untouched-false when no startDate is given', () => {
|
||||
const out = rruleToLegacyTaskRepeatCfg('FREQ=WEEKLY');
|
||||
expect(out.repeatCycle).toBe('WEEKLY');
|
||||
expect(out.monday).toBe(false);
|
||||
expect(out.sunday).toBe(false);
|
||||
});
|
||||
|
||||
it('always resets the monthly anchors so stale values cannot survive a merge', () => {
|
||||
// A cfg that previously carried nth-weekday anchors gets a day-of-month
|
||||
// rule: the spread-merge in onRRuleChange must clear the old anchors. The
|
||||
// keys are PRESENT (set to undefined) so the spread actually overwrites a
|
||||
// stale numeric value in memory; `null` would be rejected by released
|
||||
// clients' typia schema and must never be emitted.
|
||||
const out = rruleToLegacyTaskRepeatCfg('FREQ=MONTHLY;BYMONTHDAY=15');
|
||||
expect('monthlyWeekOfMonth' in out).toBe(true);
|
||||
expect('monthlyWeekday' in out).toBe(true);
|
||||
expect(out.monthlyWeekOfMonth).toBeUndefined();
|
||||
expect(out.monthlyWeekday).toBeUndefined();
|
||||
expect(out.monthlyLastDay).toBe(false);
|
||||
// The wire payload stays master-safe: no null, lastDay reset survives.
|
||||
const wire = JSON.parse(JSON.stringify(out));
|
||||
expect('monthlyWeekOfMonth' in wire).toBe(false);
|
||||
expect(wire.monthlyLastDay).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT set monthlyLastDay for the clamp idiom (BYMONTHDAY=31,-1;BYSETPOS=1)', () => {
|
||||
const out = rruleToLegacyTaskRepeatCfg(
|
||||
'FREQ=MONTHLY;BYMONTHDAY=31,-1;BYSETPOS=1',
|
||||
'2024-01-31',
|
||||
);
|
||||
expect(out.monthlyLastDay).toBe(false);
|
||||
// The converter never emits startDate — alignment is getAlignedStartDate.
|
||||
expect('startDate' in out).toBe(false);
|
||||
});
|
||||
|
||||
it('round-trips a weekly cfg (legacy → rrule → legacy)', () => {
|
||||
const legacy = cfg({
|
||||
repeatCycle: 'WEEKLY',
|
||||
repeatEvery: 2,
|
||||
monday: true,
|
||||
thursday: true,
|
||||
tuesday: false,
|
||||
wednesday: false,
|
||||
friday: false,
|
||||
saturday: false,
|
||||
sunday: false,
|
||||
});
|
||||
const back = rruleToLegacyTaskRepeatCfg(legacyTaskRepeatCfgToRRule(legacy));
|
||||
expect(back.repeatCycle).toBe('WEEKLY');
|
||||
expect(back.repeatEvery).toBe(2);
|
||||
expect(back.monday).toBe(true);
|
||||
expect(back.thursday).toBe(true);
|
||||
expect(back.tuesday).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAlignedStartDate', () => {
|
||||
it('aligns to the rule day for a monthly day rule (old clients read it from startDate)', () => {
|
||||
expect(getAlignedStartDate('FREQ=MONTHLY;BYMONTHDAY=15', '2024-06-03')).toBe(
|
||||
'2024-06-15',
|
||||
);
|
||||
// Start day past the rule day → next month.
|
||||
expect(getAlignedStartDate('FREQ=MONTHLY;BYMONTHDAY=15', '2024-06-20')).toBe(
|
||||
'2024-07-15',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined when the start already sits on the rule day', () => {
|
||||
expect(
|
||||
getAlignedStartDate('FREQ=MONTHLY;BYMONTHDAY=15', '2024-06-15'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('re-anchors onto the first IN-PHASE occurrence for INTERVAL rules', () => {
|
||||
// startDate is the rule's dtstart, which anchors the INTERVAL phase:
|
||||
// from 2024-06-20 the months are Jun, Aug, Oct… and Jun 15 is already
|
||||
// past, so the first occurrence is Aug 15. Aligning to Jul 15 (pure date
|
||||
// math) would shift the whole cadence to Jul, Sep, Nov.
|
||||
expect(
|
||||
getAlignedStartDate('FREQ=MONTHLY;INTERVAL=2;BYMONTHDAY=15', '2024-06-20'),
|
||||
).toBe('2024-08-15');
|
||||
expect(
|
||||
getAlignedStartDate('FREQ=MONTHLY;INTERVAL=2;BYMONTHDAY=15', '2024-06-10'),
|
||||
).toBe('2024-06-15');
|
||||
});
|
||||
|
||||
it('aligning onto the first occurrence keeps COUNT semantics intact', () => {
|
||||
// The first occurrence from 2024-06-20 is Jul 15; re-anchoring there drops
|
||||
// nothing, so COUNT still covers Jul/Aug/Sep.
|
||||
expect(getAlignedStartDate('FREQ=MONTHLY;BYMONTHDAY=15;COUNT=3', '2024-06-20')).toBe(
|
||||
'2024-07-15',
|
||||
);
|
||||
});
|
||||
|
||||
it('aligns a clamp-idiom rule only when the first occurrence IS the target day', () => {
|
||||
// From Jan 10 the first occurrence is Jan 31 — on the target day → align.
|
||||
expect(
|
||||
getAlignedStartDate('FREQ=MONTHLY;BYMONTHDAY=31,-1;BYSETPOS=1', '2024-01-10'),
|
||||
).toBe('2024-01-31');
|
||||
expect(
|
||||
getAlignedStartDate('FREQ=MONTHLY;BYMONTHDAY=31,-1;BYSETPOS=1', '2024-01-31'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT align past a valid clamped occurrence in a shorter month', () => {
|
||||
// From 2024-02-10 the rule's first occurrence is Feb 29 (clamped). Moving
|
||||
// the start to Mar 31 would make the engine skip it; moving it ONTO Feb 29
|
||||
// would corrupt the day the legacy engine needs. So no alignment.
|
||||
expect(
|
||||
getAlignedStartDate('FREQ=MONTHLY;BYMONTHDAY=31,-1;BYSETPOS=1', '2024-02-10'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('aligns yearly date rules to month + day (year rolls forward when passed)', () => {
|
||||
expect(getAlignedStartDate('FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=10', '2024-06-03')).toBe(
|
||||
'2025-03-10',
|
||||
);
|
||||
expect(getAlignedStartDate('FREQ=YEARLY;BYMONTH=9;BYMONTHDAY=10', '2024-06-03')).toBe(
|
||||
'2024-09-10',
|
||||
);
|
||||
});
|
||||
|
||||
it('aligns a Feb-29 yearly clamp rule only when the next occurrence is a leap day', () => {
|
||||
// From 2027-03-01 the first occurrence is 2028-02-29 — a real Feb 29.
|
||||
expect(
|
||||
getAlignedStartDate(
|
||||
'FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29,-1;BYSETPOS=1',
|
||||
'2027-03-01',
|
||||
),
|
||||
).toBe('2028-02-29');
|
||||
// From 2026-03-01 the first occurrence is 2027-02-28 (clamped) — jumping
|
||||
// to 2028-02-29 would skip it, so the start stays put.
|
||||
expect(
|
||||
getAlignedStartDate(
|
||||
'FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29,-1;BYSETPOS=1',
|
||||
'2026-03-01',
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for weekday-anchored, multi-day, and pure last-day rules', () => {
|
||||
// Weekday rules use the anchor fields (monthly) / stay approximate (yearly)
|
||||
// — moving the user's visible start date for them is worse.
|
||||
expect(getAlignedStartDate('FREQ=MONTHLY;BYDAY=2TU', '2024-06-03')).toBeUndefined();
|
||||
expect(
|
||||
getAlignedStartDate('FREQ=YEARLY;BYMONTH=6;BYDAY=2SA', '2026-09-01'),
|
||||
).toBeUndefined();
|
||||
// Multi-day lists have no single-day legacy equivalent.
|
||||
expect(
|
||||
getAlignedStartDate('FREQ=MONTHLY;BYMONTHDAY=1,15,-1', '2024-06-20'),
|
||||
).toBeUndefined();
|
||||
// Pure last-day rules use the monthlyLastDay flag, not the startDate day.
|
||||
expect(
|
||||
getAlignedStartDate('FREQ=MONTHLY;BYMONTHDAY=-1', '2024-06-03'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('re-anchors single-BYDAY weekly rules onto the first occurrence (WKST-proof biweekly phase)', () => {
|
||||
// Sun start, biweekly Monday: the engine (default WKST=MO) first fires
|
||||
// Jun 17 — the legacy rolling-week fallback from Jun 9 would fire Jun 10,
|
||||
// the OPPOSITE alternating Monday, forever.
|
||||
expect(getAlignedStartDate('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO', '2024-06-09')).toBe(
|
||||
'2024-06-17',
|
||||
);
|
||||
// WKST shifts the engine's phase (legacy cannot express WKST at all);
|
||||
// alignment captures whichever week the engine actually picks.
|
||||
expect(
|
||||
getAlignedStartDate('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;WKST=SU', '2024-06-09'),
|
||||
).toBe('2024-06-10');
|
||||
// After alignment the cadence is exactly interval*7 days in BOTH engines.
|
||||
});
|
||||
|
||||
it('weekly alignment is a no-op when the start already sits on the pattern day', () => {
|
||||
expect(getAlignedStartDate('FREQ=WEEKLY;BYDAY=MO', '2024-06-03')).toBeUndefined();
|
||||
expect(
|
||||
getAlignedStartDate('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO', '2024-06-03'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves multi-weekday, BYDAY-less, and seasonal weekly rules unaligned', () => {
|
||||
// Multi-weekday sets have no single anchor day.
|
||||
expect(
|
||||
getAlignedStartDate('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE', '2024-06-09'),
|
||||
).toBeUndefined();
|
||||
// BYDAY-less weekly anchors on the start weekday already.
|
||||
expect(getAlignedStartDate('FREQ=WEEKLY;INTERVAL=2', '2024-06-09')).toBeUndefined();
|
||||
// Seasonal (BYMONTH) weekly: aligning could move the visible start months.
|
||||
expect(
|
||||
getAlignedStartDate('FREQ=WEEKLY;BYDAY=MO;BYMONTH=12', '2024-06-09'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for daily rules and garbage', () => {
|
||||
expect(getAlignedStartDate('FREQ=DAILY', '2024-06-03')).toBeUndefined();
|
||||
expect(getAlignedStartDate('not an rrule', '2024-06-03')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,326 +0,0 @@
|
|||
import {
|
||||
MonthlyWeekOfMonth,
|
||||
MonthlyWeekday,
|
||||
TaskRepeatCfg,
|
||||
TaskRepeatCfgCopy,
|
||||
} from '../task-repeat-cfg.model';
|
||||
import { normalizeWeekdays, toNumArray } from './rrule-weekday.util';
|
||||
import { FREQ_TO_CYCLE, safeParseRRuleOptions } from './rrule-parse.util';
|
||||
import { getFirstRRuleOccurrence } from '../store/rrule-occurrence.util';
|
||||
import { getDbDateStr } from '../../../util/get-db-date-str';
|
||||
|
||||
/**
|
||||
* Converts a legacy (pre-RRULE) TaskRepeatCfg — `repeatCycle` + `repeatEvery` +
|
||||
* weekday flags + monthly anchors — into an equivalent RFC 5545 RRULE body, so
|
||||
* old "Custom" recurrences open and keep firing in the RRULE builder after the
|
||||
* legacy custom UI was removed.
|
||||
*
|
||||
* The mapping mirrors the legacy occurrence engine (`get-next-repeat-occurrence`):
|
||||
* DAILY → FREQ=DAILY[;INTERVAL=n]
|
||||
* WEEKLY → FREQ=WEEKLY[;INTERVAL=n];BYDAY=<selected weekdays | start weekday>
|
||||
* MONTHLY → nth-weekday → ;BYDAY=<pos><weekday> (e.g. 2TU = 2nd Tuesday)
|
||||
* last day → ;BYMONTHDAY=-1
|
||||
* day-of-month → ;BYMONTHDAY=<startDate day>
|
||||
* YEARLY → FREQ=YEARLY[;INTERVAL=n];BYMONTH=<m>;BYMONTHDAY=<d> (from startDate)
|
||||
*
|
||||
* Known edge differences from the legacy engine (both rare): legacy clamps a
|
||||
* day-of-month past month-end to the last day (e.g. 31 → Feb 28) and Feb 29 to
|
||||
* Feb 28 in non-leap years, whereas RRULE simply skips months that lack the day.
|
||||
*/
|
||||
|
||||
// Date.getUTCDay() index (0=Sun) → RRULE weekday code.
|
||||
const JS_DAY_TO_RRULE = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] as const;
|
||||
|
||||
// Weekday boolean fields in RRULE (Mon-first) output order.
|
||||
const WEEKLY_FIELDS: { field: keyof TaskRepeatCfg; code: string }[] = [
|
||||
{ field: 'monday', code: 'MO' },
|
||||
{ field: 'tuesday', code: 'TU' },
|
||||
{ field: 'wednesday', code: 'WE' },
|
||||
{ field: 'thursday', code: 'TH' },
|
||||
{ field: 'friday', code: 'FR' },
|
||||
{ field: 'saturday', code: 'SA' },
|
||||
{ field: 'sunday', code: 'SU' },
|
||||
];
|
||||
|
||||
/** Parse 'YYYY-MM-DD' into 1-based month, day, and UTC day-of-week (today as fallback). */
|
||||
const _parseStart = (startDate?: string): { month: number; day: number; dow: number } => {
|
||||
if (startDate && /^\d{4}-\d{1,2}-\d{1,2}/.test(startDate)) {
|
||||
const [y, m, d] = startDate.split('-').map(Number);
|
||||
return { month: m, day: d, dow: new Date(Date.UTC(y, m - 1, d)).getUTCDay() };
|
||||
}
|
||||
const now = new Date();
|
||||
return { month: now.getMonth() + 1, day: now.getDate(), dow: now.getDay() };
|
||||
};
|
||||
|
||||
/**
|
||||
* BYMONTHDAY rule part with legacy clamp semantics. The legacy engine clamps a
|
||||
* day past month-end to the month's last day (31 → Feb 28), while a plain
|
||||
* BYMONTHDAY=31 would SKIP those months. For day > 28 emit the RFC clamp idiom
|
||||
* instead: BYMONTHDAY=<d>,-1;BYSETPOS=1 = "the day, or the last day of a
|
||||
* shorter month" — behavior-identical to the legacy clamp.
|
||||
*/
|
||||
const _clampedMonthDayPart = (day: number): string =>
|
||||
day > 28 ? `BYMONTHDAY=${day},-1;BYSETPOS=1` : `BYMONTHDAY=${day}`;
|
||||
|
||||
export const legacyTaskRepeatCfgToRRule = (cfg: TaskRepeatCfg): string => {
|
||||
const interval =
|
||||
Number.isInteger(cfg.repeatEvery) && cfg.repeatEvery > 0 ? cfg.repeatEvery : 1;
|
||||
const intervalPart = interval > 1 ? `;INTERVAL=${interval}` : '';
|
||||
const { month, day, dow } = _parseStart(cfg.startDate);
|
||||
|
||||
switch (cfg.repeatCycle) {
|
||||
case 'DAILY':
|
||||
return `FREQ=DAILY${intervalPart}`;
|
||||
|
||||
case 'WEEKLY': {
|
||||
const selected = WEEKLY_FIELDS.filter(({ field }) => cfg[field] === true).map(
|
||||
(w) => w.code,
|
||||
);
|
||||
const byDay = selected.length ? selected.join(',') : JS_DAY_TO_RRULE[dow];
|
||||
return `FREQ=WEEKLY${intervalPart};BYDAY=${byDay}`;
|
||||
}
|
||||
|
||||
case 'MONTHLY': {
|
||||
if (cfg.monthlyWeekOfMonth != null && cfg.monthlyWeekday != null) {
|
||||
// Nth-weekday anchor, e.g. "2nd Tuesday" → BYDAY=2TU, "last Monday" → -1MO.
|
||||
const code = JS_DAY_TO_RRULE[cfg.monthlyWeekday];
|
||||
return `FREQ=MONTHLY${intervalPart};BYDAY=${cfg.monthlyWeekOfMonth}${code}`;
|
||||
}
|
||||
if (cfg.monthlyLastDay) {
|
||||
return `FREQ=MONTHLY${intervalPart};BYMONTHDAY=-1`;
|
||||
}
|
||||
return `FREQ=MONTHLY${intervalPart};${_clampedMonthDayPart(day)}`;
|
||||
}
|
||||
|
||||
case 'YEARLY':
|
||||
// Same clamp consideration as MONTHLY: legacy clamps Feb 29 → Feb 28 in
|
||||
// non-leap years (and day 29/30/31 in shorter months generally).
|
||||
return `FREQ=YEARLY${intervalPart};BYMONTH=${month};${_clampedMonthDayPart(day)}`;
|
||||
|
||||
default:
|
||||
// Unknown/legacy-less cfg — fall back to a weekly rule on the start weekday.
|
||||
return `FREQ=WEEKLY${intervalPart};BYDAY=${JS_DAY_TO_RRULE[dow]}`;
|
||||
}
|
||||
};
|
||||
|
||||
/** True when `n` fits the legacy `MonthlyWeekOfMonth` union (1..4 | -1). */
|
||||
const _isLegacyMonthlyOrdinal = (n: number): n is MonthlyWeekOfMonth =>
|
||||
n === -1 || (Number.isInteger(n) && n >= 1 && n <= 4);
|
||||
|
||||
/** RRULE weekday index (0=Mon … 6=Sun) → legacy weekday boolean field name. */
|
||||
const RRULE_IDX_TO_FIELD: (keyof TaskRepeatCfg)[] = [
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
'sunday',
|
||||
];
|
||||
|
||||
/**
|
||||
* Best-effort inverse of `legacyTaskRepeatCfgToRRule`: derives the legacy schedule
|
||||
* fields (`repeatCycle`, `repeatEvery`, weekday flags, monthly anchors) from an
|
||||
* RRULE body. Used to keep those fields populated alongside `rrule` so older sync
|
||||
* clients — which ignore the unknown `rrule` field — still get a faithful
|
||||
* recurrence to fall back on (plan P1.3 reverse direction).
|
||||
*
|
||||
* Returns `{}` for an unparseable or sub-daily rule (legacy fields left untouched).
|
||||
* Day-of-month has no legacy field: legacy MONTHLY day recurrence (and YEARLY)
|
||||
* reads the day/month from `startDate` — callers that persist must pair this
|
||||
* with `getAlignedStartDate` (the dialog does so once at save; doing it here
|
||||
* per call would mutate the user's visible start date on every keystroke).
|
||||
*/
|
||||
export const rruleToLegacyTaskRepeatCfg = (
|
||||
rrule: string,
|
||||
startDate?: string,
|
||||
): Partial<TaskRepeatCfg> => {
|
||||
const opts = safeParseRRuleOptions(rrule);
|
||||
if (!opts) return {};
|
||||
const cycle = FREQ_TO_CYCLE[opts.freq];
|
||||
if (!cycle) return {}; // sub-daily — no legacy equivalent
|
||||
|
||||
// Build on the mutable copy type — TaskRepeatCfg is Readonly.
|
||||
const out: Partial<TaskRepeatCfgCopy> = {
|
||||
repeatCycle: cycle,
|
||||
repeatEvery: opts.interval && opts.interval > 0 ? opts.interval : 1,
|
||||
// Monthly anchors discriminate the legacy MONTHLY paths — always reset so a
|
||||
// stale nth-weekday/last-day anchor from a previous preset or rule can't
|
||||
// override the new rule's semantics. They are re-set below only when this
|
||||
// rule actually encodes them. The numeric anchors reset to `undefined`:
|
||||
// released clients' typia schema allows these fields only absent-or-numeric,
|
||||
// so a `null` must never reach the wire (it would trip their validation /
|
||||
// repair flow). The cost: a partial update can't clear a stale anchor on
|
||||
// REMOTE clients (JSON.stringify drops undefined keys) — harmless on
|
||||
// rrule-aware clients (the engine routes on `rrule`) and only a best-effort
|
||||
// approximation gap on legacy ones. `monthlyLastDay` resets to `false`, a
|
||||
// master-safe value that DOES survive the JSON wire.
|
||||
monthlyWeekOfMonth: undefined,
|
||||
monthlyWeekday: undefined,
|
||||
monthlyLastDay: false,
|
||||
};
|
||||
|
||||
const weekdays = normalizeWeekdays(opts.byweekday);
|
||||
const monthDays = toNumArray(opts.bymonthday);
|
||||
|
||||
if (cycle === 'WEEKLY') {
|
||||
// Reset all flags, then enable the rule's weekdays (Mon-indexed).
|
||||
RRULE_IDX_TO_FIELD.forEach((field) => {
|
||||
(out as Record<string, unknown>)[field] = false;
|
||||
});
|
||||
if (weekdays.length) {
|
||||
weekdays.forEach((w) => {
|
||||
const field = RRULE_IDX_TO_FIELD[w.weekday];
|
||||
if (field) (out as Record<string, unknown>)[field] = true;
|
||||
});
|
||||
} else if (startDate) {
|
||||
// A BYDAY-less FREQ=WEEKLY means "weekly on the start weekday" — set that
|
||||
// flag (mirroring the forward converter), so the legacy WEEKLY engine
|
||||
// still fires on older clients instead of having every flag false.
|
||||
const idx = (_parseStart(startDate).dow + 6) % 7; // UTC 0=Sun → RRULE 0=Mon
|
||||
const field = RRULE_IDX_TO_FIELD[idx];
|
||||
if (field) (out as Record<string, unknown>)[field] = true;
|
||||
}
|
||||
} else if (cycle === 'MONTHLY') {
|
||||
const setPos = toNumArray(opts.bysetpos);
|
||||
const nthOrdinal = weekdays.length ? weekdays[0].n : undefined;
|
||||
if (nthOrdinal != null && _isLegacyMonthlyOrdinal(nthOrdinal)) {
|
||||
// "2nd Tuesday" → BYDAY=2TU. legacy monthlyWeekday is 0=Sun…6=Sat.
|
||||
// Ordinals outside the model union (BYDAY=5MO, -2MO — the builder's
|
||||
// custom input allows ±5) must NOT be persisted: released clients
|
||||
// typia-validate monthlyWeekOfMonth against 1|2|3|4|-1, and an
|
||||
// out-of-union value trips their repair flow. Left unset, the rrule
|
||||
// engine still fires correctly and old clients fall back to
|
||||
// day-of-month — an approximation instead of broken validation.
|
||||
out.monthlyWeekOfMonth = nthOrdinal;
|
||||
out.monthlyWeekday = ((weekdays[0].weekday + 1) % 7) as MonthlyWeekday;
|
||||
} else if (
|
||||
weekdays.length === 1 &&
|
||||
weekdays[0].n == null &&
|
||||
monthDays.length === 0 &&
|
||||
setPos.length === 1 &&
|
||||
_isLegacyMonthlyOrdinal(setPos[0])
|
||||
) {
|
||||
// The builder's weekday-set form of the same anchor: BYDAY=FR;BYSETPOS=-1
|
||||
// ("last Friday") is losslessly equivalent to BYDAY=-1FR. Without this
|
||||
// mapping old clients would fall back to startDate's day-of-month — a
|
||||
// wrong recurrence rather than an approximation. Multi-weekday sets and
|
||||
// out-of-range ordinals have no single legacy anchor and stay unmapped.
|
||||
out.monthlyWeekOfMonth = setPos[0] as MonthlyWeekOfMonth;
|
||||
out.monthlyWeekday = ((weekdays[0].weekday + 1) % 7) as MonthlyWeekday;
|
||||
} else if (monthDays.length === 1 && monthDays[0] === -1) {
|
||||
// Pure "last day of month". NOT set for the clamp idiom
|
||||
// (BYMONTHDAY=<d>,-1;BYSETPOS=1) — there the legacy day comes from the
|
||||
// aligned startDate (see getAlignedStartDate), and the legacy engine
|
||||
// clamps it natively.
|
||||
out.monthlyLastDay = true;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
const _pad2 = (n: number): string => String(n).padStart(2, '0');
|
||||
|
||||
/**
|
||||
* startDate alignment for the legacy fallback. Old clients read the monthly
|
||||
* day (and yearly month+day) from `startDate`, so for date-anchored rules it
|
||||
* should sit on the rule's day — e.g. BYMONTHDAY=15 anchored on the 3rd must
|
||||
* move the start to the 15th, else old clients fire on the 3rd.
|
||||
*
|
||||
* `startDate` is also the rule's dtstart (it anchors the INTERVAL phase and
|
||||
* COUNT), so alignment must never change the occurrence set. The only move
|
||||
* that is guaranteed occurrence-neutral is re-anchoring onto the rule's own
|
||||
* FIRST occurrence on/after the current start: an occurrence is always
|
||||
* in-phase for any INTERVAL, and nothing before it is dropped, so COUNT and
|
||||
* UNTIL semantics are preserved too.
|
||||
*
|
||||
* Returns that first occurrence as 'YYYY-MM-DD' when it falls on the rule's
|
||||
* target day, or undefined when no alignment applies or the start already
|
||||
* matches. Alignment applies only to:
|
||||
* - WEEKLY with exactly one plain BYDAY (see below), or
|
||||
* - a single positive BYMONTHDAY, or
|
||||
* - the clamp idiom BYMONTHDAY=<d>,-1;BYSETPOS=1 → target day is <d>.
|
||||
* For the clamp idiom the first occurrence can be a clamped month-end day
|
||||
* (e.g. Feb 29 for a day-31 rule): aligning PAST it would make the engine skip
|
||||
* a valid occurrence, and aligning ONTO it would corrupt the day the legacy
|
||||
* engine needs — such starts stay unaligned (old clients keep the start day as
|
||||
* a best-effort approximation).
|
||||
*
|
||||
* WEEKLY: the engine groups weeks by WKST while the legacy fallback counts
|
||||
* rolling 7-day blocks from startDate. For INTERVAL > 1 they disagree on
|
||||
* WHICH alternating week fires whenever the start doesn't sit on the rule's
|
||||
* weekday — and WKST shifts the engine's phase, which legacy cannot express.
|
||||
* Re-anchoring onto the first occurrence makes a single-weekday cadence
|
||||
* exactly interval*7 days in both engines, WKST-proof. Multi-weekday sets
|
||||
* stay approximate (no single anchor day); BYDAY-less rules already anchor
|
||||
* on the start weekday.
|
||||
*
|
||||
* Monthly/yearly weekday-anchored rules use the anchor fields and stay
|
||||
* unaligned; multi-day lists have no single-day legacy equivalent — the
|
||||
* user's start date is left alone for those.
|
||||
*/
|
||||
export const getAlignedStartDate = (
|
||||
rrule: string,
|
||||
startDate: string,
|
||||
): string | undefined => {
|
||||
const opts = safeParseRRuleOptions(rrule);
|
||||
if (!opts) return undefined;
|
||||
const cycle = FREQ_TO_CYCLE[opts.freq];
|
||||
if (cycle !== 'WEEKLY' && cycle !== 'MONTHLY' && cycle !== 'YEARLY') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(startDate);
|
||||
if (!m) return undefined;
|
||||
const startPadded = `${m[1]}-${_pad2(+m[2])}-${_pad2(+m[3])}`;
|
||||
|
||||
if (cycle === 'WEEKLY') {
|
||||
const wd = normalizeWeekdays(opts.byweekday);
|
||||
if (wd.length !== 1 || wd[0].n != null) return undefined;
|
||||
if (
|
||||
toNumArray(opts.bymonthday).length ||
|
||||
toNumArray(opts.bysetpos).length ||
|
||||
toNumArray(opts.bymonth).length
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const firstWeekly = getFirstRRuleOccurrence({ rrule, startDate: startPadded });
|
||||
if (!firstWeekly) return undefined;
|
||||
const alignedWeekly = getDbDateStr(firstWeekly);
|
||||
return alignedWeekly === startDate ? undefined : alignedWeekly;
|
||||
}
|
||||
|
||||
if (normalizeWeekdays(opts.byweekday).length) return undefined;
|
||||
|
||||
// Target day: a single positive day, or the clamp idiom {d,-1} + BYSETPOS=1.
|
||||
const monthDays = toNumArray(opts.bymonthday);
|
||||
const setPos = toNumArray(opts.bysetpos);
|
||||
const positives = monthDays.filter((d) => d > 0);
|
||||
let day: number | undefined;
|
||||
if (monthDays.length === 1 && positives.length === 1) {
|
||||
day = positives[0];
|
||||
} else if (
|
||||
monthDays.length === 2 &&
|
||||
positives.length === 1 &&
|
||||
monthDays.includes(-1) &&
|
||||
setPos.length === 1 &&
|
||||
setPos[0] === 1
|
||||
) {
|
||||
day = positives[0];
|
||||
}
|
||||
if (day == null || day > 31) return undefined;
|
||||
|
||||
// YEARLY needs a single BYMONTH — old clients read the month from startDate.
|
||||
if (cycle === 'YEARLY' && toNumArray(opts.bymonth).length !== 1) return undefined;
|
||||
|
||||
// The actual first occurrence with the CURRENT start as dtstart (local noon).
|
||||
const first = getFirstRRuleOccurrence({ rrule, startDate: startPadded });
|
||||
if (!first) return undefined;
|
||||
// Not on the target day → a clamped/short-month occurrence comes first;
|
||||
// aligning would skip it or corrupt the legacy day. Leave the start alone.
|
||||
if (first.getDate() !== day) return undefined;
|
||||
|
||||
const aligned = getDbDateStr(first);
|
||||
return aligned === startDate ? undefined : aligned;
|
||||
};
|
||||
|
|
@ -1,228 +0,0 @@
|
|||
import { RRule } from 'rrule';
|
||||
import {
|
||||
defaultRRuleFormModel,
|
||||
formModelToRRule,
|
||||
rruleToFormModel,
|
||||
} from './rrule-form.util';
|
||||
|
||||
// Complex builder coverage: forward (model → string), the round-trip guard
|
||||
// (string → model → string is semantically lossless), and the mode-detection
|
||||
// that picks day-of-month vs nth-weekday vs weekday-set vs raw-override.
|
||||
|
||||
const REF = new Date(2024, 5, 3, 12); // Mon Jun 3 2024
|
||||
|
||||
/** Canonicalise like the util's round-trip guard so order/format don't matter. */
|
||||
const canon = (s: string): string => {
|
||||
try {
|
||||
return RRule.fromString(s).toString();
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
};
|
||||
|
||||
const model = (
|
||||
over: Partial<ReturnType<typeof defaultRRuleFormModel>> = {},
|
||||
): ReturnType<typeof defaultRRuleFormModel> => ({
|
||||
...defaultRRuleFormModel(REF),
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('rrule-form util — complex builder', () => {
|
||||
describe('formModelToRRule (model → string)', () => {
|
||||
it('MONTHLY per-day ordinals → BYDAY=3MO,4SU', () => {
|
||||
expect(
|
||||
formModelToRRule(
|
||||
model({
|
||||
freq: 'MONTHLY',
|
||||
monthlyMode: 'NTH_WEEKDAY',
|
||||
nthDays: [
|
||||
{ pos: 3, days: ['MO'] },
|
||||
{ pos: 4, days: ['SU'] },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe('FREQ=MONTHLY;BYDAY=3MO,4SU');
|
||||
});
|
||||
|
||||
it('MONTHLY one ordinal with multiple weekdays → BYDAY=1MO,1TU', () => {
|
||||
expect(
|
||||
formModelToRRule(
|
||||
model({
|
||||
freq: 'MONTHLY',
|
||||
monthlyMode: 'NTH_WEEKDAY',
|
||||
nthDays: [{ pos: 1, days: ['MO', 'TU'] }],
|
||||
}),
|
||||
),
|
||||
).toBe('FREQ=MONTHLY;BYDAY=1MO,1TU');
|
||||
});
|
||||
|
||||
it('MONTHLY weekday-set + BYSETPOS=-1 (last weekday)', () => {
|
||||
expect(
|
||||
formModelToRRule(
|
||||
model({
|
||||
freq: 'MONTHLY',
|
||||
monthlyMode: 'WEEKDAYS',
|
||||
byDay: ['MO', 'TU', 'WE', 'TH', 'FR'],
|
||||
bySetPos: '-1',
|
||||
}),
|
||||
),
|
||||
).toBe('FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1');
|
||||
});
|
||||
|
||||
it('MONTHLY last day (BYMONTHDAY=-1)', () => {
|
||||
expect(
|
||||
formModelToRRule(
|
||||
model({ freq: 'MONTHLY', monthlyMode: 'DAY_OF_MONTH', monthDays: [-1] }),
|
||||
),
|
||||
).toBe('FREQ=MONTHLY;BYMONTHDAY=-1');
|
||||
});
|
||||
|
||||
it('seasonal DAILY;BYMONTH=1,4,7,10 (sorted)', () => {
|
||||
expect(formModelToRRule(model({ freq: 'DAILY', byMonth: [7, 1, 10, 4] }))).toBe(
|
||||
'FREQ=DAILY;BYMONTH=1,4,7,10',
|
||||
);
|
||||
});
|
||||
|
||||
it('WEEKLY INTERVAL + multi-weekday in Mon-first order', () => {
|
||||
expect(
|
||||
formModelToRRule(
|
||||
model({ freq: 'WEEKLY', interval: 2, byDay: ['FR', 'MO', 'WE'] }),
|
||||
),
|
||||
).toBe('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR');
|
||||
});
|
||||
|
||||
it('YEARLY on a date (BYMONTH + BYMONTHDAY)', () => {
|
||||
expect(
|
||||
formModelToRRule(
|
||||
model({
|
||||
freq: 'YEARLY',
|
||||
yearlyMode: 'DAY_OF_MONTH',
|
||||
byMonth: [3],
|
||||
monthDays: [17],
|
||||
}),
|
||||
),
|
||||
).toBe('FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=17');
|
||||
});
|
||||
|
||||
it('COUNT end condition', () => {
|
||||
expect(
|
||||
formModelToRRule(
|
||||
model({ freq: 'DAILY', interval: 3, endType: 'COUNT', count: 10 }),
|
||||
),
|
||||
).toBe('FREQ=DAILY;INTERVAL=3;COUNT=10');
|
||||
});
|
||||
|
||||
it('UNTIL end condition (noon-UTC instant)', () => {
|
||||
expect(
|
||||
formModelToRRule(
|
||||
model({ freq: 'WEEKLY', byDay: ['MO'], endType: 'UNTIL', until: '2024-12-31' }),
|
||||
),
|
||||
).toBe('FREQ=WEEKLY;BYDAY=MO;UNTIL=20241231T120000Z');
|
||||
});
|
||||
|
||||
it('WKST week-start', () => {
|
||||
expect(
|
||||
formModelToRRule(
|
||||
model({ freq: 'WEEKLY', byDay: ['MO', 'WE', 'FR'], wkst: 'SU' }),
|
||||
),
|
||||
).toBe('FREQ=WEEKLY;BYDAY=MO,WE,FR;WKST=SU');
|
||||
});
|
||||
|
||||
it('a raw override wins over the structured fields', () => {
|
||||
expect(
|
||||
formModelToRRule(
|
||||
model({ freq: 'WEEKLY', byDay: ['MO'], rawOverride: 'FREQ=HOURLY;INTERVAL=2' }),
|
||||
),
|
||||
).toBe('FREQ=HOURLY;INTERVAL=2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rruleToFormModel (string → model) mode detection', () => {
|
||||
it('per-day ordinals → NTH_WEEKDAY rows', () => {
|
||||
const m = rruleToFormModel('FREQ=MONTHLY;BYDAY=1MO,3MO', REF);
|
||||
expect(m.freq).toBe('MONTHLY');
|
||||
expect(m.monthlyMode).toBe('NTH_WEEKDAY');
|
||||
expect(m.nthDays).toEqual([
|
||||
{ pos: 1, days: ['MO'] },
|
||||
{ pos: 3, days: ['MO'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('weekdays sharing an ordinal collapse into one row (1MO,1TU)', () => {
|
||||
const m = rruleToFormModel('FREQ=MONTHLY;BYDAY=1MO,1TU', REF);
|
||||
expect(m.monthlyMode).toBe('NTH_WEEKDAY');
|
||||
expect(m.nthDays).toEqual([{ pos: 1, days: ['MO', 'TU'] }]);
|
||||
});
|
||||
|
||||
it('weekday-set + BYSETPOS → WEEKDAYS mode', () => {
|
||||
const m = rruleToFormModel('FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1', REF);
|
||||
expect(m.monthlyMode).toBe('WEEKDAYS');
|
||||
expect(m.byDay).toEqual(['MO', 'TU', 'WE', 'TH', 'FR']);
|
||||
expect(m.bySetPos).toBe('-1');
|
||||
});
|
||||
|
||||
it('plain BYMONTHDAY → DAY_OF_MONTH', () => {
|
||||
const m = rruleToFormModel('FREQ=MONTHLY;BYMONTHDAY=-1', REF);
|
||||
expect(m.monthlyMode).toBe('DAY_OF_MONTH');
|
||||
expect(m.monthDays).toEqual([-1]);
|
||||
});
|
||||
|
||||
it('YEARLY nth-weekday within a month', () => {
|
||||
const m = rruleToFormModel('FREQ=YEARLY;BYMONTH=6;BYDAY=2SA', REF);
|
||||
expect(m.freq).toBe('YEARLY');
|
||||
expect(m.yearlyMode).toBe('NTH_WEEKDAY');
|
||||
expect(m.byMonth).toEqual([6]);
|
||||
expect(m.nthDays).toEqual([{ pos: 2, days: ['SA'] }]);
|
||||
});
|
||||
|
||||
it('COUNT / UNTIL parse into endType', () => {
|
||||
expect(rruleToFormModel('FREQ=DAILY;COUNT=7', REF).endType).toBe('COUNT');
|
||||
expect(rruleToFormModel('FREQ=DAILY;COUNT=7', REF).count).toBe(7);
|
||||
const u = rruleToFormModel('FREQ=WEEKLY;BYDAY=MO;UNTIL=20241231T120000Z', REF);
|
||||
expect(u.endType).toBe('UNTIL');
|
||||
expect(u.until).toBe('2024-12-31');
|
||||
});
|
||||
|
||||
it('sub-daily FREQ falls back to a raw override', () => {
|
||||
const m = rruleToFormModel('FREQ=MINUTELY;INTERVAL=30', REF);
|
||||
expect(m.rawOverride).toBe('FREQ=MINUTELY;INTERVAL=30');
|
||||
expect(m.showAdvanced).toBe(true);
|
||||
});
|
||||
|
||||
it('garbage → defaults (no raw override)', () => {
|
||||
const m = rruleToFormModel('garbage', REF);
|
||||
expect(m.freq).toBe('WEEKLY');
|
||||
expect(m.rawOverride).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip is semantically lossless (string → model → string)', () => {
|
||||
const RULES = [
|
||||
'FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR',
|
||||
'FREQ=MONTHLY;BYMONTHDAY=-1',
|
||||
'FREQ=MONTHLY;BYMONTHDAY=1,15',
|
||||
'FREQ=MONTHLY;BYDAY=2TU',
|
||||
'FREQ=MONTHLY;BYDAY=1MO,3MO',
|
||||
'FREQ=MONTHLY;BYDAY=1MO,1TU',
|
||||
'FREQ=MONTHLY;BYDAY=2TU,4TH',
|
||||
'FREQ=YEARLY;BYMONTH=6;BYDAY=1SA,1SU',
|
||||
'FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1',
|
||||
'FREQ=YEARLY;BYMONTH=3;BYMONTHDAY=17',
|
||||
'FREQ=YEARLY;BYMONTH=6;BYDAY=2SA',
|
||||
'FREQ=YEARLY;BYYEARDAY=1',
|
||||
'FREQ=YEARLY;BYWEEKNO=20;BYDAY=MO',
|
||||
'FREQ=DAILY;INTERVAL=3;COUNT=10',
|
||||
'FREQ=WEEKLY;BYDAY=MO;UNTIL=20241231T120000Z',
|
||||
'FREQ=WEEKLY;BYDAY=MO,WE,FR;WKST=SU',
|
||||
'FREQ=DAILY;BYMONTH=1,4,7,10',
|
||||
'FREQ=MINUTELY;INTERVAL=30',
|
||||
'FREQ=HOURLY;INTERVAL=6',
|
||||
];
|
||||
|
||||
RULES.forEach((rule) => {
|
||||
it(rule, () => {
|
||||
expect(canon(formModelToRRule(rruleToFormModel(rule, REF)))).toBe(canon(rule));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,428 +0,0 @@
|
|||
import { RRule } from 'rrule';
|
||||
import {
|
||||
defaultRRuleFormModel,
|
||||
formModelToRRule,
|
||||
RRuleFormModel,
|
||||
rruleToFormModel,
|
||||
} from './rrule-form.util';
|
||||
|
||||
describe('rrule-form.util', () => {
|
||||
describe('formModelToRRule', () => {
|
||||
const base = (over: Partial<RRuleFormModel>): RRuleFormModel => ({
|
||||
...defaultRRuleFormModel(new Date(2024, 5, 3)), // Mon Jun 3 2024
|
||||
...over,
|
||||
});
|
||||
|
||||
it('daily with interval', () => {
|
||||
expect(formModelToRRule(base({ freq: 'DAILY', interval: 1 }))).toBe('FREQ=DAILY');
|
||||
expect(formModelToRRule(base({ freq: 'DAILY', interval: 3 }))).toBe(
|
||||
'FREQ=DAILY;INTERVAL=3',
|
||||
);
|
||||
});
|
||||
|
||||
it('weekly with weekdays (ordered Mon→Sun)', () => {
|
||||
expect(
|
||||
formModelToRRule(base({ freq: 'WEEKLY', interval: 2, byDay: ['WE', 'MO'] })),
|
||||
).toBe('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE');
|
||||
});
|
||||
|
||||
it('monthly nth-weekday (single row)', () => {
|
||||
expect(
|
||||
formModelToRRule(
|
||||
base({
|
||||
freq: 'MONTHLY',
|
||||
monthlyMode: 'NTH_WEEKDAY',
|
||||
nthDays: [{ pos: 2, days: ['TU'] }],
|
||||
}),
|
||||
),
|
||||
).toBe('FREQ=MONTHLY;BYDAY=2TU');
|
||||
});
|
||||
|
||||
it('monthly last weekday (-1)', () => {
|
||||
expect(
|
||||
formModelToRRule(
|
||||
base({
|
||||
freq: 'MONTHLY',
|
||||
monthlyMode: 'NTH_WEEKDAY',
|
||||
nthDays: [{ pos: -1, days: ['FR'] }],
|
||||
}),
|
||||
),
|
||||
).toBe('FREQ=MONTHLY;BYDAY=-1FR');
|
||||
});
|
||||
|
||||
it('monthly custom ordinals (5th Friday, 2nd-to-last Monday)', () => {
|
||||
expect(
|
||||
formModelToRRule(
|
||||
base({
|
||||
freq: 'MONTHLY',
|
||||
monthlyMode: 'NTH_WEEKDAY',
|
||||
nthDays: [
|
||||
{ pos: 5, days: ['FR'] },
|
||||
{ pos: -2, days: ['MO'] },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe('FREQ=MONTHLY;BYDAY=5FR,-2MO');
|
||||
});
|
||||
|
||||
it('dedupes identical ordinal+weekday tokens across rows', () => {
|
||||
expect(
|
||||
formModelToRRule(
|
||||
base({
|
||||
freq: 'MONTHLY',
|
||||
monthlyMode: 'NTH_WEEKDAY',
|
||||
nthDays: [
|
||||
{ pos: 3, days: ['MO', 'TU'] },
|
||||
{ pos: 3, days: ['TU'] },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe('FREQ=MONTHLY;BYDAY=3MO,3TU');
|
||||
});
|
||||
|
||||
it('monthly nth-weekday multiple rows (3rd Monday and 4th Sunday)', () => {
|
||||
expect(
|
||||
formModelToRRule(
|
||||
base({
|
||||
freq: 'MONTHLY',
|
||||
monthlyMode: 'NTH_WEEKDAY',
|
||||
nthDays: [
|
||||
{ pos: 3, days: ['MO'] },
|
||||
{ pos: 4, days: ['SU'] },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe('FREQ=MONTHLY;BYDAY=3MO,4SU');
|
||||
});
|
||||
|
||||
it('yearly nth-weekday rows, every other year (3rd Mon + 4th Sun in June)', () => {
|
||||
expect(
|
||||
formModelToRRule(
|
||||
base({
|
||||
freq: 'YEARLY',
|
||||
interval: 2,
|
||||
yearlyMode: 'NTH_WEEKDAY',
|
||||
byMonth: [6],
|
||||
nthDays: [
|
||||
{ pos: 3, days: ['MO'] },
|
||||
{ pos: 4, days: ['SU'] },
|
||||
],
|
||||
}),
|
||||
),
|
||||
).toBe('FREQ=YEARLY;INTERVAL=2;BYMONTH=6;BYDAY=3MO,4SU');
|
||||
});
|
||||
|
||||
it('monthly day-of-month', () => {
|
||||
expect(
|
||||
formModelToRRule(
|
||||
base({ freq: 'MONTHLY', monthlyMode: 'DAY_OF_MONTH', monthDays: [15] }),
|
||||
),
|
||||
).toBe('FREQ=MONTHLY;BYMONTHDAY=15');
|
||||
});
|
||||
|
||||
it('monthly last day (-1 in the day grid)', () => {
|
||||
expect(
|
||||
formModelToRRule(
|
||||
base({ freq: 'MONTHLY', monthlyMode: 'DAY_OF_MONTH', monthDays: [-1] }),
|
||||
),
|
||||
).toBe('FREQ=MONTHLY;BYMONTHDAY=-1');
|
||||
});
|
||||
|
||||
it('yearly with months + day', () => {
|
||||
expect(
|
||||
formModelToRRule(base({ freq: 'YEARLY', byMonth: [11, 3], monthDays: [15] })),
|
||||
).toBe('FREQ=YEARLY;BYMONTH=3,11;BYMONTHDAY=15');
|
||||
});
|
||||
|
||||
it('yearly weekdays within months (seasonal — the cron "Sat Mar–Nov" case)', () => {
|
||||
expect(
|
||||
formModelToRRule(
|
||||
base({
|
||||
freq: 'YEARLY',
|
||||
yearlyMode: 'WEEKDAYS',
|
||||
byMonth: [3, 4, 5, 6, 7, 8, 9, 10, 11],
|
||||
byDay: ['SA'],
|
||||
}),
|
||||
),
|
||||
).toBe('FREQ=YEARLY;BYMONTH=3,4,5,6,7,8,9,10,11;BYDAY=SA');
|
||||
});
|
||||
|
||||
it('yearly nth-weekday via "which" (2nd Saturday of June, every other year)', () => {
|
||||
expect(
|
||||
formModelToRRule(
|
||||
base({
|
||||
freq: 'YEARLY',
|
||||
interval: 2,
|
||||
yearlyMode: 'WEEKDAYS',
|
||||
byMonth: [6],
|
||||
byDay: ['SA'],
|
||||
bySetPos: '2',
|
||||
}),
|
||||
),
|
||||
).toBe('FREQ=YEARLY;INTERVAL=2;BYMONTH=6;BYDAY=SA;BYSETPOS=2');
|
||||
});
|
||||
|
||||
it('seasonal BYMONTH constraint on non-yearly frequencies', () => {
|
||||
expect(formModelToRRule(base({ freq: 'DAILY', byMonth: [1, 2, 3, 4] }))).toBe(
|
||||
'FREQ=DAILY;BYMONTH=1,2,3,4',
|
||||
);
|
||||
expect(
|
||||
formModelToRRule(base({ freq: 'WEEKLY', byDay: ['MO'], byMonth: [6] })),
|
||||
).toBe('FREQ=WEEKLY;BYMONTH=6;BYDAY=MO');
|
||||
});
|
||||
|
||||
it('end after N occurrences (COUNT)', () => {
|
||||
expect(formModelToRRule(base({ freq: 'DAILY', endType: 'COUNT', count: 5 }))).toBe(
|
||||
'FREQ=DAILY;COUNT=5',
|
||||
);
|
||||
});
|
||||
|
||||
it('end on date (UNTIL → noon UTC)', () => {
|
||||
expect(
|
||||
formModelToRRule(base({ freq: 'DAILY', endType: 'UNTIL', until: '2024-06-30' })),
|
||||
).toBe('FREQ=DAILY;UNTIL=20240630T120000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rruleToFormModel ∘ formModelToRRule round-trips', () => {
|
||||
const cases = [
|
||||
'FREQ=DAILY',
|
||||
'FREQ=DAILY;INTERVAL=3',
|
||||
'FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE',
|
||||
'FREQ=MONTHLY;BYDAY=2TU',
|
||||
'FREQ=MONTHLY;BYDAY=3MO,4SU',
|
||||
'FREQ=MONTHLY;BYDAY=-1FR',
|
||||
'FREQ=MONTHLY;BYMONTHDAY=15',
|
||||
'FREQ=MONTHLY;BYMONTHDAY=-1',
|
||||
'FREQ=YEARLY;BYMONTH=3,11;BYMONTHDAY=15',
|
||||
'FREQ=YEARLY;BYMONTH=3,4,5,6,7,8,9,10,11;BYDAY=SA',
|
||||
'FREQ=DAILY;BYMONTH=1,2,3,4',
|
||||
'FREQ=WEEKLY;BYMONTH=6;BYDAY=MO',
|
||||
'FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;WKST=SU',
|
||||
'FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1',
|
||||
'FREQ=DAILY;COUNT=5',
|
||||
'FREQ=DAILY;UNTIL=20240630T120000Z',
|
||||
];
|
||||
cases.forEach((rrule) => {
|
||||
it(`"${rrule}" survives parse → build`, () => {
|
||||
expect(formModelToRRule(rruleToFormModel(rrule))).toBe(rrule);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('rruleToFormModel parsing', () => {
|
||||
it('maps nth-weekday back to a single row', () => {
|
||||
const m = rruleToFormModel('FREQ=MONTHLY;BYDAY=3SA');
|
||||
expect(m.freq).toBe('MONTHLY');
|
||||
expect(m.monthlyMode).toBe('NTH_WEEKDAY');
|
||||
expect(m.nthDays).toEqual([{ pos: 3, days: ['SA'] }]);
|
||||
});
|
||||
|
||||
it('maps multiple nth-weekdays back to rows (3MO,4SU)', () => {
|
||||
const m = rruleToFormModel('FREQ=MONTHLY;BYDAY=3MO,4SU');
|
||||
expect(m.monthlyMode).toBe('NTH_WEEKDAY');
|
||||
expect(m.nthDays).toEqual([
|
||||
{ pos: 3, days: ['MO'] },
|
||||
{ pos: 4, days: ['SU'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps custom ordinals (outside the dropdown set) back to rows, no raw fallback', () => {
|
||||
const m = rruleToFormModel('FREQ=MONTHLY;BYDAY=-2MO,5FR');
|
||||
expect(m.monthlyMode).toBe('NTH_WEEKDAY');
|
||||
expect(m.nthDays).toEqual([
|
||||
{ pos: -2, days: ['MO'] },
|
||||
{ pos: 5, days: ['FR'] },
|
||||
]);
|
||||
expect(m.rawOverride).toBe('');
|
||||
});
|
||||
|
||||
it('maps yearly weekdays-within-months back to dropdown fields', () => {
|
||||
const m = rruleToFormModel('FREQ=YEARLY;BYMONTH=3,11;BYDAY=SA');
|
||||
expect(m.freq).toBe('YEARLY');
|
||||
expect(m.yearlyMode).toBe('WEEKDAYS');
|
||||
expect(m.byMonth).toEqual([3, 11]);
|
||||
expect(m.byDay).toEqual(['SA']);
|
||||
});
|
||||
|
||||
it('maps a monthly weekday set (+ set position) back to dropdowns', () => {
|
||||
const m = rruleToFormModel('FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1');
|
||||
expect(m.freq).toBe('MONTHLY');
|
||||
expect(m.monthlyMode).toBe('WEEKDAYS');
|
||||
expect(m.byDay).toEqual(['MO', 'TU', 'WE', 'TH', 'FR']);
|
||||
expect(m.bySetPos).toBe('-1');
|
||||
});
|
||||
|
||||
it('maps a multi-value BYSETPOS back without a raw fallback', () => {
|
||||
const m = rruleToFormModel('FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=2,-1');
|
||||
expect(m.monthlyMode).toBe('WEEKDAYS');
|
||||
expect(m.bySetPos).toBe('2,-1');
|
||||
expect(m.rawOverride).toBe('');
|
||||
});
|
||||
|
||||
it('YEARLY date mode without BYMONTH omits BYMONTHDAY (bare yearly = anniversary)', () => {
|
||||
// Per RFC 5545 a bare FREQ=YEARLY;BYMONTHDAY=n expands across every
|
||||
// month — i.e. fires monthly. With no months selected, emit a plain
|
||||
// FREQ=YEARLY (anchors to the start date) instead.
|
||||
expect(
|
||||
formModelToRRule({
|
||||
...defaultRRuleFormModel(new Date(2024, 5, 3)),
|
||||
freq: 'YEARLY',
|
||||
yearlyMode: 'DAY_OF_MONTH',
|
||||
byMonth: [],
|
||||
monthDays: [15],
|
||||
}),
|
||||
).toBe('FREQ=YEARLY');
|
||||
});
|
||||
|
||||
it('a parsed bare yearly BYMONTHDAY rule falls back to the raw override', () => {
|
||||
// Can't round-trip structurally without changing semantics (it fires
|
||||
// monthly) — preserve it verbatim instead.
|
||||
const m = rruleToFormModel('FREQ=YEARLY;BYMONTHDAY=15');
|
||||
expect(m.rawOverride).toBe('FREQ=YEARLY;BYMONTHDAY=15');
|
||||
});
|
||||
|
||||
it('drops BYSETPOS=0 on parse (re-emitting it would create a dead rule)', () => {
|
||||
const m = rruleToFormModel('FREQ=MONTHLY;BYDAY=MO,TU;BYSETPOS=0');
|
||||
expect(m.bySetPos).not.toContain('0');
|
||||
// The cleanup must survive the round-trip guard: a raw override would
|
||||
// store the original rule verbatim and re-emit the dead BYSETPOS=0.
|
||||
expect(m.rawOverride).toBe('');
|
||||
expect(formModelToRRule(m)).toBe('FREQ=MONTHLY;BYDAY=MO,TU');
|
||||
});
|
||||
|
||||
it('keeps non-zero BYSETPOS values when zeros are mixed in', () => {
|
||||
const m = rruleToFormModel('FREQ=MONTHLY;BYDAY=MO;BYSETPOS=0,2');
|
||||
expect(m.bySetPos).toBe('2');
|
||||
expect(m.rawOverride).toBe('');
|
||||
expect(formModelToRRule(m)).toBe('FREQ=MONTHLY;BYDAY=MO;BYSETPOS=2');
|
||||
});
|
||||
|
||||
it('round-trips the migration clamp idiom structurally (no raw fallback)', () => {
|
||||
// BYMONTHDAY=31,-1;BYSETPOS=1 = "the 31st, or the last day of shorter
|
||||
// months" — emitted by the legacy-CUSTOM migration for day > 28 anchors.
|
||||
const m = rruleToFormModel('FREQ=MONTHLY;BYMONTHDAY=31,-1;BYSETPOS=1');
|
||||
expect(m.monthlyMode).toBe('DAY_OF_MONTH');
|
||||
expect(m.monthDays).toEqual([31, -1]);
|
||||
expect(m.bySetPos).toBe('1');
|
||||
expect(m.rawOverride).toBe('');
|
||||
expect(formModelToRRule(m)).toBe('FREQ=MONTHLY;BYMONTHDAY=31,-1;BYSETPOS=1');
|
||||
});
|
||||
|
||||
it('maps COUNT to end condition', () => {
|
||||
const m = rruleToFormModel('FREQ=WEEKLY;BYDAY=MO;COUNT=12');
|
||||
expect(m.endType).toBe('COUNT');
|
||||
expect(m.count).toBe(12);
|
||||
});
|
||||
|
||||
it('falls back to defaults for empty / garbage input', () => {
|
||||
expect(rruleToFormModel('').freq).toBe('WEEKLY');
|
||||
expect(rruleToFormModel('not an rrule').freq).toBe('WEEKLY');
|
||||
expect(rruleToFormModel(undefined).endType).toBe('NEVER');
|
||||
});
|
||||
});
|
||||
|
||||
describe('advanced section (WKST + raw override)', () => {
|
||||
it('week-start (WKST) round-trips and expands the advanced section', () => {
|
||||
const m = rruleToFormModel('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;WKST=SU');
|
||||
expect(m.showAdvanced).toBe(true);
|
||||
expect(m.wkst).toBe('SU');
|
||||
expect(formModelToRRule(m)).toBe('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;WKST=SU');
|
||||
});
|
||||
|
||||
it('truly exotic rules (time-of-day parts) are preserved via raw override', () => {
|
||||
const exotic = 'FREQ=DAILY;BYHOUR=9,17';
|
||||
const m = rruleToFormModel(exotic);
|
||||
expect(m.showAdvanced).toBe(true);
|
||||
expect(m.rawOverride).toBe(exotic);
|
||||
expect(formModelToRRule(m)).toBe(exotic);
|
||||
});
|
||||
|
||||
it('raw override replaces the structured build when advanced is on', () => {
|
||||
const m = defaultRRuleFormModel(new Date(2024, 5, 3));
|
||||
m.showAdvanced = true;
|
||||
m.rawOverride = 'FREQ=YEARLY;BYYEARDAY=100';
|
||||
expect(formModelToRRule(m)).toBe('FREQ=YEARLY;BYYEARDAY=100');
|
||||
});
|
||||
|
||||
it('an advanced value applies even when the section is collapsed (cosmetic)', () => {
|
||||
const m = defaultRRuleFormModel(new Date(2024, 5, 3)); // Monday
|
||||
m.showAdvanced = false; // collapsed UI, but the value still applies
|
||||
m.rawOverride = 'FREQ=YEARLY;BYYEARDAY=100';
|
||||
expect(formModelToRRule(m)).toBe('FREQ=YEARLY;BYYEARDAY=100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('advanced BY* forward build', () => {
|
||||
const adv = (over: Partial<RRuleFormModel>): RRuleFormModel => ({
|
||||
...defaultRRuleFormModel(new Date(2024, 5, 3)),
|
||||
showAdvanced: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
it('BYSETPOS in the weekday-set mode ("last weekday")', () => {
|
||||
expect(
|
||||
formModelToRRule(
|
||||
adv({
|
||||
freq: 'MONTHLY',
|
||||
monthlyMode: 'WEEKDAYS',
|
||||
byDay: ['MO', 'TU', 'WE', 'TH', 'FR'],
|
||||
bySetPos: '-1',
|
||||
}),
|
||||
),
|
||||
).toBe('FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1');
|
||||
});
|
||||
|
||||
it('multiple / negative days of the month via the day grid', () => {
|
||||
expect(
|
||||
formModelToRRule(
|
||||
adv({ freq: 'MONTHLY', monthlyMode: 'DAY_OF_MONTH', monthDays: [1, 15, -1] }),
|
||||
),
|
||||
).toBe('FREQ=MONTHLY;BYMONTHDAY=1,15,-1');
|
||||
});
|
||||
|
||||
it('appends BYWEEKNO / BYYEARDAY', () => {
|
||||
expect(
|
||||
formModelToRRule(
|
||||
adv({ freq: 'YEARLY', byMonth: [], monthDays: [], byWeekNo: '20' }),
|
||||
),
|
||||
).toBe('FREQ=YEARLY;BYWEEKNO=20');
|
||||
expect(
|
||||
formModelToRRule(
|
||||
adv({ freq: 'YEARLY', byMonth: [], monthDays: [], byYearDay: '100,-1' }),
|
||||
),
|
||||
).toBe('FREQ=YEARLY;BYYEARDAY=100,-1');
|
||||
});
|
||||
});
|
||||
|
||||
// The canonical guard guarantees that ANY valid rule survives edit → rebuild:
|
||||
// either the structured/advanced fields reproduce it, or it is preserved
|
||||
// verbatim in the raw override. Compared via rrule's canonical serialization.
|
||||
describe('every rule round-trips (structured or raw fallback)', () => {
|
||||
const canon = (s: string): string => RRule.fromString(s).toString();
|
||||
const rules = [
|
||||
'FREQ=DAILY',
|
||||
'FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE',
|
||||
'FREQ=MONTHLY;BYMONTHDAY=15',
|
||||
'FREQ=MONTHLY;BYDAY=2TU',
|
||||
'FREQ=MONTHLY;BYDAY=3MO,4SU',
|
||||
'FREQ=MONTHLY;BYMONTHDAY=-1',
|
||||
'FREQ=YEARLY;BYMONTH=3,11;BYMONTHDAY=15',
|
||||
'FREQ=YEARLY;BYMONTH=3,4,5,6,7,8,9,10,11;BYDAY=SA',
|
||||
'FREQ=WEEKLY;BYDAY=MO;WKST=SU',
|
||||
'FREQ=DAILY;COUNT=5',
|
||||
'FREQ=DAILY;UNTIL=20240630T120000Z',
|
||||
// exotic → preserved via raw override
|
||||
'FREQ=MONTHLY;BYMONTHDAY=1,15,-1',
|
||||
'FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1',
|
||||
'FREQ=YEARLY;BYWEEKNO=20',
|
||||
'FREQ=YEARLY;BYYEARDAY=100,-1',
|
||||
'FREQ=MINUTELY;INTERVAL=90',
|
||||
];
|
||||
rules.forEach((rr) => {
|
||||
it(`"${rr}"`, () => {
|
||||
expect(canon(formModelToRRule(rruleToFormModel(rr)))).toBe(canon(rr));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,366 +0,0 @@
|
|||
import { RRule } from 'rrule';
|
||||
import { normalizeWeekdays, toNumArray } from './rrule-weekday.util';
|
||||
import { FREQ_TO_CYCLE, safeParseRRuleOptions } from './rrule-parse.util';
|
||||
|
||||
/**
|
||||
* Bidirectional bridge between the structured RRULE builder form (a dropdown per
|
||||
* RFC 5545 field) and the opaque `rrule` string stored on the TaskRepeatCfg.
|
||||
*
|
||||
* - `formModelToRRule` builds the string deterministically from the dropdowns.
|
||||
* - `rruleToFormModel` parses an existing string back into dropdown values for
|
||||
* editing (falling back to sane defaults for anything missing/unparseable).
|
||||
*
|
||||
* Scope — the structured builder covers every day-meaningful RFC 5545 rule part:
|
||||
* FREQ → DAILY | WEEKLY | MONTHLY | YEARLY (sub-daily survives via raw override)
|
||||
* INTERVAL, COUNT (after N), UNTIL (on date)
|
||||
* BYMONTH → seasonal constraint on ANY frequency (e.g. daily in Jan–Apr)
|
||||
* WEEKLY → BYDAY (weekday multi-select)
|
||||
* MONTHLY → day-of-month | nth-weekday (2MO) | weekday-set + BYSETPOS (last weekday) | last-day
|
||||
* YEARLY → on a date (BYMONTH + BYMONTHDAY) | weekdays within months (BYMONTH + BYDAY)
|
||||
* advanced → WKST, BYSETPOS, multi/negative BYMONTHDAY, raw override,
|
||||
* BYWEEKNO / BYYEARDAY (shown only for YEARLY per RFC 5545 §3.3.10)
|
||||
*
|
||||
* Anything the structured fields can't model (time-of-day parts, or a BYDAY with
|
||||
* different ordinals per weekday) is preserved verbatim via the raw override —
|
||||
* guaranteed by the canonical round-trip guard in rruleToFormModel.
|
||||
*/
|
||||
|
||||
export const RRULE_WEEKDAYS = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'] as const;
|
||||
export type RRuleWeekday = (typeof RRULE_WEEKDAYS)[number];
|
||||
|
||||
export type RRuleFreq = 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
|
||||
export type RRuleMonthlyMode = 'DAY_OF_MONTH' | 'NTH_WEEKDAY' | 'WEEKDAYS';
|
||||
/** YEARLY: a date (BYMONTHDAY), a weekday set within months (BYDAY[+BYSETPOS]),
|
||||
* or per-weekday ordinals (BYDAY=3MO,4SU). */
|
||||
export type RRuleYearlyMode = 'DAY_OF_MONTH' | 'NTH_WEEKDAY' | 'WEEKDAYS';
|
||||
export type RRuleEndType = 'NEVER' | 'COUNT' | 'UNTIL';
|
||||
/** The predefined ordinal dropdown values: 1..4 = 1st–4th occurrence; -1 = last. */
|
||||
export type RRuleSetPos = 1 | 2 | 3 | 4 | -1;
|
||||
/** One ordinal row applied to a set of weekdays, e.g. `{ pos: 3, days: ['MO',
|
||||
* 'TU'] }` = the 3rd Monday and 3rd Tuesday → BYDAY=3MO,3TU. `pos` is usually
|
||||
* one of the predefined `RRuleSetPos` values, but the custom ordinal input
|
||||
* allows any non-zero RFC 5545 ordinal (±1..±53), e.g. -2 = 2nd-to-last. */
|
||||
export interface RRuleNthDay {
|
||||
pos: number;
|
||||
days: RRuleWeekday[];
|
||||
}
|
||||
|
||||
export interface RRuleFormModel {
|
||||
freq: RRuleFreq;
|
||||
interval: number;
|
||||
byDay: RRuleWeekday[]; // WEEKLY
|
||||
monthlyMode: RRuleMonthlyMode; // MONTHLY
|
||||
monthDays: number[]; // MONTHLY/YEARLY day-of-month; -1 = last day; multiple allowed
|
||||
// MONTHLY/YEARLY nth-weekday rows (per-weekday ordinals), e.g. [{3,MO},{4,SU}].
|
||||
nthDays: RRuleNthDay[];
|
||||
byMonth: number[]; // YEARLY months (1..12)
|
||||
yearlyMode: RRuleYearlyMode; // YEARLY: date vs weekdays-within-months
|
||||
endType: RRuleEndType;
|
||||
count: number; // COUNT
|
||||
until: string; // UNTIL, 'YYYY-MM-DD'
|
||||
// --- advanced (collapsed section) ---
|
||||
wkst: RRuleWeekday | ''; // week start; '' = library default (Monday)
|
||||
// Comma-separated integer lists ('' = omitted). Cover the remaining RRULE
|
||||
// BY* rule parts as structured inputs.
|
||||
bySetPos: string; // BYSETPOS for the weekday-set "which occurrence" (single value)
|
||||
byWeekNo: string; // BYWEEKNO (ISO week numbers, 1..53 / negative)
|
||||
byYearDay: string; // BYYEARDAY (1..366 / negative)
|
||||
showAdvanced: boolean; // whether the advanced section is expanded
|
||||
rawOverride: string; // raw RRULE body that overrides the builder when set
|
||||
}
|
||||
|
||||
// RepeatCycleOption and RRuleFreq are the same four literals — reuse the
|
||||
// shared Frequency map instead of maintaining a second copy.
|
||||
const FREQ_TO_STR: Partial<Record<number, RRuleFreq>> = FREQ_TO_CYCLE;
|
||||
|
||||
/** JS `Date.getDay()` (0=Sun) → RRULE weekday index (0=Mon). */
|
||||
const jsDayToRRuleIdx = (jsDay: number): number => (jsDay + 6) % 7;
|
||||
|
||||
const pad2 = (n: number): string => String(n).padStart(2, '0');
|
||||
|
||||
/** 'YYYY-MM-DD' → RFC 5545 UNTIL value at noon UTC (matches engine occurrence instants). */
|
||||
const untilToRRule = (dateStr: string): string => {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(dateStr);
|
||||
if (!m) return '';
|
||||
return `${m[1]}${m[2]}${m[3]}T120000Z`;
|
||||
};
|
||||
|
||||
/** A UTC Date → 'YYYY-MM-DD' using UTC parts (no timezone drift). */
|
||||
const utcDateToDbStr = (d: Date): string =>
|
||||
`${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
|
||||
|
||||
/** Defaults for a fresh RRULE builder, seeded from `refDate` (the start date). */
|
||||
export const defaultRRuleFormModel = (refDate: Date = new Date()): RRuleFormModel => {
|
||||
const rruleWeekday = RRULE_WEEKDAYS[jsDayToRRuleIdx(refDate.getDay())];
|
||||
return {
|
||||
freq: 'WEEKLY',
|
||||
interval: 1,
|
||||
byDay: [rruleWeekday],
|
||||
monthlyMode: 'DAY_OF_MONTH',
|
||||
monthDays: [refDate.getDate()],
|
||||
nthDays: [
|
||||
{
|
||||
pos: Math.min(Math.floor((refDate.getDate() - 1) / 7) + 1, 4),
|
||||
days: [rruleWeekday],
|
||||
},
|
||||
],
|
||||
byMonth: [],
|
||||
yearlyMode: 'DAY_OF_MONTH',
|
||||
endType: 'NEVER',
|
||||
count: 10,
|
||||
until: '',
|
||||
wkst: '',
|
||||
bySetPos: '',
|
||||
byWeekNo: '',
|
||||
byYearDay: '',
|
||||
showAdvanced: false,
|
||||
rawOverride: '',
|
||||
};
|
||||
};
|
||||
|
||||
/** "3MO,4SU" / "1MO,1TU" — each row's ordinal applied to each of its weekdays
|
||||
* (Mon-first within a row). Deduped: two rows with the same (custom) ordinal
|
||||
* and overlapping weekdays must not emit the same token twice. */
|
||||
const nthDaysToByDay = (rows: RRuleNthDay[]): string =>
|
||||
[
|
||||
...new Set(
|
||||
(rows ?? []).flatMap((r) =>
|
||||
RRULE_WEEKDAYS.filter((d) => (r.days ?? []).includes(d)).map(
|
||||
(d) => `${r.pos}${d}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
].join(',');
|
||||
|
||||
/** Build the RFC 5545 RRULE body (no `RRULE:` prefix) from the dropdown model. */
|
||||
export const formModelToRRule = (m: RRuleFormModel): string => {
|
||||
// A raw override (advanced) wins over the structured builder entirely.
|
||||
if (m.rawOverride && m.rawOverride.trim()) {
|
||||
return m.rawOverride.trim();
|
||||
}
|
||||
const parts: string[] = [`FREQ=${m.freq}`];
|
||||
if (m.interval && m.interval > 1) parts.push(`INTERVAL=${m.interval}`);
|
||||
|
||||
const pushByDaySet = (): void => {
|
||||
if (m.byDay?.length) {
|
||||
const ordered = RRULE_WEEKDAYS.filter((d) => m.byDay.includes(d));
|
||||
parts.push(`BYDAY=${ordered.join(',')}`);
|
||||
}
|
||||
};
|
||||
const pushBySetPos = (): void => {
|
||||
if (m.bySetPos && m.bySetPos.trim()) {
|
||||
parts.push(`BYSETPOS=${m.bySetPos.replace(/\s+/g, '')}`);
|
||||
}
|
||||
};
|
||||
|
||||
// BYMONTH is a seasonal constraint valid for ANY frequency — e.g. daily in
|
||||
// Jan–Apr, weekly Mondays in June, or yearly within the chosen months.
|
||||
if (m.byMonth?.length) {
|
||||
parts.push(`BYMONTH=${[...m.byMonth].sort((a, b) => a - b).join(',')}`);
|
||||
}
|
||||
|
||||
if (m.freq === 'WEEKLY') {
|
||||
pushByDaySet();
|
||||
}
|
||||
|
||||
if (m.freq === 'MONTHLY') {
|
||||
if (m.monthlyMode === 'NTH_WEEKDAY') {
|
||||
// Per-weekday ordinals (the 3rd Monday and 4th Sunday → BYDAY=3MO,4SU).
|
||||
const byDay = nthDaysToByDay(m.nthDays);
|
||||
if (byDay) parts.push(`BYDAY=${byDay}`);
|
||||
} else if (m.monthlyMode === 'WEEKDAYS') {
|
||||
// A weekday set (e.g. Mon–Fri), optionally narrowed to one occurrence via
|
||||
// the "which occurrence" toggles (BYSETPOS) → "last weekday of month".
|
||||
pushByDaySet();
|
||||
pushBySetPos();
|
||||
} else if (m.monthDays?.length) {
|
||||
// Day(s) of the month (-1 = last day); selected via the day grid.
|
||||
parts.push(`BYMONTHDAY=${m.monthDays.join(',')}`);
|
||||
// BYSETPOS narrows the day set too — used by the migration clamp idiom
|
||||
// (BYMONTHDAY=31,-1;BYSETPOS=1 = "31st or last day of shorter months").
|
||||
// Emitting it keeps such rules round-tripping structurally.
|
||||
pushBySetPos();
|
||||
}
|
||||
}
|
||||
|
||||
if (m.freq === 'YEARLY') {
|
||||
if (m.yearlyMode === 'NTH_WEEKDAY') {
|
||||
// Per-weekday ordinals within the chosen month(s), e.g. BYDAY=3MO,4SU.
|
||||
const byDay = nthDaysToByDay(m.nthDays);
|
||||
if (byDay) parts.push(`BYDAY=${byDay}`);
|
||||
} else if (m.yearlyMode === 'WEEKDAYS') {
|
||||
// A weekday set within the chosen month(s), optionally narrowed to one
|
||||
// occurrence via "which" (BYSETPOS) → e.g. the 2nd Saturday of June.
|
||||
pushByDaySet();
|
||||
pushBySetPos();
|
||||
} else if (m.monthDays?.length && m.byMonth?.length) {
|
||||
// Date mode REQUIRES BYMONTH: per RFC 5545, FREQ=YEARLY with a bare
|
||||
// BYMONTHDAY expands across every month — i.e. fires monthly. With no
|
||||
// months selected, omit BYMONTHDAY too: a plain FREQ=YEARLY anchors to
|
||||
// the start date's month+day, which is truly yearly. Parsed bare yearly
|
||||
// rules can't round-trip through this and fall back to the raw override,
|
||||
// preserving their (monthly-firing) semantics verbatim.
|
||||
parts.push(`BYMONTHDAY=${m.monthDays.join(',')}`);
|
||||
// Same as MONTHLY: keep the clamp idiom (e.g. Feb-29 yearly) round-tripping.
|
||||
pushBySetPos();
|
||||
}
|
||||
}
|
||||
|
||||
// Advanced parts apply whenever they have a value — `showAdvanced` only drives
|
||||
// the collapsible's open/closed state, never the resulting rule.
|
||||
// BYWEEKNO / BYYEARDAY are YEARLY-only (RFC 5545 §3.3.10), so they're surfaced
|
||||
// in the yearly section and only emitted for YEARLY — a value left over after
|
||||
// switching frequency must not leak onto a non-yearly rule.
|
||||
const clean = (v: string): string => v.replace(/\s+/g, '');
|
||||
if (m.freq === 'YEARLY' && m.byWeekNo && m.byWeekNo.trim()) {
|
||||
parts.push(`BYWEEKNO=${clean(m.byWeekNo)}`);
|
||||
}
|
||||
if (m.freq === 'YEARLY' && m.byYearDay && m.byYearDay.trim()) {
|
||||
parts.push(`BYYEARDAY=${clean(m.byYearDay)}`);
|
||||
}
|
||||
if (m.wkst) parts.push(`WKST=${m.wkst}`);
|
||||
if (m.endType === 'COUNT' && m.count > 0) parts.push(`COUNT=${m.count}`);
|
||||
if (m.endType === 'UNTIL' && m.until) parts.push(`UNTIL=${untilToRRule(m.until)}`);
|
||||
|
||||
return parts.join(';');
|
||||
};
|
||||
|
||||
/** Parse an RRULE body back into dropdown values; unparseable → defaults. */
|
||||
export const rruleToFormModel = (
|
||||
rrule: string | undefined,
|
||||
refDate: Date = new Date(),
|
||||
): RRuleFormModel => {
|
||||
const model = defaultRRuleFormModel(refDate);
|
||||
if (!rrule || !rrule.trim()) return model;
|
||||
|
||||
const opts = safeParseRRuleOptions(rrule);
|
||||
if (!opts) return model;
|
||||
const freqStr = FREQ_TO_STR[opts.freq];
|
||||
if (freqStr == null) {
|
||||
// Sub-daily / unsupported FREQ (the day-granular engine has no UI for it) →
|
||||
// preserve the rule verbatim via the raw override.
|
||||
model.showAdvanced = true;
|
||||
model.rawOverride = rrule.trim();
|
||||
return model;
|
||||
}
|
||||
|
||||
model.freq = freqStr;
|
||||
model.interval = opts.interval && opts.interval > 0 ? opts.interval : 1;
|
||||
|
||||
const weekdays = normalizeWeekdays(opts.byweekday);
|
||||
const monthDays = toNumArray(opts.bymonthday);
|
||||
const months = toNumArray(opts.bymonth);
|
||||
// BYMONTH applies to any frequency.
|
||||
model.byMonth = months;
|
||||
// BYMONTHDAY (the day-of-month grid) — used by MONTHLY/YEARLY day modes.
|
||||
if (monthDays.length) model.monthDays = monthDays;
|
||||
|
||||
if (model.freq === 'WEEKLY' && weekdays.length) {
|
||||
model.byDay = weekdays.map((w) => RRULE_WEEKDAYS[w.weekday]).filter(Boolean);
|
||||
}
|
||||
|
||||
// When every weekday carries an ordinal (e.g. 3MO,4SU) it's the nth-weekday
|
||||
// mode; a plain set (MO,TU) or one with BYSETPOS stays the weekday-set mode.
|
||||
// Weekdays that share an ordinal collapse into one row: 1MO,1TU,3WE →
|
||||
// [{pos:1,days:[MO,TU]},{pos:3,days:[WE]}] (Mon-first within each row).
|
||||
const toNthDays = (): RRuleNthDay[] => {
|
||||
const byPos = new Map<number, RRuleWeekday[]>();
|
||||
for (const wd of weekdays) {
|
||||
const day = RRULE_WEEKDAYS[wd.weekday];
|
||||
const list = byPos.get(wd.n as number) ?? [];
|
||||
if (!list.includes(day)) list.push(day);
|
||||
byPos.set(wd.n as number, list);
|
||||
}
|
||||
return [...byPos.entries()].map(([pos, days]) => ({
|
||||
pos,
|
||||
days: RRULE_WEEKDAYS.filter((d) => days.includes(d)),
|
||||
}));
|
||||
};
|
||||
|
||||
if (model.freq === 'MONTHLY') {
|
||||
if (weekdays.length && weekdays.every((w) => w.n != null)) {
|
||||
model.monthlyMode = 'NTH_WEEKDAY';
|
||||
model.nthDays = toNthDays();
|
||||
} else if (weekdays.length) {
|
||||
// Weekday set with no per-day ordinal (pairs with BYSETPOS for "last weekday").
|
||||
model.monthlyMode = 'WEEKDAYS';
|
||||
model.byDay = weekdays.map((w) => RRULE_WEEKDAYS[w.weekday]).filter(Boolean);
|
||||
} else if (monthDays.length) {
|
||||
model.monthlyMode = 'DAY_OF_MONTH';
|
||||
}
|
||||
}
|
||||
|
||||
if (model.freq === 'YEARLY') {
|
||||
if (weekdays.length && weekdays.every((w) => w.n != null)) {
|
||||
model.yearlyMode = 'NTH_WEEKDAY';
|
||||
model.nthDays = toNthDays();
|
||||
} else if (weekdays.length) {
|
||||
model.yearlyMode = 'WEEKDAYS';
|
||||
model.byDay = weekdays.map((w) => RRULE_WEEKDAYS[w.weekday]).filter(Boolean);
|
||||
} else if (monthDays.length) {
|
||||
model.yearlyMode = 'DAY_OF_MONTH';
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.count != null) {
|
||||
model.endType = 'COUNT';
|
||||
model.count = opts.count;
|
||||
} else if (opts.until instanceof Date) {
|
||||
model.endType = 'UNTIL';
|
||||
model.until = utcDateToDbStr(opts.until);
|
||||
}
|
||||
|
||||
// --- advanced: WKST + remaining BY* rule parts as structured fields ---
|
||||
const w = opts.wkst as number | { weekday: number } | null | undefined;
|
||||
const wkstNum =
|
||||
typeof w === 'number' ? w : w && typeof w === 'object' ? w.weekday : undefined;
|
||||
if (wkstNum != null && wkstNum >= 0 && wkstNum < 7) {
|
||||
model.wkst = RRULE_WEEKDAYS[wkstNum];
|
||||
model.showAdvanced = true;
|
||||
}
|
||||
// BYSETPOS drives the weekday-set "which occurrence" toggles (a main control,
|
||||
// not advanced). Values outside the predefined options — including multi-value
|
||||
// lists like "2,-1" — render via the custom input. Zeros are dropped:
|
||||
// RRule.parseString accepts BYSETPOS=0 but re-emitting it produces an
|
||||
// RFC-invalid rule the occurrence engine silently treats as dead.
|
||||
const setPosArr = toNumArray(opts.bysetpos).filter((n) => n !== 0);
|
||||
if (setPosArr.length) model.bySetPos = setPosArr.join(',');
|
||||
const weekNo = toNumArray(opts.byweekno).join(',');
|
||||
const yearDay = toNumArray(opts.byyearday).join(',');
|
||||
if (weekNo) {
|
||||
model.byWeekNo = weekNo;
|
||||
model.showAdvanced = true;
|
||||
}
|
||||
if (yearDay) {
|
||||
model.byYearDay = yearDay;
|
||||
model.showAdvanced = true;
|
||||
}
|
||||
|
||||
// Round-trip guard: if the structured + advanced fields cannot faithfully
|
||||
// reproduce the input (param combos the builder doesn't model, time-of-day
|
||||
// parts, etc.), fall back to a raw override so nothing is silently lost.
|
||||
// BYSETPOS zeros are ignored in the comparison: the mapping above drops them
|
||||
// deliberately, and treating that as a mismatch would store the original
|
||||
// rule as a raw override — re-emitting the dead BYSETPOS=0 verbatim and
|
||||
// undoing the cleanup.
|
||||
const canon = (str: string): string => {
|
||||
try {
|
||||
const o = RRule.parseString(str);
|
||||
const sp = toNumArray(o.bysetpos).filter((n) => n !== 0);
|
||||
o.bysetpos = sp.length ? sp : null;
|
||||
return new RRule(o).toString();
|
||||
} catch {
|
||||
return str;
|
||||
}
|
||||
};
|
||||
if (canon(formModelToRRule(model)) !== canon(rrule)) {
|
||||
model.showAdvanced = true;
|
||||
model.rawOverride = rrule.trim();
|
||||
model.wkst = '';
|
||||
model.bySetPos = '';
|
||||
model.byWeekNo = '';
|
||||
model.byYearDay = '';
|
||||
}
|
||||
|
||||
return model;
|
||||
};
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { Frequency, RRule } from 'rrule';
|
||||
import { RepeatCycleOption } from '../task-repeat-cfg.model';
|
||||
|
||||
/**
|
||||
* Shared fail-soft RRULE parsing for every layer that inspects a rule body
|
||||
* (occurrence engine, form builder, legacy converter, preview). One definition
|
||||
* of "parseable rule with a FREQ" — previously six hand-rolled try/catch
|
||||
* copies that had already drifted in their guards.
|
||||
*/
|
||||
|
||||
export type RRuleParsedOptions = Partial<ReturnType<typeof RRule.parseString>> & {
|
||||
freq: Frequency;
|
||||
};
|
||||
|
||||
/** Parse an RRULE body; null when unparseable or lacking a FREQ. */
|
||||
export const safeParseRRuleOptions = (
|
||||
rrule: string | undefined,
|
||||
): RRuleParsedOptions | null => {
|
||||
if (!rrule || !rrule.trim()) return null;
|
||||
try {
|
||||
const opts = RRule.parseString(rrule);
|
||||
return opts.freq == null ? null : (opts as RRuleParsedOptions);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** rrule Frequency → day-granular repeat cycle. Sub-daily FREQs are absent —
|
||||
* callers treat a miss as "no legacy/day-granular equivalent". */
|
||||
export const FREQ_TO_CYCLE: Partial<Record<number, RepeatCycleOption>> = {
|
||||
[Frequency.DAILY]: 'DAILY',
|
||||
[Frequency.WEEKLY]: 'WEEKLY',
|
||||
[Frequency.MONTHLY]: 'MONTHLY',
|
||||
[Frequency.YEARLY]: 'YEARLY',
|
||||
};
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
import { getRRulePreview } from './rrule-preview.util';
|
||||
|
||||
describe('getRRulePreview', () => {
|
||||
it('humanizes a valid rrule and echoes the body', () => {
|
||||
const p = getRRulePreview('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO');
|
||||
expect(p).not.toBeNull();
|
||||
expect(p!.rrule).toBe('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO');
|
||||
expect(p!.human.toLowerCase()).toContain('week');
|
||||
});
|
||||
|
||||
it('capitalizes the human reading', () => {
|
||||
const p = getRRulePreview('FREQ=DAILY');
|
||||
expect(p).not.toBeNull();
|
||||
expect(p!.human.charAt(0)).toBe(p!.human.charAt(0).toUpperCase());
|
||||
});
|
||||
|
||||
it('compresses consecutive month ranges in the human reading', () => {
|
||||
const p = getRRulePreview('FREQ=YEARLY;BYMONTH=3,4,5,6,7,8,9,10,11;BYDAY=SA');
|
||||
expect(p).not.toBeNull();
|
||||
expect(p!.human).toContain('March to November');
|
||||
expect(p!.human).not.toContain('April');
|
||||
});
|
||||
|
||||
it('leaves non-consecutive months as a list', () => {
|
||||
const p = getRRulePreview('FREQ=YEARLY;BYMONTH=3,6,9');
|
||||
expect(p!.human).not.toContain(' to ');
|
||||
});
|
||||
|
||||
it('returns null for empty / whitespace / garbage / undefined', () => {
|
||||
expect(getRRulePreview('')).toBeNull();
|
||||
expect(getRRulePreview(' ')).toBeNull();
|
||||
expect(getRRulePreview('not an rrule')).toBeNull();
|
||||
expect(getRRulePreview(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('lists upcoming occurrences anchored at the start date', () => {
|
||||
// Future start date → deterministic regardless of "now".
|
||||
const p = getRRulePreview('FREQ=DAILY', '2099-01-05');
|
||||
expect(p!.upcoming.length).toBe(3);
|
||||
expect(p!.upcoming[0].getFullYear()).toBe(2099);
|
||||
expect(p!.upcoming[0].getMonth()).toBe(0);
|
||||
expect(p!.upcoming[0].getDate()).toBe(5);
|
||||
expect(p!.upcoming[1].getDate()).toBe(6);
|
||||
expect(p!.upcoming[2].getDate()).toBe(7);
|
||||
});
|
||||
|
||||
it('respects the interval in upcoming dates', () => {
|
||||
const p = getRRulePreview('FREQ=DAILY;INTERVAL=3', '2099-01-05');
|
||||
expect(p!.upcoming.map((d) => d.getDate())).toEqual([5, 8, 11]);
|
||||
});
|
||||
|
||||
it('completion example re-anchors to the completion day', () => {
|
||||
const p = getRRulePreview('FREQ=DAILY;INTERVAL=3', '2099-01-05');
|
||||
expect(p!.completionExample).not.toBeNull();
|
||||
expect(p!.completionExample!.done.getDate()).toBe(5);
|
||||
expect(p!.completionExample!.next.getDate()).toBe(8); // 3 days after completion
|
||||
});
|
||||
|
||||
it('bounds upcoming by COUNT', () => {
|
||||
const p = getRRulePreview('FREQ=DAILY;COUNT=2', '2099-01-05');
|
||||
expect(p!.upcoming.length).toBe(2);
|
||||
});
|
||||
|
||||
it('localizes the human reading via humanize opts', () => {
|
||||
const vocab: Record<string, string> = {
|
||||
every: 'cada',
|
||||
week: 'semana',
|
||||
weeks: 'semanas',
|
||||
on: 'en',
|
||||
};
|
||||
const p = getRRulePreview('FREQ=WEEKLY;BYDAY=MO', undefined, {
|
||||
gettext: (id) => vocab[id] ?? id,
|
||||
language: {
|
||||
dayNames: [
|
||||
'Domingo',
|
||||
'Lunes',
|
||||
'Martes',
|
||||
'Miércoles',
|
||||
'Jueves',
|
||||
'Viernes',
|
||||
'Sábado',
|
||||
],
|
||||
monthNames: [
|
||||
'Enero',
|
||||
'Febrero',
|
||||
'Marzo',
|
||||
'Abril',
|
||||
'Mayo',
|
||||
'Junio',
|
||||
'Julio',
|
||||
'Agosto',
|
||||
'Septiembre',
|
||||
'Octubre',
|
||||
'Noviembre',
|
||||
'Diciembre',
|
||||
],
|
||||
tokens: {},
|
||||
},
|
||||
andWord: 'y',
|
||||
toWord: 'a',
|
||||
});
|
||||
expect(p!.human.toLowerCase()).toContain('semana'); // "week" → localized
|
||||
expect(p!.human).toContain('Lunes'); // Monday → localized
|
||||
});
|
||||
});
|
||||
|
|
@ -1,259 +0,0 @@
|
|||
import { RRule } from 'rrule';
|
||||
import { T } from '../../../t.const';
|
||||
import { noonUtc, toLocalNoon } from '../store/rrule-occurrence.util';
|
||||
import { toNumArray } from './rrule-weekday.util';
|
||||
import { safeParseRRuleOptions } from './rrule-parse.util';
|
||||
|
||||
export interface RRulePreview {
|
||||
/** Canonical RRULE body the form produced. */
|
||||
rrule: string;
|
||||
/** Humanized English reading via rrule.toText(), e.g. "every 2 weeks on Monday". */
|
||||
human: string;
|
||||
/**
|
||||
* Next few concrete occurrences of the rule (local noon), for a "Fixed dates"
|
||||
* preview. Empty if the rule is finished/unparseable.
|
||||
*/
|
||||
upcoming: Date[];
|
||||
/**
|
||||
* Illustration for the "After completion" schedule type: if you complete the
|
||||
* first upcoming occurrence, when the next one lands. Re-anchors the rule to the
|
||||
* completion day exactly like the engine. Null when there's no next occurrence.
|
||||
*/
|
||||
completionExample: { done: Date; next: Date } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Localization hooks for the human reading. `gettext` maps rrule.js's English
|
||||
* connective tokens ('every', 'on the', 'and', …) to translations; `language`
|
||||
* supplies localized day/month names. Omitted → English (used by tests / REST).
|
||||
*/
|
||||
export interface RRuleHumanizeOpts {
|
||||
gettext: (id: string) => string;
|
||||
language: { dayNames: string[]; monthNames: string[]; tokens: unknown };
|
||||
andWord: string;
|
||||
toWord: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the localized humanizer for `getRRulePreview` from a translate function
|
||||
* (e.g. `(k) => translateService.instant(k)`). Day/month names come from existing
|
||||
* T keys; the rrule.js connective tokens map to `RRULE_NLP_*` keys, each falling
|
||||
* back to its English token when a locale lacks the key (never a raw key).
|
||||
*/
|
||||
export const buildRRuleHumanizeOpts = (
|
||||
translate: (key: string) => string,
|
||||
): RRuleHumanizeOpts => {
|
||||
const tt = (key: string, english: string): string => {
|
||||
const v = translate(key);
|
||||
return v && v !== key ? v : english;
|
||||
};
|
||||
const F = T.F.TASK_REPEAT.F;
|
||||
const EN_DAYS = [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
const dayKeys = [
|
||||
F.SUNDAY,
|
||||
F.MONDAY,
|
||||
F.TUESDAY,
|
||||
F.WEDNESDAY,
|
||||
F.THURSDAY,
|
||||
F.FRIDAY,
|
||||
F.SATURDAY,
|
||||
];
|
||||
const monthKeys = [
|
||||
F.RRULE_MONTH_1,
|
||||
F.RRULE_MONTH_2,
|
||||
F.RRULE_MONTH_3,
|
||||
F.RRULE_MONTH_4,
|
||||
F.RRULE_MONTH_5,
|
||||
F.RRULE_MONTH_6,
|
||||
F.RRULE_MONTH_7,
|
||||
F.RRULE_MONTH_8,
|
||||
F.RRULE_MONTH_9,
|
||||
F.RRULE_MONTH_10,
|
||||
F.RRULE_MONTH_11,
|
||||
F.RRULE_MONTH_12,
|
||||
];
|
||||
const dayNames = dayKeys.map((k, i) => tt(k, EN_DAYS[i]));
|
||||
const monthNames = monthKeys.map((k, i) => tt(k, MONTH_NAMES[i]));
|
||||
// Map (not an object literal) so the multi-word token "on the" is allowed.
|
||||
const vocab = new Map<string, string>([
|
||||
['every', tt(F.RRULE_NLP_EVERY, 'every')],
|
||||
['day', tt(F.RRULE_NLP_DAY, 'day')],
|
||||
['days', tt(F.RRULE_NLP_DAYS, 'days')],
|
||||
['week', tt(F.RRULE_NLP_WEEK, 'week')],
|
||||
['weeks', tt(F.RRULE_NLP_WEEKS, 'weeks')],
|
||||
['month', tt(F.RRULE_NLP_MONTH, 'month')],
|
||||
['months', tt(F.RRULE_NLP_MONTHS, 'months')],
|
||||
['year', tt(F.RRULE_NLP_YEAR, 'year')],
|
||||
['years', tt(F.RRULE_NLP_YEARS, 'years')],
|
||||
['on', tt(F.RRULE_NLP_ON, 'on')],
|
||||
['on the', tt(F.RRULE_NLP_ON_THE, 'on the')],
|
||||
['the', tt(F.RRULE_NLP_THE, 'the')],
|
||||
['and', tt(F.RRULE_NLP_AND, 'and')],
|
||||
['or', tt(F.RRULE_NLP_OR, 'or')],
|
||||
['for', tt(F.RRULE_NLP_FOR, 'for')],
|
||||
['time', tt(F.RRULE_NLP_TIME, 'time')],
|
||||
['times', tt(F.RRULE_NLP_TIMES, 'times')],
|
||||
['until', tt(F.RRULE_NLP_UNTIL, 'until')],
|
||||
['weekday', tt(F.RRULE_NLP_WEEKDAY, 'weekday')],
|
||||
['weekdays', tt(F.RRULE_NLP_WEEKDAYS, 'weekdays')],
|
||||
['in', tt(F.RRULE_NLP_IN, 'in')],
|
||||
['last', tt(F.RRULE_NLP_LAST, 'last')],
|
||||
['st', tt(F.RRULE_NLP_ST, 'st')],
|
||||
['nd', tt(F.RRULE_NLP_ND, 'nd')],
|
||||
['rd', tt(F.RRULE_NLP_RD, 'rd')],
|
||||
['th', tt(F.RRULE_NLP_TH, 'th')],
|
||||
]);
|
||||
return {
|
||||
gettext: (id: string): string => vocab.get(id) ?? id,
|
||||
language: { dayNames, monthNames, tokens: {} },
|
||||
andWord: tt(F.RRULE_NLP_AND, 'and'),
|
||||
toWord: tt(F.RRULE_NLP_TO, 'to'),
|
||||
};
|
||||
};
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December',
|
||||
];
|
||||
|
||||
/** Deduped, ascending BYMONTH list (engine-shared toNumArray + order for range compression). */
|
||||
const toMonthArray = (v: unknown): number[] =>
|
||||
[...new Set(toNumArray(v))].sort((a, b) => a - b);
|
||||
|
||||
/**
|
||||
* rrule.toText() spells out every month ("March, April … and November"). Collapse
|
||||
* runs of 3+ consecutive months into "March to November" for a shorter reading.
|
||||
*/
|
||||
const compressMonthRanges = (
|
||||
text: string,
|
||||
bymonth: unknown,
|
||||
monthNames: string[],
|
||||
andWord: string,
|
||||
toWord: string,
|
||||
): string => {
|
||||
const months = toMonthArray(bymonth);
|
||||
let result = text;
|
||||
let i = 0;
|
||||
while (i < months.length) {
|
||||
let j = i;
|
||||
while (j + 1 < months.length && months[j + 1] === months[j] + 1) j++;
|
||||
if (j - i >= 2) {
|
||||
const names = months.slice(i, j + 1).map((m) => monthNames[m - 1]);
|
||||
const last = names[names.length - 1];
|
||||
const listStr = `${names.slice(0, -1).join(', ')} ${andWord} ${last}`;
|
||||
result = result.replace(listStr, `${names[0]} ${toWord} ${last}`);
|
||||
}
|
||||
i = j + 1;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/** Parse a rrule body and anchor it at `dtstart`, or null if unparseable. */
|
||||
const _ruleAnchoredAt = (rrule: string, dtstart: Date): RRule | null => {
|
||||
const opts = safeParseRRuleOptions(rrule);
|
||||
if (!opts) return null;
|
||||
try {
|
||||
return new RRule({ ...opts, dtstart });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** The next `count` occurrences (local noon) on/after the later of start date and now. */
|
||||
const _getUpcoming = (
|
||||
rrule: string,
|
||||
startDate: string | undefined,
|
||||
count: number,
|
||||
): Date[] => {
|
||||
const anchor = startDate ? noonUtc(startDate) : new Date();
|
||||
const rule = _ruleAnchoredAt(rrule, anchor);
|
||||
if (!rule) return [];
|
||||
const out: Date[] = [];
|
||||
// Seed just before the later of the anchor and now so we show FUTURE instances
|
||||
// (a rule whose start is in the past should preview from today, not its origin).
|
||||
let seed = new Date(Math.max(anchor.getTime(), Date.now()) - 1);
|
||||
for (let k = 0; k < count; k++) {
|
||||
let occ: Date | null;
|
||||
try {
|
||||
occ = rule.after(seed, false);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
if (!occ) break;
|
||||
out.push(toLocalNoon(occ));
|
||||
seed = occ;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
/** Re-anchor the rule to `done`'s day and return the next occurrence (engine parity). */
|
||||
const _getCompletionNext = (rrule: string, done: Date): Date | null => {
|
||||
const start = new Date(
|
||||
Date.UTC(done.getFullYear(), done.getMonth(), done.getDate(), 12, 0, 0),
|
||||
);
|
||||
const rule = _ruleAnchoredAt(rrule, start);
|
||||
if (!rule) return null;
|
||||
try {
|
||||
const occ = rule.after(start, false);
|
||||
return occ ? toLocalNoon(occ) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves an RRULE body to a humanized reading + concrete upcoming dates for the
|
||||
* dialog's live preview. `startDate` anchors the occurrence examples (defaults to
|
||||
* today). Returns null when empty or unparseable.
|
||||
*/
|
||||
export const getRRulePreview = (
|
||||
rrule: string | undefined,
|
||||
startDate?: string,
|
||||
humanize?: RRuleHumanizeOpts,
|
||||
): RRulePreview | null => {
|
||||
if (typeof rrule !== 'string' || !rrule.trim()) return null;
|
||||
const body = rrule.trim();
|
||||
try {
|
||||
const rule = RRule.fromString(body);
|
||||
if (rule.options.freq == null) return null;
|
||||
const text = humanize
|
||||
? rule.toText(humanize.gettext as never, humanize.language as never)
|
||||
: rule.toText();
|
||||
const human = compressMonthRanges(
|
||||
text,
|
||||
rule.options.bymonth,
|
||||
humanize?.language.monthNames ?? MONTH_NAMES,
|
||||
humanize?.andWord ?? 'and',
|
||||
humanize?.toWord ?? 'to',
|
||||
);
|
||||
const upcoming = _getUpcoming(body, startDate, 3);
|
||||
const next = upcoming.length ? _getCompletionNext(body, upcoming[0]) : null;
|
||||
const completionExample = next ? { done: upcoming[0], next } : null;
|
||||
return {
|
||||
rrule: body,
|
||||
human: human.charAt(0).toUpperCase() + human.slice(1),
|
||||
upcoming,
|
||||
completionExample,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
/**
|
||||
* Shared normalizers for `rrule` option values, used by both the form builder
|
||||
* (`rrule-form.util`) and the legacy⇄RRULE converter (`legacy-cfg-to-rrule.util`).
|
||||
*/
|
||||
|
||||
/** Coerce an rrule numeric option (`bymonthday`, `bymonth`, …) to a number[]. */
|
||||
export const toNumArray = (v: unknown): number[] => {
|
||||
if (v == null) return [];
|
||||
if (Array.isArray(v)) return v.filter((x): x is number => typeof x === 'number');
|
||||
return typeof v === 'number' ? [v] : [];
|
||||
};
|
||||
|
||||
/** Normalize rrule's `byweekday` option to `{ weekday, n }` records. */
|
||||
export const normalizeWeekdays = (v: unknown): { weekday: number; n?: number }[] => {
|
||||
if (v == null) return [];
|
||||
const arr = Array.isArray(v) ? v : [v];
|
||||
return arr
|
||||
.map((w) => {
|
||||
if (typeof w === 'number') return { weekday: w };
|
||||
if (w && typeof w === 'object' && 'weekday' in w) {
|
||||
const wd = w as { weekday: number; n?: number };
|
||||
return { weekday: wd.weekday, n: wd.n ?? undefined };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((x): x is { weekday: number; n?: number } => x !== null);
|
||||
};
|
||||
|
|
@ -250,7 +250,7 @@
|
|||
mat-menu-item
|
||||
(click)="selectRepeatQuickSetting(option.value)"
|
||||
>
|
||||
@if (option.value === 'RRULE' || option.value === 'CUSTOM') {
|
||||
@if (option.value === 'CUSTOM') {
|
||||
<mat-icon>tune</mat-icon>
|
||||
} @else {
|
||||
<mat-icon>repeat</mat-icon>
|
||||
|
|
|
|||
|
|
@ -85,13 +85,6 @@ import { DateService } from '../../../core/date/date.service';
|
|||
import { MenuTreeService } from '../../menu-tree/menu-tree.service';
|
||||
import { SelectOptionRowComponent } from '../../../ui/select-option-row/select-option-row.component';
|
||||
|
||||
// Repeat quick settings that need the full repeat dialog to assemble the rule
|
||||
// (the add-task-bar menu can't build an rrule itself): the 'RRULE' builder
|
||||
// option and the legacy 'CUSTOM' value. Routing them through
|
||||
// getQuickSettingUpdates instead would silently create a weekly-fallback cfg.
|
||||
const isDialogRepeatSetting = (s: string | null | undefined): boolean =>
|
||||
s === 'RRULE' || s === 'CUSTOM';
|
||||
|
||||
@Component({
|
||||
selector: 'add-task-bar',
|
||||
templateUrl: './add-task-bar.component.html',
|
||||
|
|
@ -496,10 +489,7 @@ export class AddTaskBarComponent implements AfterViewInit, OnInit, OnDestroy {
|
|||
} else {
|
||||
taskData.dueDay = state.date;
|
||||
}
|
||||
} else if (
|
||||
state.repeatQuickSetting &&
|
||||
!isDialogRepeatSetting(state.repeatQuickSetting)
|
||||
) {
|
||||
} else if (state.repeatQuickSetting && state.repeatQuickSetting !== 'CUSTOM') {
|
||||
// When a repeat preset is selected without an explicit date, set dueDay to today
|
||||
// so the first task instance appears as today's occurrence instead of staying in inbox
|
||||
taskData.dueDay = this._dateService.todayStr();
|
||||
|
|
@ -529,7 +519,7 @@ export class AddTaskBarComponent implements AfterViewInit, OnInit, OnDestroy {
|
|||
// would cause double-scheduling.
|
||||
const isTimedRepeatTask =
|
||||
!!state.repeatQuickSetting &&
|
||||
!isDialogRepeatSetting(state.repeatQuickSetting) &&
|
||||
state.repeatQuickSetting !== 'CUSTOM' &&
|
||||
!!state.time;
|
||||
if (taskData.dueWithTime && !isTimedRepeatTask) {
|
||||
this._taskService
|
||||
|
|
@ -547,9 +537,7 @@ export class AddTaskBarComponent implements AfterViewInit, OnInit, OnDestroy {
|
|||
|
||||
// Create repeat config if a repeat setting was selected
|
||||
if (state.repeatQuickSetting) {
|
||||
if (isDialogRepeatSetting(state.repeatQuickSetting)) {
|
||||
// 'Custom recurring config' → open the full repeat dialog (with the
|
||||
// RRULE builder) for the freshly created task.
|
||||
if (state.repeatQuickSetting === 'CUSTOM') {
|
||||
this._openRepeatDialogForTask(taskId, resolvedRemindOption);
|
||||
} else {
|
||||
const startDate = state.date || this._dateService.todayStr();
|
||||
|
|
@ -561,8 +549,6 @@ export class AddTaskBarComponent implements AfterViewInit, OnInit, OnDestroy {
|
|||
startDate,
|
||||
...quickSettingUpdates,
|
||||
title,
|
||||
// The addTaskRepeatCfgToTask action creator clamps quickSetting to a
|
||||
// sync-safe value at the persist boundary (newer presets → 'CUSTOM').
|
||||
quickSetting: state.repeatQuickSetting,
|
||||
tagIds: taskData.tagIds ?? [],
|
||||
defaultEstimate: state.estimate || 0,
|
||||
|
|
@ -837,13 +823,7 @@ export class AddTaskBarComponent implements AfterViewInit, OnInit, OnDestroy {
|
|||
const { DialogEditTaskRepeatCfgComponent } =
|
||||
await import('../../task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component');
|
||||
this._matDialog.open(DialogEditTaskRepeatCfgComponent, {
|
||||
// Open straight into the RRULE builder — the user explicitly chose
|
||||
// "Custom recurring config" from the menu.
|
||||
data: {
|
||||
task,
|
||||
defaultRemindOption: remindOption,
|
||||
initialQuickSetting: 'RRULE',
|
||||
},
|
||||
data: { task, defaultRemindOption: remindOption },
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
|
|
|
|||
|
|
@ -46,20 +46,17 @@ describe('mapArchiveToWorklog timezone test', () => {
|
|||
// The task should be placed on the local date when it was done
|
||||
// In LA (UTC-8): 2025-01-16 at 10 PM local -> worklog for Jan 16
|
||||
// In Berlin (UTC+1): 2025-01-17 at 7 AM local -> worklog for Jan 17
|
||||
// Use the offset AT the test instant (January) — `new Date()` breaks
|
||||
// every summer in DST zones. 06:00 UTC is the previous local day only
|
||||
// west of UTC-6.
|
||||
const tzOffset = new Date(taskDoneTime).getTimezoneOffset();
|
||||
const tzOffset = new Date().getTimezoneOffset();
|
||||
const year2025 = result.worklog['2025'];
|
||||
|
||||
if (tzOffset > 360) {
|
||||
// west of UTC-6 (e.g. LA)
|
||||
if (tzOffset > 0) {
|
||||
// LA
|
||||
// Should have entry for Jan 16
|
||||
const foundEntry = year2025?.ent[1]?.ent[16];
|
||||
expect(foundEntry).toBeDefined();
|
||||
expect(foundEntry?.dateStr).toBe('2025-01-16');
|
||||
} else {
|
||||
// east of UTC-6 (e.g. New York, Berlin)
|
||||
// Berlin
|
||||
// Should have entry for Jan 17
|
||||
const foundEntry = year2025?.ent[1]?.ent[17];
|
||||
expect(foundEntry).toBeDefined();
|
||||
|
|
|
|||
|
|
@ -91,29 +91,26 @@ describe('WorklogService timezone fix', () => {
|
|||
buggyFilteredTasks.map((t) => ({ id: t.id, dateStr: t.dateStr })),
|
||||
);
|
||||
|
||||
// In any timezone WEST of UTC (e.g. LA, New York), this would fail because:
|
||||
// new Date('2024-01-15') creates Jan 15 00:00 UTC = Jan 14 local time
|
||||
// rangeStart is Jan 15 00:00 local = later than that in UTC
|
||||
// So taskDate < rangeStart, and the task is excluded.
|
||||
// Branch on the offset AT the test date (January!) — the host's current
|
||||
// offset differs across DST, and a tz-name string check only covered LA.
|
||||
const tzOffsetAtRange = rangeStart.getTimezoneOffset();
|
||||
if (tzOffsetAtRange > 0) {
|
||||
// west of UTC (e.g. LA, New York): the bug excludes the first task
|
||||
// In America/Los_Angeles timezone, this would fail because:
|
||||
// new Date('2024-01-15') creates Jan 15 00:00 UTC = Jan 14 16:00 PST
|
||||
// rangeStart is Jan 15 00:00 PST = Jan 15 08:00 UTC
|
||||
// So taskDate < rangeStart, and the task is excluded
|
||||
if (currentTimezone === 'America/Los_Angeles') {
|
||||
// The bug would cause the first task to be excluded
|
||||
expect(buggyFilteredTasks.length).toBe(2);
|
||||
expect(buggyFilteredTasks.some((t) => t.dateStr === '2024-01-15')).toBe(
|
||||
false,
|
||||
'First day should be excluded due to the timezone bug west of UTC',
|
||||
'First day should be excluded due to timezone bug in America/Los_Angeles',
|
||||
);
|
||||
} else {
|
||||
// At/east of UTC (e.g. Berlin), the bug doesn't manifest because:
|
||||
// In Europe/Berlin (UTC+1), the bug doesn't manifest because:
|
||||
// new Date('2024-01-15') creates Jan 15 00:00 UTC = Jan 15 01:00 CET
|
||||
// rangeStart is Jan 15 00:00 CET = Jan 14 23:00 UTC
|
||||
// So taskDate > rangeStart, and the task is included
|
||||
expect(buggyFilteredTasks.length).toBe(3);
|
||||
expect(buggyFilteredTasks.some((t) => t.dateStr === '2024-01-15')).toBe(
|
||||
true,
|
||||
'All tasks should be included at/east of UTC',
|
||||
'All tasks should be included in Europe/Berlin due to positive UTC offset',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2113,68 +2113,7 @@ describe('dataRepair()', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('should clear out-of-union monthly anchors', () => {
|
||||
it('clears invalid monthlyWeekOfMonth / monthlyWeekday but keeps valid ones', () => {
|
||||
const taskRepeatCfgState = {
|
||||
...mock.taskRepeatCfg,
|
||||
...fakeEntityStateFromArray<TaskRepeatCfg>([
|
||||
{
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
id: 'BAD',
|
||||
title: 'BAD',
|
||||
// e.g. a BYDAY=5MO ordinal an older build converted unguarded
|
||||
monthlyWeekOfMonth: 5 as any,
|
||||
monthlyWeekday: 9 as any,
|
||||
startDate: '2024-06-01',
|
||||
},
|
||||
{
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
id: 'GOOD',
|
||||
title: 'GOOD',
|
||||
monthlyWeekOfMonth: -1,
|
||||
monthlyWeekday: 0,
|
||||
startDate: '2024-06-01',
|
||||
},
|
||||
]),
|
||||
} as any;
|
||||
|
||||
const result = dataRepair({
|
||||
...mock,
|
||||
taskRepeatCfg: taskRepeatCfgState,
|
||||
} as any);
|
||||
|
||||
expect(
|
||||
result.data.taskRepeatCfg.entities['BAD']?.monthlyWeekOfMonth,
|
||||
).toBeUndefined();
|
||||
expect(result.data.taskRepeatCfg.entities['BAD']?.monthlyWeekday).toBeUndefined();
|
||||
expect(result.data.taskRepeatCfg.entities['GOOD']?.monthlyWeekOfMonth).toBe(-1);
|
||||
expect(result.data.taskRepeatCfg.entities['GOOD']?.monthlyWeekday).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should fix repeat configs with invalid quickSetting (issue #5802)', () => {
|
||||
it('downgrades an out-of-released-union quickSetting to CUSTOM (forward-compat)', () => {
|
||||
const taskRepeatCfgState = {
|
||||
...mock.taskRepeatCfg,
|
||||
...fakeEntityStateFromArray<TaskRepeatCfg>([
|
||||
{
|
||||
...DEFAULT_TASK_REPEAT_CFG,
|
||||
id: 'TEST',
|
||||
title: 'TEST',
|
||||
quickSetting: 'WEEKENDS' as any, // a value an old client can't validate
|
||||
startDate: '2024-06-01',
|
||||
},
|
||||
]),
|
||||
} as any;
|
||||
|
||||
const result = dataRepair({
|
||||
...mock,
|
||||
taskRepeatCfg: taskRepeatCfgState,
|
||||
} as any);
|
||||
|
||||
expect(result.data.taskRepeatCfg.entities['TEST']?.quickSetting).toEqual('CUSTOM');
|
||||
});
|
||||
|
||||
it('should change quickSetting to CUSTOM when WEEKLY_CURRENT_WEEKDAY has no startDate', () => {
|
||||
const taskRepeatCfgState = {
|
||||
...mock.taskRepeatCfg,
|
||||
|
|
|
|||
|
|
@ -9,10 +9,7 @@ import { Task, TaskArchive, TaskCopy, TaskState } from '../../features/tasks/tas
|
|||
import { unique } from '../../util/unique';
|
||||
import { isDBDateStr } from '../../util/get-db-date-str';
|
||||
import { TODAY_TAG } from '../../features/tag/tag.const';
|
||||
import {
|
||||
MASTER_SAFE_QUICK_SETTINGS,
|
||||
TaskRepeatCfgCopy,
|
||||
} from '../../features/task-repeat-cfg/task-repeat-cfg.model';
|
||||
import { TaskRepeatCfgCopy } from '../../features/task-repeat-cfg/task-repeat-cfg.model';
|
||||
import { IssueProvider } from '../../features/issue/issue.model';
|
||||
import { AppDataComplete } from '../model/model-config';
|
||||
import { INBOX_PROJECT } from '../../features/project/project.const';
|
||||
|
|
@ -129,7 +126,6 @@ export const dataRepair = (
|
|||
dataOut = _fixInvalidDueDateStrings(dataOut, summary);
|
||||
dataOut = _fixTaskRepeatMissingWeekday(dataOut, summary);
|
||||
dataOut = _fixTaskRepeatCfgInvalidQuickSetting(dataOut, summary);
|
||||
dataOut = _fixTaskRepeatInvalidMonthlyAnchor(dataOut, summary);
|
||||
dataOut = _stripLegacyMonthlyMode(dataOut, summary);
|
||||
dataOut = _createInboxProjectIfNecessary(dataOut, summary);
|
||||
dataOut = _fixOrphanedNotes(dataOut, summary);
|
||||
|
|
@ -270,17 +266,6 @@ const _fixTaskRepeatCfgInvalidQuickSetting = (
|
|||
];
|
||||
Object.keys(data.taskRepeatCfg.entities).forEach((key) => {
|
||||
const cfg = data.taskRepeatCfg.entities[key] as TaskRepeatCfgCopy;
|
||||
// Downgrade any quickSetting outside the released (sync-safe) union to
|
||||
// 'CUSTOM', so a value an older/mobile client can't typia-validate never
|
||||
// survives in stored data.
|
||||
if (cfg.quickSetting && !MASTER_SAFE_QUICK_SETTINGS.has(cfg.quickSetting)) {
|
||||
OpLog.log(
|
||||
`Fixing repeat config ${cfg.id}: unsupported quickSetting ${cfg.quickSetting} -> CUSTOM`,
|
||||
);
|
||||
cfg.quickSetting = 'CUSTOM';
|
||||
summary.entityStateFixed++;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
cfg.quickSetting &&
|
||||
quickSettingsRequiringStartDate.includes(cfg.quickSetting) &&
|
||||
|
|
@ -297,40 +282,6 @@ const _fixTaskRepeatCfgInvalidQuickSetting = (
|
|||
return data;
|
||||
};
|
||||
|
||||
// Mirror of the action-creator anchor sanitizer (task-repeat-cfg.actions.ts):
|
||||
// the monthly anchors are typia-validated against 1|2|3|4|-1 / 0..6 on every
|
||||
// client, so a `null` or out-of-union number (e.g. from an out-of-range BYDAY
|
||||
// ordinal an older build converted, or a foreign import) must be cleared here
|
||||
// too — repair is the last gate before re-validation.
|
||||
const _fixTaskRepeatInvalidMonthlyAnchor = (
|
||||
data: AppDataComplete,
|
||||
summary: RepairSummary,
|
||||
): AppDataComplete => {
|
||||
if (!data.taskRepeatCfg?.entities) {
|
||||
return data;
|
||||
}
|
||||
const isValidWeekOfMonth = (v: unknown): boolean =>
|
||||
v === -1 || (Number.isInteger(v) && (v as number) >= 1 && (v as number) <= 4);
|
||||
const isValidWeekday = (v: unknown): boolean =>
|
||||
Number.isInteger(v) && (v as number) >= 0 && (v as number) <= 6;
|
||||
Object.keys(data.taskRepeatCfg.entities).forEach((key) => {
|
||||
const cfg = data.taskRepeatCfg.entities[key] as TaskRepeatCfgCopy;
|
||||
const w = cfg.monthlyWeekOfMonth as unknown;
|
||||
const d = cfg.monthlyWeekday as unknown;
|
||||
if (w !== undefined && !isValidWeekOfMonth(w)) {
|
||||
OpLog.log(`Fixing repeat config ${cfg.id}: invalid monthlyWeekOfMonth cleared`);
|
||||
cfg.monthlyWeekOfMonth = undefined;
|
||||
summary.entityStateFixed++;
|
||||
}
|
||||
if (d !== undefined && !isValidWeekday(d)) {
|
||||
OpLog.log(`Fixing repeat config ${cfg.id}: invalid monthlyWeekday cleared`);
|
||||
cfg.monthlyWeekday = undefined;
|
||||
summary.entityStateFixed++;
|
||||
}
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
// Issue #6040 follow-up: an earlier development build of the Nth-weekday
|
||||
// feature persisted a `monthlyMode` discriminator that has since been
|
||||
// dropped. Anchor presence is now the sole source of truth, but a cfg
|
||||
|
|
|
|||
|
|
@ -1995,8 +1995,6 @@ const T = {
|
|||
MONDAY: 'F.TASK_REPEAT.F.MONDAY',
|
||||
MONTHLY_MODE_DAY_OF_MONTH: 'F.TASK_REPEAT.F.MONTHLY_MODE_DAY_OF_MONTH',
|
||||
NOTES: 'F.TASK_REPEAT.F.NOTES',
|
||||
ORD_CUSTOM: 'F.TASK_REPEAT.F.ORD_CUSTOM',
|
||||
ORD_CUSTOM_DESCRIPTION: 'F.TASK_REPEAT.F.ORD_CUSTOM_DESCRIPTION',
|
||||
ORD_FIRST: 'F.TASK_REPEAT.F.ORD_FIRST',
|
||||
ORD_FIRST_NTH: 'F.TASK_REPEAT.F.ORD_FIRST_NTH',
|
||||
ORD_FOURTH: 'F.TASK_REPEAT.F.ORD_FOURTH',
|
||||
|
|
@ -2007,22 +2005,13 @@ const T = {
|
|||
ORD_SECOND_NTH: 'F.TASK_REPEAT.F.ORD_SECOND_NTH',
|
||||
ORD_THIRD: 'F.TASK_REPEAT.F.ORD_THIRD',
|
||||
ORD_THIRD_NTH: 'F.TASK_REPEAT.F.ORD_THIRD_NTH',
|
||||
Q_BIWEEKLY_CURRENT_WEEKDAY: 'F.TASK_REPEAT.F.Q_BIWEEKLY_CURRENT_WEEKDAY',
|
||||
Q_CUSTOM: 'F.TASK_REPEAT.F.Q_CUSTOM',
|
||||
Q_DAILY: 'F.TASK_REPEAT.F.Q_DAILY',
|
||||
Q_EVERY_OTHER_DAY: 'F.TASK_REPEAT.F.Q_EVERY_OTHER_DAY',
|
||||
Q_EVERY_OTHER_YEAR_CURRENT_DATE:
|
||||
'F.TASK_REPEAT.F.Q_EVERY_OTHER_YEAR_CURRENT_DATE',
|
||||
Q_MONDAY_TO_FRIDAY: 'F.TASK_REPEAT.F.Q_MONDAY_TO_FRIDAY',
|
||||
Q_MONTHLY_CURRENT_DATE: 'F.TASK_REPEAT.F.Q_MONTHLY_CURRENT_DATE',
|
||||
Q_MONTHLY_FIRST_DAY: 'F.TASK_REPEAT.F.Q_MONTHLY_FIRST_DAY',
|
||||
Q_MONTHLY_LAST_DAY: 'F.TASK_REPEAT.F.Q_MONTHLY_LAST_DAY',
|
||||
Q_MONTHLY_LAST_WEEKDAY: 'F.TASK_REPEAT.F.Q_MONTHLY_LAST_WEEKDAY',
|
||||
Q_MONTHLY_NTH_WEEKDAY: 'F.TASK_REPEAT.F.Q_MONTHLY_NTH_WEEKDAY',
|
||||
Q_QUARTERLY_CURRENT_DATE: 'F.TASK_REPEAT.F.Q_QUARTERLY_CURRENT_DATE',
|
||||
Q_RRULE: 'F.TASK_REPEAT.F.Q_RRULE',
|
||||
Q_SEMIANNUALLY_CURRENT_DATE: 'F.TASK_REPEAT.F.Q_SEMIANNUALLY_CURRENT_DATE',
|
||||
Q_WEEKENDS: 'F.TASK_REPEAT.F.Q_WEEKENDS',
|
||||
Q_WEEKLY_CURRENT_WEEKDAY: 'F.TASK_REPEAT.F.Q_WEEKLY_CURRENT_WEEKDAY',
|
||||
Q_YEARLY_CURRENT_DATE: 'F.TASK_REPEAT.F.Q_YEARLY_CURRENT_DATE',
|
||||
QUICK_SETTING: 'F.TASK_REPEAT.F.QUICK_SETTING',
|
||||
|
|
@ -2032,115 +2021,6 @@ const T = {
|
|||
SKIP_INSTANCE: 'F.TASK_REPEAT.F.SKIP_INSTANCE',
|
||||
REPEAT_CYCLE: 'F.TASK_REPEAT.F.REPEAT_CYCLE',
|
||||
REPEAT_EVERY: 'F.TASK_REPEAT.F.REPEAT_EVERY',
|
||||
RRULE_BYDAY: 'F.TASK_REPEAT.F.RRULE_BYDAY',
|
||||
RRULE_BYDAY_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_BYDAY_DESCRIPTION',
|
||||
RRULE_BYMONTH: 'F.TASK_REPEAT.F.RRULE_BYMONTH',
|
||||
RRULE_BYMONTH_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_BYMONTH_DESCRIPTION',
|
||||
RRULE_BYMONTHDAY: 'F.TASK_REPEAT.F.RRULE_BYMONTHDAY',
|
||||
RRULE_BYMONTHDAY_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_BYMONTHDAY_DESCRIPTION',
|
||||
RRULE_BYMONTHDAY_LIST: 'F.TASK_REPEAT.F.RRULE_BYMONTHDAY_LIST',
|
||||
RRULE_BYMONTHDAY_LIST_DESCRIPTION:
|
||||
'F.TASK_REPEAT.F.RRULE_BYMONTHDAY_LIST_DESCRIPTION',
|
||||
RRULE_BYSETPOS: 'F.TASK_REPEAT.F.RRULE_BYSETPOS',
|
||||
RRULE_BYSETPOS_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_BYSETPOS_DESCRIPTION',
|
||||
RRULE_BYWEEKNO: 'F.TASK_REPEAT.F.RRULE_BYWEEKNO',
|
||||
RRULE_BYWEEKNO_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_BYWEEKNO_DESCRIPTION',
|
||||
RRULE_BYYEARDAY: 'F.TASK_REPEAT.F.RRULE_BYYEARDAY',
|
||||
RRULE_BYYEARDAY_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_BYYEARDAY_DESCRIPTION',
|
||||
RRULE_COUNT: 'F.TASK_REPEAT.F.RRULE_COUNT',
|
||||
RRULE_COUNT_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_COUNT_DESCRIPTION',
|
||||
RRULE_END: 'F.TASK_REPEAT.F.RRULE_END',
|
||||
RRULE_END_COUNT: 'F.TASK_REPEAT.F.RRULE_END_COUNT',
|
||||
RRULE_END_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_END_DESCRIPTION',
|
||||
RRULE_END_NEVER: 'F.TASK_REPEAT.F.RRULE_END_NEVER',
|
||||
RRULE_END_UNTIL: 'F.TASK_REPEAT.F.RRULE_END_UNTIL',
|
||||
RRULE_FREQ: 'F.TASK_REPEAT.F.RRULE_FREQ',
|
||||
RRULE_FREQ_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_FREQ_DESCRIPTION',
|
||||
RRULE_INTERVAL: 'F.TASK_REPEAT.F.RRULE_INTERVAL',
|
||||
RRULE_INTERVAL_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_INTERVAL_DESCRIPTION',
|
||||
RRULE_ADVANCED: 'F.TASK_REPEAT.F.RRULE_ADVANCED',
|
||||
RRULE_ADVANCED_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_ADVANCED_DESCRIPTION',
|
||||
RRULE_SCHEDULE_TYPE: 'F.TASK_REPEAT.F.RRULE_SCHEDULE_TYPE',
|
||||
RRULE_SCHEDULE_FROM_START: 'F.TASK_REPEAT.F.RRULE_SCHEDULE_FROM_START',
|
||||
RRULE_SCHEDULE_FROM_COMPLETION: 'F.TASK_REPEAT.F.RRULE_SCHEDULE_FROM_COMPLETION',
|
||||
RRULE_SCHEDULE_TYPE_DESCRIPTION:
|
||||
'F.TASK_REPEAT.F.RRULE_SCHEDULE_TYPE_DESCRIPTION',
|
||||
RRULE_NEXT: 'F.TASK_REPEAT.F.RRULE_NEXT',
|
||||
RRULE_NEXT_AFTER_COMPLETION: 'F.TASK_REPEAT.F.RRULE_NEXT_AFTER_COMPLETION',
|
||||
RRULE_NLP_EVERY: 'F.TASK_REPEAT.F.RRULE_NLP_EVERY',
|
||||
RRULE_NLP_DAY: 'F.TASK_REPEAT.F.RRULE_NLP_DAY',
|
||||
RRULE_NLP_DAYS: 'F.TASK_REPEAT.F.RRULE_NLP_DAYS',
|
||||
RRULE_NLP_WEEK: 'F.TASK_REPEAT.F.RRULE_NLP_WEEK',
|
||||
RRULE_NLP_WEEKS: 'F.TASK_REPEAT.F.RRULE_NLP_WEEKS',
|
||||
RRULE_NLP_MONTH: 'F.TASK_REPEAT.F.RRULE_NLP_MONTH',
|
||||
RRULE_NLP_MONTHS: 'F.TASK_REPEAT.F.RRULE_NLP_MONTHS',
|
||||
RRULE_NLP_YEAR: 'F.TASK_REPEAT.F.RRULE_NLP_YEAR',
|
||||
RRULE_NLP_YEARS: 'F.TASK_REPEAT.F.RRULE_NLP_YEARS',
|
||||
RRULE_NLP_ON: 'F.TASK_REPEAT.F.RRULE_NLP_ON',
|
||||
RRULE_NLP_ON_THE: 'F.TASK_REPEAT.F.RRULE_NLP_ON_THE',
|
||||
RRULE_NLP_THE: 'F.TASK_REPEAT.F.RRULE_NLP_THE',
|
||||
RRULE_NLP_AND: 'F.TASK_REPEAT.F.RRULE_NLP_AND',
|
||||
RRULE_NLP_OR: 'F.TASK_REPEAT.F.RRULE_NLP_OR',
|
||||
RRULE_NLP_FOR: 'F.TASK_REPEAT.F.RRULE_NLP_FOR',
|
||||
RRULE_NLP_TIME: 'F.TASK_REPEAT.F.RRULE_NLP_TIME',
|
||||
RRULE_NLP_TIMES: 'F.TASK_REPEAT.F.RRULE_NLP_TIMES',
|
||||
RRULE_NLP_UNTIL: 'F.TASK_REPEAT.F.RRULE_NLP_UNTIL',
|
||||
RRULE_NLP_WEEKDAY: 'F.TASK_REPEAT.F.RRULE_NLP_WEEKDAY',
|
||||
RRULE_NLP_WEEKDAYS: 'F.TASK_REPEAT.F.RRULE_NLP_WEEKDAYS',
|
||||
RRULE_NLP_IN: 'F.TASK_REPEAT.F.RRULE_NLP_IN',
|
||||
RRULE_NLP_LAST: 'F.TASK_REPEAT.F.RRULE_NLP_LAST',
|
||||
RRULE_NLP_ST: 'F.TASK_REPEAT.F.RRULE_NLP_ST',
|
||||
RRULE_NLP_ND: 'F.TASK_REPEAT.F.RRULE_NLP_ND',
|
||||
RRULE_NLP_RD: 'F.TASK_REPEAT.F.RRULE_NLP_RD',
|
||||
RRULE_NLP_TH: 'F.TASK_REPEAT.F.RRULE_NLP_TH',
|
||||
RRULE_NLP_TO: 'F.TASK_REPEAT.F.RRULE_NLP_TO',
|
||||
RRULE_BYMONTHDAY_PLACEHOLDER: 'F.TASK_REPEAT.F.RRULE_BYMONTHDAY_PLACEHOLDER',
|
||||
RRULE_BYSETPOS_PLACEHOLDER: 'F.TASK_REPEAT.F.RRULE_BYSETPOS_PLACEHOLDER',
|
||||
RRULE_BYWEEKNO_PLACEHOLDER: 'F.TASK_REPEAT.F.RRULE_BYWEEKNO_PLACEHOLDER',
|
||||
RRULE_BYYEARDAY_PLACEHOLDER: 'F.TASK_REPEAT.F.RRULE_BYYEARDAY_PLACEHOLDER',
|
||||
RRULE_RAW_PLACEHOLDER: 'F.TASK_REPEAT.F.RRULE_RAW_PLACEHOLDER',
|
||||
RRULE_FREQ_UNSUPPORTED: 'F.TASK_REPEAT.F.RRULE_FREQ_UNSUPPORTED',
|
||||
RRULE_INVALID: 'F.TASK_REPEAT.F.RRULE_INVALID',
|
||||
RRULE_NO_OCCURRENCE: 'F.TASK_REPEAT.F.RRULE_NO_OCCURRENCE',
|
||||
RRULE_COUNT_WITH_COMPLETION: 'F.TASK_REPEAT.F.RRULE_COUNT_WITH_COMPLETION',
|
||||
RRULE_RAW: 'F.TASK_REPEAT.F.RRULE_RAW',
|
||||
RRULE_RAW_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_RAW_DESCRIPTION',
|
||||
RRULE_WKST: 'F.TASK_REPEAT.F.RRULE_WKST',
|
||||
RRULE_WKST_DEFAULT: 'F.TASK_REPEAT.F.RRULE_WKST_DEFAULT',
|
||||
RRULE_WKST_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_WKST_DESCRIPTION',
|
||||
RRULE_MODE_DAY_OF_MONTH: 'F.TASK_REPEAT.F.RRULE_MODE_DAY_OF_MONTH',
|
||||
RRULE_MODE_LAST_DAY: 'F.TASK_REPEAT.F.RRULE_MODE_LAST_DAY',
|
||||
RRULE_MODE_NTH_WEEKDAY: 'F.TASK_REPEAT.F.RRULE_MODE_NTH_WEEKDAY',
|
||||
RRULE_MODE_WEEKDAYS: 'F.TASK_REPEAT.F.RRULE_MODE_WEEKDAYS',
|
||||
RRULE_MODE_ON_DATE: 'F.TASK_REPEAT.F.RRULE_MODE_ON_DATE',
|
||||
RRULE_MODE_ON_WEEKDAYS: 'F.TASK_REPEAT.F.RRULE_MODE_ON_WEEKDAYS',
|
||||
RRULE_NTH_WEEKDAY: 'F.TASK_REPEAT.F.RRULE_NTH_WEEKDAY',
|
||||
RRULE_ADD_NTH: 'F.TASK_REPEAT.F.RRULE_ADD_NTH',
|
||||
RRULE_NTH_WEEKDAY_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_NTH_WEEKDAY_DESCRIPTION',
|
||||
RRULE_YEARLY_MODE: 'F.TASK_REPEAT.F.RRULE_YEARLY_MODE',
|
||||
RRULE_YEARLY_MODE_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_YEARLY_MODE_DESCRIPTION',
|
||||
RRULE_MONTH_1: 'F.TASK_REPEAT.F.RRULE_MONTH_1',
|
||||
RRULE_MONTH_2: 'F.TASK_REPEAT.F.RRULE_MONTH_2',
|
||||
RRULE_MONTH_3: 'F.TASK_REPEAT.F.RRULE_MONTH_3',
|
||||
RRULE_MONTH_4: 'F.TASK_REPEAT.F.RRULE_MONTH_4',
|
||||
RRULE_MONTH_5: 'F.TASK_REPEAT.F.RRULE_MONTH_5',
|
||||
RRULE_MONTH_6: 'F.TASK_REPEAT.F.RRULE_MONTH_6',
|
||||
RRULE_MONTH_7: 'F.TASK_REPEAT.F.RRULE_MONTH_7',
|
||||
RRULE_MONTH_8: 'F.TASK_REPEAT.F.RRULE_MONTH_8',
|
||||
RRULE_MONTH_9: 'F.TASK_REPEAT.F.RRULE_MONTH_9',
|
||||
RRULE_MONTH_10: 'F.TASK_REPEAT.F.RRULE_MONTH_10',
|
||||
RRULE_MONTH_11: 'F.TASK_REPEAT.F.RRULE_MONTH_11',
|
||||
RRULE_MONTH_12: 'F.TASK_REPEAT.F.RRULE_MONTH_12',
|
||||
RRULE_MONTHLY_MODE: 'F.TASK_REPEAT.F.RRULE_MONTHLY_MODE',
|
||||
RRULE_MONTHLY_MODE_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_MONTHLY_MODE_DESCRIPTION',
|
||||
RRULE_DAY_2ND_LAST: 'F.TASK_REPEAT.F.RRULE_DAY_2ND_LAST',
|
||||
RRULE_DAY_3RD_LAST: 'F.TASK_REPEAT.F.RRULE_DAY_3RD_LAST',
|
||||
RRULE_DAY_LAST: 'F.TASK_REPEAT.F.RRULE_DAY_LAST',
|
||||
RRULE_SETPOS: 'F.TASK_REPEAT.F.RRULE_SETPOS',
|
||||
RRULE_SETPOS_EVERY: 'F.TASK_REPEAT.F.RRULE_SETPOS_EVERY',
|
||||
RRULE_SETPOS_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_SETPOS_DESCRIPTION',
|
||||
RRULE_UNTIL: 'F.TASK_REPEAT.F.RRULE_UNTIL',
|
||||
RRULE_UNTIL_DESCRIPTION: 'F.TASK_REPEAT.F.RRULE_UNTIL_DESCRIPTION',
|
||||
SATURDAY: 'F.TASK_REPEAT.F.SATURDAY',
|
||||
SCHEDULE_TYPE_LABEL: 'F.TASK_REPEAT.F.SCHEDULE_TYPE_LABEL',
|
||||
SKIP_OVERDUE: 'F.TASK_REPEAT.F.SKIP_OVERDUE',
|
||||
|
|
|
|||
|
|
@ -1941,8 +1941,6 @@
|
|||
"MONDAY": "Monday",
|
||||
"MONTHLY_MODE_DAY_OF_MONTH": "Day of month",
|
||||
"NOTES": "Default notes",
|
||||
"ORD_CUSTOM": "custom…",
|
||||
"ORD_CUSTOM_DESCRIPTION": "Any occurrence number (1–53); negatives count from the end, e.g. -2 = 2nd-to-last",
|
||||
"ORD_FIRST": "first",
|
||||
"ORD_FIRST_NTH": "first",
|
||||
"ORD_FOURTH": "fourth",
|
||||
|
|
@ -1953,21 +1951,13 @@
|
|||
"ORD_SECOND_NTH": "second",
|
||||
"ORD_THIRD": "third",
|
||||
"ORD_THIRD_NTH": "third",
|
||||
"Q_BIWEEKLY_CURRENT_WEEKDAY": "Every 2 weeks on {{weekdayStr}}",
|
||||
"Q_CUSTOM": "Custom recurring config",
|
||||
"Q_DAILY": "Every day",
|
||||
"Q_EVERY_OTHER_DAY": "Every other day",
|
||||
"Q_EVERY_OTHER_YEAR_CURRENT_DATE": "Every 2 years on the {{dayAndMonthStr}}",
|
||||
"Q_MONDAY_TO_FRIDAY": "Every Monday through Friday",
|
||||
"Q_MONTHLY_CURRENT_DATE": "Every month on the day {{dateDayStr}}",
|
||||
"Q_MONTHLY_FIRST_DAY": "Every month on the first day",
|
||||
"Q_MONTHLY_LAST_DAY": "Every month on the last day",
|
||||
"Q_MONTHLY_LAST_WEEKDAY": "Every month on the last {{weekdayStr}}",
|
||||
"Q_MONTHLY_NTH_WEEKDAY": "Every month on the {{ordinalStr}} {{weekdayStr}}",
|
||||
"Q_QUARTERLY_CURRENT_DATE": "Every 3 months on the day {{dateDayStr}}",
|
||||
"Q_RRULE": "Custom recurring config",
|
||||
"Q_SEMIANNUALLY_CURRENT_DATE": "Every 6 months on the day {{dateDayStr}}",
|
||||
"Q_WEEKENDS": "Every weekend (Sat & Sun)",
|
||||
"Q_WEEKLY_CURRENT_WEEKDAY": "Every week on {{weekdayStr}}",
|
||||
"Q_YEARLY_CURRENT_DATE": "Every year on the {{dayAndMonthStr}}",
|
||||
"QUICK_SETTING": "Recurring Config",
|
||||
|
|
@ -1977,113 +1967,6 @@
|
|||
"SKIP_INSTANCE": "Skip for today",
|
||||
"REPEAT_CYCLE": "Recur cycle",
|
||||
"REPEAT_EVERY": "Recur every",
|
||||
"RRULE_BYDAY": "On weekdays",
|
||||
"RRULE_BYDAY_DESCRIPTION": "Pick one or more weekdays. Used by Weekly, by Monthly's 'On selected weekdays', and by Yearly's 'On weekdays within months'. E.g. Mon + Wed = every Monday and Wednesday.",
|
||||
"RRULE_BYMONTH": "In months",
|
||||
"RRULE_BYMONTH_DESCRIPTION": "Limit to these months — works with any frequency; leave empty for every month. E.g. daily in Jan–Apr, Mondays only in June, or yearly 'every Saturday, March–November'. For Yearly this also sets which month(s) the date/weekday falls in.",
|
||||
"RRULE_BYMONTHDAY": "Day of month",
|
||||
"RRULE_BYMONTHDAY_DESCRIPTION": "Tap the day(s) of the month (1–31, e.g. 15), or Last / 2nd-last / 3rd-last to count from the end. Need others? Type a custom list like 1,15,-5. Note: months without a chosen day are skipped (e.g. 31 never fires in February) — also select 'Last day' to cover shorter months.",
|
||||
"RRULE_BYMONTHDAY_LIST": "Days of month (list)",
|
||||
"RRULE_BYMONTHDAY_LIST_DESCRIPTION": "Repeat on several days of the month, or count from the end. Comma-separated; negatives count back from month-end. E.g. 1,15,-1 = the 1st, 15th and last day. Overrides the single 'Day of month' above.",
|
||||
"RRULE_BYSETPOS": "Set position (BYSETPOS)",
|
||||
"RRULE_BYSETPOS_DESCRIPTION": "From all the matches in each period, keep only the selected occurrences. E.g. choose Mon–Fri + 'last' = the last weekday of the month; add 'first' to get both. Custom accepts comma-separated numbers; negatives count from the end (-2 = 2nd-to-last).",
|
||||
"RRULE_BYWEEKNO": "Week numbers (BYWEEKNO)",
|
||||
"RRULE_BYWEEKNO_DESCRIPTION": "For Yearly only: limit to these ISO week numbers, 1–53. Comma-separated; negatives count from year-end. E.g. 20,21 = weeks 20 and 21 each year.",
|
||||
"RRULE_BYYEARDAY": "Days of year (BYYEARDAY)",
|
||||
"RRULE_BYYEARDAY_DESCRIPTION": "For Yearly only: limit to these days of the year, 1–366. Comma-separated; negatives count from year-end. E.g. 1,100,-1 = the 1st, 100th and last day of the year.",
|
||||
"RRULE_COUNT": "Number of occurrences",
|
||||
"RRULE_COUNT_DESCRIPTION": "How many times the task is created in total, then it stops. E.g. 10 = ten occurrences.",
|
||||
"RRULE_END": "Ends",
|
||||
"RRULE_END_COUNT": "After a number of times",
|
||||
"RRULE_END_DESCRIPTION": "When the series stops. Never = open-ended. After a number of times = a fixed count (e.g. 10 occurrences). On a date = nothing is created after it.",
|
||||
"RRULE_END_NEVER": "Never",
|
||||
"RRULE_END_UNTIL": "On a date",
|
||||
"RRULE_FREQ": "Frequency",
|
||||
"RRULE_FREQ_DESCRIPTION": "How often the task repeats: Daily, Weekly, Monthly or Yearly. All the options below adapt to this choice. E.g. Weekly = once a week, Monthly = once a month.",
|
||||
"RRULE_INTERVAL": "Repeat every",
|
||||
"RRULE_INTERVAL_DESCRIPTION": "How many of the chosen units between repeats. 1 = every time, 2 = every other, 3 = every third. E.g. Weekly + 2 = every other week; Monthly + 3 = once a quarter.",
|
||||
"RRULE_ADVANCED": "Advanced options",
|
||||
"RRULE_ADVANCED_DESCRIPTION": "Optional power-user rule parts: week start, set position, multiple/negative days of the month, week-of-year, day-of-year, and a raw rule override. Leave them blank if you don't need them.",
|
||||
"RRULE_SCHEDULE_TYPE": "Schedule type",
|
||||
"RRULE_SCHEDULE_FROM_START": "Fixed dates (from start)",
|
||||
"RRULE_SCHEDULE_FROM_COMPLETION": "After I complete it",
|
||||
"RRULE_SCHEDULE_TYPE_DESCRIPTION": "Fixed dates repeats on a set calendar rhythm from the start date; After completion restarts the count when you finish (so 'every 3 days' = 3 days after each completion). It changes only which date the next task lands on — whether the next task waits for you to finish is the separate 'Only create next task after completion' option (Advanced).",
|
||||
"RRULE_NEXT": "Upcoming",
|
||||
"RRULE_NEXT_AFTER_COMPLETION": "After completion, e.g.",
|
||||
"RRULE_NLP_EVERY": "every",
|
||||
"RRULE_NLP_DAY": "day",
|
||||
"RRULE_NLP_DAYS": "days",
|
||||
"RRULE_NLP_WEEK": "week",
|
||||
"RRULE_NLP_WEEKS": "weeks",
|
||||
"RRULE_NLP_MONTH": "month",
|
||||
"RRULE_NLP_MONTHS": "months",
|
||||
"RRULE_NLP_YEAR": "year",
|
||||
"RRULE_NLP_YEARS": "years",
|
||||
"RRULE_NLP_ON": "on",
|
||||
"RRULE_NLP_ON_THE": "on the",
|
||||
"RRULE_NLP_THE": "the",
|
||||
"RRULE_NLP_AND": "and",
|
||||
"RRULE_NLP_OR": "or",
|
||||
"RRULE_NLP_FOR": "for",
|
||||
"RRULE_NLP_TIME": "time",
|
||||
"RRULE_NLP_TIMES": "times",
|
||||
"RRULE_NLP_UNTIL": "until",
|
||||
"RRULE_NLP_WEEKDAY": "weekday",
|
||||
"RRULE_NLP_WEEKDAYS": "weekdays",
|
||||
"RRULE_NLP_IN": "in",
|
||||
"RRULE_NLP_LAST": "last",
|
||||
"RRULE_NLP_ST": "st",
|
||||
"RRULE_NLP_ND": "nd",
|
||||
"RRULE_NLP_RD": "rd",
|
||||
"RRULE_NLP_TH": "th",
|
||||
"RRULE_NLP_TO": "to",
|
||||
"RRULE_BYMONTHDAY_PLACEHOLDER": "e.g. 1,15,-5",
|
||||
"RRULE_BYSETPOS_PLACEHOLDER": "1,-1",
|
||||
"RRULE_BYWEEKNO_PLACEHOLDER": "20,21",
|
||||
"RRULE_BYYEARDAY_PLACEHOLDER": "1,100,-1",
|
||||
"RRULE_RAW_PLACEHOLDER": "FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1",
|
||||
"RRULE_FREQ_UNSUPPORTED": "Only daily, weekly, monthly and yearly frequencies are supported — sub-daily rules (hourly, minutely) are not yet.",
|
||||
"RRULE_INVALID": "This recurrence rule is invalid. Please adjust the options.",
|
||||
"RRULE_NO_OCCURRENCE": "This recurrence never produces an occurrence. Please check the rule and start date.",
|
||||
"RRULE_COUNT_WITH_COMPLETION": "\"After N times\" cannot be combined with \"from completion\" — completing re-anchors the schedule, so the count would never finish. Use an until date instead.",
|
||||
"RRULE_RAW": "Raw RRULE override",
|
||||
"RRULE_RAW_DESCRIPTION": "Type a complete RFC 5545 RRULE to express anything the options above can't. When filled it replaces all of them. E.g. FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1 = the last weekday of every month. Leave empty to use the options above.",
|
||||
"RRULE_WKST": "Week starts on",
|
||||
"RRULE_WKST_DEFAULT": "Default (Monday)",
|
||||
"RRULE_WKST_DESCRIPTION": "Which day the week starts on. Only matters for Weekly with an interval above 1, where it decides which days count as the same week. Default is Monday (ISO).",
|
||||
"RRULE_MODE_DAY_OF_MONTH": "On a day of the month",
|
||||
"RRULE_MODE_LAST_DAY": "On the last day of the month",
|
||||
"RRULE_MODE_NTH_WEEKDAY": "On an nth weekday",
|
||||
"RRULE_MODE_WEEKDAYS": "On selected weekdays",
|
||||
"RRULE_MODE_ON_DATE": "On a specific date",
|
||||
"RRULE_MODE_ON_WEEKDAYS": "On weekdays within the months",
|
||||
"RRULE_NTH_WEEKDAY": "Which weekdays",
|
||||
"RRULE_ADD_NTH": "Add line",
|
||||
"RRULE_NTH_WEEKDAY_DESCRIPTION": "Each line is an occurrence + one or more weekdays, e.g. 3rd + Monday = the 3rd Monday, or 1st + Mon,Tue = the 1st Monday and the 1st Tuesday. Add lines (+) to combine occurrences — a '3rd Monday' line and a '4th Sunday' line = BYDAY=3MO,4SU. Each line uses a different occurrence and is independent (not 'the 3rd of all of them'; that's the 'On selected weekdays' set instead).",
|
||||
"RRULE_YEARLY_MODE": "Yearly pattern",
|
||||
"RRULE_YEARLY_MODE_DESCRIPTION": "How the yearly repeat is anchored. 'On a specific date' = a month and day (e.g. March 15). 'On weekdays within the months' = e.g. every Saturday in March–November (choose the months and the weekdays).",
|
||||
"RRULE_MONTH_1": "January",
|
||||
"RRULE_MONTH_2": "February",
|
||||
"RRULE_MONTH_3": "March",
|
||||
"RRULE_MONTH_4": "April",
|
||||
"RRULE_MONTH_5": "May",
|
||||
"RRULE_MONTH_6": "June",
|
||||
"RRULE_MONTH_7": "July",
|
||||
"RRULE_MONTH_8": "August",
|
||||
"RRULE_MONTH_9": "September",
|
||||
"RRULE_MONTH_10": "October",
|
||||
"RRULE_MONTH_11": "November",
|
||||
"RRULE_MONTH_12": "December",
|
||||
"RRULE_MONTHLY_MODE": "Monthly pattern",
|
||||
"RRULE_MONTHLY_MODE_DESCRIPTION": "How the monthly repeat is anchored. 'On a day of the month' = a fixed date (e.g. the 15th). 'On an nth weekday' = e.g. the 2nd Tuesday or last Friday. 'On selected weekdays' = a weekday set such as Mon–Fri (pair it with Set position in Advanced for 'the last weekday of the month'). 'On the last day' = month-end (28–31).",
|
||||
"RRULE_DAY_2ND_LAST": "2nd-last",
|
||||
"RRULE_DAY_3RD_LAST": "3rd-last",
|
||||
"RRULE_DAY_LAST": "Last",
|
||||
"RRULE_SETPOS": "Which one",
|
||||
"RRULE_SETPOS_EVERY": "Every",
|
||||
"RRULE_SETPOS_DESCRIPTION": "Which occurrence of that weekday within the month: First, Second, Third, Fourth or Last. E.g. Last + Friday = the last Friday of each month.",
|
||||
"RRULE_UNTIL": "End date",
|
||||
"RRULE_UNTIL_DESCRIPTION": "The last date an occurrence may fall on; nothing is created after it. E.g. 2024-12-31.",
|
||||
"SATURDAY": "Saturday",
|
||||
"SCHEDULE_TYPE_LABEL": "Schedule type",
|
||||
"SKIP_OVERDUE": "Don't let overdue instances pile up",
|
||||
|
|
|
|||
|
|
@ -13,14 +13,9 @@ const SQL_JS_DIST = path.join(__dirname, '..', 'node_modules', 'sql.js', 'dist')
|
|||
const SQL_JS_WASM_ABS = '/absolute' + path.join(SQL_JS_DIST, 'sql-wasm.wasm');
|
||||
|
||||
module.exports = function (config) {
|
||||
// Pin the browser timezone so timezone-sensitive specs run deterministically.
|
||||
// Chrome inherits TZ from the environment on Linux/macOS (covers CI and most
|
||||
// dev machines) — but IGNORES it on Windows (verified empirically: headless
|
||||
// Chrome with TZ=Europe/Berlin still reports the host zone there). The
|
||||
// *.tz.spec.ts files therefore branch on the offset at their test instants
|
||||
// instead of assuming a fixed zone, as the Windows backstop.
|
||||
// (Also won't apply under wallaby.)
|
||||
process.env.TZ = process.env.TZ || 'Europe/Berlin';
|
||||
// NOTE: necessary to fix some of the unit tests with a timezone in them
|
||||
// NOTE2: won't work for wallaby, but that's maybe ok for now
|
||||
// process.env.TZ = 'Europe/Berlin';
|
||||
const isCodeCoverage = Boolean(config.buildWebpack?.options?.codeCoverage);
|
||||
const reporters = ['spec', 'running-spec'];
|
||||
|
||||
|
|
|
|||
26
src/main.ts
26
src/main.ts
|
|
@ -315,34 +315,10 @@ bootstrapApplication(AppComponent, {
|
|||
// recurring-task tests. Stripped from production via the env guard.
|
||||
if (!environment.production && !environment.stage) {
|
||||
const storeRef = appRef.injector.get(Store);
|
||||
Promise.all([import('./app/op-log/apply/hydration-state.service')]).then(([m]) => {
|
||||
// Dev-only manual clock fast-forward for testing recurring / day-change
|
||||
// behavior without touching the OS clock. Overrides Date.now() (the basis
|
||||
// for the app's logical "today") by a cumulative day offset, then forces a
|
||||
// day-change re-sample so addAllDueToday() runs. Reload — or resetClock() —
|
||||
// to undo. Call from the console: __e2eTestHelpers.jumpDay() / jumpDay(7).
|
||||
const realNow = Date.now.bind(Date);
|
||||
let clockOffsetMs = 0;
|
||||
const reSampleDay = (): void => {
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
};
|
||||
const jumpDay = (days = 1): string => {
|
||||
clockOffsetMs += days * 24 * 60 * 60 * 1000;
|
||||
Date.now = () => realNow() + clockOffsetMs;
|
||||
reSampleDay();
|
||||
return new Date(realNow() + clockOffsetMs).toDateString();
|
||||
};
|
||||
const resetClock = (): void => {
|
||||
clockOffsetMs = 0;
|
||||
Date.now = realNow;
|
||||
reSampleDay();
|
||||
};
|
||||
|
||||
import('./app/op-log/apply/hydration-state.service').then((m) => {
|
||||
(window as unknown as { __e2eTestHelpers?: unknown }).__e2eTestHelpers = {
|
||||
store: storeRef,
|
||||
hydrationState: appRef.injector.get(m.HydrationStateService),
|
||||
jumpDay,
|
||||
resetClock,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue