Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,18 @@ export const CollectedFilterMenu = ({ onChange, testIdPrefix, value }: Collected
onChange={(event) => onChange(selectCollectedPreset(event.target.value as CollectedPreset, value))}
>
<option value="all">{t({ en: 'Any time', es: 'Cualquier fecha', fr: 'Toute période' })}</option>
<option value="pastWeek">{t({ en: 'This week', es: 'Esta semana', fr: 'Cette semaine' })}</option>
<option value="pastMonth">{t({ en: 'This month', es: 'Este mes', fr: 'Ce mois-ci' })}</option>
<option value="pastThreeMonths">{t({ en: '3 months', es: '3 meses', fr: '3 mois' })}</option>
<option value="pastSixMonths">{t({ en: '6 months', es: '6 meses', fr: '6 mois' })}</option>
<option value="pastYear">{t({ en: 'This year', es: 'Este año', fr: 'Cette année' })}</option>
<option value="pastTwoYears">{t({ en: '2 years', es: '2 años', fr: '2 ans' })}</option>
<option value="pastWeek">{t({ en: 'Past week', es: 'Última semana', fr: 'Semaine écoulée' })}</option>
<option value="pastMonth">{t({ en: 'Past month', es: 'Último mes', fr: 'Mois écoulé' })}</option>
<option value="pastThreeMonths">
{t({ en: 'Past 3 months', es: 'Últimos 3 meses', fr: '3 derniers mois' })}
</option>
<option value="pastSixMonths">
{t({ en: 'Past 6 months', es: 'Últimos 6 meses', fr: '6 derniers mois' })}
</option>
<option value="pastYear">{t({ en: 'Past year', es: 'Último año', fr: 'Année écoulée' })}</option>
<option value="pastTwoYears">
{t({ en: 'Past 2 years', es: 'Últimos 2 años', fr: '2 dernières années' })}
</option>
<option value="custom">{t({ en: 'Custom', es: 'Personalizado', fr: 'Personnalisé' })}</option>
</select>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<CollectedFilterMenu testIdPrefix="red" value={DEFAULT_COLLECTED_FILTER} onChange={vi.fn()} />);
const select = screen.getByTestId<HTMLSelectElement>('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'
]);
});
});
16 changes: 15 additions & 1 deletion apps/web/src/utils/__tests__/collected-filter.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';

import {
DEFAULT_COLLECTED_FILTER,
Expand Down Expand Up @@ -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', () => {
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/utils/__tests__/timeframe.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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));
});
});
17 changes: 14 additions & 3 deletions apps/web/src/utils/collected-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 };
6 changes: 4 additions & 2 deletions apps/web/src/utils/timeframe.ts
Original file line number Diff line number Diff line change
@@ -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];

Expand All @@ -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;
}
Expand Down
26 changes: 26 additions & 0 deletions testing/src/specs/datahub.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading