From 6bc68f592f5da7ac7247950c4db221af19b932e5 Mon Sep 17 00:00:00 2001 From: Darren Gillman Date: Tue, 12 May 2026 23:57:19 +0100 Subject: [PATCH 1/5] Add support for concrete repeat extensions, using the @repeat(interval, YYY-MM-DD) format The `generate repeats` action ignores the scheduled date or calendar note date, and repeats based on the YYYY-MM-DD concrete date Repeats with the relative (`+`) repeat interval modifier or with no concrete date fall through to the original behaviour Initial tests added around basic functionality. --- helpers/NPExtendedRepeat.js | 53 +++++++++++++++- .../__tests__/repeatHelpers.test.js | 62 +++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/helpers/NPExtendedRepeat.js b/helpers/NPExtendedRepeat.js index 436798c17..1e3cffb2e 100644 --- a/helpers/NPExtendedRepeat.js +++ b/helpers/NPExtendedRepeat.js @@ -41,7 +41,7 @@ const LOG_CONTEXT = 'extendedRepeat' //------------------------------------------------------------------ // Regexes + config type + settings + date math -const EXTENDED_REPEAT_STR: string = `@repeat\\(${RE_DATE_INTERVAL}\\)` // find @repeat() +const EXTENDED_REPEAT_STR: string = `@repeat\\(${RE_DATE_INTERVAL}(?:,\\s*${RE_ISO_DATE})?\\)` // find @repeat() export const RE_EXTENDED_REPEAT: RegExp = new RegExp(EXTENDED_REPEAT_STR) // find @repeat() const EXTENDED_REPEAT_CAPTURE_STR: string = `@repeat\\((.*?)\\)` // find @repeat() and return part inside brackets export const RE_EXTENDED_REPEAT_CAPTURE: RegExp = new RegExp(EXTENDED_REPEAT_CAPTURE_STR) // find @repeat() and return part inside brackets @@ -88,6 +88,18 @@ export function generateNewRepeatDate(noteToUse: CoreNoteFields, currentContent: const reRepeatArray = currentContent.match(RE_EXTENDED_REPEAT_CAPTURE) ?? [] let dateIntervalString: string = reRepeatArray.length > 0 ? reRepeatArray[1] : '' + let concreteBaseDate: string = '' + if (dateIntervalString.includes(',')) { + const [intervalPart, datePart] = dateIntervalString.split(',').map((s) => s.trim()) + // Only accept the split if the interval part is a valid interval (guards against malformed @repeat tags) + if (new RegExp(`^${RE_DATE_INTERVAL}$`).test(intervalPart)) { + dateIntervalString = intervalPart + if (datePart && new RegExp(RE_ISO_DATE).test(datePart)) { + concreteBaseDate = datePart + } + } + } + let outputTimeframe = 'day' if (currentContent.match(RE_SCHEDULED_DAILY_NOTE_LINK) || isDailyNote(noteToUse)) { outputTimeframe = 'day' @@ -110,6 +122,18 @@ export function generateNewRepeatDate(noteToUse: CoreNoteFields, currentContent: return completedDate } + if (concreteBaseDate.length > 0) { + if (dateIntervalString.startsWith('+')) { + // + prefix takes precedence: concrete date is immaterial, fall through to completion-date branch + logWarn('generateNewRepeatDate', `- concrete base date ${concreteBaseDate} ignored because + prefix takes precedence; scheduling from completion date`) + } else { + // No + prefix: concrete date wins over scheduled/completion date + newRepeatDateStr = calcOffsetDateStr(concreteBaseDate, dateIntervalString, outputTimeframe) + logDebug('generateNewRepeatDate', `- adding from concrete base date ${concreteBaseDate} -> ${newRepeatDateStr}`) + return newRepeatDateStr + } + } + if (dateIntervalString.startsWith('+')) { dateIntervalString = dateIntervalString.substring(1, dateIntervalString.length) newRepeatDateStr = calcOffsetDateStr(completedDate, dateIntervalString, outputTimeframe) @@ -218,6 +242,21 @@ export async function generateRepeatForPara( newRepeatContent = newRepeatContent.replace(new RegExp(RE_DONE_DATE_OPT_TIME.source, 'gi'), '') newRepeatContent = stripTaskMarkersFromString(newRepeatContent) newRepeatContent = textWithoutSyncedCopyTag(newRepeatContent).trim() + + // If the @repeat tag contains a concrete base date, advance it by the same interval so future + // completions continue to calculate from the correct anchor (as a daily ISO date, independent + // of the outputTimeframe used for the scheduled >date marker). + const capturedRepeat = origPara.content.match(RE_EXTENDED_REPEAT_CAPTURE) + if (capturedRepeat && capturedRepeat[1].includes(',')) { + const [intervalOnly, oldConcreteDate] = capturedRepeat[1].split(',').map((s) => s.trim()) + if (oldConcreteDate && new RegExp(RE_ISO_DATE).test(oldConcreteDate) && new RegExp(`^${RE_DATE_INTERVAL}$`).test(intervalOnly.replace(/^\+/, ''))) { + const intervalForCalc = intervalOnly.startsWith('+') ? intervalOnly.substring(1) : intervalOnly + const newConcreteDate = calcOffsetDateStr(oldConcreteDate, intervalForCalc, 'day') + newRepeatContent = newRepeatContent.replace(`@repeat(${capturedRepeat[1]})`, `@repeat(${intervalOnly}, ${newConcreteDate})`) + logDebug('generateRepeatForPara', `- advanced concrete base date from ${oldConcreteDate} to ${newConcreteDate}`) + } + } + logDebug('generateRepeatForPara', `- newRepeatContent: "${newRepeatContent}"`) const insertInOpenEditor = @@ -350,6 +389,18 @@ export async function generateRepeatForCancelledPara( .replace(/^\s*?\+\s\[+\]\s/, '') newRepeatContent = textWithoutSyncedCopyTag(newRepeatContent).trim() + // Advance the concrete base date in @repeat(...) if present + const capturedRepeat = origPara.content.match(RE_EXTENDED_REPEAT_CAPTURE) + if (capturedRepeat && capturedRepeat[1].includes(',')) { + const [intervalOnly, oldConcreteDate] = capturedRepeat[1].split(',').map((s) => s.trim()) + if (oldConcreteDate && new RegExp(RE_ISO_DATE).test(oldConcreteDate) && new RegExp(`^${RE_DATE_INTERVAL}$`).test(intervalOnly.replace(/^\+/, ''))) { + const intervalForCalc = intervalOnly.startsWith('+') ? intervalOnly.substring(1) : intervalOnly + const newConcreteDate = calcOffsetDateStr(oldConcreteDate, intervalForCalc, 'day') + newRepeatContent = newRepeatContent.replace(`@repeat(${capturedRepeat[1]})`, `@repeat(${intervalOnly}, ${newConcreteDate})`) + logDebug('generateRepeatForCancelledPara', `- advanced concrete base date from ${oldConcreteDate} to ${newConcreteDate}`) + } + } + let newPara: TParagraph if (noteIsOpenInEditor) { await Editor.insertParagraphBeforeParagraph(`${newRepeatContent} >${newRepeatDateStr}`, origPara, 'open') diff --git a/jgclark.RepeatExtensions/__tests__/repeatHelpers.test.js b/jgclark.RepeatExtensions/__tests__/repeatHelpers.test.js index 7f10eada1..013ff8a0b 100644 --- a/jgclark.RepeatExtensions/__tests__/repeatHelpers.test.js +++ b/jgclark.RepeatExtensions/__tests__/repeatHelpers.test.js @@ -55,4 +55,66 @@ describe('generateNewRepeatDate', () => { expect(result).toBe('2024-W47') }) }) + + describe('tests with concrete base date', () => { + test('@repeat(1m, 2024-01-15) completed late still gives 2024-02-15', () => { + const mockNote = { date: moment('2024-01-14').toDate(), type: 'Calendar', filename: '20240114.md' } + const currentContent = 'do expenses @repeat(1m, 2024-01-15)' + const completedDate = '2024-01-20' + const result = generateNewRepeatDate(mockNote, currentContent, completedDate) + expect(result).toBe('2024-02-15') + }) + + test('@repeat(1w, 2024-01-15) completed late still gives 2024-01-22', () => { + const mockNote = { date: moment('2024-01-10').toDate(), type: 'Calendar', filename: '20240110.md' } + const currentContent = 'task @repeat(1w, 2024-01-15)' + const completedDate = '2024-01-25' + const result = generateNewRepeatDate(mockNote, currentContent, completedDate) + expect(result).toBe('2024-01-22') + }) + + test('@repeat(+1m, 2024-01-15) — + prefix overrides concrete date, gives 2024-02-25 (from completion) not 2024-02-15', () => { + const mockNote = { date: moment('2024-01-14').toDate(), type: 'Calendar', filename: '20240114.md' } + const currentContent = 'do expenses @repeat(+1m, 2024-01-15)' + const completedDate = '2024-01-25' + const result = generateNewRepeatDate(mockNote, currentContent, completedDate) + expect(result).toBe('2024-02-25') + }) + + test('@repeat(1y, 2024-03-01) gives 2025-03-01', () => { + const mockNote = { date: moment('2024-03-01').toDate(), type: 'Calendar', filename: '20240301.md' } + const currentContent = 'annual review @repeat(1y, 2024-03-01)' + const completedDate = '2024-03-10' + const result = generateNewRepeatDate(mockNote, currentContent, completedDate) + expect(result).toBe('2025-03-01') + }) + + test('@repeat(+1d) without concrete date still uses completion date (unchanged behaviour)', () => { + const mockNote = { date: moment('2024-06-14').toDate(), type: 'Calendar', filename: '20240614.md' } + const currentContent = 'text @repeat(+1d)' + const completedDate = '2024-06-16' + const result = generateNewRepeatDate(mockNote, currentContent, completedDate) + expect(result).toBe('2024-06-17') + }) + + test('@repeat(1m) with scheduled date still uses scheduled date (unchanged behaviour)', () => { + const mockNote = { date: moment('2024-01-10').toDate(), type: 'Calendar', filename: '20240110.md' } + const currentContent = 'do expenses @repeat(1m) >2024-01-15' + const completedDate = '2024-01-20' + const result = generateNewRepeatDate(mockNote, currentContent, completedDate) + expect(result).toBe('2024-02-15') + }) + + test('malformed @repeat(foo, 2024-01-15) falls through to existing logic gracefully', () => { + const mockNote = { date: moment('2024-01-14').toDate(), type: 'Calendar', filename: '20240114.md' } + const currentContent = 'task @repeat(foo, 2024-01-15)' + const completedDate = '2024-01-20' + // Invalid interval — RE_EXTENDED_REPEAT won't match, so RE_EXTENDED_REPEAT_CAPTURE captures + // the raw "foo, 2024-01-15". The interval validation guard skips the split, leaving + // dateIntervalString as the full string. length > 0 but not a valid +/plain interval, + // so calcOffsetDateStr returns completedDate as a graceful fallback. + const result = generateNewRepeatDate(mockNote, currentContent, completedDate) + expect(typeof result).toBe('string') + }) + }) }) From 75c788825ce8d3d11791793c105897bd68a6add4 Mon Sep 17 00:00:00 2001 From: Darren Gillman Date: Sat, 13 Jun 2026 19:52:01 +0100 Subject: [PATCH 2/5] Extend concrete @repeat(...) base date to week/month/quarter/year specs Generalizes the concrete base date in @repeat(interval, ) to accept any NotePlan calendar-note date-spec (YYYY-Wnn, YYYY-MM, YYYY-Qn, YYYY) in addition to YYYY-MM-DD, mirroring the existing non-concrete behaviour. Advancing the concrete date in regenerated repeats now preserves its original format (e.g. 2026-W19 -> 2026-W20), including across 52/53-week year boundaries. Co-Authored-By: Claude Sonnet 4.6 --- helpers/NPExtendedRepeat.js | 19 ++- ...tendedRepeat.generateRepeatForPara.test.js | 96 +++++++++++- .../__tests__/repeatHelpers.test.js | 140 ++++++++++++++++++ 3 files changed, 248 insertions(+), 7 deletions(-) diff --git a/helpers/NPExtendedRepeat.js b/helpers/NPExtendedRepeat.js index 1e3cffb2e..405a0dbd7 100644 --- a/helpers/NPExtendedRepeat.js +++ b/helpers/NPExtendedRepeat.js @@ -27,6 +27,10 @@ import { RE_DONE_DATE_TIME_CAPTURES, RE_DONE_DATE_OPT_TIME, RE_ISO_DATE, + RE_NP_WEEK_SPEC, + RE_NP_MONTH_SPEC, + RE_NP_QUARTER_SPEC, + RE_NP_YEAR_SPEC, } from '@helpers/dateTime' import { JSP, logDebug, logError, logInfo, logWarn } from '@helpers/dev' import { calcOffsetDateStr, getFirstDateInPeriod } from '@helpers/NPdateTime' @@ -41,7 +45,10 @@ const LOG_CONTEXT = 'extendedRepeat' //------------------------------------------------------------------ // Regexes + config type + settings + date math -const EXTENDED_REPEAT_STR: string = `@repeat\\(${RE_DATE_INTERVAL}(?:,\\s*${RE_ISO_DATE})?\\)` // find @repeat() +// A concrete @repeat(...) base date can be given in any NP calendar-note date-spec: day, week, month, quarter or year +const RE_CONCRETE_REPEAT_DATE: string = `(?:${RE_ISO_DATE}|${RE_NP_WEEK_SPEC}|${RE_NP_MONTH_SPEC}|${RE_NP_QUARTER_SPEC}|${RE_NP_YEAR_SPEC})` +const RE_CONCRETE_REPEAT_DATE_FULL: RegExp = new RegExp(`^${RE_CONCRETE_REPEAT_DATE}$`) // validate a standalone date-spec string +const EXTENDED_REPEAT_STR: string = `@repeat\\(${RE_DATE_INTERVAL}(?:,\\s*${RE_CONCRETE_REPEAT_DATE})?\\)` // find @repeat() export const RE_EXTENDED_REPEAT: RegExp = new RegExp(EXTENDED_REPEAT_STR) // find @repeat() const EXTENDED_REPEAT_CAPTURE_STR: string = `@repeat\\((.*?)\\)` // find @repeat() and return part inside brackets export const RE_EXTENDED_REPEAT_CAPTURE: RegExp = new RegExp(EXTENDED_REPEAT_CAPTURE_STR) // find @repeat() and return part inside brackets @@ -94,7 +101,7 @@ export function generateNewRepeatDate(noteToUse: CoreNoteFields, currentContent: // Only accept the split if the interval part is a valid interval (guards against malformed @repeat tags) if (new RegExp(`^${RE_DATE_INTERVAL}$`).test(intervalPart)) { dateIntervalString = intervalPart - if (datePart && new RegExp(RE_ISO_DATE).test(datePart)) { + if (datePart && RE_CONCRETE_REPEAT_DATE_FULL.test(datePart)) { concreteBaseDate = datePart } } @@ -249,9 +256,9 @@ export async function generateRepeatForPara( const capturedRepeat = origPara.content.match(RE_EXTENDED_REPEAT_CAPTURE) if (capturedRepeat && capturedRepeat[1].includes(',')) { const [intervalOnly, oldConcreteDate] = capturedRepeat[1].split(',').map((s) => s.trim()) - if (oldConcreteDate && new RegExp(RE_ISO_DATE).test(oldConcreteDate) && new RegExp(`^${RE_DATE_INTERVAL}$`).test(intervalOnly.replace(/^\+/, ''))) { + if (oldConcreteDate && RE_CONCRETE_REPEAT_DATE_FULL.test(oldConcreteDate) && new RegExp(`^${RE_DATE_INTERVAL}$`).test(intervalOnly.replace(/^\+/, ''))) { const intervalForCalc = intervalOnly.startsWith('+') ? intervalOnly.substring(1) : intervalOnly - const newConcreteDate = calcOffsetDateStr(oldConcreteDate, intervalForCalc, 'day') + const newConcreteDate = calcOffsetDateStr(oldConcreteDate, intervalForCalc, 'base') newRepeatContent = newRepeatContent.replace(`@repeat(${capturedRepeat[1]})`, `@repeat(${intervalOnly}, ${newConcreteDate})`) logDebug('generateRepeatForPara', `- advanced concrete base date from ${oldConcreteDate} to ${newConcreteDate}`) } @@ -393,9 +400,9 @@ export async function generateRepeatForCancelledPara( const capturedRepeat = origPara.content.match(RE_EXTENDED_REPEAT_CAPTURE) if (capturedRepeat && capturedRepeat[1].includes(',')) { const [intervalOnly, oldConcreteDate] = capturedRepeat[1].split(',').map((s) => s.trim()) - if (oldConcreteDate && new RegExp(RE_ISO_DATE).test(oldConcreteDate) && new RegExp(`^${RE_DATE_INTERVAL}$`).test(intervalOnly.replace(/^\+/, ''))) { + if (oldConcreteDate && RE_CONCRETE_REPEAT_DATE_FULL.test(oldConcreteDate) && new RegExp(`^${RE_DATE_INTERVAL}$`).test(intervalOnly.replace(/^\+/, ''))) { const intervalForCalc = intervalOnly.startsWith('+') ? intervalOnly.substring(1) : intervalOnly - const newConcreteDate = calcOffsetDateStr(oldConcreteDate, intervalForCalc, 'day') + const newConcreteDate = calcOffsetDateStr(oldConcreteDate, intervalForCalc, 'base') newRepeatContent = newRepeatContent.replace(`@repeat(${capturedRepeat[1]})`, `@repeat(${intervalOnly}, ${newConcreteDate})`) logDebug('generateRepeatForCancelledPara', `- advanced concrete base date from ${oldConcreteDate} to ${newConcreteDate}`) } diff --git a/helpers/__tests__/NPExtendedRepeat.generateRepeatForPara.test.js b/helpers/__tests__/NPExtendedRepeat.generateRepeatForPara.test.js index 89975d018..d3bd096f0 100644 --- a/helpers/__tests__/NPExtendedRepeat.generateRepeatForPara.test.js +++ b/helpers/__tests__/NPExtendedRepeat.generateRepeatForPara.test.js @@ -1,7 +1,7 @@ /* global describe, expect, test, beforeAll, beforeEach, jest */ import moment from 'moment' import { DataStore, Editor, CommandBar, NotePlan, Note, Paragraph } from '@mocks/index' -import { generateRepeatForPara } from '../NPExtendedRepeat' +import { generateRepeatForPara, generateRepeatForCancelledPara } from '../NPExtendedRepeat' const repeatConfig = { deleteCompletedRepeat: false, @@ -233,3 +233,97 @@ describe('NPExtendedRepeat generateRepeatForPara', () => { expect(Editor.save).not.toHaveBeenCalled() }) }) + +describe('NPExtendedRepeat generateRepeatForPara: concrete @repeat(...) base date is advanced in the new repeat', () => { + async function expectAdvancedConcreteDate(repeatTag: string, expectedRepeatTag: string) { + const taskLine = `* [x] Task @done(2026-05-20 09:00 AM) @repeat(${repeatTag})` + const origPara = new Paragraph({ + type: 'done', + content: taskLine.slice(5), + lineIndex: 1, + rawContent: taskLine, + }) + const origNote = new Note({ + type: 'Notes', + filename: 'Projects/chores.md', + paragraphs: [ + new Paragraph({ type: 'title', content: 'Chores', headingLevel: 1, lineIndex: 0 }), + origPara, + ], + }) + origPara.note = origNote + origNote.resetLineIndexesAndContent() + + const newPara = await generateRepeatForPara(origPara, origNote, repeatConfig, false) + expect(newPara).not.toBeNull() + const inserted = origNote.paragraphs[1] + expect(inserted.content).toContain(`@repeat(${expectedRepeatTag})`) + } + + test('day-spec: @repeat(1m, 2024-01-15) -> @repeat(1m, 2024-02-15)', async () => { + await expectAdvancedConcreteDate('1m, 2024-01-15', '1m, 2024-02-15') + }) + + test('week-spec: @repeat(1w, 2026-W19) -> @repeat(1w, 2026-W20)', async () => { + await expectAdvancedConcreteDate('1w, 2026-W19', '1w, 2026-W20') + }) + + test('month-spec: @repeat(1m, 2026-05) -> @repeat(1m, 2026-06)', async () => { + await expectAdvancedConcreteDate('1m, 2026-05', '1m, 2026-06') + }) + + test('quarter-spec: @repeat(1q, 2026-Q2) -> @repeat(1q, 2026-Q3)', async () => { + await expectAdvancedConcreteDate('1q, 2026-Q2', '1q, 2026-Q3') + }) + + test('year-spec: @repeat(1y, 2026) -> @repeat(1y, 2027)', async () => { + await expectAdvancedConcreteDate('1y, 2026', '1y, 2027') + }) + + test('week-spec year end: @repeat(1w, 2026-W53) -> @repeat(1w, 2027-W01)', async () => { + await expectAdvancedConcreteDate('1w, 2026-W53', '1w, 2027-W01') + }) + + test('+ prefix is retained and the concrete date still advances: @repeat(+1w, 2026-W19) -> @repeat(+1w, 2026-W20)', async () => { + await expectAdvancedConcreteDate('+1w, 2026-W19', '+1w, 2026-W20') + }) +}) + +describe('NPExtendedRepeat generateRepeatForCancelledPara: concrete @repeat(...) base date is advanced in the new repeat', () => { + async function expectAdvancedConcreteDate(repeatTag: string, expectedRepeatTag: string) { + const taskLine = `* [-] Task @repeat(${repeatTag})` + const origPara = new Paragraph({ + type: 'cancelled', + content: taskLine, + lineIndex: 1, + rawContent: taskLine, + }) + const origNote = new Note({ + type: 'Notes', + filename: 'Projects/chores.md', + paragraphs: [ + new Paragraph({ type: 'title', content: 'Chores', headingLevel: 1, lineIndex: 0 }), + origPara, + ], + }) + origPara.note = origNote + origNote.resetLineIndexesAndContent() + + const newPara = await generateRepeatForCancelledPara(origPara, origNote, false) + expect(newPara).not.toBeNull() + const inserted = origNote.paragraphs.find((p) => p.content.includes('@repeat(')) + expect(inserted?.content).toContain(`@repeat(${expectedRepeatTag})`) + } + + test('day-spec: @repeat(1m, 2024-01-15) -> @repeat(1m, 2024-02-15)', async () => { + await expectAdvancedConcreteDate('1m, 2024-01-15', '1m, 2024-02-15') + }) + + test('week-spec: @repeat(1w, 2026-W19) -> @repeat(1w, 2026-W20)', async () => { + await expectAdvancedConcreteDate('1w, 2026-W19', '1w, 2026-W20') + }) + + test('month-spec: @repeat(1m, 2026-05) -> @repeat(1m, 2026-06)', async () => { + await expectAdvancedConcreteDate('1m, 2026-05', '1m, 2026-06') + }) +}) diff --git a/jgclark.RepeatExtensions/__tests__/repeatHelpers.test.js b/jgclark.RepeatExtensions/__tests__/repeatHelpers.test.js index 013ff8a0b..9bc94adf9 100644 --- a/jgclark.RepeatExtensions/__tests__/repeatHelpers.test.js +++ b/jgclark.RepeatExtensions/__tests__/repeatHelpers.test.js @@ -117,4 +117,144 @@ describe('generateNewRepeatDate', () => { expect(typeof result).toBe('string') }) }) + + describe('tests with concrete base date - year-end boundaries (day-spec concrete date, d/w/m increments)', () => { + test('@repeat(1d, 2026-12-31) on a daily note crosses year end, gives 2027-01-01', () => { + const mockNote = { date: moment('2026-12-31').toDate(), type: 'Calendar', filename: '20261231.md' } + const currentContent = 'task @repeat(1d, 2026-12-31)' + const completedDate = '2027-01-01' + const result = generateNewRepeatDate(mockNote, currentContent, completedDate) + expect(result).toBe('2027-01-01') + }) + + test('@repeat(1m, 2026-12-15) on a daily note crosses year end, gives 2027-01-15', () => { + const mockNote = { date: moment('2026-12-15').toDate(), type: 'Calendar', filename: '20261215.md' } + const currentContent = 'task @repeat(1m, 2026-12-15)' + const completedDate = '2026-12-20' + const result = generateNewRepeatDate(mockNote, currentContent, completedDate) + expect(result).toBe('2027-01-15') + }) + + test('@repeat(1w, 2026-12-28) — day-spec concrete date in the last (53rd) week of a 53-week year, with week outputTimeframe, crosses year end to 2027-W01', () => { + const mockNote = { date: moment('2026-10-01').toDate(), type: 'Calendar', filename: '2026-W40.md' } + const currentContent = 'task @repeat(1w, 2026-12-28)' + const completedDate = '2027-01-03' + const result = generateNewRepeatDate(mockNote, currentContent, completedDate) + expect(result).toBe('2027-W01') + }) + + test('@repeat(1w, 2025-12-22) — day-spec concrete date in the last (52nd) week of a 52-week year, with week outputTimeframe, crosses year end to 2026-W01', () => { + const mockNote = { date: moment('2025-07-25').toDate(), type: 'Calendar', filename: '2025-W30.md' } + const currentContent = 'task @repeat(1w, 2025-12-22)' + const completedDate = '2025-12-29' + const result = generateNewRepeatDate(mockNote, currentContent, completedDate) + expect(result).toBe('2026-W01') + }) + }) + + describe('tests with concrete base date - week/month/quarter/year specs', () => { + test('@repeat(1w, 2026-W19) on a different weekly note, gives 2026-W20 from the concrete date (not the note date)', () => { + const mockNote = { date: moment('2026-07-20').toDate(), type: 'Calendar', filename: '2026-W30.md' } + const currentContent = 'task @repeat(1w, 2026-W19)' + const completedDate = '2026-07-25' + const result = generateNewRepeatDate(mockNote, currentContent, completedDate) + expect(result).toBe('2026-W20') + }) + + test('@repeat(1m, 2026-W19) on a different weekly note (cross-unit), gives 2026-W23 from the concrete date', () => { + const mockNote = { date: moment('2026-07-20').toDate(), type: 'Calendar', filename: '2026-W30.md' } + const currentContent = 'task @repeat(1m, 2026-W19)' + const completedDate = '2026-07-25' + const result = generateNewRepeatDate(mockNote, currentContent, completedDate) + expect(result).toBe('2026-W23') + }) + + test('@repeat(1m, 2026-05) on a different monthly note gives 2026-06 from the concrete date', () => { + const mockNote = { date: moment('2026-09-01').toDate(), type: 'Calendar', filename: '2026-09.md' } + const currentContent = 'task @repeat(1m, 2026-05)' + const completedDate = '2026-09-10' + const result = generateNewRepeatDate(mockNote, currentContent, completedDate) + expect(result).toBe('2026-06') + }) + + test('@repeat(1q, 2026-Q2) on a different quarterly note gives 2026-Q3 from the concrete date', () => { + const mockNote = { date: moment('2026-10-01').toDate(), type: 'Calendar', filename: '2026-Q4.md' } + const currentContent = 'task @repeat(1q, 2026-Q2)' + const completedDate = '2026-10-10' + const result = generateNewRepeatDate(mockNote, currentContent, completedDate) + expect(result).toBe('2026-Q3') + }) + + test('@repeat(1y, 2026) on a different yearly note gives 2027 from the concrete date', () => { + const mockNote = { date: moment('2030-01-01').toDate(), type: 'Calendar', filename: '2030.md' } + const currentContent = 'task @repeat(1y, 2026)' + const completedDate = '2030-01-10' + const result = generateNewRepeatDate(mockNote, currentContent, completedDate) + expect(result).toBe('2027') + }) + + test('@repeat(+1w, 2026-W19) — + prefix overrides week-spec concrete date, uses completion date (2026-W22 not 2026-W20)', () => { + const mockNote = { date: moment('2026-05-04').toDate(), type: 'Calendar', filename: '2026-W19.md' } + const currentContent = 'task @repeat(+1w, 2026-W19) >2026-W19' + const completedDate = '2026-05-20' + const result = generateNewRepeatDate(mockNote, currentContent, completedDate) + expect(result).toBe('2026-W22') + }) + + test('@repeat(1w, 2024-01-15) — day-spec concrete date with week outputTimeframe gives 2024-W04', () => { + const mockNote = { date: moment('2024-01-15').toDate(), type: 'Calendar', filename: '2024-W03.md' } + const currentContent = 'task @repeat(1w, 2024-01-15) >2024-W03' + const completedDate = '2024-01-25' + const result = generateNewRepeatDate(mockNote, currentContent, completedDate) + expect(result).toBe('2024-W04') + }) + + test('@repeat(1m, 2026-W99) — invalid week number falls through gracefully (no concrete date applied)', () => { + const mockNote = { date: moment('2026-05-04').toDate(), type: 'Calendar', filename: '2026-W19.md' } + const currentContent = 'task @repeat(1m, 2026-W99) >2026-W19' + const completedDate = '2026-05-20' + const result = generateNewRepeatDate(mockNote, currentContent, completedDate) + expect(typeof result).toBe('string') + }) + + test('@repeat(1w, 2026-W52) on a different weekly note, within a 53-week year, gives 2026-W53 (not wrapping to 2027)', () => { + const mockNote = { date: moment('2026-03-09').toDate(), type: 'Calendar', filename: '2026-W10.md' } + const currentContent = 'task @repeat(1w, 2026-W52)' + const completedDate = '2026-03-15' + const result = generateNewRepeatDate(mockNote, currentContent, completedDate) + expect(result).toBe('2026-W53') + }) + + test('@repeat(1w, 2026-W53) — last week of a 53-week year crosses year end, gives 2027-W01', () => { + const mockNote = { date: moment('2026-03-09').toDate(), type: 'Calendar', filename: '2026-W10.md' } + const currentContent = 'task @repeat(1w, 2026-W53)' + const completedDate = '2027-01-03' + const result = generateNewRepeatDate(mockNote, currentContent, completedDate) + expect(result).toBe('2027-W01') + }) + + test('@repeat(1w, 2025-W52) — last week of a 52-week year crosses year end, gives 2026-W01', () => { + const mockNote = { date: moment('2025-03-10').toDate(), type: 'Calendar', filename: '2025-W10.md' } + const currentContent = 'task @repeat(1w, 2025-W52)' + const completedDate = '2025-12-29' + const result = generateNewRepeatDate(mockNote, currentContent, completedDate) + expect(result).toBe('2026-W01') + }) + + test('@repeat(1m, 2026-12) on a different monthly note crosses year end, gives 2027-01 from the concrete date', () => { + const mockNote = { date: moment('2026-03-01').toDate(), type: 'Calendar', filename: '2026-03.md' } + const currentContent = 'task @repeat(1m, 2026-12)' + const completedDate = '2026-03-10' + const result = generateNewRepeatDate(mockNote, currentContent, completedDate) + expect(result).toBe('2027-01') + }) + + test('@repeat(1q, 2026-Q4) on a different quarterly note crosses year end, gives 2027-Q1 from the concrete date', () => { + const mockNote = { date: moment('2026-01-01').toDate(), type: 'Calendar', filename: '2026-Q1.md' } + const currentContent = 'task @repeat(1q, 2026-Q4)' + const completedDate = '2026-01-10' + const result = generateNewRepeatDate(mockNote, currentContent, completedDate) + expect(result).toBe('2027-Q1') + }) + }) }) From 9187bbc231b55145d5d4bbbf1581477651770835 Mon Sep 17 00:00:00 2001 From: Darren Gillman Date: Thu, 30 Jul 2026 12:31:48 +0100 Subject: [PATCH 3/5] jgclark.RepeatExtensions v1.2.0: bump version and add release notes Bumps plugin.version, adds a CHANGELOG entry, and adds standalone release notes for the concrete @repeat(...) base date feature (day/week/month/ quarter/year specs). --- jgclark.RepeatExtensions/CHANGELOG.md | 4 + .../concrete-repeat-date-release-notes.md | 122 ++++++++++++++++++ jgclark.RepeatExtensions/plugin.json | 4 +- 3 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 jgclark.RepeatExtensions/concrete-repeat-date-release-notes.md diff --git a/jgclark.RepeatExtensions/CHANGELOG.md b/jgclark.RepeatExtensions/CHANGELOG.md index 3dd7063bc..0228d894b 100644 --- a/jgclark.RepeatExtensions/CHANGELOG.md +++ b/jgclark.RepeatExtensions/CHANGELOG.md @@ -1,6 +1,10 @@ # What's changed in 🔁 Repeat Extensions plugin? Please see the [Readme for this plugin](https://github.com/NotePlan/plugins/tree/main/jgclark.RepeatExtensions) for more details, including the available settings. For this plugin to work, **you need to have the 'Append Completion Date' setting turned on in Preferences > Todo**. +## [1.2.0] - 2026-07-30 +- New: `@repeat(interval, )` now supports a concrete base date, in any NotePlan calendar-note date-spec — day (`YYYY-MM-DD`), week (`YYYY-Wnn`), month (`YYYY-MM`), quarter (`YYYY-Qn`) or year (`YYYY`). When present, the next repeat is calculated from that fixed date rather than from the task's scheduled date or completion date. The `+` prefix (e.g. `@repeat(+1m, 2026-05-12)`) still takes precedence, in which case the concrete date is ignored for scheduling. +- After each completion or cancellation, the concrete date is advanced by the same interval and keeps its original format (e.g. `2026-W19` → `2026-W20`), including correctly across 52- and 53-week year boundaries. + ## [1.1.4] - 2026-05-21 - Now runs task sorter after repeats are generated from trigger. - When task sort cannot run because there is no `##` section heading above the repeat, logs an INFO message explaining why. diff --git a/jgclark.RepeatExtensions/concrete-repeat-date-release-notes.md b/jgclark.RepeatExtensions/concrete-repeat-date-release-notes.md new file mode 100644 index 000000000..e8a4cd00b --- /dev/null +++ b/jgclark.RepeatExtensions/concrete-repeat-date-release-notes.md @@ -0,0 +1,122 @@ +# Release Notes — Concrete Base Date for `@repeat` + +## Summary + +Adds an optional concrete base date to `@repeat` tags, using the syntax `@repeat(interval, )`. When present, the next repeat is calculated from that fixed date rather than from the task's scheduled date or completion date. After each completion or cancellation the embedded date is automatically advanced by the same interval, so the anchor rolls forward correctly for future cycles. + +--- + +## New syntax + +``` +@repeat(1m, 2026-05-12) +@repeat(1w, 2026-W20) +@repeat(1m, 2026-06) +@repeat(1q, 2026-Q3) +@repeat(1y, 2027) +``` + +- **`interval`** — any existing interval format (e.g. `1d`, `2w`, `3m`, `1y`). +- **``** — the repeat anchor, given in any NotePlan calendar-note date-spec: + - day: `YYYY-MM-DD` + - week: `YYYY-Wnn` + - month: `YYYY-MM` + - quarter: `YYYY-Qn` + - year: `YYYY` + + Must be a valid date for its spec (e.g. `2026-W53` is only valid in a 53-week year). + +--- + +## Precedence rules + +- `@repeat(1m, 2026-05-12)` — next repeat is calculated from the concrete date, ignoring the scheduled `>date` and completion date. +- `@repeat(+1m, 2026-05-12)` — the `+` prefix takes precedence: next repeat is calculated from the completion / cancellation date, the concrete date is ignored for scheduling, and a warning is logged. +- `@repeat(1m)` — unchanged: uses the task's scheduled `>date` (or note date for calendar notes). +- `@repeat(+1m)` — unchanged: uses the completion / cancellation date. + +--- + +## How the anchor advances + +After each completion or cancellation the date inside the tag is replaced with the next date in the sequence, **preserving its original format**. This happens for both completed **and** cancelled tasks (when "Allow repeats in cancelled paragraphs?" is enabled). + +**Example — completing a monthly task (day-spec anchor):** + +``` +* do expenses @repeat(1m, 2026-05-12) ← original task, completed late on 2026-05-20 +* do expenses @repeat(1m, 2026-06-12) >2026-06-12 ← new task: anchor advanced, scheduled from anchor not completion date +* do expenses @repeat(1m, 2026-07-12) >2026-07-12 ← and so on +``` + +**Example — week-spec anchor:** + +``` +* review backlog @repeat(1w, 2026-W19) ← completed during 2026-W22 +* review backlog @repeat(1w, 2026-W20) >2026-W20 ← anchor advances week-by-week, not to the completion week +``` + +**Example — month/quarter/year-spec anchors:** + +``` +@repeat(1m, 2026-05) → @repeat(1m, 2026-06) +@repeat(1q, 2026-Q2) → @repeat(1q, 2026-Q3) +@repeat(1y, 2026) → @repeat(1y, 2027) +``` + +The `>date` scheduled marker is always set to the same value as the new anchor date (reformatted to a daily date for day-spec anchors on daily notes, or to the matching week/month/quarter/year marker as appropriate). + +--- + +## Year-end handling + +The anchor advances correctly across year boundaries for every spec, including the 52- vs 53-week-year edge cases for week-spec anchors: + +``` +@repeat(1d, 2026-12-31) → @repeat(1d, 2027-01-01) +@repeat(1m, 2026-12) → @repeat(1m, 2027-01) +@repeat(1q, 2026-Q4) → @repeat(1q, 2027-Q1) +@repeat(1y, 2026) → @repeat(1y, 2027) + +@repeat(1w, 2026-W53) → @repeat(1w, 2027-W01) ← last week of a 53-week year (e.g. 2026) +@repeat(1w, 2025-W52) → @repeat(1w, 2026-W01) ← last week of a 52-week year (e.g. 2025) +``` + +A day-spec anchor that falls in the last week of the year also rolls over correctly when the output is a week-spec marker (e.g. `@repeat(1w, 2026-12-28)` → `>2027-W01`). + +--- + +## Cancelled tasks + +Cancelled tasks follow exactly the same logic as completed tasks, subject to the "Allow repeats in cancelled paragraphs?" plugin setting (disabled by default): + +1. The trigger detects that a line transitioned from open to cancelled on save. +2. `generateRepeatForCancelledPara()` strips the `[-]` marker, any existing `>date`, and any `@done(...)` from the content. +3. Today's date is used as the cancellation date. For `@repeat(+interval, )` tags (where `+` overrides the concrete date) the new scheduled date is calculated from today; for plain `@repeat(interval, )` tags the new scheduled date is calculated from the anchor date, regardless of when the task was cancelled. +4. The anchor date in the tag is advanced by the interval (preserving its format), and a new open task is inserted immediately before the cancelled one. + +**Example — cancelling a weekly task:** + +``` +* team meeting @repeat(1w, 2026-05-12) ← cancelled on 2026-05-20 (8 days late) +* team meeting @repeat(1w, 2026-05-19) >2026-05-19 ← new task: anchor used, not cancellation date +``` + +--- + +## `+` prefix with a concrete date + +When the `+` prefix is combined with a concrete date the `+` always wins. A warning is logged noting that the concrete date was immaterial, and the next repeat is scheduled from the completion or cancellation date as normal: + +``` +* task @repeat(+1m, 2026-05-12) ← completed on 2026-05-20 +* task @repeat(+1m, 2026-06-20) ← scheduled from completion date; anchor still advances by one interval +``` + +Note: the anchor in the tag **is** still advanced even in this case, even though it had no effect on scheduling. This means a `@repeat(+1m, )` tag will keep a "rolling anchor" that stays one interval behind the completion-date schedule — which may or may not be the intended behaviour. + +--- + +## Backward compatibility + +All existing `@repeat(interval)` and `@repeat(+interval)` tags are unaffected. The concrete/anchor date is purely additive — if omitted, behaviour is identical to previous versions. diff --git a/jgclark.RepeatExtensions/plugin.json b/jgclark.RepeatExtensions/plugin.json index c3a5d6557..3e1b6b928 100644 --- a/jgclark.RepeatExtensions/plugin.json +++ b/jgclark.RepeatExtensions/plugin.json @@ -8,9 +8,9 @@ "plugin.iconColor": "purple-400", "plugin.author": "Jonathan Clark", "plugin.url": "https://github.com/NotePlan/plugins/tree/main/jgclark.RepeatExtensions/", - "plugin.version": "1.1.4", + "plugin.version": "1.2.0", "plugin.releaseStatus": "full", - "plugin.lastUpdateInfo": "v1.1.4: Now runs task sorter after repeats are generated from trigger. Fix issue when using the trigger.\nv1.1.3: Shared repeat logic in /helpers/extendedRepeat.js (+ NPEditorBasics) to fix Rollup circular dependencies when building other plugins (e.g. Filer). No user-visible behavior change.\nv1.1.2: (unreleased) update required for Tidy Up plugin.\nv1.1.1: fix crash in 'generate @repeats...' when the API returns invalid lines.\nv1.1.0: can now generate repeats on cancelled tasks -- in notes with the special repeat trigger in the frontmatter. To turn this on, set new option 'Allow repeats in cancelled paragraphs?' to true.\nv1.0.1: turn on auto-update again.\nv1.0.0: Add ability to sort section after a repeat has been generated in it.\nv0.9.1: improvements to handling repeats on tasks with sync markers (the blue asterisks). See documentation for more details.\nv0.9.0: new setting 'Don't look for repeats in Done or Archive note sections?'.", + "plugin.lastUpdateInfo": "v1.2.0: @repeat(interval, ) now supports a concrete base date in any calendar-note date-spec (day, week, month, quarter or year), advanced automatically on each completion/cancellation, including across 52/53-week year boundaries.\nv1.1.4: Now runs task sorter after repeats are generated from trigger. Fix issue when using the trigger.\nv1.1.3: Shared repeat logic in /helpers/extendedRepeat.js (+ NPEditorBasics) to fix Rollup circular dependencies when building other plugins (e.g. Filer). No user-visible behavior change.\nv1.1.2: (unreleased) update required for Tidy Up plugin.\nv1.1.1: fix crash in 'generate @repeats...' when the API returns invalid lines.\nv1.1.0: can now generate repeats on cancelled tasks -- in notes with the special repeat trigger in the frontmatter. To turn this on, set new option 'Allow repeats in cancelled paragraphs?' to true.\nv1.0.1: turn on auto-update again.\nv1.0.0: Add ability to sort section after a repeat has been generated in it.\nv0.9.1: improvements to handling repeats on tasks with sync markers (the blue asterisks). See documentation for more details.\nv0.9.0: new setting 'Don't look for repeats in Done or Archive note sections?'.", "plugin.dependencies": [], "plugin.script": "script.js", "plugin.isRemote": "false", From e405b7e5db71df2bcd251855c48038cc68476073 Mon Sep 17 00:00:00 2001 From: Darren Gillman Date: Thu, 30 Jul 2026 15:22:41 +0100 Subject: [PATCH 4/5] Clarify concrete @repeat date wording: format vs. scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Calendar-note date-spec" read as if the feature only worked in calendar notes. It works identically in project notes and calendar notes — the phrase was describing the date format, not a restriction on where the tag can be used. --- jgclark.RepeatExtensions/CHANGELOG.md | 2 +- jgclark.RepeatExtensions/concrete-repeat-date-release-notes.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jgclark.RepeatExtensions/CHANGELOG.md b/jgclark.RepeatExtensions/CHANGELOG.md index 0228d894b..571b899b2 100644 --- a/jgclark.RepeatExtensions/CHANGELOG.md +++ b/jgclark.RepeatExtensions/CHANGELOG.md @@ -2,7 +2,7 @@ Please see the [Readme for this plugin](https://github.com/NotePlan/plugins/tree/main/jgclark.RepeatExtensions) for more details, including the available settings. For this plugin to work, **you need to have the 'Append Completion Date' setting turned on in Preferences > Todo**. ## [1.2.0] - 2026-07-30 -- New: `@repeat(interval, )` now supports a concrete base date, in any NotePlan calendar-note date-spec — day (`YYYY-MM-DD`), week (`YYYY-Wnn`), month (`YYYY-MM`), quarter (`YYYY-Qn`) or year (`YYYY`). When present, the next repeat is calculated from that fixed date rather than from the task's scheduled date or completion date. The `+` prefix (e.g. `@repeat(+1m, 2026-05-12)`) still takes precedence, in which case the concrete date is ignored for scheduling. +- New: `@repeat(interval, )` now supports a concrete base date, written using any of NotePlan's calendar-note date formats — day (`YYYY-MM-DD`), week (`YYYY-Wnn`), month (`YYYY-MM`), quarter (`YYYY-Qn`) or year (`YYYY`). This works the same whether the task is in a project note or a calendar note. When present, the next repeat is calculated from that fixed date rather than from the task's scheduled date or completion date. The `+` prefix (e.g. `@repeat(+1m, 2026-05-12)`) still takes precedence, in which case the concrete date is ignored for scheduling. - After each completion or cancellation, the concrete date is advanced by the same interval and keeps its original format (e.g. `2026-W19` → `2026-W20`), including correctly across 52- and 53-week year boundaries. ## [1.1.4] - 2026-05-21 diff --git a/jgclark.RepeatExtensions/concrete-repeat-date-release-notes.md b/jgclark.RepeatExtensions/concrete-repeat-date-release-notes.md index e8a4cd00b..43a6ca36e 100644 --- a/jgclark.RepeatExtensions/concrete-repeat-date-release-notes.md +++ b/jgclark.RepeatExtensions/concrete-repeat-date-release-notes.md @@ -17,7 +17,7 @@ Adds an optional concrete base date to `@repeat` tags, using the syntax `@repeat ``` - **`interval`** — any existing interval format (e.g. `1d`, `2w`, `3m`, `1y`). -- **``** — the repeat anchor, given in any NotePlan calendar-note date-spec: +- **``** — the repeat anchor, given in any of NotePlan's calendar-note date formats (works the same in project notes and calendar notes): - day: `YYYY-MM-DD` - week: `YYYY-Wnn` - month: `YYYY-MM` From 5635c4942a7c082ea36ed5f165367cbaf44a2d95 Mon Sep 17 00:00:00 2001 From: Darren Gillman Date: Sat, 1 Aug 2026 19:15:03 +0100 Subject: [PATCH 5/5] Address review feedback on PR #760 - Rename RE_CONCRETE_REPEAT_DATE (a plain string, not a RegExp) to CONCRETE_REPEAT_DATE_STR, matching the file's existing convention that RE_* is reserved for actual RegExp values. - Fold the concrete base date feature into the README, since only README files are surfaced to users; the standalone release-notes doc is kept as a more detailed reference. --- helpers/NPExtendedRepeat.js | 6 +++--- jgclark.RepeatExtensions/README.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/helpers/NPExtendedRepeat.js b/helpers/NPExtendedRepeat.js index 405a0dbd7..5a0466d9d 100644 --- a/helpers/NPExtendedRepeat.js +++ b/helpers/NPExtendedRepeat.js @@ -46,9 +46,9 @@ const LOG_CONTEXT = 'extendedRepeat' // Regexes + config type + settings + date math // A concrete @repeat(...) base date can be given in any NP calendar-note date-spec: day, week, month, quarter or year -const RE_CONCRETE_REPEAT_DATE: string = `(?:${RE_ISO_DATE}|${RE_NP_WEEK_SPEC}|${RE_NP_MONTH_SPEC}|${RE_NP_QUARTER_SPEC}|${RE_NP_YEAR_SPEC})` -const RE_CONCRETE_REPEAT_DATE_FULL: RegExp = new RegExp(`^${RE_CONCRETE_REPEAT_DATE}$`) // validate a standalone date-spec string -const EXTENDED_REPEAT_STR: string = `@repeat\\(${RE_DATE_INTERVAL}(?:,\\s*${RE_CONCRETE_REPEAT_DATE})?\\)` // find @repeat() +const CONCRETE_REPEAT_DATE_STR: string = `(?:${RE_ISO_DATE}|${RE_NP_WEEK_SPEC}|${RE_NP_MONTH_SPEC}|${RE_NP_QUARTER_SPEC}|${RE_NP_YEAR_SPEC})` +const RE_CONCRETE_REPEAT_DATE_FULL: RegExp = new RegExp(`^${CONCRETE_REPEAT_DATE_STR}$`) // validate a standalone date-spec string +const EXTENDED_REPEAT_STR: string = `@repeat\\(${RE_DATE_INTERVAL}(?:,\\s*${CONCRETE_REPEAT_DATE_STR})?\\)` // find @repeat() export const RE_EXTENDED_REPEAT: RegExp = new RegExp(EXTENDED_REPEAT_STR) // find @repeat() const EXTENDED_REPEAT_CAPTURE_STR: string = `@repeat\\((.*?)\\)` // find @repeat() and return part inside brackets export const RE_EXTENDED_REPEAT_CAPTURE: RegExp = new RegExp(EXTENDED_REPEAT_CAPTURE_STR) // find @repeat() and return part inside brackets diff --git a/jgclark.RepeatExtensions/README.md b/jgclark.RepeatExtensions/README.md index d09666b42..0106a452e 100644 --- a/jgclark.RepeatExtensions/README.md +++ b/jgclark.RepeatExtensions/README.md @@ -68,6 +68,35 @@ You can specify scheduled dates to all the other calendar note types supported b The resulting repeat lines will also be specified using that same note type, and will write to the appropriate new calendar note (unless it came from a project note, in which case it will stay in the same project note). +## Specifying a Concrete Base Date +As well as an interval, `@repeat(...)` can also take a **concrete base date** to repeat from: `@repeat(interval, )`. When present, the next repeat is calculated from that fixed date, rather than from the task's scheduled date or completion date. After each completion (or cancellation) the date is automatically advanced by the interval, so it keeps in step for future cycles. + +`` can be given in any of NotePlan's calendar-note date formats, and works the same whether the task is in a project note or a calendar note: +- day: `YYYY-MM-DD` +- week: `YYYY-Wnn` +- month: `YYYY-MM` +- quarter: `YYYY-Qn` +- year: `YYYY` + +For example, the next repeat is always calculated from the base date — not the completion date, and not the scheduled `>date` either, even if the task had been rescheduled away from its base date: +``` +* project reporting @repeat(1m, 2026-05-13) >2026-05-17 +(completed on 2026-05-20) +* project reporting @repeat(1m, 2026-06-13) >2026-06-13 +``` +``` +* do expenses @repeat(1m, 2026-05-12) +(completed on 2026-05-20) +* do expenses @repeat(1m, 2026-06-12) >2026-06-12 +``` +``` +* review backlog @repeat(1w, 2026-W19) +(completed on 2026-05-20) +* review backlog @repeat(1w, 2026-W20) >2026-W20 +``` + +If the interval has a `+` prefix (e.g. `@repeat(+1m, 2026-05-12)`), the `+` takes precedence as usual: the next repeat is scheduled from the completion/cancellation date, and the concrete date is ignored for scheduling (though it's still advanced in the new task). + ## Configuration For Repeat Extensions plugin to work, **you need to have the 'Append Completion Date' setting turned on in Preferences > Todo**, and not to mind the time portion of the `@done(...)` tag being removed, as a sign that the line has been processed.