fix: conversations hidden when filtering by a date range (#15385)

This commit is contained in:
Sivin Varghese
2026-08-11 10:08:10 +05:30
committed by GitHub
parent 66067a1dfe
commit 2fbcc715ce
2 changed files with 126 additions and 3 deletions

View File

@@ -170,6 +170,35 @@ const contains = (filterValue, conversationValue) => {
return false;
};
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
/**
* Checks whether a value is a calendar date without a time, as emitted by the
* date pickers and required by the backend (Date.iso8601)
* @param {*} value - The value to check
* @returns {Boolean} - Returns true for `YYYY-MM-DD` strings
*/
const isDateOnly = value =>
typeof value === 'string' && DATE_ONLY_PATTERN.test(value);
/**
* Reduces a value to the UTC calendar day it falls on
* @param {*} value - An epoch timestamp, an ISO string or a `YYYY-MM-DD` string
* @returns {Number|null} - Milliseconds at UTC midnight, or null when unparseable
*
* `coerceToDate` reads a `YYYY-MM-DD` string as midnight in the browser
* timezone, which lands on the previous day for browsers behind UTC.
*/
const toUtcDay = value => {
const date = isDateOnly(value)
? new Date(`${value}T00:00:00.000Z`)
: coerceToDate(value);
if (date === null) return null;
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
};
/**
* Compares two date values using a comparison function
* @param {*} conversationValue - The conversation value to compare
@@ -178,13 +207,24 @@ const contains = (filterValue, conversationValue) => {
* @returns {Boolean} - Returns true if the comparison succeeds, false otherwise
*/
const compareDates = (conversationValue, filterValue, compareFn) => {
const conversationDate = coerceToDate(conversationValue);
// In saved views, the filterValue might be returned as an Array
// In conversation list, when filtering, the filterValue will be returned as a string
const valueToCompare = Array.isArray(filterValue)
? filterValue[0]
: filterValue;
// A date filter compares whole days. The backend casts both sides with
// `::date` (Filters::FilterHelper#date_filter), so the time of day never
// takes part and the day is the one in UTC.
if (isDateOnly(valueToCompare)) {
const conversationDay = toUtcDay(conversationValue);
const filterDay = toUtcDay(valueToCompare);
if (conversationDay === null || filterDay === null) return false;
return compareFn(conversationDay, filterDay);
}
const conversationDate = coerceToDate(conversationValue);
const filterDate = coerceToDate(valueToCompare);
if (conversationDate === null || filterDate === null) return false;

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { matchesFilters } from '../filterHelpers';
// SAMPLE PAYLOAD
@@ -1798,4 +1798,87 @@ describe('filterHelpers', () => {
expect(matchesFilters(conversation, filters)).toBe(false);
});
});
// These expectations hold in every timezone. Run the suite under
// TZ=Asia/Kolkata and TZ=America/Los_Angeles to cover browsers ahead of and
// behind UTC, where the boundary used to shift and hide rows.
describe('date-only filter values are compared as whole UTC days', () => {
const buildFilter = (filterOperator, value) => [
{
attribute_key: 'last_activity_at',
filter_operator: filterOperator,
values: [value],
query_operator: 'and',
},
];
const lateInDay = { last_activity_at: 1786132800 }; // 2026-08-07 20:00 UTC
const earlyInDay = { last_activity_at: 1786075200 }; // 2026-08-07 04:00 UTC
it('includes both ends of the day for is_less_than', () => {
expect(
matchesFilters(lateInDay, buildFilter('is_less_than', '2026-08-08'))
).toBe(true);
expect(
matchesFilters(earlyInDay, buildFilter('is_less_than', '2026-08-08'))
).toBe(true);
});
it('includes both ends of the day for is_greater_than', () => {
expect(
matchesFilters(lateInDay, buildFilter('is_greater_than', '2026-08-06'))
).toBe(true);
expect(
matchesFilters(earlyInDay, buildFilter('is_greater_than', '2026-08-06'))
).toBe(true);
});
it('excludes the whole day named by the bound', () => {
expect(
matchesFilters(lateInDay, buildFilter('is_greater_than', '2026-08-07'))
).toBe(false);
expect(
matchesFilters(earlyInDay, buildFilter('is_greater_than', '2026-08-07'))
).toBe(false);
expect(
matchesFilters(lateInDay, buildFilter('is_less_than', '2026-08-07'))
).toBe(false);
expect(
matchesFilters(earlyInDay, buildFilter('is_less_than', '2026-08-07'))
).toBe(false);
});
it('still excludes conversations outside the range', () => {
expect(
matchesFilters(lateInDay, buildFilter('is_less_than', '2026-08-06'))
).toBe(false);
expect(
matchesFilters(earlyInDay, buildFilter('is_greater_than', '2026-08-08'))
).toBe(false);
});
// Date custom attributes are stored as full ISO strings, not date-only ones
it('compares date custom attributes against the same boundary', () => {
const filters = [
{
attribute_key: 'renewal_date',
filter_operator: 'is_greater_than',
values: ['2026-08-07'],
query_operator: 'and',
},
];
expect(
matchesFilters(
{ custom_attributes: { renewal_date: '2026-08-08T00:00:00.000Z' } },
filters
)
).toBe(true);
expect(
matchesFilters(
{ custom_attributes: { renewal_date: '2026-08-07T00:00:00.000Z' } },
filters
)
).toBe(false);
});
});
});