feat(dates): implement 15-minute interval rounding

Implement time interval rounding to support standardized data queries
by rounding dates to the next 15-minute interval. This change:
- Adds roundToNextInterval method to Search class
- Updates Period.js to round dates to 15-minute intervals
- Updates date range calculations to preserve proper time spans
- Upgrades Sentry browser dependency from v5 to v8.55.0
This commit is contained in:
Shekar Siri 2025-04-10 12:54:26 +02:00
parent 83897cb89c
commit c499114c78
3 changed files with 227 additions and 142 deletions

View file

@ -143,7 +143,7 @@ export default class Search {
return new FilterItem(filter).toJson();
});
const { startDate, endDate } = this.getDateRange(js.rangeValue, js.startDate, js.endDate);
const { startDate, endDate } = this.getDateRange(js.rangeValue, js.startDate, js.endDate, 15);
js.startDate = startDate;
js.endDate = endDate;
@ -152,7 +152,38 @@ export default class Search {
return js;
}
private getDateRange(rangeName: string, customStartDate: number, customEndDate: number): {
private roundToNextInterval(timestamp: number, intervalMinutes: number): number {
if (intervalMinutes <= 0) {
return timestamp; // No rounding if interval is invalid
}
const date = new Date(timestamp);
const minutes = date.getMinutes();
const seconds = date.getSeconds();
const milliseconds = date.getMilliseconds();
// Calculate minutes to add to reach next interval slot
const minutesToAdd = (intervalMinutes - (minutes % intervalMinutes)) % intervalMinutes;
// If exactly at interval mark but has seconds/milliseconds, round up to next slot
const shouldAddExtra = (minutesToAdd === 0 && (seconds > 0 || milliseconds > 0));
const adjustedMinutesToAdd = shouldAddExtra ? intervalMinutes : minutesToAdd;
// Create a new date with added minutes and zeroed seconds/milliseconds
const roundedDate = new Date(timestamp);
roundedDate.setMinutes(minutes + adjustedMinutesToAdd);
roundedDate.setSeconds(0);
roundedDate.setMilliseconds(0);
return roundedDate.getTime();
}
private getDateRange(
rangeName: string,
customStartDate: number,
customEndDate: number,
roundingOption: number | 'none' = 'none'
): {
startDate: number;
endDate: number
} {
@ -178,6 +209,29 @@ export default class Search {
startDate = endDate - 24 * 60 * 60 * 1000;
}
// Apply rounding if specified and it's a number
if (roundingOption !== 'none' && typeof roundingOption === 'number') {
const intervalMinutes = roundingOption;
// For CUSTOM_RANGE, do not apply rounding
if (rangeName !== CUSTOM_RANGE) {
endDate = this.roundToNextInterval(endDate, intervalMinutes);
// Recalculate the start date based on the rounded end date to maintain the correct time span
switch (rangeName) {
case LAST_7_DAYS:
startDate = endDate - 7 * 24 * 60 * 60 * 1000;
break;
case LAST_30_DAYS:
startDate = endDate - 30 * 24 * 60 * 60 * 1000;
break;
case LAST_24_HOURS:
default:
startDate = endDate - 24 * 60 * 60 * 1000;
}
}
}
return {
startDate,
endDate

View file

@ -1,109 +1,146 @@
import { DateTime, Interval, Settings } from "luxon";
import Record from "Types/Record";
import { DateTime, Interval, Settings } from 'luxon';
import Record from 'Types/Record';
export const LAST_30_MINUTES = "LAST_30_MINUTES";
export const TODAY = "TODAY";
export const LAST_24_HOURS = "LAST_24_HOURS";
export const YESTERDAY = "YESTERDAY";
export const LAST_7_DAYS = "LAST_7_DAYS";
export const LAST_30_DAYS = "LAST_30_DAYS";
export const THIS_MONTH = "THIS_MONTH";
export const LAST_MONTH = "LAST_MONTH";
export const THIS_YEAR = "THIS_YEAR";
export const CUSTOM_RANGE = "CUSTOM_RANGE";
export const LAST_30_MINUTES = 'LAST_30_MINUTES';
export const TODAY = 'TODAY';
export const LAST_24_HOURS = 'LAST_24_HOURS';
export const YESTERDAY = 'YESTERDAY';
export const LAST_7_DAYS = 'LAST_7_DAYS';
export const LAST_30_DAYS = 'LAST_30_DAYS';
export const THIS_MONTH = 'THIS_MONTH';
export const LAST_MONTH = 'LAST_MONTH';
export const THIS_YEAR = 'THIS_YEAR';
export const CUSTOM_RANGE = 'CUSTOM_RANGE';
function roundToNext15Minutes(dateTime) {
const minutes = dateTime.minute;
const secondsAndMillis = dateTime.second + (dateTime.millisecond / 1000);
// Calculate minutes to add to reach next 15-minute slot
const minutesToAdd = (15 - (minutes % 15)) % 15;
// If exactly at 15-minute mark but has seconds/milliseconds, round up to next slot
const adjustedMinutesToAdd = (minutesToAdd === 0 && secondsAndMillis > 0) ? 15 : minutesToAdd;
return dateTime
.plus({ minutes: adjustedMinutesToAdd })
.set({ second: 0, millisecond: 0 });
}
// Helper function to get rounded now
function getRoundedNow(offset) {
const now = DateTime.now().setZone(offset);
return roundToNext15Minutes(now);
}
function getRange(rangeName, offset) {
const now = DateTime.now().setZone(offset);
switch (rangeName) {
case TODAY:
return Interval.fromDateTimes(now.startOf("day"), now.endOf("day"));
case YESTERDAY:
const yesterday = now.minus({ days: 1 });
return Interval.fromDateTimes(
yesterday.startOf("day"),
yesterday.endOf("day")
);
case LAST_24_HOURS:
return Interval.fromDateTimes(now.minus({ hours: 24 }), now);
case LAST_30_MINUTES:
return Interval.fromDateTimes(
now.minus({ minutes: 30 }).startOf("minute"),
now.startOf("minute")
);
case LAST_7_DAYS:
return Interval.fromDateTimes(
now.minus({ days: 7 }).startOf("day"),
now.endOf("day")
);
case LAST_30_DAYS:
return Interval.fromDateTimes(
now.minus({ days: 30 }).startOf("day"),
now.endOf("day")
);
case THIS_MONTH:
return Interval.fromDateTimes(now.startOf("month"), now.endOf("month"));
case LAST_MONTH:
const lastMonth = now.minus({ months: 1 });
return Interval.fromDateTimes(lastMonth.startOf("month"), lastMonth.endOf("month"));
case THIS_YEAR:
return Interval.fromDateTimes(now.startOf("year"), now.endOf("year"));
default:
return Interval.fromDateTimes(now, now);
}
const now = getRoundedNow(offset);
switch (rangeName) {
case TODAY:
return Interval.fromDateTimes(now.startOf('day'), now.endOf('day'));
case YESTERDAY:
const yesterday = now.minus({ days: 1 });
return Interval.fromDateTimes(
yesterday.startOf('day'),
yesterday.endOf('day')
);
case LAST_24_HOURS:
return Interval.fromDateTimes(now.minus({ hours: 24 }), now);
case LAST_30_MINUTES:
return Interval.fromDateTimes(
now.minus({ minutes: 30 }),
now
);
case LAST_7_DAYS:
return Interval.fromDateTimes(
now.minus({ days: 7 }).startOf('day'),
now.endOf('day')
);
case LAST_30_DAYS:
return Interval.fromDateTimes(
now.minus({ days: 30 }).startOf('day'),
now.endOf('day')
);
case THIS_MONTH:
return Interval.fromDateTimes(now.startOf('month'), now.endOf('month'));
case LAST_MONTH:
const lastMonth = now.minus({ months: 1 });
return Interval.fromDateTimes(lastMonth.startOf('month'), lastMonth.endOf('month'));
case THIS_YEAR:
return Interval.fromDateTimes(now.startOf('year'), now.endOf('year'));
default:
return Interval.fromDateTimes(now, now);
}
}
// Get the current rounded time for default Record value
const defaultNow = getRoundedNow();
export default Record(
{
start: 0,
end: 0,
rangeName: CUSTOM_RANGE,
range: Interval.fromDateTimes(DateTime.now(), DateTime.now()),
start: 0,
end: 0,
rangeName: CUSTOM_RANGE,
range: Interval.fromDateTimes(defaultNow, defaultNow)
},
{
fromJS: (period) => {
const offset = period.timezoneOffset || DateTime.now().offset;
if (!period.rangeName || period.rangeName === CUSTOM_RANGE) {
const isLuxon = DateTime.isDateTime(period.start);
const start = isLuxon
? period.start : DateTime.fromMillis(period.start || 0, { zone: Settings.defaultZone });
const end = isLuxon
? period.end : DateTime.fromMillis(period.end || 0, { zone: Settings.defaultZone });
const range = Interval.fromDateTimes(start, end);
return {
...period,
range,
start: range.start.toMillis(),
end: range.end.toMillis(),
};
}
const range = getRange(period.rangeName, offset);
return {
...period,
range,
start: range.start.toMillis(),
end: range.end.toMillis(),
};
fromJS: (period) => {
const offset = period.timezoneOffset || DateTime.now().offset;
if (!period.rangeName || period.rangeName === CUSTOM_RANGE) {
const isLuxon = DateTime.isDateTime(period.start);
let start, end;
if (isLuxon) {
start = roundToNext15Minutes(period.start);
end = roundToNext15Minutes(period.end);
} else {
start = roundToNext15Minutes(
DateTime.fromMillis(period.start || 0, { zone: Settings.defaultZone })
);
end = roundToNext15Minutes(
DateTime.fromMillis(period.end || 0, { zone: Settings.defaultZone })
);
}
const range = Interval.fromDateTimes(start, end);
return {
...period,
range,
start: range.start.toMillis(),
end: range.end.toMillis()
};
}
const range = getRange(period.rangeName, offset);
return {
...period,
range,
start: range.start.toMillis(),
end: range.end.toMillis()
};
},
methods: {
toJSON() {
return {
startDate: this.start,
endDate: this.end,
rangeName: this.rangeName,
rangeValue: this.rangeName
};
},
methods: {
toJSON() {
return {
startDate: this.start,
endDate: this.end,
rangeName: this.rangeName,
rangeValue: this.rangeName,
};
},
toTimestamps() {
return {
startTimestamp: this.start,
endTimestamp: this.end,
};
},
rangeFormatted(format = "MMM dd yyyy, HH:mm", tz) {
const start = this.range.start.setZone(tz);
const end = this.range.end.setZone(tz);
return `${start.toFormat(format)} - ${end.toFormat(format)}`;
},
toTimestamps() {
return {
startTimestamp: this.start,
endTimestamp: this.end
};
},
rangeFormatted(format = 'MMM dd yyyy, HH:mm', tz) {
const start = this.range.start.setZone(tz);
const end = this.range.end.setZone(tz);
return `${start.toFormat(format)} - ${end.toFormat(format)}`;
}
}
}
);

View file

@ -2952,67 +2952,61 @@ __metadata:
languageName: node
linkType: hard
"@sentry/browser@npm:^5.21.1":
version: 5.30.0
resolution: "@sentry/browser@npm:5.30.0"
"@sentry-internal/browser-utils@npm:8.55.0":
version: 8.55.0
resolution: "@sentry-internal/browser-utils@npm:8.55.0"
dependencies:
"@sentry/core": "npm:5.30.0"
"@sentry/types": "npm:5.30.0"
"@sentry/utils": "npm:5.30.0"
tslib: "npm:^1.9.3"
checksum: 10c1/4787cc3ea90600b36b548a8403afb30f13e1e562dd426871879d824536c16005d0734b7406498f1a6dd4fa7e0a49808e17a1c2c24750430ba7f86f909a9eb95a
"@sentry/core": "npm:8.55.0"
checksum: 10c1/67fdc5ec9c8bc6c8eeda4598332a7937a8c7d6cc1cadb05a886323f3d13c25def7b9258ad4b834919dea5d612010de8900f5cf738e9a577a711c839f285557d7
languageName: node
linkType: hard
"@sentry/core@npm:5.30.0":
version: 5.30.0
resolution: "@sentry/core@npm:5.30.0"
"@sentry-internal/feedback@npm:8.55.0":
version: 8.55.0
resolution: "@sentry-internal/feedback@npm:8.55.0"
dependencies:
"@sentry/hub": "npm:5.30.0"
"@sentry/minimal": "npm:5.30.0"
"@sentry/types": "npm:5.30.0"
"@sentry/utils": "npm:5.30.0"
tslib: "npm:^1.9.3"
checksum: 10c1/5c6dcdccc48a9d6957af7745226eacd3d4926574593e852ccbad0fbaa71355879b9c4707c194e3d9b1ef389d98171a3d85d2c75636a5c6d1cc3c9950cd06334a
"@sentry/core": "npm:8.55.0"
checksum: 10c1/3f6fd3b8c2305b457a5c729c92b2a2335200e5ee0d5a210b513246e00ecda6d2a28940871ed88eee7f7bd8465571388698a7b789c8e0f3d5832ff3a0b040b514
languageName: node
linkType: hard
"@sentry/hub@npm:5.30.0":
version: 5.30.0
resolution: "@sentry/hub@npm:5.30.0"
"@sentry-internal/replay-canvas@npm:8.55.0":
version: 8.55.0
resolution: "@sentry-internal/replay-canvas@npm:8.55.0"
dependencies:
"@sentry/types": "npm:5.30.0"
"@sentry/utils": "npm:5.30.0"
tslib: "npm:^1.9.3"
checksum: 10c1/28b86742c72427b5831ee3077c377d1f305d2eb080f7dc977e81b8f29e8eb0dfa07f129c1f5cda29bda9238fe50e292ab719119c4c5a5b7ef580a24bcb6356a3
"@sentry-internal/replay": "npm:8.55.0"
"@sentry/core": "npm:8.55.0"
checksum: 10c1/48511881330193d754e01b842e3b2b931641d0954bac8a8f01503ff3d2aedc9f1779049be0a7a56ba35583769f0566381853c7656888c42f9f59224c6520e593
languageName: node
linkType: hard
"@sentry/minimal@npm:5.30.0":
version: 5.30.0
resolution: "@sentry/minimal@npm:5.30.0"
"@sentry-internal/replay@npm:8.55.0":
version: 8.55.0
resolution: "@sentry-internal/replay@npm:8.55.0"
dependencies:
"@sentry/hub": "npm:5.30.0"
"@sentry/types": "npm:5.30.0"
tslib: "npm:^1.9.3"
checksum: 10c1/d28ad14e43d3c5c06783288ace1fcf1474437070f04d1476b04d0288656351d9a6285cc66d346e8d84a3e73cf895944c06fa7c82bad93415831e4449e11f2d89
"@sentry-internal/browser-utils": "npm:8.55.0"
"@sentry/core": "npm:8.55.0"
checksum: 10c1/d60b4261df037b4c82dafc6b25695b2be32f95a45cd25fc43c659d65644325238f7152f6222cd5d4f3f52407c3f5ad67ea30b38fea27c9422536f8aaba6b0048
languageName: node
linkType: hard
"@sentry/types@npm:5.30.0":
version: 5.30.0
resolution: "@sentry/types@npm:5.30.0"
checksum: 10c1/07fe7f04f6aae13f037761fe56a20e06fa4a768bf024fb81970d3087ab9ab5b45bd85b9081945ef5019d93b7de742918374a0e7b70a992dbb29a5078982ddfd9
"@sentry/browser@npm:^8.34.0":
version: 8.55.0
resolution: "@sentry/browser@npm:8.55.0"
dependencies:
"@sentry-internal/browser-utils": "npm:8.55.0"
"@sentry-internal/feedback": "npm:8.55.0"
"@sentry-internal/replay": "npm:8.55.0"
"@sentry-internal/replay-canvas": "npm:8.55.0"
"@sentry/core": "npm:8.55.0"
checksum: 10c1/3baf51a0b401bb63b345df480773d49b713dd557e15baf2c98f089612c9497aca6f2c7849b1c4d6ded6229d1de495e3305a0438145333de26c6ba190d261c039
languageName: node
linkType: hard
"@sentry/utils@npm:5.30.0":
version: 5.30.0
resolution: "@sentry/utils@npm:5.30.0"
dependencies:
"@sentry/types": "npm:5.30.0"
tslib: "npm:^1.9.3"
checksum: 10c1/311ad0be0e40af9f4ab7be2dfb8a782a779fa56700a0662f49ebcbff0dbbe4ea5dff690ad2c0ed4ecb6a6721a3066186b3c8f677fa302c5b606f86dfaa654de3
"@sentry/core@npm:8.55.0":
version: 8.55.0
resolution: "@sentry/core@npm:8.55.0"
checksum: 10c1/fbb71058626214674c4b103160fea859ce1fcc83b26533920b2c4fc7d5169bde178b08cd46dad29fabaf616fa465db4274356c500a37f33888bdb8d10fda3d55
languageName: node
linkType: hard
@ -11574,7 +11568,7 @@ __metadata:
"@jest/globals": "npm:^29.7.0"
"@medv/finder": "npm:^3.1.0"
"@openreplay/sourcemap-uploader": "npm:^3.0.10"
"@sentry/browser": "npm:^5.21.1"
"@sentry/browser": "npm:^8.34.0"
"@svg-maps/world": "npm:^1.0.1"
"@tanstack/react-query": "npm:^5.56.2"
"@trivago/prettier-plugin-sort-imports": "npm:^4.3.0"
@ -15762,7 +15756,7 @@ __metadata:
languageName: node
linkType: hard
"tslib@npm:^1.8.1, tslib@npm:^1.9.3":
"tslib@npm:^1.8.1":
version: 1.14.1
resolution: "tslib@npm:1.14.1"
checksum: 10c1/24ee51ea8fb127ca8ad30a25fdac22c5bb11a2b043781757ddde0daf2e03126e1e13e88ab1748d9c50f786a648b5b038e70782063fd15c3ad07ebec039df8f6f