From 7285607cad6aedf2f08b254cc29c76d052515682 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Thu, 17 Apr 2025 14:06:26 +0200 Subject: [PATCH] feat: remind me later about cleanup (#9790) --- .../CleanupReminder/CleanupReminder.test.tsx | 20 ++++- .../CleanupReminder/CleanupReminder.tsx | 57 ++++++++++---- .../CleanupReminder/useFlagReminders.test.tsx | 76 +++++++++++++++++++ .../CleanupReminder/useFlagReminders.ts | 53 +++++++++++++ 4 files changed, 191 insertions(+), 15 deletions(-) create mode 100644 frontend/src/component/feature/FeatureView/CleanupReminder/useFlagReminders.test.tsx create mode 100644 frontend/src/component/feature/FeatureView/CleanupReminder/useFlagReminders.ts diff --git a/frontend/src/component/feature/FeatureView/CleanupReminder/CleanupReminder.test.tsx b/frontend/src/component/feature/FeatureView/CleanupReminder/CleanupReminder.test.tsx index 9fd43374e5..c34676bac8 100644 --- a/frontend/src/component/feature/FeatureView/CleanupReminder/CleanupReminder.test.tsx +++ b/frontend/src/component/feature/FeatureView/CleanupReminder/CleanupReminder.test.tsx @@ -2,7 +2,7 @@ import { vi } from 'vitest'; import { CleanupReminder } from './CleanupReminder'; import { render } from 'utils/testRenderer'; import type { IFeatureToggle } from 'interfaces/featureToggle'; -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import { DELETE_FEATURE, UPDATE_FEATURE, @@ -11,6 +11,10 @@ import { const currentTime = '2024-04-25T08:05:00.000Z'; const monthAgo = '2024-03-25T06:05:00.000Z'; +beforeEach(() => { + window.localStorage.clear(); +}); + test('render complete feature reminder', async () => { vi.setSystemTime(currentTime); const feature = { @@ -55,6 +59,13 @@ test('render remove flag from code reminder', async () => { }); await screen.findByText('Time to remove flag from code?'); + + const reminder = await screen.findByText('Remind me later'); + reminder.click(); + + await waitFor(() => { + expect(screen.queryByText('Archive flag')).not.toBeInTheDocument(); + }); }); test('render archive flag reminder', async () => { @@ -78,4 +89,11 @@ test('render archive flag reminder', async () => { await screen.findByText('child1'); const okButton = await screen.findByText('OK'); okButton.click(); + + const reminder = await screen.findByText('Remind me later'); + reminder.click(); + + await waitFor(() => { + expect(screen.queryByText('Archive flag')).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/component/feature/FeatureView/CleanupReminder/CleanupReminder.tsx b/frontend/src/component/feature/FeatureView/CleanupReminder/CleanupReminder.tsx index 31c2de7cc2..56c3c9c835 100644 --- a/frontend/src/component/feature/FeatureView/CleanupReminder/CleanupReminder.tsx +++ b/frontend/src/component/feature/FeatureView/CleanupReminder/CleanupReminder.tsx @@ -1,5 +1,5 @@ import { type FC, useState } from 'react'; -import { Alert, Box, styled } from '@mui/material'; +import { Alert, Box, Button, styled } from '@mui/material'; import FlagIcon from '@mui/icons-material/OutlinedFlag'; import CleaningServicesIcon from '@mui/icons-material/CleaningServices'; import { parseISO } from 'date-fns'; @@ -17,12 +17,19 @@ import type { IFeatureToggle } from 'interfaces/featureToggle'; import { FeatureArchiveNotAllowedDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveNotAllowedDialog'; import { FeatureArchiveDialog } from 'component/common/FeatureArchiveDialog/FeatureArchiveDialog'; import { useNavigate } from 'react-router-dom'; +import { useFlagReminders } from './useFlagReminders'; const StyledBox = styled(Box)(({ theme }) => ({ marginRight: theme.spacing(2), marginBottom: theme.spacing(2), })); +const ActionsBox = styled(Box)(({ theme }) => ({ + display: 'flex', + gap: theme.spacing(1), + alignItems: 'center', +})); + type ReminderType = 'complete' | 'removeCode' | 'archive' | null; export const CleanupReminder: FC<{ @@ -42,6 +49,7 @@ export const CleanupReminder: FC<{ const daysInStage = enteredStageAt ? differenceInDays(new Date(), parseISO(enteredStageAt)) : 0; + const { shouldShowReminder, snoozeReminder } = useFlagReminders(); const determineReminder = (): ReminderType => { if (!currentStage || !isRelevantType) return null; @@ -49,7 +57,10 @@ export const CleanupReminder: FC<{ if (currentStage.name === 'live' && daysInStage > 30) { return 'complete'; } - if (currentStage.name === 'completed') { + if ( + currentStage.name === 'completed' && + shouldShowReminder(feature.name) + ) { if (isSafeToArchive(currentStage.environments)) { return 'archive'; } @@ -76,7 +87,7 @@ export const CleanupReminder: FC<{ setMarkCompleteDialogueOpen(true) } @@ -109,16 +120,23 @@ export const CleanupReminder: FC<{ severity='warning' icon={} action={ - setArchiveDialogueOpen(true)} - projectId={feature.project} - > - Archive flag - + + + setArchiveDialogueOpen(true)} + projectId={feature.project} + > + Archive flag + + } > Time to clean up technical debt? @@ -149,7 +167,18 @@ export const CleanupReminder: FC<{ )} {reminder === 'removeCode' && ( - }> + } + action={ + + } + > Time to remove flag from code?

This flag was marked as complete and ready for cleanup. diff --git a/frontend/src/component/feature/FeatureView/CleanupReminder/useFlagReminders.test.tsx b/frontend/src/component/feature/FeatureView/CleanupReminder/useFlagReminders.test.tsx new file mode 100644 index 0000000000..4163cc7261 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/CleanupReminder/useFlagReminders.test.tsx @@ -0,0 +1,76 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useFlagReminders } from './useFlagReminders'; + +const TestComponent = ({ + days = 7, + maxReminders = 50, +}: { + days?: number; + maxReminders?: number; +}) => { + const { shouldShowReminder, snoozeReminder } = useFlagReminders({ + days, + maxReminders, + }); + + return ( +

+ + +
+ {shouldShowReminder('test-flag') ? 'yes' : 'no'} +
+
+ ); +}; + +describe('useFlagReminders (integration)', () => { + beforeEach(() => { + window.localStorage.clear(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should show reminder when no snooze exists', () => { + render(); + + expect(screen.getByTestId('result').textContent).toBe('yes'); + }); + + it('should not show reminder after snoozing', () => { + render(); + fireEvent.click(screen.getByText('Snooze')); + + expect(screen.getByTestId('result').textContent).toBe('no'); + }); + + it('should show reminder again after snooze expires', () => { + const { rerender } = render(); + fireEvent.click(screen.getByText('Snooze')); + + // Advance 4 days + vi.advanceTimersByTime(4 * 24 * 60 * 60 * 1000); + rerender(); + + expect(screen.getByTestId('result').textContent).toBe('yes'); + }); + + it('should respect max reminders and remove oldest entries', () => { + render(); + fireEvent.click(screen.getByText('Snooze')); + fireEvent.click(screen.getByText('Snooze Another')); + + expect(screen.getByTestId('result').textContent).toBe('yes'); + }); +}); diff --git a/frontend/src/component/feature/FeatureView/CleanupReminder/useFlagReminders.ts b/frontend/src/component/feature/FeatureView/CleanupReminder/useFlagReminders.ts new file mode 100644 index 0000000000..b1da8ed977 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/CleanupReminder/useFlagReminders.ts @@ -0,0 +1,53 @@ +import { isAfter, addDays } from 'date-fns'; +import { useLocalStorageState } from 'hooks/useLocalStorageState'; + +type FlagReminderMap = Record; // timestamp in ms + +const REMINDER_KEY = 'flag-reminders:v1'; +const MAX_REMINDERS = 50; +const DAYS = 7; + +export const useFlagReminders = ({ + days = DAYS, + maxReminders = MAX_REMINDERS, +}: { + days?: number; + maxReminders?: number; +} = {}) => { + const [reminders, setReminders] = useLocalStorageState( + REMINDER_KEY, + {}, + ); + + const shouldShowReminder = (flagName: string): boolean => { + const snoozedUntil = reminders[flagName]; + return !snoozedUntil || isAfter(new Date(), new Date(snoozedUntil)); + }; + + const snoozeReminder = (flagName: string, snoozeDays: number = days) => { + const snoozedUntil = addDays(new Date(), snoozeDays).getTime(); + + setReminders((prev) => { + const updated = { ...prev, [flagName]: snoozedUntil }; + + const entries = Object.entries(updated); + + if (entries.length > maxReminders) { + // Sort by timestamp (oldest first) + entries.sort((a, b) => a[1] - b[1]); + + // Keep only the newest maxReminders + const trimmed = entries.slice(entries.length - maxReminders); + + return Object.fromEntries(trimmed); + } + + return updated; + }); + }; + + return { + shouldShowReminder, + snoozeReminder, + }; +};