From 9e35c45f56184c93db10285e3dfdd0e5ce80e832 Mon Sep 17 00:00:00 2001 From: Tan Do Date: Wed, 12 Aug 2026 12:49:23 -0500 Subject: [PATCH 01/12] Add getStrictTimeFromNowString and isEndOfLocalDay date helpers getStrictTimeFromNowString wraps date-fns's formatDistanceToNowStrict with the same getDateLocale() plumbing as the existing getTimeFromNowString, producing a tighter "3 hours" instead of "about 3 hours" for future dates. isEndOfLocalDay is a heuristic: true when a timestamp is within about a minute of local midnight (23:59:xx or 00:00:xx), used to infer whether a due date was snapped to end-of-day rather than genuinely time-specific. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QkUEA9bCk35Y5PdKXsJQsc --- __tests__/helpers/locale/date-helpers.test.ts | 43 +++++++++++++++++-- src/helpers/date-helpers.ts | 17 +++++++- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/__tests__/helpers/locale/date-helpers.test.ts b/__tests__/helpers/locale/date-helpers.test.ts index 4ee12ea4f..88ab90458 100644 --- a/__tests__/helpers/locale/date-helpers.test.ts +++ b/__tests__/helpers/locale/date-helpers.test.ts @@ -4,8 +4,8 @@ import { toDate, daysInMonth, getDayOfWeek, getDayOfWeekLetter, getAbbreviatedDayOfWeek, getDayAndDateAndTimeString, getFullDayAndDateString, getFullDateString, getLongDateString, getShortDateString, getShortestDateString, - getMonthName, getAbbreviatedMonthName, getTimeFromNowString, - getRelativeDateString, getTimeOfDayString, getShortTimeOfDayString, getDayOfMonth } from '../../../src/helpers/date-helpers'; + getMonthName, getAbbreviatedMonthName, getTimeFromNowString, getStrictTimeFromNowString, + getRelativeDateString, getTimeOfDayString, getShortTimeOfDayString, getDayOfMonth, isEndOfLocalDay } from '../../../src/helpers/date-helpers'; import { describe, it } from '@jest/globals'; let mockMDHLanguage = "en"; @@ -244,7 +244,44 @@ describe('Date Helper Tests', () => { }); }); - describe('getRelativeDateString', () => { + describe('getStrictTimeFromNowString', () => { + it('Should return an English strict time string in hours.', () => { + mockMDHLanguage = "en"; + const result = getStrictTimeFromNowString(new Date(new Date().getTime() + 3 * 60 * 60 * 1000 + 5000)); + expect(result).toBe("3 hours"); + }); + it('Should return an English strict time string in minutes.', () => { + mockMDHLanguage = "en"; + const result = getStrictTimeFromNowString(new Date(new Date().getTime() + 45 * 60 * 1000 + 2000)); + expect(result).toBe("45 minutes"); + }); + it('Should return a localized strict time string.', () => { + mockMDHLanguage = "de-DE"; + const result = getStrictTimeFromNowString(new Date(new Date().setDate(new Date().getDate()+1))); + expect(result).toBe("1 Tag"); + }); + }); + + describe('isEndOfLocalDay', () => { + it('Should return true for 23:59.', () => { + const result = isEndOfLocalDay(new Date(2024, 10, 15, 23, 59, 30)); + expect(result).toBe(true); + }); + it('Should return true for 00:00.', () => { + const result = isEndOfLocalDay(new Date(2024, 10, 15, 0, 0, 30)); + expect(result).toBe(true); + }); + it('Should return false for a midday time.', () => { + const result = isEndOfLocalDay(new Date(2024, 10, 15, 12, 0)); + expect(result).toBe(false); + }); + it('Should return false for something not a date at all.', () => { + const result = isEndOfLocalDay("not a date"); + expect(result).toBe(false); + }); + }); + + describe('getRelativeDateString', () => { it('Should return an English time string.', () => { mockMDHLanguage = "en"; const result = getRelativeDateString(new Date(2024, 11, 4, 3, 22), new Date(2024, 11, 5)); diff --git a/src/helpers/date-helpers.ts b/src/helpers/date-helpers.ts index 588f70f2a..c7187b22a 100644 --- a/src/helpers/date-helpers.ts +++ b/src/helpers/date-helpers.ts @@ -1,4 +1,4 @@ -import { add, Duration, isSameDay, sub, Day, parseISO, formatRelative, formatDistanceToNow, differenceInDays } from "date-fns"; +import { add, Duration, isSameDay, sub, Day, parseISO, formatRelative, formatDistanceToNow, formatDistanceToNowStrict, differenceInDays } from "date-fns"; import language from "./language"; import { formatDateForLocale, getDateLocale, getIntlLocale, capitalizeFirstLetterForLocale } from "./locale"; @@ -179,6 +179,13 @@ export function getTimeFromNowString(dateOrDateString: string | Date) { return formatDistanceToNow(date, { locale: getDateLocale() }); } +/** For future dates - a tighter, single-unit string with no "about"/"almost" qualifiers, e.g., "3 hours" */ +export function getStrictTimeFromNowString(dateOrDateString: string | Date) { + const date = toDate(dateOrDateString); + if (!date) { return "" } + return formatDistanceToNowStrict(date, { locale: getDateLocale() }); +} + /** For past dates - e.g., "2 weeks ago" or "yesterday" */ export function getRelativeDateString(dateOrDateString: string | Date, baseDate: Date, capitalize: boolean = true): string { const date = toDate(dateOrDateString); @@ -196,6 +203,14 @@ export function getTimeOfDayString(dateOrDateString: Date | string) { return formatDateForLocale(date, "p"); } +/** Heuristic for "was this snapped to end-of-local-day" - true within about a minute of local + * midnight (23:59:xx or 00:00:xx). A due date genuinely, precisely due at 23:59 is indistinguishable. */ +export function isEndOfLocalDay(dateOrDateString: Date | string): boolean { + const date = toDate(dateOrDateString); + if (!date) { return false; } + return (date.getHours() === 23 && date.getMinutes() === 59) || (date.getHours() === 0 && date.getMinutes() === 0); +} + /** e.g., 12 P (localized, may be 24h) - a time of 00:00:00 is returned as an empty string */ export function getShortTimeOfDayString(dateOrDateString: Date | string) { const date = toDate(dateOrDateString); From c7e877e38783e35ee3f90d6f6ee63d8a37165252 Mon Sep 17 00:00:00 2001 From: Tan Do Date: Wed, 12 Aug 2026 12:49:29 -0500 Subject: [PATCH 02/12] Add due-today-with-time/due-tomorrow-with-time locale strings New locale keys for SingleSurveyTask's upcoming clock-time display, in all 16 supported locales. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QkUEA9bCk35Y5PdKXsJQsc --- locales/de.json | 2 ++ locales/en.json | 2 ++ locales/es.json | 2 ++ locales/fil.json | 2 ++ locales/fr-CA.json | 2 ++ locales/fr.json | 2 ++ locales/it.json | 2 ++ locales/nl.json | 2 ++ locales/pl.json | 2 ++ locales/pt-PT.json | 2 ++ locales/pt.json | 2 ++ locales/ro.json | 2 ++ locales/so.json | 2 ++ locales/sw.json | 2 ++ locales/tl.json | 2 ++ locales/vi.json | 2 ++ 16 files changed, 32 insertions(+) diff --git a/locales/de.json b/locales/de.json index be1f1f827..c7e3595ec 100644 --- a/locales/de.json +++ b/locales/de.json @@ -430,7 +430,9 @@ "due": "Fällig", "due-in": "Fällig in", "due-today": "Heute fällig", + "due-today-with-time": "Heute fällig um {time}", "due-tomorrow": "Morgen fällig", + "due-tomorrow-with-time": "Morgen fällig um {time}", "edit": "Bearbeiten", "ehr-intro-search": "Suchen Sie, indem Sie den Namen Ihres Anbieters oder des Mitgliederportals für Ihren Gesundheitsdienstleister oder Ihre Krankenkasse eingeben.", "elevated-heart-rate-time": "Zeit mit erhöhter Herzfrequenz", diff --git a/locales/en.json b/locales/en.json index c5a3d07dc..7523f4d1c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -430,7 +430,9 @@ "due": "Due", "due-in": "Due in", "due-today": "Due Today", + "due-today-with-time": "Due Today at {time}", "due-tomorrow": "Due Tomorrow", + "due-tomorrow-with-time": "Due Tomorrow at {time}", "edit": "Edit", "ehr-intro-search": "Search by entering the name of your provider or member portal for your healthcare provider or health plan.", "elevated-heart-rate-time": "Elevated Heart Rate Time", diff --git a/locales/es.json b/locales/es.json index ef9fe8428..67ce3d851 100644 --- a/locales/es.json +++ b/locales/es.json @@ -430,7 +430,9 @@ "due": "Vence", "due-in": "Vencido en", "due-today": "Vence hoy", + "due-today-with-time": "Vence hoy a las {time}", "due-tomorrow": "Vence mañana", + "due-tomorrow-with-time": "Vence mañana a las {time}", "edit": "Editar", "ehr-intro-search": "Busca ingresando el nombre de tu proveedor o portal de miembros de tu proveedor de atención médica o plan de salud.", "elevated-heart-rate-time": "Tiempo con Frecuencia Cardíaca Elevada", diff --git a/locales/fil.json b/locales/fil.json index aa9ba6353..23a705998 100644 --- a/locales/fil.json +++ b/locales/fil.json @@ -430,7 +430,9 @@ "due": "Dapat", "due-in": "Dapat sa loob ng", "due-today": "Dapat Ngayon", + "due-today-with-time": "Dapat Ngayon nang {time}", "due-tomorrow": "Dapat Bukas", + "due-tomorrow-with-time": "Dapat Bukas nang {time}", "edit": "I-edit", "ehr-intro-search": "Maghanap sa pamamagitan ng paglalagay ng pangalan ng iyong provider o member portal para sa iyong healthcare provider o health plan.", "elevated-heart-rate-time": "Oras ng Mataas na Heart Rate", diff --git a/locales/fr-CA.json b/locales/fr-CA.json index c23a49e32..190afc1bc 100644 --- a/locales/fr-CA.json +++ b/locales/fr-CA.json @@ -430,7 +430,9 @@ "due": "À faire", "due-in": "À faire dans", "due-today": "À faire aujourd'hui", + "due-today-with-time": "À faire aujourd'hui à {time}", "due-tomorrow": "À faire demain", + "due-tomorrow-with-time": "À faire demain à {time}", "edit": "Modifier", "ehr-intro-search": "Recherchez en saisissant le nom de votre fournisseur ou le portail membre pour votre fournisseur de soins de santé ou régime de santé.", "elevated-heart-rate-time": "Temps de fréquence cardiaque élevée", diff --git a/locales/fr.json b/locales/fr.json index 2fcbf8ba5..c87f3e854 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -430,7 +430,9 @@ "due": "À faire", "due-in": "À faire dans", "due-today": "À faire aujourd'hui", + "due-today-with-time": "À faire aujourd'hui à {time}", "due-tomorrow": "À faire demain", + "due-tomorrow-with-time": "À faire demain à {time}", "edit": "Éditer", "ehr-intro-search": "Recherchez en entrant le nom de votre prestataire ou du portail des membres de votre prestataire de soins de santé ou de votre mutuelle.", "elevated-heart-rate-time": "Temps avec Fréquence Cardiaque Élevée", diff --git a/locales/it.json b/locales/it.json index e33a56c53..86c9a5128 100644 --- a/locales/it.json +++ b/locales/it.json @@ -430,7 +430,9 @@ "due": "Scadenza", "due-in": "Scadenza tra", "due-today": "Scadenza oggi", + "due-today-with-time": "Scadenza oggi alle {time}", "due-tomorrow": "Scadenza domani", + "due-tomorrow-with-time": "Scadenza domani alle {time}", "edit": "Modificare", "ehr-intro-search": "Cerca inserendo il nome del tuo medico o del portale per i membri del tuo fornitore di assistenza sanitaria o del tuo piano sanitario.", "elevated-heart-rate-time": "Tempo con Frequenza Cardiaca Elevata", diff --git a/locales/nl.json b/locales/nl.json index 8ba06eecf..7d54a69e9 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -430,7 +430,9 @@ "due": "Vervallen", "due-in": "Vervalt over", "due-today": "Vandaag vervallen", + "due-today-with-time": "Vandaag vervallen om {time}", "due-tomorrow": "Morgen vervallen", + "due-tomorrow-with-time": "Morgen vervallen om {time}", "edit": "Bewerken", "ehr-intro-search": "Zoek door de naam van uw zorgverlener of ledenportaal voor uw zorgverlener of zorgverzekeraar in te voeren.", "elevated-heart-rate-time": "Tijd met Verhoogde Hartslag", diff --git a/locales/pl.json b/locales/pl.json index 414792771..ed5e3560a 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -430,7 +430,9 @@ "due": "Termin", "due-in": "Termin za", "due-today": "Termin dzisiaj", + "due-today-with-time": "Termin dzisiaj o {time}", "due-tomorrow": "Termin jutro", + "due-tomorrow-with-time": "Termin jutro o {time}", "edit": "Edytować", "ehr-intro-search": "Wyszukaj, wpisując nazwę swojego dostawcy usług medycznych lub portalu członkowskiego dla swojego dostawcy usług medycznych lub planu zdrowotnego.", "elevated-heart-rate-time": "Czas z podwyższoną częstością akcji serca", diff --git a/locales/pt-PT.json b/locales/pt-PT.json index 69cb56f47..ab01dfad6 100644 --- a/locales/pt-PT.json +++ b/locales/pt-PT.json @@ -430,7 +430,9 @@ "due": "Vencimento", "due-in": "Vencimento em", "due-today": "Vence hoje", + "due-today-with-time": "Vence hoje às {time}", "due-tomorrow": "Vence amanhã", + "due-tomorrow-with-time": "Vence amanhã às {time}", "edit": "Editar", "ehr-intro-search": "Pesquise digitando o nome do seu prestador ou o portal de membros do seu prestador de saúde ou do seu plano de saúde.", "elevated-heart-rate-time": "Tempo com frequência cardíaca elevada", diff --git a/locales/pt.json b/locales/pt.json index 3ffe2bdfb..50dc045bd 100644 --- a/locales/pt.json +++ b/locales/pt.json @@ -430,7 +430,9 @@ "due": "Vencimento", "due-in": "Vencimento em", "due-today": "Vencimento hoje", + "due-today-with-time": "Vencimento hoje às {time}", "due-tomorrow": "Vencimento amanhã", + "due-tomorrow-with-time": "Vencimento amanhã às {time}", "edit": "Editar", "ehr-intro-search": "Pesquise digitando o nome do seu provedor ou portal de membros do seu provedor de saúde ou plano de saúde.", "elevated-heart-rate-time": "Tempo com Frequência Cardíaca Elevada", diff --git a/locales/ro.json b/locales/ro.json index ef1183acb..d0469893a 100644 --- a/locales/ro.json +++ b/locales/ro.json @@ -430,7 +430,9 @@ "due": "Scadent", "due-in": "Scadent în", "due-today": "Scadente azi", + "due-today-with-time": "Scadente azi la {time}", "due-tomorrow": "Scadente mâine", + "due-tomorrow-with-time": "Scadente mâine la {time}", "edit": "Editează", "ehr-intro-search": "Caută introducând numele furnizorului tău sau portalul de membru pentru furnizorul tău de servicii medicale sau planul de sănătate.", "elevated-heart-rate-time": "Timp cu ritm cardiac ridicat", diff --git a/locales/so.json b/locales/so.json index 796fdae6d..a795b470c 100644 --- a/locales/so.json +++ b/locales/so.json @@ -430,7 +430,9 @@ "due": "Waa la bixinayaa", "due-in": "Waa la bixinayaa", "due-today": "Maanta waa la bixinayaa", + "due-today-with-time": "Maanta waa la bixinayaa saacadda {time}", "due-tomorrow": "Berri waa la bixinayaa", + "due-tomorrow-with-time": "Berri waa la bixinayaa saacadda {time}", "edit": "Wax ka beddel", "ehr-intro-search": "Raadi adiga oo gelin magaca bixiyahaaga ama barta xubnaha ee bixiyahaaga daryeelka caafimaadka ama qorshaha caafimaadka.", "elevated-heart-rate-time": "Waqtiga Garaaca Wadnaha Sare", diff --git a/locales/sw.json b/locales/sw.json index fd70b17df..9641fe5a8 100644 --- a/locales/sw.json +++ b/locales/sw.json @@ -430,7 +430,9 @@ "due": "Inadaiwa", "due-in": "Inadaiwa kwa", "due-today": "Zinadaiwa Leo", + "due-today-with-time": "Zinadaiwa Leo saa {time}", "due-tomorrow": "Zinadaiwa Kesho", + "due-tomorrow-with-time": "Zinadaiwa Kesho saa {time}", "edit": "Hariri", "ehr-intro-search": "Tafuta kwa kuingiza jina la mtoa huduma wako au tovuti ya mwanachama kwa mtoa huduma wako wa afya au mpango wa afya.", "elevated-heart-rate-time": "Muda wa Mapigo ya Moyo Yaliyopanda", diff --git a/locales/tl.json b/locales/tl.json index d71ce2436..448ba9cba 100644 --- a/locales/tl.json +++ b/locales/tl.json @@ -430,7 +430,9 @@ "due": "Dapat", "due-in": "Dapat sa loob ng", "due-today": "Dapat Ngayon", + "due-today-with-time": "Dapat Ngayon nang {time}", "due-tomorrow": "Dapat Bukas", + "due-tomorrow-with-time": "Dapat Bukas nang {time}", "edit": "I-edit", "ehr-intro-search": "Maghanap sa pamamagitan ng paglalagay ng pangalan ng iyong provider o portal ng miyembro para sa iyong healthcare provider o health plan.", "elevated-heart-rate-time": "Oras ng Mataas na Heart Rate", diff --git a/locales/vi.json b/locales/vi.json index a19326413..d0ceb9fd4 100644 --- a/locales/vi.json +++ b/locales/vi.json @@ -430,7 +430,9 @@ "due": "Đến hạn", "due-in": "Đến hạn trong", "due-today": "Đến hạn hôm nay", + "due-today-with-time": "Đến hạn hôm nay lúc {time}", "due-tomorrow": "Đến hạn ngày mai", + "due-tomorrow-with-time": "Đến hạn ngày mai lúc {time}", "edit": "Chỉnh sửa", "ehr-intro-search": "Tìm kiếm bằng cách nhập tên nhà cung cấp hoặc cổng thành viên cho nhà cung cấp dịch vụ chăm sóc sức khỏe hoặc kế hoạch sức khỏe của bạn.", "elevated-heart-rate-time": "Thời gian nhịp tim tăng cao", From 2c8dc6110f109edc6b7f7cf8caa4855d4b8b428c Mon Sep 17 00:00:00 2001 From: Tan Do Date: Wed, 12 Aug 2026 12:49:39 -0500 Subject: [PATCH 03/12] Show clock time on due dates that aren't end-of-day Due dates that fall today or tomorrow are ambiguous on their own (a task "due tomorrow" could mean anywhere from a few minutes to nearly 48 hours away once snapped to end-of-day). Append the local clock time to the due-today/due-tomorrow labels, e.g. "Due Today at 1:00 AM", so the remaining time is unambiguous. The SurveyTask SDK type carries no precision flag, so day-granular due dates (snapped to local end-of-day) are inferred heuristically via isEndOfLocalDay and rendered without a clock time - the time would just be an artifact of the snap. Everything else (a legacy pre-snap task, a genuinely time-specific due date, or a traveling participant whose local midnight has shifted) is treated as effectively precise and rendered with one, using getStrictTimeFromNowString for the "due in N" case to avoid vague qualifiers like "about". Also fixes a bug in the precise-time branch: overdue was compared against startOfToday() instead of the current moment, so a task due earlier today read as "Due Today at 9:00 AM" instead of "Overdue". Adds Storybook stories covering both due-today/due-tomorrow-with-time cases, the day-granular overdue case, the effectively-precise overdue case, and the effectively-precise "due in N days" case. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QkUEA9bCk35Y5PdKXsJQsc --- .../SingleSurveyTask.stories.tsx | 96 +++++++++++++++++++ .../SingleSurveyTask/SingleSurveyTask.tsx | 42 +++++--- 2 files changed, 127 insertions(+), 11 deletions(-) diff --git a/src/components/presentational/SingleSurveyTask/SingleSurveyTask.stories.tsx b/src/components/presentational/SingleSurveyTask/SingleSurveyTask.stories.tsx index cace82775..34b73fa22 100644 --- a/src/components/presentational/SingleSurveyTask/SingleSurveyTask.stories.tsx +++ b/src/components/presentational/SingleSurveyTask/SingleSurveyTask.stories.tsx @@ -88,4 +88,100 @@ export const Default = { } }, render: render +}; + +const dueLaterToday = () => { + const dueDate = new Date(); + dueDate.setHours(23, 0, 0, 0); + return dueDate.getTime(); +}; + +const dueEarlyTomorrow = () => { + const dueDate = new Date(); + dueDate.setDate(dueDate.getDate() + 1); + dueDate.setHours(1, 0, 0, 0); + return dueDate.getTime(); +}; + +export const DueTodayWithTime = { + args: { + ...Default.args, + name: 'Survey Name', + description: 'This is the survey description.', + status: 'incomplete', + dueDate: dueLaterToday() + }, + argTypes: Default.argTypes, + render: render +}; + +export const DueTomorrowWithTime = { + args: { + ...Default.args, + name: 'Survey Name', + description: 'This is the survey description.', + status: 'incomplete', + dueDate: dueEarlyTomorrow() + }, + argTypes: Default.argTypes, + render: render +}; + +// Snapped to end-of-local-day (23:59), already past - the day-granular overdue case. +const dueYesterdayEndOfDay = () => { + const dueDate = new Date(); + dueDate.setDate(dueDate.getDate() - 1); + dueDate.setHours(23, 59, 0, 0); + return dueDate.getTime(); +}; + +export const OverdueDayGranular = { + args: { + ...Default.args, + name: 'Survey Name', + description: 'This is the survey description.', + status: 'incomplete', + dueDate: dueYesterdayEndOfDay() + }, + argTypes: Default.argTypes, + render: render +}; + +// Non-midnight time, earlier today - the effectively-precise overdue case. +const dueEarlierToday = () => { + const dueDate = new Date(); + dueDate.setHours(dueDate.getHours() - 3); + return dueDate.getTime(); +}; + +export const OverdueEarlierToday = { + args: { + ...Default.args, + name: 'Survey Name', + description: 'This is the survey description.', + status: 'incomplete', + dueDate: dueEarlierToday() + }, + argTypes: Default.argTypes, + render: render +}; + +// Several days out, non-midnight time - the effectively-precise "due in N" case. +const dueInSeveralDays = () => { + const dueDate = new Date(); + dueDate.setDate(dueDate.getDate() + 3); + dueDate.setHours(14, 0, 0, 0); + return dueDate.getTime(); +}; + +export const DueInDays = { + args: { + ...Default.args, + name: 'Survey Name', + description: 'This is the survey description.', + status: 'incomplete', + dueDate: dueInSeveralDays() + }, + argTypes: Default.argTypes, + render: render }; \ No newline at end of file diff --git a/src/components/presentational/SingleSurveyTask/SingleSurveyTask.tsx b/src/components/presentational/SingleSurveyTask/SingleSurveyTask.tsx index b833a2dc8..069adba8c 100644 --- a/src/components/presentational/SingleSurveyTask/SingleSurveyTask.tsx +++ b/src/components/presentational/SingleSurveyTask/SingleSurveyTask.tsx @@ -13,7 +13,7 @@ import checkMark from '../../../assets/greenCheck.svg'; import Action from '../Action'; import LoadingIndicator from '../LoadingIndicator'; import { noop } from '../../../helpers/functions'; -import { getTimeFromNowString, getRelativeDateString } from "../../../helpers/date-helpers"; +import { getTimeFromNowString, getStrictTimeFromNowString, getRelativeDateString, getTimeOfDayString, isEndOfLocalDay } from "../../../helpers/date-helpers"; export type SingleSurveyTaskVariant = 'default' | 'expanded'; @@ -33,6 +33,7 @@ export default function (props: SingleSurveyTaskProps) { const layoutContext = useContext(LayoutContext); const getDueDate = () => { + let now = new Date(); let today = startOfToday(); let tomorrow = add(new Date(today), { days: 1 }); let dueDate = parseISO(props.task.dueDate ?? ''); @@ -40,17 +41,36 @@ export default function (props: SingleSurveyTaskProps) { let dueDateClasses: string[] = ['due-date']; let dueDateString: string; - if (isSameDay(dueDate, tomorrow)) { - dueDateString = language('due-tomorrow'); - dueDateClasses.push('warning'); - } else if (isSameDay(dueDate, today)) { - dueDateString = language('due-today'); - dueDateClasses.push('warning'); - } else if (isAfter(today, dueDate)) { - dueDateString = language('overdue'); - dueDateClasses.push('danger'); + // No precision flag on the SDK type, so infer day-granular vs. precise from the timestamp itself. + if (isEndOfLocalDay(dueDate)) { + // Day-granular: omit the clock time, it's just an artifact of the snap. + if (isSameDay(dueDate, tomorrow)) { + dueDateString = language('due-tomorrow'); + dueDateClasses.push('warning'); + } else if (isSameDay(dueDate, today)) { + dueDateString = language('due-today'); + dueDateClasses.push('warning'); + } else if (isAfter(today, dueDate)) { + dueDateString = language('overdue'); + dueDateClasses.push('danger'); + } else { + dueDateString = language('due-in') + ' ' + getTimeFromNowString(dueDate); + } } else { - dueDateString = language('due-in') + ' ' + getTimeFromNowString(dueDate); + // Effectively precise: show the clock time, and compare against now (not startOfToday()) + // so a task due earlier today reads as overdue instead of "Due Today at 9:00 AM". + if (isAfter(now, dueDate)) { + dueDateString = language('overdue'); + dueDateClasses.push('danger'); + } else if (isSameDay(dueDate, tomorrow)) { + dueDateString = language('due-tomorrow-with-time', undefined, { time: getTimeOfDayString(dueDate) }); + dueDateClasses.push('warning'); + } else if (isSameDay(dueDate, today)) { + dueDateString = language('due-today-with-time', undefined, { time: getTimeOfDayString(dueDate) }); + dueDateClasses.push('warning'); + } else { + dueDateString = language('due-in') + ' ' + getStrictTimeFromNowString(dueDate); + } } return
{dueDateString}
; From 71e2e74ce2c8b1820a56b048c3cb710ff6bedf89 Mon Sep 17 00:00:00 2001 From: Tan Do Date: Thu, 13 Aug 2026 05:28:42 -0500 Subject: [PATCH 04/12] 4.2.1-auto-closing-tasks-2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f40ef523f..1a01eb2f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@careevolution/mydatahelps-ui", - "version": "4.2.1", + "version": "4.2.1-auto-closing-tasks-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@careevolution/mydatahelps-ui", - "version": "4.2.1", + "version": "4.2.1-auto-closing-tasks-2", "license": "MIT", "dependencies": { "@emotion/react": "11.14.0", diff --git a/package.json b/package.json index 795601045..1ee838ea9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@careevolution/mydatahelps-ui", - "version": "4.2.1", + "version": "4.2.1-auto-closing-tasks-2", "description": "MyDataHelps UI Library", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", From 333fc7b5acf2993a1596aac31946d89ca0531100 Mon Sep 17 00:00:00 2001 From: Tan Do Date: Thu, 1 Oct 2026 03:49:50 -0500 Subject: [PATCH 05/12] Drive SingleSurveyTask due-date display from preciseDueDate Co-Authored-By: Claude Opus 5.5 --- .../SingleSurveyTask.test.tsx | 63 +++++++++++++++++++ .../SingleSurveyTask/SingleSurveyTask.tsx | 36 +++++------ 2 files changed, 80 insertions(+), 19 deletions(-) create mode 100644 __tests__/components/presentational/SingleSurveyTask/SingleSurveyTask.test.tsx diff --git a/__tests__/components/presentational/SingleSurveyTask/SingleSurveyTask.test.tsx b/__tests__/components/presentational/SingleSurveyTask/SingleSurveyTask.test.tsx new file mode 100644 index 000000000..142786f85 --- /dev/null +++ b/__tests__/components/presentational/SingleSurveyTask/SingleSurveyTask.test.tsx @@ -0,0 +1,63 @@ +/** + * @jest-environment jsdom + */ +import React from 'react'; +import { render } from '@testing-library/react'; +import { SurveyTask } from '@careevolution/mydatahelps-js'; +import SingleSurveyTask from '../../../../src/components/presentational/SingleSurveyTask/SingleSurveyTask'; + +// The helpers barrel pulls in langchain, which needs TextEncoder (absent from jsdom). +jest.mock('../../../../src/helpers/AIAssistant', () => ({})); +jest.mock('../../../../src/assets/greenCheck.svg', () => 'greenCheck.svg'); + +describe('SingleSurveyTask Component Tests', () => { + const now = new Date(2026, 9, 1, 16, 0); + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(now); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const renderDueDate = (dueDate: Date, preciseDueDate?: boolean): string => { + const task = { + surveyDisplayName: 'Survey Name', + status: 'incomplete', + dueDate: dueDate.toISOString(), + preciseDueDate + } as SurveyTask; + const { container } = render( { }} />); + return container.querySelector('.due-date')?.textContent ?? ''; + }; + + describe('Day-granular due dates', () => { + it('Should show Due Today for a legacy task due earlier today.', () => { + expect(renderDueDate(new Date(2026, 9, 1, 14, 37))).toBe('Due Today'); + }); + + it('Should show Due Tomorrow without a time for a legacy task due tomorrow.', () => { + expect(renderDueDate(new Date(2026, 9, 2, 10, 15))).toBe('Due Tomorrow'); + }); + + it('Should show Due Today when preciseDueDate is false.', () => { + expect(renderDueDate(new Date(2026, 9, 1, 14, 37), false)).toBe('Due Today'); + }); + }); + + describe('Precise due dates', () => { + it('Should show Overdue for a task due earlier today.', () => { + expect(renderDueDate(new Date(2026, 9, 1, 14, 0), true)).toBe('Overdue'); + }); + + it('Should show Due Today with the time for a task due later today.', () => { + expect(renderDueDate(new Date(2026, 9, 1, 18, 30), true)).toBe('Due Today at 6:30 PM'); + }); + + it('Should show Due Tomorrow with the time for a task due tomorrow.', () => { + expect(renderDueDate(new Date(2026, 9, 2, 9, 0), true)).toBe('Due Tomorrow at 9:00 AM'); + }); + }); +}); diff --git a/src/components/presentational/SingleSurveyTask/SingleSurveyTask.tsx b/src/components/presentational/SingleSurveyTask/SingleSurveyTask.tsx index 069adba8c..2318f44b4 100644 --- a/src/components/presentational/SingleSurveyTask/SingleSurveyTask.tsx +++ b/src/components/presentational/SingleSurveyTask/SingleSurveyTask.tsx @@ -13,7 +13,7 @@ import checkMark from '../../../assets/greenCheck.svg'; import Action from '../Action'; import LoadingIndicator from '../LoadingIndicator'; import { noop } from '../../../helpers/functions'; -import { getTimeFromNowString, getStrictTimeFromNowString, getRelativeDateString, getTimeOfDayString, isEndOfLocalDay } from "../../../helpers/date-helpers"; +import { getTimeFromNowString, getStrictTimeFromNowString, getRelativeDateString, getTimeOfDayString } from "../../../helpers/date-helpers"; export type SingleSurveyTaskVariant = 'default' | 'expanded'; @@ -41,24 +41,9 @@ export default function (props: SingleSurveyTaskProps) { let dueDateClasses: string[] = ['due-date']; let dueDateString: string; - // No precision flag on the SDK type, so infer day-granular vs. precise from the timestamp itself. - if (isEndOfLocalDay(dueDate)) { - // Day-granular: omit the clock time, it's just an artifact of the snap. - if (isSameDay(dueDate, tomorrow)) { - dueDateString = language('due-tomorrow'); - dueDateClasses.push('warning'); - } else if (isSameDay(dueDate, today)) { - dueDateString = language('due-today'); - dueDateClasses.push('warning'); - } else if (isAfter(today, dueDate)) { - dueDateString = language('overdue'); - dueDateClasses.push('danger'); - } else { - dueDateString = language('due-in') + ' ' + getTimeFromNowString(dueDate); - } - } else { - // Effectively precise: show the clock time, and compare against now (not startOfToday()) - // so a task due earlier today reads as overdue instead of "Due Today at 9:00 AM". + if (props.task.preciseDueDate === true) { + // Compare against now (not startOfToday()) so a task due earlier today reads as overdue + // instead of "Due Today at 9:00 AM". if (isAfter(now, dueDate)) { dueDateString = language('overdue'); dueDateClasses.push('danger'); @@ -71,6 +56,19 @@ export default function (props: SingleSurveyTaskProps) { } else { dueDateString = language('due-in') + ' ' + getStrictTimeFromNowString(dueDate); } + } else { + if (isSameDay(dueDate, tomorrow)) { + dueDateString = language('due-tomorrow'); + dueDateClasses.push('warning'); + } else if (isSameDay(dueDate, today)) { + dueDateString = language('due-today'); + dueDateClasses.push('warning'); + } else if (isAfter(today, dueDate)) { + dueDateString = language('overdue'); + dueDateClasses.push('danger'); + } else { + dueDateString = language('due-in') + ' ' + getTimeFromNowString(dueDate); + } } return
{dueDateString}
; From 0cd66093907c4d168552d215a90a524c842688d4 Mon Sep 17 00:00:00 2001 From: Tan Do Date: Thu, 1 Oct 2026 03:49:51 -0500 Subject: [PATCH 06/12] Remove isEndOfLocalDay Co-Authored-By: Claude Opus 5.5 --- __tests__/helpers/locale/date-helpers.test.ts | 23 ++----------------- src/helpers/date-helpers.ts | 8 ------- 2 files changed, 2 insertions(+), 29 deletions(-) diff --git a/__tests__/helpers/locale/date-helpers.test.ts b/__tests__/helpers/locale/date-helpers.test.ts index 88ab90458..e795e5b19 100644 --- a/__tests__/helpers/locale/date-helpers.test.ts +++ b/__tests__/helpers/locale/date-helpers.test.ts @@ -5,7 +5,7 @@ import { toDate, daysInMonth, getDayOfWeek, getDayOfWeekLetter, getAbbreviatedDayOfWeek, getDayAndDateAndTimeString, getFullDayAndDateString, getFullDateString, getLongDateString, getShortDateString, getShortestDateString, getMonthName, getAbbreviatedMonthName, getTimeFromNowString, getStrictTimeFromNowString, - getRelativeDateString, getTimeOfDayString, getShortTimeOfDayString, getDayOfMonth, isEndOfLocalDay } from '../../../src/helpers/date-helpers'; + getRelativeDateString, getTimeOfDayString, getShortTimeOfDayString, getDayOfMonth } from '../../../src/helpers/date-helpers'; import { describe, it } from '@jest/globals'; let mockMDHLanguage = "en"; @@ -262,26 +262,7 @@ describe('Date Helper Tests', () => { }); }); - describe('isEndOfLocalDay', () => { - it('Should return true for 23:59.', () => { - const result = isEndOfLocalDay(new Date(2024, 10, 15, 23, 59, 30)); - expect(result).toBe(true); - }); - it('Should return true for 00:00.', () => { - const result = isEndOfLocalDay(new Date(2024, 10, 15, 0, 0, 30)); - expect(result).toBe(true); - }); - it('Should return false for a midday time.', () => { - const result = isEndOfLocalDay(new Date(2024, 10, 15, 12, 0)); - expect(result).toBe(false); - }); - it('Should return false for something not a date at all.', () => { - const result = isEndOfLocalDay("not a date"); - expect(result).toBe(false); - }); - }); - - describe('getRelativeDateString', () => { + describe('getRelativeDateString', () => { it('Should return an English time string.', () => { mockMDHLanguage = "en"; const result = getRelativeDateString(new Date(2024, 11, 4, 3, 22), new Date(2024, 11, 5)); diff --git a/src/helpers/date-helpers.ts b/src/helpers/date-helpers.ts index c7187b22a..3ecc3661b 100644 --- a/src/helpers/date-helpers.ts +++ b/src/helpers/date-helpers.ts @@ -203,14 +203,6 @@ export function getTimeOfDayString(dateOrDateString: Date | string) { return formatDateForLocale(date, "p"); } -/** Heuristic for "was this snapped to end-of-local-day" - true within about a minute of local - * midnight (23:59:xx or 00:00:xx). A due date genuinely, precisely due at 23:59 is indistinguishable. */ -export function isEndOfLocalDay(dateOrDateString: Date | string): boolean { - const date = toDate(dateOrDateString); - if (!date) { return false; } - return (date.getHours() === 23 && date.getMinutes() === 59) || (date.getHours() === 0 && date.getMinutes() === 0); -} - /** e.g., 12 P (localized, may be 24h) - a time of 00:00:00 is returned as an empty string */ export function getShortTimeOfDayString(dateOrDateString: Date | string) { const date = toDate(dateOrDateString); From 376b5f620fe3eb4c4a5e302ff94dbe15d0a0fcd5 Mon Sep 17 00:00:00 2001 From: Tan Do Date: Thu, 1 Oct 2026 03:49:51 -0500 Subject: [PATCH 07/12] Mark precise SingleSurveyTask stories and add a legacy story Co-Authored-By: Claude Opus 5.5 --- .../SingleSurveyTask.stories.tsx | 41 +++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/src/components/presentational/SingleSurveyTask/SingleSurveyTask.stories.tsx b/src/components/presentational/SingleSurveyTask/SingleSurveyTask.stories.tsx index 34b73fa22..3ad6146ce 100644 --- a/src/components/presentational/SingleSurveyTask/SingleSurveyTask.stories.tsx +++ b/src/components/presentational/SingleSurveyTask/SingleSurveyTask.stories.tsx @@ -21,6 +21,7 @@ interface SingleSurveyTaskStoryArgs { endDate?: number; surveyActive?: boolean; surveyBlocked?: boolean; + preciseDueDate?: boolean; } const render = (args: SingleSurveyTaskStoryArgs) => { @@ -30,7 +31,8 @@ const render = (args: SingleSurveyTaskStoryArgs) => { status: args.status, dueDate: args.dueDate ? new Date(args.dueDate).toISOString() : undefined, endDate: args.endDate ? new Date(args.endDate).toISOString() : undefined, - hasSavedProgress: args.hasSavedProgress + hasSavedProgress: args.hasSavedProgress, + preciseDueDate: args.preciseDueDate } as SurveyTask; return @@ -109,7 +111,8 @@ export const DueTodayWithTime = { name: 'Survey Name', description: 'This is the survey description.', status: 'incomplete', - dueDate: dueLaterToday() + dueDate: dueLaterToday(), + preciseDueDate: true }, argTypes: Default.argTypes, render: render @@ -121,13 +124,14 @@ export const DueTomorrowWithTime = { name: 'Survey Name', description: 'This is the survey description.', status: 'incomplete', - dueDate: dueEarlyTomorrow() + dueDate: dueEarlyTomorrow(), + preciseDueDate: true }, argTypes: Default.argTypes, render: render }; -// Snapped to end-of-local-day (23:59), already past - the day-granular overdue case. +// No preciseDueDate, already past - the day-granular overdue case. const dueYesterdayEndOfDay = () => { const dueDate = new Date(); dueDate.setDate(dueDate.getDate() - 1); @@ -147,7 +151,7 @@ export const OverdueDayGranular = { render: render }; -// Non-midnight time, earlier today - the effectively-precise overdue case. +// Precise, earlier today - overdue rather than "Due Today at