From 3d2c811e784f8f89138eefce6a5fb96db88bdf5d Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Tue, 9 Jun 2026 13:56:38 +0200 Subject: [PATCH] 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. --- .gitignore | 4 - docs/wiki/4.13-Repeating-Tasks.md | 4 - .../add-task-bar-custom-repeat.spec.ts | 81 --- ...ing-preserve-past-due-day-bug-7344.spec.ts | 23 +- ...weekday-checkboxes-frozen-bug-8025.spec.ts | 192 +++++++ ...eat-task-first-occurrence-bug-5594.spec.ts | 51 +- .../recurring/rrule-builder-roundtrip.spec.ts | 226 -------- e2e/tests/recurring/rrule-repeat.spec.ts | 105 ---- .../supersync-server-backup-revert.spec.ts | 21 +- package-lock.json | 10 - package.json | 1 - .../package-lock.json | 512 ----------------- .../package-lock.json | 512 ----------------- .../local-rest-api-handler.service.ts | 1 - .../archive/archive.service.tz.spec.ts | 11 +- .../features/metric/metric.util.tz.spec.ts | 11 +- ...build-repeat-quick-setting-options.spec.ts | 11 +- .../build-repeat-quick-setting-options.ts | 42 +- ...dialog-edit-task-repeat-cfg.component.html | 50 +- ...dialog-edit-task-repeat-cfg.component.scss | 48 -- ...log-edit-task-repeat-cfg.component.spec.ts | 541 ++---------------- .../dialog-edit-task-repeat-cfg.component.ts | 317 ++-------- ...-edit-task-repeat-cfg.component.tz.spec.ts | 11 +- .../get-quick-setting-updates.spec.ts | 135 +---- .../get-quick-setting-updates.ts | 152 +---- .../rrule-builder.component.html | 486 ---------------- .../rrule-builder.component.scss | 140 ----- .../rrule-builder.component.spec.ts | 323 ----------- .../rrule-builder/rrule-builder.component.ts | 435 -------------- .../task-repeat-cfg-form.const.spec.ts | 88 +-- .../task-repeat-cfg-form.const.ts | 219 ++++++- .../store/get-first-repeat-occurrence.util.ts | 8 - .../get-newest-possible-due-date.util.ts | 9 - .../get-next-repeat-occurrence.util.spec.ts | 34 -- .../store/get-next-repeat-occurrence.util.ts | 11 - ...et-repeat-occurrence-rrule.complex.spec.ts | 81 --- .../store/get-repeat-occurrence-rrule.spec.ts | 66 --- .../store/rrule-occurrence.complex.spec.ts | 274 --------- .../store/rrule-occurrence.day-march.spec.ts | 191 ------- .../store/rrule-occurrence.invariants.spec.ts | 348 ----------- .../store/rrule-occurrence.util.ts | 238 -------- ...ask-repeat-cfg-to-rrule-input.util.spec.ts | 44 -- .../task-repeat-cfg-to-rrule-input.util.ts | 16 - .../store/task-repeat-cfg.actions.spec.ts | 256 --------- .../store/task-repeat-cfg.actions.ts | 49 +- .../store/task-repeat-cfg.effects.ts | 14 +- .../task-repeat-cfg.model.spec.ts | 34 -- .../task-repeat-cfg/task-repeat-cfg.model.ts | 82 +-- .../util/legacy-cfg-to-rrule.util.spec.ts | 462 --------------- .../util/legacy-cfg-to-rrule.util.ts | 326 ----------- .../util/rrule-form.complex.spec.ts | 228 -------- .../util/rrule-form.util.spec.ts | 428 -------------- .../task-repeat-cfg/util/rrule-form.util.ts | 366 ------------ .../task-repeat-cfg/util/rrule-parse.util.ts | 35 -- .../util/rrule-preview.util.spec.ts | 105 ---- .../util/rrule-preview.util.ts | 259 --------- .../util/rrule-weekday.util.ts | 27 - .../add-task-bar-actions.component.html | 2 +- .../add-task-bar/add-task-bar.component.ts | 28 +- .../util/map-archive-to-worklog.tz.spec.ts | 11 +- .../worklog/worklog.service.timezone.spec.ts | 21 +- src/app/op-log/validation/data-repair.spec.ts | 61 -- src/app/op-log/validation/data-repair.ts | 51 +- src/app/t.const.ts | 120 ---- src/assets/i18n/en.json | 117 ---- src/karma.conf.js | 11 +- src/main.ts | 26 +- 67 files changed, 691 insertions(+), 8511 deletions(-) delete mode 100644 e2e/tests/add-task-bar/add-task-bar-custom-repeat.spec.ts create mode 100644 e2e/tests/recurring/repeat-custom-weekday-checkboxes-frozen-bug-8025.spec.ts delete mode 100644 e2e/tests/recurring/rrule-builder-roundtrip.spec.ts delete mode 100644 e2e/tests/recurring/rrule-repeat.spec.ts delete mode 100644 src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/rrule-builder/rrule-builder.component.html delete mode 100644 src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/rrule-builder/rrule-builder.component.scss delete mode 100644 src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/rrule-builder/rrule-builder.component.spec.ts delete mode 100644 src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/rrule-builder/rrule-builder.component.ts delete mode 100644 src/app/features/task-repeat-cfg/store/get-repeat-occurrence-rrule.complex.spec.ts delete mode 100644 src/app/features/task-repeat-cfg/store/get-repeat-occurrence-rrule.spec.ts delete mode 100644 src/app/features/task-repeat-cfg/store/rrule-occurrence.complex.spec.ts delete mode 100644 src/app/features/task-repeat-cfg/store/rrule-occurrence.day-march.spec.ts delete mode 100644 src/app/features/task-repeat-cfg/store/rrule-occurrence.invariants.spec.ts delete mode 100644 src/app/features/task-repeat-cfg/store/rrule-occurrence.util.ts delete mode 100644 src/app/features/task-repeat-cfg/store/task-repeat-cfg-to-rrule-input.util.spec.ts delete mode 100644 src/app/features/task-repeat-cfg/store/task-repeat-cfg-to-rrule-input.util.ts delete mode 100644 src/app/features/task-repeat-cfg/store/task-repeat-cfg.actions.spec.ts delete mode 100644 src/app/features/task-repeat-cfg/task-repeat-cfg.model.spec.ts delete mode 100644 src/app/features/task-repeat-cfg/util/legacy-cfg-to-rrule.util.spec.ts delete mode 100644 src/app/features/task-repeat-cfg/util/legacy-cfg-to-rrule.util.ts delete mode 100644 src/app/features/task-repeat-cfg/util/rrule-form.complex.spec.ts delete mode 100644 src/app/features/task-repeat-cfg/util/rrule-form.util.spec.ts delete mode 100644 src/app/features/task-repeat-cfg/util/rrule-form.util.ts delete mode 100644 src/app/features/task-repeat-cfg/util/rrule-parse.util.ts delete mode 100644 src/app/features/task-repeat-cfg/util/rrule-preview.util.spec.ts delete mode 100644 src/app/features/task-repeat-cfg/util/rrule-preview.util.ts delete mode 100644 src/app/features/task-repeat-cfg/util/rrule-weekday.util.ts diff --git a/.gitignore b/.gitignore index 33e4cedbfd..b8abb40d27 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/docs/wiki/4.13-Repeating-Tasks.md b/docs/wiki/4.13-Repeating-Tasks.md index cefb09108e..174fb00e53 100644 --- a/docs/wiki/4.13-Repeating-Tasks.md +++ b/docs/wiki/4.13-Repeating-Tasks.md @@ -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. diff --git a/e2e/tests/add-task-bar/add-task-bar-custom-repeat.spec.ts b/e2e/tests/add-task-bar/add-task-bar-custom-repeat.spec.ts deleted file mode 100644 index 94fade0909..0000000000 --- a/e2e/tests/add-task-bar/add-task-bar-custom-repeat.spec.ts +++ /dev/null @@ -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 => - page.evaluate((title) => { - type TaskLike = { title?: string; repeatCfgId?: string | null }; - type CfgLike = { rrule?: string | null }; - type StoreState = { - tasks?: { entities?: Record }; - taskRepeatCfg?: { entities?: Record }; - }; - 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); - }); -}); diff --git a/e2e/tests/recurring/recurring-preserve-past-due-day-bug-7344.spec.ts b/e2e/tests/recurring/recurring-preserve-past-due-day-bug-7344.spec.ts index 2c5e175d04..466fbb49c1 100644 --- a/e2e/tests/recurring/recurring-preserve-past-due-day-bug-7344.spec.ts +++ b/e2e/tests/recurring/recurring-preserve-past-due-day-bug-7344.spec.ts @@ -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 }); diff --git a/e2e/tests/recurring/repeat-custom-weekday-checkboxes-frozen-bug-8025.spec.ts b/e2e/tests/recurring/repeat-custom-weekday-checkboxes-frozen-bug-8025.spec.ts new file mode 100644 index 0000000000..696eba8c0e --- /dev/null +++ b/e2e/tests/recurring/repeat-custom-weekday-checkboxes-frozen-bug-8025.spec.ts @@ -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 => { + 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 => { + 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 => + page.evaluate( + ({ taskTitle, weekdayKey }: { taskTitle: string; weekdayKey: WeekdayKey }) => { + type RepeatCfgLike = { + quickSetting?: string | null; + repeatCycle?: string | null; + title?: string | null; + } & Partial>; + type StoreState = { + taskRepeatCfg?: { entities?: Record }; + }; + 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, + }); + }); +}); diff --git a/e2e/tests/recurring/repeat-task-first-occurrence-bug-5594.spec.ts b/e2e/tests/recurring/repeat-task-first-occurrence-bug-5594.spec.ts index 208b88de8a..23f0021b88 100644 --- a/e2e/tests/recurring/repeat-task-first-occurrence-bug-5594.spec.ts +++ b/e2e/tests/recurring/repeat-task-first-occurrence-bug-5594.spec.ts @@ -12,7 +12,7 @@ import { expect, test } from '../../fixtures/test.fixture'; * verify the task is NOT in Today but IS scheduled for Monday. */ test.describe('Issue #5594: First repeat occurrence should not always be today', () => { - test('weekly weekday repeat created on Saturday should schedule for Monday', async ({ + test('weekly Mon/Wed/Fri repeat created on Saturday should schedule for Monday', async ({ page, workViewPage, taskPage, @@ -42,19 +42,48 @@ test.describe('Issue #5594: First repeat occurrence should not always be today', const repeatDialog = page.locator('mat-dialog-container'); await repeatDialog.waitFor({ state: 'visible', timeout: 10000 }); - // 5. Pick the "Every Monday through Friday" quick setting. Saturday is not in - // Mon–Fri, so a task created on Saturday first fires on Monday — the same - // assertion the original Mon/Wed/Fri custom rule made (the legacy Custom - // weekday-picker UI was removed in favour of the RRULE builder). + // 5. Change quickSetting to CUSTOM const quickSettingSelect = repeatDialog.locator('mat-select').first(); await quickSettingSelect.click(); - const mfOption = page - .locator('mat-option') - .filter({ hasText: /Monday through Friday/i }); - await expect(mfOption).toBeVisible({ timeout: 5000 }); - await mfOption.click(); + const customOption = page.locator('mat-option').filter({ hasText: /Custom/i }); + await customOption.click(); - // 6. Save the repeat config + // 6. Wait for the custom config section to be visible + const repeatCycleSelect = repeatDialog.locator('.repeat-cycle mat-select').first(); + await expect(repeatCycleSelect).toBeVisible({ timeout: 5000 }); + + // Ensure repeat cycle is WEEKLY (should be default) + const repeatCycleText = await repeatCycleSelect.textContent(); + if (!repeatCycleText?.includes('Week')) { + await repeatCycleSelect.click(); + const weeklyOption = page.locator('mat-option').filter({ hasText: /Week/i }); + await weeklyOption.click(); + } + + // 7. Set weekday checkboxes: enable Mon/Wed/Fri, disable Tue/Thu/Sat/Sun + // The default has Mon-Fri enabled. We need to uncheck Tue and Thu. + const weekdaysContainer = repeatDialog.locator('.weekdays'); + await expect(weekdaysContainer).toBeVisible({ timeout: 5000 }); + + // Get all checkbox fields + const tuesdayCheckbox = weekdaysContainer + .locator('formly-field') + .nth(1) + .locator('input[type="checkbox"]'); + const thursdayCheckbox = weekdaysContainer + .locator('formly-field') + .nth(3) + .locator('input[type="checkbox"]'); + + // Uncheck Tuesday and Thursday + if (await tuesdayCheckbox.isChecked()) { + await tuesdayCheckbox.click(); + } + if (await thursdayCheckbox.isChecked()) { + await thursdayCheckbox.click(); + } + + // 8. Save the repeat config const saveBtn = repeatDialog.getByRole('button', { name: /Save/i }); await expect(saveBtn).toBeEnabled({ timeout: 5000 }); await saveBtn.click(); diff --git a/e2e/tests/recurring/rrule-builder-roundtrip.spec.ts b/e2e/tests/recurring/rrule-builder-roundtrip.spec.ts deleted file mode 100644 index 0df470367b..0000000000 --- a/e2e/tests/recurring/rrule-builder-roundtrip.spec.ts +++ /dev/null @@ -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 => { - const repeatItem = page - .locator('task-detail-item') - .filter({ has: page.locator('mat-icon', { hasText: 'repeat' }) }) - .first(); - await expect(repeatItem).toBeVisible({ timeout: 5000 }); - await repeatItem.click(); - const dialog = page.locator('mat-dialog-container'); - await expect(dialog).toBeVisible({ timeout: 10000 }); - return dialog; -}; - -/** - * Read the persisted rrule for a task (by title substring) from the live - * NgRx store. Saving a recurring cfg re-plans the task onto the rule's first - * occurrence — which is usually NOT today — so the task leaves the work view - * and a UI reopen is date-dependent. The store read is the date-independent - * persistence oracle; the parse→widget rendering side of the round-trip is - * covered by the dialog/builder unit specs. - */ -const getPersistedRRule = async (page: Page, titlePart: string): Promise => - page.evaluate((title) => { - type TaskLike = { title?: string; repeatCfgId?: string | null }; - type CfgLike = { rrule?: string | null }; - type StoreState = { - tasks?: { entities?: Record }; - taskRepeatCfg?: { entities?: Record }; - }; - 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 => { - 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; - addTask: (t: string) => Promise; - }, - taskPage: { - getTaskByText: (t: string) => Locator; - openTaskDetail: (t: Locator) => Promise; - }, - title: string, -): Promise => { - 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 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(); - }); -}); diff --git a/e2e/tests/recurring/rrule-repeat.spec.ts b/e2e/tests/recurring/rrule-repeat.spec.ts deleted file mode 100644 index 0fc380ca58..0000000000 --- a/e2e/tests/recurring/rrule-repeat.spec.ts +++ /dev/null @@ -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 => - page.evaluate((title) => { - type TaskLike = { title?: string; repeatCfgId?: string | null }; - type CfgLike = { repeatCycle?: string; rrule?: string | null }; - type StoreState = { - tasks?: { entities?: Record }; - taskRepeatCfg?: { entities?: Record }; - }; - 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'); - }); -}); diff --git a/e2e/tests/sync/supersync-server-backup-revert.spec.ts b/e2e/tests/sync/supersync-server-backup-revert.spec.ts index 32b683b4e0..046e298081 100644 --- a/e2e/tests/sync/supersync-server-backup-revert.spec.ts +++ b/e2e/tests/sync/supersync-server-backup-revert.spec.ts @@ -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 } diff --git a/package-lock.json b/package-lock.json index fa80cd13ac..8afc80a7f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2802088ff4..32526b616d 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/packages/plugin-dev/caldav-calendar-provider/package-lock.json b/packages/plugin-dev/caldav-calendar-provider/package-lock.json index 98b286807c..da56a29a51 100644 --- a/packages/plugin-dev/caldav-calendar-provider/package-lock.json +++ b/packages/plugin-dev/caldav-calendar-provider/package-lock.json @@ -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", diff --git a/packages/plugin-dev/google-calendar-provider/package-lock.json b/packages/plugin-dev/google-calendar-provider/package-lock.json index 4dc6ccaed7..36ba9a5573 100644 --- a/packages/plugin-dev/google-calendar-provider/package-lock.json +++ b/packages/plugin-dev/google-calendar-provider/package-lock.json @@ -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", diff --git a/src/app/core/electron/local-rest-api-handler.service.ts b/src/app/core/electron/local-rest-api-handler.service.ts index 3be5a0000f..6b20d22f28 100644 --- a/src/app/core/electron/local-rest-api-handler.service.ts +++ b/src/app/core/electron/local-rest-api-handler.service.ts @@ -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); diff --git a/src/app/features/archive/archive.service.tz.spec.ts b/src/app/features/archive/archive.service.tz.spec.ts index 097c0ae0fd..04dc6bda74 100644 --- a/src/app/features/archive/archive.service.tz.spec.ts +++ b/src/app/features/archive/archive.service.tz.spec.ts @@ -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'); } }); diff --git a/src/app/features/metric/metric.util.tz.spec.ts b/src/app/features/metric/metric.util.tz.spec.ts index ae2154fbcd..21dbf52569 100644 --- a/src/app/features/metric/metric.util.tz.spec.ts +++ b/src/app/features/metric/metric.util.tz.spec.ts @@ -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'); } }); diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/build-repeat-quick-setting-options.spec.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/build-repeat-quick-setting-options.spec.ts index 8e63fb32f2..e744b97fae 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/build-repeat-quick-setting-options.spec.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/build-repeat-quick-setting-options.spec.ts @@ -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()); }); }); diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/build-repeat-quick-setting-options.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/build-repeat-quick-setting-options.ts index 344be4e964..65cdac4e1b 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/build-repeat-quick-setting-options.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/build-repeat-quick-setting-options.ts @@ -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), }, ]; }; diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.html b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.html index 2db347934f..49ea85ebc1 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.html +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/dialog-edit-task-repeat-cfg.component.html @@ -32,14 +32,14 @@ [model]="repeatCfg()" > - @if (isRRuleMode()) { - + @if (isWeekdaySelectionInvalid()) { + } @@ -62,32 +62,6 @@ - - @if (isRRuleMode() && rrulePreview(); as p) { -
-
→ {{ p.human }}
- @if (repeatCfg().repeatFromCompletionDate) { - @if (p.completionExample; as ex) { -
- {{ T.F.TASK_REPEAT.F.RRULE_NEXT_AFTER_COMPLETION | translate }}: - {{ ex.done | date: 'mediumDate' }} → {{ ex.next | date: 'mediumDate' }} -
- } - } @else if (p.upcoming.length) { -
- {{ T.F.TASK_REPEAT.F.RRULE_NEXT | translate }}: - @for (d of p.upcoming; track d.getTime(); let last = $last) { - {{ d | date: 'mediumDate' }} - @if (!last) { - · - } - } -
- } -
{{ p.rrule }}
-
- } - - } - - - } - - - @if (model().freq === 'MONTHLY') { -
- {{ T.F.TASK_REPEAT.F.RRULE_MONTHLY_MODE | translate }} - -
- - @if (model().monthlyMode === 'DAY_OF_MONTH') { -
- -
- @for (d of dayGrid; track d) { - - } - @for (nd of negativeDays; track nd.value) { - - } -
- -
- {{ T.F.TASK_REPEAT.F.RRULE_BYMONTHDAY_DESCRIPTION | translate }} -
-
- } - - @if (model().monthlyMode === 'WEEKDAYS') { - - } - } - - - @if (model().freq === 'YEARLY') { -
- {{ T.F.TASK_REPEAT.F.RRULE_YEARLY_MODE | translate }} - -
-
- {{ T.F.TASK_REPEAT.F.RRULE_YEARLY_MODE_DESCRIPTION | translate }} -
- @if (model().yearlyMode === 'DAY_OF_MONTH') { -
- -
- @for (d of dayGrid; track d) { - - } - @for (nd of negativeDays; track nd.value) { - - } -
- -
- } - @if (model().yearlyMode === 'WEEKDAYS') { - - } - } - - - -
-
- - @for (o of ordinalOpts; track o.value) { - - } - -
- @if (isSetPosCustom()) { - - } -
- @for (w of weekdays; track w.value) { - - } -
-
-
- {{ T.F.TASK_REPEAT.F.RRULE_BYSETPOS_DESCRIPTION | translate }} -
-
- - - @if (isNthMode()) { -
- - @for (nd of model().nthDays; track $index; let rowIdx = $index) { -
- - @if (isNthRowCustom(rowIdx)) { - - } -
- @for (w of weekdays; track w.value) { - - } -
- @if (model().nthDays.length > 1) { - - } -
- } - @if (canAddNthDay()) { - - } -
- {{ T.F.TASK_REPEAT.F.RRULE_NTH_WEEKDAY_DESCRIPTION | translate }} -
-
- } - - -
- -
- @for (m of months; track m.value) { - - } -
-
- {{ T.F.TASK_REPEAT.F.RRULE_BYMONTH_DESCRIPTION | translate }} -
-
- - -
- {{ T.F.TASK_REPEAT.F.RRULE_END | translate }} - - @if (model().endType === 'COUNT') { - - } - @if (model().endType === 'UNTIL') { - - } -
- - -
- -
- - -
-
- {{ T.F.TASK_REPEAT.F.RRULE_SCHEDULE_TYPE_DESCRIPTION | translate }} -
-
- - - - - -
-
- - -
- {{ T.F.TASK_REPEAT.F.RRULE_WKST_DESCRIPTION | translate }} -
-
- - - @if (model().freq === 'YEARLY') { -
- - -
- {{ T.F.TASK_REPEAT.F.RRULE_BYWEEKNO_DESCRIPTION | translate }} -
-
- -
- - -
- {{ T.F.TASK_REPEAT.F.RRULE_BYYEARDAY_DESCRIPTION | translate }} -
-
- } - -
- - -
- {{ T.F.TASK_REPEAT.F.RRULE_RAW_DESCRIPTION | translate }} -
-
-
-
- diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/rrule-builder/rrule-builder.component.scss b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/rrule-builder/rrule-builder.component.scss deleted file mode 100644 index 4ece4a5a8a..0000000000 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/rrule-builder/rrule-builder.component.scss +++ /dev/null @@ -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); -} diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/rrule-builder/rrule-builder.component.spec.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/rrule-builder/rrule-builder.component.spec.ts deleted file mode 100644 index a8c3ac1885..0000000000 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/rrule-builder/rrule-builder.component.spec.ts +++ /dev/null @@ -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; - let component: RruleBuilderComponent; - - const setup = async ( - rrule = '', - startDate = '2024-06-03', - repeatFromCompletion = false, - ): Promise => { - 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, - ).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); - }); -}); diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/rrule-builder/rrule-builder.component.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/rrule-builder/rrule-builder.component.ts deleted file mode 100644 index ff62cb9ee9..0000000000 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/rrule-builder/rrule-builder.component.ts +++ /dev/null @@ -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 { - 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(''); - startDate = input(undefined); - repeatFromCompletion = input(false); - rruleChange = output(); - repeatFromCompletionChange = output(); - - private _model = signal(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[] = [ - { 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[] = [ - { 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[] = [ - { 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[] = [ - { 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[] = [ - { 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[] = [ - { 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[] = 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): 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 = { 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>(new Set()); - - private _patchNthDay(i: number, patch: Partial): 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[] { - 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); - } -} diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts index 20288dea3e..adc53f2583 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.spec.ts @@ -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 => { - let patched: Record = {}; - const field = { - model: { startDate }, - form: { - patchValue: (v: Record) => { - 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); + }); }); }); }); diff --git a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts index 2d7a0d184c..3fccc6d28d 100644 --- a/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts +++ b/src/app/features/task-repeat-cfg/dialog-edit-task-repeat-cfg/task-repeat-cfg-form.const.ts @@ -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 = { + 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. ]; diff --git a/src/app/features/task-repeat-cfg/store/get-first-repeat-occurrence.util.ts b/src/app/features/task-repeat-cfg/store/get-first-repeat-occurrence.util.ts index f9f89b459c..de0f8a963f 100644 --- a/src/app/features/task-repeat-cfg/store/get-first-repeat-occurrence.util.ts +++ b/src/app/features/task-repeat-cfg/store/get-first-repeat-occurrence.util.ts @@ -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); diff --git a/src/app/features/task-repeat-cfg/store/get-newest-possible-due-date.util.ts b/src/app/features/task-repeat-cfg/store/get-newest-possible-due-date.util.ts index 6bd917e74c..f446cf7f4f 100644 --- a/src/app/features/task-repeat-cfg/store/get-newest-possible-due-date.util.ts +++ b/src/app/features/task-repeat-cfg/store/get-newest-possible-due-date.util.ts @@ -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}"`, diff --git a/src/app/features/task-repeat-cfg/store/get-next-repeat-occurrence.util.spec.ts b/src/app/features/task-repeat-cfg/store/get-next-repeat-occurrence.util.spec.ts index 6f92350048..0d742b597d 100644 --- a/src/app/features/task-repeat-cfg/store/get-next-repeat-occurrence.util.spec.ts +++ b/src/app/features/task-repeat-cfg/store/get-next-repeat-occurrence.util.spec.ts @@ -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); - }); }); }); diff --git a/src/app/features/task-repeat-cfg/store/get-next-repeat-occurrence.util.ts b/src/app/features/task-repeat-cfg/store/get-next-repeat-occurrence.util.ts index b3ab40c05d..ec203902ba 100644 --- a/src/app/features/task-repeat-cfg/store/get-next-repeat-occurrence.util.ts +++ b/src/app/features/task-repeat-cfg/store/get-next-repeat-occurrence.util.ts @@ -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}"`, diff --git a/src/app/features/task-repeat-cfg/store/get-repeat-occurrence-rrule.complex.spec.ts b/src/app/features/task-repeat-cfg/store/get-repeat-occurrence-rrule.complex.spec.ts deleted file mode 100644 index f473db6078..0000000000 --- a/src/app/features/task-repeat-cfg/store/get-repeat-occurrence-rrule.complex.spec.ts +++ /dev/null @@ -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 => ({ - ...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'); - }); -}); diff --git a/src/app/features/task-repeat-cfg/store/get-repeat-occurrence-rrule.spec.ts b/src/app/features/task-repeat-cfg/store/get-repeat-occurrence-rrule.spec.ts deleted file mode 100644 index dd15b297a9..0000000000 --- a/src/app/features/task-repeat-cfg/store/get-repeat-occurrence-rrule.spec.ts +++ /dev/null @@ -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 => ({ - ...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'); - }); -}); diff --git a/src/app/features/task-repeat-cfg/store/rrule-occurrence.complex.spec.ts b/src/app/features/task-repeat-cfg/store/rrule-occurrence.complex.spec.ts deleted file mode 100644 index 72439c23ee..0000000000 --- a/src/app/features/task-repeat-cfg/store/rrule-occurrence.complex.spec.ts +++ /dev/null @@ -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 => ({ rrule, startDate, ...over }); - -const range = ( - rrule: string, - startDate: string, - fromS: string, - toS: string, - over: Partial = {}, -): 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(); - }); - }); -}); diff --git a/src/app/features/task-repeat-cfg/store/rrule-occurrence.day-march.spec.ts b/src/app/features/task-repeat-cfg/store/rrule-occurrence.day-march.spec.ts deleted file mode 100644 index 497877852f..0000000000 --- a/src/app/features/task-repeat-cfg/store/rrule-occurrence.day-march.spec.ts +++ /dev/null @@ -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 => ({ - ...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 = 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(); - 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']); - }); - }); -}); diff --git a/src/app/features/task-repeat-cfg/store/rrule-occurrence.invariants.spec.ts b/src/app/features/task-repeat-cfg/store/rrule-occurrence.invariants.spec.ts deleted file mode 100644 index 0a16852671..0000000000 --- a/src/app/features/task-repeat-cfg/store/rrule-occurrence.invariants.spec.ts +++ /dev/null @@ -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 => ({ - rrule, - startDate: '1970-01-01', - lastTaskCreationDay: '1970-01-01', - ...fields, -}); - -const VALID_RRULES = [ - 'FREQ=DAILY', // every day - 'FREQ=WEEKLY;BYDAY=MO', // weekly Monday - 'FREQ=MONTHLY;BYMONTHDAY=15', // monthly day-15 - 'FREQ=YEARLY;BYMONTH=3,4,5,6,7,8,9,10,11;BYDAY=SA', // Saturdays Mar–Nov - 'FREQ=MONTHLY;BYMONTHDAY=-1', // last day of month (cron `L`) - 'FREQ=MONTHLY;BYDAY=3SA', // 3rd Saturday (cron `SAT#3`) - 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', // weekdays - 'FREQ=MONTHLY;BYMONTHDAY=1', // monthly day-1 - 'FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1', // Jan 1 yearly - // --- patterns cron CANNOT express --- - 'FREQ=WEEKLY;INTERVAL=2;BYDAY=MO', // every other Monday - 'FREQ=DAILY;COUNT=10', // end after 10 - 'FREQ=DAILY;UNTIL=20240630T120000Z', // end on a date -]; - -const INVALID_RRULES = [ - '', - ' ', - 'nope', - 'totally bogus text', - 'INTERVAL=2', // no FREQ - 'FREQ=BOGUS', // bad frequency - 'FREQ=WEEKLY;BYDAY=99XX', // bad weekday token -]; -// NB: rrule.js does NOT range-check BYMONTHDAY, so `BYMONTHDAY=99` parses as a -// valid-but-never-firing rule (handled like "Feb 30" below: occurrence = null), -// not as a parse error — hence it is intentionally absent from INVALID_RRULES. - -const BASE = new Date(2024, 5, 15, 12, 0, 0); // Sat Jun 15 2024, noon -const BASE_STR = getDbDateStr(BASE); - -describe('rrule-occurrence invariants', () => { - describe('getNextRRuleOccurrence — for every valid rrule', () => { - VALID_RRULES.forEach((rrule) => { - it(`"${rrule}" returns a Date strictly after fromDate's day, never throws`, () => { - // UNTIL/COUNT rules may legitimately have no occurrence after BASE; use a - // start aligned with BASE so unbounded rules still produce one. - const cfg = inp(rrule, { startDate: '2024-06-01' }); - // A throw would fail this test; null is allowed (COUNT/UNTIL may be spent). - const r = getNextRRuleOccurrence(cfg, BASE); - if (r !== null) { - expect(getDbDateStr(r) > BASE_STR) - .withContext(`${rrule} → ${r}`) - .toBe(true); - expect(r.getHours()).toBe(12); - } - }); - }); - - it('an unbounded rule always returns a later day', () => { - const r = getNextRRuleOccurrence(inp('FREQ=DAILY'), BASE); - expect(r).not.toBeNull(); - expect(getDbDateStr(r!) > BASE_STR).toBe(true); - }); - }); - - describe('getNewestPossibleRRuleDueDate — for every valid rrule', () => { - VALID_RRULES.forEach((rrule) => { - it(`"${rrule}" returns null or a day on/before today and >= startDate`, () => { - const r = getNewestPossibleRRuleDueDate(inp(rrule), BASE); - if (r !== null) { - expect(getDbDateStr(r) <= BASE_STR) - .withContext(`${rrule} → ${r}`) - .toBe(true); - expect(getDbDateStr(r) >= '1970-01-01').toBe(true); - } - }); - }); - }); - - describe('invalid expressions are rejected gracefully (null, no throw)', () => { - INVALID_RRULES.forEach((rrule) => { - it(`"${rrule}"`, () => { - expect(isRRuleValid(rrule)).toBe(false); - expect(getNextRRuleOccurrence(inp(rrule), BASE)).toBeNull(); - expect(getNewestPossibleRRuleDueDate(inp(rrule), BASE)).toBeNull(); - expect(getFirstRRuleOccurrence(inp(rrule))).toBeNull(); - }); - }); - }); - - it('getNextRRuleOccurrence is deterministic', () => { - const a = getNextRRuleOccurrence(inp('FREQ=WEEKLY;BYDAY=MO'), BASE); - const b = getNextRRuleOccurrence(inp('FREQ=WEEKLY;BYDAY=MO'), BASE); - expect(getDbDateStr(a!)).toBe(getDbDateStr(b!)); - }); - - it('advancing fromDate yields strictly increasing occurrence days (monotonic)', () => { - const days: string[] = []; - let from = BASE; - for (let i = 0; i < 6; i++) { - const next = getNextRRuleOccurrence(inp('FREQ=WEEKLY;BYDAY=MO'), from); - expect(next).not.toBeNull(); - const s = getDbDateStr(next!); - if (days.length) expect(s > days[days.length - 1]).toBe(true); - days.push(s); - from = next!; - } - expect(days.length).toBe(6); - }); - - describe('calendar edges', () => { - it('leap day: next Feb 29 from a leap-year start', () => { - const cfg = inp('FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29', { startDate: '2024-01-01' }); - expect(getDbDateStr(getNextRRuleOccurrence(cfg, new Date(2024, 0, 1, 12))!)).toBe( - '2024-02-29', - ); - // After Feb 29 2024 the next leap-day is 2028. - expect(getDbDateStr(getNextRRuleOccurrence(cfg, new Date(2024, 2, 1, 12))!)).toBe( - '2028-02-29', - ); - }); - - it('leap day: due today on Feb 29, null on Feb 28 (no fire yet this year)', () => { - const cfg = inp('FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29', { startDate: '2024-01-01' }); - expect( - getDbDateStr(getNewestPossibleRRuleDueDate(cfg, new Date(2024, 1, 29, 12))!), - ).toBe('2024-02-29'); - expect(getNewestPossibleRRuleDueDate(cfg, new Date(2024, 1, 28, 12))).toBeNull(); - }); - - it('year rollover: monthly day-1 from mid-December → Jan 1 next year', () => { - const r = getNextRRuleOccurrence( - inp('FREQ=MONTHLY;BYMONTHDAY=1'), - new Date(2024, 11, 15, 12), - ); - expect(getDbDateStr(r!)).toBe('2025-01-01'); - }); - - it('yearly Jan 1 from February → Jan 1 next year', () => { - const r = getNextRRuleOccurrence( - inp('FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1'), - new Date(2024, 1, 10, 12), - ); - expect(getDbDateStr(r!)).toBe('2025-01-01'); - }); - - it('pathological "Feb 30" never fires → null, returns promptly (no hang)', () => { - const cfg = inp('FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=30'); - expect(getNextRRuleOccurrence(cfg, BASE)).toBeNull(); - expect(getNewestPossibleRRuleDueDate(cfg, BASE)).toBeNull(); - }); - - it('DST spring-forward: next() AND newest both resolve the exact day', () => { - // US spring-forward 2024-03-10. UTC-space math is structurally DST-immune. - expect( - getDbDateStr( - getNextRRuleOccurrence(inp('FREQ=DAILY'), new Date(2024, 2, 9, 12))!, - ), - ).toBe('2024-03-10'); - expect( - getDbDateStr( - getNewestPossibleRRuleDueDate(inp('FREQ=DAILY'), new Date(2024, 2, 10, 12))!, - ), - ).toBe('2024-03-10'); - }); - - it('DST fall-back: next() and newest both resolve the exact day', () => { - // US fall-back 2024-11-03. - expect( - getDbDateStr( - getNextRRuleOccurrence(inp('FREQ=DAILY'), new Date(2024, 10, 2, 12))!, - ), - ).toBe('2024-11-03'); - expect( - getDbDateStr( - getNewestPossibleRRuleDueDate(inp('FREQ=DAILY'), new Date(2024, 10, 3, 12))!, - ), - ).toBe('2024-11-03'); - }); - }); - - describe('getFirstRRuleOccurrence', () => { - it('daily: first occurrence is the start day itself', () => { - const r = getFirstRRuleOccurrence(inp('FREQ=DAILY', { startDate: '2024-06-01' })); - expect(getDbDateStr(r!)).toBe('2024-06-01'); - }); - - it('weekly Monday: first Monday on/after a Saturday start', () => { - // 2024-06-01 is a Saturday → first Monday is 2024-06-03. - const r = getFirstRRuleOccurrence( - inp('FREQ=WEEKLY;BYDAY=MO', { startDate: '2024-06-01' }), - ); - expect(getDbDateStr(r!)).toBe('2024-06-03'); - }); - - it('returns null for an invalid RRULE', () => { - expect( - getFirstRRuleOccurrence(inp('nope', { startDate: '2024-06-01' })), - ).toBeNull(); - }); - }); - - // Metamorphic relations — these have NO cron analogue; they exercise exactly - // the capabilities (true intervals, finite series, exception dates) that - // motivated the move off cron. - describe('metamorphic relations (beyond cron)', () => { - it('INTERVAL=2 occurrences are a subset of INTERVAL=1', () => { - const every = inp('FREQ=WEEKLY;BYDAY=MO', { startDate: '2024-06-01' }); - const other = inp('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO', { startDate: '2024-06-01' }); - const everySet = new Set(); - 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); - }); - }); - }); -}); diff --git a/src/app/features/task-repeat-cfg/store/rrule-occurrence.util.ts b/src/app/features/task-repeat-cfg/store/rrule-occurrence.util.ts deleted file mode 100644 index 284009350b..0000000000 --- a/src/app/features/task-repeat-cfg/store/rrule-occurrence.util.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { RRule, RRuleSet } from 'rrule'; -import { Log } from '../../../core/log'; -import { safeParseRRuleOptions } from '../util/rrule-parse.util'; - -/** - * Day-granular, DST-safe occurrence engine for RFC 5545 RRULE strings. - * - * Provides four contracts (next / newest / first / validity) that the - * day-granular recurrence machinery routes to whenever a cfg carries an - * `rrule` string, in place of the legacy repeatCycle calculation. - * - * Two properties that make this engine robust, both *structural* here: - * - * 1. DST-safety. All recurrence math runs in pure UTC, which has no DST. The - * resolved calendar day is only re-expressed at LOCAL noon at the very end - * (`toLocalNoon`). Local noon is invariant under DST (transitions happen - * ~02:00–03:00), so `getDbDateStr()` of the result is timezone-stable. The - * cron engine had to avoid `prev()` because it skipped the spring-forward - * midnight; in UTC space `.before()` is safe, so we can use it directly. - * 2. Fail-soft. A malformed RRULE never throws out of these functions: it logs - * (id/expression only — never user content) and returns `null`, exactly - * like `safeParse` in the cron engine. - * - * RRULE is stored as an opaque string, so adopting it never grows the - * `repeatCycle` enum — older sync clients that validate against a fixed enum - * set keep accepting the data (forward-compatible, unlike a new `'CRON'` value). - */ - -export interface RRuleOccurrenceInput { - /** RFC 5545 RRULE body, e.g. `"FREQ=WEEKLY;BYDAY=MO"` (no `RRULE:` prefix needed). */ - rrule: string; - /** Effective recurrence start, `YYYY-MM-DD`. Anchors INTERVAL / COUNT / UNTIL. */ - startDate: string; - /** Last day a task was created, `YYYY-MM-DD`; occurrences must be strictly after it. */ - lastTaskCreationDay?: string; - /** Skipped dates (`YYYY-MM-DD`), RFC 5545 EXDATE. Removed from the occurrence set. */ - exdates?: string[]; -} - -const DAY_MS = 86_400_000; -const FALLBACK_LAST_CREATION = '1970-01-01'; - -/** Noon UTC instant for a `YYYY-MM-DD` string — the canonical occurrence time. */ -export const noonUtc = (dateStr: string): Date => new Date(`${dateStr}T12:00:00Z`); - -/** UTC-midnight instant for a `YYYY-MM-DD` string. */ -const _midnightUtc = (dateStr: string): Date => new Date(`${dateStr}T00:00:00Z`); - -/** A Date's LOCAL calendar day pinned to UTC midnight (drops time + tz for clean UTC math). */ -const _localDayAsUtc = (d: Date): Date => - new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); - -/** - * Re-express a UTC occurrence (noon UTC of the intended day) as that same - * calendar day at LOCAL noon, matching the cron engine's day-granular output. - * Exported (with `noonUtc`) so the preview util anchors occurrences on the - * exact same instants as the engine — they must never diverge. - */ -export const toLocalNoon = (utcOcc: Date): Date => - new Date(utcOcc.getUTCFullYear(), utcOcc.getUTCMonth(), utcOcc.getUTCDate(), 12, 0, 0); - -/** Parse + anchor an RRULE into a set (with EXDATEs), or null if malformed. */ -const _buildRuleSet = (input: RRuleOccurrenceInput): RRuleSet | null => { - const options = safeParseRRuleOptions(input.rrule); - if (!options) return null; - try { - const set = new RRuleSet(); - set.rrule(new RRule({ ...options, dtstart: noonUtc(input.startDate) })); - for (const ex of input.exdates ?? []) { - set.exdate(noonUtc(ex)); - } - return set; - } catch (e) { - // Never log the rule body — the raw-override field makes it free-text user - // input, and the log history is exportable. - Log.warn('Invalid RRULE', (e as Error)?.name); - return null; - } -}; - -// isRRuleValid is called as a routing guard for EVERY repeat cfg on every -// overdue/day-change scan; the construct + probe below is the expensive part. -// Rule strings are few and immutable, so a tiny memo makes repeat calls free. -const _validityCache = new Map(); - -/** - * 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 []; - } -}; diff --git a/src/app/features/task-repeat-cfg/store/task-repeat-cfg-to-rrule-input.util.spec.ts b/src/app/features/task-repeat-cfg/store/task-repeat-cfg-to-rrule-input.util.spec.ts deleted file mode 100644 index b925ed96e5..0000000000 --- a/src/app/features/task-repeat-cfg/store/task-repeat-cfg-to-rrule-input.util.spec.ts +++ /dev/null @@ -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 => ({ - ...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(); - }); -}); diff --git a/src/app/features/task-repeat-cfg/store/task-repeat-cfg-to-rrule-input.util.ts b/src/app/features/task-repeat-cfg/store/task-repeat-cfg-to-rrule-input.util.ts deleted file mode 100644 index 46cbc8ab3e..0000000000 --- a/src/app/features/task-repeat-cfg/store/task-repeat-cfg-to-rrule-input.util.ts +++ /dev/null @@ -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, -}); diff --git a/src/app/features/task-repeat-cfg/store/task-repeat-cfg.actions.spec.ts b/src/app/features/task-repeat-cfg/store/task-repeat-cfg.actions.spec.ts deleted file mode 100644 index a55c095947..0000000000 --- a/src/app/features/task-repeat-cfg/store/task-repeat-cfg.actions.spec.ts +++ /dev/null @@ -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 => - ({ ...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), - }); - 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, - }, - }); - 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, - }, - }); - 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), - }); - 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 = (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, - 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'); - }); -}); diff --git a/src/app/features/task-repeat-cfg/store/task-repeat-cfg.actions.ts b/src/app/features/task-repeat-cfg/store/task-repeat-cfg.actions.ts index 7cafdb5eb0..516243b863 100644 --- a/src/app/features/task-repeat-cfg/store/task-repeat-cfg.actions.ts +++ b/src/app/features/task-repeat-cfg/store/task-repeat-cfg.actions.ts @@ -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 = >(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 = >(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 }) => ({ ...cfgProps, - changes: _toPersisted(cfgProps.changes), meta: { isPersistent: true, entityType: 'TASK_REPEAT_CFG', diff --git a/src/app/features/task-repeat-cfg/store/task-repeat-cfg.effects.ts b/src/app/features/task-repeat-cfg/store/task-repeat-cfg.effects.ts index 535a91352f..2800f0612d 100644 --- a/src/app/features/task-repeat-cfg/store/task-repeat-cfg.effects.ts +++ b/src/app/features/task-repeat-cfg/store/task-repeat-cfg.effects.ts @@ -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) => diff --git a/src/app/features/task-repeat-cfg/task-repeat-cfg.model.spec.ts b/src/app/features/task-repeat-cfg/task-repeat-cfg.model.spec.ts deleted file mode 100644 index 1b6a8a8ab6..0000000000 --- a/src/app/features/task-repeat-cfg/task-repeat-cfg.model.spec.ts +++ /dev/null @@ -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'); - } - }); -}); diff --git a/src/app/features/task-repeat-cfg/task-repeat-cfg.model.ts b/src/app/features/task-repeat-cfg/task-repeat-cfg.model.ts index 5ff69b2bfe..4bf6e01545 100644 --- a/src/app/features/task-repeat-cfg/task-repeat-cfg.model.ts +++ b/src/app/features/task-repeat-cfg/task-repeat-cfg.model.ts @@ -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 = - new Set([ - 'DAILY', - 'WEEKLY_CURRENT_WEEKDAY', - 'MONTHLY_CURRENT_DATE', - 'MONTHLY_FIRST_DAY', - 'MONTHLY_LAST_DAY', - 'MONTHLY_NTH_WEEKDAY', - 'MONDAY_TO_FRIDAY', - 'YEARLY_CURRENT_DATE', - 'CUSTOM', - ]); - -/** Map a quick-setting to a value safe to persist (non-master → 'CUSTOM'). */ -export const toSyncSafeQuickSetting = (qs: RepeatQuickSetting): RepeatQuickSetting => - MASTER_SAFE_QUICK_SETTINGS.has(qs) ? qs : 'CUSTOM'; - // MONTHLY Nth-weekday anchor (issue #6040). Both fields together form an // anchor like "first Thursday" or "last Monday"; either field absent / // out-of-range falls back to legacy day-of-month recurrence. @@ -124,21 +68,7 @@ export interface TaskRepeatCfgCopy { // MONTHLY-only: when both fields are set and in range, the recurrence // anchors to the Nth weekday of each month instead of the numeric day. // Anchor presence is the discriminator — there is no separate mode field. - // ONLY absent-or-numeric is sync-safe: released clients' typia schema has no - // `null` here, so a null must never be persisted (clearing happens via - // `undefined`; a stale anchor on remote clients is inert once `rrule` is - // set, since the occurrence engine routes on it). Issue #6040. - // INHERENT GAP (not fixable, don't try): an `undefined` clear is dropped by - // the op-log's JSON partial-update merge, so a remote PRE-rrule client (which - // ignores `rrule`) keeps scheduling from a stale anchor after an nth-weekday → - // day-of-month switch. There is no in-schema value meaning "no anchor" to send - // instead (0 is a valid weekday; null/0 trip released clients' repair dialog). - // A `| null` migration would NOT help: it only benefits a client that is - // null-aware yet rrule-UNAWARE, but null can only ship with-or-after rrule, so - // that band is empty — any client new enough to accept the null clear already - // routes on `rrule`, where the stale anchor is inert. Only the UPDATE path is - // affected (ADD sends the field absent → no anchor remotely). See the op-log - // JSON round-trip spec pinning this behavior. + // Issue #6040. monthlyWeekOfMonth?: MonthlyWeekOfMonth; monthlyWeekday?: MonthlyWeekday; @@ -169,16 +99,6 @@ export interface TaskRepeatCfgCopy { deletedInstanceDates?: string[]; // When true, missed/overdue instances are silently skipped instead of being created skipOverdue?: boolean; - - // Advanced recurrence: an RFC 5545 RRULE body (e.g. `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO`), - // stored WITHOUT the `RRULE:` prefix. When set it wins over the legacy schedule - // fields (repeatEvery, weekday flags, monthly anchors) — the occurrence engine - // routes on its presence. Stored as an opaque string so it never grows the - // `repeatCycle` enum, keeping older sync clients forward-compatible: they ignore - // the unknown field and fall back to `repeatCycle` (kept populated with the - // FREQ-derived legacy cycle as a best-effort approximation). Lets users express - // "every other Saturday, March–November, 10 times" in one config. - rrule?: string; } export type TaskRepeatCfg = Readonly; diff --git a/src/app/features/task-repeat-cfg/util/legacy-cfg-to-rrule.util.spec.ts b/src/app/features/task-repeat-cfg/util/legacy-cfg-to-rrule.util.spec.ts deleted file mode 100644 index a7f15395f1..0000000000 --- a/src/app/features/task-repeat-cfg/util/legacy-cfg-to-rrule.util.spec.ts +++ /dev/null @@ -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 => - ({ - 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[] = [ - { 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(); - }); -}); diff --git a/src/app/features/task-repeat-cfg/util/legacy-cfg-to-rrule.util.ts b/src/app/features/task-repeat-cfg/util/legacy-cfg-to-rrule.util.ts deleted file mode 100644 index be636a25e8..0000000000 --- a/src/app/features/task-repeat-cfg/util/legacy-cfg-to-rrule.util.ts +++ /dev/null @@ -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= - * MONTHLY → nth-weekday → ;BYDAY= (e.g. 2TU = 2nd Tuesday) - * last day → ;BYMONTHDAY=-1 - * day-of-month → ;BYMONTHDAY= - * YEARLY → FREQ=YEARLY[;INTERVAL=n];BYMONTH=;BYMONTHDAY= (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=,-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 => { - 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 = { - 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)[field] = false; - }); - if (weekdays.length) { - weekdays.forEach((w) => { - const field = RRULE_IDX_TO_FIELD[w.weekday]; - if (field) (out as Record)[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)[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=,-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=,-1;BYSETPOS=1 → target day is . - * 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; -}; diff --git a/src/app/features/task-repeat-cfg/util/rrule-form.complex.spec.ts b/src/app/features/task-repeat-cfg/util/rrule-form.complex.spec.ts deleted file mode 100644 index d56e5f8af2..0000000000 --- a/src/app/features/task-repeat-cfg/util/rrule-form.complex.spec.ts +++ /dev/null @@ -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 => ({ - ...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)); - }); - }); - }); -}); diff --git a/src/app/features/task-repeat-cfg/util/rrule-form.util.spec.ts b/src/app/features/task-repeat-cfg/util/rrule-form.util.spec.ts deleted file mode 100644 index ef36388162..0000000000 --- a/src/app/features/task-repeat-cfg/util/rrule-form.util.spec.ts +++ /dev/null @@ -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 => ({ - ...defaultRRuleFormModel(new Date(2024, 5, 3)), // Mon Jun 3 2024 - ...over, - }); - - it('daily with interval', () => { - expect(formModelToRRule(base({ freq: 'DAILY', interval: 1 }))).toBe('FREQ=DAILY'); - expect(formModelToRRule(base({ freq: 'DAILY', interval: 3 }))).toBe( - 'FREQ=DAILY;INTERVAL=3', - ); - }); - - it('weekly with weekdays (ordered Mon→Sun)', () => { - expect( - formModelToRRule(base({ freq: 'WEEKLY', interval: 2, byDay: ['WE', 'MO'] })), - ).toBe('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE'); - }); - - it('monthly nth-weekday (single row)', () => { - expect( - formModelToRRule( - base({ - freq: 'MONTHLY', - monthlyMode: 'NTH_WEEKDAY', - nthDays: [{ pos: 2, days: ['TU'] }], - }), - ), - ).toBe('FREQ=MONTHLY;BYDAY=2TU'); - }); - - it('monthly last weekday (-1)', () => { - expect( - formModelToRRule( - base({ - freq: 'MONTHLY', - monthlyMode: 'NTH_WEEKDAY', - nthDays: [{ pos: -1, days: ['FR'] }], - }), - ), - ).toBe('FREQ=MONTHLY;BYDAY=-1FR'); - }); - - it('monthly custom ordinals (5th Friday, 2nd-to-last Monday)', () => { - expect( - formModelToRRule( - base({ - freq: 'MONTHLY', - monthlyMode: 'NTH_WEEKDAY', - nthDays: [ - { pos: 5, days: ['FR'] }, - { pos: -2, days: ['MO'] }, - ], - }), - ), - ).toBe('FREQ=MONTHLY;BYDAY=5FR,-2MO'); - }); - - it('dedupes identical ordinal+weekday tokens across rows', () => { - expect( - formModelToRRule( - base({ - freq: 'MONTHLY', - monthlyMode: 'NTH_WEEKDAY', - nthDays: [ - { pos: 3, days: ['MO', 'TU'] }, - { pos: 3, days: ['TU'] }, - ], - }), - ), - ).toBe('FREQ=MONTHLY;BYDAY=3MO,3TU'); - }); - - it('monthly nth-weekday multiple rows (3rd Monday and 4th Sunday)', () => { - expect( - formModelToRRule( - base({ - freq: 'MONTHLY', - monthlyMode: 'NTH_WEEKDAY', - nthDays: [ - { pos: 3, days: ['MO'] }, - { pos: 4, days: ['SU'] }, - ], - }), - ), - ).toBe('FREQ=MONTHLY;BYDAY=3MO,4SU'); - }); - - it('yearly nth-weekday rows, every other year (3rd Mon + 4th Sun in June)', () => { - expect( - formModelToRRule( - base({ - freq: 'YEARLY', - interval: 2, - yearlyMode: 'NTH_WEEKDAY', - byMonth: [6], - nthDays: [ - { pos: 3, days: ['MO'] }, - { pos: 4, days: ['SU'] }, - ], - }), - ), - ).toBe('FREQ=YEARLY;INTERVAL=2;BYMONTH=6;BYDAY=3MO,4SU'); - }); - - it('monthly day-of-month', () => { - expect( - formModelToRRule( - base({ freq: 'MONTHLY', monthlyMode: 'DAY_OF_MONTH', monthDays: [15] }), - ), - ).toBe('FREQ=MONTHLY;BYMONTHDAY=15'); - }); - - it('monthly last day (-1 in the day grid)', () => { - expect( - formModelToRRule( - base({ freq: 'MONTHLY', monthlyMode: 'DAY_OF_MONTH', monthDays: [-1] }), - ), - ).toBe('FREQ=MONTHLY;BYMONTHDAY=-1'); - }); - - it('yearly with months + day', () => { - expect( - formModelToRRule(base({ freq: 'YEARLY', byMonth: [11, 3], monthDays: [15] })), - ).toBe('FREQ=YEARLY;BYMONTH=3,11;BYMONTHDAY=15'); - }); - - it('yearly weekdays within months (seasonal — the cron "Sat Mar–Nov" case)', () => { - expect( - formModelToRRule( - base({ - freq: 'YEARLY', - yearlyMode: 'WEEKDAYS', - byMonth: [3, 4, 5, 6, 7, 8, 9, 10, 11], - byDay: ['SA'], - }), - ), - ).toBe('FREQ=YEARLY;BYMONTH=3,4,5,6,7,8,9,10,11;BYDAY=SA'); - }); - - it('yearly nth-weekday via "which" (2nd Saturday of June, every other year)', () => { - expect( - formModelToRRule( - base({ - freq: 'YEARLY', - interval: 2, - yearlyMode: 'WEEKDAYS', - byMonth: [6], - byDay: ['SA'], - bySetPos: '2', - }), - ), - ).toBe('FREQ=YEARLY;INTERVAL=2;BYMONTH=6;BYDAY=SA;BYSETPOS=2'); - }); - - it('seasonal BYMONTH constraint on non-yearly frequencies', () => { - expect(formModelToRRule(base({ freq: 'DAILY', byMonth: [1, 2, 3, 4] }))).toBe( - 'FREQ=DAILY;BYMONTH=1,2,3,4', - ); - expect( - formModelToRRule(base({ freq: 'WEEKLY', byDay: ['MO'], byMonth: [6] })), - ).toBe('FREQ=WEEKLY;BYMONTH=6;BYDAY=MO'); - }); - - it('end after N occurrences (COUNT)', () => { - expect(formModelToRRule(base({ freq: 'DAILY', endType: 'COUNT', count: 5 }))).toBe( - 'FREQ=DAILY;COUNT=5', - ); - }); - - it('end on date (UNTIL → noon UTC)', () => { - expect( - formModelToRRule(base({ freq: 'DAILY', endType: 'UNTIL', until: '2024-06-30' })), - ).toBe('FREQ=DAILY;UNTIL=20240630T120000Z'); - }); - }); - - describe('rruleToFormModel ∘ formModelToRRule round-trips', () => { - const cases = [ - 'FREQ=DAILY', - 'FREQ=DAILY;INTERVAL=3', - 'FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE', - 'FREQ=MONTHLY;BYDAY=2TU', - 'FREQ=MONTHLY;BYDAY=3MO,4SU', - 'FREQ=MONTHLY;BYDAY=-1FR', - 'FREQ=MONTHLY;BYMONTHDAY=15', - 'FREQ=MONTHLY;BYMONTHDAY=-1', - 'FREQ=YEARLY;BYMONTH=3,11;BYMONTHDAY=15', - 'FREQ=YEARLY;BYMONTH=3,4,5,6,7,8,9,10,11;BYDAY=SA', - 'FREQ=DAILY;BYMONTH=1,2,3,4', - 'FREQ=WEEKLY;BYMONTH=6;BYDAY=MO', - 'FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;WKST=SU', - 'FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1', - 'FREQ=DAILY;COUNT=5', - 'FREQ=DAILY;UNTIL=20240630T120000Z', - ]; - cases.forEach((rrule) => { - it(`"${rrule}" survives parse → build`, () => { - expect(formModelToRRule(rruleToFormModel(rrule))).toBe(rrule); - }); - }); - }); - - describe('rruleToFormModel parsing', () => { - it('maps nth-weekday back to a single row', () => { - const m = rruleToFormModel('FREQ=MONTHLY;BYDAY=3SA'); - expect(m.freq).toBe('MONTHLY'); - expect(m.monthlyMode).toBe('NTH_WEEKDAY'); - expect(m.nthDays).toEqual([{ pos: 3, days: ['SA'] }]); - }); - - it('maps multiple nth-weekdays back to rows (3MO,4SU)', () => { - const m = rruleToFormModel('FREQ=MONTHLY;BYDAY=3MO,4SU'); - expect(m.monthlyMode).toBe('NTH_WEEKDAY'); - expect(m.nthDays).toEqual([ - { pos: 3, days: ['MO'] }, - { pos: 4, days: ['SU'] }, - ]); - }); - - it('maps custom ordinals (outside the dropdown set) back to rows, no raw fallback', () => { - const m = rruleToFormModel('FREQ=MONTHLY;BYDAY=-2MO,5FR'); - expect(m.monthlyMode).toBe('NTH_WEEKDAY'); - expect(m.nthDays).toEqual([ - { pos: -2, days: ['MO'] }, - { pos: 5, days: ['FR'] }, - ]); - expect(m.rawOverride).toBe(''); - }); - - it('maps yearly weekdays-within-months back to dropdown fields', () => { - const m = rruleToFormModel('FREQ=YEARLY;BYMONTH=3,11;BYDAY=SA'); - expect(m.freq).toBe('YEARLY'); - expect(m.yearlyMode).toBe('WEEKDAYS'); - expect(m.byMonth).toEqual([3, 11]); - expect(m.byDay).toEqual(['SA']); - }); - - it('maps a monthly weekday set (+ set position) back to dropdowns', () => { - const m = rruleToFormModel('FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1'); - expect(m.freq).toBe('MONTHLY'); - expect(m.monthlyMode).toBe('WEEKDAYS'); - expect(m.byDay).toEqual(['MO', 'TU', 'WE', 'TH', 'FR']); - expect(m.bySetPos).toBe('-1'); - }); - - it('maps a multi-value BYSETPOS back without a raw fallback', () => { - const m = rruleToFormModel('FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=2,-1'); - expect(m.monthlyMode).toBe('WEEKDAYS'); - expect(m.bySetPos).toBe('2,-1'); - expect(m.rawOverride).toBe(''); - }); - - it('YEARLY date mode without BYMONTH omits BYMONTHDAY (bare yearly = anniversary)', () => { - // Per RFC 5545 a bare FREQ=YEARLY;BYMONTHDAY=n expands across every - // month — i.e. fires monthly. With no months selected, emit a plain - // FREQ=YEARLY (anchors to the start date) instead. - expect( - formModelToRRule({ - ...defaultRRuleFormModel(new Date(2024, 5, 3)), - freq: 'YEARLY', - yearlyMode: 'DAY_OF_MONTH', - byMonth: [], - monthDays: [15], - }), - ).toBe('FREQ=YEARLY'); - }); - - it('a parsed bare yearly BYMONTHDAY rule falls back to the raw override', () => { - // Can't round-trip structurally without changing semantics (it fires - // monthly) — preserve it verbatim instead. - const m = rruleToFormModel('FREQ=YEARLY;BYMONTHDAY=15'); - expect(m.rawOverride).toBe('FREQ=YEARLY;BYMONTHDAY=15'); - }); - - it('drops BYSETPOS=0 on parse (re-emitting it would create a dead rule)', () => { - const m = rruleToFormModel('FREQ=MONTHLY;BYDAY=MO,TU;BYSETPOS=0'); - expect(m.bySetPos).not.toContain('0'); - // The cleanup must survive the round-trip guard: a raw override would - // store the original rule verbatim and re-emit the dead BYSETPOS=0. - expect(m.rawOverride).toBe(''); - expect(formModelToRRule(m)).toBe('FREQ=MONTHLY;BYDAY=MO,TU'); - }); - - it('keeps non-zero BYSETPOS values when zeros are mixed in', () => { - const m = rruleToFormModel('FREQ=MONTHLY;BYDAY=MO;BYSETPOS=0,2'); - expect(m.bySetPos).toBe('2'); - expect(m.rawOverride).toBe(''); - expect(formModelToRRule(m)).toBe('FREQ=MONTHLY;BYDAY=MO;BYSETPOS=2'); - }); - - it('round-trips the migration clamp idiom structurally (no raw fallback)', () => { - // BYMONTHDAY=31,-1;BYSETPOS=1 = "the 31st, or the last day of shorter - // months" — emitted by the legacy-CUSTOM migration for day > 28 anchors. - const m = rruleToFormModel('FREQ=MONTHLY;BYMONTHDAY=31,-1;BYSETPOS=1'); - expect(m.monthlyMode).toBe('DAY_OF_MONTH'); - expect(m.monthDays).toEqual([31, -1]); - expect(m.bySetPos).toBe('1'); - expect(m.rawOverride).toBe(''); - expect(formModelToRRule(m)).toBe('FREQ=MONTHLY;BYMONTHDAY=31,-1;BYSETPOS=1'); - }); - - it('maps COUNT to end condition', () => { - const m = rruleToFormModel('FREQ=WEEKLY;BYDAY=MO;COUNT=12'); - expect(m.endType).toBe('COUNT'); - expect(m.count).toBe(12); - }); - - it('falls back to defaults for empty / garbage input', () => { - expect(rruleToFormModel('').freq).toBe('WEEKLY'); - expect(rruleToFormModel('not an rrule').freq).toBe('WEEKLY'); - expect(rruleToFormModel(undefined).endType).toBe('NEVER'); - }); - }); - - describe('advanced section (WKST + raw override)', () => { - it('week-start (WKST) round-trips and expands the advanced section', () => { - const m = rruleToFormModel('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;WKST=SU'); - expect(m.showAdvanced).toBe(true); - expect(m.wkst).toBe('SU'); - expect(formModelToRRule(m)).toBe('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;WKST=SU'); - }); - - it('truly exotic rules (time-of-day parts) are preserved via raw override', () => { - const exotic = 'FREQ=DAILY;BYHOUR=9,17'; - const m = rruleToFormModel(exotic); - expect(m.showAdvanced).toBe(true); - expect(m.rawOverride).toBe(exotic); - expect(formModelToRRule(m)).toBe(exotic); - }); - - it('raw override replaces the structured build when advanced is on', () => { - const m = defaultRRuleFormModel(new Date(2024, 5, 3)); - m.showAdvanced = true; - m.rawOverride = 'FREQ=YEARLY;BYYEARDAY=100'; - expect(formModelToRRule(m)).toBe('FREQ=YEARLY;BYYEARDAY=100'); - }); - - it('an advanced value applies even when the section is collapsed (cosmetic)', () => { - const m = defaultRRuleFormModel(new Date(2024, 5, 3)); // Monday - m.showAdvanced = false; // collapsed UI, but the value still applies - m.rawOverride = 'FREQ=YEARLY;BYYEARDAY=100'; - expect(formModelToRRule(m)).toBe('FREQ=YEARLY;BYYEARDAY=100'); - }); - }); - - describe('advanced BY* forward build', () => { - const adv = (over: Partial): RRuleFormModel => ({ - ...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)); - }); - }); - }); -}); diff --git a/src/app/features/task-repeat-cfg/util/rrule-form.util.ts b/src/app/features/task-repeat-cfg/util/rrule-form.util.ts deleted file mode 100644 index 00abb38362..0000000000 --- a/src/app/features/task-repeat-cfg/util/rrule-form.util.ts +++ /dev/null @@ -1,366 +0,0 @@ -import { RRule } from 'rrule'; -import { normalizeWeekdays, toNumArray } from './rrule-weekday.util'; -import { FREQ_TO_CYCLE, safeParseRRuleOptions } from './rrule-parse.util'; - -/** - * Bidirectional bridge between the structured RRULE builder form (a dropdown per - * RFC 5545 field) and the opaque `rrule` string stored on the TaskRepeatCfg. - * - * - `formModelToRRule` builds the string deterministically from the dropdowns. - * - `rruleToFormModel` parses an existing string back into dropdown values for - * editing (falling back to sane defaults for anything missing/unparseable). - * - * Scope — the structured builder covers every day-meaningful RFC 5545 rule part: - * FREQ → DAILY | WEEKLY | MONTHLY | YEARLY (sub-daily survives via raw override) - * INTERVAL, COUNT (after N), UNTIL (on date) - * BYMONTH → seasonal constraint on ANY frequency (e.g. daily in Jan–Apr) - * WEEKLY → BYDAY (weekday multi-select) - * MONTHLY → day-of-month | nth-weekday (2MO) | weekday-set + BYSETPOS (last weekday) | last-day - * YEARLY → on a date (BYMONTH + BYMONTHDAY) | weekdays within months (BYMONTH + BYDAY) - * advanced → WKST, BYSETPOS, multi/negative BYMONTHDAY, raw override, - * BYWEEKNO / BYYEARDAY (shown only for YEARLY per RFC 5545 §3.3.10) - * - * Anything the structured fields can't model (time-of-day parts, or a BYDAY with - * different ordinals per weekday) is preserved verbatim via the raw override — - * guaranteed by the canonical round-trip guard in rruleToFormModel. - */ - -export const RRULE_WEEKDAYS = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'] as const; -export type RRuleWeekday = (typeof RRULE_WEEKDAYS)[number]; - -export type RRuleFreq = 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY'; -export type RRuleMonthlyMode = 'DAY_OF_MONTH' | 'NTH_WEEKDAY' | 'WEEKDAYS'; -/** YEARLY: a date (BYMONTHDAY), a weekday set within months (BYDAY[+BYSETPOS]), - * or per-weekday ordinals (BYDAY=3MO,4SU). */ -export type RRuleYearlyMode = 'DAY_OF_MONTH' | 'NTH_WEEKDAY' | 'WEEKDAYS'; -export type RRuleEndType = 'NEVER' | 'COUNT' | 'UNTIL'; -/** The predefined ordinal dropdown values: 1..4 = 1st–4th occurrence; -1 = last. */ -export type RRuleSetPos = 1 | 2 | 3 | 4 | -1; -/** One ordinal row applied to a set of weekdays, e.g. `{ pos: 3, days: ['MO', - * 'TU'] }` = the 3rd Monday and 3rd Tuesday → BYDAY=3MO,3TU. `pos` is usually - * one of the predefined `RRuleSetPos` values, but the custom ordinal input - * allows any non-zero RFC 5545 ordinal (±1..±53), e.g. -2 = 2nd-to-last. */ -export interface RRuleNthDay { - pos: number; - days: RRuleWeekday[]; -} - -export interface RRuleFormModel { - freq: RRuleFreq; - interval: number; - byDay: RRuleWeekday[]; // WEEKLY - monthlyMode: RRuleMonthlyMode; // MONTHLY - monthDays: number[]; // MONTHLY/YEARLY day-of-month; -1 = last day; multiple allowed - // MONTHLY/YEARLY nth-weekday rows (per-weekday ordinals), e.g. [{3,MO},{4,SU}]. - nthDays: RRuleNthDay[]; - byMonth: number[]; // YEARLY months (1..12) - yearlyMode: RRuleYearlyMode; // YEARLY: date vs weekdays-within-months - endType: RRuleEndType; - count: number; // COUNT - until: string; // UNTIL, 'YYYY-MM-DD' - // --- advanced (collapsed section) --- - wkst: RRuleWeekday | ''; // week start; '' = library default (Monday) - // Comma-separated integer lists ('' = omitted). Cover the remaining RRULE - // BY* rule parts as structured inputs. - bySetPos: string; // BYSETPOS for the weekday-set "which occurrence" (single value) - byWeekNo: string; // BYWEEKNO (ISO week numbers, 1..53 / negative) - byYearDay: string; // BYYEARDAY (1..366 / negative) - showAdvanced: boolean; // whether the advanced section is expanded - rawOverride: string; // raw RRULE body that overrides the builder when set -} - -// RepeatCycleOption and RRuleFreq are the same four literals — reuse the -// shared Frequency map instead of maintaining a second copy. -const FREQ_TO_STR: Partial> = FREQ_TO_CYCLE; - -/** JS `Date.getDay()` (0=Sun) → RRULE weekday index (0=Mon). */ -const jsDayToRRuleIdx = (jsDay: number): number => (jsDay + 6) % 7; - -const pad2 = (n: number): string => String(n).padStart(2, '0'); - -/** 'YYYY-MM-DD' → RFC 5545 UNTIL value at noon UTC (matches engine occurrence instants). */ -const untilToRRule = (dateStr: string): string => { - const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(dateStr); - if (!m) return ''; - return `${m[1]}${m[2]}${m[3]}T120000Z`; -}; - -/** A UTC Date → 'YYYY-MM-DD' using UTC parts (no timezone drift). */ -const utcDateToDbStr = (d: Date): string => - `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`; - -/** Defaults for a fresh RRULE builder, seeded from `refDate` (the start date). */ -export const defaultRRuleFormModel = (refDate: Date = new Date()): RRuleFormModel => { - const rruleWeekday = RRULE_WEEKDAYS[jsDayToRRuleIdx(refDate.getDay())]; - return { - freq: 'WEEKLY', - interval: 1, - byDay: [rruleWeekday], - monthlyMode: 'DAY_OF_MONTH', - monthDays: [refDate.getDate()], - nthDays: [ - { - pos: Math.min(Math.floor((refDate.getDate() - 1) / 7) + 1, 4), - days: [rruleWeekday], - }, - ], - byMonth: [], - yearlyMode: 'DAY_OF_MONTH', - endType: 'NEVER', - count: 10, - until: '', - wkst: '', - bySetPos: '', - byWeekNo: '', - byYearDay: '', - showAdvanced: false, - rawOverride: '', - }; -}; - -/** "3MO,4SU" / "1MO,1TU" — each row's ordinal applied to each of its weekdays - * (Mon-first within a row). Deduped: two rows with the same (custom) ordinal - * and overlapping weekdays must not emit the same token twice. */ -const nthDaysToByDay = (rows: RRuleNthDay[]): string => - [ - ...new Set( - (rows ?? []).flatMap((r) => - RRULE_WEEKDAYS.filter((d) => (r.days ?? []).includes(d)).map( - (d) => `${r.pos}${d}`, - ), - ), - ), - ].join(','); - -/** Build the RFC 5545 RRULE body (no `RRULE:` prefix) from the dropdown model. */ -export const formModelToRRule = (m: RRuleFormModel): string => { - // A raw override (advanced) wins over the structured builder entirely. - if (m.rawOverride && m.rawOverride.trim()) { - return m.rawOverride.trim(); - } - const parts: string[] = [`FREQ=${m.freq}`]; - if (m.interval && m.interval > 1) parts.push(`INTERVAL=${m.interval}`); - - const pushByDaySet = (): void => { - if (m.byDay?.length) { - const ordered = RRULE_WEEKDAYS.filter((d) => m.byDay.includes(d)); - parts.push(`BYDAY=${ordered.join(',')}`); - } - }; - const pushBySetPos = (): void => { - if (m.bySetPos && m.bySetPos.trim()) { - parts.push(`BYSETPOS=${m.bySetPos.replace(/\s+/g, '')}`); - } - }; - - // BYMONTH is a seasonal constraint valid for ANY frequency — e.g. daily in - // Jan–Apr, weekly Mondays in June, or yearly within the chosen months. - if (m.byMonth?.length) { - parts.push(`BYMONTH=${[...m.byMonth].sort((a, b) => a - b).join(',')}`); - } - - if (m.freq === 'WEEKLY') { - pushByDaySet(); - } - - if (m.freq === 'MONTHLY') { - if (m.monthlyMode === 'NTH_WEEKDAY') { - // Per-weekday ordinals (the 3rd Monday and 4th Sunday → BYDAY=3MO,4SU). - const byDay = nthDaysToByDay(m.nthDays); - if (byDay) parts.push(`BYDAY=${byDay}`); - } else if (m.monthlyMode === 'WEEKDAYS') { - // A weekday set (e.g. Mon–Fri), optionally narrowed to one occurrence via - // the "which occurrence" toggles (BYSETPOS) → "last weekday of month". - pushByDaySet(); - pushBySetPos(); - } else if (m.monthDays?.length) { - // Day(s) of the month (-1 = last day); selected via the day grid. - parts.push(`BYMONTHDAY=${m.monthDays.join(',')}`); - // BYSETPOS narrows the day set too — used by the migration clamp idiom - // (BYMONTHDAY=31,-1;BYSETPOS=1 = "31st or last day of shorter months"). - // Emitting it keeps such rules round-tripping structurally. - pushBySetPos(); - } - } - - if (m.freq === 'YEARLY') { - if (m.yearlyMode === 'NTH_WEEKDAY') { - // Per-weekday ordinals within the chosen month(s), e.g. BYDAY=3MO,4SU. - const byDay = nthDaysToByDay(m.nthDays); - if (byDay) parts.push(`BYDAY=${byDay}`); - } else if (m.yearlyMode === 'WEEKDAYS') { - // A weekday set within the chosen month(s), optionally narrowed to one - // occurrence via "which" (BYSETPOS) → e.g. the 2nd Saturday of June. - pushByDaySet(); - pushBySetPos(); - } else if (m.monthDays?.length && m.byMonth?.length) { - // Date mode REQUIRES BYMONTH: per RFC 5545, FREQ=YEARLY with a bare - // BYMONTHDAY expands across every month — i.e. fires monthly. With no - // months selected, omit BYMONTHDAY too: a plain FREQ=YEARLY anchors to - // the start date's month+day, which is truly yearly. Parsed bare yearly - // rules can't round-trip through this and fall back to the raw override, - // preserving their (monthly-firing) semantics verbatim. - parts.push(`BYMONTHDAY=${m.monthDays.join(',')}`); - // Same as MONTHLY: keep the clamp idiom (e.g. Feb-29 yearly) round-tripping. - pushBySetPos(); - } - } - - // Advanced parts apply whenever they have a value — `showAdvanced` only drives - // the collapsible's open/closed state, never the resulting rule. - // BYWEEKNO / BYYEARDAY are YEARLY-only (RFC 5545 §3.3.10), so they're surfaced - // in the yearly section and only emitted for YEARLY — a value left over after - // switching frequency must not leak onto a non-yearly rule. - const clean = (v: string): string => v.replace(/\s+/g, ''); - if (m.freq === 'YEARLY' && m.byWeekNo && m.byWeekNo.trim()) { - parts.push(`BYWEEKNO=${clean(m.byWeekNo)}`); - } - if (m.freq === 'YEARLY' && m.byYearDay && m.byYearDay.trim()) { - parts.push(`BYYEARDAY=${clean(m.byYearDay)}`); - } - if (m.wkst) parts.push(`WKST=${m.wkst}`); - if (m.endType === 'COUNT' && m.count > 0) parts.push(`COUNT=${m.count}`); - if (m.endType === 'UNTIL' && m.until) parts.push(`UNTIL=${untilToRRule(m.until)}`); - - return parts.join(';'); -}; - -/** Parse an RRULE body back into dropdown values; unparseable → defaults. */ -export const rruleToFormModel = ( - rrule: string | undefined, - refDate: Date = new Date(), -): RRuleFormModel => { - const model = defaultRRuleFormModel(refDate); - if (!rrule || !rrule.trim()) return model; - - const opts = safeParseRRuleOptions(rrule); - if (!opts) return model; - const freqStr = FREQ_TO_STR[opts.freq]; - if (freqStr == null) { - // Sub-daily / unsupported FREQ (the day-granular engine has no UI for it) → - // preserve the rule verbatim via the raw override. - model.showAdvanced = true; - model.rawOverride = rrule.trim(); - return model; - } - - model.freq = freqStr; - model.interval = opts.interval && opts.interval > 0 ? opts.interval : 1; - - const weekdays = normalizeWeekdays(opts.byweekday); - const monthDays = toNumArray(opts.bymonthday); - const months = toNumArray(opts.bymonth); - // BYMONTH applies to any frequency. - model.byMonth = months; - // BYMONTHDAY (the day-of-month grid) — used by MONTHLY/YEARLY day modes. - if (monthDays.length) model.monthDays = monthDays; - - if (model.freq === 'WEEKLY' && weekdays.length) { - model.byDay = weekdays.map((w) => RRULE_WEEKDAYS[w.weekday]).filter(Boolean); - } - - // When every weekday carries an ordinal (e.g. 3MO,4SU) it's the nth-weekday - // mode; a plain set (MO,TU) or one with BYSETPOS stays the weekday-set mode. - // Weekdays that share an ordinal collapse into one row: 1MO,1TU,3WE → - // [{pos:1,days:[MO,TU]},{pos:3,days:[WE]}] (Mon-first within each row). - const toNthDays = (): RRuleNthDay[] => { - const byPos = new Map(); - 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; -}; diff --git a/src/app/features/task-repeat-cfg/util/rrule-parse.util.ts b/src/app/features/task-repeat-cfg/util/rrule-parse.util.ts deleted file mode 100644 index 06266b97cb..0000000000 --- a/src/app/features/task-repeat-cfg/util/rrule-parse.util.ts +++ /dev/null @@ -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> & { - 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> = { - [Frequency.DAILY]: 'DAILY', - [Frequency.WEEKLY]: 'WEEKLY', - [Frequency.MONTHLY]: 'MONTHLY', - [Frequency.YEARLY]: 'YEARLY', -}; diff --git a/src/app/features/task-repeat-cfg/util/rrule-preview.util.spec.ts b/src/app/features/task-repeat-cfg/util/rrule-preview.util.spec.ts deleted file mode 100644 index 46fae73227..0000000000 --- a/src/app/features/task-repeat-cfg/util/rrule-preview.util.spec.ts +++ /dev/null @@ -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 = { - 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 - }); -}); diff --git a/src/app/features/task-repeat-cfg/util/rrule-preview.util.ts b/src/app/features/task-repeat-cfg/util/rrule-preview.util.ts deleted file mode 100644 index 058a1114e8..0000000000 --- a/src/app/features/task-repeat-cfg/util/rrule-preview.util.ts +++ /dev/null @@ -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([ - ['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; - } -}; diff --git a/src/app/features/task-repeat-cfg/util/rrule-weekday.util.ts b/src/app/features/task-repeat-cfg/util/rrule-weekday.util.ts deleted file mode 100644 index 7b9b9ce537..0000000000 --- a/src/app/features/task-repeat-cfg/util/rrule-weekday.util.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Shared normalizers for `rrule` option values, used by both the form builder - * (`rrule-form.util`) and the legacy⇄RRULE converter (`legacy-cfg-to-rrule.util`). - */ - -/** Coerce an rrule numeric option (`bymonthday`, `bymonth`, …) to a number[]. */ -export const toNumArray = (v: unknown): number[] => { - if (v == null) return []; - if (Array.isArray(v)) return v.filter((x): x is number => typeof x === 'number'); - return typeof v === 'number' ? [v] : []; -}; - -/** Normalize rrule's `byweekday` option to `{ weekday, n }` records. */ -export const normalizeWeekdays = (v: unknown): { weekday: number; n?: number }[] => { - if (v == null) return []; - const arr = Array.isArray(v) ? v : [v]; - return arr - .map((w) => { - if (typeof w === 'number') return { weekday: w }; - if (w && typeof w === 'object' && 'weekday' in w) { - const wd = w as { weekday: number; n?: number }; - return { weekday: wd.weekday, n: wd.n ?? undefined }; - } - return null; - }) - .filter((x): x is { weekday: number; n?: number } => x !== null); -}; diff --git a/src/app/features/tasks/add-task-bar/add-task-bar-actions/add-task-bar-actions.component.html b/src/app/features/tasks/add-task-bar/add-task-bar-actions/add-task-bar-actions.component.html index a54883fb22..e645b29faf 100644 --- a/src/app/features/tasks/add-task-bar/add-task-bar-actions/add-task-bar-actions.component.html +++ b/src/app/features/tasks/add-task-bar/add-task-bar-actions/add-task-bar-actions.component.html @@ -250,7 +250,7 @@ mat-menu-item (click)="selectRepeatQuickSetting(option.value)" > - @if (option.value === 'RRULE' || option.value === 'CUSTOM') { + @if (option.value === 'CUSTOM') { tune } @else { repeat diff --git a/src/app/features/tasks/add-task-bar/add-task-bar.component.ts b/src/app/features/tasks/add-task-bar/add-task-bar.component.ts index 1e670f9eef..6869e4f722 100644 --- a/src/app/features/tasks/add-task-bar/add-task-bar.component.ts +++ b/src/app/features/tasks/add-task-bar/add-task-bar.component.ts @@ -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) => { diff --git a/src/app/features/worklog/util/map-archive-to-worklog.tz.spec.ts b/src/app/features/worklog/util/map-archive-to-worklog.tz.spec.ts index 93d638fb08..ed7c34472e 100644 --- a/src/app/features/worklog/util/map-archive-to-worklog.tz.spec.ts +++ b/src/app/features/worklog/util/map-archive-to-worklog.tz.spec.ts @@ -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(); diff --git a/src/app/features/worklog/worklog.service.timezone.spec.ts b/src/app/features/worklog/worklog.service.timezone.spec.ts index 3688d2fd9e..9af5d08e3e 100644 --- a/src/app/features/worklog/worklog.service.timezone.spec.ts +++ b/src/app/features/worklog/worklog.service.timezone.spec.ts @@ -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', ); } }); diff --git a/src/app/op-log/validation/data-repair.spec.ts b/src/app/op-log/validation/data-repair.spec.ts index 351465bb39..d796392516 100644 --- a/src/app/op-log/validation/data-repair.spec.ts +++ b/src/app/op-log/validation/data-repair.spec.ts @@ -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([ - { - ...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([ - { - ...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, diff --git a/src/app/op-log/validation/data-repair.ts b/src/app/op-log/validation/data-repair.ts index d673718bb8..c3366bd1a0 100644 --- a/src/app/op-log/validation/data-repair.ts +++ b/src/app/op-log/validation/data-repair.ts @@ -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 diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 1908b54768..fda2b8f676 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -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', diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 4c67a0e060..65ee9e162a 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1941,8 +1941,6 @@ "MONDAY": "Monday", "MONTHLY_MODE_DAY_OF_MONTH": "Day of month", "NOTES": "Default notes", - "ORD_CUSTOM": "custom…", - "ORD_CUSTOM_DESCRIPTION": "Any occurrence number (1–53); negatives count from the end, e.g. -2 = 2nd-to-last", "ORD_FIRST": "first", "ORD_FIRST_NTH": "first", "ORD_FOURTH": "fourth", @@ -1953,21 +1951,13 @@ "ORD_SECOND_NTH": "second", "ORD_THIRD": "third", "ORD_THIRD_NTH": "third", - "Q_BIWEEKLY_CURRENT_WEEKDAY": "Every 2 weeks on {{weekdayStr}}", "Q_CUSTOM": "Custom recurring config", "Q_DAILY": "Every day", - "Q_EVERY_OTHER_DAY": "Every other day", - "Q_EVERY_OTHER_YEAR_CURRENT_DATE": "Every 2 years on the {{dayAndMonthStr}}", "Q_MONDAY_TO_FRIDAY": "Every Monday through Friday", "Q_MONTHLY_CURRENT_DATE": "Every month on the day {{dateDayStr}}", "Q_MONTHLY_FIRST_DAY": "Every month on the first day", "Q_MONTHLY_LAST_DAY": "Every month on the last day", - "Q_MONTHLY_LAST_WEEKDAY": "Every month on the last {{weekdayStr}}", "Q_MONTHLY_NTH_WEEKDAY": "Every month on the {{ordinalStr}} {{weekdayStr}}", - "Q_QUARTERLY_CURRENT_DATE": "Every 3 months on the day {{dateDayStr}}", - "Q_RRULE": "Custom recurring config", - "Q_SEMIANNUALLY_CURRENT_DATE": "Every 6 months on the day {{dateDayStr}}", - "Q_WEEKENDS": "Every weekend (Sat & Sun)", "Q_WEEKLY_CURRENT_WEEKDAY": "Every week on {{weekdayStr}}", "Q_YEARLY_CURRENT_DATE": "Every year on the {{dayAndMonthStr}}", "QUICK_SETTING": "Recurring Config", @@ -1977,113 +1967,6 @@ "SKIP_INSTANCE": "Skip for today", "REPEAT_CYCLE": "Recur cycle", "REPEAT_EVERY": "Recur every", - "RRULE_BYDAY": "On weekdays", - "RRULE_BYDAY_DESCRIPTION": "Pick one or more weekdays. Used by Weekly, by Monthly's 'On selected weekdays', and by Yearly's 'On weekdays within months'. E.g. Mon + Wed = every Monday and Wednesday.", - "RRULE_BYMONTH": "In months", - "RRULE_BYMONTH_DESCRIPTION": "Limit to these months — works with any frequency; leave empty for every month. E.g. daily in Jan–Apr, Mondays only in June, or yearly 'every Saturday, March–November'. For Yearly this also sets which month(s) the date/weekday falls in.", - "RRULE_BYMONTHDAY": "Day of month", - "RRULE_BYMONTHDAY_DESCRIPTION": "Tap the day(s) of the month (1–31, e.g. 15), or Last / 2nd-last / 3rd-last to count from the end. Need others? Type a custom list like 1,15,-5. Note: months without a chosen day are skipped (e.g. 31 never fires in February) — also select 'Last day' to cover shorter months.", - "RRULE_BYMONTHDAY_LIST": "Days of month (list)", - "RRULE_BYMONTHDAY_LIST_DESCRIPTION": "Repeat on several days of the month, or count from the end. Comma-separated; negatives count back from month-end. E.g. 1,15,-1 = the 1st, 15th and last day. Overrides the single 'Day of month' above.", - "RRULE_BYSETPOS": "Set position (BYSETPOS)", - "RRULE_BYSETPOS_DESCRIPTION": "From all the matches in each period, keep only the selected occurrences. E.g. choose Mon–Fri + 'last' = the last weekday of the month; add 'first' to get both. Custom accepts comma-separated numbers; negatives count from the end (-2 = 2nd-to-last).", - "RRULE_BYWEEKNO": "Week numbers (BYWEEKNO)", - "RRULE_BYWEEKNO_DESCRIPTION": "For Yearly only: limit to these ISO week numbers, 1–53. Comma-separated; negatives count from year-end. E.g. 20,21 = weeks 20 and 21 each year.", - "RRULE_BYYEARDAY": "Days of year (BYYEARDAY)", - "RRULE_BYYEARDAY_DESCRIPTION": "For Yearly only: limit to these days of the year, 1–366. Comma-separated; negatives count from year-end. E.g. 1,100,-1 = the 1st, 100th and last day of the year.", - "RRULE_COUNT": "Number of occurrences", - "RRULE_COUNT_DESCRIPTION": "How many times the task is created in total, then it stops. E.g. 10 = ten occurrences.", - "RRULE_END": "Ends", - "RRULE_END_COUNT": "After a number of times", - "RRULE_END_DESCRIPTION": "When the series stops. Never = open-ended. After a number of times = a fixed count (e.g. 10 occurrences). On a date = nothing is created after it.", - "RRULE_END_NEVER": "Never", - "RRULE_END_UNTIL": "On a date", - "RRULE_FREQ": "Frequency", - "RRULE_FREQ_DESCRIPTION": "How often the task repeats: Daily, Weekly, Monthly or Yearly. All the options below adapt to this choice. E.g. Weekly = once a week, Monthly = once a month.", - "RRULE_INTERVAL": "Repeat every", - "RRULE_INTERVAL_DESCRIPTION": "How many of the chosen units between repeats. 1 = every time, 2 = every other, 3 = every third. E.g. Weekly + 2 = every other week; Monthly + 3 = once a quarter.", - "RRULE_ADVANCED": "Advanced options", - "RRULE_ADVANCED_DESCRIPTION": "Optional power-user rule parts: week start, set position, multiple/negative days of the month, week-of-year, day-of-year, and a raw rule override. Leave them blank if you don't need them.", - "RRULE_SCHEDULE_TYPE": "Schedule type", - "RRULE_SCHEDULE_FROM_START": "Fixed dates (from start)", - "RRULE_SCHEDULE_FROM_COMPLETION": "After I complete it", - "RRULE_SCHEDULE_TYPE_DESCRIPTION": "Fixed dates repeats on a set calendar rhythm from the start date; After completion restarts the count when you finish (so 'every 3 days' = 3 days after each completion). It changes only which date the next task lands on — whether the next task waits for you to finish is the separate 'Only create next task after completion' option (Advanced).", - "RRULE_NEXT": "Upcoming", - "RRULE_NEXT_AFTER_COMPLETION": "After completion, e.g.", - "RRULE_NLP_EVERY": "every", - "RRULE_NLP_DAY": "day", - "RRULE_NLP_DAYS": "days", - "RRULE_NLP_WEEK": "week", - "RRULE_NLP_WEEKS": "weeks", - "RRULE_NLP_MONTH": "month", - "RRULE_NLP_MONTHS": "months", - "RRULE_NLP_YEAR": "year", - "RRULE_NLP_YEARS": "years", - "RRULE_NLP_ON": "on", - "RRULE_NLP_ON_THE": "on the", - "RRULE_NLP_THE": "the", - "RRULE_NLP_AND": "and", - "RRULE_NLP_OR": "or", - "RRULE_NLP_FOR": "for", - "RRULE_NLP_TIME": "time", - "RRULE_NLP_TIMES": "times", - "RRULE_NLP_UNTIL": "until", - "RRULE_NLP_WEEKDAY": "weekday", - "RRULE_NLP_WEEKDAYS": "weekdays", - "RRULE_NLP_IN": "in", - "RRULE_NLP_LAST": "last", - "RRULE_NLP_ST": "st", - "RRULE_NLP_ND": "nd", - "RRULE_NLP_RD": "rd", - "RRULE_NLP_TH": "th", - "RRULE_NLP_TO": "to", - "RRULE_BYMONTHDAY_PLACEHOLDER": "e.g. 1,15,-5", - "RRULE_BYSETPOS_PLACEHOLDER": "1,-1", - "RRULE_BYWEEKNO_PLACEHOLDER": "20,21", - "RRULE_BYYEARDAY_PLACEHOLDER": "1,100,-1", - "RRULE_RAW_PLACEHOLDER": "FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1", - "RRULE_FREQ_UNSUPPORTED": "Only daily, weekly, monthly and yearly frequencies are supported — sub-daily rules (hourly, minutely) are not yet.", - "RRULE_INVALID": "This recurrence rule is invalid. Please adjust the options.", - "RRULE_NO_OCCURRENCE": "This recurrence never produces an occurrence. Please check the rule and start date.", - "RRULE_COUNT_WITH_COMPLETION": "\"After N times\" cannot be combined with \"from completion\" — completing re-anchors the schedule, so the count would never finish. Use an until date instead.", - "RRULE_RAW": "Raw RRULE override", - "RRULE_RAW_DESCRIPTION": "Type a complete RFC 5545 RRULE to express anything the options above can't. When filled it replaces all of them. E.g. FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1 = the last weekday of every month. Leave empty to use the options above.", - "RRULE_WKST": "Week starts on", - "RRULE_WKST_DEFAULT": "Default (Monday)", - "RRULE_WKST_DESCRIPTION": "Which day the week starts on. Only matters for Weekly with an interval above 1, where it decides which days count as the same week. Default is Monday (ISO).", - "RRULE_MODE_DAY_OF_MONTH": "On a day of the month", - "RRULE_MODE_LAST_DAY": "On the last day of the month", - "RRULE_MODE_NTH_WEEKDAY": "On an nth weekday", - "RRULE_MODE_WEEKDAYS": "On selected weekdays", - "RRULE_MODE_ON_DATE": "On a specific date", - "RRULE_MODE_ON_WEEKDAYS": "On weekdays within the months", - "RRULE_NTH_WEEKDAY": "Which weekdays", - "RRULE_ADD_NTH": "Add line", - "RRULE_NTH_WEEKDAY_DESCRIPTION": "Each line is an occurrence + one or more weekdays, e.g. 3rd + Monday = the 3rd Monday, or 1st + Mon,Tue = the 1st Monday and the 1st Tuesday. Add lines (+) to combine occurrences — a '3rd Monday' line and a '4th Sunday' line = BYDAY=3MO,4SU. Each line uses a different occurrence and is independent (not 'the 3rd of all of them'; that's the 'On selected weekdays' set instead).", - "RRULE_YEARLY_MODE": "Yearly pattern", - "RRULE_YEARLY_MODE_DESCRIPTION": "How the yearly repeat is anchored. 'On a specific date' = a month and day (e.g. March 15). 'On weekdays within the months' = e.g. every Saturday in March–November (choose the months and the weekdays).", - "RRULE_MONTH_1": "January", - "RRULE_MONTH_2": "February", - "RRULE_MONTH_3": "March", - "RRULE_MONTH_4": "April", - "RRULE_MONTH_5": "May", - "RRULE_MONTH_6": "June", - "RRULE_MONTH_7": "July", - "RRULE_MONTH_8": "August", - "RRULE_MONTH_9": "September", - "RRULE_MONTH_10": "October", - "RRULE_MONTH_11": "November", - "RRULE_MONTH_12": "December", - "RRULE_MONTHLY_MODE": "Monthly pattern", - "RRULE_MONTHLY_MODE_DESCRIPTION": "How the monthly repeat is anchored. 'On a day of the month' = a fixed date (e.g. the 15th). 'On an nth weekday' = e.g. the 2nd Tuesday or last Friday. 'On selected weekdays' = a weekday set such as Mon–Fri (pair it with Set position in Advanced for 'the last weekday of the month'). 'On the last day' = month-end (28–31).", - "RRULE_DAY_2ND_LAST": "2nd-last", - "RRULE_DAY_3RD_LAST": "3rd-last", - "RRULE_DAY_LAST": "Last", - "RRULE_SETPOS": "Which one", - "RRULE_SETPOS_EVERY": "Every", - "RRULE_SETPOS_DESCRIPTION": "Which occurrence of that weekday within the month: First, Second, Third, Fourth or Last. E.g. Last + Friday = the last Friday of each month.", - "RRULE_UNTIL": "End date", - "RRULE_UNTIL_DESCRIPTION": "The last date an occurrence may fall on; nothing is created after it. E.g. 2024-12-31.", "SATURDAY": "Saturday", "SCHEDULE_TYPE_LABEL": "Schedule type", "SKIP_OVERDUE": "Don't let overdue instances pile up", diff --git a/src/karma.conf.js b/src/karma.conf.js index ea4a746664..ce69051502 100644 --- a/src/karma.conf.js +++ b/src/karma.conf.js @@ -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']; diff --git a/src/main.ts b/src/main.ts index 0c5a2e5242..ddb80ce97e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -315,34 +315,10 @@ bootstrapApplication(AppComponent, { // recurring-task tests. Stripped from production via the env guard. if (!environment.production && !environment.stage) { const storeRef = appRef.injector.get(Store); - Promise.all([import('./app/op-log/apply/hydration-state.service')]).then(([m]) => { - // Dev-only manual clock fast-forward for testing recurring / day-change - // behavior without touching the OS clock. Overrides Date.now() (the basis - // for the app's logical "today") by a cumulative day offset, then forces a - // day-change re-sample so addAllDueToday() runs. Reload — or resetClock() — - // to undo. Call from the console: __e2eTestHelpers.jumpDay() / jumpDay(7). - const realNow = Date.now.bind(Date); - let clockOffsetMs = 0; - const reSampleDay = (): void => { - window.dispatchEvent(new Event('focus')); - }; - const jumpDay = (days = 1): string => { - clockOffsetMs += days * 24 * 60 * 60 * 1000; - Date.now = () => realNow() + clockOffsetMs; - reSampleDay(); - return new Date(realNow() + clockOffsetMs).toDateString(); - }; - const resetClock = (): void => { - clockOffsetMs = 0; - Date.now = realNow; - reSampleDay(); - }; - + import('./app/op-log/apply/hydration-state.service').then((m) => { (window as unknown as { __e2eTestHelpers?: unknown }).__e2eTestHelpers = { store: storeRef, hydrationState: appRef.injector.get(m.HydrationStateService), - jumpDay, - resetClock, }; }); }