Compare commits

..

1 Commits

Author SHA1 Message Date
Jatinderjit Singh
61cd4e38dc fix: maintenance ignores recurrence when fixed times also set 2026-04-28 01:43:03 +05:30
10 changed files with 94 additions and 363 deletions

View File

@@ -105,7 +105,7 @@ function createMockLicense(
status: '',
updated_at: '0',
},
state: LicenseState.ACTIVATED,
state: LicenseState.ACTIVE,
status: LicenseStatus.VALID,
platform: LicensePlatform.CLOUD,
created_at: '0',
@@ -931,7 +931,7 @@ describe('PrivateRoute', () => {
isFetchingActiveLicense: false,
activeLicense: createMockLicense({
platform: LicensePlatform.CLOUD,
state: LicenseState.ACTIVATED,
state: LicenseState.ACTIVE,
}),
},
isCloudUser: true,
@@ -1522,7 +1522,7 @@ describe('PrivateRoute', () => {
isFetchingActiveLicense: false,
activeLicense: createMockLicense({
platform: LicensePlatform.CLOUD,
state: LicenseState.ACTIVATED,
state: LicenseState.ACTIVE,
}),
trialInfo: createMockTrialInfo({ workSpaceBlock: false }),
user: createMockUser({ role: USER_ROLES.ADMIN as ROLES }),

View File

@@ -4,13 +4,7 @@ import {
notOfTrailResponse,
trialConvertedToSubscriptionResponse,
} from 'mocks-server/__mockdata__/licenses';
import { act, render, screen, getAppContextMock } from 'tests/test-utils';
import APIError from 'types/api/error';
import {
LicensePlatform,
LicenseResModel,
LicenseState,
} from 'types/api/licensesV3/getActive';
import { act, render, screen } from 'tests/test-utils';
import { getFormattedDate } from 'utils/timeUtils';
import BillingContainer from './BillingContainer';
@@ -26,7 +20,7 @@ window.ResizeObserver =
describe('BillingContainer', () => {
jest.setTimeout(30000);
it('Component should render', async () => {
test('Component should render', async () => {
render(<BillingContainer />);
const dataInjection = screen.getByRole('columnheader', {
@@ -67,7 +61,7 @@ describe('BillingContainer', () => {
jest.useRealTimers();
});
it('OnTrail', async () => {
test('OnTrail', async () => {
// Pin "now" so trial end (20 Oct 2023) is tomorrow => "1 days_remaining"
render(
@@ -79,19 +73,17 @@ describe('BillingContainer', () => {
// If the component schedules any setTimeout on mount, flush them:
jest.runOnlyPendingTimers();
await expect(screen.findByText('Free Trial')).resolves.toBeInTheDocument();
await expect(screen.findByText('billing')).resolves.toBeInTheDocument();
await expect(screen.findByText(/\$0/i)).resolves.toBeInTheDocument();
expect(await screen.findByText('Free Trial')).toBeInTheDocument();
expect(await screen.findByText('billing')).toBeInTheDocument();
expect(await screen.findByText(/\$0/i)).toBeInTheDocument();
await expect(
screen.findByText(
expect(
await screen.findByText(
/You are in free trial period. Your free trial will end on 20 Oct 2023/i,
),
).resolves.toBeInTheDocument();
).toBeInTheDocument();
await expect(
screen.findByText(/1 days_remaining/i),
).resolves.toBeInTheDocument();
expect(await screen.findByText(/1 days_remaining/i)).toBeInTheDocument();
const upgradeButtons = await screen.findAllByRole('button', {
name: /upgrade_plan/i,
@@ -99,19 +91,13 @@ describe('BillingContainer', () => {
expect(upgradeButtons).toHaveLength(2);
expect(upgradeButtons[1]).toBeInTheDocument();
await expect(
screen.findByText(/checkout_plans/i),
).resolves.toBeInTheDocument();
await expect(
screen.findByRole('link', { name: /here/i }),
).resolves.toBeInTheDocument();
await expect(
screen.findByText('Cancel Subscription', { selector: 'span' }),
).resolves.toBeInTheDocument();
expect(await screen.findByText(/checkout_plans/i)).toBeInTheDocument();
expect(
await screen.findByRole('link', { name: /here/i }),
).toBeInTheDocument();
});
it('OnTrail but trialConvertedToSubscription', async () => {
test('OnTrail but trialConvertedToSubscription', async () => {
await act(async () => {
render(
<BillingContainer />,
@@ -148,89 +134,10 @@ describe('BillingContainer', () => {
const dayRemainingInBillingPeriod =
await screen.findByText(/1 days_remaining/i);
expect(dayRemainingInBillingPeriod).toBeInTheDocument();
await expect(
screen.findByText('Cancel Subscription', { selector: 'span' }),
).resolves.toBeInTheDocument();
});
});
describe('CancelSubscriptionBanner visibility', () => {
const baseActiveLicense = getAppContextMock('ADMIN')
.activeLicense as LicenseResModel;
it('should render when license is ACTIVATED and platform is CLOUD', async () => {
render(<BillingContainer />);
await expect(
screen.findByText('Cancel Subscription', { selector: 'span' }),
).resolves.toBeInTheDocument();
});
it.each([
['EXPIRED', LicenseState.EXPIRED],
['TERMINATED', LicenseState.TERMINATED],
['CANCELLED', LicenseState.CANCELLED],
['EVALUATION_EXPIRED', LicenseState.EVALUATION_EXPIRED],
['DEFAULTED', LicenseState.DEFAULTED],
['ISSUED', LicenseState.ISSUED],
['EVALUATING', LicenseState.EVALUATING],
])('should not render when license state is %s', async (_, state) => {
render(
<BillingContainer />,
{},
{
appContextOverrides: {
activeLicense: { ...baseActiveLicense, state },
},
},
);
await screen.findByText('billing');
expect(
screen.queryByText('Cancel Subscription', { selector: 'span' }),
).not.toBeInTheDocument();
});
const makeAPIError = (statusCode: number): APIError =>
new APIError({
httpStatusCode: statusCode as any,
error: { code: 'error', message: 'error', url: '', errors: [] },
});
it.each([
[
'Self-Hosted platform',
{
activeLicense: {
...baseActiveLicense,
platform: LicensePlatform.SELF_HOSTED,
},
activeLicenseFetchError: null,
},
],
[
'Community Enterprise user (license API 404)',
{
activeLicense: null,
activeLicenseFetchError: makeAPIError(404),
},
],
[
'Community user (license API 501)',
{
activeLicense: null,
activeLicenseFetchError: makeAPIError(501),
},
],
])('should not render for %s', async (_, overrides) => {
render(<BillingContainer />, {}, { appContextOverrides: overrides });
await screen.findByText('billing');
expect(
screen.queryByText('Cancel Subscription', { selector: 'span' }),
).not.toBeInTheDocument();
});
});
it('Not on ontrail', async () => {
test('Not on ontrail', async () => {
const { findByText } = render(
<BillingContainer />,
{},

View File

@@ -34,12 +34,10 @@ import { CheckoutSuccessPayloadProps } from 'types/api/billing/checkout';
import { getBaseUrl } from 'utils/basePath';
import { getFormattedDate, getRemainingDays } from 'utils/timeUtils';
import CancelSubscriptionBanner from './CancelSubscriptionBanner';
import { BillingUsageGraph } from './BillingUsageGraph/BillingUsageGraph';
import { prepareCsvData } from './BillingUsageGraph/utils';
import './BillingContainer.styles.scss';
import { LicenseState } from 'types/api/licensesV3/getActive';
interface DataType {
key: string;
@@ -319,7 +317,7 @@ export default function BillingContainer(): JSX.Element {
const handleBilling = useCallback(async () => {
if (!trialInfo?.trialConvertedToSubscription) {
void logEvent('Billing : Upgrade Plan', {
logEvent('Billing : Upgrade Plan', {
user: pick(user, ['email', 'userId', 'name']),
org,
});
@@ -328,7 +326,7 @@ export default function BillingContainer(): JSX.Element {
url: getBaseUrl(),
});
} else {
void logEvent('Billing : Manage Billing', {
logEvent('Billing : Manage Billing', {
user: pick(user, ['email', 'userId', 'name']),
org,
});
@@ -537,10 +535,6 @@ export default function BillingContainer(): JSX.Element {
{(isLoading || isFetchingBillingData) && renderTableSkeleton()}
</div>
{isCloudUserVal && activeLicense?.state === LicenseState.ACTIVATED && (
<CancelSubscriptionBanner />
)}
{!trialInfo?.trialConvertedToSubscription && (
<div className="upgrade-plan-benefits">
<Row

View File

@@ -1,35 +0,0 @@
.banner {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--padding-4);
border-radius: 4px;
border: 1px solid var(--callout-error-border);
background-color: var(--callout-error-background);
margin: var(--spacing-4) 0 var(--spacing-12);
}
.info {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.title {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--callout-error-title);
}
.subtitle {
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--paragraph-base-400-font-weight);
line-height: var(--paragraph-base-400-line-height);
color: var(--callout-error-icon);
}
.dialogBody {
font-size: var(--font-size-sm);
line-height: var(--line-height-relaxed);
color: var(--l2-foreground);
}

View File

@@ -1,68 +0,0 @@
import { render, screen, userEvent } from 'tests/test-utils';
import CancelSubscriptionBanner from './CancelSubscriptionBanner';
jest.mock('utils/basePath', () => ({
getBasePath: (): string => '/',
withBasePath: (path: string): string => path,
getAbsoluteUrl: (path: string): string => `https://test.signoz.io${path}`,
getBaseUrl: (): string => 'https://test.signoz.io',
}));
describe('CancelSubscriptionBanner', () => {
it('renders banner with title and subtitle', () => {
render(<CancelSubscriptionBanner />);
expect(
screen.getByText('Cancel Subscription', { selector: 'span' }),
).toBeInTheDocument();
expect(
screen.getByText('Cancel your SigNoz subscription.'),
).toBeInTheDocument();
});
it('opens dialog with correct content when Cancel Subscription is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(
screen.getByText(/reach out to our support team/i),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /keep subscription/i }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /contact support/i }),
).toBeInTheDocument();
});
it('sends mailto to cloud-support with correct subject on Contact Support', async () => {
const realCreateElement = document.createElement.bind(document);
const mockClick = jest.fn();
const mockAnchor = { href: '', click: mockClick };
jest.spyOn(document, 'createElement').mockImplementation((tag: string) => {
if (tag === 'a') {
return mockAnchor as unknown as HTMLAnchorElement;
}
return realCreateElement(tag);
});
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<CancelSubscriptionBanner />);
await user.click(
screen.getByRole('button', { name: /cancel subscription/i }),
);
await user.click(screen.getByRole('button', { name: /contact support/i }));
expect(mockAnchor.href).toContain('mailto:cloud-support@signoz.io');
expect(mockAnchor.href).toContain('Cancel%20My%20SigNoz%20Subscription');
expect(mockClick).toHaveBeenCalledTimes(1);
jest.restoreAllMocks();
});
});

View File

@@ -1,106 +0,0 @@
import { useState } from 'react';
import { X } from '@signozhq/icons';
import { Button, DialogWrapper } from '@signozhq/ui';
import logEvent from 'api/common/logEvent';
import { pick } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { getBaseUrl } from 'utils/basePath';
import styles from './CancelSubscriptionBanner.module.scss';
function CancelSubscriptionBanner(): JSX.Element {
const [open, setOpen] = useState(false);
const { user, org } = useAppContext();
const handleOpenCancelDialog = (): void => {
void logEvent('Billing : Cancel Subscription Clicked', {
user: pick(user, ['email', 'displayName', 'role', 'organization']),
role: user?.role,
});
setOpen(true);
};
const handleContactSupport = (): void => {
void logEvent('Billing : Cancel Subscription Confirmed', {
user: pick(user, ['email', 'displayName', 'role', 'organization']),
role: user?.role,
});
const subject = encodeURIComponent('Cancel My SigNoz Subscription');
const orgName = org?.[0]?.displayName ?? '';
const body = encodeURIComponent(
[
'Hi SigNoz Team,',
'',
'I would like to cancel my SigNoz Cloud subscription.',
'Please find my account details below.',
'',
'Account Details:',
` • SigNoz URL: ${getBaseUrl()}`,
...(orgName ? [` • Organization: ${orgName}`] : []),
` • Account Email: ${user?.email ?? ''}`,
'',
'Reason for Cancellation:',
'[Please share the reason for cancellation]',
'',
'Additional feedback (optional):',
'[Any other feedback]',
'',
'Regards,',
'[user name or team name]',
].join('\n'),
);
const link = document.createElement('a');
link.href = `mailto:cloud-support@signoz.io?subject=${subject}&body=${body}`;
link.click();
setOpen(false);
};
const footer = (
<>
<Button
variant="solid"
color="secondary"
onClick={(): void => setOpen(false)}
>
Keep Subscription
</Button>
<Button variant="solid" color="destructive" onClick={handleContactSupport}>
Contact Support
</Button>
</>
);
return (
<>
<div className={styles.banner}>
<div className={styles.info}>
<span className={styles.title}>Cancel Subscription</span>
<span className={styles.subtitle}>Cancel your SigNoz subscription.</span>
</div>
<Button
variant="solid"
color="destructive"
prefix={<X size={12} />}
onClick={handleOpenCancelDialog}
>
Cancel Subscription
</Button>
</div>
<DialogWrapper
open={open}
onOpenChange={setOpen}
title="Cancel your subscription"
width="narrow"
showCloseButton
footer={footer}
>
<p className={styles.dialogBody}>
To cancel your SigNoz subscription, please reach out to our support team.
We&apos;ll be happy to assist you.
</p>
</DialogWrapper>
</>
);
}
export default CancelSubscriptionBanner;

View File

@@ -119,7 +119,7 @@ export function getAppContextMock(
status: '',
updated_at: '0',
},
state: LicenseState.ACTIVATED,
state: LicenseState.ACTIVE,
status: LicenseStatus.VALID,
platform: LicensePlatform.CLOUD,
created_at: '0',

View File

@@ -11,7 +11,7 @@ export enum LicenseStatus {
export enum LicenseState {
DEFAULTED = 'DEFAULTED',
ACTIVATED = 'ACTIVATED',
ACTIVE = 'ACTIVE',
EXPIRED = 'EXPIRED',
ISSUED = 'ISSUED',
EVALUATING = 'EVALUATING',

View File

@@ -11,9 +11,7 @@ import (
"github.com/uptrace/bun"
)
var (
ErrCodeInvalidPlannedMaintenancePayload = errors.MustNewCode("invalid_planned_maintenance_payload")
)
var ErrCodeInvalidPlannedMaintenancePayload = errors.MustNewCode("invalid_planned_maintenance_payload")
type MaintenanceStatus struct {
valuer.String
@@ -63,15 +61,15 @@ type StorablePlannedMaintenance struct {
}
type PlannedMaintenance struct {
ID valuer.UUID `json:"id" required:"true"`
Name string `json:"name" required:"true"`
Description string `json:"description"`
Schedule *Schedule `json:"schedule" required:"true"`
RuleIDs []string `json:"alertIds"`
CreatedAt time.Time `json:"createdAt"`
CreatedBy string `json:"createdBy"`
UpdatedAt time.Time `json:"updatedAt"`
UpdatedBy string `json:"updatedBy"`
ID valuer.UUID `json:"id" required:"true"`
Name string `json:"name" required:"true"`
Description string `json:"description"`
Schedule *Schedule `json:"schedule" required:"true"`
RuleIDs []string `json:"alertIds"`
CreatedAt time.Time `json:"createdAt"`
CreatedBy string `json:"createdBy"`
UpdatedAt time.Time `json:"updatedAt"`
UpdatedBy string `json:"updatedBy"`
Status MaintenanceStatus `json:"status" required:"true"`
Kind MaintenanceKind `json:"kind" required:"true"`
}
@@ -161,8 +159,11 @@ func (m *PlannedMaintenance) ShouldSkip(ruleID string, now time.Time) bool {
currentTime := now.In(loc)
// fixed schedule
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
// fixed schedule — only when no recurrence is configured.
// When recurrence is set, the recurring check below handles everything;
// falling through here would cause the window to match the absolute
// StartTimeEndTime range instead of the daily/weekly/monthly pattern.
if m.Schedule.Recurrence == nil && !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
startTime := m.Schedule.StartTime.In(loc)
endTime := m.Schedule.EndTime.In(loc)
if currentTime.Equal(startTime) || currentTime.Equal(endTime) ||

View File

@@ -13,7 +13,6 @@ func timePtr(t time.Time) *time.Time {
}
func TestShouldSkipMaintenance(t *testing.T) {
cases := []struct {
name string
maintenance *PlannedMaintenance
@@ -499,7 +498,7 @@ func TestShouldSkipMaintenance(t *testing.T) {
},
},
},
ts: time.Date(2024, 04, 1, 12, 10, 0, 0, time.UTC),
ts: time.Date(2024, 4, 1, 12, 10, 0, 0, time.UTC),
skip: true,
},
{
@@ -508,14 +507,14 @@ func TestShouldSkipMaintenance(t *testing.T) {
Schedule: &Schedule{
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 04, 01, 12, 0, 0, 0, time.UTC),
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
},
},
},
ts: time.Date(2024, 04, 15, 12, 10, 0, 0, time.UTC),
ts: time.Date(2024, 4, 15, 12, 10, 0, 0, time.UTC),
skip: true,
},
{
@@ -524,14 +523,14 @@ func TestShouldSkipMaintenance(t *testing.T) {
Schedule: &Schedule{
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 04, 01, 12, 0, 0, 0, time.UTC),
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
},
},
},
ts: time.Date(2024, 04, 14, 12, 10, 0, 0, time.UTC), // 14th 04 is sunday
ts: time.Date(2024, 4, 14, 12, 10, 0, 0, time.UTC), // 14th 04 is sunday
skip: false,
},
{
@@ -540,14 +539,14 @@ func TestShouldSkipMaintenance(t *testing.T) {
Schedule: &Schedule{
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 04, 01, 12, 0, 0, 0, time.UTC),
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
},
},
},
ts: time.Date(2024, 04, 16, 12, 10, 0, 0, time.UTC), // 16th 04 is tuesday
ts: time.Date(2024, 4, 16, 12, 10, 0, 0, time.UTC), // 16th 04 is tuesday
skip: false,
},
{
@@ -556,14 +555,14 @@ func TestShouldSkipMaintenance(t *testing.T) {
Schedule: &Schedule{
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 04, 01, 12, 0, 0, 0, time.UTC),
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
},
},
},
ts: time.Date(2024, 05, 06, 12, 10, 0, 0, time.UTC),
ts: time.Date(2024, 5, 6, 12, 10, 0, 0, time.UTC),
skip: true,
},
{
@@ -572,14 +571,14 @@ func TestShouldSkipMaintenance(t *testing.T) {
Schedule: &Schedule{
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 04, 01, 12, 0, 0, 0, time.UTC),
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
},
},
},
ts: time.Date(2024, 05, 06, 14, 00, 0, 0, time.UTC),
ts: time.Date(2024, 5, 6, 14, 0, 0, 0, time.UTC),
skip: true,
},
{
@@ -588,13 +587,13 @@ func TestShouldSkipMaintenance(t *testing.T) {
Schedule: &Schedule{
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 04, 04, 12, 0, 0, 0, time.UTC),
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
},
},
ts: time.Date(2024, 04, 04, 12, 10, 0, 0, time.UTC),
ts: time.Date(2024, 4, 4, 12, 10, 0, 0, time.UTC),
skip: true,
},
{
@@ -603,13 +602,13 @@ func TestShouldSkipMaintenance(t *testing.T) {
Schedule: &Schedule{
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 04, 04, 12, 0, 0, 0, time.UTC),
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
},
},
ts: time.Date(2024, 04, 04, 14, 10, 0, 0, time.UTC),
ts: time.Date(2024, 4, 4, 14, 10, 0, 0, time.UTC),
skip: false,
},
{
@@ -618,13 +617,52 @@ func TestShouldSkipMaintenance(t *testing.T) {
Schedule: &Schedule{
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 04, 04, 12, 0, 0, 0, time.UTC),
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
},
},
ts: time.Date(2024, 05, 04, 12, 10, 0, 0, time.UTC),
ts: time.Date(2024, 5, 4, 12, 10, 0, 0, time.UTC),
skip: true,
},
// The recurrence should govern, when set. Not the fixed range.
{
name: "recurring-daily-with-fixed-times-outside-daily-window",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
// These fixed fields should be ignored when Recurrence is set.
StartTime: time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 4, 30, 18, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2026, 4, 1, 14, 0, 0, 0, time.UTC), // daily at 14:00
Duration: valuer.MustParseTextDuration("2h"), // until 16:00
RepeatType: RepeatTypeDaily,
},
},
},
// 11:00 is inside the fixed range but outside the daily 14:00-16:00 window.
// Before the fix this returned true (bug); after fix it returns false.
ts: time.Date(2026, 4, 15, 11, 0, 0, 0, time.UTC),
skip: false,
},
{
name: "recurring-daily-with-fixed-times-inside-daily-window",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 4, 30, 18, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2026, 4, 1, 14, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
},
},
// 15:00 is inside the daily 14:00-16:00 window — should skip.
ts: time.Date(2026, 4, 15, 15, 0, 0, 0, time.UTC),
skip: true,
},
}