From 3ff5ee80de6a1202b8e2fd9b4748b3fac7c25ea7 Mon Sep 17 00:00:00 2001 From: Misael Alves <112561288+misaelalves99@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:00:16 -0300 Subject: [PATCH] fix(web): clarify rolling collected date filters --- .../CollectedFilterMenu.tsx | 18 ++++-- .../__tests__/CollectedFilterMenu.test.tsx | 64 +++++++++++++++++++ .../utils/__tests__/collected-filter.test.ts | 16 ++++- .../web/src/utils/__tests__/timeframe.test.ts | 10 +++ apps/web/src/utils/collected-filter.ts | 17 ++++- apps/web/src/utils/timeframe.ts | 6 +- testing/src/specs/datahub.spec.ts | 26 ++++++++ 7 files changed, 145 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/CollectedFilterMenu/__tests__/CollectedFilterMenu.test.tsx diff --git a/apps/web/src/components/CollectedFilterMenu/CollectedFilterMenu.tsx b/apps/web/src/components/CollectedFilterMenu/CollectedFilterMenu.tsx index 6e914f9cb..b7f4a7c99 100644 --- a/apps/web/src/components/CollectedFilterMenu/CollectedFilterMenu.tsx +++ b/apps/web/src/components/CollectedFilterMenu/CollectedFilterMenu.tsx @@ -34,12 +34,18 @@ export const CollectedFilterMenu = ({ onChange, testIdPrefix, value }: Collected onChange={(event) => onChange(selectCollectedPreset(event.target.value as CollectedPreset, value))} > - - - - - - + + + + + + diff --git a/apps/web/src/components/CollectedFilterMenu/__tests__/CollectedFilterMenu.test.tsx b/apps/web/src/components/CollectedFilterMenu/__tests__/CollectedFilterMenu.test.tsx new file mode 100644 index 000000000..c4c9600c1 --- /dev/null +++ b/apps/web/src/components/CollectedFilterMenu/__tests__/CollectedFilterMenu.test.tsx @@ -0,0 +1,64 @@ +import { i18n } from '@douglasneuroinformatics/libui/i18n'; +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { DEFAULT_COLLECTED_FILTER } from '@/utils/collected-filter'; + +import { CollectedFilterMenu } from '../CollectedFilterMenu'; + +import '@/services/i18n'; + +describe('CollectedFilterMenu', () => { + afterEach(() => { + cleanup(); + i18n.changeLanguage('en'); + }); + + it.each([ + [ + 'en', + ['Any time', 'Past week', 'Past month', 'Past 3 months', 'Past 6 months', 'Past year', 'Past 2 years', 'Custom'] + ], + [ + 'es', + [ + 'Cualquier fecha', + 'Última semana', + 'Último mes', + 'Últimos 3 meses', + 'Últimos 6 meses', + 'Último año', + 'Últimos 2 años', + 'Personalizado' + ] + ], + [ + 'fr', + [ + 'Toute période', + 'Semaine écoulée', + 'Mois écoulé', + '3 derniers mois', + '6 derniers mois', + 'Année écoulée', + '2 dernières années', + 'Personnalisé' + ] + ] + ] as const)('should label every rolling window accurately in %s', (language, labels) => { + i18n.changeLanguage(language); + render(); + const select = screen.getByTestId('red-collected-preset'); + expect(Array.from(select.options, (option) => option.textContent)).toStrictEqual(labels); + expect(Array.from(select.options, (option) => option.value)).toStrictEqual([ + 'all', + 'pastWeek', + 'pastMonth', + 'pastThreeMonths', + 'pastSixMonths', + 'pastYear', + 'pastTwoYears', + 'custom' + ]); + }); +}); diff --git a/apps/web/src/utils/__tests__/collected-filter.test.ts b/apps/web/src/utils/__tests__/collected-filter.test.ts index 475417f42..adcabae7a 100644 --- a/apps/web/src/utils/__tests__/collected-filter.test.ts +++ b/apps/web/src/utils/__tests__/collected-filter.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { DEFAULT_COLLECTED_FILTER, @@ -29,6 +29,20 @@ describe('presetMinDate', () => { it('should order the windows so a longer one reaches further back', () => { expect(presetMinDate('pastWeek')!.getTime()).toBeGreaterThan(presetMinDate('pastYear')!.getTime()); }); + + it.each([ + ['pastMonth', new Date(2026, 2, 31, 12, 20), new Date(2026, 1, 28, 12, 20)], + ['pastThreeMonths', new Date(2026, 4, 31, 12, 20), new Date(2026, 1, 28, 12, 20)], + ['pastMonth', new Date(2024, 2, 31, 12, 20), new Date(2024, 1, 29, 12, 20)], + ['pastYear', new Date(2024, 1, 29, 12, 20), new Date(2023, 1, 28, 12, 20)] + ] as const)('should clamp %s at month or leap-year boundaries', (preset, now, expected) => { + vi.useFakeTimers({ now, toFake: ['Date'] }); + try { + expect(presetMinDate(preset)).toStrictEqual(expected); + } finally { + vi.useRealTimers(); + } + }); }); describe('matchesCollectedFilter', () => { diff --git a/apps/web/src/utils/__tests__/timeframe.test.ts b/apps/web/src/utils/__tests__/timeframe.test.ts index 25a80fef4..b5cb57c10 100644 --- a/apps/web/src/utils/__tests__/timeframe.test.ts +++ b/apps/web/src/utils/__tests__/timeframe.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { presetMinDate } from '@/utils/collected-filter'; import { timeframeMinDate } from '@/utils/timeframe'; describe('timeframeMinDate', () => { @@ -31,4 +32,13 @@ describe('timeframeMinDate', () => { expect(timeframeMinDate('pastYear')).not.toStrictEqual(chosen); expect(timeframeMinDate('pastYear')).toStrictEqual(new Date(2026, 2, 15, 12)); }); + + it.each([ + ['pastMonth', new Date(2026, 2, 31, 12), new Date(2026, 1, 28, 12)], + ['pastYear', new Date(2024, 1, 29, 12), new Date(2023, 1, 28, 12)] + ] as const)('should clamp %s consistently with the Collected filter', (key, now, expected) => { + vi.setSystemTime(now); + expect(timeframeMinDate(key)).toStrictEqual(expected); + expect(timeframeMinDate(key)).toStrictEqual(presetMinDate(key)); + }); }); diff --git a/apps/web/src/utils/collected-filter.ts b/apps/web/src/utils/collected-filter.ts index 395aefd68..7e7c20959 100644 --- a/apps/web/src/utils/collected-filter.ts +++ b/apps/web/src/utils/collected-filter.ts @@ -10,11 +10,22 @@ type CollectedFilter = { const DEFAULT_COLLECTED_FILTER: CollectedFilter = { max: null, min: null, preset: 'all' }; +/** Subtract whole calendar months while preserving the local clock and clamping the day. */ +function monthsBeforeClamped(now: Date, count: number): Date { + const result = new Date(now); + const originalDay = result.getDate(); + result.setDate(1); + result.setMonth(result.getMonth() - count); + const lastDay = new Date(result.getFullYear(), result.getMonth() + 1, 0).getDate(); + result.setDate(Math.min(originalDay, lastDay)); + return result; +} + /** The earliest date a preset admits, or null for no lower bound */ function presetMinDate(preset: CollectedPreset): Date | null { const now = new Date(); - const monthsAgo = (count: number) => new Date(new Date(now).setMonth(now.getMonth() - count)); - const yearsAgo = (count: number) => new Date(new Date(now).setFullYear(now.getFullYear() - count)); + const monthsAgo = (count: number) => monthsBeforeClamped(now, count); + const yearsAgo = (count: number) => monthsBeforeClamped(now, count * 12); switch (preset) { case 'pastMonth': return monthsAgo(1); @@ -59,5 +70,5 @@ function selectCollectedPreset(preset: CollectedPreset, previous: CollectedFilte }; } -export { DEFAULT_COLLECTED_FILTER, matchesCollectedFilter, presetMinDate, selectCollectedPreset }; +export { DEFAULT_COLLECTED_FILTER, matchesCollectedFilter, monthsBeforeClamped, presetMinDate, selectCollectedPreset }; export type { CollectedFilter, CollectedPreset }; diff --git a/apps/web/src/utils/timeframe.ts b/apps/web/src/utils/timeframe.ts index e163c2c5e..d564d75fd 100644 --- a/apps/web/src/utils/timeframe.ts +++ b/apps/web/src/utils/timeframe.ts @@ -1,3 +1,5 @@ +import { monthsBeforeClamped } from '@/utils/collected-filter'; + /** The timeframe a record listing can be narrowed to, as stored in the URL. */ type TimeframeKey = (typeof TIMEFRAME_KEYS)[number]; @@ -13,10 +15,10 @@ const TIMEFRAME_KEYS = ['all', 'pastMonth', 'pastYear'] as const; function timeframeMinDate(key: TimeframeKey): Date | null { const now = new Date(); if (key === 'pastMonth') { - return new Date(new Date(now).setMonth(now.getMonth() - 1)); + return monthsBeforeClamped(now, 1); } if (key === 'pastYear') { - return new Date(new Date(now).setFullYear(now.getFullYear() - 1)); + return monthsBeforeClamped(now, 12); } return null; } diff --git a/testing/src/specs/datahub.spec.ts b/testing/src/specs/datahub.spec.ts index 9966b37b4..221997a67 100644 --- a/testing/src/specs/datahub.spec.ts +++ b/testing/src/specs/datahub.spec.ts @@ -265,6 +265,32 @@ test.describe('data hub', () => { // Everything was collected just now, so the tightest window keeps it and nothing is lost. await datahubPage.selectCollectedWindow('pastMonth'); await expect(datahubPage.rows).toHaveCount(1); + await expect(datahubPage.collectedPresetFilter.locator('option:checked')).toHaveText('Past month'); + }); + + test('should keep early-March records in the past month at March end', async ({ + api, + isolatedGroupManager, + page, + uniqueId + }) => { + const group = await isolatedGroupManager(); + const instrumentId = await api.findInstrumentIdByName('DNP_HAPPINESS_QUESTIONNAIRE'); + const included = `within-window-${uniqueId}`; + const excluded = `before-window-${uniqueId}`; + await api.uploadRecords(group.id, instrumentId, [ + { data: HAPPINESS_RECORD, date: new Date(2026, 2, 2, 12), subjectId: included }, + { data: HAPPINESS_RECORD, date: new Date(2026, 1, 27, 12), subjectId: excluded } + ]); + + await page.clock.setFixedTime(new Date(2026, 2, 31, 12)); + const datahubPage = new DatahubPage(page); + await datahubPage.goto('/datahub/subjects'); + await expect(datahubPage.rows).toHaveCount(2); + + await datahubPage.selectCollectedWindow('pastMonth'); + await expect(datahubPage.rows).toHaveCount(1); + await expect(datahubPage.rows).toContainText(included.slice(0, 9)); }); // `GET /v1/subjects` is gated on `read Subject`, which a standard user holds, but resolving