diff --git a/src/components/EditSections/EditUserInfo/EditUserInfo.js b/src/components/EditSections/EditUserInfo/EditUserInfo.js
index 36e1f8cfa..7ef58d5e6 100644
--- a/src/components/EditSections/EditUserInfo/EditUserInfo.js
+++ b/src/components/EditSections/EditUserInfo/EditUserInfo.js
@@ -1,5 +1,4 @@
import get from 'lodash/get';
-import moment from 'moment-timezone';
import PropTypes from 'prop-types';
import React from 'react';
import { Field } from 'react-final-form';
@@ -89,7 +88,7 @@ class EditUserInfo extends React.Component {
setRecalculatedExpirationDate = (startCalcToday) => {
const { form: { change } } = this.props;
- const recalculatedDate = this.calculateNewExpirationDate(startCalcToday).format('L');
+ const recalculatedDate = this.calculateNewExpirationDate(startCalcToday).format('YYYY-MM-DD');
const parsedRecalculatedDate = this.parseExpirationDate(recalculatedDate);
change('expirationDate', parsedRecalculatedDate);
@@ -102,16 +101,36 @@ class EditUserInfo extends React.Component {
this.setState({ showUserTypeModal: false });
}
+ /**
+ * Calculates a new expiration date by adding the patron group's offset days
+ * to either today's date or the existing expiration date.
+ *
+ * All calculations are performed in the tenant's timezone to ensure
+ * consistent date arithmetic regardless of the user's browser timezone.
+ *
+ * @param {boolean} startCalcToday - If true, calculate from today; if false, from existing expiration date
+ * @returns {object} dayjs object representing the new expiration date in tenant timezone
+ */
calculateNewExpirationDate = (startCalcToday) => {
- const { initialValues } = this.props;
- const now = Date.now();
- const expirationDate = initialValues.expirationDate ? new Date(initialValues.expirationDate) : now;
+ const {
+ initialValues,
+ stripes,
+ } = this.props;
+ const {
+ timezone = 'UTC',
+ } = stripes;
+
+ // Use tenant timezone for date calculations to ensure consistency
+ const now = dayjs().tz(timezone);
+ const expirationDate = initialValues.expirationDate ? dayjs.tz(initialValues.expirationDate, timezone) : now;
const offsetOfSelectedPatronGroup = this.state.selectedPatronGroup ? this.getPatronGroupOffset() : '';
- const shouldRecalculateFromToday = startCalcToday || initialValues.expirationDate === undefined || expirationDate <= now;
- const baseDate = shouldRecalculateFromToday ? dayjs() : dayjs(expirationDate);
+ const shouldRecalculateFromToday = startCalcToday || initialValues.expirationDate === undefined || expirationDate.isSameOrBefore(now);
+ const baseDate = shouldRecalculateFromToday ? now : expirationDate;
+
+ const result = baseDate.add(offsetOfSelectedPatronGroup, 'd');
- return baseDate.add(offsetOfSelectedPatronGroup, 'd');
+ return result;
}
getPatronGroupOffset = () => {
@@ -119,16 +138,65 @@ class EditUserInfo extends React.Component {
return get(selectedPatronGroup, 'expirationOffsetInDays', '');
};
+ /**
+ * Parses expiration date input and converts to UTC ISO string for consistent storage.
+ *
+ * This function handles both:
+ * 1. ISO date strings (YYYY-MM-DD) from recalculation - converted to UTC end-of-day
+ * 2. Date strings from manual datepicker input - parsed and converted to UTC end-of-day
+ *
+ * We store dates as UTC end-of-day to ensure the user gets the full day of access.
+ * The timezone-aware comparison logic in getNowAndExpirationEndOfDayInTenantTz
+ * handles proper expiration timing based on the tenant's timezone.
+ * This ensures consistent display in the datepicker regardless of timezone.
+ *
+ * @param {string} expirationDate - Date input (ISO string from calculation or datepicker input)
+ * @returns {string} UTC ISO string for storage (end of day)
+ */
parseExpirationDate = (expirationDate) => {
- const {
- stripes: {
- timezone,
- },
- } = this.props;
+ if (!expirationDate) return expirationDate;
- return expirationDate
- ? moment.tz(expirationDate, timezone).endOf('day').toDate().toISOString()
- : expirationDate;
+ const dateToStore = dayjs.utc(expirationDate).endOf('day').toISOString();
+
+ return dateToStore;
+ };
+
+ /**
+ * Creates timezone-aware date objects for expiration comparison.
+ *
+ * This function extracts just the date part (YYYY-MM-DD) from the UTC stored expiration date because:
+ * 1. Dates are stored as UTC end-of-day (e.g., "2025-07-31T23:59:59.999Z") and not end-of-day for some old data
+ * 2. We need to compare the expiration date against the current time in the tenant's timezone
+ * 3. A user should expire at midnight in their local timezone, not at a UTC-based time
+ *
+ * @param {string} expDate - UTC ISO string of the expiration date
+ * @returns {Object} Object with nowInTenantTz and expirationEndOfDayInTenantTz dayjs instances
+ */
+ getNowAndExpirationEndOfDayInTenantTz = (expDate) => {
+ const { stripes } = this.props;
+ const timezone = stripes.timezone || 'UTC';
+
+ // Use `dayjs.utc` rather than `dayjs` to avoid month and day shifting due to local timezone.
+ // For example, if timezone is UTC+3 and the `expirationDate` is 2025-07-31T23:59:59.999Z,
+ // `dayjs('2025-07-31T23:59:59.999Z')` will be parsed as 2025-08-01T02:59:59+03:00
+ // with the 3 hours offset applied (month and day are changed).
+ // The `dayjs.utc` ensures that we get the exact date and time as stored in the database without any timezone adjustments.
+ // `dayjs.utc('2025-07-31T23:59:59.999Z')` returns 2025-07-31T23:59:59Z.
+ const expirationDate = dayjs.utc(expDate);
+ // Format using `.format('YYYY-MM-DD')` to get just the date part, since the time can be incorrect for some old data.
+ const expirationDateString = expirationDate.format('YYYY-MM-DD');
+
+ // Create end of day in tenant timezone for that date. Use `timezone`, otherwise when switching the timezone in the settings,
+ // it will not be taken into account in the calculations.
+ const expirationEndOfDayInTenantTz = dayjs.tz(expirationDateString, timezone).endOf('day');
+ // Same here, if you switch the timezone in the settings, `dayjs()` without timezone specified will use your local timezone,
+ // not the one selected in the settings.
+ const nowInTenantTz = dayjs().tz(timezone);
+
+ return {
+ nowInTenantTz,
+ expirationEndOfDayInTenantTz,
+ };
};
render() {
@@ -151,16 +219,21 @@ class EditUserInfo extends React.Component {
const { barcode } = initialValues;
const isUserExpired = () => {
- const expirationDate = new Date(initialValues.expirationDate);
- const now = Date.now();
- return expirationDate <= now;
+ if (!initialValues.expirationDate) return false;
+
+ const { nowInTenantTz, expirationEndOfDayInTenantTz } = this.getNowAndExpirationEndOfDayInTenantTz(initialValues.expirationDate);
+
+ return expirationEndOfDayInTenantTz.isBefore(nowInTenantTz);
};
const willUserExtend = () => {
const expirationDate = form.getFieldState('expirationDate')?.value ?? '';
- const currentExpirationDate = new Date(expirationDate);
- const now = Date.now();
- return currentExpirationDate >= now;
+
+ if (!expirationDate) return false;
+
+ const { nowInTenantTz, expirationEndOfDayInTenantTz } = this.getNowAndExpirationEndOfDayInTenantTz(expirationDate);
+
+ return expirationEndOfDayInTenantTz.isAfter(nowInTenantTz);
};
const isStatusFieldDisabled = () => {
diff --git a/src/components/EditSections/EditUserInfo/EditUserInfo.test.js b/src/components/EditSections/EditUserInfo/EditUserInfo.test.js
index 4fd7697f7..51bf1232b 100644
--- a/src/components/EditSections/EditUserInfo/EditUserInfo.test.js
+++ b/src/components/EditSections/EditUserInfo/EditUserInfo.test.js
@@ -73,6 +73,22 @@ jest.mock('./components', () => ({
const onSubmit = jest.fn();
+const getDayjsMock = (date) => {
+ const dayjsObj = jest.requireActual('@folio/stripes/components').dayjs(date);
+
+ return {
+ ...dayjsObj,
+ format: jest.fn().mockReturnValue('05/04/2027'),
+ add: jest.fn().mockReturnThis(),
+ tz: jest.fn().mockReturnThis(),
+ endOf: jest.fn().mockReturnThis(),
+ toISOString: jest.fn().mockReturnValue('2027-05-04T03:59:59.999Z'),
+ isSameOrBefore: jest.fn().mockReturnValue(false),
+ isBefore: jest.fn().mockReturnValue(true),
+ isAfter: jest.fn().mockReturnValue(true),
+ };
+};
+
const arrayMutators = {
concat: jest.fn(),
move: jest.fn(),
@@ -169,17 +185,12 @@ const props = {
describe('Render Edit User Information component', () => {
beforeEach(() => {
+ jest.clearAllMocks();
isConsortiumEnabled.mockClear().mockReturnValue(false);
- dayjs.mockImplementation((date) => {
- const dayjsObj = jest.requireActual('@folio/stripes/components').dayjs(date);
-
- return {
- ...dayjsObj,
- format: jest.fn().mockReturnValue('05/04/2027'),
- add: jest.fn().mockReturnThis(),
- };
- });
+ dayjs.mockImplementation(getDayjsMock);
+ dayjs.tz = jest.fn(getDayjsMock);
+ dayjs.utc = jest.fn(getDayjsMock);
});
it('Must be rendered', () => {
@@ -194,21 +205,11 @@ describe('Render Edit User Information component', () => {
describe('when "Reset" (recalculate) button is clicked', () => {
it('should change expiration date', async () => {
- dayjs.mockImplementation((date) => {
- const dayjsObj = jest.requireActual('@folio/stripes/components').dayjs(date);
-
- return {
- ...dayjsObj,
- format: jest.fn().mockReturnValue('06/05/2025'),
- add: jest.fn().mockReturnThis(),
- };
- });
-
renderEditUserInfo(props);
await act(() => userEvent.click(screen.getByText('ui-users.information.recalculate.expirationDate')));
- expect(changeMock).toHaveBeenCalledWith('expirationDate', '2025-06-05T03:59:59.999Z');
+ expect(changeMock).toHaveBeenCalledWith('expirationDate', '2027-05-04T03:59:59.999Z');
});
});
@@ -347,4 +348,560 @@ describe('Render Edit User Information component', () => {
expect(screen.queryByText('Profile Picture')).not.toBeInTheDocument();
});
});
+
+ describe('parseExpirationDate', () => {
+ describe('when recalculating expiration date by hitting the "Reset" button', () => {
+ it('should convert dates to UTC end-of-day ', async () => {
+ const mockUtcDayjs = {
+ ...getDayjsMock(),
+ endOf: jest.fn().mockReturnThis(),
+ toISOString: jest.fn().mockReturnValue('2025-07-31T23:59:59.999Z')
+ };
+
+ dayjs.utc = jest.fn().mockReturnValue(mockUtcDayjs);
+
+ renderEditUserInfo(props);
+
+ await act(() => userEvent.click(screen.getByText('ui-users.information.recalculate.expirationDate')));
+ await userEvent.click(screen.getByText('ui-users.information.recalculate.modal.button'));
+
+ expect(dayjs.utc).toHaveBeenCalled();
+ expect(mockUtcDayjs.endOf).toHaveBeenCalledWith('day');
+ expect(mockUtcDayjs.toISOString).toHaveBeenCalled();
+ });
+
+ it('should process recalculated dates with correct format', async () => {
+ const mockCalculatedDate = {
+ ...getDayjsMock(),
+ format: jest.fn().mockReturnValue('2025-07-31'),
+ endOf: jest.fn().mockReturnThis(),
+ isSameOrBefore: jest.fn().mockReturnValue(false),
+ };
+
+ const mockUtcDayjs = {
+ ...getDayjsMock(),
+ endOf: jest.fn().mockReturnThis(),
+ toISOString: jest.fn().mockReturnValue('2025-07-31T23:59:59.999Z')
+ };
+
+ dayjs.mockImplementation(() => mockCalculatedDate);
+ dayjs.tz = jest.fn().mockReturnValue(mockCalculatedDate);
+ dayjs.utc = jest.fn().mockReturnValue(mockUtcDayjs);
+
+ renderEditUserInfo(props);
+
+ await act(() => userEvent.click(screen.getByText('ui-users.information.recalculate.expirationDate')));
+ await userEvent.click(screen.getByText('ui-users.information.recalculate.modal.button'));
+
+ expect(mockCalculatedDate.format).toHaveBeenCalledWith('YYYY-MM-DD');
+ expect(dayjs.utc).toHaveBeenCalledWith('2025-07-31');
+ expect(mockUtcDayjs.endOf).toHaveBeenCalledWith('day');
+ expect(changeMock).toHaveBeenCalledWith('expirationDate', '2025-07-31T23:59:59.999Z');
+ });
+ });
+ });
+
+ describe('getNowAndExpirationEndOfDayInTenantTz', () => {
+ it('should handle timezone-aware expiration comparison for expired users', () => {
+ const expiredDate = '2023-01-15T23:59:59.999Z';
+
+ const mockUtcDayjs = {
+ ...getDayjsMock(),
+ format: jest.fn().mockReturnValue('2023-01-15')
+ };
+
+ const mockExpirationEndOfDay = {
+ ...getDayjsMock(),
+ isBefore: jest.fn().mockReturnValue(true),
+ isAfter: jest.fn().mockReturnValue(false),
+ endOf: jest.fn().mockReturnThis()
+ };
+
+ const mockNowInTz = {
+ ...getDayjsMock(),
+ isBefore: jest.fn().mockReturnValue(false),
+ isAfter: jest.fn().mockReturnValue(true)
+ };
+
+ const mockDayjsBase = {
+ ...getDayjsMock(),
+ tz: jest.fn().mockReturnValue(mockNowInTz)
+ };
+
+ dayjs.utc = jest.fn().mockReturnValue(mockUtcDayjs);
+ dayjs.tz = jest.fn().mockReturnValue(mockExpirationEndOfDay);
+ dayjs.mockReturnValue(mockDayjsBase);
+
+ const propsWithExpiredUser = {
+ ...props,
+ initialValues: {
+ ...props.initialValues,
+ expirationDate: expiredDate,
+ }
+ };
+
+ renderEditUserInfo(propsWithExpiredUser);
+
+ expect(dayjs.utc).toHaveBeenCalledWith(expiredDate);
+ expect(mockUtcDayjs.format).toHaveBeenCalledWith('YYYY-MM-DD');
+ expect(dayjs.tz).toHaveBeenCalledWith('2023-01-15', 'America/New_York');
+ expect(screen.getByText('ui-users.errors.userExpired')).toBeInTheDocument();
+ });
+
+ it('should handle timezone-aware expiration comparison for active users', () => {
+ const futureDate = '2026-12-31T23:59:59.999Z';
+
+ const mockUtcDayjs = {
+ ...getDayjsMock(),
+ format: jest.fn().mockReturnValue('2026-12-31')
+ };
+
+ const mockExpirationEndOfDay = {
+ ...getDayjsMock(),
+ isBefore: jest.fn().mockReturnValue(false),
+ isAfter: jest.fn().mockReturnValue(true),
+ endOf: jest.fn().mockReturnThis()
+ };
+
+ const mockNowInTz = {
+ ...getDayjsMock(),
+ isBefore: jest.fn().mockReturnValue(true),
+ isAfter: jest.fn().mockReturnValue(false)
+ };
+
+ const mockDayjsBase = {
+ ...getDayjsMock(),
+ tz: jest.fn().mockReturnValue(mockNowInTz)
+ };
+
+ dayjs.utc = jest.fn().mockReturnValue(mockUtcDayjs);
+ dayjs.tz = jest.fn().mockReturnValue(mockExpirationEndOfDay);
+ dayjs.mockReturnValue(mockDayjsBase);
+
+ const propsWithActiveUser = {
+ ...props,
+ initialValues: {
+ ...props.initialValues,
+ expirationDate: futureDate,
+ }
+ };
+
+ renderEditUserInfo(propsWithActiveUser);
+
+ expect(dayjs.utc).toHaveBeenCalledWith(futureDate);
+ expect(mockUtcDayjs.format).toHaveBeenCalledWith('YYYY-MM-DD');
+ expect(dayjs.tz).toHaveBeenCalledWith('2026-12-31', 'America/New_York');
+ expect(screen.queryByText('ui-users.errors.userExpired')).not.toBeInTheDocument();
+ });
+
+ it('should use tenant timezone', () => {
+ const timezone = 'America/Vancouver';
+
+ const mockUtcDayjs = {
+ ...getDayjsMock(),
+ format: jest.fn().mockReturnValue('2025-07-15')
+ };
+
+ const mockTimezoneObj = {
+ ...getDayjsMock(),
+ isBefore: jest.fn().mockReturnValue(false),
+ isAfter: jest.fn().mockReturnValue(true),
+ endOf: jest.fn().mockReturnThis()
+ };
+
+ const mockNowInTz = {
+ ...getDayjsMock(),
+ isBefore: jest.fn().mockReturnValue(false),
+ isAfter: jest.fn().mockReturnValue(true)
+ };
+
+ const mockDayjsBase = {
+ ...getDayjsMock(),
+ tz: jest.fn().mockReturnValue(mockNowInTz)
+ };
+
+ dayjs.utc = jest.fn().mockReturnValue(mockUtcDayjs);
+ dayjs.tz = jest.fn().mockReturnValue(mockTimezoneObj);
+ dayjs.mockReturnValue(mockDayjsBase);
+
+ const timezoneProps = {
+ ...props,
+ stripes: {
+ ...props.stripes,
+ timezone
+ },
+ initialValues: {
+ ...props.initialValues,
+ expirationDate: '2025-07-15T23:59:59.999Z'
+ }
+ };
+
+ renderEditUserInfo(timezoneProps);
+
+ expect(dayjs.tz).toHaveBeenCalledWith('2025-07-15', timezone);
+ expect(mockDayjsBase.tz).toHaveBeenCalledWith(timezone);
+ });
+
+ it('should extract date part correctly to avoid time inconsistencies', () => {
+ // Test with old data that might not be end-of-day
+ const inconsistentTimeData = '2025-06-15T14:30:22.123Z';
+
+ const mockUtcDayjs = {
+ ...getDayjsMock(),
+ format: jest.fn().mockReturnValue('2025-06-15')
+ };
+
+ const mockTimezoneObj = {
+ ...getDayjsMock(),
+ isBefore: jest.fn().mockReturnValue(false),
+ isAfter: jest.fn().mockReturnValue(true),
+ endOf: jest.fn().mockReturnThis()
+ };
+
+ const mockNowInTz = {
+ ...getDayjsMock(),
+ isBefore: jest.fn().mockReturnValue(false),
+ isAfter: jest.fn().mockReturnValue(true)
+ };
+
+ const mockDayjsBase = {
+ ...getDayjsMock(),
+ tz: jest.fn().mockReturnValue(mockNowInTz)
+ };
+
+ dayjs.utc = jest.fn().mockReturnValue(mockUtcDayjs);
+ dayjs.tz = jest.fn().mockReturnValue(mockTimezoneObj);
+ dayjs.mockReturnValue(mockDayjsBase);
+
+ const propsWithInconsistentTime = {
+ ...props,
+ initialValues: {
+ ...props.initialValues,
+ expirationDate: inconsistentTimeData,
+ }
+ };
+
+ renderEditUserInfo(propsWithInconsistentTime);
+
+ expect(dayjs.utc).toHaveBeenCalledWith(inconsistentTimeData);
+ expect(mockUtcDayjs.format).toHaveBeenCalledWith('YYYY-MM-DD');
+ expect(dayjs.tz).toHaveBeenCalledWith('2025-06-15', 'America/New_York');
+ expect(mockTimezoneObj.endOf).toHaveBeenCalledWith('day');
+ });
+
+ it('should handle UTC fallback when timezone is not specified', () => {
+ const mockUtcDayjs = {
+ ...getDayjsMock(),
+ format: jest.fn().mockReturnValue('2025-08-01')
+ };
+
+ const mockTimezoneObj = {
+ ...getDayjsMock(),
+ isBefore: jest.fn().mockReturnValue(false),
+ isAfter: jest.fn().mockReturnValue(true),
+ endOf: jest.fn().mockReturnThis()
+ };
+
+ const mockNowInTz = {
+ ...getDayjsMock(),
+ isBefore: jest.fn().mockReturnValue(false),
+ isAfter: jest.fn().mockReturnValue(true)
+ };
+
+ const mockDayjsBase = {
+ ...getDayjsMock(),
+ tz: jest.fn().mockReturnValue(mockNowInTz)
+ };
+
+ dayjs.utc = jest.fn().mockReturnValue(mockUtcDayjs);
+ dayjs.tz = jest.fn().mockReturnValue(mockTimezoneObj);
+ dayjs.mockReturnValue(mockDayjsBase);
+
+ const propsWithoutTimezone = {
+ ...props,
+ stripes: {
+ ...props.stripes,
+ timezone: undefined,
+ },
+ initialValues: {
+ ...props.initialValues,
+ expirationDate: '2025-08-01T23:59:59.999Z'
+ }
+ };
+
+ renderEditUserInfo(propsWithoutTimezone);
+
+ expect(dayjs.tz).toHaveBeenCalledWith('2025-08-01', 'UTC');
+ expect(mockDayjsBase.tz).toHaveBeenCalledWith('UTC');
+ });
+
+ it('should handle form field expiration date changes for willUserExtend logic', async () => {
+ const futureDate = '2026-01-01T23:59:59.999Z';
+ const expiredInitialDate = '2023-01-01T23:59:59.999Z';
+
+ const mockUtcDayjsForInitial = {
+ ...getDayjsMock(),
+ format: jest.fn().mockReturnValue('2023-01-01'),
+ };
+
+ const mockUtcDayjsForFuture = {
+ ...getDayjsMock(),
+ format: jest.fn().mockReturnValue('2026-01-01'),
+ };
+
+ const mockExpiredEndOfDay = {
+ ...getDayjsMock(),
+ isBefore: jest.fn().mockReturnValue(true),
+ isAfter: jest.fn().mockReturnValue(false),
+ endOf: jest.fn().mockReturnThis()
+ };
+
+ const mockFutureEndOfDay = {
+ ...getDayjsMock(),
+ isBefore: jest.fn().mockReturnValue(false),
+ isAfter: jest.fn().mockReturnValue(true),
+ endOf: jest.fn().mockReturnThis()
+ };
+
+ const mockNowInTz = {
+ ...getDayjsMock(),
+ isBefore: jest.fn().mockReturnValue(true),
+ isAfter: jest.fn().mockReturnValue(false)
+ };
+
+ const mockForm = {
+ ...props.form,
+ getFieldState: jest.fn().mockReturnValue({ value: futureDate })
+ };
+
+ const mockDayjsBase = {
+ ...getDayjsMock(),
+ tz: jest.fn().mockReturnValue(mockNowInTz)
+ };
+
+ dayjs.utc = jest.fn()
+ .mockImplementation((date) => {
+ if (date === expiredInitialDate) return mockUtcDayjsForInitial;
+ if (date === futureDate) return mockUtcDayjsForFuture;
+ return mockUtcDayjsForInitial;
+ });
+
+ dayjs.tz = jest.fn()
+ .mockImplementation((date) => {
+ if (date === '2023-01-01') return mockExpiredEndOfDay;
+ if (date === '2026-01-01') return mockFutureEndOfDay;
+ return mockExpiredEndOfDay;
+ });
+
+ dayjs.mockReturnValue(mockDayjsBase);
+
+ const propsWithExtendingUser = {
+ ...props,
+ form: mockForm,
+ initialValues: {
+ ...props.initialValues,
+ expirationDate: expiredInitialDate,
+ }
+ };
+
+ renderEditUserInfo(propsWithExtendingUser);
+
+ expect(screen.getByText('ui-users.information.recalculate.will.reactivate.user')).toBeInTheDocument();
+ expect(mockForm.getFieldState).toHaveBeenCalledWith('expirationDate');
+ });
+ });
+
+ describe('calculateNewExpirationDate', () => {
+ it('should calculate from today when startCalcToday is true', async () => {
+ const mockCalculatedDate = {
+ ...getDayjsMock(),
+ format: jest.fn().mockReturnValue('2025-08-05'),
+ add: jest.fn().mockReturnThis(),
+ tz: jest.fn().mockReturnThis(),
+ isSameOrBefore: jest.fn().mockReturnValue(false),
+ };
+
+ const mockUtcDayjs = {
+ ...getDayjsMock(),
+ endOf: jest.fn().mockReturnThis(),
+ toISOString: jest.fn().mockReturnValue('2025-08-05T23:59:59.999Z')
+ };
+
+ dayjs.mockImplementation(() => mockCalculatedDate);
+ dayjs.tz = jest.fn().mockReturnValue(mockCalculatedDate);
+ dayjs.utc = jest.fn().mockReturnValue(mockUtcDayjs);
+
+ renderEditUserInfo(props);
+
+ await act(() => userEvent.click(screen.getByText('ui-users.information.recalculate.expirationDate')));
+ await userEvent.click(screen.getByText('ui-users.information.recalculate.modal.button'));
+
+ expect(dayjs().tz).toHaveBeenCalledWith('America/New_York');
+ expect(mockCalculatedDate.add).toHaveBeenCalledWith(730, 'd');
+ expect(mockCalculatedDate.format).toHaveBeenCalledWith('YYYY-MM-DD');
+ });
+
+ it('should calculate from existing expiration date when startCalcToday is false and date is in future', async () => {
+ const futureDate = '2026-01-01T23:59:59.999Z';
+
+ const mockExistingDate = {
+ ...getDayjsMock(),
+ format: jest.fn().mockReturnValue('2026-01-01'),
+ add: jest.fn().mockReturnThis(),
+ tz: jest.fn().mockReturnThis(),
+ isSameOrBefore: jest.fn().mockReturnValue(false),
+ };
+
+ const mockNowDate = {
+ ...getDayjsMock(),
+ format: jest.fn().mockReturnValue('2025-08-05'),
+ add: jest.fn().mockReturnThis(),
+ tz: jest.fn().mockReturnThis(),
+ isSameOrBefore: jest.fn().mockReturnValue(true),
+ };
+
+ const mockUtcDayjs = {
+ ...getDayjsMock(),
+ endOf: jest.fn().mockReturnThis(),
+ toISOString: jest.fn().mockReturnValue('2026-01-01T23:59:59.999Z')
+ };
+
+ dayjs.mockImplementation(() => mockNowDate);
+ dayjs.tz = jest.fn()
+ .mockReturnValueOnce(mockExistingDate)
+ .mockReturnValue(mockExistingDate);
+ dayjs.utc = jest.fn().mockReturnValue(mockUtcDayjs);
+
+ const propsWithFutureDate = {
+ ...props,
+ initialValues: {
+ ...props.initialValues,
+ expirationDate: futureDate,
+ }
+ };
+
+ renderEditUserInfo(propsWithFutureDate);
+
+ await userEvent.click(screen.getByText('ui-users.information.recalculate.expirationDate'));
+
+ expect(dayjs.tz).toHaveBeenCalledWith(futureDate, 'America/New_York');
+ expect(mockExistingDate.add).toHaveBeenCalledWith(730, 'd');
+ });
+
+ it('should fallback to today when existing expiration date is expired', async () => {
+ const expiredDate = '2023-01-01T23:59:59.999Z';
+
+ const mockExpiredDate = {
+ ...getDayjsMock(),
+ format: jest.fn().mockReturnValue('2023-01-01'),
+ add: jest.fn().mockReturnThis(),
+ tz: jest.fn().mockReturnThis(),
+ isSameOrBefore: jest.fn().mockReturnValue(true),
+ };
+
+ const mockNowDate = {
+ ...getDayjsMock(),
+ format: jest.fn().mockReturnValue('2025-08-05'),
+ add: jest.fn().mockReturnThis(),
+ tz: jest.fn().mockReturnThis(),
+ isSameOrBefore: jest.fn().mockReturnValue(false),
+ };
+
+ const mockUtcDayjs = {
+ ...getDayjsMock(),
+ endOf: jest.fn().mockReturnThis(),
+ toISOString: jest.fn().mockReturnValue('2025-08-05T23:59:59.999Z')
+ };
+
+ dayjs.mockImplementation(() => mockNowDate);
+ dayjs.tz = jest.fn()
+ .mockReturnValueOnce(mockExpiredDate)
+ .mockReturnValue(mockNowDate);
+ dayjs.utc = jest.fn().mockReturnValue(mockUtcDayjs);
+
+ const propsWithExpiredDate = {
+ ...props,
+ initialValues: {
+ ...props.initialValues,
+ expirationDate: expiredDate,
+ }
+ };
+
+ renderEditUserInfo(propsWithExpiredDate);
+
+ await userEvent.click(screen.getByText('ui-users.information.recalculate.expirationDate'));
+
+ expect(mockNowDate.add).toHaveBeenCalledWith(730, 'd');
+ });
+
+ it('should handle undefined expiration date by using today', async () => {
+ const mockNowDate = {
+ ...getDayjsMock(),
+ format: jest.fn().mockReturnValue('2025-08-05'),
+ add: jest.fn().mockReturnThis(),
+ tz: jest.fn().mockReturnThis(),
+ isSameOrBefore: jest.fn().mockReturnValue(false),
+ };
+
+ const mockUtcDayjs = {
+ ...getDayjsMock(),
+ endOf: jest.fn().mockReturnThis(),
+ toISOString: jest.fn().mockReturnValue('2025-08-05T23:59:59.999Z')
+ };
+
+ dayjs.mockImplementation(() => mockNowDate);
+ dayjs.tz = jest.fn().mockReturnValue(mockNowDate);
+ dayjs.utc = jest.fn().mockReturnValue(mockUtcDayjs);
+
+ const propsWithoutDate = {
+ ...props,
+ initialValues: {
+ ...props.initialValues,
+ expirationDate: undefined,
+ }
+ };
+
+ renderEditUserInfo(propsWithoutDate);
+
+ await userEvent.click(screen.getByText('ui-users.information.recalculate.expirationDate'));
+
+ expect(mockNowDate.add).toHaveBeenCalledWith(730, 'd');
+ });
+
+ it('should use tenant timezone for calculations', async () => {
+ const timezone = 'Asia/Tokyo';
+
+ const mockCalculatedDate = {
+ ...getDayjsMock(),
+ format: jest.fn().mockReturnValue('2025-08-05'),
+ add: jest.fn().mockReturnThis(),
+ tz: jest.fn().mockReturnThis(),
+ isSameOrBefore: jest.fn().mockReturnValue(false),
+ };
+
+ const mockUtcDayjs = {
+ ...getDayjsMock(),
+ endOf: jest.fn().mockReturnThis(),
+ toISOString: jest.fn().mockReturnValue('2025-08-05T23:59:59.999Z')
+ };
+
+ dayjs.mockImplementation(() => mockCalculatedDate);
+ dayjs.tz = jest.fn().mockReturnValue(mockCalculatedDate);
+ dayjs.utc = jest.fn().mockReturnValue(mockUtcDayjs);
+
+ const timezoneProps = {
+ ...props,
+ stripes: {
+ ...props.stripes,
+ timezone,
+ },
+ };
+
+ renderEditUserInfo(timezoneProps);
+
+ userEvent.click(screen.getByText('ui-users.information.recalculate.expirationDate'));
+
+ expect(dayjs().tz).toHaveBeenCalledWith(timezone);
+ });
+ });
});
diff --git a/src/components/Loans/OpenLoans/components/ActionsDropdown/ActionsDropdown.js b/src/components/Loans/OpenLoans/components/ActionsDropdown/ActionsDropdown.js
index ab484fc18..88f0c2157 100644
--- a/src/components/Loans/OpenLoans/components/ActionsDropdown/ActionsDropdown.js
+++ b/src/components/Loans/OpenLoans/components/ActionsDropdown/ActionsDropdown.js
@@ -57,12 +57,15 @@ class ActionsDropdown extends React.Component {
patronGroup,
history,
} = this.props;
+ const {
+ timezone = 'UTC',
+ } = stripes;
const itemStatusName = loan?.item?.status?.name;
const itemDetailsLink = `/inventory/view/${loan.item?.instanceId}/${loan.item?.holdingsRecordId}/${loan.itemId}`;
const loanPolicyLink = `/settings/circulation/loan-policies/${loan.loanPolicyId}`;
const buttonDisabled = !stripes.hasPerm('ui-users.feesfines.actions.all');
- const isUserActive = checkUserActive(user);
+ const isUserActive = checkUserActive(user, timezone);
const isVirtualUser = isDcbUser(user);
const isVirtualItem = isDcbItem(loan?.item);
const isItemAbsent = !loan?.item;
diff --git a/src/components/Loans/OpenLoans/components/OpenLoansSubHeader/OpenLoansSubHeader.js b/src/components/Loans/OpenLoans/components/OpenLoansSubHeader/OpenLoansSubHeader.js
index bb1b74d82..24761a1dc 100644
--- a/src/components/Loans/OpenLoans/components/OpenLoansSubHeader/OpenLoansSubHeader.js
+++ b/src/components/Loans/OpenLoans/components/OpenLoansSubHeader/OpenLoansSubHeader.js
@@ -6,7 +6,10 @@ import {
} from 'lodash';
import { useIntl } from 'react-intl';
-import { IfPermission } from '@folio/stripes/core';
+import {
+ IfPermission,
+ useStripes,
+} from '@folio/stripes/core';
import {
Button,
Dropdown,
@@ -54,9 +57,14 @@ const OpenLoansSubHeader = ({
patronGroup
}) => {
const intl = useIntl();
+ const stripes = useStripes();
const [toggleDropdownState, setToggleDropdownState] = useState(false);
+ const {
+ timezone = 'UTC',
+ } = stripes;
+
const headers = [
'action',
'dueDate',
@@ -124,7 +132,7 @@ const OpenLoansSubHeader = ({
const countRenews = getRenewalPatronBlocksFromPatronBlocks(patronBlocks);
const onlyClaimedReturnedItemsSelected = hasEveryLoanItemStatus(checkedLoans, itemStatuses.CLAIMED_RETURNED);
const onlyLostyItemsSelected = hasAnyLoanItemStatus(checkedLoans, lostItemStatuses);
- const isUserActive = checkUserActive(user);
+ const isUserActive = checkUserActive(user, timezone);
const isVirtualUser = isDcbUser(user);
const checkedLoansArray = Object.values(checkedLoans);
diff --git a/src/components/ProxyGroup/ProxyEditItem/ProxyEditItem.js b/src/components/ProxyGroup/ProxyEditItem/ProxyEditItem.js
index 7465cd357..3e7e84ca1 100644
--- a/src/components/ProxyGroup/ProxyEditItem/ProxyEditItem.js
+++ b/src/components/ProxyGroup/ProxyEditItem/ProxyEditItem.js
@@ -11,6 +11,7 @@ import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { Field } from 'react-final-form';
import { OnChange } from 'react-final-form-listeners';
+import { withStripes } from '@folio/stripes/core';
import {
Row,
Col,
@@ -35,6 +36,9 @@ class ProxyEditItem extends React.Component {
onDelete: PropTypes.func,
change: PropTypes.func.isRequired,
formValues: PropTypes.object,
+ stripes: PropTypes.shape({
+ timezone: PropTypes.string,
+ }).isRequired,
};
constructor() {
@@ -96,11 +100,15 @@ class ProxyEditItem extends React.Component {
const {
index,
namespace,
+ stripes,
} = this.props;
+ const {
+ timezone = 'UTC',
+ } = stripes;
const formValues = this.state.formValues;
- this.toggleStatus(!getWarning(formValues, namespace, index));
+ this.toggleStatus(!getWarning(formValues, namespace, index, timezone));
}
optionsFor = (list) => {
@@ -244,4 +252,4 @@ class ProxyEditItem extends React.Component {
}
}
-export default ProxyEditItem;
+export default withStripes(ProxyEditItem);
diff --git a/src/components/util/getProxySponsorWarning.js b/src/components/util/getProxySponsorWarning.js
index 11aeb7414..2d467ef94 100644
--- a/src/components/util/getProxySponsorWarning.js
+++ b/src/components/util/getProxySponsorWarning.js
@@ -1,8 +1,9 @@
import React from 'react';
-import moment from 'moment';
import { FormattedMessage } from 'react-intl';
import { get } from 'lodash';
+import { dayjs } from '@folio/stripes/components';
+
/**
* getProxySponsorWarning
* Return a warning for the given namespace-index pair if any one of these
@@ -26,24 +27,24 @@ import { get } from 'lodash';
*
* @return empty string indicates no warnings; a string contains a warning message.
*/
-export default function getProxySponsorWarning(values, namespace, index) {
+export default function getProxySponsorWarning(values, namespace, index, timezone = 'UTC') {
const proxyRel = values[namespace][index] || {};
- const today = moment().endOf('day');
+ const today = dayjs().tz(timezone).endOf('day');
let warning = '';
// proxy/sponsor user expired
- if (get(proxyRel, 'user.expirationDate') && moment(proxyRel.user.expirationDate).isSameOrBefore(today, 'day')) {
+ if (get(proxyRel, 'user.expirationDate') && dayjs.tz(proxyRel.user.expirationDate, timezone).isSameOrBefore(today, 'day')) {
warning =