Refactored Date to Temporal in critical date zones (#16544)
Fixes https://github.com/twentyhq/twenty/issues/16110 This PR implements Temporal to replace the legacy Date object, in all features that are time zone sensitive. (around 80% of the app) Here we define a few utils to handle Temporal primitives and obtain an easier DX for timezone manipulation, front end and back end. This PR deactivates the usage of timezone from the graph configuration, because for now it's always UTC and is not really relevant, let's handle that later. Workflows code and backend only code that don't take user input are using UTC time zone, the affected utils have not been refactored yet because this PR is big enough. # New way of filtering on date intervals As we'll progressively rollup Temporal everywhere in the codebase and remove `Date` JS object everywhere possible, we'll use the way to filter that is recommended by Temporal. This way of filtering on date intervals involves half-open intervals, and is the preferred way to avoid edge-cases with DST and smallest time increment edge-case. ## Filtering endOfX with DST edge-cases Some day-light save time shifts involve having no existing hour, or even day on certain days, for example Samoa Islands have no 30th of December 2011 : https://www.timeanddate.com/news/time/samoa-dateline.html, it jumps from 29th to 31st, so filtering on `< next period start` makes it easier to let the date library handle the strict inferior comparison, than filtering on `≤ end of period` and trying to compute manually the end of the period. For example for Samoa Islands, is end of day `2011-12-29T23:59:59.999` or is it `2011-12-30T23:59:59.999` ? If you say I don't need to know and compute it, because I want everything strictly before `2011-12-29T00:00:00 + start of next day (according to the library which knows those edge-cases)`, then you have a 100% deterministic way of computing date intervals in any timezone, for any day of any year. Of course the Samoa example is an extreme one, but more common ones involve DST shifts of 1 hour, which are still problematic on certain days of the year. ## Computing the exact _end of period_ Having an open interval filtering, with `[included - included]` instead of half-open `[included - excluded)`, forces to compute the open end of an interval, which often involves taking an arbitrary unit like minute, second, microsecond or nanosecond, which will lead to edge-case of unhandled values. For example, let's say my code computes endOfDay by setting the time to `23:59:59.999`, if another library, API, or anything else, ends up giving me a date-time with another time precision `23:59:59.999999999` (down to the nanosecond), then this date-time will be filtered out, while it should not. The good deterministic way to avoid 100% of those complex bugs is to create a half-open filter : `≥ start of period` to `< start of next period` For example : `≥ 2025-01-01T00:00:00` to `< 2025-01-02T00:00:00` instead of `≥ 2025-01-01T00:00:00` to `≤ 2025-01-01T23:59:59.999` Because, `2025-01-01T00:00:00` = `2025-01-01T00:00:00.000` = `2025-01-01T00:00:00.000000` = `2025-01-01T00:00:00.000000000` => no risk of error in computing start of period But `2025-01-01T23:59:59` ≠ `2025-01-01T23:59:59.999` ≠ `2025-01-01T23:59:59.999999` ≠ `2025-01-01T23:59:59.999999999` => existing risk of error in computing end of period This is why an half-open interval has no risk of error in computing a date-time interval filter. Here is a link to this debate : https://github.com/tc39/proposal-temporal/issues/2568 > For this reason, we recommend not calculating the exact nanosecond at the end of the day if it's not absolutely necessary. For example, if it's needed for <= comparisons, we recommend just changing the comparison code. So instead of <= zdtEndOfDay your code could be < zdtStartOfNextDay which is easier to calculate and not subject to the issue of not knowing which unit is the right one. > > [Justin Grant](https://github.com/justingrant), top contributor of Temporal ## Application to our codebase Applying this half-open filtering paradigm to our codebase means we would have to rename `IS_AFTER` to `IS_AFTER_OR_EQUAL` and to keep `IS_BEFORE` (or even `IS_STRICTLY_BEFORE`) to make this half-open interval self-explanatory everywhere in the codebase, this will avoid any confusion. See the relevant issue : https://github.com/twentyhq/core-team-issues/issues/2010 In the mean time, we'll keep this operand and add this semantic in the naming everywhere possible. ## Example with a different user timezone Example on a graph grouped by week in timezone Pacific/Samoa, on a computer running on Europe/Paris : <img width="342" height="511" alt="image" src="https://github.com/user-attachments/assets/9e7d5121-ecc4-4233-835b-f59293fbd8c8" /> Then the associated data in the table view, with our **half-open date-time filter** : <img width="804" height="262" alt="image" src="https://github.com/user-attachments/assets/28efe1d7-d2fc-4aec-b521-bada7f980447" /> And the associated SQL query result to see how DATE_TRUNC in Postgres applies its internal start of week logic : <img width="709" height="220" alt="image" src="https://github.com/user-attachments/assets/4d0542e1-eaae-4b4b-afa9-5005f48ffdca" /> The associated SQL query without parameters to test in your SQL client : ```SQL SELECT "opportunity"."closeDate" as "close_date", TO_CHAR(DATE_TRUNC('week', "opportunity"."closeDate", 'Pacific/Samoa') AT TIME ZONE 'Pacific/Samoa', 'YYYY-MM-DD') AS "DATE_TRUNC by week start in timezone Pacific/Samoa", "opportunity"."name" FROM "workspace_1wgvd1injqtife6y4rvfbu3h5"."opportunity" "opportunity" ORDER BY "opportunity"."closeDate" ASC NULLS LAST ``` # Date picker simplification (not in this PR) Our DatePicker component, which is wrapping `react-datepicker` library component, is now exposing plain dates as string instead of Date object. The Date object is still used internally to manage the library component, but since the date picker calendar is only manipulating plain dates, there is no need to add timezone management to it, and no need to expose a handleChange with Date object. The timezone management relies on date time inputs now. The modification has been made in a previous PR : https://github.com/twentyhq/twenty/issues/15377 but it's good to reference it here. # Calendar feature refactor Calendar feature has been refactored to rely on Temporal.PlainDate as much as possible, while leaving some date-fns utils to avoid re-coding them. Since the trick is to use utils to convert back and from Date object in exec env reliably, we can do it everywhere we need to interface legacy Date object utils and Temporal related code. ## TimeZone is now shown on Calendar : <img width="894" height="958" alt="image" src="https://github.com/user-attachments/assets/231f8107-fad6-4786-b532-456692c20f1d" /> ## Month picker has been refactored <img width="503" height="266" alt="image" src="https://github.com/user-attachments/assets/cb90bc34-6c4d-436d-93bc-4b6fb00de7f5" /> Since the days weren't useful, the picker has been refactored to remove the days. # Miscellaneous - Fixed a bug with drag and drop edge-case with 2 items in a list. # Improvements ## Lots of chained operations It would be nice to create small utils to avoid repeated chained operations, but that is how Temporal is designed, a very small set of primitive operations that allow to compose everything needed. Maybe we'll have wrappers on top of Temporal in the coming years. ## Creation of Temporal objects is throwing errors If the input is badly formatted Temporal will throw, we might want to adopt a global strategy to avoid that. Example : ```ts const newPlainDate = Temporal.PlainDate.from('bad-string'); // Will throw ```
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import { ObjectRecordGroupByDateGranularity } from '@/types';
|
||||
|
||||
export const GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE = [
|
||||
ObjectRecordGroupByDateGranularity.DAY,
|
||||
ObjectRecordGroupByDateGranularity.WEEK,
|
||||
ObjectRecordGroupByDateGranularity.MONTH,
|
||||
ObjectRecordGroupByDateGranularity.QUARTER,
|
||||
ObjectRecordGroupByDateGranularity.YEAR,
|
||||
];
|
||||
@@ -17,6 +17,7 @@ export { DEFAULT_RELATIVE_DATE_FILTER_VALUE } from './DefaultRelativeDateFilterV
|
||||
export { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from './FieldForTotalCountAggregateOperation';
|
||||
export { MAX_OPTIONS_TO_DISPLAY } from './FieldMetadataMaxOptionsToDisplay';
|
||||
export { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from './FieldRestrictedAdditionalPermissionsRequired';
|
||||
export { GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE } from './GroupByDateGranularityThatRequireTimeZone';
|
||||
export { LABEL_IDENTIFIER_FIELD_METADATA_TYPES } from './LabelIdentifierFieldMetadataTypes';
|
||||
export { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from './MultiItemFieldDefaultMaxValues';
|
||||
export { MULTI_ITEM_FIELD_MIN_MAX_VALUES } from './MultiItemFieldMinMaxValues';
|
||||
|
||||
@@ -7,6 +7,5 @@
|
||||
* |___/
|
||||
*/
|
||||
|
||||
export { CalendarStartDay } from './constants';
|
||||
|
||||
export default {};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export type ArraySortDirection = 'asc' | 'desc';
|
||||
@@ -1,3 +1,4 @@
|
||||
export interface RecordFilterValueDependencies {
|
||||
currentWorkspaceMemberId?: string;
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export enum ViewFilterOperand {
|
||||
LESS_THAN_OR_EQUAL = 'LESS_THAN_OR_EQUAL', // TODO: we could change this to 'lessThanOrEqual' for consistency but it would require a migration
|
||||
GREATER_THAN_OR_EQUAL = 'GREATER_THAN_OR_EQUAL', // TODO: we could change this to 'greaterThanOrEqual' for consistency but it would require a migration
|
||||
IS_BEFORE = 'IS_BEFORE',
|
||||
IS_AFTER = 'IS_AFTER',
|
||||
IS_AFTER = 'IS_AFTER', // TODO: migrate this to IS_AFTER_OR_EQUAL
|
||||
CONTAINS = 'CONTAINS',
|
||||
DOES_NOT_CONTAIN = 'DOES_NOT_CONTAIN',
|
||||
IS_EMPTY = 'IS_EMPTY',
|
||||
|
||||
@@ -12,6 +12,7 @@ export { ALLOWED_ADDRESS_SUBFIELDS } from './AddressFieldsType';
|
||||
export { AppBasePath } from './AppBasePath';
|
||||
export { AppPath } from './AppPath';
|
||||
export type { Arrayable } from './Arrayable';
|
||||
export type { ArraySortDirection } from './ArraySortDirection';
|
||||
export type { ActorMetadata } from './composite-types/actor.composite-type';
|
||||
export {
|
||||
FieldActorSource,
|
||||
|
||||
@@ -135,12 +135,6 @@ describe('resolveRichTextVariables', () => {
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
|
||||
it('should return null for null input', () => {
|
||||
const result = resolveRichTextVariables(null, context);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return undefined for undefined input', () => {
|
||||
const result = resolveRichTextVariables(undefined, context);
|
||||
|
||||
|
||||
+34
-34
@@ -231,47 +231,47 @@ describe('safeParseRelativeDateFilterJSONStringified', () => {
|
||||
});
|
||||
|
||||
describe('THIS direction', () => {
|
||||
it('should parse THIS direction with SECOND unit (no amount)', () => {
|
||||
const input = JSON.stringify({
|
||||
direction: 'THIS',
|
||||
unit: 'SECOND',
|
||||
});
|
||||
it('should parse THIS direction with SECOND unit (no amount)', () => {
|
||||
const input = JSON.stringify({
|
||||
direction: 'THIS',
|
||||
unit: 'SECOND',
|
||||
});
|
||||
|
||||
const result = safeParseRelativeDateFilterJSONStringified(input);
|
||||
const result = safeParseRelativeDateFilterJSONStringified(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
direction: 'THIS',
|
||||
unit: 'SECOND',
|
||||
});
|
||||
});
|
||||
expect(result).toEqual({
|
||||
direction: 'THIS',
|
||||
unit: 'SECOND',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse THIS direction with MINUTE unit (no amount)', () => {
|
||||
const input = JSON.stringify({
|
||||
direction: 'THIS',
|
||||
unit: 'MINUTE',
|
||||
});
|
||||
it('should parse THIS direction with MINUTE unit (no amount)', () => {
|
||||
const input = JSON.stringify({
|
||||
direction: 'THIS',
|
||||
unit: 'MINUTE',
|
||||
});
|
||||
|
||||
const result = safeParseRelativeDateFilterJSONStringified(input);
|
||||
const result = safeParseRelativeDateFilterJSONStringified(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
direction: 'THIS',
|
||||
unit: 'MINUTE',
|
||||
});
|
||||
});
|
||||
expect(result).toEqual({
|
||||
direction: 'THIS',
|
||||
unit: 'MINUTE',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse THIS direction with HOUR unit (no amount)', () => {
|
||||
const input = JSON.stringify({
|
||||
direction: 'THIS',
|
||||
unit: 'HOUR',
|
||||
});
|
||||
it('should parse THIS direction with HOUR unit (no amount)', () => {
|
||||
const input = JSON.stringify({
|
||||
direction: 'THIS',
|
||||
unit: 'HOUR',
|
||||
});
|
||||
|
||||
const result = safeParseRelativeDateFilterJSONStringified(input);
|
||||
const result = safeParseRelativeDateFilterJSONStringified(input);
|
||||
|
||||
expect(result).toEqual({
|
||||
direction: 'THIS',
|
||||
unit: 'HOUR',
|
||||
});
|
||||
});
|
||||
expect(result).toEqual({
|
||||
direction: 'THIS',
|
||||
unit: 'HOUR',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse THIS direction with DAY unit (no amount)', () => {
|
||||
const input = JSON.stringify({
|
||||
@@ -403,7 +403,7 @@ describe('safeParseRelativeDateFilterJSONStringified', () => {
|
||||
const input = JSON.stringify({
|
||||
direction: 'NEXT',
|
||||
amount: 1,
|
||||
unit: 'HOUR',
|
||||
unit: 'ASD',
|
||||
});
|
||||
|
||||
const result = safeParseRelativeDateFilterJSONStringified(input);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
import { sortPlainDate } from '../sortPlainDate';
|
||||
|
||||
describe('sortPlainDate', () => {
|
||||
const earlyDate = Temporal.PlainDate.from('2024-01-15');
|
||||
const middleDate = Temporal.PlainDate.from('2024-06-20');
|
||||
const lateDate = Temporal.PlainDate.from('2024-12-25');
|
||||
|
||||
describe('ascending order', () => {
|
||||
const comparator = sortPlainDate('asc');
|
||||
|
||||
it('should return negative when first date is earlier', () => {
|
||||
expect(comparator(earlyDate, lateDate)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should return positive when first date is later', () => {
|
||||
expect(comparator(lateDate, earlyDate)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return zero when dates are equal', () => {
|
||||
const sameDate = Temporal.PlainDate.from('2024-06-20');
|
||||
|
||||
expect(comparator(middleDate, sameDate)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('descending order', () => {
|
||||
const comparator = sortPlainDate('desc');
|
||||
|
||||
it('should return positive when first date is earlier', () => {
|
||||
expect(comparator(earlyDate, lateDate)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return negative when first date is later', () => {
|
||||
expect(comparator(lateDate, earlyDate)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should return zero when dates are equal', () => {
|
||||
const sameDate = Temporal.PlainDate.from('2024-06-20');
|
||||
|
||||
expect(comparator(middleDate, sameDate)).toStrictEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('array sorting', () => {
|
||||
it('should sort dates in ascending order', () => {
|
||||
const dates = [middleDate, lateDate, earlyDate];
|
||||
const sorted = [...dates].sort(sortPlainDate('asc'));
|
||||
|
||||
expect(sorted).toEqual([earlyDate, middleDate, lateDate]);
|
||||
});
|
||||
|
||||
it('should sort dates in descending order', () => {
|
||||
const dates = [middleDate, lateDate, earlyDate];
|
||||
const sorted = [...dates].sort(sortPlainDate('desc'));
|
||||
|
||||
expect(sorted).toEqual([lateDate, middleDate, earlyDate]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const isPlainDateAfter = (
|
||||
a: Temporal.PlainDate,
|
||||
b: Temporal.PlainDate,
|
||||
) => {
|
||||
return Temporal.PlainDate.compare(a, b) === 1;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const isPlainDateBefore = (
|
||||
a: Temporal.PlainDate,
|
||||
b: Temporal.PlainDate,
|
||||
) => {
|
||||
return Temporal.PlainDate.compare(a, b) === -1;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const isPlainDateBeforeOrEqual = (
|
||||
plainDateA: Temporal.PlainDate,
|
||||
plainDateB: Temporal.PlainDate,
|
||||
) => {
|
||||
const comparisonResult = Temporal.PlainDate.compare(plainDateA, plainDateB);
|
||||
|
||||
return comparisonResult <= 0;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const isPlainDateInSameMonth = (
|
||||
plainDateA: Temporal.PlainDate,
|
||||
plainDateB: Temporal.PlainDate,
|
||||
) => {
|
||||
return (
|
||||
plainDateA.month === plainDateB.month && plainDateA.year === plainDateB.year
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const isPlainDateInWeekend = (plainDate: Temporal.PlainDate) => {
|
||||
return plainDate.dayOfWeek > 5;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const isSamePlainDate = (
|
||||
plainDateA: Temporal.PlainDate,
|
||||
plainDateB: Temporal.PlainDate,
|
||||
) => {
|
||||
return Temporal.PlainDate.compare(plainDateA, plainDateB) === 0;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const parseToPlainDateOrThrow = (stringDate: string) => {
|
||||
try {
|
||||
const parsedPlainDate = Temporal.Instant.from(stringDate)
|
||||
.toZonedDateTimeISO('UTC')
|
||||
.toPlainDate();
|
||||
|
||||
return parsedPlainDate;
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedPlainDate = Temporal.PlainDate.from(stringDate);
|
||||
|
||||
return parsedPlainDate;
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
|
||||
throw new Error(`Cannot parse date string as PlainDate : "${stringDate}"`);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { type ArraySortDirection } from '@/types/ArraySortDirection';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const sortPlainDate =
|
||||
(direction: ArraySortDirection) =>
|
||||
(plainDateA: Temporal.PlainDate, plainDateB: Temporal.PlainDate) => {
|
||||
const comparisonResult = Temporal.PlainDate.compare(plainDateA, plainDateB);
|
||||
|
||||
if (comparisonResult === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return direction === 'asc' ? comparisonResult : -comparisonResult;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const turnJSDateToPlainDate = (date: Date) => {
|
||||
const plainDate = Temporal.PlainDate.from({
|
||||
day: date.getDate(),
|
||||
month: date.getMonth() + 1,
|
||||
year: date.getFullYear(),
|
||||
});
|
||||
|
||||
return plainDate;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const turnPlainDateIntoUserTimeZoneInstantString = (
|
||||
plainDate: Temporal.PlainDate,
|
||||
userTimeZone: string,
|
||||
) => {
|
||||
return plainDate.toZonedDateTime(userTimeZone).toInstant().toString();
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const turnPlainDateToShiftedDateInSystemTimeZone = (
|
||||
plainDate: Temporal.PlainDate,
|
||||
) => {
|
||||
const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
const dateShiftedToISOString = plainDate
|
||||
.toZonedDateTime(systemTimeZone)
|
||||
.toInstant()
|
||||
.toString();
|
||||
|
||||
const dateForDatePicker = new Date(dateShiftedToISOString);
|
||||
|
||||
return dateForDatePicker;
|
||||
};
|
||||
+3
-1
@@ -30,7 +30,9 @@ describe('computeRecordGqlOperationFilter', () => {
|
||||
fields: [companyIdField],
|
||||
recordFilters,
|
||||
recordFilterGroups: [],
|
||||
filterValueDependencies: {},
|
||||
filterValueDependencies: {
|
||||
timeZone: 'UTC',
|
||||
},
|
||||
});
|
||||
|
||||
expect(filter).toEqual({
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { type RelativeDateFilterUnit } from '@/utils/filter/dates/utils/relativeDateFilterUnitSchema';
|
||||
|
||||
export type DateTimePeriod = RelativeDateFilterUnit | 'QUARTER';
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
import { FirstDayOfTheWeek } from '@/types';
|
||||
import { getNextPeriodStart } from '@/utils/filter/dates/utils/getNextPeriodStart';
|
||||
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
describe('getNextPeriodStart', () => {
|
||||
const referenceDateTimeJanuary = Temporal.ZonedDateTime.from(
|
||||
'2026-01-14T12:34:56[Europe/Paris]',
|
||||
);
|
||||
|
||||
const referenceDateTimeJune = Temporal.ZonedDateTime.from(
|
||||
'2026-06-14T12:34:56[Europe/Paris]',
|
||||
);
|
||||
|
||||
it('should get next day start', () => {
|
||||
const nextDayStart = getNextPeriodStart(referenceDateTimeJanuary, 'DAY');
|
||||
|
||||
expect(nextDayStart.toString()).toEqual(
|
||||
'2026-01-15T00:00:00+01:00[Europe/Paris]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should get next week start - no first day of the week given (should default to monday)', () => {
|
||||
const nextWeekStart = getNextPeriodStart(referenceDateTimeJanuary, 'WEEK');
|
||||
|
||||
expect(nextWeekStart.toString()).toEqual(
|
||||
'2026-01-19T00:00:00+01:00[Europe/Paris]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should get next week start - Monday', () => {
|
||||
const nextWeekStart = getNextPeriodStart(
|
||||
referenceDateTimeJanuary,
|
||||
'WEEK',
|
||||
FirstDayOfTheWeek.MONDAY,
|
||||
);
|
||||
|
||||
expect(nextWeekStart.toString()).toEqual(
|
||||
'2026-01-19T00:00:00+01:00[Europe/Paris]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should get next week start - Saturday', () => {
|
||||
const nextWeekStart = getNextPeriodStart(
|
||||
referenceDateTimeJanuary,
|
||||
'WEEK',
|
||||
FirstDayOfTheWeek.SATURDAY,
|
||||
);
|
||||
|
||||
expect(nextWeekStart.toString()).toEqual(
|
||||
'2026-01-17T00:00:00+01:00[Europe/Paris]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should get next week start - Sunday', () => {
|
||||
const nextWeekStart = getNextPeriodStart(
|
||||
referenceDateTimeJanuary,
|
||||
'WEEK',
|
||||
FirstDayOfTheWeek.SUNDAY,
|
||||
);
|
||||
|
||||
expect(nextWeekStart.toString()).toEqual(
|
||||
'2026-01-18T00:00:00+01:00[Europe/Paris]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should get next month start', () => {
|
||||
const nextMonthStart = getNextPeriodStart(
|
||||
referenceDateTimeJanuary,
|
||||
'MONTH',
|
||||
);
|
||||
|
||||
expect(nextMonthStart.toString()).toEqual(
|
||||
'2026-02-01T00:00:00+01:00[Europe/Paris]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should get next year start', () => {
|
||||
const nextQuarterStart = getNextPeriodStart(referenceDateTimeJune, 'YEAR');
|
||||
|
||||
expect(nextQuarterStart.toString()).toEqual(
|
||||
'2027-01-01T00:00:00+01:00[Europe/Paris]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should get next start of quarter', () => {
|
||||
const referenceDateTimeMarch = Temporal.ZonedDateTime.from(
|
||||
'2026-03-14T12:34:56[Europe/Paris]',
|
||||
);
|
||||
|
||||
const referenceDateTimeSeptember = Temporal.ZonedDateTime.from(
|
||||
'2026-09-14T12:34:56[Europe/Paris]',
|
||||
);
|
||||
|
||||
const referenceDateTimeDecember = Temporal.ZonedDateTime.from(
|
||||
'2026-12-14T12:34:56[Europe/Paris]',
|
||||
);
|
||||
|
||||
const nextStartOfQuarterForMarch = getNextPeriodStart(
|
||||
referenceDateTimeMarch,
|
||||
'QUARTER',
|
||||
);
|
||||
|
||||
const nextStartOfQuarterForJune = getNextPeriodStart(
|
||||
referenceDateTimeJune,
|
||||
'QUARTER',
|
||||
);
|
||||
|
||||
const nextStartOfQuarterForSeptember = getNextPeriodStart(
|
||||
referenceDateTimeSeptember,
|
||||
'QUARTER',
|
||||
);
|
||||
|
||||
const nextStartOfQuarterForDecember = getNextPeriodStart(
|
||||
referenceDateTimeDecember,
|
||||
'QUARTER',
|
||||
);
|
||||
|
||||
expect(nextStartOfQuarterForMarch.toString()).toEqual(
|
||||
'2026-04-01T00:00:00+02:00[Europe/Paris]',
|
||||
);
|
||||
expect(nextStartOfQuarterForJune.toString()).toEqual(
|
||||
'2026-07-01T00:00:00+02:00[Europe/Paris]',
|
||||
);
|
||||
expect(nextStartOfQuarterForSeptember.toString()).toEqual(
|
||||
'2026-10-01T00:00:00+02:00[Europe/Paris]',
|
||||
);
|
||||
expect(nextStartOfQuarterForDecember.toString()).toEqual(
|
||||
'2027-01-01T00:00:00+01:00[Europe/Paris]',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { FirstDayOfTheWeek } from '@/types';
|
||||
import { getPeriodStart } from '@/utils/filter/dates/utils/getPeriodStart';
|
||||
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
describe('getPeriodStart', () => {
|
||||
const referenceDateTimeJanuary = Temporal.ZonedDateTime.from(
|
||||
'2026-01-14T12:34:56[Europe/Paris]',
|
||||
);
|
||||
|
||||
const referenceDateTimeJune = Temporal.ZonedDateTime.from(
|
||||
'2026-06-14T12:34:56[Europe/Paris]',
|
||||
);
|
||||
|
||||
it('should get start of day', () => {
|
||||
const startOfDay = getPeriodStart(referenceDateTimeJanuary, 'DAY');
|
||||
|
||||
expect(startOfDay.toString()).toEqual(
|
||||
'2026-01-14T00:00:00+01:00[Europe/Paris]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should get start of week - weekd start on Monday', () => {
|
||||
const startOfWeek = getPeriodStart(
|
||||
referenceDateTimeJanuary,
|
||||
'WEEK',
|
||||
FirstDayOfTheWeek.MONDAY,
|
||||
);
|
||||
|
||||
expect(startOfWeek.toString()).toEqual(
|
||||
'2026-01-12T00:00:00+01:00[Europe/Paris]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should get start of week - weekd start on Saturday', () => {
|
||||
const startOfWeek = getPeriodStart(
|
||||
referenceDateTimeJanuary,
|
||||
'WEEK',
|
||||
FirstDayOfTheWeek.SATURDAY,
|
||||
);
|
||||
|
||||
expect(startOfWeek.toString()).toEqual(
|
||||
'2026-01-10T00:00:00+01:00[Europe/Paris]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should get start of week - weekd start on Sunday', () => {
|
||||
const startOfWeek = getPeriodStart(
|
||||
referenceDateTimeJanuary,
|
||||
'WEEK',
|
||||
FirstDayOfTheWeek.SUNDAY,
|
||||
);
|
||||
|
||||
expect(startOfWeek.toString()).toEqual(
|
||||
'2026-01-11T00:00:00+01:00[Europe/Paris]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should get start of week - no first day of the week given (should default to monday)', () => {
|
||||
const startOfWeek = getPeriodStart(referenceDateTimeJanuary, 'WEEK');
|
||||
|
||||
expect(startOfWeek.toString()).toEqual(
|
||||
'2026-01-12T00:00:00+01:00[Europe/Paris]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should get start of week - no first day of the week given (should default to monday)', () => {
|
||||
const startOfWeek = getPeriodStart(referenceDateTimeJanuary, 'WEEK');
|
||||
|
||||
expect(startOfWeek.toString()).toEqual(
|
||||
'2026-01-12T00:00:00+01:00[Europe/Paris]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should get start of month', () => {
|
||||
const startOfWeek = getPeriodStart(referenceDateTimeJanuary, 'MONTH');
|
||||
|
||||
expect(startOfWeek.toString()).toEqual(
|
||||
'2026-01-01T00:00:00+01:00[Europe/Paris]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should get start of year', () => {
|
||||
const startOfWeek = getPeriodStart(referenceDateTimeJune, 'YEAR');
|
||||
|
||||
expect(startOfWeek.toString()).toEqual(
|
||||
'2026-01-01T00:00:00+01:00[Europe/Paris]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should get start of quarter', () => {
|
||||
const referenceDateTimeMarch = Temporal.ZonedDateTime.from(
|
||||
'2026-03-14T12:34:56[Europe/Paris]',
|
||||
);
|
||||
|
||||
const referenceDateTimeSeptember = Temporal.ZonedDateTime.from(
|
||||
'2026-09-14T12:34:56[Europe/Paris]',
|
||||
);
|
||||
|
||||
const referenceDateTimeDecember = Temporal.ZonedDateTime.from(
|
||||
'2026-12-14T12:34:56[Europe/Paris]',
|
||||
);
|
||||
|
||||
const startOfQuarterForMarch = getPeriodStart(
|
||||
referenceDateTimeMarch,
|
||||
'QUARTER',
|
||||
);
|
||||
|
||||
const startOfQuarterForJune = getPeriodStart(
|
||||
referenceDateTimeJune,
|
||||
'QUARTER',
|
||||
);
|
||||
|
||||
const startOfQuarterForSeptember = getPeriodStart(
|
||||
referenceDateTimeSeptember,
|
||||
'QUARTER',
|
||||
);
|
||||
|
||||
const startOfQuarterForDecember = getPeriodStart(
|
||||
referenceDateTimeDecember,
|
||||
'QUARTER',
|
||||
);
|
||||
|
||||
expect(startOfQuarterForMarch.toString()).toEqual(
|
||||
'2026-01-01T00:00:00+01:00[Europe/Paris]',
|
||||
);
|
||||
expect(startOfQuarterForJune.toString()).toEqual(
|
||||
'2026-04-01T00:00:00+02:00[Europe/Paris]',
|
||||
);
|
||||
expect(startOfQuarterForSeptember.toString()).toEqual(
|
||||
'2026-07-01T00:00:00+02:00[Europe/Paris]',
|
||||
);
|
||||
expect(startOfQuarterForDecember.toString()).toEqual(
|
||||
'2026-10-01T00:00:00+02:00[Europe/Paris]',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
addYears,
|
||||
} from 'date-fns';
|
||||
|
||||
/** @deprecated Use addUnitToZonedDateTime */
|
||||
export const addUnitToDateTime = (
|
||||
dateTime: Date,
|
||||
amount: number,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { assertUnreachable, type DateTimePeriod } from '@/utils';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const addUnitToZonedDateTime = (
|
||||
zonedDateTime: Temporal.ZonedDateTime,
|
||||
unit: DateTimePeriod,
|
||||
amount: number,
|
||||
) => {
|
||||
switch (unit) {
|
||||
case 'DAY':
|
||||
return zonedDateTime.add({ days: amount });
|
||||
case 'WEEK': {
|
||||
return zonedDateTime.add({ weeks: amount });
|
||||
}
|
||||
case 'QUARTER': {
|
||||
return zonedDateTime.add({
|
||||
months: amount * 3,
|
||||
});
|
||||
}
|
||||
case 'MONTH':
|
||||
return zonedDateTime.add({
|
||||
months: amount,
|
||||
});
|
||||
case 'YEAR':
|
||||
return zonedDateTime.add({
|
||||
years: amount,
|
||||
});
|
||||
case 'SECOND':
|
||||
return zonedDateTime.add({
|
||||
seconds: amount,
|
||||
});
|
||||
case 'MINUTE':
|
||||
return zonedDateTime.add({
|
||||
minutes: amount,
|
||||
});
|
||||
case 'HOUR':
|
||||
return zonedDateTime.add({
|
||||
hours: amount,
|
||||
});
|
||||
default:
|
||||
return assertUnreachable(unit);
|
||||
}
|
||||
};
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { CalendarStartDay } from '@/constants';
|
||||
import { FirstDayOfTheWeek } from '@/types';
|
||||
import { assertUnreachable } from '@/utils/assertUnreachable';
|
||||
|
||||
export const convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek = (
|
||||
calendarStartDayNonIsoNumber: CalendarStartDay,
|
||||
systemCalendarStartDay: FirstDayOfTheWeek,
|
||||
): FirstDayOfTheWeek => {
|
||||
switch (calendarStartDayNonIsoNumber) {
|
||||
case CalendarStartDay.MONDAY:
|
||||
return FirstDayOfTheWeek.MONDAY;
|
||||
case CalendarStartDay.SATURDAY:
|
||||
return FirstDayOfTheWeek.SATURDAY;
|
||||
case CalendarStartDay.SUNDAY:
|
||||
return FirstDayOfTheWeek.SUNDAY;
|
||||
case CalendarStartDay.SYSTEM:
|
||||
return systemCalendarStartDay;
|
||||
default:
|
||||
return assertUnreachable(calendarStartDayNonIsoNumber);
|
||||
}
|
||||
};
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { CalendarStartDay } from '@/constants';
|
||||
import { FirstDayOfTheWeek } from '@/types';
|
||||
|
||||
export const convertFirstDayOfTheWeekToCalendarStartDayNumber = (
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek,
|
||||
): CalendarStartDay => {
|
||||
switch (firstDayOfTheWeek) {
|
||||
case FirstDayOfTheWeek.MONDAY:
|
||||
return CalendarStartDay.MONDAY;
|
||||
case FirstDayOfTheWeek.SATURDAY:
|
||||
return CalendarStartDay.SATURDAY;
|
||||
case FirstDayOfTheWeek.SUNDAY:
|
||||
return CalendarStartDay.SUNDAY;
|
||||
}
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import { DATE_TYPE_FORMAT } from '@/constants';
|
||||
import { parse } from 'date-fns';
|
||||
|
||||
export const getDateFromPlainDate = (plainDate: string) => {
|
||||
return parse(plainDate, DATE_TYPE_FORMAT, new Date());
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
import { type Nullable } from '@/types';
|
||||
import { type FirstDayOfTheWeek } from '@/utils/filter/dates/utils/firstDayOfWeekSchema';
|
||||
import { getFirstDayOfTheWeekAsANumberForDateFNS } from '@/utils/filter/dates/utils/getFirstDayOfTheWeekAsANumberForDateFNS';
|
||||
import { type RelativeDateFilterUnit } from '@/utils/filter/dates/utils/relativeDateFilterUnitSchema';
|
||||
import { isDefined } from '@/utils/validation';
|
||||
import {
|
||||
endOfDay,
|
||||
endOfHour,
|
||||
endOfMinute,
|
||||
endOfMonth,
|
||||
endOfSecond,
|
||||
endOfWeek,
|
||||
endOfYear,
|
||||
} from 'date-fns';
|
||||
|
||||
export const getEndUnitOfDateTime = (
|
||||
dateTime: Date,
|
||||
unit: RelativeDateFilterUnit,
|
||||
firstDayOfTheWeek?: Nullable<FirstDayOfTheWeek>,
|
||||
) => {
|
||||
switch (unit) {
|
||||
case 'SECOND':
|
||||
return endOfSecond(dateTime);
|
||||
case 'MINUTE':
|
||||
return endOfMinute(dateTime);
|
||||
case 'HOUR':
|
||||
return endOfHour(dateTime);
|
||||
case 'DAY':
|
||||
return endOfDay(dateTime);
|
||||
case 'WEEK': {
|
||||
if (isDefined(firstDayOfTheWeek)) {
|
||||
const firstDayOfTheWeekAsDateFNSNumber =
|
||||
getFirstDayOfTheWeekAsANumberForDateFNS(firstDayOfTheWeek);
|
||||
|
||||
return endOfWeek(dateTime, {
|
||||
weekStartsOn: firstDayOfTheWeekAsDateFNSNumber,
|
||||
});
|
||||
} else {
|
||||
return endOfWeek(dateTime);
|
||||
}
|
||||
}
|
||||
case 'MONTH':
|
||||
return endOfMonth(dateTime);
|
||||
case 'YEAR':
|
||||
return endOfYear(dateTime);
|
||||
}
|
||||
};
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { FirstDayOfTheWeek as FirstDayOfTheWeekEnum } from '@/types/FirstDayOfTheWeek';
|
||||
import { assertUnreachable } from '@/utils/assertUnreachable';
|
||||
import { type FirstDayOfTheWeek } from '@/utils/filter/dates/utils/firstDayOfWeekSchema';
|
||||
|
||||
export const getFirstDayOfTheWeekAsISONumber = (
|
||||
firstDayOfTheWeek: FirstDayOfTheWeek,
|
||||
): 1 | 6 | 7 => {
|
||||
switch (firstDayOfTheWeek) {
|
||||
case FirstDayOfTheWeekEnum.MONDAY:
|
||||
return 1;
|
||||
case FirstDayOfTheWeekEnum.SATURDAY:
|
||||
return 6;
|
||||
case FirstDayOfTheWeekEnum.SUNDAY:
|
||||
return 7;
|
||||
default:
|
||||
return assertUnreachable(firstDayOfTheWeek);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { type Nullable } from '@/types';
|
||||
import { assertUnreachable } from '@/utils/assertUnreachable';
|
||||
import { type DateTimePeriod } from '@/utils/filter/dates/types/DateTimePeriod';
|
||||
import { type FirstDayOfTheWeek } from '@/utils/filter/dates/utils/firstDayOfWeekSchema';
|
||||
import { getPeriodStart } from '@/utils/filter/dates/utils/getPeriodStart';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const FIRST_DAY_OF_WEEK_ISO_8601_MONDAY = 1;
|
||||
|
||||
export const getNextPeriodStart = (
|
||||
dateTime: Temporal.ZonedDateTime,
|
||||
unit: DateTimePeriod,
|
||||
firstDayOfTheWeek?: Nullable<FirstDayOfTheWeek>,
|
||||
) => {
|
||||
switch (unit) {
|
||||
case 'DAY':
|
||||
return getPeriodStart(dateTime, 'DAY').add({ days: 1 });
|
||||
case 'WEEK': {
|
||||
return getPeriodStart(dateTime, 'WEEK', firstDayOfTheWeek).add({
|
||||
weeks: 1,
|
||||
});
|
||||
}
|
||||
case 'MONTH':
|
||||
return getPeriodStart(dateTime, 'MONTH', firstDayOfTheWeek).add({
|
||||
months: 1,
|
||||
});
|
||||
case 'QUARTER':
|
||||
return getPeriodStart(dateTime, 'QUARTER', firstDayOfTheWeek).add({
|
||||
months: 3,
|
||||
});
|
||||
case 'YEAR':
|
||||
return getPeriodStart(dateTime, 'YEAR', firstDayOfTheWeek).add({
|
||||
years: 1,
|
||||
});
|
||||
case 'SECOND':
|
||||
return getPeriodStart(dateTime, 'SECOND').add({ seconds: 1 });
|
||||
case 'MINUTE':
|
||||
return getPeriodStart(dateTime, 'MINUTE').add({ minutes: 1 });
|
||||
case 'HOUR':
|
||||
return getPeriodStart(dateTime, 'HOUR').add({ hours: 1 });
|
||||
default:
|
||||
return assertUnreachable(unit);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { type Nullable } from '@/types';
|
||||
import { assertUnreachable, type DateTimePeriod } from '@/utils';
|
||||
|
||||
import { type FirstDayOfTheWeek } from '@/utils/filter/dates/utils/firstDayOfWeekSchema';
|
||||
import { getFirstDayOfTheWeekAsISONumber } from '@/utils/filter/dates/utils/getFirstDayOfTheWeekAsISONumber';
|
||||
import { FIRST_DAY_OF_WEEK_ISO_8601_MONDAY } from '@/utils/filter/dates/utils/getNextPeriodStart';
|
||||
import { isDefined } from '@/utils/validation';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const getPeriodStart = (
|
||||
dateTime: Temporal.ZonedDateTime,
|
||||
unit: DateTimePeriod,
|
||||
firstDayOfTheWeek?: Nullable<FirstDayOfTheWeek>,
|
||||
) => {
|
||||
switch (unit) {
|
||||
case 'DAY':
|
||||
return dateTime.startOfDay();
|
||||
case 'WEEK': {
|
||||
const firstDayOfTheWeekAsISONumber = isDefined(firstDayOfTheWeek)
|
||||
? getFirstDayOfTheWeekAsISONumber(firstDayOfTheWeek)
|
||||
: FIRST_DAY_OF_WEEK_ISO_8601_MONDAY;
|
||||
|
||||
const daysOffsetToSutract =
|
||||
(dateTime.dayOfWeek - firstDayOfTheWeekAsISONumber + 7) % 7;
|
||||
|
||||
return dateTime.startOfDay().subtract({ days: daysOffsetToSutract });
|
||||
}
|
||||
case 'QUARTER': {
|
||||
const firstMonthOfTheQuarter = Math.floor((dateTime.month - 1) / 3);
|
||||
|
||||
return dateTime
|
||||
.startOfDay()
|
||||
.with({ day: 1, month: firstMonthOfTheQuarter * 3 + 1 });
|
||||
}
|
||||
case 'MONTH':
|
||||
return dateTime.startOfDay().with({ day: 1 });
|
||||
case 'YEAR':
|
||||
return dateTime.startOfDay().with({ day: 1, month: 1 });
|
||||
case 'SECOND':
|
||||
return dateTime.with({ nanosecond: 0, microsecond: 0, millisecond: 0 });
|
||||
case 'MINUTE':
|
||||
return dateTime.with({
|
||||
second: 0,
|
||||
nanosecond: 0,
|
||||
microsecond: 0,
|
||||
millisecond: 0,
|
||||
});
|
||||
case 'HOUR':
|
||||
return dateTime.with({
|
||||
minute: 0,
|
||||
second: 0,
|
||||
nanosecond: 0,
|
||||
microsecond: 0,
|
||||
millisecond: 0,
|
||||
});
|
||||
default:
|
||||
return assertUnreachable(unit);
|
||||
}
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import { DATE_TYPE_FORMAT } from '@/constants';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
export const getPlainDateFromDate = (date: Date) => {
|
||||
return format(date, DATE_TYPE_FORMAT);
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
import { type Nullable } from '@/types';
|
||||
import { type FirstDayOfTheWeek } from '@/utils/filter/dates/utils/firstDayOfWeekSchema';
|
||||
import { getFirstDayOfTheWeekAsANumberForDateFNS } from '@/utils/filter/dates/utils/getFirstDayOfTheWeekAsANumberForDateFNS';
|
||||
import { type RelativeDateFilterUnit } from '@/utils/filter/dates/utils/relativeDateFilterUnitSchema';
|
||||
import { isDefined } from '@/utils/validation';
|
||||
import {
|
||||
startOfDay,
|
||||
startOfHour,
|
||||
startOfMinute,
|
||||
startOfMonth,
|
||||
startOfSecond,
|
||||
startOfWeek,
|
||||
startOfYear,
|
||||
} from 'date-fns';
|
||||
|
||||
export const getStartUnitOfDateTime = (
|
||||
dateTime: Date,
|
||||
unit: RelativeDateFilterUnit,
|
||||
firstDayOfTheWeek?: Nullable<FirstDayOfTheWeek>,
|
||||
) => {
|
||||
switch (unit) {
|
||||
case 'SECOND':
|
||||
return startOfSecond(dateTime);
|
||||
case 'MINUTE':
|
||||
return startOfMinute(dateTime);
|
||||
case 'HOUR':
|
||||
return startOfHour(dateTime);
|
||||
case 'DAY':
|
||||
return startOfDay(dateTime);
|
||||
case 'WEEK': {
|
||||
if (isDefined(firstDayOfTheWeek)) {
|
||||
const firstDayOfTheWeekAsDateFNSNumber =
|
||||
getFirstDayOfTheWeekAsANumberForDateFNS(firstDayOfTheWeek);
|
||||
|
||||
return startOfWeek(dateTime, {
|
||||
weekStartsOn: firstDayOfTheWeekAsDateFNSNumber,
|
||||
});
|
||||
} else {
|
||||
return startOfWeek(dateTime);
|
||||
}
|
||||
}
|
||||
case 'MONTH':
|
||||
return startOfMonth(dateTime);
|
||||
case 'YEAR':
|
||||
return startOfYear(dateTime);
|
||||
}
|
||||
};
|
||||
+6
-2
@@ -7,7 +7,7 @@ const REGEX_FOR_RELATIVE_DATE_FILTER_STRINGIFIED_PARSING =
|
||||
|
||||
export const relativeDateFilterStringifiedSchema = z
|
||||
.string()
|
||||
.transform((value) => {
|
||||
.transform((value, context) => {
|
||||
const regexForParsingStringifiedRelativeDateFilter = new RegExp(
|
||||
REGEX_FOR_RELATIVE_DATE_FILTER_STRINGIFIED_PARSING,
|
||||
);
|
||||
@@ -15,7 +15,11 @@ export const relativeDateFilterStringifiedSchema = z
|
||||
const result = regexForParsingStringifiedRelativeDateFilter.exec(value);
|
||||
|
||||
if (!isNonEmptyArray(result)) {
|
||||
throw new Error(`Cannot parse stringified relative date filter`);
|
||||
context.addIssue(
|
||||
`Cannot parse stringified inline relative date filter, value : "${value}"`,
|
||||
);
|
||||
|
||||
return z.NEVER;
|
||||
}
|
||||
|
||||
const [_, direction, amount, unit, timezone, firstDayOfTheWeek] = result;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { resolveRelativeDateTimeFilterStringified } from '@/utils/filter/dates/u
|
||||
export type ResolvedDateTimeFilterValue<O extends ViewFilterOperand> =
|
||||
O extends ViewFilterOperand.IS_RELATIVE
|
||||
? ReturnType<typeof resolveRelativeDateTimeFilterStringified>
|
||||
: Date | null;
|
||||
: string | null;
|
||||
|
||||
type PartialViewFilter<O extends ViewFilterOperand> = {
|
||||
value: string;
|
||||
@@ -21,5 +21,5 @@ export const resolveDateTimeFilter = <O extends ViewFilterOperand>(
|
||||
viewFilter.value,
|
||||
) as ResolvedDateTimeFilterValue<O>;
|
||||
}
|
||||
return new Date(viewFilter.value) as ResolvedDateTimeFilterValue<O>;
|
||||
return viewFilter.value as ResolvedDateTimeFilterValue<O>;
|
||||
};
|
||||
|
||||
@@ -1,53 +1,86 @@
|
||||
import { addUnitToDateTime } from '@/utils/filter/dates/utils/addUnitToDateTime';
|
||||
import { getEndUnitOfDateTime } from '@/utils/filter/dates/utils/getEndUnitOfDateTime';
|
||||
import { getPlainDateFromDate } from '@/utils/filter/dates/utils/getPlainDateFromDate';
|
||||
import { getStartUnitOfDateTime } from '@/utils/filter/dates/utils/getStartUnitOfDateTime';
|
||||
import { addUnitToZonedDateTime } from '@/utils/filter/dates/utils/addUnitToZonedDateTime';
|
||||
import { getNextPeriodStart } from '@/utils/filter/dates/utils/getNextPeriodStart';
|
||||
import { getPeriodStart } from '@/utils/filter/dates/utils/getPeriodStart';
|
||||
import { type RelativeDateFilter } from '@/utils/filter/dates/utils/relativeDateFilterSchema';
|
||||
import { subUnitFromDateTime } from '@/utils/filter/dates/utils/subUnitFromDateTime';
|
||||
import { isDefined } from '@/utils/validation';
|
||||
import { TZDate } from '@date-fns/tz';
|
||||
import { subUnitFromZonedDateTime } from '@/utils/filter/dates/utils/subUnitFromZonedDateTime';
|
||||
import { isDefined } from 'class-validator';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
|
||||
// TODO: use this in workflows where there is duplicated logic
|
||||
export const resolveRelativeDateFilter = (
|
||||
relativeDateFilter: RelativeDateFilter,
|
||||
referenceTodayZonedDateTime: Temporal.ZonedDateTime,
|
||||
) => {
|
||||
const { direction, amount, unit, firstDayOfTheWeek } = relativeDateFilter;
|
||||
|
||||
const referenceDate = new TZDate();
|
||||
|
||||
switch (direction) {
|
||||
case 'NEXT':
|
||||
case 'NEXT': {
|
||||
if (!isDefined(amount)) {
|
||||
throw new Error('Amount is required');
|
||||
}
|
||||
|
||||
const startOfNextDay = referenceTodayZonedDateTime
|
||||
.startOfDay()
|
||||
.add({ days: 1 });
|
||||
|
||||
const startOfNextPeriod = addUnitToZonedDateTime(
|
||||
startOfNextDay,
|
||||
unit,
|
||||
amount,
|
||||
);
|
||||
|
||||
const start = startOfNextDay.toPlainDate().toString();
|
||||
const end = startOfNextPeriod?.toPlainDate().toString();
|
||||
|
||||
return {
|
||||
...relativeDateFilter,
|
||||
start: getPlainDateFromDate(referenceDate),
|
||||
end: getPlainDateFromDate(
|
||||
addUnitToDateTime(referenceDate, amount, unit),
|
||||
),
|
||||
start,
|
||||
end,
|
||||
};
|
||||
case 'PAST':
|
||||
}
|
||||
case 'PAST': {
|
||||
if (!isDefined(amount)) {
|
||||
throw new Error('Amount is required');
|
||||
}
|
||||
|
||||
const startOfDay = referenceTodayZonedDateTime.startOfDay();
|
||||
|
||||
const startOfNextPeriod = subUnitFromZonedDateTime(
|
||||
startOfDay,
|
||||
unit,
|
||||
amount,
|
||||
);
|
||||
|
||||
const start = startOfNextPeriod?.toPlainDate().toString();
|
||||
const end = startOfDay.toPlainDate().toString();
|
||||
|
||||
return {
|
||||
...relativeDateFilter,
|
||||
start: getPlainDateFromDate(
|
||||
subUnitFromDateTime(referenceDate, amount, unit),
|
||||
),
|
||||
end: getPlainDateFromDate(referenceDate),
|
||||
start,
|
||||
end,
|
||||
};
|
||||
case 'THIS':
|
||||
}
|
||||
case 'THIS': {
|
||||
const startOfPeriod = getPeriodStart(
|
||||
referenceTodayZonedDateTime,
|
||||
unit,
|
||||
firstDayOfTheWeek,
|
||||
);
|
||||
|
||||
const endOfPeriod = getNextPeriodStart(
|
||||
referenceTodayZonedDateTime,
|
||||
unit,
|
||||
firstDayOfTheWeek,
|
||||
);
|
||||
|
||||
const start = startOfPeriod?.toPlainDate().toString();
|
||||
const end = endOfPeriod?.toPlainDate().toString();
|
||||
|
||||
return {
|
||||
...relativeDateFilter,
|
||||
start: getPlainDateFromDate(
|
||||
getStartUnitOfDateTime(referenceDate, unit, firstDayOfTheWeek),
|
||||
),
|
||||
end: getPlainDateFromDate(
|
||||
getEndUnitOfDateTime(referenceDate, unit, firstDayOfTheWeek),
|
||||
),
|
||||
start,
|
||||
end,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+20
-5
@@ -1,6 +1,8 @@
|
||||
import { relativeDateFilterStringifiedSchema } from '@/utils/filter/dates/utils/relativeDateFilterStringifiedSchema';
|
||||
import { resolveRelativeDateFilter } from '@/utils/filter/dates/utils/resolveRelativeDateFilter';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined } from 'class-validator';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const resolveRelativeDateFilterStringified = (
|
||||
relativeDateFilterStringified?: string | null,
|
||||
@@ -9,12 +11,25 @@ export const resolveRelativeDateFilterStringified = (
|
||||
return null;
|
||||
}
|
||||
|
||||
const relativeDateFilter = relativeDateFilterStringifiedSchema.parse(
|
||||
relativeDateFilterStringified,
|
||||
);
|
||||
const relativeDateFilterParseResult =
|
||||
relativeDateFilterStringifiedSchema.safeParse(
|
||||
relativeDateFilterStringified,
|
||||
);
|
||||
|
||||
const relativeDateFilterWithDateRange =
|
||||
resolveRelativeDateFilter(relativeDateFilter);
|
||||
if (!relativeDateFilterParseResult.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relativeDateFilter = relativeDateFilterParseResult.data;
|
||||
|
||||
const referenceTodayZonedDateTime = isDefined(relativeDateFilter.timezone)
|
||||
? Temporal.Now.zonedDateTimeISO(relativeDateFilter.timezone)
|
||||
: Temporal.Now.zonedDateTimeISO();
|
||||
|
||||
const relativeDateFilterWithDateRange = resolveRelativeDateFilter(
|
||||
relativeDateFilter,
|
||||
referenceTodayZonedDateTime,
|
||||
);
|
||||
|
||||
return relativeDateFilterWithDateRange;
|
||||
};
|
||||
|
||||
+50
-28
@@ -1,51 +1,73 @@
|
||||
import { addUnitToDateTime } from '@/utils/filter/dates/utils/addUnitToDateTime';
|
||||
import { getEndUnitOfDateTime } from '@/utils/filter/dates/utils/getEndUnitOfDateTime';
|
||||
import { getStartUnitOfDateTime } from '@/utils/filter/dates/utils/getStartUnitOfDateTime';
|
||||
import { addUnitToZonedDateTime } from '@/utils/filter/dates/utils/addUnitToZonedDateTime';
|
||||
import { getNextPeriodStart } from '@/utils/filter/dates/utils/getNextPeriodStart';
|
||||
import { getPeriodStart } from '@/utils/filter/dates/utils/getPeriodStart';
|
||||
import { type RelativeDateFilter } from '@/utils/filter/dates/utils/relativeDateFilterSchema';
|
||||
import { subUnitFromDateTime } from '@/utils/filter/dates/utils/subUnitFromDateTime';
|
||||
import { subUnitFromZonedDateTime } from '@/utils/filter/dates/utils/subUnitFromZonedDateTime';
|
||||
import { isDefined } from '@/utils/validation';
|
||||
import { TZDate } from '@date-fns/tz';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { roundToNearestMinutes } from 'date-fns';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const resolveRelativeDateTimeFilter = (
|
||||
relativeDateFilter: RelativeDateFilter,
|
||||
referenceZonedDateTime: Temporal.ZonedDateTime,
|
||||
) => {
|
||||
const { direction, amount, unit, timezone, firstDayOfTheWeek } =
|
||||
relativeDateFilter;
|
||||
const { direction, amount, unit, firstDayOfTheWeek } = relativeDateFilter;
|
||||
|
||||
const referenceDate = roundToNearestMinutes(
|
||||
isNonEmptyString(timezone)
|
||||
? new TZDate().withTimeZone(timezone)
|
||||
: new TZDate(),
|
||||
);
|
||||
const isSubDayUnit = ['SECOND', 'MINUTE', 'HOUR'].includes(unit);
|
||||
|
||||
switch (direction) {
|
||||
case 'NEXT':
|
||||
case 'NEXT': {
|
||||
if (!isDefined(amount)) {
|
||||
throw new Error('Amount is required');
|
||||
}
|
||||
|
||||
return {
|
||||
...relativeDateFilter,
|
||||
start: referenceDate,
|
||||
end: addUnitToDateTime(referenceDate, amount, unit),
|
||||
};
|
||||
case 'PAST':
|
||||
if (isSubDayUnit) {
|
||||
return {
|
||||
...relativeDateFilter,
|
||||
start: referenceZonedDateTime,
|
||||
end: addUnitToZonedDateTime(referenceZonedDateTime, unit, amount),
|
||||
};
|
||||
} else {
|
||||
const startOfNextDay = referenceZonedDateTime
|
||||
.startOfDay()
|
||||
.add({ days: 1 });
|
||||
|
||||
return {
|
||||
...relativeDateFilter,
|
||||
start: startOfNextDay,
|
||||
end: addUnitToZonedDateTime(startOfNextDay, unit, amount),
|
||||
};
|
||||
}
|
||||
}
|
||||
case 'PAST': {
|
||||
if (!isDefined(amount)) {
|
||||
throw new Error('Amount is required');
|
||||
}
|
||||
|
||||
return {
|
||||
...relativeDateFilter,
|
||||
start: subUnitFromDateTime(referenceDate, amount, unit),
|
||||
end: referenceDate,
|
||||
};
|
||||
if (isSubDayUnit) {
|
||||
return {
|
||||
...relativeDateFilter,
|
||||
start: subUnitFromZonedDateTime(referenceZonedDateTime, unit, amount),
|
||||
end: referenceZonedDateTime,
|
||||
};
|
||||
} else {
|
||||
const startOfDay = referenceZonedDateTime.startOfDay();
|
||||
|
||||
return {
|
||||
...relativeDateFilter,
|
||||
start: subUnitFromZonedDateTime(startOfDay, unit, amount),
|
||||
end: startOfDay,
|
||||
};
|
||||
}
|
||||
}
|
||||
case 'THIS':
|
||||
return {
|
||||
...relativeDateFilter,
|
||||
start: getStartUnitOfDateTime(referenceDate, unit, firstDayOfTheWeek),
|
||||
end: getEndUnitOfDateTime(referenceDate, unit, firstDayOfTheWeek),
|
||||
start: getPeriodStart(referenceZonedDateTime, unit, firstDayOfTheWeek),
|
||||
end: getNextPeriodStart(
|
||||
referenceZonedDateTime,
|
||||
unit,
|
||||
firstDayOfTheWeek,
|
||||
),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
+19
-28
@@ -1,44 +1,35 @@
|
||||
import { relativeDateFilterStringifiedSchema } from '@/utils/filter/dates/utils/relativeDateFilterStringifiedSchema';
|
||||
import { resolveRelativeDateTimeFilter } from '@/utils/filter/dates/utils/resolveRelativeDateTimeFilter';
|
||||
import { shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone } from '@/utils/filter/dates/utils/shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone';
|
||||
import { isDefined } from '@/utils/validation';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined } from 'class-validator';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const resolveRelativeDateTimeFilterStringified = (
|
||||
relativeDateTimeFilterStringified?: string | null,
|
||||
relativeDateTimeFilterStringified: string | null | undefined,
|
||||
) => {
|
||||
if (!isNonEmptyString(relativeDateTimeFilterStringified)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relativeDateFilter = relativeDateFilterStringifiedSchema.parse(
|
||||
relativeDateTimeFilterStringified,
|
||||
);
|
||||
const relativeDateFilterParseResult =
|
||||
relativeDateFilterStringifiedSchema.safeParse(
|
||||
relativeDateTimeFilterStringified,
|
||||
);
|
||||
|
||||
const relativeDateFilterWithDateRange =
|
||||
resolveRelativeDateTimeFilter(relativeDateFilter);
|
||||
if (relativeDateFilterParseResult.success) {
|
||||
const relativeDateFilter = relativeDateFilterParseResult.data;
|
||||
|
||||
if (isDefined(relativeDateFilter.timezone)) {
|
||||
const shiftedStartDate =
|
||||
shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone(
|
||||
relativeDateFilterWithDateRange.start,
|
||||
relativeDateFilter.timezone,
|
||||
'add',
|
||||
);
|
||||
const referenceTodayZonedDateTime = isDefined(relativeDateFilter.timezone)
|
||||
? Temporal.Now.zonedDateTimeISO(relativeDateFilter.timezone)
|
||||
: Temporal.Now.zonedDateTimeISO();
|
||||
|
||||
const shiftedEndDate =
|
||||
shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone(
|
||||
relativeDateFilterWithDateRange.end,
|
||||
relativeDateFilter.timezone,
|
||||
'add',
|
||||
);
|
||||
const relativeDateFilterWithDateRange = resolveRelativeDateTimeFilter(
|
||||
relativeDateFilter,
|
||||
referenceTodayZonedDateTime.round({ smallestUnit: 'second' }),
|
||||
);
|
||||
|
||||
return {
|
||||
...relativeDateFilterWithDateRange,
|
||||
start: shiftedStartDate,
|
||||
end: shiftedEndDate,
|
||||
};
|
||||
return relativeDateFilterWithDateRange;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return relativeDateFilterWithDateRange;
|
||||
};
|
||||
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
import { computeTimezoneDifferenceInMinutes } from '@/utils/filter/utils/computeTimezoneDifferenceInMinutes';
|
||||
import { addMinutes, subMinutes } from 'date-fns';
|
||||
|
||||
export const shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone =
|
||||
(pointInTime: Date, targetTimezone: string, direction: 'add' | 'sub') => {
|
||||
const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
const timezoneDifferenceInMinutesFromSystemTimezone =
|
||||
computeTimezoneDifferenceInMinutes(
|
||||
targetTimezone,
|
||||
systemTimeZone,
|
||||
pointInTime,
|
||||
);
|
||||
|
||||
if (direction === 'add') {
|
||||
return addMinutes(
|
||||
pointInTime,
|
||||
timezoneDifferenceInMinutesFromSystemTimezone,
|
||||
);
|
||||
} else {
|
||||
return subMinutes(
|
||||
pointInTime,
|
||||
timezoneDifferenceInMinutesFromSystemTimezone,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { assertUnreachable, type DateTimePeriod } from '@/utils';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
|
||||
export const subUnitFromZonedDateTime = (
|
||||
zonedDateTime: Temporal.ZonedDateTime,
|
||||
unit: DateTimePeriod,
|
||||
amount: number,
|
||||
) => {
|
||||
switch (unit) {
|
||||
case 'DAY':
|
||||
return zonedDateTime.subtract({ days: amount });
|
||||
case 'WEEK': {
|
||||
return zonedDateTime.subtract({ weeks: amount });
|
||||
}
|
||||
case 'QUARTER': {
|
||||
return zonedDateTime.subtract({
|
||||
months: amount * 3,
|
||||
});
|
||||
}
|
||||
case 'MONTH':
|
||||
return zonedDateTime.subtract({
|
||||
months: amount,
|
||||
});
|
||||
case 'YEAR':
|
||||
return zonedDateTime.subtract({
|
||||
years: amount,
|
||||
});
|
||||
case 'SECOND':
|
||||
return zonedDateTime.subtract({
|
||||
seconds: amount,
|
||||
});
|
||||
case 'MINUTE':
|
||||
return zonedDateTime.subtract({
|
||||
minutes: amount,
|
||||
});
|
||||
case 'HOUR':
|
||||
return zonedDateTime.subtract({
|
||||
hours: amount,
|
||||
});
|
||||
default:
|
||||
return assertUnreachable(unit);
|
||||
}
|
||||
};
|
||||
+120
-98
@@ -34,30 +34,24 @@ import {
|
||||
isExpectedSubFieldName,
|
||||
} from '@/utils/filter';
|
||||
|
||||
import {
|
||||
endOfDay,
|
||||
endOfMinute,
|
||||
roundToNearestMinutes,
|
||||
startOfDay,
|
||||
startOfMinute,
|
||||
} from 'date-fns';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type DateTimeFilter } from '@/types/RecordGqlOperationFilter';
|
||||
import {
|
||||
checkIfShouldComputeEmptinessFilter,
|
||||
checkIfShouldSkipFiltering,
|
||||
CustomError,
|
||||
getFilterTypeFromFieldType,
|
||||
getPlainDateFromDate,
|
||||
getNextPeriodStart,
|
||||
getPeriodStart,
|
||||
isDefined,
|
||||
resolveDateFilter,
|
||||
resolveDateTimeFilter,
|
||||
resolveRelativeDateFilterStringified,
|
||||
type RecordFilter,
|
||||
} from '@/utils';
|
||||
import { arrayOfStringsOrVariablesSchema } from '@/utils/filter/utils/validation-schemas/arrayOfStringsOrVariablesSchema';
|
||||
import { arrayOfUuidOrVariableSchema } from '@/utils/filter/utils/validation-schemas/arrayOfUuidsOrVariablesSchema';
|
||||
import { jsonRelationFilterValueSchema } from '@/utils/filter/utils/validation-schemas/jsonRelationFilterValueSchema';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
type FieldShared = {
|
||||
id: string;
|
||||
@@ -67,7 +61,7 @@ type FieldShared = {
|
||||
};
|
||||
|
||||
type TurnRecordFilterIntoRecordGqlOperationFilterParams = {
|
||||
filterValueDependencies?: RecordFilterValueDependencies;
|
||||
filterValueDependencies: RecordFilterValueDependencies;
|
||||
recordFilter: RecordFilter;
|
||||
fieldMetadataItems: FieldShared[];
|
||||
};
|
||||
@@ -174,20 +168,53 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
|
||||
);
|
||||
}
|
||||
case 'DATE': {
|
||||
const resolvedFilterValue = resolveDateFilter(recordFilter);
|
||||
if (recordFilter.operand === RecordFilterOperand.IS_RELATIVE) {
|
||||
const relativeDateFilterValue = resolveRelativeDateFilterStringified(
|
||||
recordFilter.value,
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const defaultDateRange = resolveDateFilter({
|
||||
value: 'PAST_1_DAY',
|
||||
operand: RecordFilterOperand.IS_RELATIVE,
|
||||
});
|
||||
|
||||
const plainDateFilter =
|
||||
typeof resolvedFilterValue === 'string' ? resolvedFilterValue : null;
|
||||
if (!defaultDateRange) {
|
||||
throw new Error('Failed to resolve default date range');
|
||||
}
|
||||
|
||||
const nowAsPlainDate = getPlainDateFromDate(now);
|
||||
const start =
|
||||
relativeDateFilterValue?.start?.toString() ?? defaultDateRange.start;
|
||||
|
||||
const end =
|
||||
relativeDateFilterValue?.end?.toString() ?? defaultDateRange.end;
|
||||
|
||||
return {
|
||||
and: [
|
||||
{
|
||||
[correspondingFieldMetadataItem.name]: {
|
||||
gte: start,
|
||||
} as DateFilter,
|
||||
},
|
||||
{
|
||||
[correspondingFieldMetadataItem.name]: {
|
||||
lt: end,
|
||||
} as DateFilter,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const nowAsPlainDate = Temporal.Now.plainDateISO(
|
||||
filterValueDependencies.timeZone,
|
||||
).toString();
|
||||
|
||||
const plainDateFilter = recordFilter.value;
|
||||
|
||||
switch (recordFilter.operand) {
|
||||
case RecordFilterOperand.IS_AFTER: {
|
||||
return {
|
||||
[correspondingFieldMetadataItem.name]: {
|
||||
gt: plainDateFilter,
|
||||
gte: plainDateFilter,
|
||||
} as DateFilter,
|
||||
};
|
||||
}
|
||||
@@ -198,38 +225,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
|
||||
} as DateFilter,
|
||||
};
|
||||
}
|
||||
case RecordFilterOperand.IS_RELATIVE: {
|
||||
const dateRange = z
|
||||
.object({ start: z.string(), end: z.string() })
|
||||
.safeParse(resolvedFilterValue).data;
|
||||
|
||||
const defaultDateRange = resolveDateFilter({
|
||||
value: 'PAST_1_DAY',
|
||||
operand: RecordFilterOperand.IS_RELATIVE,
|
||||
});
|
||||
|
||||
if (!defaultDateRange) {
|
||||
throw new Error('Failed to resolve default date range');
|
||||
}
|
||||
|
||||
const { start: startPlainDate, end: endPlainDate } =
|
||||
dateRange ?? defaultDateRange;
|
||||
|
||||
return {
|
||||
and: [
|
||||
{
|
||||
[correspondingFieldMetadataItem.name]: {
|
||||
gte: startPlainDate,
|
||||
} as DateFilter,
|
||||
},
|
||||
{
|
||||
[correspondingFieldMetadataItem.name]: {
|
||||
lte: endPlainDate,
|
||||
} as DateFilter,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
case RecordFilterOperand.IS: {
|
||||
return {
|
||||
[correspondingFieldMetadataItem.name]: {
|
||||
@@ -240,7 +236,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
|
||||
case RecordFilterOperand.IS_IN_PAST:
|
||||
return {
|
||||
[correspondingFieldMetadataItem.name]: {
|
||||
lte: nowAsPlainDate,
|
||||
lt: nowAsPlainDate,
|
||||
} as DateFilter,
|
||||
};
|
||||
case RecordFilterOperand.IS_IN_FUTURE:
|
||||
@@ -263,71 +259,97 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
|
||||
}
|
||||
}
|
||||
case 'DATE_TIME': {
|
||||
const resolvedFilterValue = resolveDateTimeFilter(recordFilter);
|
||||
const now = roundToNearestMinutes(new Date());
|
||||
const date =
|
||||
resolvedFilterValue instanceof Date ? resolvedFilterValue : now;
|
||||
if (recordFilter.operand === RecordFilterOperand.IS_RELATIVE) {
|
||||
const resolvedFilterValue = resolveDateTimeFilter(recordFilter);
|
||||
|
||||
const parsedRelativeDateFilterValue =
|
||||
isDefined(resolvedFilterValue) &&
|
||||
typeof resolvedFilterValue === 'object'
|
||||
? resolvedFilterValue
|
||||
: null;
|
||||
|
||||
if (!isDefined(parsedRelativeDateFilterValue)) {
|
||||
throw new Error(
|
||||
`Cannot parse relative date filter : "${recordFilter.value}"`,
|
||||
);
|
||||
}
|
||||
|
||||
const defaultDateRange = resolveDateTimeFilter({
|
||||
value: `PAST_1_DAY;;${filterValueDependencies.timeZone}`,
|
||||
operand: RecordFilterOperand.IS_RELATIVE,
|
||||
});
|
||||
|
||||
if (
|
||||
!isDefined(defaultDateRange?.start) ||
|
||||
!isDefined(defaultDateRange?.end)
|
||||
) {
|
||||
throw new Error('Failed to resolve default date range');
|
||||
}
|
||||
|
||||
const start =
|
||||
parsedRelativeDateFilterValue?.start ?? defaultDateRange.start;
|
||||
|
||||
const end = parsedRelativeDateFilterValue?.end ?? defaultDateRange.end;
|
||||
|
||||
return {
|
||||
and: [
|
||||
{
|
||||
[correspondingFieldMetadataItem.name]: {
|
||||
gte: start.toInstant().toString(),
|
||||
} as DateTimeFilter,
|
||||
},
|
||||
{
|
||||
[correspondingFieldMetadataItem.name]: {
|
||||
lt: end.toInstant().toString(),
|
||||
} as DateTimeFilter,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (!isNonEmptyString(recordFilter.value)) {
|
||||
throw new Error(`Date filter is empty`);
|
||||
}
|
||||
|
||||
const resolvedDateTime = Temporal.Instant.from(recordFilter.value);
|
||||
|
||||
const now = Temporal.Now.zonedDateTimeISO(
|
||||
filterValueDependencies.timeZone,
|
||||
);
|
||||
|
||||
switch (recordFilter.operand) {
|
||||
case RecordFilterOperand.IS_AFTER: {
|
||||
return {
|
||||
[correspondingFieldMetadataItem.name]: {
|
||||
gt: date.toISOString(),
|
||||
gte: resolvedDateTime.toString(),
|
||||
} as DateTimeFilter,
|
||||
};
|
||||
}
|
||||
case RecordFilterOperand.IS_BEFORE: {
|
||||
return {
|
||||
[correspondingFieldMetadataItem.name]: {
|
||||
lt: date.toISOString(),
|
||||
lt: resolvedDateTime.toString(),
|
||||
} as DateTimeFilter,
|
||||
};
|
||||
}
|
||||
case RecordFilterOperand.IS_RELATIVE: {
|
||||
const dateRange = z
|
||||
.object({ start: z.date(), end: z.date() })
|
||||
.safeParse(resolvedFilterValue).data;
|
||||
|
||||
const defaultDateRange = resolveDateTimeFilter({
|
||||
value: 'PAST_1_DAY',
|
||||
operand: RecordFilterOperand.IS_RELATIVE,
|
||||
});
|
||||
|
||||
if (!defaultDateRange) {
|
||||
throw new Error('Failed to resolve default date range');
|
||||
}
|
||||
|
||||
const { start, end } = dateRange ?? defaultDateRange;
|
||||
|
||||
return {
|
||||
and: [
|
||||
{
|
||||
[correspondingFieldMetadataItem.name]: {
|
||||
gte: start.toISOString(),
|
||||
} as DateTimeFilter,
|
||||
},
|
||||
{
|
||||
[correspondingFieldMetadataItem.name]: {
|
||||
lte: end.toISOString(),
|
||||
} as DateTimeFilter,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
case RecordFilterOperand.IS: {
|
||||
const isValid = resolvedFilterValue instanceof Date;
|
||||
const date = isValid ? resolvedFilterValue : now;
|
||||
const start = resolvedDateTime
|
||||
.toZonedDateTimeISO('UTC')
|
||||
.with({ second: 0, millisecond: 0, microsecond: 0, nanosecond: 0 })
|
||||
.toInstant();
|
||||
|
||||
const end = start.add({ minutes: 1 });
|
||||
|
||||
return {
|
||||
and: [
|
||||
{
|
||||
[correspondingFieldMetadataItem.name]: {
|
||||
lte: endOfMinute(date).toISOString(),
|
||||
lt: end.toString(),
|
||||
} as DateTimeFilter,
|
||||
},
|
||||
{
|
||||
[correspondingFieldMetadataItem.name]: {
|
||||
gte: startOfMinute(date).toISOString(),
|
||||
gte: start.toString(),
|
||||
} as DateTimeFilter,
|
||||
},
|
||||
],
|
||||
@@ -336,13 +358,13 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
|
||||
case RecordFilterOperand.IS_IN_PAST:
|
||||
return {
|
||||
[correspondingFieldMetadataItem.name]: {
|
||||
lte: now.toISOString(),
|
||||
lt: now.toInstant().round('minute').toString(),
|
||||
} as DateTimeFilter,
|
||||
};
|
||||
case RecordFilterOperand.IS_IN_FUTURE:
|
||||
return {
|
||||
[correspondingFieldMetadataItem.name]: {
|
||||
gte: now.toISOString(),
|
||||
gt: now.toInstant().round('minute').toString(),
|
||||
} as DateTimeFilter,
|
||||
};
|
||||
case RecordFilterOperand.IS_TODAY: {
|
||||
@@ -350,22 +372,22 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
|
||||
and: [
|
||||
{
|
||||
[correspondingFieldMetadataItem.name]: {
|
||||
lte: endOfDay(now).toISOString(),
|
||||
gte: getPeriodStart(now, 'DAY').toInstant().toString(),
|
||||
} as DateTimeFilter,
|
||||
},
|
||||
{
|
||||
[correspondingFieldMetadataItem.name]: {
|
||||
gte: startOfDay(now).toISOString(),
|
||||
lt: getNextPeriodStart(now, 'DAY').toInstant().toString(),
|
||||
} as DateTimeFilter,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown operand ${recordFilter.operand} for ${filterType} filter`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unknown operand ${recordFilter.operand} for ${filterType} filter`,
|
||||
);
|
||||
}
|
||||
case 'RATING':
|
||||
switch (recordFilter.operand) {
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { tzOffset } from '@date-fns/tz';
|
||||
|
||||
export const computeTimezoneDifferenceInMinutes = (
|
||||
timezoneA: string,
|
||||
timezoneB: string,
|
||||
referenceDateForDST: Date,
|
||||
) => {
|
||||
const minutesOffsetA = tzOffset(timezoneA, referenceDateForDST);
|
||||
const minutesOffsetB = tzOffset(timezoneB, referenceDateForDST);
|
||||
|
||||
return minutesOffsetB - minutesOffsetA;
|
||||
};
|
||||
@@ -20,6 +20,17 @@ export { sumByProperty } from './array/sumByProperty';
|
||||
export { upsertIntoArrayOfObjectsComparingId } from './array/upsertIntoArrayOfObjectComparingId';
|
||||
export { assertUnreachable } from './assertUnreachable';
|
||||
export { computeDiffBetweenObjects } from './compute-diff-between-objects';
|
||||
export { isPlainDateAfter } from './date/isPlainDateAfter';
|
||||
export { isPlainDateBefore } from './date/isPlainDateBefore';
|
||||
export { isPlainDateBeforeOrEqual } from './date/isPlainDateBeforeOrEqual';
|
||||
export { isPlainDateInSameMonth } from './date/isPlainDateInSameMonth';
|
||||
export { isPlainDateInWeekend } from './date/isPlainDateInWeekend';
|
||||
export { isSamePlainDate } from './date/isSamePlainDate';
|
||||
export { parseToPlainDateOrThrow } from './date/parseToPlainDateOrThrow';
|
||||
export { sortPlainDate } from './date/sortPlainDate';
|
||||
export { turnJSDateToPlainDate } from './date/turnJSDateToPlainDate';
|
||||
export { turnPlainDateIntoUserTimeZoneInstantString } from './date/turnPlainDateIntoUserTimeZoneInstantString';
|
||||
export { turnPlainDateToShiftedDateInSystemTimeZone } from './date/turnPlainDateToShiftedDateInSystemTimeZone';
|
||||
export { deepMerge } from './deepMerge';
|
||||
export { CustomError } from './errors/CustomError';
|
||||
export { evalFromContext } from './evalFromContext';
|
||||
@@ -33,14 +44,20 @@ export { computeGqlOperationFilterForLinks } from './filter/compute-record-gql-o
|
||||
export { computeEmptyGqlOperationFilterForEmails } from './filter/computeEmptyGqlOperationFilterForEmails';
|
||||
export { computeEmptyGqlOperationFilterForLinks } from './filter/computeEmptyGqlOperationFilterForLinks';
|
||||
export { computeRecordGqlOperationFilter } from './filter/computeRecordGqlOperationFilter';
|
||||
export type { DateTimePeriod } from './filter/dates/types/DateTimePeriod';
|
||||
export { addUnitToDateTime } from './filter/dates/utils/addUnitToDateTime';
|
||||
export { addUnitToZonedDateTime } from './filter/dates/utils/addUnitToZonedDateTime';
|
||||
export { convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek } from './filter/dates/utils/convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek';
|
||||
export { convertFirstDayOfTheWeekToCalendarStartDayNumber } from './filter/dates/utils/convertFirstDayOfTheWeekToCalendarStartDayNumber';
|
||||
export type { FirstDayOfTheWeek } from './filter/dates/utils/firstDayOfWeekSchema';
|
||||
export { firstDayOfWeekSchema } from './filter/dates/utils/firstDayOfWeekSchema';
|
||||
export { getDateFromPlainDate } from './filter/dates/utils/getDateFromPlainDate';
|
||||
export { getEndUnitOfDateTime } from './filter/dates/utils/getEndUnitOfDateTime';
|
||||
export { getFirstDayOfTheWeekAsANumberForDateFNS } from './filter/dates/utils/getFirstDayOfTheWeekAsANumberForDateFNS';
|
||||
export { getPlainDateFromDate } from './filter/dates/utils/getPlainDateFromDate';
|
||||
export { getStartUnitOfDateTime } from './filter/dates/utils/getStartUnitOfDateTime';
|
||||
export { getFirstDayOfTheWeekAsISONumber } from './filter/dates/utils/getFirstDayOfTheWeekAsISONumber';
|
||||
export {
|
||||
FIRST_DAY_OF_WEEK_ISO_8601_MONDAY,
|
||||
getNextPeriodStart,
|
||||
} from './filter/dates/utils/getNextPeriodStart';
|
||||
export { getPeriodStart } from './filter/dates/utils/getPeriodStart';
|
||||
export { relativeDateFilterAmountSchema } from './filter/dates/utils/relativeDateFilterAmountSchema';
|
||||
export type { RelativeDateFilterDirection } from './filter/dates/utils/relativeDateFilterDirectionSchema';
|
||||
export { relativeDateFilterDirectionSchema } from './filter/dates/utils/relativeDateFilterDirectionSchema';
|
||||
@@ -57,8 +74,8 @@ export { resolveRelativeDateFilter } from './filter/dates/utils/resolveRelativeD
|
||||
export { resolveRelativeDateFilterStringified } from './filter/dates/utils/resolveRelativeDateFilterStringified';
|
||||
export { resolveRelativeDateTimeFilter } from './filter/dates/utils/resolveRelativeDateTimeFilter';
|
||||
export { resolveRelativeDateTimeFilterStringified } from './filter/dates/utils/resolveRelativeDateTimeFilterStringified';
|
||||
export { shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone } from './filter/dates/utils/shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone';
|
||||
export { subUnitFromDateTime } from './filter/dates/utils/subUnitFromDateTime';
|
||||
export { subUnitFromZonedDateTime } from './filter/dates/utils/subUnitFromZonedDateTime';
|
||||
export { isEmptinessOperand } from './filter/isEmptinessOperand';
|
||||
export { turnAnyFieldFilterIntoRecordGqlFilter } from './filter/turnAnyFieldFilterIntoRecordGqlFilter';
|
||||
export type {
|
||||
@@ -68,7 +85,6 @@ export type {
|
||||
export { turnRecordFilterGroupsIntoGqlOperationFilter } from './filter/turnRecordFilterGroupIntoGqlOperationFilter';
|
||||
export { turnRecordFilterIntoRecordGqlOperationFilter } from './filter/turnRecordFilterIntoGqlOperationFilter';
|
||||
export { combineFilters } from './filter/utils/combineFilters';
|
||||
export { computeTimezoneDifferenceInMinutes } from './filter/utils/computeTimezoneDifferenceInMinutes';
|
||||
export { convertViewFilterOperandToCoreOperand } from './filter/utils/convert-view-filter-operand-to-core-operand.util';
|
||||
export { convertViewFilterValueToString } from './filter/utils/convertViewFilterValueToString';
|
||||
export { createAnyFieldRecordFilterBaseProperties } from './filter/utils/createAnyFieldRecordFilterBaseProperties';
|
||||
|
||||
@@ -11,7 +11,9 @@ export const lowercaseUrlOriginAndRemoveTrailingSlash = (rawUrl: string) => {
|
||||
|
||||
const lowercaseOrigin = url.origin.toLowerCase();
|
||||
const path =
|
||||
safeDecodeURIComponent(url.pathname) + safeDecodeURIComponent(url.search) + url.hash;
|
||||
safeDecodeURIComponent(url.pathname) +
|
||||
safeDecodeURIComponent(url.search) +
|
||||
url.hash;
|
||||
|
||||
return (lowercaseOrigin + path).replace(/\/$/, '');
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user