From 2fbcc715cec0961b747ed035f4c52783a0125c52 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:08:10 +0530 Subject: [PATCH] fix: conversations hidden when filtering by a date range (#15385) --- .../conversations/helpers/filterHelpers.js | 44 +++++++++- .../helpers/specs/filterHelpers.spec.js | 85 ++++++++++++++++++- 2 files changed, 126 insertions(+), 3 deletions(-) diff --git a/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js b/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js index 5355f0a7b..bf6b9f05d 100644 --- a/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js +++ b/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js @@ -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; diff --git a/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js b/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js index 1662c9a81..1c355ebea 100644 --- a/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js +++ b/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js @@ -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); + }); + }); });