diff --git a/src/layouts/schedule/helpers/schedule-helper.available-dates.spec.js b/src/layouts/schedule/helpers/schedule-helper.available-dates.spec.js new file mode 100644 index 000000000..b3adf88f5 --- /dev/null +++ b/src/layouts/schedule/helpers/schedule-helper.available-dates.spec.js @@ -0,0 +1,107 @@ +import { + getAvailableAppointmentDatesToShow, + getAvailableDatesFromProviderDays, +} from "./schedule-helper"; + +describe("getAvailableDatesFromProviderDays", () => { + test("returns sorted dates that have at least one time slot", () => { + const days = [ + { date: "2026-06-16", timeSlots: [{ id: "a" }] }, + { date: "2026-06-11", timeSlots: [] }, + { date: "2026-06-14", timeSlots: [{ id: "b" }, { id: "c" }] }, + ]; + + expect(getAvailableDatesFromProviderDays(days)).toEqual(["2026-06-14", "2026-06-16"]); + }); + + test("returns an empty array when days is null or undefined", () => { + expect(getAvailableDatesFromProviderDays(null)).toEqual([]); + expect(getAvailableDatesFromProviderDays(undefined)).toEqual([]); + }); +}); + +describe("getAvailableAppointmentDatesToShow", () => { + const selectedDate = "2026-06-15"; + + test("AC1: happy path", () => { + const available = [ + "2026-06-11", + "2026-06-12", + "2026-06-13", + "2026-06-14", + "2026-06-16", + "2026-06-17", + "2026-06-18", + "2026-06-19", + "2026-06-20", + ]; + + expect(getAvailableAppointmentDatesToShow(available, selectedDate)).toEqual([ + "2026-06-11", + "2026-06-14", + "2026-06-16", + ]); + }); + + test("AC2: no previous dates available", () => { + const available = ["2026-06-16", "2026-06-17", "2026-06-18", "2026-06-19", "2026-06-20"]; + + expect(getAvailableAppointmentDatesToShow(available, selectedDate)).toEqual([ + "2026-06-16", + "2026-06-17", + "2026-06-18", + ]); + }); + + test("AC3: no next dates available", () => { + const available = [ + "2026-06-09", + "2026-06-10", + "2026-06-11", + "2026-06-12", + "2026-06-13", + "2026-06-14", + ]; + + expect(getAvailableAppointmentDatesToShow(available, selectedDate)).toEqual([ + "2026-06-09", + "2026-06-10", + "2026-06-14", + ]); + }); + + test("AC4: only one previous date available and it matches earliest", () => { + const available = [ + "2026-06-11", + "2026-06-16", + "2026-06-17", + "2026-06-18", + "2026-06-19", + "2026-06-20", + ]; + + expect(getAvailableAppointmentDatesToShow(available, selectedDate)).toEqual([ + "2026-06-11", + "2026-06-16", + "2026-06-17", + ]); + }); + + test("AC5: no dates available", () => { + expect(getAvailableAppointmentDatesToShow([], selectedDate)).toEqual([]); + expect(getAvailableAppointmentDatesToShow([""], selectedDate)).toEqual([]); + expect(getAvailableAppointmentDatesToShow([], null)).toEqual([]); + }); + + test("AC6: only one date available", () => { + expect(getAvailableAppointmentDatesToShow(["2026-06-13"], selectedDate)).toEqual([ + "2026-06-13", + ]); + }); + + test("AC7: only two dates available", () => { + expect( + getAvailableAppointmentDatesToShow(["2026-06-11", "2026-06-12"], selectedDate) + ).toEqual(["2026-06-11", "2026-06-12"]); + }); +}); diff --git a/src/layouts/schedule/helpers/schedule-helper.js b/src/layouts/schedule/helpers/schedule-helper.js index 0841322dc..31584eb91 100644 --- a/src/layouts/schedule/helpers/schedule-helper.js +++ b/src/layouts/schedule/helpers/schedule-helper.js @@ -257,3 +257,104 @@ export function getInitialViewWeeks(todayString, initialViewRowsToShow, preSelec } return weeks; } + +export const SCHEDULING_DAY_ABBRS = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"]; +export const SCHEDULING_MONTH_ABBRS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +const MAX_AVAILABLE_APPOINTMENT_DATES_TO_SHOW = 3; + +/** + * Returns sorted YYYY-MM-DD strings for days that have at least one time slot. + * @param {Array<{ date: string, timeSlots?: Array }> | null | undefined} days + */ +export function getAvailableDatesFromProviderDays(days) { + return (days ?? []) + .filter((day) => day.timeSlots?.length > 0) + .map((day) => day.date) + .sort(); +} + +/** + * Selects up to three available appointment dates to display when the selected day has no slots. + * Priority order: earliest, previous closest to selected, next closest to selected, then chronological. + * @param {string[]} availableDates - Sorted or unsorted YYYY-MM-DD strings with availability. + * @param {string} selectedDate - Currently selected YYYY-MM-DD date from the date picker. + * @returns {string[]} + */ +export function getAvailableAppointmentDatesToShow(availableDates, selectedDate) { + const sorted = [...new Set((availableDates ?? []).filter(Boolean))].sort(); + if (sorted.length === 0 || !selectedDate) { + return []; + } + + const result = []; + const used = new Set(); + + const add = (date) => { + if (date && sorted.includes(date) && !used.has(date)) { + used.add(date); + result.push(date); + } + }; + + const earliest = sorted[0]; + const previousClosest = sorted.filter((date) => date < selectedDate).at(-1) ?? null; + const nextClosest = sorted.find((date) => date > selectedDate) ?? null; + + const priorityCandidates = [earliest]; + + if (previousClosest && previousClosest !== earliest) { + priorityCandidates.push(previousClosest); + } else if (nextClosest && nextClosest !== earliest) { + priorityCandidates.push(nextClosest); + } + + if (nextClosest) { + priorityCandidates.push(nextClosest); + } + + for (const date of priorityCandidates) { + if (result.length >= MAX_AVAILABLE_APPOINTMENT_DATES_TO_SHOW) { + break; + } + add(date); + } + + for (const date of sorted) { + if (result.length >= MAX_AVAILABLE_APPOINTMENT_DATES_TO_SHOW) { + break; + } + add(date); + } + + return result.sort(); +} + +/** + * @param {string} dateString - YYYY-MM-DD + */ +export function formatSchedulingDateChip(dateString) { + const date = convertDateStringToDate(dateString); + const dayAbbr = SCHEDULING_DAY_ABBRS[date.getDay()]; + const monthAbbr = SCHEDULING_MONTH_ABBRS[date.getMonth()]; + const day = date.getDate(); + const dayLabel = `${dayAbbr.charAt(0) + dayAbbr.slice(1).toLowerCase()}.`; + + return { + value: dateString, + label: `${dayLabel} ${monthAbbr} ${day}`, + }; +} diff --git a/src/layouts/scheduling/available-appointment-date-chips/available-appointment-date-chips.spec.js b/src/layouts/scheduling/available-appointment-date-chips/available-appointment-date-chips.spec.js new file mode 100644 index 000000000..98ec36a37 --- /dev/null +++ b/src/layouts/scheduling/available-appointment-date-chips/available-appointment-date-chips.spec.js @@ -0,0 +1,32 @@ +import { shallowMount } from "@vue/test-utils"; +import availableAppointmentDateChips from "./available-appointment-date-chips.vue"; + +describe("available-appointment-date-chips.vue", () => { + test("renders a button for each date", () => { + const wrapper = shallowMount(availableAppointmentDateChips, { + props: { + dates: ["2026-06-11", "2026-06-16"], + label: "Available appointments:", + }, + }); + + expect(wrapper.text()).toContain("Available appointments:"); + expect(wrapper.findAll(".available-appointment-date-chips__day-card").length).toBe(2); + expect(wrapper.text()).toContain("Thu. Jun 11"); + expect(wrapper.text()).toContain("Tue. Jun 16"); + expect(wrapper.findAll(".available-appointment-date-chips__day-label").length).toBe(2); + wrapper.unmount(); + }); + + test("emits date-selected when a chip is clicked", async () => { + const wrapper = shallowMount(availableAppointmentDateChips, { + props: { + dates: ["2026-06-11"], + }, + }); + + await wrapper.find(".available-appointment-date-chips__day-card").trigger("click"); + expect(wrapper.emitted("date-selected")[0]).toEqual(["2026-06-11"]); + wrapper.unmount(); + }); +}); diff --git a/src/layouts/scheduling/available-appointment-date-chips/available-appointment-date-chips.vue b/src/layouts/scheduling/available-appointment-date-chips/available-appointment-date-chips.vue new file mode 100644 index 000000000..87adefb8a --- /dev/null +++ b/src/layouts/scheduling/available-appointment-date-chips/available-appointment-date-chips.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/src/layouts/scheduling/date-picker/date-picker.spec.js b/src/layouts/scheduling/date-picker/date-picker.spec.js index ddb5cbf3f..6e06aad84 100644 --- a/src/layouts/scheduling/date-picker/date-picker.spec.js +++ b/src/layouts/scheduling/date-picker/date-picker.spec.js @@ -154,6 +154,30 @@ describe("date-picker.vue", () => { expect(wrapper.find(".date-picker__day-card--selected").exists()).toBe(false); wrapper.unmount(); }); + + test("scrolls visible window and updates selection when modelValue changes externally", async () => { + const wrapper = mountDesktop({ + availableDates: [ + "2026-01-21", + "2026-01-22", + "2026-01-23", + "2026-01-25", + "2026-01-28", + "2026-01-30", + ], + startDate: "2026-01-21", + endDate: "2026-01-30", + modelValue: "2026-01-21", + }); + + expect(wrapper.vm.windowStart).toBe(0); + + await wrapper.setProps({ modelValue: "2026-01-28" }); + + expect(wrapper.vm.windowStart).toBe(5); + expect(wrapper.find(".date-picker__day-card--selected").exists()).toBe(true); + wrapper.unmount(); + }); }); describe("navigation", () => { diff --git a/src/layouts/scheduling/date-picker/date-picker.vue b/src/layouts/scheduling/date-picker/date-picker.vue index 5e7caf089..0e35536af 100644 --- a/src/layouts/scheduling/date-picker/date-picker.vue +++ b/src/layouts/scheduling/date-picker/date-picker.vue @@ -171,6 +171,10 @@ export default { }, }, watch: { + modelValue(newValue, oldValue) { + if (newValue === oldValue) return; + this.syncWindowToSelectedDate(); + }, allDates(newDates) { if (!newDates.length) return; @@ -182,7 +186,9 @@ export default { if (this.pendingAutoSelect) { this.pendingAutoSelect = false; this.advanceWindowAndAutoSelect(); + return; } + this.syncWindowToSelectedDate(); }, }, mounted() { @@ -192,8 +198,7 @@ export default { // If a model value is set, we need to set the window start to the index of the model value. // Otherwise, we need to initialize the window. if (this.modelValue) { - const index = this.allDates.findIndex((d) => d.value === this.modelValue); - if (index !== -1) this.windowStart = this.getWindowStartFromIndex(index); + this.syncWindowToSelectedDate(); } else { this.initialize(); } @@ -214,6 +219,14 @@ export default { getWindowStartFromIndex(index) { return Math.floor(index / this.windowSize) * this.windowSize; }, + syncWindowToSelectedDate() { + if (!this.modelValue || !this.allDates.length) return; + + const index = this.allDates.findIndex((date) => date.value === this.modelValue); + if (index !== -1) { + this.windowStart = this.getWindowStartFromIndex(index); + } + }, goBack() { this.windowStart = Math.max(0, this.windowStart - this.windowSize); this.autoSelectFirstAvailable(); diff --git a/src/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card.spec.js b/src/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card.spec.js index 09647f4ca..abed491f5 100644 --- a/src/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card.spec.js +++ b/src/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card.spec.js @@ -134,6 +134,31 @@ describe("inshop-scheduling-card.vue", () => { wrapper.unmount(); }); + test("shows available date chips when selected day has no slots and chips are provided", async () => { + const wrapper = mountComponent({ + timeSlots: [], + availableDateChips: ["2026-06-11", "2026-06-14", "2026-06-16"], + }); + await wrapper.find(".inshop-scheduling-card__header").trigger("click"); + expect(wrapper.text()).toContain("Available appointments:"); + expect(wrapper.find("available-appointment-date-chips-stub").exists()).toBe(true); + expect(wrapper.find(".inshop-scheduling-card__time-slots").exists()).toBe(false); + wrapper.unmount(); + }); + + test("emits date-selected when an available date chip is selected", async () => { + const wrapper = mountComponent({ + timeSlots: [], + availableDateChips: ["2026-06-11"], + }); + await wrapper.find(".inshop-scheduling-card__header").trigger("click"); + await wrapper + .find("available-appointment-date-chips-stub") + .vm.$emit("date-selected", "2026-06-11"); + expect(wrapper.emitted("date-selected")[0]).toEqual(["2026-06-11"]); + wrapper.unmount(); + }); + test("shows drop off SubText only when the option is selected", async () => { const wrapper = mountComponent(); expect(wrapper.text()).not.toContain("Park in any open spot"); diff --git a/src/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card.vue b/src/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card.vue index a701ab54c..1316a41cd 100644 --- a/src/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card.vue +++ b/src/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card.vue @@ -37,73 +37,82 @@ {{ formattedAddress }}
-
- -
- {{ dropOffSection.label }} -
@@ -117,6 +126,7 @@ import { mapInshopTimeSlotToDisplaySlot, } from "@/layouts/schedule/helpers/schedule-helper"; import schedulingCardLoader from "@/layouts/scheduling/scheduling-card-loader/scheduling-card-loader"; +import availableAppointmentDateChips from "@/layouts/scheduling/available-appointment-date-chips/available-appointment-date-chips.vue"; function toTitleCase(str) { if (!str) return ""; @@ -133,7 +143,7 @@ const DROPOFF_CMS_NAME_TO_ROUTE_FLAG = { export default { name: "inshop-scheduling-card", - emits: ["update:modelValue", "address-clicked"], + emits: ["update:modelValue", "address-clicked", "date-selected"], props: { modelValue: { type: Object, @@ -163,6 +173,10 @@ export default { type: Boolean, default: false, }, + availableDateChips: { + type: Array, + default: () => [], + }, }, data() { return { @@ -272,6 +286,15 @@ export default { appointmentCountLabel() { return `${this.appointmentCount} appt${this.appointmentCount === 1 ? "" : "s"}`; }, + showAvailableDateChips() { + return this.availableDateChips.length > 0 && this.appointmentCount === 0; + }, + availableDatesLabel() { + return ( + this.getCmsContent(this.cmsWidgetName, "AvailableDatesLabel") || + "Available appointments:" + ); + }, selectedRouteCodeValue() { if ( this.modelValue?.appointmentType !== AppointmentTypeStrings.IN_SHOP || @@ -327,6 +350,7 @@ export default { }, components: { schedulingCardLoader, + availableAppointmentDateChips, }, }; diff --git a/src/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card.spec.js b/src/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card.spec.js index c771b6e9a..4e581cf9b 100644 --- a/src/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card.spec.js +++ b/src/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card.spec.js @@ -83,6 +83,29 @@ describe("mobile-scheduling-card.vue", () => { wrapper.unmount(); }); + test("shows available date chips when selected day has no slots and chips are provided", () => { + const wrapper = mountComponent({ + timeSlots: [], + availableDateChips: ["2026-06-11", "2026-06-14", "2026-06-16"], + }); + expect(wrapper.text()).toContain("Available appointments:"); + expect(wrapper.find("available-appointment-date-chips-stub").exists()).toBe(true); + expect(wrapper.find(".mobile-scheduling-card__time-slots").exists()).toBe(false); + wrapper.unmount(); + }); + + test("emits date-selected when an available date chip is selected", async () => { + const wrapper = mountComponent({ + timeSlots: [], + availableDateChips: ["2026-06-11"], + }); + await wrapper + .find("available-appointment-date-chips-stub") + .vm.$emit("date-selected", "2026-06-11"); + expect(wrapper.emitted("date-selected")[0]).toEqual(["2026-06-11"]); + wrapper.unmount(); + }); + test("shows free flag when showFreeFlag is true", () => { const wrapper = mountComponent({ showFreeFlag: true }); expect(wrapper.text()).toContain("We'll come to you for free!"); diff --git a/src/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card.vue b/src/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card.vue index b1e2586c1..87f8fe8d1 100644 --- a/src/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card.vue +++ b/src/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card.vue @@ -51,7 +51,12 @@ {{ addressHintText }}
-
+ +
{{ headerTitle }}
@@ -104,6 +110,11 @@ import { hasGlassPartsOrRepairInfo, hasInsuranceInfo, } from "@/helpers/page-prerequisites-helper.js"; +import { debugLog } from "@/helpers/debug-log-helper"; +import { + getAvailableAppointmentDatesToShow, + getAvailableDatesFromProviderDays, +} from "@/layouts/schedule/helpers/schedule-helper"; /** * Returns a YYYY-MM-DD date string offset by the given number of days from a base date. @@ -345,6 +356,15 @@ export default { ); return day?.timeSlots ?? []; }, + mobileAvailableDateChips() { + if (!this.selectedDate || this.mobileTimeSlotsForSelectedDate.length > 0) { + return []; + } + const availableDates = getAvailableDatesFromProviderDays( + this.mobileProviderAndTimeSlot?.timeSlots?.days + ); + return getAvailableAppointmentDatesToShow(availableDates, this.selectedDate); + }, premiumTimeSlotPrice() { if (this.mobilePremiumAppointmentFee?.partType !== PREMIUM_FEE_PART_TYPE) { return null; @@ -396,6 +416,24 @@ export default { const day = providerEntry?.timeSlots?.days?.find((d) => d.date === this.selectedDate); return day?.timeSlots ?? []; }, + getInshopAvailableDateChips(providerNumber) { + if ( + !this.selectedDate || + this.getInshopTimeSlotsForSelectedDate(providerNumber).length + ) { + return []; + } + const providerEntry = this.inShopProvidersAndTimeslots.find( + ({ provider }) => provider.providerNumber === providerNumber + ); + const availableDates = getAvailableDatesFromProviderDays( + providerEntry?.timeSlots?.days + ); + return getAvailableAppointmentDatesToShow(availableDates, this.selectedDate); + }, + onSchedulingDateSelected(date) { + this.selectedDate = date; + }, onInshopAddressClicked(provider) { // TODO: open Google Maps when address link functionality is implemented console.log("onInshopAddressClicked", provider?.providerNumber);