From c66b389edd424e6cc6d661b88c85a87c60e5d9ce Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 26 Oct 2026 20:30:45 +0100 Subject: [PATCH 1/8] test(date): reproduce Pick a day overriding a nested variable day (#2265) --- src/choiceExecutor.dateScope.test.ts | 69 +++++++++++++++++++++++++++- tests/e2e/date-origin-scope.test.ts | 26 ++++++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/choiceExecutor.dateScope.test.ts b/src/choiceExecutor.dateScope.test.ts index 8f4744a2a..7e4a02c64 100644 --- a/src/choiceExecutor.dateScope.test.ts +++ b/src/choiceExecutor.dateScope.test.ts @@ -21,6 +21,8 @@ vi.mock("./utils/fileOpening", async (importOriginal) => ({ ...await importOriginal>(), getOpenFileOriginLeaf: () => null, })); +const datePrompt = vi.fn(async () => "@date:2026-03-01"); +vi.mock("./gui/VDateInputPrompt/VDateInputPrompt", () => ({ default: { Prompt: datePrompt } })); const runTemplate = vi.fn<(executor: IChoiceExecutor, choice: IChoice) => Promise>(); vi.mock("./engine/TemplateChoiceEngine", () => ({ TemplateChoiceEngine: class { @@ -31,6 +33,7 @@ vi.mock("./engine/TemplateChoiceEngine", () => ({ const { ChoiceExecutor } = await import("./choiceExecutor"); const { QuickAddApi } = await import("./quickAddApi"); +const { applyInvocationDate } = await import("./utils/resolveDateOrigin"); const originalMoment = window.moment; beforeAll(() => { window.moment = realMoment; }); afterAll(() => { window.moment = originalMoment; }); @@ -48,7 +51,10 @@ describe("ChoiceExecutor date scope", () => { return choice; } - beforeEach(() => { runTemplate.mockReset(); }); + beforeEach(() => { + runTemplate.mockReset(); + datePrompt.mockClear(); + }); function recordDays() { const days: Record = {}; @@ -88,6 +94,67 @@ describe("ChoiceExecutor date scope", () => { expect(days).toEqual({ First: "2026-01-02", Second: "2026-01-02" }); }); + function macroOf(...children: IChoice[]) { + const macro = new MacroChoice("Macro"); + macro.macro.commands = children.map((child) => new NestedChoiceCommand(child)); + return macro; + } + + it("keeps a nested variable day when the macro is run with Pick a day", async () => { + const runner = executor(); + const days = recordDays(); + runner.variables.set("testDate", "2026-01-02"); + runner.pickDate = true; + + await runner.execute(macroOf( + datedTemplate("Variable", { kind: "variable", name: "testDate" }), + datedTemplate("Today"), + )); + + expect(datePrompt).toHaveBeenCalledTimes(1); + expect(days).toEqual({ Variable: "2026-01-02", Today: "2026-03-01" }); + }); + + it("keeps a nested variable day under a macro's own Which day", async () => { + const runner = executor(); + const days = recordDays(); + const macro = macroOf( + datedTemplate("Variable", { kind: "variable", name: "testDate" }), + datedTemplate("Yesterday", { kind: "relative", offset: -1, unit: "days" }), + ); + macro.dateOrigin = { kind: "variable", name: "macroDay" }; + runner.variables.set("macroDay", "2026-01-05"); + runner.variables.set("testDate", "2026-01-02"); + + await runner.execute(macro); + + expect(days).toEqual({ Variable: "2026-01-02", Yesterday: "2026-01-05" }); + }); + + it("keeps a nested variable day under an invocation date", async () => { + const runner = executor(); + const days = recordDays(); + runner.variables.set("testDate", "2026-01-02"); + applyInvocationDate(runner, "2026-03-01"); + + await runner.execute(macroOf( + datedTemplate("Variable", { kind: "variable", name: "testDate" }), + datedTemplate("Today"), + )); + + expect(days).toEqual({ Variable: "2026-01-02", Today: "2026-03-01" }); + }); + + it("lets an invocation date replace the invoked choice's own variable day", async () => { + const runner = executor(); + const days = recordDays(); + applyInvocationDate(runner, "2026-03-01"); + + await runner.execute(datedTemplate("Variable", { kind: "variable", name: "unset" })); + + expect(days).toEqual({ Variable: "2026-03-01" }); + }); + it("gives each executeChoice call its own day and keeps it out of the caller", async () => { const runner = executor(); const days = recordDays(); diff --git a/tests/e2e/date-origin-scope.test.ts b/tests/e2e/date-origin-scope.test.ts index 916b8dc66..adda013b7 100644 --- a/tests/e2e/date-origin-scope.test.ts +++ b/tests/e2e/date-origin-scope.test.ts @@ -7,9 +7,10 @@ import { CommandType } from "../../src/types/macros/CommandType"; import type { DateOrigin } from "../../src/types/dateOrigin"; import { NestedChoiceCommand } from "../../src/types/macros/QuickCommands/NestedChoiceCommand"; import { createQuickAddE2EHarness, seedVaultFile } from "./e2eVault"; -import { POLL_OPTS } from "./uiHelpers"; +import { POLL_OPTS, pressKey, typeInto } from "./uiHelpers"; const getContext = createQuickAddE2EHarness("date-origin-scope"); +const DATE_INPUT = ".qaDatePrompt input.qa-vdate-input"; async function datedTemplate(name: string, folder: string, dateOrigin?: DateOrigin) { const { obsidian, sandbox } = getContext(); @@ -118,3 +119,26 @@ it("keeps an executeChoice options.date out of later macro steps", async () => { await expect.poll(() => sandbox.read("after-api/2026-01-04.md").catch(() => null), POLL_OPTS).toBe("2026-01-04"); expect(await sandbox.read("after-api/2026-01-02.md")).toBe("2026-01-02"); }); + +it("keeps a nested variable day when the macro runs with Pick a day", async () => { + const { obsidian, plugin, sandbox } = getContext(); + const macro = new MacroChoice("Picked macro"); + macro.command = true; + macro.pickDayCommand = true; + macro.macro.commands = [ + await scriptStep("set-test-date.js", `params.variables.testDate = "2026-01-02";`), + new NestedChoiceCommand(await datedTemplate("Variable child", "picked/variable", { kind: "variable", name: "testDate" })), + new NestedChoiceCommand(await datedTemplate("Today child", "picked/today")), + ]; + await plugin.data<{ choices: IChoice[] }>().patch((data) => { + data.choices = [macro]; + }); + await plugin.reload({ waitUntilReady: true }); + + await obsidian.exec("command", { id: `quickadd:choice:${macro.id}:pick-day` }); + await typeInto(obsidian, DATE_INPUT, "2026-03-01"); + await pressKey(obsidian, "Enter"); + + await expect.poll(() => sandbox.read("picked/today/2026-03-01.md").catch(() => null), POLL_OPTS).toBe("2026-03-01"); + expect(await sandbox.read("picked/variable/2026-01-02.md")).toBe("2026-01-02"); +}); From e2cd63ea8b29e2669e3ce3a3c50b58e2acf25ff8 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 26 Oct 2026 20:32:59 +0100 Subject: [PATCH 2/8] fix(date): keep a nested variable day under Pick a day (#2265) --- docs/src/content/docs/docs/Choices/MacroChoice.md | 4 +++- src/IChoiceExecutor.ts | 5 +++-- src/choiceExecutor.ts | 11 +++++++++-- src/types/dateOrigin.ts | 5 +++++ src/utils/resolveDateOrigin.ts | 9 ++++++++- 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/docs/src/content/docs/docs/Choices/MacroChoice.md b/docs/src/content/docs/docs/Choices/MacroChoice.md index 9eb36f3f6..d0d290850 100644 --- a/docs/src/content/docs/docs/Choices/MacroChoice.md +++ b/docs/src/content/docs/docs/Choices/MacroChoice.md @@ -300,7 +300,9 @@ commands share the same name, rename one before using the selector form. Same [Which day](/docs/Choices/TemplateChoice/#date-origin) setting as a Template. Child choices in the macro inherit that day, so a weekly pack can -ask once and then write every template for last week. +ask once and then write every template for last week. A child set to **A +variable…** keeps its variable's day, even when you run the macro with +"Name (pick a day)". ### Run on startup {#run-on-startup} diff --git a/src/IChoiceExecutor.ts b/src/IChoiceExecutor.ts index 4fc46d726..0fdeb7705 100644 --- a/src/IChoiceExecutor.ts +++ b/src/IChoiceExecutor.ts @@ -58,8 +58,9 @@ export interface IChoiceExecutor { */ clocks?: RunClocks; /** - * When true, this run asks for a day even if the choice is set to Today - * (or another fixed day). Used by `{name} (pick a day)` and `date=ask`. + * When true, the next choice to resolve its Which day asks for a day even + * if it is set to Today (or another fixed day). Choices it runs keep their + * own Which day. Used by `{name} (pick a day)` and `date=ask`. */ pickDate?: boolean; /** diff --git a/src/choiceExecutor.ts b/src/choiceExecutor.ts index df566644f..426f7b9b5 100644 --- a/src/choiceExecutor.ts +++ b/src/choiceExecutor.ts @@ -132,8 +132,8 @@ export class ChoiceExecutor implements IChoiceExecutor { ? this.triggerContextOverride : { activeFile: this.app.workspace.getActiveFile() }; this.clocks = { + ...this.clocks, now: this.clocks?.now ?? new Date(), - date: this.clocks?.date, }; } this.executionDepth++; @@ -340,6 +340,8 @@ export class ChoiceExecutor implements IChoiceExecutor { const setting = this.pickDate ? dateOriginForPick(normalizeDateOrigin(choice.dateOrigin)) : normalizeDateOrigin(choice.dateOrigin); + // Pick a day answers this choice's Which day, not the ones it runs. + this.pickDate = false; const reservedSeed = setting?.kind === "ask" ? this.variables.get(QA_INTERNAL_DATE_ORIGIN) @@ -351,7 +353,12 @@ export class ChoiceExecutor implements IChoiceExecutor { reservedSeed, }); - if (plan.status === "inherit") return; + if (plan.status === "inherit") { + if (this.clocks?.invoked) { + this.clocks = { now: this.clocks.now, date: this.clocks.date }; + } + return; + } if (plan.status === "error") { log.logError(plan.message); diff --git a/src/types/dateOrigin.ts b/src/types/dateOrigin.ts index 7cbe6bdaa..1bf6bada3 100644 --- a/src/types/dateOrigin.ts +++ b/src/types/dateOrigin.ts @@ -11,6 +11,11 @@ export type DateOrigin = export interface RunClocks { now: Date; date?: Date; + /** + * The caller chose `date` for the choice it runs, so it replaces that + * choice's Which day. Choices nested under it get the day without this. + */ + invoked?: boolean; } export function isDateOriginUnit(value: unknown): value is DateOriginUnit { diff --git a/src/utils/resolveDateOrigin.ts b/src/utils/resolveDateOrigin.ts index 331bb2ccc..9f08cb6cd 100644 --- a/src/utils/resolveDateOrigin.ts +++ b/src/utils/resolveDateOrigin.ts @@ -48,7 +48,13 @@ export function planDateOrigin(input: { variables?: Map; reservedSeed?: unknown; }): DateOriginPlan { - if (input.clocks?.date) return { status: "inherit" }; + // A variable names its own day, so only the caller's day replaces it. + if ( + input.clocks?.date && + (input.clocks.invoked || input.setting?.kind !== "variable") + ) { + return { status: "inherit" }; + } if (input.reservedSeed !== undefined) { const date = dateFromStoredValue(input.reservedSeed); @@ -113,6 +119,7 @@ export function applyInvocationDate( executor.clocks = { now: executor.clocks?.now ?? new Date(), date, + invoked: true, }; return true; } From f1dd5bfc89fcac7bbcb819ef5e76c67fd2dccda8 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 26 Oct 2026 20:40:18 +0100 Subject: [PATCH 3/8] fix(date): ask for a nested explicit pick under an enclosing day --- src/choiceExecutor.dateScope.test.ts | 21 +++++++++++++++++ src/choiceExecutor.ts | 14 ++++++------ src/preflight/collectChoiceRequirements.ts | 9 +++----- src/preflight/runOnePagePreflight.ts | 11 ++++----- src/utils/resolveDateOrigin.ts | 26 ++++++++++++++++++++-- 5 files changed, 59 insertions(+), 22 deletions(-) diff --git a/src/choiceExecutor.dateScope.test.ts b/src/choiceExecutor.dateScope.test.ts index 7e4a02c64..c72c2b55f 100644 --- a/src/choiceExecutor.dateScope.test.ts +++ b/src/choiceExecutor.dateScope.test.ts @@ -155,6 +155,27 @@ describe("ChoiceExecutor date scope", () => { expect(days).toEqual({ Variable: "2026-03-01" }); }); + it("asks for an executeChoice date of ask under the caller's day", async () => { + const runner = executor(); + const days = recordDays(); + const child = datedTemplate("Child"); + const plugin = { settings: { choices: [child] } } as unknown as QuickAdd; + const api = QuickAddApi.GetApi(new App(), plugin, runner); + const caller = datedTemplate("Caller", { kind: "variable", name: "callerDay" }); + runner.variables.set("callerDay", "2026-01-05"); + runTemplate.mockImplementation(async (active, choice) => { + if (choice.name === "Caller") { + await api.executeChoice("Child", {}, { date: "ask" }); + } + days[choice.name] = day(active.clocks?.date); + }); + + await runner.execute(caller); + + expect(datePrompt).toHaveBeenCalledTimes(1); + expect(days).toEqual({ Child: "2026-03-01", Caller: "2026-01-05" }); + }); + it("gives each executeChoice call its own day and keeps it out of the caller", async () => { const runner = executor(); const days = recordDays(); diff --git a/src/choiceExecutor.ts b/src/choiceExecutor.ts index 426f7b9b5..2b5ed03f5 100644 --- a/src/choiceExecutor.ts +++ b/src/choiceExecutor.ts @@ -29,12 +29,14 @@ import { } from "./utils/frontmatterPropertyLinks"; import type { QuickAddTriggerContext } from "./types/QuickAddTriggerContext"; import type { RunClocks } from "./types/dateOrigin"; -import { normalizeDateOrigin } from "./types/dateOrigin"; -import { dateOriginForPick } from "./types/dateOriginPresets"; import { childChoicesOf } from "./utils/choiceUtils"; import { QA_INTERNAL_DATE_ORIGIN, VALUE_SYNTAX } from "./constants"; import VDateInputPrompt from "./gui/VDateInputPrompt/VDateInputPrompt"; -import { planDateOrigin, dateFromStoredValue } from "./utils/resolveDateOrigin"; +import { + planDateOrigin, + dateFromStoredValue, + dateOriginInput, +} from "./utils/resolveDateOrigin"; import { log } from "./logger/logManager"; import type { ICommand } from "./types/macros/ICommand"; import type { LoadedUserScript } from "./utils/userScript"; @@ -337,9 +339,7 @@ export class ChoiceExecutor implements IChoiceExecutor { } async resolveDateOrigin(choice: IChoice): Promise { - const setting = this.pickDate - ? dateOriginForPick(normalizeDateOrigin(choice.dateOrigin)) - : normalizeDateOrigin(choice.dateOrigin); + const { setting, clocks } = dateOriginInput(choice, this); // Pick a day answers this choice's Which day, not the ones it runs. this.pickDate = false; const reservedSeed = @@ -348,7 +348,7 @@ export class ChoiceExecutor implements IChoiceExecutor { : undefined; const plan = planDateOrigin({ setting, - clocks: this.clocks, + clocks, variables: this.variables, reservedSeed, }); diff --git a/src/preflight/collectChoiceRequirements.ts b/src/preflight/collectChoiceRequirements.ts index 46f7c1c29..55eca43c9 100644 --- a/src/preflight/collectChoiceRequirements.ts +++ b/src/preflight/collectChoiceRequirements.ts @@ -10,12 +10,11 @@ import { QA_INTERNAL_DATE_ORIGIN, VALUE_SYNTAX, } from "src/constants"; -import { normalizeDateOrigin } from "src/types/dateOrigin"; -import { dateOriginForPick } from "src/types/dateOriginPresets"; import type QuickAdd from "src/main"; import type ICaptureChoice from "src/types/choices/ICaptureChoice"; import type IChoice from "src/types/choices/IChoice"; import { isMacroChoice, isTemplateChoice } from "src/types/choices/choiceType"; +import { dateOriginInput } from "src/utils/resolveDateOrigin"; import type IMacroChoice from "src/types/choices/IMacroChoice"; import type ITemplateChoice from "src/types/choices/ITemplateChoice"; import type { IUserScript } from "src/types/macros/IUserScript"; @@ -528,10 +527,8 @@ function dateOriginRequirement( choice: IChoice, choiceExecutor: IChoiceExecutor, ): FieldRequirement | undefined { - const dateOrigin = choiceExecutor.pickDate - ? dateOriginForPick(normalizeDateOrigin(choice.dateOrigin)) - : normalizeDateOrigin(choice.dateOrigin); - if (dateOrigin?.kind !== "ask" || choiceExecutor.clocks?.date) { + const { setting: dateOrigin, clocks } = dateOriginInput(choice, choiceExecutor); + if (dateOrigin?.kind !== "ask" || clocks?.date) { return undefined; } diff --git a/src/preflight/runOnePagePreflight.ts b/src/preflight/runOnePagePreflight.ts index 6076b6229..a8817ff39 100644 --- a/src/preflight/runOnePagePreflight.ts +++ b/src/preflight/runOnePagePreflight.ts @@ -19,9 +19,8 @@ import { } from "./collectChoiceRequirements"; import type { FormAnswer } from "src/interactive/promptProvider"; import { QA_INTERNAL_DATE_ORIGIN } from "src/constants"; -import { normalizeDateOrigin, type RunClocks } from "src/types/dateOrigin"; -import { dateOriginForPick } from "src/types/dateOriginPresets"; -import { planDateOrigin } from "src/utils/resolveDateOrigin"; +import type { RunClocks } from "src/types/dateOrigin"; +import { dateOriginInput, planDateOrigin } from "src/utils/resolveDateOrigin"; import { buildDiscoveryFormPlan, storeDiscoveryFormAnswers } from "./discoveryFormPlan"; import { hasActivePreparedChoiceInputs } from "./preparedChoiceInputs"; @@ -36,12 +35,10 @@ function previewRunClocks( choiceExecutor: IChoiceExecutor, values: Record, ): RunClocks | undefined { - const setting = choiceExecutor.pickDate - ? dateOriginForPick(normalizeDateOrigin(choice.dateOrigin)) - : normalizeDateOrigin(choice.dateOrigin); + const { setting, clocks } = dateOriginInput(choice, choiceExecutor); const plan = planDateOrigin({ setting, - clocks: choiceExecutor.clocks, + clocks, variables: new Map([ ...choiceExecutor.variables, ...Object.entries(values), diff --git a/src/utils/resolveDateOrigin.ts b/src/utils/resolveDateOrigin.ts index 9f08cb6cd..3bfba0fde 100644 --- a/src/utils/resolveDateOrigin.ts +++ b/src/utils/resolveDateOrigin.ts @@ -1,6 +1,12 @@ import { NLDParser } from "../parsers/NLDParser"; -import type { DateOrigin, DateOriginUnit, RunClocks } from "../types/dateOrigin"; -import { isPickDateToken } from "../types/dateOriginPresets"; +import type IChoice from "../types/choices/IChoice"; +import { + normalizeDateOrigin, + type DateOrigin, + type DateOriginUnit, + type RunClocks, +} from "../types/dateOrigin"; +import { dateOriginForPick, isPickDateToken } from "../types/dateOriginPresets"; import { parseNaturalLanguageDate } from "./dateParser"; import { resolveExistingVariableKey } from "./valueSyntax"; @@ -42,6 +48,22 @@ export function dateFromStoredValue(stored: unknown): Date | undefined { return undefined; } +/** + * The Which day a choice resolves with, and the clocks it may inherit a day + * from. Pick a day asks even under an enclosing day. + */ +export function dateOriginInput( + choice: IChoice, + executor: { clocks?: RunClocks; pickDate?: boolean }, +): { setting?: DateOrigin; clocks?: RunClocks } { + const own = normalizeDateOrigin(choice.dateOrigin); + if (!executor.pickDate) return { setting: own, clocks: executor.clocks }; + return { + setting: dateOriginForPick(own), + clocks: executor.clocks && { now: executor.clocks.now }, + }; +} + export function planDateOrigin(input: { setting?: DateOrigin; clocks?: RunClocks; From 59cd28d7ca283fab5774328d3e1f279802df59ce Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 26 Oct 2026 20:47:44 +0100 Subject: [PATCH 4/8] refactor(date): hand a caller's day to one choice through callerDay --- src/IChoiceExecutor.ts | 11 +++--- src/choiceExecutor.dateScope.test.ts | 38 ++++++++++++++++++- src/choiceExecutor.ts | 20 +++++----- src/gui/suggesters/choiceSuggester.test.ts | 14 +++---- src/gui/suggesters/choiceSuggester.ts | 6 +-- src/main.ts | 2 +- .../collectChoiceRequirements.test.ts | 8 ++-- src/quickAddApi.test.ts | 14 +++---- src/quickAddApi.ts | 5 +-- src/types/dateOrigin.ts | 8 ++-- src/utils/resolveDateOrigin.test.ts | 16 +++----- src/utils/resolveDateOrigin.ts | 34 ++++++++--------- 12 files changed, 101 insertions(+), 75 deletions(-) diff --git a/src/IChoiceExecutor.ts b/src/IChoiceExecutor.ts index 0fdeb7705..8b15596a1 100644 --- a/src/IChoiceExecutor.ts +++ b/src/IChoiceExecutor.ts @@ -4,7 +4,7 @@ import type ICaptureChoice from "./types/choices/ICaptureChoice"; import type { MacroAbortError } from "./errors/MacroAbortError"; import type { ChoiceOutcome } from "./types/ChoiceOutcome"; import type { FrontmatterPropertyTarget } from "./utils/frontmatterPropertyLinks"; -import type { RunClocks } from "./types/dateOrigin"; +import type { CallerDay, RunClocks } from "./types/dateOrigin"; import type { QuickAddTriggerContext } from "./types/QuickAddTriggerContext"; import type { PromptProvider } from "./interactive/promptProvider"; import type IMacroChoice from "./types/choices/IMacroChoice"; @@ -58,11 +58,12 @@ export interface IChoiceExecutor { */ clocks?: RunClocks; /** - * When true, the next choice to resolve its Which day asks for a day even - * if it is set to Today (or another fixed day). Choices it runs keep their - * own Which day. Used by `{name} (pick a day)` and `date=ask`. + * Replaces the Which day of the next choice to resolve one: `"pick"` asks + * even if it is set to Today, a date is that day. Choices it runs inherit + * the result like any enclosing day. Set by `{name} (pick a day)`, Shift in + * the menu, and `date=`. */ - pickDate?: boolean; + callerDay?: CallerDay; /** * Sets {@link clocks} from the choice's Which day setting, asking when it is * Ask each time. A Template that searches existing notes calls this after its diff --git a/src/choiceExecutor.dateScope.test.ts b/src/choiceExecutor.dateScope.test.ts index c72c2b55f..e631b3d78 100644 --- a/src/choiceExecutor.dateScope.test.ts +++ b/src/choiceExecutor.dateScope.test.ts @@ -7,6 +7,8 @@ import type { IChoiceExecutor } from "./IChoiceExecutor"; import { TemplateChoice } from "./types/choices/TemplateChoice"; import { MacroChoice } from "./types/choices/MacroChoice"; import { NestedChoiceCommand } from "./types/macros/QuickCommands/NestedChoiceCommand"; +import { MultiChoice } from "./types/choices/MultiChoice"; +import { QA_INTERNAL_DATE_ORIGIN } from "./constants"; import { ChoiceAbortError } from "./errors/ChoiceAbortError"; vi.mock("./gui/choiceList/ChoiceView.svelte", () => ({})); @@ -23,6 +25,14 @@ vi.mock("./utils/fileOpening", async (importOriginal) => ({ })); const datePrompt = vi.fn(async () => "@date:2026-03-01"); vi.mock("./gui/VDateInputPrompt/VDateInputPrompt", () => ({ default: { Prompt: datePrompt } })); +vi.mock("./gui/suggesters/choiceSuggester", async (importOriginal) => ({ + ...await importOriginal>(), + default: { + Open: (_plugin: unknown, choices: IChoice[], options: { choiceExecutor: IChoiceExecutor; completion: (error?: unknown) => void }) => { + options.choiceExecutor.execute(choices[0]).then(() => options.completion(), options.completion); + }, + }, +})); const runTemplate = vi.fn<(executor: IChoiceExecutor, choice: IChoice) => Promise>(); vi.mock("./engine/TemplateChoiceEngine", () => ({ TemplateChoiceEngine: class { @@ -104,7 +114,7 @@ describe("ChoiceExecutor date scope", () => { const runner = executor(); const days = recordDays(); runner.variables.set("testDate", "2026-01-02"); - runner.pickDate = true; + runner.callerDay = "pick"; await runner.execute(macroOf( datedTemplate("Variable", { kind: "variable", name: "testDate" }), @@ -176,6 +186,32 @@ describe("ChoiceExecutor date scope", () => { expect(days).toEqual({ Child: "2026-03-01", Caller: "2026-01-05" }); }); + it("hands a caller's day past a folder to the picked choice", async () => { + const runner = executor(); + const days = recordDays(); + const folder = new MultiChoice("Folder"); + folder.choices = [datedTemplate("Variable", { kind: "variable", name: "unset" })]; + applyInvocationDate(runner, "2026-03-01"); + + await runner.execute(folder); + + expect(days).toEqual({ Variable: "2026-03-01" }); + }); + + it("uses a collected date answer for one ask only", async () => { + const runner = executor(); + const days = recordDays(); + runner.variables.set(QA_INTERNAL_DATE_ORIGIN, "@date:2026-01-05"); + + await runner.execute(macroOf( + datedTemplate("First", { kind: "ask" }), + datedTemplate("Second", { kind: "ask" }), + )); + + expect(datePrompt).toHaveBeenCalledTimes(1); + expect(days).toEqual({ First: "2026-01-05", Second: "2026-03-01" }); + }); + it("gives each executeChoice call its own day and keeps it out of the caller", async () => { const runner = executor(); const days = recordDays(); diff --git a/src/choiceExecutor.ts b/src/choiceExecutor.ts index 2b5ed03f5..7f3b6941b 100644 --- a/src/choiceExecutor.ts +++ b/src/choiceExecutor.ts @@ -28,7 +28,7 @@ import { type FrontmatterPropertyTarget, } from "./utils/frontmatterPropertyLinks"; import type { QuickAddTriggerContext } from "./types/QuickAddTriggerContext"; -import type { RunClocks } from "./types/dateOrigin"; +import type { CallerDay, RunClocks } from "./types/dateOrigin"; import { childChoicesOf } from "./utils/choiceUtils"; import { QA_INTERNAL_DATE_ORIGIN, VALUE_SYNTAX } from "./constants"; import VDateInputPrompt from "./gui/VDateInputPrompt/VDateInputPrompt"; @@ -63,7 +63,7 @@ export class ChoiceExecutor implements IChoiceExecutor { public focusedProperty: FrontmatterPropertyTarget | null = null; public triggerContext: QuickAddTriggerContext | null = null; public clocks?: RunClocks; - public pickDate = false; + public callerDay?: CallerDay; private pendingAbort: MacroAbortError | null = null; private pendingResult: ChoiceOutcome | null = null; private executionDepth = 0; @@ -134,8 +134,8 @@ export class ChoiceExecutor implements IChoiceExecutor { ? this.triggerContextOverride : { activeFile: this.app.workspace.getActiveFile() }; this.clocks = { - ...this.clocks, now: this.clocks?.now ?? new Date(), + date: this.clocks?.date, }; } this.executionDepth++; @@ -154,7 +154,7 @@ export class ChoiceExecutor implements IChoiceExecutor { // not to a later run on this executor (a script that kept its quickAddApi). this.triggerContextOverride = undefined; this.clocks = undefined; - this.pickDate = false; + this.callerDay = undefined; // Preloaded script modules are scoped to ONE outermost execution: a // cancelled/aborted run must not strand its entries, or a later // trigger on a long-lived executor (api.executeChoice callers reuse @@ -323,6 +323,8 @@ export class ChoiceExecutor implements IChoiceExecutor { } private async applyDateOrigin(choice: IChoice): Promise { + // A folder has no Which day, so the caller's day waits for the picked choice. + if (choice.type === "Multi") return; if ( isTemplateChoice(choice) && shouldRunTemplateNoteDiscovery( @@ -340,12 +342,14 @@ export class ChoiceExecutor implements IChoiceExecutor { async resolveDateOrigin(choice: IChoice): Promise { const { setting, clocks } = dateOriginInput(choice, this); - // Pick a day answers this choice's Which day, not the ones it runs. - this.pickDate = false; + // The caller's day answers this choice's Which day, not the ones it runs. + this.callerDay = undefined; const reservedSeed = setting?.kind === "ask" ? this.variables.get(QA_INTERNAL_DATE_ORIGIN) : undefined; + // One answer per ask, so a later ask in the run still prompts. + if (setting?.kind === "ask") this.variables.delete(QA_INTERNAL_DATE_ORIGIN); const plan = planDateOrigin({ setting, clocks, @@ -354,9 +358,7 @@ export class ChoiceExecutor implements IChoiceExecutor { }); if (plan.status === "inherit") { - if (this.clocks?.invoked) { - this.clocks = { now: this.clocks.now, date: this.clocks.date }; - } + this.clocks = clocks; return; } diff --git a/src/gui/suggesters/choiceSuggester.test.ts b/src/gui/suggesters/choiceSuggester.test.ts index db0963478..6666ab7d9 100644 --- a/src/gui/suggesters/choiceSuggester.test.ts +++ b/src/gui/suggesters/choiceSuggester.test.ts @@ -1026,20 +1026,20 @@ describe("ChoiceSuggester", () => { expect(completion).toHaveBeenCalledWith(); }); - it.each([false, true])( - "holds Shift to pick a day for the chosen run only (enclosing pickDate: %s)", + it.each([undefined, "pick" as const])( + "holds Shift to pick a day for the chosen run only (enclosing callerDay: %s)", async (enclosing) => { - let pickDateDuringRun: boolean | undefined; - executor.pickDate = enclosing; - executor.executeWithFocusedProperty = async () => { pickDateDuringRun = executor.pickDate; }; + let callerDayDuringRun: unknown; + executor.callerDay = enclosing; + executor.executeWithFocusedProperty = async () => { callerDayDuringRun = executor.callerDay; }; const suggester = makeSuggester(rootChoices); suggester.onChooseItem( topNote, new MouseEvent("click", { shiftKey: true }), ); await flushMicrotasks(); - expect(pickDateDuringRun).toBe(true); - expect(executor.pickDate).toBe(enclosing); + expect(callerDayDuringRun).toBe("pick"); + expect(executor.callerDay).toBe(enclosing); }, ); diff --git a/src/gui/suggesters/choiceSuggester.ts b/src/gui/suggesters/choiceSuggester.ts index 9e40a8c5f..7f11d7ab9 100644 --- a/src/gui/suggesters/choiceSuggester.ts +++ b/src/gui/suggesters/choiceSuggester.ts @@ -549,9 +549,9 @@ export default class ChoiceSuggester extends FuzzySuggestModal { // leaf inherits the executor's still-live context, which holds the same // values this level was handed. Falls back to execute() only for stub // executors without the method. - const enclosingPickDate = this.choiceExecutor.pickDate; + const enclosingCallerDay = this.choiceExecutor.callerDay; if ("shiftKey" in evt && evt.shiftKey) { - this.choiceExecutor.pickDate = true; + this.choiceExecutor.callerDay = "pick"; } const run = this.choiceExecutor.executeWithFocusedProperty ? this.choiceExecutor.executeWithFocusedProperty( @@ -563,7 +563,7 @@ export default class ChoiceSuggester extends FuzzySuggestModal { : this.choiceExecutor.execute(item, this.ancestry); // Shift picks a day for this choice, not for the Macro steps after it. const execute = run.finally(() => { - this.choiceExecutor.pickDate = enclosingPickDate; + this.choiceExecutor.callerDay = enclosingCallerDay; }); this.settleCompletion(execute, `Could not run "${item.name}"`); } diff --git a/src/main.ts b/src/main.ts index ec20d038b..483b6f6f4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -602,7 +602,7 @@ export default class QuickAdd extends Plugin { try { current = this.getChoiceById(choiceId); const executor = new ChoiceExecutor(this.app, this); - executor.pickDate = pickDate; + if (pickDate) executor.callerDay = "pick"; await executor.execute(current); } catch (err) { // The outermost handler: the last chance to say which choice failed. diff --git a/src/preflight/collectChoiceRequirements.test.ts b/src/preflight/collectChoiceRequirements.test.ts index ca68677a2..4c9ed1971 100644 --- a/src/preflight/collectChoiceRequirements.test.ts +++ b/src/preflight/collectChoiceRequirements.test.ts @@ -1371,7 +1371,7 @@ describe("collectChoiceRequirements - path-context memo (issue #1484 review fix) }); }); -describe("collectChoiceRequirements - pickDate", () => { +describe("collectChoiceRequirements - pick a day", () => { const collect = (choice: IChoice, executor: IChoiceExecutor, options?: Parameters[4]) => collectChoiceRequirements(app, plugin, executor, choice, options); @@ -1381,7 +1381,7 @@ describe("collectChoiceRequirements - pickDate", () => { } as unknown as App; const plugin = createPreflightPlugin(); - it("collects a date field when pickDate is set on a Today choice", async () => { + it("collects a date field when a Today choice is run with Pick a day", async () => { const choice = createCaptureChoice("Inbox.md"); const requirements = await collect( choice, @@ -1389,7 +1389,7 @@ describe("collectChoiceRequirements - pickDate", () => { ...createChoiceExecutor(), execute: vi.fn(), variables: new Map(), - pickDate: true, + callerDay: "pick", }, ); expect( @@ -1397,7 +1397,7 @@ describe("collectChoiceRequirements - pickDate", () => { ).toBe(true); }); - it("does not collect a date field for a Today choice without pickDate", async () => { + it("does not collect a date field for a Today choice without Pick a day", async () => { const choice = createCaptureChoice("Inbox.md"); const requirements = await collect(choice, createChoiceExecutor()); expect( diff --git a/src/quickAddApi.test.ts b/src/quickAddApi.test.ts index 5ed8a441c..8ef8f75fd 100644 --- a/src/quickAddApi.test.ts +++ b/src/quickAddApi.test.ts @@ -185,8 +185,7 @@ function makeChoiceExecutor() { consumeAbortSignal: vi.fn( (): InstanceType | null => null, ), - pickDate: false as boolean | undefined, - clocks: undefined as { now: Date; date?: Date } | undefined, + callerDay: undefined as "pick" | Date | undefined, }; } @@ -1002,15 +1001,14 @@ describe("executeChoice", () => { expect(executor.execute).toHaveBeenCalled(); }); - it("treats date ask as pickDate for that call only", async () => { + it("treats date ask as a pick for that call only", async () => { const executor = makeChoiceExecutor(); - let pickDateDuringRun: boolean | undefined; - executor.execute.mockImplementation(async () => { pickDateDuringRun = executor.pickDate; }); + let callerDayDuringRun: unknown; + executor.execute.mockImplementation(async () => { callerDayDuringRun = executor.callerDay; }); const { api } = getApi(makeApp(), makePlugin(), executor); await api.executeChoice("C", {}, { date: "ask" }); - expect(pickDateDuringRun).toBe(true); - expect(executor.pickDate).toBe(false); - expect(executor.clocks).toBeUndefined(); + expect(callerDayDuringRun).toBe("pick"); + expect(executor.callerDay).toBeUndefined(); }); it("rejects an invalid Date origin and does not execute", async () => { diff --git a/src/quickAddApi.ts b/src/quickAddApi.ts index 81ebd0039..5d991445c 100644 --- a/src/quickAddApi.ts +++ b/src/quickAddApi.ts @@ -101,7 +101,7 @@ export class QuickAddApi { return; } - const { clocks, pickDate } = choiceExecutor; + const { callerDay } = choiceExecutor; if (!applyInvocationDate(choiceExecutor, options?.date)) { reportError( new Error(`Could not parse date origin '${String(options?.date)}'`), @@ -126,8 +126,7 @@ export class QuickAddApi { await choiceExecutor.execute(choice, chain); } finally { // options.date is this call's day, not the calling script's. - choiceExecutor.clocks = clocks; - choiceExecutor.pickDate = pickDate; + choiceExecutor.callerDay = callerDay; } const abort = choiceExecutor.consumeAbortSignal?.(); choiceExecutor.variables.clear(); diff --git a/src/types/dateOrigin.ts b/src/types/dateOrigin.ts index 1bf6bada3..fb4139ce0 100644 --- a/src/types/dateOrigin.ts +++ b/src/types/dateOrigin.ts @@ -11,13 +11,11 @@ export type DateOrigin = export interface RunClocks { now: Date; date?: Date; - /** - * The caller chose `date` for the choice it runs, so it replaces that - * choice's Which day. Choices nested under it get the day without this. - */ - invoked?: boolean; } +/** A caller's answer to a choice's Which day: ask for one, or this day. */ +export type CallerDay = "pick" | Date; + export function isDateOriginUnit(value: unknown): value is DateOriginUnit { return ( typeof value === "string" && diff --git a/src/utils/resolveDateOrigin.test.ts b/src/utils/resolveDateOrigin.test.ts index 416fdb823..7f82b6177 100644 --- a/src/utils/resolveDateOrigin.test.ts +++ b/src/utils/resolveDateOrigin.test.ts @@ -145,20 +145,16 @@ describe("planDateOrigin", () => { }); describe("applyInvocationDate", () => { - it("treats ask as pickDate and leaves clocks unset", () => { - const executor: { clocks?: { now: Date; date?: Date }; pickDate?: boolean } = - {}; + it("treats ask as a pick", () => { + const executor: { callerDay?: "pick" | Date } = {}; expect(applyInvocationDate(executor, "ask")).toBe(true); - expect(executor.pickDate).toBe(true); - expect(executor.clocks).toBeUndefined(); + expect(executor.callerDay).toBe("pick"); }); - it("parses a concrete day onto clocks", () => { - const executor: { clocks?: { now: Date; date?: Date }; pickDate?: boolean } = - {}; + it("parses a concrete day", () => { + const executor: { callerDay?: "pick" | Date } = {}; expect(applyInvocationDate(executor, "2026-08-21")).toBe(true); - expect(executor.pickDate).toBeUndefined(); - expect(executor.clocks?.date).toBeInstanceOf(Date); + expect(executor.callerDay).toBeInstanceOf(Date); }); it("rejects an unreadable value", () => { diff --git a/src/utils/resolveDateOrigin.ts b/src/utils/resolveDateOrigin.ts index 3bfba0fde..9c5086165 100644 --- a/src/utils/resolveDateOrigin.ts +++ b/src/utils/resolveDateOrigin.ts @@ -2,6 +2,7 @@ import { NLDParser } from "../parsers/NLDParser"; import type IChoice from "../types/choices/IChoice"; import { normalizeDateOrigin, + type CallerDay, type DateOrigin, type DateOriginUnit, type RunClocks, @@ -50,18 +51,20 @@ export function dateFromStoredValue(stored: unknown): Date | undefined { /** * The Which day a choice resolves with, and the clocks it may inherit a day - * from. Pick a day asks even under an enclosing day. + * from. A caller's day replaces both, even under an enclosing day. */ export function dateOriginInput( choice: IChoice, - executor: { clocks?: RunClocks; pickDate?: boolean }, + executor: { clocks?: RunClocks; callerDay?: CallerDay }, ): { setting?: DateOrigin; clocks?: RunClocks } { + const { callerDay, clocks } = executor; const own = normalizeDateOrigin(choice.dateOrigin); - if (!executor.pickDate) return { setting: own, clocks: executor.clocks }; - return { - setting: dateOriginForPick(own), - clocks: executor.clocks && { now: executor.clocks.now }, - }; + if (callerDay === undefined) return { setting: own, clocks }; + const now = clocks?.now ?? new Date(); + if (callerDay === "pick") { + return { setting: dateOriginForPick(own), clocks: { now } }; + } + return { setting: undefined, clocks: { now, date: callerDay } }; } export function planDateOrigin(input: { @@ -70,11 +73,8 @@ export function planDateOrigin(input: { variables?: Map; reservedSeed?: unknown; }): DateOriginPlan { - // A variable names its own day, so only the caller's day replaces it. - if ( - input.clocks?.date && - (input.clocks.invoked || input.setting?.kind !== "variable") - ) { + // A variable names its own day, so an enclosing day doesn't replace it. + if (input.clocks?.date && input.setting?.kind !== "variable") { return { status: "inherit" }; } @@ -127,21 +127,17 @@ export function planDateOrigin(input: { } export function applyInvocationDate( - executor: { clocks?: RunClocks; pickDate?: boolean }, + executor: { callerDay?: CallerDay }, raw: unknown, ): boolean { if (raw === undefined || raw === null) return true; if (typeof raw === "string" && !raw.trim()) return true; if (isPickDateToken(raw)) { - executor.pickDate = true; + executor.callerDay = "pick"; return true; } const date = dateFromStoredValue(raw); if (!date) return false; - executor.clocks = { - now: executor.clocks?.now ?? new Date(), - date, - invoked: true, - }; + executor.callerDay = date; return true; } From c77fa43815c266e1897d990ab7c36ae389181046 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 26 Oct 2026 20:55:07 +0100 Subject: [PATCH 5/8] fix(preflight): preview the day a caller handed the choice --- .../runOnePagePreflight.filenamePreview.test.ts | 14 ++++++++++++++ src/preflight/runOnePagePreflight.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/preflight/runOnePagePreflight.filenamePreview.test.ts b/src/preflight/runOnePagePreflight.filenamePreview.test.ts index f086814c0..252737f37 100644 --- a/src/preflight/runOnePagePreflight.filenamePreview.test.ts +++ b/src/preflight/runOnePagePreflight.filenamePreview.test.ts @@ -344,4 +344,18 @@ describe("the one-page preview follows the choice's Which day", () => { const out = await computePreview!({ title: "Standup" }); expect(out[0].text).toBe("Daily/2026-08-21 Standup"); }); + + it("previews the day the caller handed the choice", async () => { + const executor = createExecutor(); + executor.callerDay = new Date(2026, 2, 1); + await runOnePagePreflight( + createApp(), + createPlugin(), + executor, + withDateOrigin({ kind: "relative", offset: -1, unit: "days" }), + ); + + const out = await computePreview!({ title: "Standup" }); + expect(out[0].text).toBe("Daily/2026-03-01 Standup"); + }); }); diff --git a/src/preflight/runOnePagePreflight.ts b/src/preflight/runOnePagePreflight.ts index a8817ff39..2d96759eb 100644 --- a/src/preflight/runOnePagePreflight.ts +++ b/src/preflight/runOnePagePreflight.ts @@ -46,7 +46,7 @@ function previewRunClocks( reservedSeed: setting?.kind === "ask" ? values[QA_INTERNAL_DATE_ORIGIN] : undefined, }); - if (plan.status !== "set") return choiceExecutor.clocks; + if (plan.status !== "set") return clocks; return { now: choiceExecutor.clocks?.now ?? new Date(), date: plan.date }; } From eba9e1f20724cb8fbcf15059001eb6aad2802a2d Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 26 Oct 2026 21:02:30 +0100 Subject: [PATCH 6/8] fix(preflight): keep a caller's day off a macro's steps --- src/choiceExecutor.ts | 5 +---- src/main.ts | 4 ++-- src/preflight/collectChoiceRequirements.test.ts | 16 ++++++++++++++++ src/preflight/collectChoiceRequirements.ts | 12 +++++++++--- src/preflight/discoveryFormPlan.test.ts | 10 ++++++++++ src/preflight/discoveryFormPlan.ts | 4 ++-- src/quickAddApi.test.ts | 3 ++- src/utils/resolveDateOrigin.test.ts | 5 +++-- 8 files changed, 45 insertions(+), 14 deletions(-) diff --git a/src/choiceExecutor.ts b/src/choiceExecutor.ts index 7f3b6941b..3c4e6751a 100644 --- a/src/choiceExecutor.ts +++ b/src/choiceExecutor.ts @@ -133,10 +133,7 @@ export class ChoiceExecutor implements IChoiceExecutor { this.triggerContextOverride !== undefined ? this.triggerContextOverride : { activeFile: this.app.workspace.getActiveFile() }; - this.clocks = { - now: this.clocks?.now ?? new Date(), - date: this.clocks?.date, - }; + this.clocks = { now: new Date() }; } this.executionDepth++; return enclosingClocks; diff --git a/src/main.ts b/src/main.ts index 483b6f6f4..0c83bc955 100644 --- a/src/main.ts +++ b/src/main.ts @@ -593,7 +593,7 @@ export default class QuickAdd extends Plugin { private async runRegisteredChoice( choiceId: string, fallbackName: string, - pickDate = false, + pickDay = false, ): Promise { // Resolved outside the try so the failure can name the choice the user // actually ran; a bare UUID tells them nothing. Falls back to the name @@ -602,7 +602,7 @@ export default class QuickAdd extends Plugin { try { current = this.getChoiceById(choiceId); const executor = new ChoiceExecutor(this.app, this); - if (pickDate) executor.callerDay = "pick"; + if (pickDay) executor.callerDay = "pick"; await executor.execute(current); } catch (err) { // The outermost handler: the last chance to say which choice failed. diff --git a/src/preflight/collectChoiceRequirements.test.ts b/src/preflight/collectChoiceRequirements.test.ts index 4c9ed1971..78624e9f9 100644 --- a/src/preflight/collectChoiceRequirements.test.ts +++ b/src/preflight/collectChoiceRequirements.test.ts @@ -1397,6 +1397,22 @@ describe("collectChoiceRequirements - pick a day", () => { ).toBe(true); }); + it("does not collect a date field for an Ask choice handed a day", async () => { + const choice = Object.assign(createCaptureChoice("Inbox.md"), { dateOrigin: { kind: "ask" } }); + const requirements = await collect( + choice, + { + ...createChoiceExecutor(), + execute: vi.fn(), + variables: new Map(), + callerDay: new Date(2026, 2, 1), + }, + ); + expect( + requirements.some((requirement) => requirement.id === QA_INTERNAL_DATE_ORIGIN), + ).toBe(false); + }); + it("does not collect a date field for a Today choice without Pick a day", async () => { const choice = createCaptureChoice("Inbox.md"); const requirements = await collect(choice, createChoiceExecutor()); diff --git a/src/preflight/collectChoiceRequirements.ts b/src/preflight/collectChoiceRequirements.ts index 55eca43c9..3ee90d27b 100644 --- a/src/preflight/collectChoiceRequirements.ts +++ b/src/preflight/collectChoiceRequirements.ts @@ -66,6 +66,8 @@ interface CollectChoiceRequirementsOptions { * The engine consumes these entries instead of re-loading (delete-on-use). */ preloadedUserScripts?: Map; + /** A step of the choice being run, so the caller's day isn't its to answer. */ + step?: boolean; } async function readTemplate(app: App, path: string): Promise { @@ -525,7 +527,7 @@ function registerSiblingCaptureTargets( function dateOriginRequirement( choice: IChoice, - choiceExecutor: IChoiceExecutor, + choiceExecutor: Pick, ): FieldRequirement | undefined { const { setting: dateOrigin, clocks } = dateOriginInput(choice, choiceExecutor); if (dateOrigin?.kind !== "ask" || clocks?.date) { @@ -544,7 +546,7 @@ function dateOriginRequirement( function withDateOriginRequirement( choice: IChoice, - choiceExecutor: IChoiceExecutor, + choiceExecutor: Pick, requirements: FieldRequirement[], ): FieldRequirement[] { const dateRequirement = dateOriginRequirement(choice, choiceExecutor); @@ -579,7 +581,11 @@ export async function collectChoiceRequirements( } else { return []; } - return withDateOriginRequirement(choice, choiceExecutor, requirements); + return withDateOriginRequirement( + choice, + options?.step ? { clocks: choiceExecutor.clocks } : choiceExecutor, + requirements, + ); } export function listDeferredMacroSteps( diff --git a/src/preflight/discoveryFormPlan.test.ts b/src/preflight/discoveryFormPlan.test.ts index 7a2973b26..6704e70dd 100644 --- a/src/preflight/discoveryFormPlan.test.ts +++ b/src/preflight/discoveryFormPlan.test.ts @@ -257,4 +257,14 @@ describe("discovery form planning", () => { expect(executor.variables.get(QA_INTERNAL_DATE_ORIGIN)).toBe("@date:2026-09-05"); }); }); + + it("asks for a picked day on the macro, not on its steps", async () => { + executor.callerDay = "pick"; + const plan = await buildDiscoveryFormPlan(app, plugin, executor, + macro(nested(template, "note"), nested(capture, "cap"))); + if (!plan) throw new Error("Expected discovery form"); + expect(plan.steps.map((step) => [step.occurrenceId, step.bindings.has(QA_INTERNAL_DATE_ORIGIN)])) + .toEqual([["macro", true], ["note", false], ["cap", false]]); + expect(executor.callerDay).toBe("pick"); + }); }); diff --git a/src/preflight/discoveryFormPlan.ts b/src/preflight/discoveryFormPlan.ts index c0af7980a..2d5a385e2 100644 --- a/src/preflight/discoveryFormPlan.ts +++ b/src/preflight/discoveryFormPlan.ts @@ -164,7 +164,7 @@ export async function buildDiscoveryFormPlan( if (!collectable) continue; const collected = getUnresolvedRequirements(await collectChoiceRequirements( app, plugin, executor, collectable, - { preloadedUserScripts: executor.preloadedUserScripts }, + { preloadedUserScripts: executor.preloadedUserScripts, step: true }, ), executor.variables); const existingNoteInputs = new Map(); if (discovery && getExistingNoteAction(discovery.existingNoteAction) !== "open") { @@ -173,7 +173,7 @@ export async function buildDiscoveryFormPlan( fileNameFormat: { enabled: false, format: "" }, folder: { ...discovery.folder, enabled: false }, }; - for (const requirement of await collectChoiceRequirements(app, plugin, executor, existingTarget)) { + for (const requirement of await collectChoiceRequirements(app, plugin, executor, existingTarget, { step: true })) { existingNoteInputs.set(requirement.id, requirement); } } diff --git a/src/quickAddApi.test.ts b/src/quickAddApi.test.ts index 8ef8f75fd..b3262a9c2 100644 --- a/src/quickAddApi.test.ts +++ b/src/quickAddApi.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { App } from "obsidian"; import { getDateVariableFormat, rememberDateVariableFormat } from "./formatters/helpers/dateTokens"; import type QuickAdd from "./main"; +import type { CallerDay } from "./types/dateOrigin"; // --------------------------------------------------------------------------- // Hoisted mock state / spies. These back the vi.mock factories below. @@ -185,7 +186,7 @@ function makeChoiceExecutor() { consumeAbortSignal: vi.fn( (): InstanceType | null => null, ), - callerDay: undefined as "pick" | Date | undefined, + callerDay: undefined as CallerDay | undefined, }; } diff --git a/src/utils/resolveDateOrigin.test.ts b/src/utils/resolveDateOrigin.test.ts index 7f82b6177..3cd62f1fa 100644 --- a/src/utils/resolveDateOrigin.test.ts +++ b/src/utils/resolveDateOrigin.test.ts @@ -1,6 +1,7 @@ // @vitest-environment jsdom import realMoment from "moment"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CallerDay } from "../types/dateOrigin"; import { addCalendarOffset, applyInvocationDate, @@ -146,13 +147,13 @@ describe("planDateOrigin", () => { describe("applyInvocationDate", () => { it("treats ask as a pick", () => { - const executor: { callerDay?: "pick" | Date } = {}; + const executor: { callerDay?: CallerDay } = {}; expect(applyInvocationDate(executor, "ask")).toBe(true); expect(executor.callerDay).toBe("pick"); }); it("parses a concrete day", () => { - const executor: { callerDay?: "pick" | Date } = {}; + const executor: { callerDay?: CallerDay } = {}; expect(applyInvocationDate(executor, "2026-08-21")).toBe(true); expect(executor.callerDay).toBeInstanceOf(Date); }); From 0804ab8123cf2d915aecc072021817a34672a61f Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 30 Oct 2026 22:42:26 +0100 Subject: [PATCH 7/8] fix(preflight): let a Template run on its own answer its caller's day --- src/preflight/discoveryFormPlan.test.ts | 8 ++++++++ src/preflight/discoveryFormPlan.ts | 6 ++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/preflight/discoveryFormPlan.test.ts b/src/preflight/discoveryFormPlan.test.ts index 6704e70dd..7a87fb292 100644 --- a/src/preflight/discoveryFormPlan.test.ts +++ b/src/preflight/discoveryFormPlan.test.ts @@ -267,4 +267,12 @@ describe("discovery form planning", () => { .toEqual([["macro", true], ["note", false], ["cap", false]]); expect(executor.callerDay).toBe("pick"); }); + + it("asks for a picked day on a Template run on its own", async () => { + executor.callerDay = "pick"; + const plan = await buildDiscoveryFormPlan(app, plugin, executor, template); + if (!plan) throw new Error("Expected discovery form"); + expect(plan.steps.map((step) => [step.occurrenceId, step.bindings.has(QA_INTERNAL_DATE_ORIGIN)])) + .toEqual([[template.id, true]]); + }); }); diff --git a/src/preflight/discoveryFormPlan.ts b/src/preflight/discoveryFormPlan.ts index 2d5a385e2..e2e10d24b 100644 --- a/src/preflight/discoveryFormPlan.ts +++ b/src/preflight/discoveryFormPlan.ts @@ -142,6 +142,8 @@ export async function buildDiscoveryFormPlan( steps.push({ occurrenceId: choice.id, choiceId: choice.id, noteId: null, bindings }); } } + // A Template run on its own is its only entry, and the caller's day is its to answer. + const entriesAreSteps = isMacroChoice(choice); for (const entry of entries) { const group = { ...entry.group, id: entry.occurrenceId }; const child = entry.kind === "choice" ? entry.choice : null; @@ -164,7 +166,7 @@ export async function buildDiscoveryFormPlan( if (!collectable) continue; const collected = getUnresolvedRequirements(await collectChoiceRequirements( app, plugin, executor, collectable, - { preloadedUserScripts: executor.preloadedUserScripts, step: true }, + { preloadedUserScripts: executor.preloadedUserScripts, step: entriesAreSteps }, ), executor.variables); const existingNoteInputs = new Map(); if (discovery && getExistingNoteAction(discovery.existingNoteAction) !== "open") { @@ -173,7 +175,7 @@ export async function buildDiscoveryFormPlan( fileNameFormat: { enabled: false, format: "" }, folder: { ...discovery.folder, enabled: false }, }; - for (const requirement of await collectChoiceRequirements(app, plugin, executor, existingTarget, { step: true })) { + for (const requirement of await collectChoiceRequirements(app, plugin, executor, existingTarget, { step: entriesAreSteps })) { existingNoteInputs.set(requirement.id, requirement); } } From ad20748c3ceb1c9ab30c664c02a56a4d9c06db79 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 30 Oct 2026 22:52:43 +0100 Subject: [PATCH 8/8] fix(preflight): give a macro's steps the day the macro was handed --- src/preflight/collectChoiceRequirements.ts | 9 +++++++-- src/preflight/discoveryFormPlan.test.ts | 9 +++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/preflight/collectChoiceRequirements.ts b/src/preflight/collectChoiceRequirements.ts index 3ee90d27b..30531b2a6 100644 --- a/src/preflight/collectChoiceRequirements.ts +++ b/src/preflight/collectChoiceRequirements.ts @@ -66,7 +66,10 @@ interface CollectChoiceRequirementsOptions { * The engine consumes these entries instead of re-loading (delete-on-use). */ preloadedUserScripts?: Map; - /** A step of the choice being run, so the caller's day isn't its to answer. */ + /** + * A step of the choice being run. The caller's day answers that choice, so + * a step only sees the day it will inherit. + */ step?: boolean; } @@ -583,7 +586,9 @@ export async function collectChoiceRequirements( } return withDateOriginRequirement( choice, - options?.step ? { clocks: choiceExecutor.clocks } : choiceExecutor, + options?.step + ? { clocks: dateOriginInput(choice, choiceExecutor).clocks } + : choiceExecutor, requirements, ); } diff --git a/src/preflight/discoveryFormPlan.test.ts b/src/preflight/discoveryFormPlan.test.ts index 7a87fb292..97b6eed69 100644 --- a/src/preflight/discoveryFormPlan.test.ts +++ b/src/preflight/discoveryFormPlan.test.ts @@ -268,6 +268,15 @@ describe("discovery form planning", () => { expect(executor.callerDay).toBe("pick"); }); + it("asks no step for a day the macro was handed", async () => { + executor.callerDay = new Date(2026, 2, 1); + capture.dateOrigin = { kind: "ask" }; + const plan = await buildDiscoveryFormPlan(app, plugin, executor, + macro(nested(template, "note"), nested(capture, "cap"))); + if (!plan) throw new Error("Expected discovery form"); + expect(plan.requirements.some((field) => field.id === QA_INTERNAL_DATE_ORIGIN)).toBe(false); + }); + it("asks for a picked day on a Template run on its own", async () => { executor.callerDay = "pick"; const plan = await buildDiscoveryFormPlan(app, plugin, executor, template);