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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/(.*)$": "<rootDir>/src/$1",
Expand Down
19 changes: 19 additions & 0 deletions jest.timezone.config.js
Original file line number Diff line number Diff line change
@@ -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: "<rootDir>/tests/jest-timezone-setup.js",
testRegex: "/tests/.*\\.timezone\\.spec\\.(ts|tsx)$",
testPathIgnorePatterns: ["/node_modules/"]
};
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
31 changes: 31 additions & 0 deletions src/utils/dateAndTime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<input type="date">` 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();
Expand Down
8 changes: 6 additions & 2 deletions src/variables/types/date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions tests/jest-timezone-setup.js
Original file line number Diff line number Diff line change
@@ -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";
};
42 changes: 42 additions & 0 deletions tests/variables/date.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
109 changes: 109 additions & 0 deletions tests/variables/date.timezone.spec.ts
Original file line number Diff line number Diff line change
@@ -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
`);
});
});