// ========================================
// CONFIGURATION
// ========================================
const API_URL = "https://www.modernanimal.com/v3/public/";
const STAGING_API_URL = "https://staging.modernanimal.com/v3/public/";
const CORS_PROXY = "https://ma-cors-proxy.info-e9b.workers.dev";
const AUTH_TOKEN = "10dd78c8441ed4079f2b21e8e17690e7c3122151";
const locationId = document.getElementById('wf-form-Widget').dataset.locationId;
const locationTimeZone = document.getElementById('wf-form-Widget').dataset.locationTimeZone;
const maxWeeks = 8;
const hostname = window.location.hostname;
// ========================================
// STATE
// ========================================
let targetURL;
if (hostname === 'www.modernanimal.com' || hostname === 'modernanimal.com') {
targetURL = API_URL;
} else if (hostname === 'staging.modernanimal.com') {
targetURL = STAGING_API_URL;
} else {
targetURL = CORS_PROXY;
}
let currentWeekStart = null;
let currentWeekEnd = null;
let selectedDay = null;
let selectedDoctor = null;
let allSlots = [];
let allDoctors = []; // Store all doctors from 8-week fetch
let datePickerMonth = null;
let datePickerYear = null;
let userMessage = null;
let previousWeekStart = null;
// ========================================
// INITIALIZATION
// ========================================
init();
async function init() {
setupEventListeners();
await loadInitialWeeks();
}
function setupEventListeners() {
// Week navigation
document.getElementById('prevWeek').addEventListener('click', (e) => {
e.preventDefault();
navigateWeek(-7);
});
document.getElementById('nextWeek').addEventListener('click', (e) => {
e.preventDefault();
navigateWeek(7);
});
// Date picker
document.getElementById('dateRangeDisplay').addEventListener('click', openDatePicker);
document.getElementById('customDatePickerModal').addEventListener('click', (e) => {
if (e.target.id === 'customDatePickerModal') closeDatePicker();
});
document.getElementById('dateCancelBtn').addEventListener('click', closeDatePicker);
document.getElementById('datePrevMonth').addEventListener('click', () => changePickerMonth(-1));
document.getElementById('dateNextMonth').addEventListener('click', () => changePickerMonth(1));
// UPDATED: Jump to next day handler - using class selector to catch all instances
document.addEventListener('click', (e) => {
if (e.target.closest('.jump-next-day')) {
e.preventDefault();
jumpToNextAvailableDay();
}
});
// UPDATED: Jump to next day for selected doctor handler - using class selector
document.addEventListener('click', (e) => {
if (e.target.closest('.jump-next-dr-day')) {
e.preventDefault();
jumpToNextAvailableDayForDoctor();
}
});
}
// ========================================
// DATA LOADING
// ========================================
async function loadInitialWeeks() {
const today = getTodayInTimezone();
const todayStr = `${today.year}-${today.month}-${today.day}`;
showLoading(true);
// Fetch all 8 weeks at once
const startGte = formatDateInTimezone(todayStr, '00:00:00');
const maxDateStr = addDaysToDateString(todayStr, maxWeeks * 7);
const endLte = formatDateInTimezone(maxDateStr, '23:59:59');
allSlots = await fetchSlots(startGte, endLte);
// Build full doctor roster from all 8 weeks
buildDoctorRoster();
// Set current week range for display purposes
const todayWeekRange = getWeekRangeFromString(todayStr);
currentWeekStart = todayWeekRange.startGte;
currentWeekEnd = todayWeekRange.endLte;
// Check if today has any slots
const todaySlots = getSlotsForDate(todayStr);
if (todaySlots.length === 0) {
// No slots today - find next available day
const futureSlots = allSlots.filter(s => getSlotDateString(s) > todayStr);
if (futureSlots.length > 0) {
// Found future slots - jump to first available day
const firstAvailableDay = getSlotDateString(futureSlots[0]);
const weekRange = getWeekRangeFromString(firstAvailableDay);
currentWeekStart = weekRange.startGte;
currentWeekEnd = weekRange.endLte;
selectedDay = firstAvailableDay;
// Show auto-push message
document.getElementById('autoPush').classList.add('active');
} else {
// No future slots at all - stay on today
selectedDay = todayStr;
}
} else {
// Today has slots - select today normally
selectedDay = todayStr;
}
selectedDoctor = null;
document.getElementById('selectedDoctorText').innerHTML = `
No doctor preference
`;
render();
showLoading(false);
}
// Build the complete doctor roster from all slots
function buildDoctorRoster() {
const doctorMap = {};
allSlots.forEach(slot => {
if (slot.staff && slot.staff.id) {
doctorMap[slot.staff.id] = slot.staff;
}
});
allDoctors = Object.values(doctorMap);
console.log('Built doctor roster:', allDoctors.length, 'doctors');
}
async function loadWeekFromDateString(dateString) {
console.log('Loading week from date string:', dateString);
const weekRange = getWeekRangeFromString(dateString);
const isSameWeek = weekRange.startGte === currentWeekStart && weekRange.endLte === currentWeekEnd;
if (isSameWeek) {
// Same week - just update selected day without refetching
handleSameWeekDateSelection(dateString);
return;
}
// Different week - no fetch needed, just update week range
// All data already loaded in loadInitialWeeks()
currentWeekStart = weekRange.startGte;
currentWeekEnd = weekRange.endLte;
// Hide auto-push message when user manually navigates
document.getElementById('autoPush').classList.remove('active');
// Check if selected doctor has availability in this week
if (selectedDoctor) {
const weekDoctorSlots = getSlotsForWeek().filter(s => s.staff?.id === selectedDoctor);
if (weekDoctorSlots.length > 0) {
// Doctor has availability this week - But only select the date user picked
selectedDay = dateString;
userMessage = null;
} else {
// Doctor has no availability this week - reset to no preference
selectedDoctor = null;
document.getElementById('selectedDoctorText').innerHTML = `
No doctor preference
`;
await handleDateSelection(dateString, allSlots);
}
} else {
// No doctor selected - proceed normally
await handleDateSelection(dateString, allSlots);
}
render();
}
async function loadWeekFromDateStringNoPreference(dateString) {
const weekRange = getWeekRangeFromString(dateString);
// No fetch needed - all data already loaded
currentWeekStart = weekRange.startGte;
currentWeekEnd = weekRange.endLte;
// Select today if Monday is in past
const today = getTodayInTimezone();
const todayStr = `${today.year}-${today.month}-${today.day}`;
const weekStartStr = currentWeekStart.match(/^(\d{4}-\d{2}-\d{2})/)[1];
selectedDay = weekStartStr < todayStr ? todayStr : weekStartStr;
userMessage = null;
document.getElementById('autoPush').classList.remove('active');
// Keep selectedDoctor as is (don't reset anymore)
render();
}
async function fetchSlots(startGte, endLte) {
const graphqlPayload = {
query: `
query GetSlots($startGte: DateTime!, $endLte: DateTime!, $locationIds: [ID!]) {
slots(
filters: {
start: { gte: $startGte }
end: { lte: $endLte }
location: { id: { inList: $locationIds } }
appointmentType: { inList: [STANDARD] }
appointment: { id: { isNull: true } }
published: true
isDropOff: false
}
order: { start: ASC }
) {
bookingUrl
start
end
staff {
id
name
image { url }
}
}
}
`,
variables: {
startGte,
endLte,
locationIds: [locationId]
}
};
try {
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 second timeout
const res = await fetch(targetURL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "*/*",
"Authorization": `Bearer ${AUTH_TOKEN}`
},
body: JSON.stringify(graphqlPayload),
signal: controller.signal
});
clearTimeout(timeoutId);
const data = await res.json();
if (data.data && data.data.slots) {
// Filter out slots without valid staff AND slots in the past
return data.data.slots.filter(slot => {
if (!slot.staff || !slot.staff.id || !slot.staff.name) {
console.warn('Slot without valid staff data:', slot.bookingUrl || slot.start);
return false;
}
// Filter out past slots
const slotTime = new Date(slot.start);
const now = new Date();
if (slotTime <= now) {
return false;
}
return true;
});
}
return [];
} catch (err) {
console.error('API Error:', err);
// Different message for timeout vs other errors
if (err.name === 'AbortError') {
userMessage = 'Request timed out. Please check your connection and try again or call us at (866) 505-8755.';
} else {
userMessage = 'Failed to load appointments. Please try again or call us at (866) 505-8755.';
}
showLoading(false);
return [];
}
}
// ========================================
// DATE SELECTION LOGIC
// ========================================
function handleSameWeekDateSelection(dateString) {
const requestedDateSlots = getSlotsForDate(dateString);
selectedDay = dateString;
userMessage = null;
document.getElementById('autoPush').classList.remove('active');
// Don't reset doctor anymore - keep selection persistent
render();
}
async function handleDateSelection(dateString, slots) {
const requestedDateSlots = slots.filter(s => getSlotDateString(s) === dateString);
selectedDay = dateString;
userMessage = null;
document.getElementById('autoPush').classList.remove('active');
}
async function jumpToNextAvailableDay() {
console.log('=== JUMP BUTTON CLICKED ===');
console.log('Current selected day:', selectedDay);
// Get the current week's end date
const currentWeekEndDate = currentWeekEnd.match(/^(\d{4}-\d{2}-\d{2})/)[1];
// First check if there are any slots later in the current week ONLY
const laterSlots = getSlotsOnOrAfterDate(selectedDay);
console.log('Later slots count:', laterSlots.length);
// Filter to only slots AFTER the currently selected day AND within current week
const futureSlotsThisWeek = laterSlots.filter(s => {
const slotDate = getSlotDateString(s);
return slotDate > selectedDay && slotDate <= currentWeekEndDate;
});
console.log('Future slots this week:', futureSlotsThisWeek.length);
if (futureSlotsThisWeek.length > 0) {
// Found slots later this week - select the first one
selectedDay = getSlotDateString(futureSlotsThisWeek[0]);
userMessage = null;
// Don't reset doctor anymore
render();
return;
}
// No slots later this week - search forward through already-loaded data
await searchForwardForSlots(selectedDay);
}
async function jumpToNextAvailableDayForDoctor() {
console.log('=== JUMP DOCTOR DAY BUTTON CLICKED ===');
console.log('Current selected day:', selectedDay);
console.log('Selected doctor:', selectedDoctor);
if (!selectedDoctor) {
console.log('No doctor selected - should not happen');
return;
}
// Get the current week's end date
const currentWeekEndDate = currentWeekEnd.match(/^(\d{4}-\d{2}-\d{2})/)[1];
// Get slots for selected doctor after current day
const doctorSlots = allSlots.filter(s => s.staff?.id === selectedDoctor);
const laterDoctorSlots = doctorSlots.filter(s => getSlotDateString(s) >= selectedDay);
// Filter to only slots AFTER the currently selected day AND within current week
const futureDoctorSlotsThisWeek = laterDoctorSlots.filter(s => {
const slotDate = getSlotDateString(s);
return slotDate > selectedDay && slotDate <= currentWeekEndDate;
});
if (futureDoctorSlotsThisWeek.length > 0) {
// Found slots later this week for this doctor
selectedDay = getSlotDateString(futureDoctorSlotsThisWeek[0]);
userMessage = null;
render();
return;
}
// No slots later this week - search forward through all loaded data
await searchForwardForSlotsForDoctor(selectedDay);
}
async function searchForwardForSlots(fromDateStr) {
console.log('Searching forward for slots from:', fromDateStr);
// Find all slots that come after the selected date
const futureSlots = allSlots.filter(s => getSlotDateString(s) > fromDateStr);
if (futureSlots.length > 0) {
// Found slots - jump to the first one
const firstSlot = futureSlots[0];
const firstSlotDateStr = getSlotDateString(firstSlot);
// Update to the week containing this slot
const weekRange = getWeekRangeFromString(firstSlotDateStr);
currentWeekStart = weekRange.startGte;
currentWeekEnd = weekRange.endLte;
console.log('Updated week range:', currentWeekStart, 'to', currentWeekEnd);
console.log('Selected day:', selectedDay);
selectedDay = firstSlotDateStr;
userMessage = null;
// Don't reset doctor anymore
render();
return;
}
// No slots found in the loaded data
render();
// hide jump button
const jumpBtn = document.getElementById('jumpNextDay');
if (jumpBtn) {
jumpBtn.classList.remove('active');
console.log('No future slots found - hiding jump button');
}
}
async function searchForwardForSlotsForDoctor(fromDateStr) {
console.log('Searching forward for doctor slots from:', fromDateStr);
// Find all slots for selected doctor that come after the selected date
const futureDoctorSlots = allSlots.filter(s =>
s.staff?.id === selectedDoctor && getSlotDateString(s) > fromDateStr
);
if (futureDoctorSlots.length > 0) {
// Found slots - jump to the first one
const firstSlot = futureDoctorSlots[0];
const firstSlotDateStr = getSlotDateString(firstSlot);
// Update to the week containing this slot
const weekRange = getWeekRangeFromString(firstSlotDateStr);
currentWeekStart = weekRange.startGte;
currentWeekEnd = weekRange.endLte;
selectedDay = firstSlotDateStr;
userMessage = null;
render();
return;
}
// No slots found for this doctor in the loaded data
render();
// hide jump button for doctor
const jumpBtn = document.getElementById('jumpNextDrDay');
if (jumpBtn) {
jumpBtn.classList.remove('active');
console.log('No future doctor slots found - hiding jump button');
}
}
function findBestDayToSelect(preferredDateStr, slots) {
const slotsForPreferred = slots.filter(s => getSlotDateString(s) === preferredDateStr);
if (slotsForPreferred.length > 0) {
return preferredDateStr;
}
return preferredDateStr; // <-- STAY ON PREFERRED DATE
}
// ========================================
// WEEK NAVIGATION
// ========================================
function navigateWeek(days) {
const currentDateStr = currentWeekStart.match(/^(\d{4}-\d{2}-\d{2})/)[1];
const newDateStr = addDaysToDateString(currentDateStr, days);
console.log(`${days > 0 ? 'Next' : 'Prev'} week: going from Monday`, currentDateStr, 'to Monday', newDateStr);
// Keep doctor selected, just navigate to new week
loadWeekFromDateStringNoPreference(newDateStr);
}
// ========================================
// DATE PICKER
// ========================================
function openDatePicker() {
// If we have a selected day, open to that month, otherwise open to current month
if (selectedDay) {
const [year, month] = selectedDay.split('-').map(Number);
datePickerYear = year;
datePickerMonth = month - 1; // 0-indexed
} else {
const today = getTodayInTimezone();
datePickerMonth = parseInt(today.month) - 1;
datePickerYear = parseInt(today.year);
}
renderDatePicker();
document.getElementById('customDatePickerModal').classList.add('active');
}
function closeDatePicker() {
document.getElementById('customDatePickerModal').classList.remove('active');
}
function changePickerMonth(direction) {
datePickerMonth += direction;
if (datePickerMonth < 0) {
datePickerMonth = 11;
datePickerYear--;
} else if (datePickerMonth > 11) {
datePickerMonth = 0;
datePickerYear++;
}
renderDatePicker();
}
function renderDatePicker() {
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
document.getElementById('dateMonthYear').textContent = `${monthNames[datePickerMonth]} ${datePickerYear}`;
const daysContainer = document.getElementById('datePickerDays');
daysContainer.innerHTML = '';
const today = getTodayInTimezone();
const todayStr = `${today.year}-${today.month}-${today.day}`;
const maxDateStr = addDaysToDateString(todayStr, maxWeeks * 7);
const firstDay = new Date(datePickerYear, datePickerMonth, 1).getDay();
const firstDayAdjusted = firstDay === 0 ? 6 : firstDay - 1;
const daysInMonth = new Date(datePickerYear, datePickerMonth + 1, 0).getDate();
// Empty cells before month starts
for (let i = 0; i < firstDayAdjusted; i++) {
const empty = document.createElement('div');
empty.className = 'date-picker-day empty';
daysContainer.appendChild(empty);
}
// Days of month
for (let day = 1; day <= daysInMonth; day++) {
const dateStr = `${datePickerYear}-${String(datePickerMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const isPast = dateStr < todayStr;
const isBeyondLimit = dateStr > maxDateStr;
const isToday = dateStr === todayStr;
const dayEl = document.createElement('div');
dayEl.className = 'date-picker-day';
dayEl.textContent = day;
if (isPast || isBeyondLimit) {
dayEl.classList.add('disabled');
} else {
if (isToday) dayEl.classList.add('today');
dayEl.addEventListener('click', () => {
loadWeekFromDateString(dateStr);
closeDatePicker();
});
}
daysContainer.appendChild(dayEl);
}
}
// ========================================
// RENDERING
// ========================================
function render() {
renderDateRange();
renderDays();
renderUserMessage();
renderDoctors();
renderTimes();
updateNavigationButtons();
}
function renderDateRange() {
const startStr = currentWeekStart.match(/^(\d{4}-\d{2}-\d{2})/)[1];
const endStr = currentWeekEnd.match(/^(\d{4}-\d{2}-\d{2})/)[1];
const [startYear, startMonth, startDay] = startStr.split('-').map(Number);
const [endYear, endMonth, endDay] = endStr.split('-').map(Number);
const start = new Date(startYear, startMonth - 1, startDay);
const end = new Date(endYear, endMonth - 1, endDay);
const format = (d) => new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }).format(d);
document.getElementById('startDate').textContent = format(start);
document.getElementById('endDate').textContent = format(end);
}
function renderDays() {
const dayList = document.getElementById('dayList');
dayList.innerHTML = '';
// Check if week actually changed
const shouldAnimate = previousWeekStart !== currentWeekStart;
previousWeekStart = currentWeekStart;
const today = getTodayInTimezone();
const todayStr = `${today.year}-${today.month}-${today.day}`;
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const weekStartParts = currentWeekStart.match(/^(\d{4})-(\d{2})-(\d{2})/);
const weekStartDate = new Date(`${weekStartParts[1]}-${weekStartParts[2]}-${weekStartParts[3]}T00:00:00`);
for (let i = 0; i < 7; i++) {
const dayDate = new Date(weekStartDate);
dayDate.setDate(weekStartDate.getDate() + i);
const year = dayDate.getFullYear();
const month = String(dayDate.getMonth() + 1).padStart(2, '0');
const day = String(dayDate.getDate()).padStart(2, '0');
const dayStr = `${year}-${month}-${day}`;
const daySlots = getSlotsForDate(dayStr);
const isPast = dayStr < todayStr;
const maxDateStr = addDaysToDateString(todayStr, maxWeeks * 7);
const isBeyondLimit = dayStr > maxDateStr;
const hasSlots = daySlots.length > 0;
const isSelected = dayStr === selectedDay;
const isToday = dayStr === todayStr;
const classes = ['widget_day-item'];
if (isPast || isBeyondLimit) classes.push('is-inactive');
else if (!hasSlots) classes.push('is-blocked');
if (isSelected) classes.push('is-active');
const a = document.createElement('a');
a.href = '#';
a.className = classes.join(' ');
a.innerHTML = `