Skip to content

Commit 5c82367

Browse files
fix(adoption-insights): normalize event timestamps to UTC and fix frontend date parsing (#2562)
1 parent 61d0d2e commit 5c82367

6 files changed

Lines changed: 96 additions & 5 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-adoption-insights-backend': patch
3+
'@red-hat-developer-hub/backstage-plugin-adoption-insights': patch
4+
---
5+
6+
normalize event timestamps to UTC and fix frontend date parsing

workspaces/adoption-insights/plugins/adoption-insights-backend/src/models/Event.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
AnalyticsEvent,
2020
AnalyticsEventAttributes,
2121
} from '@backstage/core-plugin-api';
22+
import { normalizeToUTCISO } from '../utils/date';
2223

2324
export type EventType = {
2425
user_ref: string;
@@ -49,8 +50,11 @@ export class Event {
4950
this.action = event.action;
5051
this.subject = event.subject;
5152
this.value = event.value;
52-
this.created_at =
53+
const rawTimestamp =
5354
(event.context?.timestamp as string) || new Date().toISOString();
55+
this.created_at = normalizeToUTCISO(
56+
typeof rawTimestamp === 'string' ? rawTimestamp : new Date(),
57+
);
5458

5559
// Handle type-based conversion
5660
this.context = isJson ? event.context : JSON.stringify(event.context ?? {});

workspaces/adoption-insights/plugins/adoption-insights-backend/src/utils/date.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
getDateGroupingType,
2121
getTimeZoneOffsetString,
2222
isSameMonth,
23+
normalizeToUTCISO,
2324
toEndOfDayUTC,
2425
toStartOfDayUTC,
2526
} from './date';
@@ -111,6 +112,42 @@ describe('convertToTargetTimezone', () => {
111112
});
112113
});
113114

115+
describe('normalizeToUTCISO', () => {
116+
it('should return ISO string for Date input', () => {
117+
const d = new Date('2026-03-11T16:17:46.540Z');
118+
expect(normalizeToUTCISO(d)).toBe('2026-03-11T16:17:46.540Z');
119+
});
120+
121+
it('should normalize ISO with Z to UTC ISO', () => {
122+
expect(normalizeToUTCISO('2026-03-11T16:17:46.540Z')).toBe(
123+
'2026-03-11T16:17:46.540Z',
124+
);
125+
});
126+
127+
it('should normalize ISO with offset to UTC ISO', () => {
128+
expect(normalizeToUTCISO('2026-03-11T11:17:46.540-05:00')).toBe(
129+
'2026-03-11T16:17:46.540Z',
130+
);
131+
});
132+
133+
it('should normalize "yyyy-MM-dd HH:mm:ss.SSS -0500" to UTC ISO', () => {
134+
expect(normalizeToUTCISO('2026-03-11 11:17:46.540 -0500')).toBe(
135+
'2026-03-11T16:17:46.540Z',
136+
);
137+
});
138+
139+
it('should normalize "yyyy-MM-dd HH:mm:ss -0500" to UTC ISO', () => {
140+
expect(normalizeToUTCISO('2026-03-11 11:17:46 -0500')).toBe(
141+
'2026-03-11T16:17:46.000Z',
142+
);
143+
});
144+
145+
it('should return original string when unparseable (no silent substitution with current time)', () => {
146+
const unparseable = 'not-a-date';
147+
expect(normalizeToUTCISO(unparseable)).toBe(unparseable);
148+
});
149+
});
150+
114151
describe('getTimeZoneOffsetString', () => {
115152
const fixedISODate = '2025-07-09T12:00:00.000Z';
116153

workspaces/adoption-insights/plugins/adoption-insights-backend/src/utils/date.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,38 @@ export const hasZFormat = (dateStr: string): boolean => {
5656
return dateStr.includes('Z') || dateStr.includes('T');
5757
};
5858

59+
/**
60+
* Normalize any date string or Date to UTC ISO string (e.g. 2026-03-11T16:17:46.540Z).
61+
* Use this before storing timestamps in the database so they are consistent.
62+
*/
63+
export const normalizeToUTCISO = (date: string | Date): string => {
64+
if (date instanceof Date) {
65+
return date.toISOString();
66+
}
67+
const s = String(date).trim();
68+
// Luxon fromISO handles ISO with Z or offset (e.g. -05:00)
69+
let dt = DateTime.fromISO(s, { setZone: true });
70+
if (dt.isValid) {
71+
return dt.toUTC().toISO() ?? s;
72+
}
73+
// Try "yyyy-MM-dd HH:mm:ss.SSS -0500" or "yyyy-MM-dd HH:mm:ss -0500" style
74+
dt = DateTime.fromFormat(s, 'yyyy-MM-dd HH:mm:ss.SSS ZZZ', { setZone: true });
75+
if (dt.isValid) {
76+
return dt.toUTC().toISO() ?? s;
77+
}
78+
dt = DateTime.fromFormat(s, 'yyyy-MM-dd HH:mm:ss ZZZ', { setZone: true });
79+
if (dt.isValid) {
80+
return dt.toUTC().toISO() ?? s;
81+
}
82+
dt = DateTime.fromFormat(s, 'yyyy-MM-dd HH:mm:ss', { zone: 'UTC' });
83+
if (dt.isValid) {
84+
return dt.toISO() ?? s;
85+
}
86+
// Return original when unparseable: do not substitute current time, which would
87+
// silently corrupt created_at and misplace events in partitions/date-range queries.
88+
return s;
89+
};
90+
5991
export const convertToTargetTimezone = (
6092
date: string | Date,
6193
timeZone: string = new Intl.DateTimeFormat().resolvedOptions().timeZone,

workspaces/adoption-insights/plugins/adoption-insights/src/utils/__tests__/utils.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@ describe('safeDate', () => {
5353
expect(result.toISOString()).toBe('2025-09-29T00:00:00.000Z');
5454
});
5555

56+
it('should normalize short offset -04 to -04:00 for parsing', () => {
57+
const result = safeDate('2026-03-09T01:00:00-04');
58+
expect(result).toBeInstanceOf(Date);
59+
expect(result.toISOString()).toBe('2026-03-09T05:00:00.000Z');
60+
});
61+
5662
it('should handle invalid dates gracefully', () => {
5763
const result = safeDate('invalid-date');
5864
expect(result).toBeInstanceOf(Date);

workspaces/adoption-insights/plugins/adoption-insights/src/utils/utils.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,18 @@ import { APIsViewOptions } from '../types';
3030
import { adoptionInsightsTranslationRef } from '../translations';
3131

3232
/**
33-
* Parse date string and normalize timezone format.
34-
* Converts +00 timezone format to Z for better browser compatibility.
33+
* Parse date string and normalize timezone format for browser compatibility.
34+
* - Converts +00 at end to Z.
35+
* - Expands short offset (e.g. -04 or +05) to -04:00 or +05:00 so Date parses reliably.
3536
*/
3637
export const safeDate = (dateString: string): Date => {
37-
const normalizedDate = dateString.replace(/\+00$/, 'Z');
38-
return new Date(normalizedDate);
38+
let normalized = dateString.replace(/\+00$/, 'Z');
39+
// Short offset like -04 or +05 is not reliably parsed by Date; expand to -04:00 or +05:00.
40+
// Only apply when string has a time part (T) so we don't change date-only strings like 2025-03-01.
41+
if (normalized.includes('T')) {
42+
normalized = normalized.replace(/([-+])(\d{2})$/g, '$1$2:00');
43+
}
44+
return new Date(normalized);
3945
};
4046

4147
// =============================================================================

0 commit comments

Comments
 (0)