From 04ab5936ecd8b43fc35db946e2d69874bd170334 Mon Sep 17 00:00:00 2001 From: sovf4717 <1173870168@qq.com> Date: Fri, 16 Oct 2026 14:19:18 +0800 Subject: [PATCH 1/2] feat: add custom recurrence 'end after N completions' condition --- .ce/temp/dedup.js | 32 +++++ docs/releases/unreleased.md | 2 + src/components/RecurrenceContextMenu.ts | 75 +++++++++- src/core/recurrence.ts | 122 ++++++++++++++++- .../task-service/taskRecurringPlanning.ts | 12 +- .../components/recurrenceEndAfterDone.test.ts | 128 ++++++++++++++++++ tests/unit/core/recurrence.test.ts | 105 +++++++++++++- .../services/taskRecurringPlanning.test.ts | 57 +++++++- 8 files changed, 518 insertions(+), 15 deletions(-) create mode 100644 .ce/temp/dedup.js create mode 100644 tests/unit/components/recurrenceEndAfterDone.test.ts diff --git a/.ce/temp/dedup.js b/.ce/temp/dedup.js new file mode 100644 index 000000000..2292a0690 --- /dev/null +++ b/.ce/temp/dedup.js @@ -0,0 +1,32 @@ +const fs = require("fs"); +const p = "src/core/recurrence.ts"; +let c = fs.readFileSync(p, "utf8"); +const eol = c.includes("\r\n") ? "\r\n" : "\n"; +const lines = [ + "function normalizeMinutePrecisionDateInputs(", + "\ttask: T", + "): T {", + "\tconst scheduled = normalizeMinutePrecisionDateTime(task.scheduled);", + "\tconst dateCreated = normalizeMinutePrecisionDateTime(task.dateCreated);", + "", + "\tif (scheduled === task.scheduled && dateCreated === task.dateCreated) {", + "\t\treturn task;", + "\t}", + "", + "\treturn {", + "\t\t...task,", + "\t\tscheduled,", + "\t\tdateCreated,", + "\t};", + "}", +]; +const block = lines.join(eol) + eol; +const doubled = block + block; +const needle = "function normalizeMinutePrecisionDateInputs"; +if (c.includes(doubled)) { + c = c.replace(doubled, block); + fs.writeFileSync(p, c); + console.log("removed dup; copies now:", c.split(needle).length - 1); +} else { + console.log("doubled NOT found; copies:", c.split(needle).length - 1); +} \ No newline at end of file diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 0a687915c..f98a0f858 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -34,6 +34,8 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Added +- Custom recurrence editor now supports an "End after done [n] times" end condition: a recurring task stops advancing after it has been completed a cumulative n times, and the recurrence resumes from the most recent occurrence when instances are un-completed and the total drops below n. + ## Changed ## Removed diff --git a/src/components/RecurrenceContextMenu.ts b/src/components/RecurrenceContextMenu.ts index 05844e6e8..9cf72469d 100644 --- a/src/components/RecurrenceContextMenu.ts +++ b/src/components/RecurrenceContextMenu.ts @@ -31,9 +31,10 @@ export interface CustomRecurrenceRuleInput { byMonthDay: number[]; byMonth: number[]; bySetPos: number | undefined; - endType: "never" | "count" | "until"; + endType: "never" | "count" | "until" | "doneCount"; count: number | undefined; until: string; +doneCount?: number; monthlyType?: string; yearlyType?: string; } @@ -467,6 +468,11 @@ export function buildCustomRecurrenceRule(input: CustomRecurrenceRuleInput): str parts.push(`UNTIL=${input.until.replace(/-/g, "")}`); } break; + case "doneCount": + if (input.doneCount && input.doneCount > 0) { + parts.push(`ENDONDONE=${input.doneCount}`); + } + break; } return parts.join(";"); @@ -597,9 +603,10 @@ class CustomRecurrenceModal extends Modal { private byMonthDay: number[] = []; private byMonth: number[] = []; private bySetPos: number | undefined; // For "first Monday", "last Friday", etc. - private count: number | undefined; +private count: number | undefined; + private doneCount: number | undefined; private until = ""; - private endType: "never" | "count" | "until" = "never"; + private endType: "never" | "count" | "until" | "doneCount" = "never"; private dtstart = ""; private dtstartTime = ""; private recurrenceAnchor: "scheduled" | "completion" = "scheduled"; // NEW: Recurrence anchor @@ -718,7 +725,11 @@ class CustomRecurrenceModal extends Modal { } else { this.until = value; } - this.endType = "until"; +this.endType = "until"; + break; + case "ENDONDONE": + this.doneCount = parseInt(value, 10); + this.endType = "doneCount"; break; } } @@ -1252,6 +1263,47 @@ class CustomRecurrenceModal extends Modal { }, }); +// End after done N times + const doneCountRadio = endConditionContainer.createEl("label", { cls: "radio-option" }); + doneCountRadio.classList.remove( + "tn-static-display-flex-4d51fc62", + "tn-static-display-flex-75816cae", + "tn-static-display-flex-8bb39979", + "tn-static-display-inline-block-60e32dcb", + "tn-static-display-inline-cccfa456", + "tn-static-display-inline-flex-f984c520", + "tn-static-display-none-6b99de8b", + "tn-static-min-height-800px-997b4c8c" + ); + doneCountRadio.classList.add("tn-static-display-block-2a1b75c9"); + doneCountRadio.classList.remove( + "tn-static-font-size-12px-65574819", + "tn-static-font-weight-bold-0fe8c30d", + "tn-static-font-weight-bold-e0b452bd", + "tn-static-margin-bottom-0-75rem-c05a3c6e", + "tn-static-margin-bottom-20px-49f14f8f" + ); + doneCountRadio.classList.add("tn-static-margin-bottom-8px-fdf33f23"); + const doneCountInput = doneCountRadio.createEl("input", { type: "radio", value: "doneCount" }); + doneCountInput.name = "end-type"; + doneCountInput.checked = this.endType === "doneCount"; + doneCountRadio.createSpan({ text: " End after done " }); + const doneCountText = doneCountRadio.createEl("input", { type: "number", placeholder: "5" }); + doneCountText.classList.remove( + "tn-static-width-100-0466783d", + "tn-static-width-12px-fbf353fb", + "tn-static-width-16px-7375d50b", + "tn-static-width-1px-aa77e27e", + "tn-static-width-200px-2acaf3b5", + "tn-static-width-80px-8573bae3" + ); + doneCountText.classList.add("tn-static-width-60px-bd09c419"); + doneCountText.classList.add("tn-static-margin-left-4px-46cec891"); + doneCountText.classList.remove("tn-static-margin-right-8px-539fa9a0"); + doneCountText.classList.add("tn-static-margin-right-4px-c6b76b85"); + doneCountText.value = this.doneCount ? this.doneCount.toString() : ""; + doneCountRadio.createSpan({ text: " times" }); + // Event listeners for end condition neverInput.addEventListener("change", () => { if (neverInput.checked) this.endType = "never"; @@ -1279,6 +1331,18 @@ class CustomRecurrenceModal extends Modal { } }); + doneCountInput.addEventListener("change", () => { + if (doneCountInput.checked) this.endType = "doneCount"; + }); + + doneCountText.addEventListener("input", () => { + this.doneCount = parseInt(doneCountText.value) || undefined; + if (doneCountText.value) { + doneCountInput.checked = true; + this.endType = "doneCount"; + } + }); + this.updateFrequencySpecificVisibility = () => { const useFlexibleInterval = this.recurrenceAnchor === "completion"; byDaySetting.settingEl.style.display = @@ -1392,7 +1456,8 @@ class CustomRecurrenceModal extends Modal { bySetPos: this.bySetPos, endType: this.endType, count: this.count, - until: this.until, +until: this.until, + doneCount: this.doneCount, monthlyType, yearlyType, }); diff --git a/src/core/recurrence.ts b/src/core/recurrence.ts index 3437785ba..0178484a6 100644 --- a/src/core/recurrence.ts +++ b/src/core/recurrence.ts @@ -5,7 +5,12 @@ import { addDTSTARTToRecurrenceRule as addDTSTARTToRecurrenceRuleModel, addDTSTARTToRecurrenceRuleWithDraggedTime as addDTSTARTToRecurrenceRuleWithDraggedTimeModel, getNextUncompletedOccurrence as getNextUncompletedOccurrenceModel, - updateToNextScheduledOccurrence as updateToNextScheduledOccurrenceModel, +updateToNextScheduledOccurrence as updateToNextScheduledOccurrenceModel, + generateRecurringInstances as generateRecurringInstancesModel, + isDueByRRule as isDueByRRuleModel, + shouldShowRecurringTaskOnDate as shouldShowRecurringTaskOnDateModel, + getFiniteRecurringInstanceCount as getFiniteRecurringInstanceCountModel, + getRecurrenceDisplayText as getRecurrenceDisplayTextModel, } from "@tasknotes/model/recurrence"; import { getTodayString } from "../utils/dateUtils"; @@ -16,11 +21,54 @@ type AddDTSTARTWithDraggedTimeTaskInput = Parameters< type RecurringTaskInput = Parameters[0]; type RecurrenceUpdateTaskInput = Parameters[0]; type RecurrenceUpdateResult = ReturnType; +type GenerateInstancesTaskInput = Parameters[0]; +type FiniteInstanceCountTaskInput = Parameters[0]; +type IsDueTaskInput = Parameters[0]; +type ShouldShowTaskInput = Parameters[0]; export interface RecurrenceOccurrenceOptions { minOccurrenceDate?: string; } +/** + * Extract the "end after done N times" cap encoded in a recurrence rule as the + * non-standard ENDONDONE property, or null when the rule carries no such cap. + */ +export function getRecurrenceEndAfterDoneCount(recurrence?: string | null): number | null { +if (!recurrence || typeof recurrence !== "string") return null; + const match = recurrence.match(/(?:^|;)ENDONDONE=(\d+)(?:;|$)/); + if (!match) return null; + const count = Number.parseInt(match[1], 10); + return Number.isFinite(count) && count > 0 ? count : null; +} + +/** + * A rule carrying ENDONDONE is "terminated" once the task has been completed at + * least ENDONDONE times. Termination is derived purely from round-trippable + * fields (the rule + complete_instances), so un-completing instances that drop + * the count back below the cap resumes the recurrence automatically. + */ +export function isRecurrenceTerminated(task: { + recurrence?: string; + complete_instances?: string[]; +}): boolean { + const cap = getRecurrenceEndAfterDoneCount(task.recurrence); + if (cap === null) return false; + return (task.complete_instances?.length ?? 0) >= cap; +} + +/** + * ENDONDONE is not a valid RFC 5545 RRULE part and the underlying rrule parser + * rejects it, so any rule handed to a parse-based model function must have it + * removed first. Rules without ENDONDONE are returned unchanged. + */ +export function stripEndAfterDoneFromRecurrence(recurrence?: string): string | undefined { +if (!recurrence || typeof recurrence !== "string") return recurrence; + const parts = recurrence.split(";").filter((part) => !/^ENDONDONE=\d+$/.test(part)); + const stripped = parts.join(";"); + return stripped === recurrence ? recurrence : stripped || undefined; +} + export function addDTSTARTToRecurrenceRule(task: AddDTSTARTTaskInput): string | null { return addDTSTARTToRecurrenceRuleModel(normalizeMinutePrecisionDateInputs(task)); } @@ -41,7 +89,10 @@ export function getNextUncompletedOccurrence( task: RecurringTaskInput, options?: RecurrenceOccurrenceOptions ): Date | null { - return getNextUncompletedOccurrenceModel(task, { + if (isRecurrenceTerminated(task)) { + return null; + } + return getNextUncompletedOccurrenceModel(stripForModel(task), { today: options?.minOccurrenceDate ?? getTodayString(), }); } @@ -51,9 +102,70 @@ export function updateToNextScheduledOccurrence( maintainDueOffset = true, options?: RecurrenceOccurrenceOptions ): RecurrenceUpdateResult { - return updateToNextScheduledOccurrenceModel(task, maintainDueOffset, { - today: options?.minOccurrenceDate ?? getTodayString(), - }); + if (isRecurrenceTerminated(task)) { + return { scheduled: null, due: null }; + } + return updateToNextScheduledOccurrenceModel( + stripForModel(task), + maintainDueOffset, + { + today: options?.minOccurrenceDate ?? getTodayString(), + } + ); +} +export function generateRecurringInstances( + task: GenerateInstancesTaskInput & { complete_instances?: string[] }, + startDate: Date, + endDate: Date +): Date[] { + if (isRecurrenceTerminated(task)) { + return []; + } + return generateRecurringInstancesModel(stripForModel(task), startDate, endDate); +} + +export function isDueByRRule( + task: IsDueTaskInput, + date: Date +): boolean { + if (isRecurrenceTerminated(task)) { + return false; + } + return isDueByRRuleModel(stripForModel(task), date); +} + +export function shouldShowRecurringTaskOnDate( + task: ShouldShowTaskInput, + targetDate: Date +): boolean { + if (isRecurrenceTerminated(task)) { + return false; + } + return shouldShowRecurringTaskOnDateModel(stripForModel(task), targetDate); +} + +export function getFiniteRecurringInstanceCount( + task: FiniteInstanceCountTaskInput & { complete_instances?: string[] } +): number | null { + const cap = getRecurrenceEndAfterDoneCount(task.recurrence); + if (cap !== null) { + return cap; + } + return getFiniteRecurringInstanceCountModel(stripForModel(task)); +} + +export function getRecurrenceDisplayText(recurrence: string): string { + return getRecurrenceDisplayTextModel(stripEndAfterDoneFromRecurrence(recurrence) ?? ""); +} + +function stripForModel( + task: T +): T { + const stripped = stripEndAfterDoneFromRecurrence(task.recurrence); + if (stripped === task.recurrence) { + return task; + } + return { ...task, recurrence: stripped }; } function normalizeMinutePrecisionDateInputs( diff --git a/src/services/task-service/taskRecurringPlanning.ts b/src/services/task-service/taskRecurringPlanning.ts index 4a4803605..46c99f3bf 100644 --- a/src/services/task-service/taskRecurringPlanning.ts +++ b/src/services/task-service/taskRecurringPlanning.ts @@ -2,6 +2,7 @@ import { addDTSTARTToRecurrenceRule, generateRecurringInstances, getNextUncompletedOccurrence, + getRecurrenceEndAfterDoneCount, updateDTSTARTInRecurrenceRule, updateToNextScheduledOccurrence, } from "../../core/recurrence"; @@ -247,7 +248,12 @@ export function buildRecurringTaskCompletePlan({ updatedTask.skipped_instances = skippedInstances.filter((d) => d !== dateStr); } - if (newComplete && typeof updatedTask.recurrence === "string") { + const endAfterDone = getRecurrenceEndAfterDoneCount(freshTask.recurrence); + const willTerminate = newComplete && + endAfterDone !== null && + (updatedTask.complete_instances?.length ?? 0) >= endAfterDone; + + if (newComplete && !willTerminate && typeof updatedTask.recurrence === "string") { if (recurrenceAnchor === "completion") { const updatedRecurrence = updateDTSTARTInRecurrenceRule( updatedTask.recurrence, @@ -295,10 +301,10 @@ export function buildRecurringTaskCompletePlan({ maintainDueDateOffsetInRecurring, { minOccurrenceDate: floorDate } ); - if (nextDates.scheduled) { + if (!willTerminate && nextDates.scheduled) { updatedTask.scheduled = nextDates.scheduled; } - if (nextDates.due) { + if (!willTerminate && nextDates.due) { updatedTask.due = nextDates.due; } resolveGoogleCalendarRecurringExceptionAfterCurrentInstanceAction( diff --git a/tests/unit/components/recurrenceEndAfterDone.test.ts b/tests/unit/components/recurrenceEndAfterDone.test.ts new file mode 100644 index 000000000..767e6897a --- /dev/null +++ b/tests/unit/components/recurrenceEndAfterDone.test.ts @@ -0,0 +1,128 @@ +import { + buildCustomRecurrenceRule, + type CustomRecurrenceRuleInput, +} from "../../../src/components/RecurrenceContextMenu"; +import { + getRecurrenceEndAfterDoneCount, + isRecurrenceTerminated, + stripEndAfterDoneFromRecurrence, +} from "../../../src/core/recurrence"; + +function buildRule(overrides: Partial): string { + return buildCustomRecurrenceRule({ + frequency: "DAILY", + interval: 1, + dtstart: "2026-05-18", + dtstartTime: "", + recurrenceAnchor: "scheduled", + byDay: [], + byMonthDay: [], + byMonth: [], + bySetPos: undefined, + endType: "never", + count: undefined, + until: "", + doneCount: undefined, + ...overrides, + }); +} + +describe("custom recurrence end-after-done round trip", () => { + it("emits ENDONDONE for the doneCount end type", () => { + const rule = buildRule({ endType: "doneCount", doneCount: 5 }); + + expect(rule).toBe("DTSTART:20260518;FREQ=DAILY;ENDONDONE=5"); + }); + + it("omits ENDONDONE when doneCount is not positive", () => { + expect(buildRule({ endType: "doneCount", doneCount: 0 })).toBe( + "DTSTART:20260518;FREQ=DAILY" + ); + expect(buildRule({ endType: "doneCount", doneCount: undefined })).toBe( + "DTSTART:20260518;FREQ=DAILY" + ); + }); + + it("ignores doneCount for the other end types", () => { + expect(buildRule({ endType: "count", count: 3, doneCount: 5 })).toBe( + "DTSTART:20260518;FREQ=DAILY;COUNT=3" + ); + expect(buildRule({ endType: "until", until: "2026-12-31", doneCount: 5 })).toBe( + "DTSTART:20260518;FREQ=DAILY;UNTIL=20261231" + ); + }); + + describe("getRecurrenceEndAfterDoneCount", () => { + it("extracts the cap wherever it appears in the rule", () => { + expect(getRecurrenceEndAfterDoneCount("FREQ=DAILY;ENDONDONE=3")).toBe(3); + expect(getRecurrenceEndAfterDoneCount("ENDONDONE=3;FREQ=DAILY")).toBe(3); + expect(getRecurrenceEndAfterDoneCount("FREQ=DAILY;INTERVAL=2;ENDONDONE=7")).toBe( + 7 + ); + }); + + it("returns null when no valid cap is present", () => { + expect(getRecurrenceEndAfterDoneCount("FREQ=DAILY")).toBeNull(); + expect(getRecurrenceEndAfterDoneCount(undefined)).toBeNull(); + expect(getRecurrenceEndAfterDoneCount("")).toBeNull(); + expect(getRecurrenceEndAfterDoneCount("FREQ=DAILY;ENDONDONE=0")).toBeNull(); + }); + }); + + describe("isRecurrenceTerminated", () => { + const rule = "FREQ=DAILY;ENDONDONE=3"; + + it("is false below the cap and true at or above it", () => { + expect(isRecurrenceTerminated({ recurrence: rule, complete_instances: [] })).toBe( + false + ); + expect( + isRecurrenceTerminated({ + recurrence: rule, + complete_instances: ["2026-05-18", "2026-05-19"], + }) + ).toBe(false); + expect( + isRecurrenceTerminated({ + recurrence: rule, + complete_instances: ["2026-05-18", "2026-05-19", "2026-05-20"], + }) + ).toBe(true); + expect( + isRecurrenceTerminated({ + recurrence: rule, + complete_instances: ["2026-05-18", "2026-05-19", "2026-05-20", "2026-05-21"], + }) + ).toBe(true); + }); + + it("is always false without a cap", () => { + expect( + isRecurrenceTerminated({ + recurrence: "FREQ=DAILY", + complete_instances: ["2026-05-18", "2026-05-19", "2026-05-20"], + }) + ).toBe(false); + }); + }); + + describe("stripEndAfterDoneFromRecurrence", () => { + it("removes only the ENDONDONE part", () => { + expect(stripEndAfterDoneFromRecurrence("FREQ=DAILY;ENDONDONE=3")).toBe( + "FREQ=DAILY" + ); + expect( + stripEndAfterDoneFromRecurrence("DTSTART:20260518;FREQ=DAILY;ENDONDONE=3") + ).toBe("DTSTART:20260518;FREQ=DAILY"); + }); + + it("returns rules without ENDONDONE unchanged", () => { + expect(stripEndAfterDoneFromRecurrence("FREQ=DAILY")).toBe("FREQ=DAILY"); + expect(stripEndAfterDoneFromRecurrence(undefined)).toBeUndefined(); + }); + + it("returns undefined when stripping empties the rule", () => { + expect(stripEndAfterDoneFromRecurrence("ENDONDONE=3")).toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/core/recurrence.test.ts b/tests/unit/core/recurrence.test.ts index 14c7f3399..7d0ff4c3c 100644 --- a/tests/unit/core/recurrence.test.ts +++ b/tests/unit/core/recurrence.test.ts @@ -1,6 +1,13 @@ import { addDTSTARTToRecurrenceRule, + generateRecurringInstances, + getFiniteRecurringInstanceCount, getNextUncompletedOccurrence, + getRecurrenceEndAfterDoneCount, + isDueByRRule, + isRecurrenceTerminated, + shouldShowRecurringTaskOnDate, + stripEndAfterDoneFromRecurrence, updateToNextScheduledOccurrence, } from "../../../src/core/recurrence"; import type { TaskInfo } from "../../../src/types"; @@ -66,6 +73,102 @@ describe("core/recurrence", () => { scheduled: "2026-03-29", }); - expect(recurrence).toBe("DTSTART:20260329;FREQ=DAILY;INTERVAL=2"); + expect(recurrence).toBe("DTSTART:20260329;FREQ=DAILY;INTERVAL=2"); + }); + + it("exposes the configured ENDONDONE completion cap", () => { + expect(getRecurrenceEndAfterDoneCount("DTSTART:20260301;FREQ=DAILY;ENDONDONE=5")).toBe(5); + expect(getRecurrenceEndAfterDoneCount("FREQ=DAILY")).toBeNull(); + expect(getRecurrenceEndAfterDoneCount("FREQ=DAILY;ENDONDONE=0")).toBeNull(); + expect(getRecurrenceEndAfterDoneCount(null)).toBeNull(); + }); + + it("detects termination from the stored rule and completed instance count", () => { + expect( + isRecurrenceTerminated({ + recurrence: "FREQ=DAILY;ENDONDONE=3", + complete_instances: ["2026-03-01", "2026-03-02", "2026-03-03"], + }) + ).toBe(true); + expect( + isRecurrenceTerminated({ + recurrence: "FREQ=DAILY;ENDONDONE=3", + complete_instances: ["2026-03-01", "2026-03-02"], + }) + ).toBe(false); + expect( + isRecurrenceTerminated({ + recurrence: "FREQ=DAILY", + complete_instances: ["2026-03-01", "2026-03-02", "2026-03-03"], + }) + ).toBe(false); + }); + + it("strips ENDONDONE from the rule before it reaches the recurrence model", () => { + expect(stripEndAfterDoneFromRecurrence("DTSTART:20260301;FREQ=DAILY;ENDONDONE=3")).toBe( + "DTSTART:20260301;FREQ=DAILY" + ); + expect(stripEndAfterDoneFromRecurrence("FREQ=DAILY")).toBe("FREQ=DAILY"); + expect(stripEndAfterDoneFromRecurrence(undefined)).toBeUndefined(); + }); + + it("reports the ENDONDONE cap as the finite instance count", () => { + expect(getFiniteRecurringInstanceCount({ recurrence: "FREQ=DAILY;ENDONDONE=7" })).toBe(7); + }); + + it("stops advancing scheduled/due once the completion cap is reached", () => { + const result = updateToNextScheduledOccurrence({ + title: "Terminating recurring task", + recurrence: "DTSTART:20260301;FREQ=WEEKLY;ENDONDONE=2", + recurrence_anchor: "scheduled", + scheduled: "2026-03-22", + due: "2026-03-24", + complete_instances: ["2026-03-01", "2026-03-08"], + }); + + expect(result).toEqual({ scheduled: null, due: null }); + }); + + it("still advances scheduled/due while below the completion cap", () => { + const result = updateToNextScheduledOccurrence({ + title: "Below-cap recurring task", + recurrence: "DTSTART:20260301;FREQ=WEEKLY;ENDONDONE=5", + recurrence_anchor: "scheduled", + scheduled: "2026-03-22", + due: "2026-03-24", + complete_instances: ["2026-03-01", "2026-03-08"], + }); + + expect(result).toEqual({ + scheduled: "2026-03-29", + due: "2026-03-31", + }); + }); + + it("returns no next occurrence when the completion cap is reached", () => { + const next = getNextUncompletedOccurrence({ + title: "Terminating task", + recurrence: "DTSTART:20260301;FREQ=WEEKLY;ENDONDONE=2", + recurrence_anchor: "scheduled", + scheduled: "2026-03-22", + complete_instances: ["2026-03-01", "2026-03-08"], + }); + + expect(next).toBeNull(); + }); + + it("reports a terminated recurrence as not-due, not-shown, and with no instances", () => { + const task = { + title: "Terminating task", + recurrence: "DTSTART:20260301;FREQ=DAILY;ENDONDONE=2", + recurrence_anchor: "scheduled", + scheduled: "2026-03-29", + complete_instances: ["2026-03-01", "2026-03-08"], + }; + const target = new Date("2026-03-29T00:00:00Z"); + + expect(isDueByRRule(task, target)).toBe(false); + expect(shouldShowRecurringTaskOnDate(task, target)).toBe(false); + expect(generateRecurringInstances(task, target, target)).toEqual([]); }); }); diff --git a/tests/unit/services/taskRecurringPlanning.test.ts b/tests/unit/services/taskRecurringPlanning.test.ts index bfd607a73..1e40d7279 100644 --- a/tests/unit/services/taskRecurringPlanning.test.ts +++ b/tests/unit/services/taskRecurringPlanning.test.ts @@ -91,7 +91,62 @@ describe("taskRecurringPlanning", () => { expect(plan.newComplete).toBe(false); expect(plan.updatedTask.complete_instances).toEqual(["2026-05-18"]); expect(plan.updatedTask.skipped_instances).toEqual([]); - }); + }); + + it("terminates the recurrence when completion reaches the ENDONDONE cap", () => { + const plan = buildRecurringTaskCompletePlan({ + freshTask: createRecurringTask({ + recurrence: "DTSTART:20260501;FREQ=DAILY;ENDONDONE=2", + scheduled: "2026-05-19", + complete_instances: ["2026-05-17"], + }), + targetDate: new Date("2026-05-19T12:00:00.000Z"), + currentTimestamp: "2026-05-19T07:15:00+10:00", + maintainDueDateOffsetInRecurring: true, + }); + + expect(plan.newComplete).toBe(true); + expect(plan.updatedTask.complete_instances).toEqual(["2026-05-17", "2026-05-19"]); + // The rule is retained verbatim and scheduled/due are NOT advanced past the last instance. + expect(plan.updatedTask.recurrence).toBe("DTSTART:20260501;FREQ=DAILY;ENDONDONE=2"); + expect(plan.updatedTask.scheduled).toBe("2026-05-19"); + }); + + it("still advances the schedule while below the ENDONDONE cap", () => { + const plan = buildRecurringTaskCompletePlan({ + freshTask: createRecurringTask({ + recurrence: "DTSTART:20260501;FREQ=DAILY;ENDONDONE=3", + scheduled: "2026-05-19", + complete_instances: ["2026-05-17"], + }), + targetDate: new Date("2026-05-19T12:00:00.000Z"), + currentTimestamp: "2026-05-19T07:15:00+10:00", + maintainDueDateOffsetInRecurring: true, + }); + + expect(plan.newComplete).toBe(true); + expect(plan.updatedTask.complete_instances).toEqual(["2026-05-17", "2026-05-19"]); + expect(plan.updatedTask.scheduled).toBe("2026-05-20"); + }); + + it("resumes the recurrence when an instance is un-completed back below the cap", () => { + const plan = buildRecurringTaskCompletePlan({ + freshTask: createRecurringTask({ + recurrence: "DTSTART:20260501;FREQ=DAILY;ENDONDONE=2", + scheduled: "2026-05-19", + complete_instances: ["2026-05-17", "2026-05-19"], + }), + targetDate: new Date("2026-05-19T12:00:00.000Z"), + currentTimestamp: "2026-05-19T07:15:00+10:00", + maintainDueDateOffsetInRecurring: true, + }); + +expect(plan.newComplete).toBe(false); + expect(plan.updatedTask.complete_instances).toEqual(["2026-05-17"]); + // Below the cap again the recurrence is active, so the model recomputes a concrete + // scheduled date (the un-completed occurrence reopens) instead of short-circuiting. + expect(plan.updatedTask.scheduled).toBe("2026-05-19"); + }); it("updates DTSTART for completion-anchored recurrence plans", () => { const plan = buildRecurringTaskCompletePlan({ From 29ce27f8f81733a63e135331ae26b4e3669c48ae Mon Sep 17 00:00:00 2001 From: sovf4717 <1173870168@qq.com> Date: Fri, 16 Oct 2026 19:18:59 +0800 Subject: [PATCH 2/2] feat: add 'end after completion' setting for default recurring rules --- docs/releases/unreleased.md | 1 + src/i18n/resources/de.ts | 5 ++ src/i18n/resources/en.ts | 5 ++ src/i18n/resources/es.ts | 5 ++ src/i18n/resources/fr.ts | 5 ++ src/i18n/resources/ja.ts | 5 ++ src/i18n/resources/ko.ts | 5 ++ src/i18n/resources/pt.ts | 5 ++ src/i18n/resources/ru.ts | 5 ++ src/i18n/resources/zh.ts | 5 ++ src/services/InstantTaskConvertService.ts | 11 +--- .../task-service/taskCreationDefaults.ts | 22 +++++++- src/settings/defaults.ts | 1 + src/settings/tabs/taskPropertiesTab.ts | 50 ++++++++++++++++- src/types/settings.ts | 1 + .../services/taskCreationDefaults.test.ts | 55 +++++++++++++++++++ 16 files changed, 175 insertions(+), 11 deletions(-) diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index f98a0f858..8c6a27153 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -35,6 +35,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l ## Added - Custom recurrence editor now supports an "End after done [n] times" end condition: a recurring task stops advancing after it has been completed a cumulative n times, and the recurrence resumes from the most recent occurrence when instances are un-completed and the total drops below n. +- Settings: new "Recurrence end after done" default under Task Properties. When a new task uses the default recurrence, it stops repeating after it has been completed the configured number of times (0 = no limit). ## Changed diff --git a/src/i18n/resources/de.ts b/src/i18n/resources/de.ts index 988e4337e..6179d2bdd 100644 --- a/src/i18n/resources/de.ts +++ b/src/i18n/resources/de.ts @@ -1032,6 +1032,11 @@ export const de: TranslationTree = { name: "Wiederholung", description: "Muster für wiederholende Aufgaben (täglich, wöchentlich, monatlich, jährlich oder benutzerdefinierte RRULE). Wenn eine wiederkehrende Aufgabe abgeschlossen wird, wird ihr geplantes Datum automatisch auf das nächste Vorkommen aktualisiert." }, + recurrenceEndAfterDone: { + name: "Wiederholungs-Ende nach Erledigungen", + description: + "Gilt, wenn neue Aufgaben die Standardwiederholung verwenden. Die Aufgabe hört auf wiederholt zu werden, nachdem sie diese Anzahl von Malen erledigt wurde (0 = ohne Limit).", + }, recurrenceAnchor: { name: "Wiederholungsanker", description: "Bestimmt, wie das nächste Vorkommen berechnet wird: 'scheduled' verwendet das geplante Datum, 'completion' verwendet das tatsächliche Abschlussdatum." diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index b858a56ec..8a52e8891 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -1096,6 +1096,11 @@ export const en: TranslationTree = { description: "Pattern for repeating tasks (daily, weekly, monthly, yearly, or custom RRULE). When a recurring task is completed, its scheduled date is automatically updated to the next occurrence.", }, + recurrenceEndAfterDone: { + name: "Recurrence end after done", + description: + "Applies when a new task uses the default recurrence. The task stops repeating after it has been completed this many times (0 = no limit).", + }, recurrenceAnchor: { name: "Recurrence anchor", description: diff --git a/src/i18n/resources/es.ts b/src/i18n/resources/es.ts index 8002c6895..b66ea0112 100644 --- a/src/i18n/resources/es.ts +++ b/src/i18n/resources/es.ts @@ -1032,6 +1032,11 @@ export const es: TranslationTree = { name: "Recurrencia", description: "Patrón para tareas repetitivas (diario, semanal, mensual, anual o RRULE personalizada). Cuando se completa una tarea recurrente, su fecha programada se actualiza automáticamente a la siguiente ocurrencia." }, + recurrenceEndAfterDone: { + name: "Fin de recurrencia tras completados", + description: + "Se aplica cuando una tarea nueva usa la recurrencia predeterminada. Deja de repetirse después de que la tarea se haya completado este número de veces (0 = sin límite).", + }, recurrenceAnchor: { name: "Ancla de recurrencia", description: "Controla cómo se calcula la siguiente ocurrencia: 'scheduled' usa la fecha programada, 'completion' usa la fecha de finalización real." diff --git a/src/i18n/resources/fr.ts b/src/i18n/resources/fr.ts index 4fcd40edc..bf0c27906 100644 --- a/src/i18n/resources/fr.ts +++ b/src/i18n/resources/fr.ts @@ -1032,6 +1032,11 @@ export const fr: TranslationTree = { name: "Récurrence", description: "Modèle pour les tâches répétitives (quotidien, hebdomadaire, mensuel, annuel ou RRULE personnalisée). Quand une tâche récurrente est terminée, sa date planifiée est automatiquement mise à jour à la prochaine occurrence." }, + recurrenceEndAfterDone: { + name: "Fin de la récurrence après exécutions", + description: + "S'applique quand la nouvelle tâche utilise la récurrence par défaut. Elle cesse de se répéter après avoir été complétée ce nombre de fois (0 = pas de limite).", + }, recurrenceAnchor: { name: "Ancre de récurrence", description: "Contrôle le calcul de la prochaine occurrence : 'scheduled' utilise la date planifiée, 'completion' utilise la date de complétion réelle." diff --git a/src/i18n/resources/ja.ts b/src/i18n/resources/ja.ts index 722d58c67..4300127ae 100644 --- a/src/i18n/resources/ja.ts +++ b/src/i18n/resources/ja.ts @@ -1032,6 +1032,11 @@ export const ja: TranslationTree = { name: "繰り返し", description: "繰り返しタスクのパターン(毎日、毎週、毎月、毎年、またはカスタムRRULE)。繰り返しタスクが完了すると、予定日は自動的に次の発生日に更新されます。" }, + recurrenceEndAfterDone: { + name: "完了回数での繰り返し終了", + description: + "新しいタスクが既定の繰り返しを使用する場合に適用します。タスクがこの回数だけ完了すると繰り返しが終了します(0 = 制限なし)。", + }, recurrenceAnchor: { name: "繰り返しアンカー", description: "次の発生日の計算方法を制御します:'scheduled'は予定日を使用、'completion'は実際の完了日を使用します。" diff --git a/src/i18n/resources/ko.ts b/src/i18n/resources/ko.ts index 6b61fb43a..bc8fe142d 100644 --- a/src/i18n/resources/ko.ts +++ b/src/i18n/resources/ko.ts @@ -1032,6 +1032,11 @@ export const ko: TranslationTree = { name: "반복", description: "반복 작업 패턴 (매일, 매주, 매월, 매년 또는 사용자 지정 RRULE). 반복 작업이 완료되면 예정일이 자동으로 다음 발생으로 업데이트됩니다." }, + recurrenceEndAfterDone: { + name: "완료 횟수 후 반복 종료", + description: + "새 작업이 기본 반복을 사용하는 경우에 적용됩니다. 작업이 이 횟수만큼 완료되면 반복이 종료됩니다(0 = 제한 없음).", + }, recurrenceAnchor: { name: "반복 기준", description: "다음 발생 계산 방법을 제어합니다: 'scheduled'는 예정일을, 'completion'은 실제 완료일을 사용합니다." diff --git a/src/i18n/resources/pt.ts b/src/i18n/resources/pt.ts index e33f6d6df..140bda98f 100644 --- a/src/i18n/resources/pt.ts +++ b/src/i18n/resources/pt.ts @@ -1034,6 +1034,11 @@ export const pt: TranslationTree = { name: "Recorrência", description: "Padrão para tarefas repetitivas (diário, semanal, mensal, anual ou RRULE personalizado). Quando uma tarefa recorrente é concluída, sua data agendada é automaticamente atualizada para a próxima ocorrência." }, + recurrenceEndAfterDone: { + name: "Término da recorrência após concluídas", + description: + "Aplica-se quando uma nova tarefa usa a recorrência predefinida. Deixa de repetir-se depois de a tarefa ter sido concluída este número de vezes (0 = sem limite).", + }, recurrenceAnchor: { name: "Âncora de recorrência", description: "Controla como a próxima ocorrência é calculada: 'scheduled' usa a data agendada, 'completion' usa a data de conclusão real." diff --git a/src/i18n/resources/ru.ts b/src/i18n/resources/ru.ts index 09701584e..3f61029cc 100644 --- a/src/i18n/resources/ru.ts +++ b/src/i18n/resources/ru.ts @@ -1032,6 +1032,11 @@ export const ru: TranslationTree = { name: "Повторение", description: "Шаблон для повторяющихся задач (ежедневно, еженедельно, ежемесячно, ежегодно или пользовательское RRULE). При завершении повторяющейся задачи запланированная дата автоматически обновляется на следующее вхождение." }, + recurrenceEndAfterDone: { + name: "Завершение повторения после выполнений", + description: + "Применяется, когда новая задача использует повторение по умолчанию. Повторение завершается после того, как задача будет выполнена указанное число раз (0 = без ограничения).", + }, recurrenceAnchor: { name: "Якорь повторения", description: "Определяет способ вычисления следующего вхождения: 'scheduled' использует запланированную дату, 'completion' использует фактическую дату завершения." diff --git a/src/i18n/resources/zh.ts b/src/i18n/resources/zh.ts index f459586c5..208452ec8 100644 --- a/src/i18n/resources/zh.ts +++ b/src/i18n/resources/zh.ts @@ -1032,6 +1032,11 @@ export const zh: TranslationTree = { name: "重复", description: "重复任务的模式(每日、每周、每月、每年或自定义RRULE)。当重复任务完成时,其安排日期会自动更新到下一次出现。" }, + recurrenceEndAfterDone: { + name: "完成后结束重复", + description: + "新建任务使用默认重复时生效。任务累计完成达到该次数后结束重复(0 = 不限次数)。", + }, recurrenceAnchor: { name: "重复锚点", description: "控制下一次出现的计算方式:'scheduled'使用安排日期,'completion'使用实际完成日期。" diff --git a/src/services/InstantTaskConvertService.ts b/src/services/InstantTaskConvertService.ts index 44f8c7a73..0820cb6a9 100644 --- a/src/services/InstantTaskConvertService.ts +++ b/src/services/InstantTaskConvertService.ts @@ -12,6 +12,7 @@ import { formatDateForStorage, } from "../utils/dateUtils"; import { calculateDefaultDateTime } from "../utils/helpers"; +import { buildDefaultRecurrenceRule } from "./task-service/taskCreationDefaults"; import { StatusManager } from "./StatusManager"; import { PriorityManager } from "./PriorityManager"; import { dispatchTaskUpdate } from "../editor/TaskLinkOverlay"; @@ -722,14 +723,8 @@ export class InstantTaskConvertService { // Use the RRule string directly recurrence = parsedData.recurrence; } else if (defaults.defaultRecurrence && defaults.defaultRecurrence !== "none") { - // Convert default recurrence frequency to rrule string - const freqMap: Record = { - daily: "FREQ=DAILY", - weekly: "FREQ=WEEKLY", - monthly: "FREQ=MONTHLY", - yearly: "FREQ=YEARLY", - }; - recurrence = freqMap[defaults.defaultRecurrence] || undefined; + // Default recurrence rule, including the optional end-after-done cap + recurrence = buildDefaultRecurrenceRule(defaults); } } else { // Minimal behavior: only use parsed data, use "none" for unset values diff --git a/src/services/task-service/taskCreationDefaults.ts b/src/services/task-service/taskCreationDefaults.ts index 5a468a7b1..701ae337d 100644 --- a/src/services/task-service/taskCreationDefaults.ts +++ b/src/services/task-service/taskCreationDefaults.ts @@ -29,6 +29,24 @@ const DEFAULT_RECURRENCE_RULES: Record +): string | undefined { + const base = DEFAULT_RECURRENCE_RULES[defaults.defaultRecurrence]; + if (base === undefined) return undefined; + const endAfterDone = + typeof defaults.defaultEndAfterDone === "number" && defaults.defaultEndAfterDone > 0 + ? Math.floor(defaults.defaultEndAfterDone) + : 0; + return endAfterDone > 0 ? `${base};ENDONDONE=${endAfterDone}` : base; +} + export function applyTaskCreationDefaults( taskData: TaskCreationData, context: TaskCreationDefaultsContext, @@ -74,8 +92,8 @@ export function applyTaskCreationDefaults( result.timeEstimate = defaults.defaultTimeEstimate; } - if (!result.recurrence && defaults.defaultRecurrence !== "none") { - result.recurrence = DEFAULT_RECURRENCE_RULES[defaults.defaultRecurrence]; + if (!result.recurrence) { + result.recurrence = buildDefaultRecurrenceRule(defaults); } if ( diff --git a/src/settings/defaults.ts b/src/settings/defaults.ts index 1ea5a3ed0..8870afd95 100644 --- a/src/settings/defaults.ts +++ b/src/settings/defaults.ts @@ -114,6 +114,7 @@ export const DEFAULT_TASK_CREATION_DEFAULTS: TaskCreationDefaults = { inheritParentTaskProperties: false, defaultTimeEstimate: 0, defaultRecurrence: "none", + defaultEndAfterDone: 0, defaultDueDate: "none", defaultDueTime: "none", defaultScheduledDate: "today", diff --git a/src/settings/tabs/taskPropertiesTab.ts b/src/settings/tabs/taskPropertiesTab.ts index fb34b3ec3..2516e7ec7 100644 --- a/src/settings/tabs/taskPropertiesTab.ts +++ b/src/settings/tabs/taskPropertiesTab.ts @@ -5,7 +5,7 @@ import { createSectionHeader, createHelpText, } from "../components/settingHelpers"; -import { createCardInput } from "../components/CardComponent"; +import { createCard, createCardInput, createCardNumberInput } from "../components/CardComponent"; // Import property card modules import { @@ -18,6 +18,7 @@ import { renderUserFieldsSection, renderSimplePropertyCard, renderMetadataPropertyCard, + createPropertyDescription, } from "./taskProperties"; /** @@ -191,6 +192,53 @@ export function renderTaskPropertiesTab( }, }); + // Recurrence End-After-Done Default Card + const endAfterDoneInput = createCardNumberInput( + 0, + undefined, + 1, + plugin.settings.taskCreationDefaults.defaultEndAfterDone || 0 + ); + endAfterDoneInput.addEventListener("change", () => { + const value = parseInt(endAfterDoneInput.value, 10); + plugin.settings.taskCreationDefaults.defaultEndAfterDone = + Number.isFinite(value) && value > 0 ? value : 0; + save(); + }); + createCard(container, { + id: "property-defaultEndAfterDone", + collapsible: true, + defaultCollapsed: true, + header: { + primaryText: translate( + "settings.taskProperties.properties.recurrenceEndAfterDone.name" + ), + }, + content: { + sections: [ + { + rows: [ + { + label: "", + input: createPropertyDescription( + translate( + "settings.taskProperties.properties.recurrenceEndAfterDone.description" + ) + ), + fullWidth: true, + }, + { + label: translate("settings.taskProperties.propertyCard.default"), + input: endAfterDoneInput, + }, + ], + }, + ], + }, + }); + + // Recurrence Anchor Property Card + // Recurrence Anchor Property Card renderMetadataPropertyCard(container, plugin, save, translate, "recurrenceAnchor", translate("settings.taskProperties.properties.recurrenceAnchor.name"), diff --git a/src/types/settings.ts b/src/types/settings.ts index a74274cd1..adc465a33 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -288,6 +288,7 @@ export interface TaskCreationDefaults { inheritParentTaskProperties: boolean; // Copy parent task projects, contexts, priority, and tags when creating subtasks defaultTimeEstimate: number; // minutes, 0 = no default defaultRecurrence: "none" | "daily" | "weekly" | "monthly" | "yearly"; + defaultEndAfterDone: number; // Stop recurring after this many completions, 0 = no limit // Date defaults defaultDueDate: "none" | "today" | "tomorrow" | "next-week"; defaultDueTime: DefaultTaskTime; diff --git a/tests/unit/services/taskCreationDefaults.test.ts b/tests/unit/services/taskCreationDefaults.test.ts index 9bf3f5453..e22853daf 100644 --- a/tests/unit/services/taskCreationDefaults.test.ts +++ b/tests/unit/services/taskCreationDefaults.test.ts @@ -1,6 +1,7 @@ import { DEFAULT_TASK_CREATION_DEFAULTS } from "../../../src/settings/defaults"; import { applyTaskCreationDefaults, + buildDefaultRecurrenceRule, type TaskCreationDefaultAdapters, type TaskCreationDefaultsContext, } from "../../../src/services/task-service/taskCreationDefaults"; @@ -154,4 +155,58 @@ describe("task creation defaults", () => { topic: "manual", }); }); + it("appends ENDONDONE to the default recurrence rule when defaultEndAfterDone is set", () => { + const result = applyTaskCreationDefaults( + { title: "Capped default recurrence" }, + createContext({ + defaultRecurrence: "weekly", + defaultEndAfterDone: 3, + }) + ); + + expect(result.recurrence).toBe("FREQ=WEEKLY;ENDONDONE=3"); + }); + + it("leaves the default recurrence rule uncapped when defaultEndAfterDone is zero", () => { + const result = applyTaskCreationDefaults( + { title: "Uncapped default recurrence" }, + createContext({ + defaultRecurrence: "daily", + }) + ); + + expect(result.recurrence).toBe("FREQ=DAILY"); + }); + + it("never overwrites an explicit recurrence value", () => { + const result = applyTaskCreationDefaults( + { title: "Explicit rule", recurrence: "FREQ=MONTHLY" }, + createContext({ + defaultRecurrence: "weekly", + defaultEndAfterDone: 5, + }) + ); + + expect(result.recurrence).toBe("FREQ=MONTHLY"); + }); + + describe("buildDefaultRecurrenceRule", () => { + it("returns undefined for no default recurrence", () => { + expect( + buildDefaultRecurrenceRule({ defaultRecurrence: "none", defaultEndAfterDone: 3 }) + ).toBeUndefined(); + }); + + it("returns the base rule when defaultEndAfterDone is zero", () => { + expect( + buildDefaultRecurrenceRule({ defaultRecurrence: "monthly", defaultEndAfterDone: 0 }) + ).toBe("FREQ=MONTHLY"); + }); + + it("appends ENDONDONE for a positive cap", () => { + expect( + buildDefaultRecurrenceRule({ defaultRecurrence: "yearly", defaultEndAfterDone: 2 }) + ).toBe("FREQ=YEARLY;ENDONDONE=2"); + }); + }); });