updates for schedule

This commit is contained in:
Bill Richardson 2026-01-14 13:29:52 -05:00
parent 9fb3be5f44
commit b15ea179b9
6 changed files with 226 additions and 49 deletions

View file

@ -16,7 +16,7 @@
class="appointment-date-picker-container"
:class="isMobileView ? 'mobile' : 'desktop'">
<div class="header-cell header-start">
<span
<button
v-if="activePageIndex !== 0"
class="span-block-flex"
@click="gotoPreviousPage()">
@ -25,13 +25,13 @@
src="~@/assets/img/icons/icon-ablue-previous.png"
alt="Left arrow icon" />
Previous
</span>
</button>
</div>
<div class="header-cell header-middle">
{{ headerDateString }}
</div>
<div class="header-cell header-end">
<span
<button
class="span-block-flex"
@click="gotoNextPage()">
More
@ -39,7 +39,7 @@
class="arrow-icon"
src="~@/assets/img/icons/icon-ablue-next.png"
alt="Right arrow icon" />
</span>
</button>
</div>
<div class="week-row-container date-picker-row">
<template
@ -56,6 +56,7 @@
v-for="n in daysToAdd"
:key="n">
<div
tabindex="0"
class="morning-row-cell"
:class="[showMorningAvailabilityForIndex(n - 1) > 0 ? 'has-appointments' : 'no-appointments'
, checkIsSelectedDay(n - 1, 'morning') ? 'selected-date' : '']"
@ -72,6 +73,7 @@
v-for="n in daysToAdd"
:key="n">
<div
tabindex="0"
class="afternoon-row-cell"
:class="[showAfternoonAvailabilityForIndex(n - 1) > 0 ? 'has-appointments' : 'no-appointments'
, checkIsSelectedDay(n - 1, 'afternoon') ? 'selected-date' : '']"
@ -89,17 +91,28 @@
class="appointment-time-picker-container"
:class="isMobileView ? 'mobile' : 'desktop'">
<div class="appointment-time-picker-header">
Available Times:
Available times:
</div>
<div
<template
v-for="timeSlot in selectableTimeSlotsData"
:key="timeSlot.startTime"
class="time-slot-button"
:class="[checkIsSelectedTimeSlot(timeSlot) ? 'selected-time-slot' : ''
, {'has-date-error': showTimeSlotError}]"
@click="selectTimeSlotForDay(timeSlot)">
{{ displayTimeSlotTime(timeSlot.startTime) }}
</div>
:key="timeSlot.startTime">
<label
:for="`timeslot${timeSlot.startTime}`"
class="time-slot-button"
:class="[checkIsSelectedTimeSlot(timeSlot) ? 'selected-time-slot' : ''
, {'has-date-error': showTimeSlotError}]"
@click="selectTimeSlotForDay(timeSlot)">
{{ displayTimeSlotTime(timeSlot.startTime) }}
</label>
<input
:id="`timeslot${timeSlot.startTime}`"
v-model="selectedTime"
type="radio"
name="time-slot"
class="sr-only"
:value="timeSlot.startTime"
@change="timeSlotInputChanged(timeSlot)" />
</template>
</div>
<div class="row form-test-error">
<div v-if="showTimeSlotError">
@ -121,8 +134,12 @@ import {
convertDateToTwoDigitDay,
convertDateToTwoDigitMonth,
getDisplayTextForDurationLength,
isAfternoon,
militaryToTwelveHourTime
} from '@/helpers/date-helper';
import {
PREMIUM_TIME_SLOT_ID_FLAG
} from '@/constants/schedule-constants';
import showIssLoadingModal from '@/helpers/loading-modal-helper.js';
import errorMessages from '@/constants/error-messages.js';
import { selectableDaysOptions } from './mixins/helpers';
@ -207,6 +224,7 @@ export default {
selectableTimeSlotsData: [],
selectedTimeOfDayGrouping: null,
selectedDate: null,
selectedTime: null,
selectedTimeSlot: null,
activeDate: new Date(),
activePageIndex: 0,
@ -262,7 +280,8 @@ export default {
watch: {
selectedTimeSlot(newValue, oldValue) {
if (newValue !== oldValue) {
this.$emit('timeSlotSelected', newValue);
const testObj = this.getSelectedTimeSlotInfoObject(newValue);
this.$emit('timeSlotSelected', testObj);
}
},
selectedDate(newValue, oldValue) {
@ -285,11 +304,54 @@ export default {
initializeComponent(initialData) {
this.setCalendarData(initialData);
},
addPremiumFlagToInput(routeCode) {
return (`${routeCode}${PREMIUM_TIME_SLOT_ID_FLAG}`);
},
removePremiumFlagFromInput(routeCode) {
return routeCode.replace(PREMIUM_TIME_SLOT_ID_FLAG, '');
},
getSelectedTimeSlotInfoObject(timeSlot) {
const routeCode = timeSlot?.id;
let routeCodeToUse = routeCode;
const routeCodeIncludesPremium = routeCode?.includes(PREMIUM_TIME_SLOT_ID_FLAG);
if (routeCodeIncludesPremium) {
routeCodeToUse = this.removePremiumFlagFromInput(routeCode);
}
const fullTimeSlot = this.selectableTimeSlotsData?.find((ts) => ts.id === routeCodeToUse);
if (fullTimeSlot) {
return {
timeSlot: {
date: this.selectedDate,
routeCode: fullTimeSlot.id,
startTime: fullTimeSlot.startTime,
endTime: fullTimeSlot.endTime,
jobMaxMinutes: this.appointmentDurationMinutesMaximum.toString(),
jobMinMinutes: this.appointmentDurationMinutesMinimum.toString()
},
isPremiumAppointment: !!routeCodeIncludesPremium
};
}
return {
timeSlot: {
date: null,
startTime: null,
endTime: null,
routeCode: null,
jobMaxMinutes: null,
jobMinMinutes: null
},
isPremiumAppointment: null
};
},
handleMqlChange(e) {
this.isMobileView = !e.matches;
this.activePageIndex = 0;
this.selectedDate = null;
this.selectedTimeOfDayGrouping = null;
this.selectedTime = null;
this.selectedTimeSlot = null;
this.findFirstAvailableDateInView();
},
@ -311,6 +373,46 @@ export default {
}
}
},
findInitialPageIndex(daysFromStart) {
if (daysFromStart !== null && daysFromStart !== undefined) {
return {
initialPageIndex: Math.floor(daysFromStart / this.daysToAdd),
initialDayIndex: daysFromStart % this.daysToAdd
};
}
return {
initialPageIndex: 0,
initialDayIndex: null
};
},
findInitialDate(myDateString, initialDayIndex) {
const dateInView = this.getDateToShow(initialDayIndex);
const dateInViewString = convertDateToDateString(dateInView);
if (dateInViewString === myDateString) {
const isAfternoonSlot = isAfternoon(myDateString);
const selectableDateObj = this.selectableDatesData.find((dateObj) => dateObj.date === dateInViewString);
if (selectableDateObj) {
if (isAfternoonSlot) {
this.showAfternoonTimes(initialDayIndex);
this.findInitialTimeByStore(selectableDateObj.afternoonTimeSlots);
} else {
this.showMorningTimes(initialDayIndex);
this.findInitialTimeByStore(selectableDateObj.morningTimeSlots);
}
}
}
},
findInitialTimeByStore(timeSlots) {
const initialTimeSlot = this.mainStore.order.schedule;
if (!initialTimeSlot || !initialTimeSlot.startTime) {
return;
}
const initialStartTime = initialTimeSlot.startTime;
const foundTimeSlot = timeSlots.find((ts) => ts.startTime === initialStartTime);
if (foundTimeSlot) {
this.selectTimeSlotForDay(foundTimeSlot);
}
},
getDateToShow(index) {
const dayOffset = this.activePageIndex * this.daysToAdd;
const dateToShow = new Date(this.activeDate);
@ -321,6 +423,38 @@ export default {
const dateToShow = this.getDateToShow(index);
return convertDateToDateString(dateToShow);
},
getEndDateForExistingDate(startDate, selectedDateString, daysInView) {
const endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + (daysInView - 1));
if (selectedDateString) {
const selectedDate = new Date(`${selectedDateString}T00:00:00`);
const daysDifferenceComparedToStartDate = Math.ceil((selectedDate - startDate) / (1000 * 60 * 60 * 24));
if (selectedDate > endDate) {
const daysDifferenceComparedToStartDateAsPages = Math.ceil(daysDifferenceComparedToStartDate / daysInView);
const newEndDate = new Date(startDate);
newEndDate.setDate(newEndDate.getDate() + (daysDifferenceComparedToStartDateAsPages * daysInView) - 1);
return {
endDateString: convertDateToDateString(newEndDate),
newDaysLoaded: (daysDifferenceComparedToStartDateAsPages * daysInView),
daysFromStart: daysDifferenceComparedToStartDate
};
}
return {
endDateString: convertDateToDateString(endDate),
newDaysLoaded: null,
daysFromStart: daysDifferenceComparedToStartDate
};
}
return {
endDateString: convertDateToDateString(endDate),
newDaysLoaded: null,
daysFromStart: null
};
},
gotoNextPage() {
const maxPageIndex = Math.floor((this.daysLoaded - 1) / this.daysToAdd);
if (this.activePageIndex >= maxPageIndex) {
@ -329,6 +463,7 @@ export default {
this.daysLoaded += this.initialDaysToLoad;
this.activePageIndex += 1;
this.selectedDate = null;
this.selectedTime = null;
this.selectedTimeSlot = null;
this.findFirstAvailableDateInView();
});
@ -336,12 +471,14 @@ export default {
}
this.activePageIndex += 1;
this.selectedDate = null;
this.selectedTIme = null;
this.selectedTimeSlot = null;
this.findFirstAvailableDateInView();
},
gotoPreviousPage() {
this.activePageIndex -= 1;
this.selectedDate = null;
this.selectedTime = null;
this.selectedTimeSlot = null;
this.findFirstAvailableDateInView();
},
@ -357,6 +494,7 @@ export default {
},
selectTimeSlotForDay(timeSlot) {
if (timeSlot) {
this.selectedTime = timeSlot.startTime;
this.selectedTimeSlot = timeSlot;
}
},
@ -387,6 +525,7 @@ export default {
this.selectedDate = morningDateString;
this.selectedTimeOfDayGrouping = 'morning';
this.selectableTimeSlotsData = selectableDateObj.morningTimeSlots;
this.selectedTime = null;
this.selectedTimeSlot = null;
}
},
@ -411,13 +550,18 @@ export default {
this.selectedTimeOfDayGrouping = 'afternoon';
this.selectableTimeSlotsData = selectableDateObj.afternoonTimeSlots;
this.selectedTimeSlot = null;
this.selectedTime = null;
}
},
timeSlotInputChanged(timeSlot) {
this.selectTimeSlotForDay(timeSlot);
},
async loadInitialData(config) {
/*
** NOTE: this _could_ be called by a parent before fully loaded, so data or computeds might not be available
*/
let todayDateString;
const todayDateObject = new Date();
const calendarViewDirection = 'future';
const initialDays = 15;
@ -426,13 +570,23 @@ export default {
} else if (config.todayOverrideDateString) {
todayDateString = config.todayOverrideDateString;
} else {
todayDateString = convertDateToDateString(new Date());
todayDateString = convertDateToDateString(todayDateObject);
}
const initialViewStartDate = todayDateString;
const initialEndDate = new Date();
initialEndDate.setDate(initialEndDate.getDate() + (initialDays - 1));
const initialViewEndDate = convertDateToDateString(initialEndDate);
let initialViewEndDate = convertDateToDateString(initialEndDate);
const endDateObject = this.getEndDateForExistingDate(
todayDateObject,
config.preSelectedDate,
initialDays
);
if (endDateObject.endDateString !== initialViewEndDate) {
initialViewEndDate = endDateObject.endDateString;
}
const loadInitialDataPromise = new Promise((resolve) => {
const response = config.customSelectableDatesCallback(
@ -452,7 +606,8 @@ export default {
calendarViewDirection,
initialShopTimeSlotsResponse: response,
preSelectedDate: config.preSelectedDate,
initialDaysLoaded: initialDays
initialDaysLoaded: endDateObject.newDaysLoaded || initialDays,
daysFromStart: endDateObject.daysFromStart || null
};
return initialData;
@ -480,7 +635,18 @@ export default {
this.appointmentDurationMinutesMaximum = config.initialShopTimeSlotsResponse.estimatedServiceMinutesMaximum;
this.appointmentDurationMinutesMinimum = config.initialShopTimeSlotsResponse.estimatedServiceMinutesMinimum;
this.findFirstAvailableDateInView();
let pageIndexObj = { initialPageIndex: 0, initialDayIndex: null };
if (config.daysFromStart !== null && config.daysFromStart !== undefined) {
pageIndexObj = this.findInitialPageIndex(config.daysFromStart);
this.activePageIndex = pageIndexObj.initialPageIndex;
}
if (config.preSelectedDate) {
this.findInitialDate(config.preSelectedDate, pageIndexObj.initialDayIndex);
} else {
this.findFirstAvailableDateInView();
}
this.isLoading = false;
},
async loadMoreDays(maxPageIndex, daysInView, daysToFetch) {
@ -590,6 +756,9 @@ export default {
.span-block-flex {
display: flex;
align-items: center;
border: none;
background-color: #fff;
color: #525656;
cursor: pointer;
&:hover {
@ -601,6 +770,7 @@ export default {
text-align: end;
.span-block-flex {
justify-self: flex-end;
justify-content: flex-end;
img {
@ -610,6 +780,7 @@ export default {
}
.header-middle {
font-weight: 600;
color: #000;
@include media-breakpoint-up(xs) {
grid-column: 2 / span 1;
}
@ -623,6 +794,7 @@ export default {
text-align: start;
.span-block-flex {
justify-self: flex-start;
justify-content: flex-start;
img {
@ -677,6 +849,10 @@ export default {
border: solid 1px #0070d1;
}
&:focus, &:focus-visible {
outline: none;
border: solid 2px #0070d1;
}
}
.span-block {
display: block;
@ -697,7 +873,7 @@ export default {
.time-slot-button {
width: 100%;
border: 1px solid #b3b4b5;
border-radius: 1.375rem;
border-radius: $border-radius-list-button;
box-shadow: 0 1px 5px 0 rgba(0, 0, 0, .2);
color: #525656;
padding: 0.5rem 0;

View file

@ -66,6 +66,12 @@ export function getDisplayTextForDurationLength(durationMinimum, durationMaximum
return `${durationText} ${unitText}`;
}
export function isAfternoon(timeString) {
if (typeof timeString !== 'string') return false;
const hours = parseInt(timeString.split(':')[0], 10);
return hours >= 12;
}
export function militaryToTwelveHourTime(timeString) {
// Expected input: "HH:MM"
if (typeof timeString !== 'string') return null;

View file

@ -6,7 +6,6 @@ import { createTestingPinia } from '@pinia/testing';
import { shallowMount } from '@vue/test-utils';
import { getMountOptions } from '@/helpers/unit-test-helper.js';
import { useMainStore } from '@/store/index.js';
import { AppointmentTypeStrings } from '@/constants/schedule-constants';
// Mock fetchCmsContentForPage
jest.mock('@/helpers/cms-content-helper', () => ({
@ -214,7 +213,7 @@ describe('schedule-page.vue', () => {
// Act
const newShopTimeSlots = await wrapper.vm.getAvailableDatesMethod(
'2023-01-01',
'2023-01-31'
'2023-01-15'
);
// Assert
@ -236,7 +235,7 @@ describe('schedule-page.vue', () => {
estimatedServiceMinutesMaximum: 120
});
});
test('Should call API service in days of 34 or less when getAvailableDatesMethod is called with large date ranges', async () => {
test('Should call API service in days of 15 or less when getAvailableDatesMethod is called with large date ranges', async () => {
// Arrange
const { wrapper } = getShallowMountedComponent();
wrapper.vm.selectableDatesData = {
@ -264,7 +263,7 @@ describe('schedule-page.vue', () => {
// 2023-01-01 --> 2023-02-05
// 2023-02-06 --> 2023-03-12
// 2023-03-13 --> 2023-03-31
expect(store.getShopTimeSlots).toHaveBeenCalledTimes(3);
expect(store.getShopTimeSlots).toHaveBeenCalledTimes(6);
});
});
describe('Rendering', () => {
@ -296,7 +295,9 @@ describe('schedule-page.vue', () => {
const { wrapper } = getShallowMountedComponent();
wrapper.vm.$router.navigate = jest.fn(() => ({}));
wrapper.vm.selectedTimeSlotInfo = {
id: 'test-id'
timeSlot: {
routeCode: 'test-id'
}
};
// Act

View file

@ -73,7 +73,7 @@ import BaseFormMixin from '@/mixins/base-form-mixin.js';
import { useMainStore } from '@/store';
// Define constants
const TIME_SLOTS_CALL_DAYS_LIMIT = 34; // needs to be 34 for API limits (35 does not consistently work)
const TIME_SLOTS_CALL_DAYS_LIMIT = 15;
const getAvailableDates = async (
startDateString,
@ -83,7 +83,7 @@ const getAvailableDates = async (
) => {
const apiEndDateLimit = sumDateString(
startDateString,
TIME_SLOTS_CALL_DAYS_LIMIT
TIME_SLOTS_CALL_DAYS_LIMIT - 1
);
const difference = calcDaysBetweenDates(startDateString, endDateString);
const apiCallsCount = Math.ceil(difference / TIME_SLOTS_CALL_DAYS_LIMIT);
@ -98,7 +98,7 @@ const getAvailableDates = async (
apiStartDate = sumDateString(apiEndDate, 1);
apiEndDate = sumDateString(
apiStartDate,
TIME_SLOTS_CALL_DAYS_LIMIT
TIME_SLOTS_CALL_DAYS_LIMIT - 1
);
if (i === apiCallsCount) {
@ -108,11 +108,7 @@ const getAvailableDates = async (
apiEndDate = apiEndDateLimit;
}
if (
appointmentType === AppointmentTypeStrings.MOBILE
|| appointmentType
=== AppointmentTypeStrings.MOBILE_NOT_ITAC_AND_NOT_NOCOMP
) {
if (appointmentType === AppointmentTypeStrings.MOBILE || appointmentType === AppointmentTypeStrings.MOBILE_NOT_ITAC_AND_NOT_NOCOMP) {
storeActionConfig = {
storeAction: GET_MOBILE_TIME_SLOTS,
payload: {
@ -278,7 +274,7 @@ export default {
return useMainStore().order.serviceLocation.appointmentType;
},
isFormValid() {
const hasTimeSlotSelected = this.selectedTimeSlotInfo?.id != null;
const hasTimeSlotSelected = this.selectedTimeSlotInfo?.timeSlot?.routeCode != null;
return hasTimeSlotSelected;
},
supportingItems() {
@ -328,9 +324,6 @@ export default {
getServiceZipCtuCodeFromStore() {
return this.mainStore.order.serviceLocation.zipCodeCtu;
},
dateTimeSelected() {
console.log('dateTimeSelected called');
},
getSelectedDate() {
return this.mainStore.order.schedule.date;
},

View file

@ -2369,23 +2369,23 @@ export const useMainStore = defineStore({
const isMobileApt = appointmentType === AppointmentTypeStrings.MOBILE
|| appointmentType === AppointmentTypeStrings.MOBILE_NOT_ITAC_AND_NOT_NOCOMP;
const retPricedLineItems = await globalMethods.callHttpClient({
method: endpoints.TaxOrderItems.method,
endpoint: endpoints.TaxOrderItems.url,
payload: {
ParentAccountNumber: this.order.parentAccountNumber,
BillToAccountNumber: this.billToAccountNumber,
ProviderNumber: this.providerNumber,
AppointmentType: appointmentType,
PricedLineItems: getLineItemsFlattened(pricedLineItems),
ServiceLocation: {
City: isMobileApt ? serviceLocationCity : null,
State: isMobileApt ? serviceLocationState : null,
ZipCode: isMobileApt ? serviceLocationZipCode : null,
},
ServerData: lineItemServerData ? lineItemServerData : "",
},
ParentAccountNumber: this.order.parentAccountNumber,
BillToAccountNumber: this.billToAccountNumber,
ProviderNumber: this.providerNumber,
AppointmentType: appointmentType,
PricedLineItems: getLineItemsFlattened(pricedLineItems),
ServiceLocation: {
City: isMobileApt ? serviceLocationCity : null,
State: isMobileApt ? serviceLocationState : null,
ZipCode: isMobileApt ? serviceLocationZipCode : null
},
ServerData: lineItemServerData || ''
}
}).then((response) => {
this.order.lineItems.serverData = response.data.serverData;
return addTaxesToPricedLineItems(pricedLineItems, response.data.taxedLineItems);

View file

@ -154,6 +154,7 @@ $border-radius: 0.25rem;
$border-radius-sm: 0.2rem;
$border-radius-lg: 0.5rem; //Used for buttons. Can be used for other things, of course.
$border-radius-pill: 50rem;
$border-radius-list-button: 1.375rem; // Used for list buttons
//Progress Bar Styling
$progress-bar-success-color: $green;