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:
Johannes Millan 2026-06-09 13:56:38 +02:00
parent ccbd66d3d9
commit 3d2c811e78
67 changed files with 691 additions and 8511 deletions

4
.gitignore vendored
View file

@ -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

View file

@ -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.

View file

@ -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);
});
});

View file

@ -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 });

View file

@ -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,
});
});
});

View file

@ -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
// MonFri, 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();

View file

@ -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 parsewidget 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();
});
});

View file

@ -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');
});
});

View file

@ -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
View file

@ -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",

View file

@ -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": {

View file

@ -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",

View file

@ -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",

View file

@ -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);

View file

@ -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');
}
});

View file

@ -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');
}
});

View file

@ -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());
});
});

View file

@ -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),
},
];
};

View file

@ -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">&rarr; {{ 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' }} &rarr; {{ 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">&middot;</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

View file

@ -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);

View file

@ -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();
});
});
});

View file

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

View file

@ -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');
}
});

View file

@ -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');
});
});
});

View file

@ -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;

View file

@ -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)"
>
&times;
</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>

View file

@ -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);
}

View file

@ -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);
});
});

View file

@ -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);
}
}

View file

@ -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);
});
});
});
});

View file

@ -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.
];

View file

@ -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);

View file

@ -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}"`,

View file

@ -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);
});
});
});

View file

@ -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}"`,

View file

@ -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');
});
});

View file

@ -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');
});
});

View file

@ -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();
});
});
});

View file

@ -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']);
});
});
});

View file

@ -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 MarNov
'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);
});
});
});
});

View file

@ -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:0003: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 [];
}
};

View file

@ -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();
});
});

View file

@ -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,
});

View file

@ -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');
});
});

View file

@ -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',

View file

@ -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) =>

View file

@ -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');
}
});
});

View file

@ -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, MarchNovember, 10 times" in one config.
rrule?: string;
}
export type TaskRepeatCfg = Readonly<TaskRepeatCfgCopy>;

View file

@ -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();
});
});

View file

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

View file

@ -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));
});
});
});
});

View file

@ -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 MarNov" 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));
});
});
});
});

View file

@ -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 JanApr)
* 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 = 1st4th 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
// JanApr, 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. MonFri), 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;
};

View file

@ -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',
};

View file

@ -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
});
});

View file

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

View file

@ -1,27 +0,0 @@
/**
* Shared normalizers for `rrule` option values, used by both the form builder
* (`rrule-form.util`) and the legacyRRULE 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);
};

View file

@ -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>

View file

@ -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) => {

View file

@ -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();

View file

@ -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',
);
}
});

View file

@ -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,

View file

@ -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

View file

@ -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',

View file

@ -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 (153); 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 JanApr, Mondays only in June, or yearly 'every Saturday, MarchNovember'. 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 (131, 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 MonFri + '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, 153. 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, 1366. 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 MarchNovember (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 MonFri (pair it with Set position in Advanced for 'the last weekday of the month'). 'On the last day' = month-end (2831).",
"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",

View file

@ -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'];

View file

@ -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,
};
});
}