From 720ea7bbb6a832ae3991db495cfdd5079e0c7d72 Mon Sep 17 00:00:00 2001 From: muditagrawal2007 Date: Wed, 28 Oct 2026 00:28:14 +0530 Subject: [PATCH] fix(#169): keep the date that was picked in the date picker A date picker always submits an ISO "yyyy-mm-dd" value, no matter which locale the browser or the user is using. `new Date("2026-06-24")` parses such a value as UTC midnight, so formatting it in local time moved the date back by one day for every timezone west of UTC. Picking the 24th of June produced a note dated the 23rd of June, and picking today produced yesterday. src/utils/dateAndTime.ts - Added formatDateInput(), which parses an ISO date as a local date and formats it with the user's date format. Values that aren't an ISO date still go through the generic parser. src/variables/types/date.ts - Use formatDateInput() instead of new Date(input). - An empty date input now yields an empty string instead of "Invalid date". tests/variables/date.spec.ts - Added unit tests for the date variable. tests/variables/date.timezone.spec.ts - Added regression tests for the bug. They only fail in a timezone behind UTC, so they run in a separate suite (jest.timezone.config.js) that has the timezone set for the whole process. Jest sandboxes process.env, so the timezone can't be changed from inside a test. --- jest.config.js | 3 + jest.timezone.config.js | 19 +++++ package.json | 5 +- src/utils/dateAndTime.ts | 31 ++++++++ src/variables/types/date.ts | 8 +- tests/jest-timezone-setup.js | 11 +++ tests/variables/date.spec.ts | 42 ++++++++++ tests/variables/date.timezone.spec.ts | 109 ++++++++++++++++++++++++++ 8 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 jest.timezone.config.js create mode 100644 tests/jest-timezone-setup.js create mode 100644 tests/variables/date.spec.ts create mode 100644 tests/variables/date.timezone.spec.ts diff --git a/jest.config.js b/jest.config.js index 861a582..225771d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,6 +2,9 @@ module.exports = { transform: { "^.+\\.ts?$": "ts-jest" }, testEnvironment: "node", testRegex: "/tests/.*\\.(test|spec)?\\.(ts|tsx)$", + // The timezone dependent tests run in their own suite, see + // jest.timezone.config.js. + testPathIgnorePatterns: ["/node_modules/", "\\.timezone\\.spec\\.[jt]sx?$"], moduleFileExtensions: ["ts", "js", "json", "node"], moduleNameMapper: { "^@templates/(.*)$": "/src/$1", diff --git a/jest.timezone.config.js b/jest.timezone.config.js new file mode 100644 index 0000000..99618ab --- /dev/null +++ b/jest.timezone.config.js @@ -0,0 +1,19 @@ +// A second test suite that runs in a timezone behind UTC. +// +// A date picker always submits an ISO "yyyy-mm-dd" value, which the +// JavaScript Date constructor reads as UTC midnight. Formatting that value in +// local time moved the date back by one day for every timezone west of UTC. +// +// Jest sandboxes process.env, so the local timezone can't be changed from +// inside a test. The tests that depend on it run here instead, in a process +// that has the timezone set up front. +// +// Run it with: npm run test:timezone +const baseConfig = require("./jest.config"); + +module.exports = { + ...baseConfig, + globalSetup: "/tests/jest-timezone-setup.js", + testRegex: "/tests/.*\\.timezone\\.spec\\.(ts|tsx)$", + testPathIgnorePatterns: ["/node_modules/"] +}; diff --git a/package.json b/package.json index caca106..33187b0 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,9 @@ "update": "npm install -g generator-joplin && yo joplin --node-package-manager npm --update --force", "lint": "eslint '{src,tests}/**/*.{ts,json}'", "release": "standard-version", - "test": "jest", - "coverage": "jest --coverage", + "test": "jest && npm run test:timezone", + "test:timezone": "jest --config jest.timezone.config.js", + "coverage": "jest --coverage && npm run test:timezone", "announce": "node scripts/announceRelease.js", "postpublish": "npm run announce", "updateVersion": "webpack --env joplin-plugin-config=updateVersion" diff --git a/src/utils/dateAndTime.ts b/src/utils/dateAndTime.ts index 4588ed6..61a2176 100644 --- a/src/utils/dateAndTime.ts +++ b/src/utils/dateAndTime.ts @@ -47,6 +47,37 @@ export class DateAndTimeUtils { return moment(ms).format(format); } + /** + * Formats the value of an HTML date input using the given format. + * + * An HTML `` always submits an ISO "yyyy-mm-dd" string, + * no matter which locale the browser or the user is using. + * `new Date("2026-07-07")` parses such a string as UTC midnight, so + * formatting it in local time moves the date back by one day for every + * timezone west of UTC. Parsing it as a local date instead keeps the day + * the user actually picked in the date picker. + */ + public formatDateInput(input: string, format: string | null = null): string { + if (!format) { + format = this.getDateFormat(); + } + + const trimmedInput = input.trim(); + + const isoDate = moment(trimmedInput, "YYYY-MM-DD", true); + if (isoDate.isValid()) { + return this.formatMsToLocal(isoDate.valueOf(), format); + } + + // Fall back to the generic parser for values that aren't an ISO date. + const parsedDate = new Date(trimmedInput); + if (Number.isNaN(parsedDate.getTime())) { + throw new Error(`Was not able to parse ${input} as a date`); + } + + return this.formatMsToLocal(parsedDate.getTime(), format); + } + public formatLocalToJoplinCompatibleUnixTime(input: string, format: string | null = null): number { if (!format) { format = this.getDateTimeFormat(); diff --git a/src/variables/types/date.ts b/src/variables/types/date.ts index 3dd5241..4a04cc0 100644 --- a/src/variables/types/date.ts +++ b/src/variables/types/date.ts @@ -6,8 +6,12 @@ export class DateCustomVariable extends CustomVariable { static definitionName = "date"; public processInput(input: string, dateAndTimeUtils: DateAndTimeUtils): string { - const inputDate = new Date(input); - return dateAndTimeUtils.formatMsToLocal(inputDate.getTime(), dateAndTimeUtils.getDateFormat()); + // The date input is empty when the user didn't pick a date at all. + if (!input.trim()) { + return ""; + } + + return dateAndTimeUtils.formatDateInput(input, dateAndTimeUtils.getDateFormat()); } protected inputHTML(): string { diff --git a/tests/jest-timezone-setup.js b/tests/jest-timezone-setup.js new file mode 100644 index 0000000..cef88d5 --- /dev/null +++ b/tests/jest-timezone-setup.js @@ -0,0 +1,11 @@ +// The date picker bug only shows up in timezones west of UTC, because +// `new Date("2026-06-24")` is parsed as UTC midnight and then formatted in +// local time. Jest sandboxes process.env, so the local timezone of a test +// can't be changed from inside the test. The tests that depend on it +// therefore run in a separate suite with the timezone set for the whole +// process. +// +// See jest.timezone.config.js. +module.exports = async () => { + process.env.TZ = "America/New_York"; +}; diff --git a/tests/variables/date.spec.ts b/tests/variables/date.spec.ts new file mode 100644 index 0000000..7a7caa6 --- /dev/null +++ b/tests/variables/date.spec.ts @@ -0,0 +1,42 @@ +import { DateAndTimeUtils } from "@templates/utils/dateAndTime"; +import { DateCustomVariable } from "@templates/variables/types/date"; + +describe("Date custom variable", () => { + const dateFormat = "DD/MM/YYYY"; + const timeFormat = "HH:mm"; + const userLocale = "en_GB"; + + const dateAndTimeUtils = new DateAndTimeUtils(userLocale, dateFormat, timeFormat); + + test("should format the date that was picked in the date picker", () => { + const variable = new DateCustomVariable("some_date", "Some date"); + + expect(variable.processInput("2026-06-24", dateAndTimeUtils)).toEqual("24/06/2026"); + }); + + test("should format the date according to the user config", () => { + const utils = new DateAndTimeUtils(userLocale, "DD.MM.YYYY", timeFormat); + const variable = new DateCustomVariable("some_date", "Some date"); + + expect(variable.processInput("2026-06-24", utils)).toEqual("24.06.2026"); + }); + + test("should return an empty string when no date was picked", () => { + const variable = new DateCustomVariable("some_date", "Some date"); + + expect(variable.processInput("", dateAndTimeUtils)).toEqual(""); + expect(variable.processInput(" ", dateAndTimeUtils)).toEqual(""); + }); + + test("should throw an error when the date can't be parsed", () => { + const variable = new DateCustomVariable("some_date", "Some date"); + + expect(() => variable.processInput("not a date", dateAndTimeUtils)).toThrow(); + }); + + test("should still support dates that aren't in the ISO format", () => { + const variable = new DateCustomVariable("some_date", "Some date"); + + expect(variable.processInput("2026/06/24", dateAndTimeUtils)).toEqual("24/06/2026"); + }); +}); diff --git a/tests/variables/date.timezone.spec.ts b/tests/variables/date.timezone.spec.ts new file mode 100644 index 0000000..1fa7320 --- /dev/null +++ b/tests/variables/date.timezone.spec.ts @@ -0,0 +1,109 @@ +import joplin from "api"; + +import * as assert from "assert"; +import * as dedent from "dedent"; + +import { DateAndTimeUtils } from "@templates/utils/dateAndTime"; +import { Logger } from "@templates/logger"; +import { Parser } from "@templates/parser"; + +import { CustomVariable } from "@templates/variables/types/base"; +import { DateCustomVariable } from "@templates/variables/types/date"; + +import * as templateVariableViews from "@templates/views/templateVariables"; + +// This suite runs in a timezone behind UTC, see jest.timezone.config.js. +describe("Date custom variable in a timezone behind UTC", () => { + const dateFormat = "DD/MM/YYYY"; + const timeFormat = "HH:mm"; + const userLocale = "en_GB"; + + const dateAndTimeUtils = new DateAndTimeUtils(userLocale, dateFormat, timeFormat); + const logger = new Logger(__dirname); + + const testVariableTypes = (variableTypes: { [name: string]: typeof CustomVariable }) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + jest.spyOn(templateVariableViews, "setTemplateVariablesView").mockImplementation(async (handle: string, title: string, variableDict: { [name: string]: CustomVariable }) => { + for (const [name, variable] of Object.entries(variableDict)) { + expect(variable instanceof variableTypes[name]).toBeTruthy(); + } + }); + }; + + const handleVariableDialog = (id: "cancel" | "ok", variables: { [name: string]: string }) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + jest.spyOn(joplin.views.dialogs, "open").mockImplementation(async (handle: string) => { + if (id === "cancel") { + return { id }; + } + + return { + id, + formData: { + variables + } + }; + }); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("should run in a timezone behind UTC", () => { + // Guards the premise of this suite. An ISO date is read as UTC + // midnight, so in a timezone behind UTC it is already yesterday. + expect(new Date("2026-06-24").getDate()).toEqual(23); + }); + + test("should keep the day that was picked in the date picker (#169)", () => { + const variable = new DateCustomVariable("some_date", "Some date"); + + expect(variable.processInput("2026-06-24", dateAndTimeUtils)).toEqual("24/06/2026"); + }); + + test("should keep the day that was picked in the date picker at a month boundary (#169)", () => { + const variable = new DateCustomVariable("some_date", "Some date"); + + // The old behaviour turned this into 31/05/2026. + expect(variable.processInput("2026-06-01", dateAndTimeUtils)).toEqual("01/06/2026"); + }); + + test("should keep the day that was picked in the date picker at a year boundary (#169)", () => { + const variable = new DateCustomVariable("some_date", "Some date"); + + // The old behaviour turned this into 31/12/2025. + expect(variable.processInput("2026-01-01", dateAndTimeUtils)).toEqual("01/01/2026"); + }); + + test("should not shift the date in a note created from a template (#169)", async () => { + const parser = new Parser(dateAndTimeUtils, "variable-dialog", logger); + + const template = { + id: "note-id", + title: "Some Template", + body: dedent` + --- + some_date: date + --- + + some_date: {{ some_date }} + ` + }; + + testVariableTypes({ + some_date: DateCustomVariable + }); + + handleVariableDialog("ok", { + some_date: "2026-06-24" + }); + + const parsedTemplate = await parser.parseTemplate(template); + + assert(parsedTemplate); + expect(parsedTemplate.body).toEqual(dedent` + some_date: 24/06/2026 + `); + }); +});