diff --git a/package.json b/package.json index 1e9f4ad4..233860b4 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,6 @@ "dependencies": { "color": "^4.2.3", "date-fns": "^2.30.0", - "date-fns-tz": "^1.3.8", "rxjs": "^7.5.7" }, "peerDependencies": { diff --git a/src/chart/components/chart/chart.component.ts b/src/chart/components/chart/chart.component.ts index d126da56..c3a76053 100644 --- a/src/chart/components/chart/chart.component.ts +++ b/src/chart/components/chart/chart.component.ts @@ -270,8 +270,8 @@ export class ChartComponent extends ChartBaseElement { * @param {Timestamp} end - The end timestamp of the range. * @returns {void} */ - public setTimestampRange(start: Timestamp, end: Timestamp): void { - return this.chartModel.setTimestampRange(start, end); + public setTimestampRange(start: Timestamp, end: Timestamp, forceNoAnimation: boolean = true): void { + return this.chartModel.setTimestampRange(start, end, forceNoAnimation); } /** @@ -279,8 +279,8 @@ export class ChartComponent extends ChartBaseElement { * @param xStart - viewport start in units * @param xEnd - viewport end in units */ - public setXScale(xStart: Unit, xEnd: Unit) { - return this.scale.setXScale(xStart, xEnd); + public setXScale(xStart: Unit, xEnd: Unit, forceNoAnimation: boolean = true) { + return this.scale.setXScale(xStart, xEnd, forceNoAnimation); } /** diff --git a/src/chart/components/chart/chart.model.ts b/src/chart/components/chart/chart.model.ts index 74fc8a0f..11c3162c 100755 --- a/src/chart/components/chart/chart.model.ts +++ b/src/chart/components/chart/chart.model.ts @@ -64,7 +64,6 @@ export class ChartModel extends ChartBaseElement { public readonly nextCandleTimeStampSubject: Subject = new Subject(); public readonly chartTypeChanged: Subject = new Subject(); public readonly mainInstrumentChangedSubject: Subject = new Subject(); - public readonly scaleInversedSubject: Subject = new Subject(); public readonly offsetsChanged = new Subject(); private candlesTransformersByChartType: Partial> = {}; lastCandleLabelsByChartType: Partial> = {}; @@ -802,11 +801,11 @@ export class ChartModel extends ChartBaseElement { * @param {Timestamp} end - The end timestamp of the range. * @returns {void} */ - setTimestampRange(start: Timestamp, end: Timestamp): void { + setTimestampRange(start: Timestamp, end: Timestamp, forceNoAnimation: boolean = true): void { const startUnit = this.candleFromTimestamp(start).startUnit; const endCandle = this.candleFromTimestamp(end); const endUnit = endCandle.startUnit + endCandle.width; - return this.scale.setXScale(startUnit, endUnit); + return this.scale.setXScale(startUnit, endUnit, forceNoAnimation); } /** diff --git a/src/chart/components/x_axis/time/parser/time-formats-matchers.functions.ts b/src/chart/components/x_axis/time/parser/time-formats-matchers.functions.ts index bfde0208..24bdfdaa 100644 --- a/src/chart/components/x_axis/time/parser/time-formats-matchers.functions.ts +++ b/src/chart/components/x_axis/time/parser/time-formats-matchers.functions.ts @@ -16,7 +16,7 @@ import { isSunday, startOfMonth, endOfMonth, -} from 'date-fns/esm'; +} from 'date-fns'; import { ParsedCTimeFormat, ParsedNCTimeFormat, diff --git a/src/chart/components/x_axis/time/x-axis-weights.functions.ts b/src/chart/components/x_axis/time/x-axis-weights.functions.ts index 9f2e9eb8..0b7f9a49 100644 --- a/src/chart/components/x_axis/time/x-axis-weights.functions.ts +++ b/src/chart/components/x_axis/time/x-axis-weights.functions.ts @@ -60,14 +60,14 @@ export const getWeightFromTimeFormat = (format: ParsedTimeFormat) => { export const groupLabelsByWeight = (weightedLabels: XAxisLabelWeighted[]): Record => { const labelsGroupedByWeight: Record = {}; - weightedLabels.forEach(weightedLabel => { + for (const weightedLabel of weightedLabels) { const labelsByWeight = labelsGroupedByWeight[weightedLabel.weight]; if (labelsByWeight === undefined) { labelsGroupedByWeight[weightedLabel.weight] = [weightedLabel]; } else { labelsByWeight.push(weightedLabel); } - }); + } return labelsGroupedByWeight; }; diff --git a/src/chart/components/x_axis/time/x-axis-weights.generator.ts b/src/chart/components/x_axis/time/x-axis-weights.generator.ts index 3cb7c8f6..f2278665 100644 --- a/src/chart/components/x_axis/time/x-axis-weights.generator.ts +++ b/src/chart/components/x_axis/time/x-axis-weights.generator.ts @@ -30,12 +30,9 @@ const getWeightByDate = ( currentDate: Date, previousDate: Date, sortedWeights: [number, TimeFormatMatcher][], - tzOffset: (time: number) => Date, ): number => { - const offsetCurrentDate = tzOffset(currentDate.getTime()); - const offsetPrevDate = tzOffset(previousDate.getTime()); for (const [weight, timeMatcher] of sortedWeights) { - if (timeMatcher(offsetCurrentDate, offsetPrevDate)) { + if (timeMatcher(currentDate, previousDate)) { return weight; } } @@ -52,16 +49,15 @@ export function mapCandlesToWeightedPoints( tzOffset: (time: number) => Date, ): WeightedPoint[] { const result: WeightedPoint[] = new Array(visualCandles.length); - // assume that candles` timestamp before the first visual candle is unreachable - let prevDate = new Date(0); + let prevDate = tzOffset(0); for (let i = 0; i < visualCandles.length; i++) { const currentCandle = visualCandles[i]; - const currentDate = new Date(currentCandle.candle.timestamp); - + // calculate timstamp to Date in acordance with provided timezone and time + const currentDate = tzOffset(currentCandle.candle.timestamp); const currentWeightedPoint: WeightedPoint = { - weight: getWeightByDate(currentDate, prevDate, sortedWeights, tzOffset), + weight: getWeightByDate(currentDate, prevDate, sortedWeights), }; result[i] = currentWeightedPoint; prevDate = currentDate; diff --git a/src/chart/components/x_axis/x-axis-labels.generator.ts b/src/chart/components/x_axis/x-axis-labels.generator.ts index ebdb30e7..14defb01 100644 --- a/src/chart/components/x_axis/x-axis-labels.generator.ts +++ b/src/chart/components/x_axis/x-axis-labels.generator.ts @@ -3,6 +3,7 @@ * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. * If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { CanvasBoundsContainer, CanvasElement } from '../../canvas/canvas-bounds-container'; import { FullChartConfig } from '../../chart.config'; import EventBus from '../../events/event-bus'; import { CanvasModel } from '../../model/canvas.model'; @@ -68,9 +69,10 @@ export class XAxisTimeLabelsGenerator implements XAxisLabelsGenerator { private labelsGroupedByWeight: Record = {}; private weightedCache?: { labels: XAxisLabelWeighted[]; coverUpLevel: number }; private levelsCache: Record = {}; + private prevAnimationId = ''; get labels(): XAxisLabelWeighted[] { - return this.getLabelsFromChartType(); + return this.filterLabelsInViewport(this.getLabelsFromChartType()); } private formatsByWeightMap: Record; @@ -89,6 +91,7 @@ export class XAxisTimeLabelsGenerator implements XAxisLabelsGenerator { private scale: ScaleModel, private timeZoneModel: TimeZoneModel, private canvasModel: CanvasModel, + private canvasBoundsContainer: CanvasBoundsContainer, ) { this.formatsByWeightMap = config.components.xAxis.formatsForLabelsConfig; const { weightToTimeFormatsDict, weightToTimeFormatMatcherDict } = generateWeightsMapForConfig( @@ -100,6 +103,21 @@ export class XAxisTimeLabelsGenerator implements XAxisLabelsGenerator { this.weightToTimeFormatsDict = weightToTimeFormatsDict; } + private filterLabelsInViewport(labels: XAxisLabelWeighted[]) { + const bounds = this.canvasBoundsContainer.getBounds(CanvasElement.X_AXIS); + const filteredLabels = []; + + for (const label of labels) { + const x = this.scale.toX(label.value); + // skip labels outside viewport + if (x < 0 || x > bounds.width) { + continue; + } + filteredLabels.push(label); + } + return filteredLabels; + } + private getLabelsFromChartType() { const labels = this.weightedCache?.labels ?? []; //@ts-ignore @@ -143,7 +161,8 @@ export class XAxisTimeLabelsGenerator implements XAxisLabelsGenerator { allCandlesWithFake: VisualCandle[], ): XAxisLabelWeighted[] { const arr = new Array(weightedPoints.length); - weightedPoints.forEach((point, index) => { + for (let index = 0; index < weightedPoints.length; ++index) { + const point = weightedPoints[index]; const visualCandle = allCandlesWithFake[index]; const labelFormat = this.weightToTimeFormatsDict[point.weight]; const formattedLabel = this.timeZoneModel.getDateTimeFormatter(labelFormat)(visualCandle.candle.timestamp); @@ -155,7 +174,7 @@ export class XAxisTimeLabelsGenerator implements XAxisLabelsGenerator { time: visualCandle.candle.timestamp, text: formattedLabel, }; - }); + } return arr; } @@ -334,35 +353,54 @@ export class XAxisTimeLabelsGenerator implements XAxisLabelsGenerator { } /** - * Recalculates cached labels based on the current configuration and zoom level. - * If there are no grouped labels, the cache is not set. - * Calculates the maximum label width based on the font size and the maximum format length. * Calculates the cover up level based on the maximum label width and the mean candle width. - * If the cover up level is negative, the cache is not updated. - * If the cover up level has not changed, the cached labels are returned. - * Otherwise, the labels are filtered by extended rules and grouped by cover up level. - * The filtered labels are then cached and returned. + * Cover up level is based on maximum label width, which is based on the font size and the maximum format length. + * Used to group labels. */ - private recalculateCachedLabels() { - // skip cache setting if we don't have grouped labels + private calculateCoverUpLevel = () => { + const animation = this.scale.currentAnimation; + const meanCandleWidthInUnits = this.chartModel.mainCandleSeries.meanCandleWidth; + let meanCandleWidthInPixels; if (Object.getOwnPropertyNames(this.labelsGroupedByWeight).length === 0) { - return; + return -1; } + // calculate coverUpLevel for target zoomX to prevent extra labels calculations on every animation tick + if (animation?.animationInProgress) { + if (animation.id !== this.prevAnimationId) { + this.prevAnimationId = animation.id; + } + meanCandleWidthInPixels = unitToPixels(meanCandleWidthInUnits, animation.animationConfig.targetZoomX); + } else { + meanCandleWidthInPixels = unitToPixels(meanCandleWidthInUnits, this.scale.zoomX); + } + if (!isFinite(meanCandleWidthInPixels)) { + return -1; + } const fontSize = this.config.components.xAxis.fontSize; - const maxFormatLength = Object.values(this.formatsByWeightMap).reduce((max, item) => { - return Math.max(item.length, max); - }, 1); + const maxFormatLength = Object.values(this.formatsByWeightMap).reduce( + (max, item) => Math.max(item.length, max), + 1, + ); const maxLabelWidth = fontSize * maxFormatLength; - const meanCandleWidthPx = unitToPixels(this.chartModel.mainCandleSeries.meanCandleWidth, this.scale.zoomX); - - const coverUpLevel = Math.round(maxLabelWidth / meanCandleWidthPx); + return Math.round(maxLabelWidth / meanCandleWidthInPixels); + }; + /** + * Recalculates cached labels based on the current configuration and zoom level. + * If there are no grouped labels, the cache is not set. + * Recalculating depends on cover up level. + * If the cover up level is negative, the cache is not updated. + * If the cover up level has not changed, the cached labels are returned. + * Otherwise, the labels are filtered by extended rules and grouped by cover up level. + * The filtered labels are then cached and returned. + */ + private recalculateCachedLabels() { + const coverUpLevel = this.calculateCoverUpLevel(); // for some reason sometimes `this.scale.zoomX` is negative so we dont want to update labels - if (coverUpLevel < 0 && !isFinite(meanCandleWidthPx)) { + if (coverUpLevel < 0) { return; } - if (this.weightedCache === undefined || coverUpLevel !== this.weightedCache.coverUpLevel) { const labelsFromCache = this.getLabelsFromCache(coverUpLevel); if (labelsFromCache) { diff --git a/src/chart/components/x_axis/x-axis-time-labels.drawer.ts b/src/chart/components/x_axis/x-axis-time-labels.drawer.ts index 5acefb91..ac2ffc6b 100644 --- a/src/chart/components/x_axis/x-axis-time-labels.drawer.ts +++ b/src/chart/components/x_axis/x-axis-time-labels.drawer.ts @@ -75,10 +75,6 @@ export class XAxisTimeLabelsDrawer implements Drawer { ctx.fillStyle = color; for (const label of labels) { const x = this.viewportModel.toX(label.value) - calculateTextWidth(label.text, ctx, font) / 2; - // skip labels outside viewport - if (x < 0 || x > bounds.width) { - continue; - } const y = bounds.y + fontHeight - 1 + offsetTop; // -1 for font drawing inconsistency const labelText = label.text; ctx.font = font; diff --git a/src/chart/components/x_axis/x-axis.component.ts b/src/chart/components/x_axis/x-axis.component.ts index 076dd1b7..777dbbfe 100644 --- a/src/chart/components/x_axis/x-axis.component.ts +++ b/src/chart/components/x_axis/x-axis.component.ts @@ -61,6 +61,7 @@ export class XAxisComponent extends ChartBaseElement { scale, timeZoneModel, this.canvasModel, + canvasBoundsContainer, ); this.xAxisLabelsGenerator = xAxisLabelsGenerator; @@ -121,18 +122,14 @@ export class XAxisComponent extends ChartBaseElement { this.chartComponent.chartModel.candlesPrependSubject .pipe( filter(({ prependedCandles }) => prependedCandles.length !== 0), - map(({ prependedCandles }) => { - return this.chartComponent.chartModel.mainCandleSeries.visualPoints.slice( - 0, - prependedCandles.length, - ); - }), + map(({ prependedCandles }) => + this.chartComponent.chartModel.mainCandleSeries.visualPoints.slice(0, prependedCandles.length), + ), ) .subscribe(newCandles => { //@ts-ignore if (availableBarTypes.includes(this.config.components.chart.type)) { - this.xAxisLabelsGenerator.updateHistoryLabels && - this.xAxisLabelsGenerator.updateHistoryLabels(newCandles); + this.xAxisLabelsGenerator.updateHistoryLabels?.(newCandles); } }), ); @@ -152,9 +149,7 @@ export class XAxisComponent extends ChartBaseElement { distinctUntilChanged((a, b) => a?.candle?.timestamp === b?.candle?.timestamp), filter(notEmpty), ) - .subscribe(x => { - this.xAxisLabelsGenerator.updateLastLabel && this.xAxisLabelsGenerator.updateLastLabel(x); - }), + .subscribe(x => this.xAxisLabelsGenerator?.updateLastLabel?.(x)), ); } diff --git a/src/chart/components/y_axis/y-axis-scale.handler.ts b/src/chart/components/y_axis/y-axis-scale.handler.ts index d4d5fc57..7188cd47 100644 --- a/src/chart/components/y_axis/y-axis-scale.handler.ts +++ b/src/chart/components/y_axis/y-axis-scale.handler.ts @@ -69,6 +69,8 @@ export class YAxisScaleHandler extends ChartBaseElement { } private onYDragStart = () => { + // halt previous scale animation if drag is started + this.scale.haltAnimation(); this.lastYStart = this.scale.yStart; this.lastYEnd = this.scale.yEnd; this.lastYHeight = this.scale.yEnd - this.scale.yStart; diff --git a/src/chart/model/data-series.model.ts b/src/chart/model/data-series.model.ts index 2c451fd3..c0eaf592 100644 --- a/src/chart/model/data-series.model.ts +++ b/src/chart/model/data-series.model.ts @@ -147,7 +147,7 @@ export class DataSeriesModel< protected doActivate(): void { this.addRxSubscription(this.scale.xChanged.subscribe(() => this.recalculateDataViewportIndexes())); - this.addRxSubscription(this.scale.scaleInversedSubject.subscribe(() => this.recalculateVisualPoints())); + this.addRxSubscription(this.scale.scaleInversedSubject.subscribe(() => { this.recalculateVisualPoints(); })); } /** diff --git a/src/chart/model/scale.model.ts b/src/chart/model/scale.model.ts index ad5d2fe9..0a40d4fc 100644 --- a/src/chart/model/scale.model.ts +++ b/src/chart/model/scale.model.ts @@ -83,6 +83,14 @@ export class ScaleModel extends ViewportModel { ); } + protected doActivate(): void { + this.addRxSubscription( + this.scaleInversedSubject.subscribe(() => { + this.fireChanged(); + }), + ); + } + /** * The method adds a new "constraint" to the existing list of x-axis constraints for charting. * The "constraint" is expected to be an object containing information about the constraints, such as the minimum and maximum values for the x-axis. @@ -181,10 +189,10 @@ export class ScaleModel extends ViewportModel { * @param fireChanged * @param forceNoAutoScale - force NOT apply auto-scaling (for lazy loading) */ - public setXScale(xStart: Unit, xEnd: Unit) { + public setXScale(xStart: Unit, xEnd: Unit, forceNoAnimation: boolean = true) { const initialState = this.export(); - super.setXScale(xStart, xEnd, false); - const state = this.export(); + const zoomX = this.calculateZoomX(xStart, xEnd); + const state = { ...initialState, zoomX, xStart, xEnd }; const constrainedState = this.scalePostProcessor(initialState, state); if (this.state.lockPriceToBarRatio) { changeYToKeepRatio(constrainedState, this.zoomXYRatio); @@ -192,8 +200,12 @@ export class ScaleModel extends ViewportModel { if (this.state.auto) { this.autoScaleModel.doAutoYScale(constrainedState); } - - this.apply(constrainedState); + if (forceNoAnimation || this.config.scale.disableAnimations) { + this.haltAnimation(); + this.apply(constrainedState); + } else { + startViewportModelAnimation(this.canvasAnimation, this, constrainedState); + } } public setYScale(yStart: Unit, yEnd: Unit, fire = false) { diff --git a/src/chart/model/time-zone.model.ts b/src/chart/model/time-zone.model.ts index 8c4d9147..cedf8a5c 100644 --- a/src/chart/model/time-zone.model.ts +++ b/src/chart/model/time-zone.model.ts @@ -3,12 +3,11 @@ * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. * If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { getTimezoneOffset as getTimezoneOffsetDateFnsTz } from 'date-fns-tz'; import { Observable, ReplaySubject, Subject } from 'rxjs'; import { DateFormatter, FullChartConfig } from '../chart.config'; -import { memoize } from '../utils/performance/memoize.utils'; import { DateTimeFormatter, DateTimeFormatterFactory, dateTimeFormatterFactory } from './date-time.formatter'; import { Timestamp } from './scaling/viewport.model'; +import { getTimezoneOffset } from '../utils/timezone.utils'; export interface TimeZone { readonly timeZone: string; @@ -16,18 +15,17 @@ export interface TimeZone { readonly utcOffset: string; } -export const memoizedTZOffset = memoize(getTimezoneOffsetDateFnsTz); - -export const getTimezoneOffset = (timezone: string, time: Timestamp) => { - // we must pass time here, because we can have daylight saving time, otherwise we will get wrong offset - const localOffset = new Date(time).getTimezoneOffset() * 60 * 1000; - return localOffset + memoizedTZOffset(timezone, time); -}; +// 1 hour in milliseconds +const timeMultiplier = 60 * 1000; export class TimeZoneModel { private timeZoneChangedSubject: Subject = new ReplaySubject(); private dateTimeFormatterFactory: DateTimeFormatterFactory; - public currentTzOffset = (time: Timestamp): number => this.getOffset(this.config.timezone, time); + + public currentTzOffset = (time: Timestamp): number => { + // we must pass time here, because we can have daylight saving time, otherwise we will get wrong offset + return getTimezoneOffset(this.config.timezone, time) + new Date(time).getTimezoneOffset() * timeMultiplier; + }; constructor(private config: FullChartConfig) { this.dateTimeFormatterFactory = this.initFormatterFactory(this.config.dateFormatter); @@ -100,35 +98,17 @@ export class TimeZoneModel { return this.formatterCache[format]; } - /** - * Calculates the offset of a given timezone from the local timezone. - * @private - * @param {string} timezone - The timezone to calculate the offset for. - */ - private getOffset = (timezone: string, time: Timestamp) => getTimezoneOffset(timezone, time); - - /** - * Gets the timezone offset value in milliseconds - * @param {string} timezone name - * @returns {function(time:Number):Date} - */ public tzOffset(timezone: string): (time: number) => Date { - // we must pass time here, because we can have daylight saving time, otherwise we will get wrong offset - const localOffset = (time: Timestamp) => -new Date(time).getTimezoneOffset() * timeMultiplier; - let offset: (time: Timestamp) => number; if (!timezone) { - offset = localOffset; + return time => new Date(time); } else { - // we must pass time here, because we can have daylight saving time, otherwise we will get wrong offset - offset = (time: Timestamp) => memoizedTZOffset(timezone, time); + // In JS Date object is created with local tz offset, + // so we have to subtract localOffset from current time + return time => { + return new Date( + time + getTimezoneOffset(timezone, time) + new Date(time).getTimezoneOffset() * timeMultiplier, + ); + }; } - // In JS Date object is created with local tz offset, - // so we have to subtract localOffset from current time - return time => { - return new Date(+time + offset(time) - localOffset(time)); - }; } -} - -// 1 hour in milliseconds -const timeMultiplier = 60 * 1000; +} \ No newline at end of file diff --git a/src/chart/utils/timezone.utils.ts b/src/chart/utils/timezone.utils.ts new file mode 100644 index 00000000..557b9e0e --- /dev/null +++ b/src/chart/utils/timezone.utils.ts @@ -0,0 +1,45 @@ +/** + * Get a cached Intl.DateTimeFormat instance for the IANA `timeZone`. This can be used + * to get deterministic local date/time output according to the `en-US` locale which + * can be used to extract local time parts as necessary. + * Returns the [year, month, day, hour, minute] tokens of the provided timezone + */ +const dateTimeFormatCache: Record = {}; +export function getDateTimeFormat(timeZone: string) { + if (!dateTimeFormatCache[timeZone]) { + // New browsers use `hourCycle`, IE and Chrome <73 does not support it and uses `hour12` + dateTimeFormatCache[timeZone] = new Intl.DateTimeFormat('en-US', { + hourCycle: 'h23', + timeZone, + year: 'numeric', + month: 'numeric', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + } + return dateTimeFormatCache[timeZone]; +} + +/** + * Calulates provided timezone offset. + * @param timeZone - timezone as IANA string ('Asia/Amman', 'Europe/Amsterdam') + * @param timestamp - timestamp as number (1701251319717) + * Returns offset from UTC + */ +export const getTimezoneOffset = (timeZone: string, timestamp: number = Date.now()) => { + const formatter = getDateTimeFormat(timeZone); + const dateString = formatter.format(timestamp); + // exec() returns [, fMonth, fDay, fYear, fHour, fMinute] + const tokens = /(\d+)\/(\d+)\/(\d+),? (\d+):(\d+)/.exec(dateString); + + if (tokens) { + const asUTC = Date.UTC(+tokens[3], +tokens[1] - 1, +tokens[2], +tokens[4] % 24, +tokens[5], 0); + let asTimezone = timestamp; + const over = asTimezone % 1000; + asTimezone -= over >= 0 ? over : 1000 + over; + return asUTC - asTimezone; + } + return 0; +}; diff --git a/yarn.lock b/yarn.lock index d5f79ffe..d67fc61e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -648,7 +648,6 @@ __metadata: color: ^4.2.3 commander: ^10.0.1 date-fns: ^2.30.0 - date-fns-tz: ^1.3.8 esbuild: ^0.17.19 esbuild-jest: ^0.5.0 esbuild-loader: ^3.0.1 @@ -3731,15 +3730,6 @@ __metadata: languageName: node linkType: hard -"date-fns-tz@npm:^1.3.8": - version: 1.3.8 - resolution: "date-fns-tz@npm:1.3.8" - peerDependencies: - date-fns: ">=2.0.0" - checksum: dbf17cb88de7c7b71a9f86b815ac62ebdbf725c030e78d2421309456b5001a85284a5b6763f4af0ba660183f12e876c165e725a0723f642edb7846407db03133 - languageName: node - linkType: hard - "date-fns@npm:^2.30.0": version: 2.30.0 resolution: "date-fns@npm:2.30.0"