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