mirror of
https://github.com/twentyhq/twenty.git
synced 2026-04-18 14:01:45 -04:00
This PR implements the necessary tools to have `react-datepicker` calendar and our date picker components work reliably no matter the timezone difference between the user execution environment and the user application timezone. Fixes https://github.com/twentyhq/core-team-issues/issues/1781 This PR won't cover everything needed to have Twenty handle timezone properly, here is the follow-up issue : https://github.com/twentyhq/core-team-issues/issues/1807 # Features in this PR This PR brings a lot of features that have to be merged together. - DATE field type is now handled as string only, because it shouldn't involve timezone nor the JS Date object at all, since it is a day like a birthday date, and not an absolute point in time. - DATE_TIME field wasn't properly handled when the user settings timezone was different from the system one - A timezone abbreviation suffix has been added to most DATE_TIME display component, only when the timezone is different from the system one in the settings. - A lot of bugs, small features and improvements have been made here : https://github.com/twentyhq/core-team-issues/issues/1781 # Handling of timezones ## Essential concepts This topic is so complex and easy to misunderstand that it is necessary to define the precise terms and concepts first. It resembles character encoding and should be treated with the same care. - Wall-clock time : the time expressed in the timezone of a user, it is distinct from the absolute point in time it points to, much like a pointer being a different value than the value that it points to. - Absolute time : a point in time, regardless of the timezone, it is an objective point in time, of course it has to be expressed in a given timezone, because we have to talk about when it is located in time between humans, but it is in fact distinct from any wall clock time, it exists in itself without any clock running on earth. However, by convention the low-level way to store an absolute point in time is in UTC, which is a timezone, because there is no way to store an absolute point in time without a referential, much like a point in space cannot be stored without a referential. - DST : Daylight Save Time, makes the timezone shift in a specific period every year in a given timezone, to make better use of longer days for various reasons, not all timezones have DST. DST can be 1 hour or 30 min, 45 min, which makes computation difficult. - UTC : It is NOT an “absolute timezone”, it is the wall-clock time at 0° longitude without DST, which is an arbitrary and shared human convention. UTC is often used as the standard reference wall-clock time for talking about absolute point in time without having to do timezone and DST arithmetic. PostgreSQL stores everything in UTC by convention, but outputs everything in the server’s SESSION TIMEZONE. ## How should an absolute point in time be stored ? Since an absolute point in time is essentially distinct from its timezone it could be stored in an absolute way, but in practice it is impossible to store an absolute point in time without a referential. We have to say that a rocket launched at X given time, in UTC, EST, CET, etc. And of course, someone in China will say that it launched at 10:30, while in San Francisco it will have launched at 19:30, but it is THE SAME absolute point in time. Let’s take a related example in computer science with character encoding. If a text is stored without the associated encoding table, the correct meaning associated to the bits stored in memory can be lost forever. It can become impossible for a program to guess what encoding table should be used for a given text stored as bits, thus the glitches that appeared a lot back in the early days of internet and document processing. The same can happen with date time storing, if we don’t have the timezone associated with the absolute point in time, the information of when it absolutely happened is lost. It is NOT necessary to store an absolute point in time in UTC, it is more of a standard and practical wall-clock time to be associated with an absolute point in time. But an absolute point in time MUST be store with a timezone, with its time referential, otherwise the information of when it absolutely happened is lost. For example, it is easier to pass around a date as a string in UTC, like `2024-01-02T00:00:00Z` because it allows front-end and back-end code to “talk” in the same standard and DST-free wall-clock time, BUT it is not necessary. Because we have date libraries that operate on the standard ISO timezone tables, we can talk in different timezone and let the libraries handle the conversion internally. It is false to say that UTC is an absolute timezone or an absolute point in time, it is just the standard, conventional time referential, because one can perfectly store every absolute points in time in UTC+10 with a complex DST table and have the exactly correct absolute points in time, without any loss of information, without having any UTC+0 dates involved. Thus storing an absolute point in time without a timezone associated, for example with `timestamp` PostgreSQL data type, is equivalent to storing a wall-clock time and then throwing away voluntarily the information that allows to know when it absolutely happened, which is a voluntary data-loss if the code that stores and retrieves those wall-clock points in time don’t store the associated timezone somewhere. This is why we use `timestamptz` type in PostgreSQL, so that we make sure that the correct absolute point in time is stored at the exact time we send it to PostgreSQL server, no matter the front-end, back-end and SQL server's timezone differences. ## The JavaScript Date object The native JavaScript Date object is now officially considered legacy ([source](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date)), the Date object stores an absolute point in time BUT it forces the storage to use its execution environment timezone, and one CANNOT modify this timezone, this is a legacy behavior. To obtain the desired result and store an absolute point in time with an arbitrary timezone there are several options : - The new Temporal API that is the successor of the legacy Date object. - Moment / Luxon / @date-fns/tz that expose objects that allow to use any timezone to store an absolute point in time. ## How PostgreSQL stores absolute point in times PostgreSQL stores absolute points in time internally in UTC ([source](https://www.postgresql.org/docs/current/datatype-datetime.html#DATATYPE-DATETIME-INPUT-TIME-STAMPS)), but the output date is expressed in the server’s session timezone ([source](https://www.postgresql.org/docs/current/sql-set.html)) which can be different from UTC. Example with the object companies in Twenty seed database, on a local instance, with a new “datetime” custom column : <img width="374" height="554" alt="image" src="https://github.com/user-attachments/assets/4394cb43-d97e-4479-801d-ca068f800e39" /> <img width="516" height="524" alt="image" src="https://github.com/user-attachments/assets/b652f36a-d2e2-47a4-8950-647ca688cbbd" /> ## Why can’t I just use the JavaScript native Date object with some manual logic ? Because the JavaScript Date object does not allow to change its internal timezone, the libraries that are based on it will behave on the execution environment timezone, thus leading to bugs that appear only on the computers of users in a timezone but not for other in another timezone. In our case the `react-datepicker` library forces to use the `Date` object, thus forcing the calendar to behave in the execution environment system timezone, which causes a lot of problems when we decide to display the Twenty application DATE_TIME values in another timezone than the user system one, the bugs that appear will be of the off-by-one date class, for example clicking on 23 will select 24, thus creating an unreliable feature for some system / application timezone combinations. A solution could be to manually compute the difference of minutes between the application user and the system timezones, but that’s not reliable because of DST which makes this computation unreliable when DST are applied at different period of the year for the two timezones. ## Why can’t I compute the timezone difference manually ? Because of DST, the work to compute the timezone difference reliably, not just for the usual happy path, is equivalent to developing the internal mechanism of a date timezone library, which is equivalent to use a library that handles timezones. ## Using `@date-fns/tz` to solve this problem We could have used `luxon` but it has a heavy bundle size, so instead we rely here on `@date-fns/tz` (~1kB) which gives us a `TZDate` object that allows to use any given timezone to store an absolute point-in-time. The solution here is to trick `react-datepicker` by shifting a Date object by the difference of timezone between the user application timezone and the system timezone. Let’s take a concerte example. System timezone : Midway, ⇒ UTC-11:00, has no DST. User application timezone : Auckland, NZ ⇒ UTC+13:00, has a DST. We’ll take the NZ daylight time, so that will make a timezone difference of 24 hours ! Let’s take an error-prone date : `2025-01-01T00:00:00` . This date is usually a good test-case because it can generate three classes of bugs : off-by-one day bugs, off-by-one month bugs and off-by-one year bugs, at the same time. Here is the absolute point in time we take expressed in the different wall-clock time points we manipulate Case | In system timezone ⇒ UTC-11 | In UTC | In user application timezone ⇒ UTC+13 -- | -- | -- | -- Original date | `2024-12-31T00:00:00-11:00` | `2024-12-31T11:00:00Z` | `2025-01-01T00:00:00+13:00` Date shifted for react-datepicker | `2025-01-01T00:00:00-11:00` | `2025-01-01T11:00:00Z` | `2025-01-02T00:00:00+13:00` We can see with this table that we have the number part of the date that is the same (`2025-01-01T00:00:00`) but with a different timezone to “trick” `react-datepicker` and have it display the correct day in its calendar. You can find the code in the hooks `useTurnPointInTimeIntoReactDatePickerShiftedDate` and `useTurnReactDatePickerShiftedDateBackIntoPointInTime` that contain the logic that produces the above table internally. ## Miscellaneous Removed FormDateFieldInput and FormDateTimeFieldInput stories as they do not behave the same depending of the execution environment and it would be easier to put them back after having refactored FormDateFieldInput and FormDateTimeFieldInput --------- Co-authored-by: Charles Bochet <charles@twenty.com>
240 lines
7.6 KiB
JSON
240 lines
7.6 KiB
JSON
{
|
|
"private": true,
|
|
"dependencies": {
|
|
"@apollo/client": "^3.7.17",
|
|
"@date-fns/tz": "^1.4.1",
|
|
"@emotion/react": "^11.11.1",
|
|
"@emotion/styled": "^11.11.0",
|
|
"@floating-ui/react": "^0.24.3",
|
|
"@linaria/core": "^6.2.0",
|
|
"@linaria/react": "^6.2.1",
|
|
"@radix-ui/colors": "^3.0.0",
|
|
"@sentry/profiling-node": "^9.26.0",
|
|
"@sentry/react": "^9.26.0",
|
|
"@sniptt/guards": "^0.2.0",
|
|
"@tabler/icons-react": "^3.31.0",
|
|
"@wyw-in-js/vite": "^0.7.0",
|
|
"archiver": "^7.0.1",
|
|
"danger-plugin-todos": "^1.3.1",
|
|
"date-fns": "^2.30.0",
|
|
"date-fns-tz": "^2.0.0",
|
|
"deep-equal": "^2.2.2",
|
|
"file-type": "16.5.4",
|
|
"framer-motion": "^11.18.0",
|
|
"fuse.js": "^7.1.0",
|
|
"googleapis": "105",
|
|
"hex-rgb": "^5.0.0",
|
|
"immer": "^10.1.1",
|
|
"libphonenumber-js": "^1.10.26",
|
|
"lodash.camelcase": "^4.3.0",
|
|
"lodash.chunk": "^4.2.0",
|
|
"lodash.compact": "^3.0.1",
|
|
"lodash.escaperegexp": "^4.1.2",
|
|
"lodash.groupby": "^4.6.0",
|
|
"lodash.identity": "^3.0.0",
|
|
"lodash.isempty": "^4.4.0",
|
|
"lodash.isequal": "^4.5.0",
|
|
"lodash.isobject": "^3.0.2",
|
|
"lodash.kebabcase": "^4.1.1",
|
|
"lodash.mapvalues": "^4.6.0",
|
|
"lodash.merge": "^4.6.2",
|
|
"lodash.omit": "^4.5.0",
|
|
"lodash.pickby": "^4.6.0",
|
|
"lodash.snakecase": "^4.1.1",
|
|
"lodash.upperfirst": "^4.3.1",
|
|
"microdiff": "^1.3.2",
|
|
"planer": "^1.2.0",
|
|
"pluralize": "^8.0.0",
|
|
"react": "^18.2.0",
|
|
"react-dom": "^18.2.0",
|
|
"react-responsive": "^9.0.2",
|
|
"react-router-dom": "^6.4.4",
|
|
"react-tooltip": "^5.13.1",
|
|
"recoil": "^0.7.7",
|
|
"remark-gfm": "^3.0.1",
|
|
"rxjs": "^7.2.0",
|
|
"semver": "^7.5.4",
|
|
"slash": "^5.1.0",
|
|
"storybook-addon-mock-date": "^0.6.0",
|
|
"ts-key-enum": "^2.0.12",
|
|
"tslib": "^2.8.1",
|
|
"type-fest": "4.10.1",
|
|
"typescript": "5.9.2",
|
|
"uuid": "^9.0.0",
|
|
"vite-tsconfig-paths": "^4.2.1",
|
|
"xlsx-ugnis": "^0.19.3",
|
|
"zod": "^4.1.11"
|
|
},
|
|
"devDependencies": {
|
|
"@babel/core": "^7.14.5",
|
|
"@babel/preset-react": "^7.14.5",
|
|
"@babel/preset-typescript": "^7.24.6",
|
|
"@chromatic-com/storybook": "^3",
|
|
"@graphql-codegen/cli": "^3.3.1",
|
|
"@graphql-codegen/client-preset": "^4.1.0",
|
|
"@graphql-codegen/typescript": "^3.0.4",
|
|
"@graphql-codegen/typescript-operations": "^3.0.4",
|
|
"@graphql-codegen/typescript-react-apollo": "^3.3.7",
|
|
"@nx/eslint": "21.3.11",
|
|
"@nx/eslint-plugin": "21.3.11",
|
|
"@nx/jest": "21.3.11",
|
|
"@nx/js": "21.3.11",
|
|
"@nx/react": "21.3.11",
|
|
"@nx/storybook": "21.3.11",
|
|
"@nx/vite": "21.3.11",
|
|
"@nx/web": "21.3.11",
|
|
"@playwright/test": "^1.46.0",
|
|
"@sentry/types": "^8",
|
|
"@storybook/addon-actions": "8.6.14",
|
|
"@storybook/addon-coverage": "^1.0.0",
|
|
"@storybook/addon-essentials": "8.6.14",
|
|
"@storybook/addon-interactions": "8.6.14",
|
|
"@storybook/addon-links": "8.6.14",
|
|
"@storybook/blocks": "8.6.14",
|
|
"@storybook/core-server": "8.6.14",
|
|
"@storybook/icons": "^1.2.9",
|
|
"@storybook/preview-api": "8.6.14",
|
|
"@storybook/react": "8.6.14",
|
|
"@storybook/react-vite": "8.6.14",
|
|
"@storybook/test": "8.6.14",
|
|
"@storybook/test-runner": "^0.23.0",
|
|
"@storybook/types": "8.6.14",
|
|
"@stylistic/eslint-plugin": "^1.5.0",
|
|
"@swc-node/register": "1.8.0",
|
|
"@swc/cli": "^0.3.12",
|
|
"@swc/core": "1.13.3",
|
|
"@swc/helpers": "~0.5.2",
|
|
"@swc/jest": "^0.2.39",
|
|
"@testing-library/jest-dom": "^6.6.3",
|
|
"@testing-library/react": "^16.3.0",
|
|
"@types/addressparser": "^1.0.3",
|
|
"@types/bcrypt": "^5.0.0",
|
|
"@types/bytes": "^3.1.1",
|
|
"@types/chrome": "^0.0.267",
|
|
"@types/deep-equal": "^1.0.1",
|
|
"@types/express": "^4.17.13",
|
|
"@types/fs-extra": "^11.0.4",
|
|
"@types/graphql-fields": "^1.3.6",
|
|
"@types/inquirer": "^9.0.9",
|
|
"@types/jest": "^30.0.0",
|
|
"@types/lodash.camelcase": "^4.3.7",
|
|
"@types/lodash.compact": "^3.0.9",
|
|
"@types/lodash.escaperegexp": "^4.1.9",
|
|
"@types/lodash.groupby": "^4.6.9",
|
|
"@types/lodash.identity": "^3.0.9",
|
|
"@types/lodash.isempty": "^4.4.7",
|
|
"@types/lodash.isequal": "^4.5.7",
|
|
"@types/lodash.isobject": "^3.0.7",
|
|
"@types/lodash.kebabcase": "^4.1.7",
|
|
"@types/lodash.mapvalues": "^4.6.9",
|
|
"@types/lodash.omit": "^4.5.9",
|
|
"@types/lodash.pickby": "^4.6.9",
|
|
"@types/lodash.snakecase": "^4.1.7",
|
|
"@types/lodash.upperfirst": "^4.3.7",
|
|
"@types/ms": "^0.7.31",
|
|
"@types/node": "^24.0.0",
|
|
"@types/passport-google-oauth20": "^2.0.11",
|
|
"@types/passport-jwt": "^3.0.8",
|
|
"@types/passport-microsoft": "^2.1.0",
|
|
"@types/pluralize": "^0.0.33",
|
|
"@types/react": "^18.2.39",
|
|
"@types/react-datepicker": "^6.2.0",
|
|
"@types/react-dom": "^18.2.15",
|
|
"@types/supertest": "^2.0.11",
|
|
"@types/uuid": "^9.0.2",
|
|
"@typescript-eslint/eslint-plugin": "^8.39.0",
|
|
"@typescript-eslint/parser": "^8.39.0",
|
|
"@typescript-eslint/utils": "^8.39.0",
|
|
"@vitejs/plugin-react-swc": "3.11.0",
|
|
"@yarnpkg/types": "^4.0.0",
|
|
"chromatic": "^6.18.0",
|
|
"concurrently": "^8.2.2",
|
|
"cross-var": "^1.1.0",
|
|
"danger": "^13.0.4",
|
|
"dotenv-cli": "^7.4.4",
|
|
"esbuild": "^0.25.10",
|
|
"eslint": "^9.32.0",
|
|
"eslint-config-prettier": "^9.1.0",
|
|
"eslint-plugin-import": "^2.31.0",
|
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
|
"eslint-plugin-lingui": "^0.9.0",
|
|
"eslint-plugin-prefer-arrow": "^1.2.3",
|
|
"eslint-plugin-prettier": "^5.1.2",
|
|
"eslint-plugin-project-structure": "^3.9.1",
|
|
"eslint-plugin-react": "^7.37.2",
|
|
"eslint-plugin-react-hooks": "^5.0.0",
|
|
"eslint-plugin-react-refresh": "^0.4.4",
|
|
"eslint-plugin-simple-import-sort": "^10.0.0",
|
|
"eslint-plugin-storybook": "^0.9.0",
|
|
"eslint-plugin-unicorn": "^56.0.1",
|
|
"eslint-plugin-unused-imports": "^3.0.0",
|
|
"http-server": "^14.1.1",
|
|
"jest": "29.7.0",
|
|
"jest-environment-jsdom": "30.0.0-beta.3",
|
|
"jest-environment-node": "^29.4.1",
|
|
"jest-fetch-mock": "^3.0.3",
|
|
"jsdom": "~22.1.0",
|
|
"msw": "^2.0.11",
|
|
"msw-storybook-addon": "^2.0.5",
|
|
"nx": "21.3.11",
|
|
"playwright": "^1.46.0",
|
|
"prettier": "^3.1.1",
|
|
"raw-loader": "^4.0.2",
|
|
"rimraf": "^5.0.5",
|
|
"source-map-support": "^0.5.20",
|
|
"storybook": "8.6.14",
|
|
"storybook-addon-cookie": "^3.2.0",
|
|
"storybook-addon-pseudo-states": "^2.1.2",
|
|
"supertest": "^6.1.3",
|
|
"ts-jest": "^29.1.1",
|
|
"ts-loader": "^9.2.3",
|
|
"ts-node": "10.9.1",
|
|
"tsconfig-paths": "^4.2.0",
|
|
"tsx": "^4.17.0",
|
|
"vite": "^7.0.0",
|
|
"vite-plugin-checker": "^0.10.2",
|
|
"vite-plugin-cjs-interop": "^2.2.0",
|
|
"vite-plugin-dts": "3.8.1",
|
|
"vite-plugin-svgr": "^4.2.0"
|
|
},
|
|
"engines": {
|
|
"node": "^24.5.0",
|
|
"npm": "please-use-yarn",
|
|
"yarn": ">=4.0.2"
|
|
},
|
|
"license": "AGPL-3.0",
|
|
"name": "twenty",
|
|
"packageManager": "yarn@4.9.2",
|
|
"resolutions": {
|
|
"graphql": "16.8.1",
|
|
"type-fest": "4.10.1",
|
|
"typescript": "5.9.2",
|
|
"graphql-redis-subscriptions/ioredis": "^5.6.0",
|
|
"prosemirror-view": "1.40.0",
|
|
"prosemirror-transform": "1.10.4"
|
|
},
|
|
"version": "0.2.1",
|
|
"nx": {},
|
|
"scripts": {
|
|
"start": "npx concurrently --kill-others 'npx nx run-many -t start -p twenty-server twenty-front' 'npx wait-on tcp:3000 && npx nx run twenty-server:worker'"
|
|
},
|
|
"workspaces": {
|
|
"packages": [
|
|
"packages/twenty-front",
|
|
"packages/twenty-server",
|
|
"packages/twenty-emails",
|
|
"packages/twenty-ui",
|
|
"packages/twenty-utils",
|
|
"packages/twenty-zapier",
|
|
"packages/twenty-website",
|
|
"packages/twenty-docs",
|
|
"packages/twenty-e2e-testing",
|
|
"packages/twenty-shared",
|
|
"packages/twenty-sdk",
|
|
"packages/twenty-apps",
|
|
"packages/twenty-cli",
|
|
"tools/eslint-rules"
|
|
]
|
|
}
|
|
}
|