Skip to content

Commit 6b0b083

Browse files
fix: [VMP-2536] - Maintenance date from local to utc (#13059)
* fix: maintenance local to utc * More tests * Adjust the when field logic * Added changeset: Fix incorrect maintenance time display in the Upcoming maintenance table --------- Co-authored-by: Jaalah Ramos <[email protected]>
1 parent 095c904 commit 6b0b083

File tree

4 files changed

+128
-87
lines changed

4 files changed

+128
-87
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/manager": Fixed
3+
---
4+
5+
Fix incorrect maintenance time display in the Upcoming maintenance table ([#13059](https://github.com/linode/manager/pull/13059))

packages/manager/src/features/Account/Maintenance/MaintenanceTableRow.tsx

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
import {
2-
useAccountMaintenancePoliciesQuery,
3-
useProfile,
4-
} from '@linode/queries';
1+
import { useProfile } from '@linode/queries';
52
import { Stack, Tooltip } from '@linode/ui';
63
import { Hidden } from '@linode/ui';
74
import { capitalize, getFormattedStatus, truncate } from '@linode/utilities';
@@ -84,22 +81,18 @@ export const MaintenanceTableRow = (props: MaintenanceTableRowProps) => {
8481
const dateField = getMaintenanceDateField(tableType);
8582
const dateValue = props.maintenance[dateField];
8683

87-
// Fetch policies to derive a start time when the API doesn't provide one
88-
const { data: policies } = useAccountMaintenancePoliciesQuery();
89-
9084
// Precompute for potential use; currently used via getUpcomingRelativeLabel
9185
React.useMemo(
92-
() => deriveMaintenanceStartISO(props.maintenance, policies),
93-
[policies, props.maintenance]
86+
() => deriveMaintenanceStartISO(props.maintenance),
87+
[props.maintenance]
9488
);
9589

96-
const upcomingRelativeLabel = React.useMemo(
97-
() =>
98-
tableType === 'upcoming'
99-
? getUpcomingRelativeLabel(props.maintenance, policies)
100-
: undefined,
101-
[policies, props.maintenance, tableType]
102-
);
90+
const upcomingRelativeLabel = React.useMemo(() => {
91+
if (tableType !== 'upcoming') {
92+
return undefined;
93+
}
94+
return getUpcomingRelativeLabel(props.maintenance);
95+
}, [props.maintenance, tableType]);
10396

10497
return (
10598
<TableRow key={entity.id}>

packages/manager/src/features/Account/Maintenance/utilities.test.ts

Lines changed: 70 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,30 +5,12 @@ import {
55
getUpcomingRelativeLabel,
66
} from './utilities';
77

8-
import type { AccountMaintenance, MaintenancePolicy } from '@linode/api-v4';
8+
import type { AccountMaintenance } from '@linode/api-v4';
99

1010
// Freeze time to a stable reference so relative labels are deterministic
1111
const NOW_ISO = '2025-10-27T12:00:00.000Z';
1212

1313
describe('Account Maintenance utilities', () => {
14-
const policies: MaintenancePolicy[] = [
15-
{
16-
description: 'Migrate',
17-
is_default: true,
18-
label: 'Migrate',
19-
notification_period_sec: 3 * 60 * 60, // 3 hours
20-
slug: 'linode/migrate',
21-
type: 'linode_migrate',
22-
},
23-
{
24-
description: 'Power Off / On',
25-
is_default: false,
26-
label: 'Power Off / Power On',
27-
notification_period_sec: 72 * 60 * 60, // 72 hours
28-
slug: 'linode/power_off_on',
29-
type: 'linode_power_off_on',
30-
},
31-
];
3214

3315
const baseMaintenance: Omit<AccountMaintenance, 'when'> & { when: string } = {
3416
complete_time: null,
@@ -60,33 +42,33 @@ describe('Account Maintenance utilities', () => {
6042
...baseMaintenance,
6143
start_time: '2025-10-27T12:00:00.000Z',
6244
};
63-
expect(deriveMaintenanceStartISO(m, policies)).toBe(
45+
expect(deriveMaintenanceStartISO(m)).toBe(
6446
'2025-10-27T12:00:00.000Z'
6547
);
6648
});
6749

68-
it('derives start_time from when + policy seconds when missing', () => {
50+
it('uses when directly as start time (when already accounts for notification period)', () => {
6951
const m: AccountMaintenance = {
7052
...baseMaintenance,
7153
start_time: null,
72-
when: '2025-10-27T09:00:00.000Z', // +3h -> 12:00Z
54+
when: '2025-10-27T09:00:00.000Z',
7355
};
74-
expect(deriveMaintenanceStartISO(m, policies)).toBe(
75-
'2025-10-27T12:00:00.000Z'
56+
// `when` already accounts for notification_period_sec, so it IS the start time
57+
expect(deriveMaintenanceStartISO(m)).toBe(
58+
'2025-10-27T09:00:00.000Z'
7659
);
7760
});
7861

79-
it('returns undefined when policy cannot be found', () => {
62+
it('uses when directly for all statuses without needing policies', () => {
8063
const m: AccountMaintenance = {
8164
...baseMaintenance,
8265
start_time: null,
83-
// Use an intentionally unknown slug to exercise the no-policy fallback path.
84-
// Even though the API default is typically 'linode/migrate', the client may
85-
// not have policies loaded yet or could encounter a fetch error; this ensures
86-
// we verify the graceful fallback behavior.
66+
status: 'pending',
67+
// Policies not needed - when IS the start time
8768
maintenance_policy_set: 'unknown/policy' as any,
69+
when: '2025-10-27T09:00:00.000Z',
8870
};
89-
expect(deriveMaintenanceStartISO(m, policies)).toBeUndefined();
71+
expect(deriveMaintenanceStartISO(m)).toBe('2025-10-27T09:00:00.000Z');
9072
});
9173
});
9274

@@ -100,7 +82,7 @@ describe('Account Maintenance utilities', () => {
10082
when: '2025-10-27T10:00:00.000Z',
10183
};
10284
// NOW=12:00Z, when=10:00Z => "2 hours ago"
103-
expect(getUpcomingRelativeLabel(m, policies)).toContain('hour');
85+
expect(getUpcomingRelativeLabel(m)).toContain('hour');
10486
});
10587

10688
it('uses derived start to express time until maintenance (hours when <1 day)', () => {
@@ -110,16 +92,15 @@ describe('Account Maintenance utilities', () => {
11092
when: '2025-10-27T09:00:00.000Z',
11193
};
11294
// Allow any non-empty string; exact phrasing depends on Luxon locale
113-
expect(getUpcomingRelativeLabel(m, policies)).toBeTypeOf('string');
95+
expect(getUpcomingRelativeLabel(m)).toBeTypeOf('string');
11496
});
11597

11698
it('shows days+hours when >= 1 day away (avoids day-only rounding)', () => {
11799
const m: AccountMaintenance = {
118100
...baseMaintenance,
119-
maintenance_policy_set: 'linode/power_off_on', // 72h
120-
when: '2025-10-25T20:00:00.000Z', // +72h => 2025-10-28T20:00Z; from NOW (27 12:00Z) => 1 day 8 hours
101+
when: '2025-10-28T20:00:00.000Z', // from NOW (27 12:00Z) => 1 day 8 hours
121102
};
122-
const label = getUpcomingRelativeLabel(m, policies);
103+
const label = getUpcomingRelativeLabel(m);
123104
expect(label).toBe('in 1 day 8 hours');
124105
});
125106

@@ -129,7 +110,7 @@ describe('Account Maintenance utilities', () => {
129110
...baseMaintenance,
130111
start_time: '2025-10-30T04:00:00.000Z',
131112
};
132-
const label = getUpcomingRelativeLabel(m, policies);
113+
const label = getUpcomingRelativeLabel(m);
133114
expect(label).toBe('in 2 days 16 hours');
134115
});
135116

@@ -139,7 +120,7 @@ describe('Account Maintenance utilities', () => {
139120
// NOW is 12:00Z; start in 37 minutes
140121
start_time: '2025-10-27T12:37:00.000Z',
141122
};
142-
const label = getUpcomingRelativeLabel(m, policies);
123+
const label = getUpcomingRelativeLabel(m);
143124
expect(label).toBe('in 37 minutes');
144125
});
145126

@@ -149,8 +130,59 @@ describe('Account Maintenance utilities', () => {
149130
// NOW is 12:00Z; start in 30 seconds
150131
start_time: '2025-10-27T12:00:30.000Z',
151132
};
152-
const label = getUpcomingRelativeLabel(m, policies);
133+
const label = getUpcomingRelativeLabel(m);
153134
expect(label).toBe('in 30 seconds');
154135
});
136+
137+
it('uses when directly as start time (when already accounts for notification period)', () => {
138+
// Real-world scenario: API returns when=2025-11-06T16:12:41
139+
// `when` already accounts for notification_period_sec, so it IS the start time
140+
const m: AccountMaintenance = {
141+
...baseMaintenance,
142+
start_time: null,
143+
when: '2025-11-06T16:12:41', // No timezone indicator, should be parsed as UTC
144+
};
145+
146+
const derivedStart = deriveMaintenanceStartISO(m);
147+
// `when` equals start time (no addition needed)
148+
expect(derivedStart).toBe('2025-11-06T16:12:41.000Z');
149+
});
150+
151+
it('shows correct relative time (when equals start)', () => {
152+
// Scenario: when=2025-11-06T16:12:41 (when IS the start time)
153+
// If now is 2025-11-06T16:14:41 (2 minutes after when), should show "2 minutes ago"
154+
// Save original Date.now
155+
const originalDateNow = Date.now;
156+
157+
// Mock "now" to be 2 minutes after when (which is the start time)
158+
const mockNow = '2025-11-06T16:14:41.000Z';
159+
Date.now = vi.fn(() => new Date(mockNow).getTime());
160+
161+
const m: AccountMaintenance = {
162+
...baseMaintenance,
163+
start_time: null,
164+
when: '2025-11-06T16:12:41',
165+
};
166+
167+
const label = getUpcomingRelativeLabel(m);
168+
// when=start=16:12:41, now=16:14:41, difference is 2 minutes in the past
169+
expect(label).toContain('minute'); // Should show "2 minutes ago" or similar
170+
171+
// Restore original Date.now
172+
Date.now = originalDateNow;
173+
});
174+
175+
it('handles date without timezone indicator correctly (parsed as UTC)', () => {
176+
// Verify that dates without timezone are parsed as UTC
177+
const m: AccountMaintenance = {
178+
...baseMaintenance,
179+
start_time: null,
180+
when: '2025-11-06T16:12:41', // No Z suffix or timezone
181+
};
182+
183+
const derivedStart = deriveMaintenanceStartISO(m);
184+
// `when` equals start time (no addition needed)
185+
expect(derivedStart).toBe('2025-11-06T16:12:41.000Z');
186+
});
155187
});
156188
});

packages/manager/src/features/Account/Maintenance/utilities.ts

Lines changed: 44 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { DateTime } from 'luxon';
44
import { parseAPIDate } from 'src/utilities/date';
55

66
import type { MaintenanceTableType } from './MaintenanceTable';
7-
import type { AccountMaintenance, MaintenancePolicy } from '@linode/api-v4';
7+
import type { AccountMaintenance } from '@linode/api-v4';
88

99
export const COMPLETED_MAINTENANCE_FILTER = Object.freeze({
1010
status: { '+or': ['completed', 'canceled'] },
@@ -56,37 +56,36 @@ export const getMaintenanceDateLabel = (type: MaintenanceTableType): string => {
5656
};
5757

5858
/**
59-
* Derive the maintenance start when API `start_time` is absent by adding the
60-
* policy notification window to the `when` (notice publish time).
59+
* Derive the maintenance start timestamp.
60+
*
61+
* The `when` and `start_time` fields are equivalent timestamps representing
62+
* when the maintenance will happen (or has happened). Prefer `start_time` if
63+
* available, otherwise use `when`.
6164
*/
6265
export const deriveMaintenanceStartISO = (
63-
maintenance: AccountMaintenance,
64-
policies?: MaintenancePolicy[]
66+
maintenance: AccountMaintenance
6567
): string | undefined => {
6668
if (maintenance.start_time) {
6769
return maintenance.start_time;
6870
}
69-
const notificationSecs = policies?.find(
70-
(p) => p.slug === maintenance.maintenance_policy_set
71-
)?.notification_period_sec;
72-
if (maintenance.when && notificationSecs) {
73-
try {
74-
return parseAPIDate(maintenance.when)
75-
.plus({ seconds: notificationSecs })
76-
.toISO();
77-
} catch {
78-
return undefined;
79-
}
71+
72+
if (!maintenance.when) {
73+
return undefined;
74+
}
75+
76+
// `when` is a timestamp equivalent to `start_time`
77+
try {
78+
return parseAPIDate(maintenance.when).toISO();
79+
} catch {
80+
return undefined;
8081
}
81-
return undefined;
8282
};
8383

8484
/**
8585
* Build a user-friendly relative label for the Upcoming table.
8686
*
8787
* Behavior:
88-
* - Prefers the actual or policy-derived start time to express time until maintenance
89-
* - Falls back to the notice relative time when the start cannot be determined
88+
* - Uses `start_time` if available, otherwise uses `when` (both are equivalent timestamps)
9089
* - Avoids day-only rounding by showing days + hours when >= 1 day
9190
*
9291
* Formatting rules:
@@ -96,28 +95,29 @@ export const deriveMaintenanceStartISO = (
9695
* - "in N seconds" when < 1 minute
9796
*/
9897
export const getUpcomingRelativeLabel = (
99-
maintenance: AccountMaintenance,
100-
policies?: MaintenancePolicy[]
98+
maintenance: AccountMaintenance
10199
): string => {
102-
const startISO = deriveMaintenanceStartISO(maintenance, policies);
100+
const startISO = deriveMaintenanceStartISO(maintenance);
101+
102+
// Use the derived start timestamp (from start_time or when)
103+
const targetDT = startISO
104+
? parseAPIDate(startISO)
105+
: maintenance.when
106+
? parseAPIDate(maintenance.when)
107+
: null;
103108

104-
// Fallback: when start cannot be determined, show the notice time relative to now
105-
if (!startISO) {
106-
return maintenance.when
107-
? (parseAPIDate(maintenance.when).toRelative() ?? '—')
108-
: '—';
109+
if (!targetDT) {
110+
return '—';
109111
}
110112

111-
// Prefer the actual or policy-derived start time to express "time until maintenance"
112-
const startDT = parseAPIDate(startISO);
113-
const now = DateTime.local();
114-
if (startDT <= now) {
115-
return startDT.toRelative() ?? '—';
113+
const now = DateTime.utc();
114+
if (targetDT <= now) {
115+
return targetDT.toRelative() ?? '—';
116116
}
117117

118118
// Avoid day-only rounding near boundaries by including hours alongside days.
119119
// For times under an hour, show exact minutes remaining; under a minute, show seconds.
120-
const diff = startDT
120+
const diff = targetDT
121121
.diff(now, ['days', 'hours', 'minutes', 'seconds'])
122122
.toObject();
123123
let days = Math.floor(diff.days ?? 0);
@@ -135,6 +135,17 @@ export const getUpcomingRelativeLabel = (
135135
hours = 0;
136136
}
137137

138+
// Round up hours when we have significant minutes (>= 30) for better accuracy
139+
if (days >= 1 && minutes >= 30) {
140+
hours += 1;
141+
minutes = 0;
142+
// Check if rounding caused hours to overflow
143+
if (hours === 24) {
144+
days += 1;
145+
hours = 0;
146+
}
147+
}
148+
138149
if (days >= 1) {
139150
const dayPart = pluralize('day', 'days', days);
140151
const hourPart = hours ? ` ${pluralize('hour', 'hours', hours)}` : '';

0 commit comments

Comments
 (0)