Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .ce/temp/dedup.js
Original file line number Diff line number Diff line change
@@ -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<T extends { scheduled?: string; dateCreated?: string }>(",
"\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);
}
3 changes: 3 additions & 0 deletions docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ 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

## Removed
Expand Down
75 changes: 70 additions & 5 deletions src/components/RecurrenceContextMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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(";");
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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,
});
Expand Down
122 changes: 117 additions & 5 deletions src/core/recurrence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -16,11 +21,54 @@ type AddDTSTARTWithDraggedTimeTaskInput = Parameters<
type RecurringTaskInput = Parameters<typeof getNextUncompletedOccurrenceModel>[0];
type RecurrenceUpdateTaskInput = Parameters<typeof updateToNextScheduledOccurrenceModel>[0];
type RecurrenceUpdateResult = ReturnType<typeof updateToNextScheduledOccurrenceModel>;
type GenerateInstancesTaskInput = Parameters<typeof generateRecurringInstancesModel>[0];
type FiniteInstanceCountTaskInput = Parameters<typeof getFiniteRecurringInstanceCountModel>[0];
type IsDueTaskInput = Parameters<typeof isDueByRRuleModel>[0];
type ShouldShowTaskInput = Parameters<typeof shouldShowRecurringTaskOnDateModel>[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));
}
Expand All @@ -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(),
});
}
Expand All @@ -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<T extends { recurrence?: string; complete_instances?: string[] }>(
task: T
): T {
const stripped = stripEndAfterDoneFromRecurrence(task.recurrence);
if (stripped === task.recurrence) {
return task;
}
return { ...task, recurrence: stripped };
}

function normalizeMinutePrecisionDateInputs<T extends { scheduled?: string; dateCreated?: string }>(
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/resources/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/resources/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/resources/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/resources/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/resources/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,11 @@ export const ja: TranslationTree = {
name: "繰り返し",
description: "繰り返しタスクのパターン(毎日、毎週、毎月、毎年、またはカスタムRRULE)。繰り返しタスクが完了すると、予定日は自動的に次の発生日に更新されます。"
},
recurrenceEndAfterDone: {
name: "完了回数での繰り返し終了",
description:
"新しいタスクが既定の繰り返しを使用する場合に適用します。タスクがこの回数だけ完了すると繰り返しが終了します(0 = 制限なし)。",
},
recurrenceAnchor: {
name: "繰り返しアンカー",
description: "次の発生日の計算方法を制御します:'scheduled'は予定日を使用、'completion'は実際の完了日を使用します。"
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/resources/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,11 @@ export const ko: TranslationTree = {
name: "반복",
description: "반복 작업 패턴 (매일, 매주, 매월, 매년 또는 사용자 지정 RRULE). 반복 작업이 완료되면 예정일이 자동으로 다음 발생으로 업데이트됩니다."
},
recurrenceEndAfterDone: {
name: "완료 횟수 후 반복 종료",
description:
"새 작업이 기본 반복을 사용하는 경우에 적용됩니다. 작업이 이 횟수만큼 완료되면 반복이 종료됩니다(0 = 제한 없음).",
},
recurrenceAnchor: {
name: "반복 기준",
description: "다음 발생 계산 방법을 제어합니다: 'scheduled'는 예정일을, 'completion'은 실제 완료일을 사용합니다."
Expand Down
Loading