// ========================================
// 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 = [];
let datePickerMonth = null;
let datePickerYear = null;
let userMessage = null;
let previousWeekStart = null;
// Background loading state
let backgroundLoadComplete = false;
let backgroundLoadPromise = null;
let loadedThroughDate = null; // tracks how far we've fetched
let skipTimeAnimation = false; // suppresses time slot animation on silent bg re-renders
// ========================================
// INITIALIZATION
// ========================================
init();
async function init() {
setupEventListeners();
await loadInitialWeeks();
}
function setupEventListeners() {
document.getElementById('prevWeek').addEventListener('click', (e) => {
e.preventDefault();
navigateWeek(-7);
});
document.getElementById('nextWeek').addEventListener('click', (e) => {
e.preventDefault();
navigateWeek(7);
});
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));
document.addEventListener('click', (e) => {
if (e.target.closest('.jump-next-day')) {
e.preventDefault();
jumpToNextAvailableDay();
}
});
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}`;
const maxDateStr = addDaysToDateString(todayStr, maxWeeks * 7);
showLoading(true);
// --- Determine the first batch end date ---
// Fetch today through end of this Sunday (current week).
const thisWeekRange = getWeekRangeFromString(todayStr);
const thisSundayStr = thisWeekRange.endLte.match(/^(\d{4}-\d{2}-\d{2})/)[1];
const firstBatchStart = formatDateInTimezone(todayStr, '00:00:00');
const firstBatchEnd = formatDateInTimezone(thisSundayStr, '23:59:59');
let firstBatchSlots = await fetchSlots(firstBatchStart, firstBatchEnd);
// If no slots this partial/full week, also fetch next full week immediately
// so the user sees something useful without a second loading flash.
const firstBatchHasSlots = firstBatchSlots.length > 0;
if (!firstBatchHasSlots && thisSundayStr < maxDateStr) {
const nextMonday = addDaysToDateString(thisSundayStr, 1);
const nextSunday = addDaysToDateString(nextMonday, 6);
const clampedNextSunday = nextSunday > maxDateStr ? maxDateStr : nextSunday;
const secondBatchStart = formatDateInTimezone(nextMonday, '00:00:00');
const secondBatchEnd = formatDateInTimezone(clampedNextSunday, '23:59:59');
const secondBatchSlots = await fetchSlots(secondBatchStart, secondBatchEnd);
allSlots = [...firstBatchSlots, ...secondBatchSlots];
loadedThroughDate = clampedNextSunday;
} else {
allSlots = firstBatchSlots;
loadedThroughDate = thisSundayStr;
}
buildDoctorRoster();
// Set initial week display
const todayWeekRange = getWeekRangeFromString(todayStr);
currentWeekStart = todayWeekRange.startGte;
currentWeekEnd = todayWeekRange.endLte;
// Determine selected day
const todaySlots = getSlotsForDate(todayStr);
if (todaySlots.length === 0) {
const futureSlots = allSlots.filter(s => getSlotDateString(s) > todayStr);
if (futureSlots.length > 0) {
const firstAvailableDay = getSlotDateString(futureSlots[0]);
const weekRange = getWeekRangeFromString(firstAvailableDay);
currentWeekStart = weekRange.startGte;
currentWeekEnd = weekRange.endLte;
selectedDay = firstAvailableDay;
document.getElementById('autoPush').classList.add('active');
} else {
selectedDay = todayStr;
}
} else {
selectedDay = todayStr;
}
selectedDoctor = null;
document.getElementById('selectedDoctorText').innerHTML = `
No doctor preference
`;
render();
showLoading(false);
// Kick off background loading for the remaining weeks (fire and forget)
if (loadedThroughDate < maxDateStr) {
backgroundLoadPromise = loadRemainingWeeksInBackground(loadedThroughDate, maxDateStr);
} else {
backgroundLoadComplete = true;
}
}
async function loadRemainingWeeksInBackground(fromDate, maxDateStr) {
const nextStart = addDaysToDateString(fromDate, 1);
if (nextStart > maxDateStr) {
backgroundLoadComplete = true;
buildDoctorRoster();
render();
return;
}
const startGte = formatDateInTimezone(nextStart, '00:00:00');
const endLte = formatDateInTimezone(maxDateStr, '23:59:59');
try {
const remainingSlots = await fetchSlots(startGte, endLte);
allSlots = [...allSlots, ...remainingSlots];
backgroundLoadComplete = true;
loadedThroughDate = maxDateStr;
buildDoctorRoster();
// Silently re-render to update doctor list and jump buttons with full data
skipTimeAnimation = true;
render();
skipTimeAnimation = false;
} catch (err) {
console.warn('Background load failed:', err);
backgroundLoadComplete = true;
}
}
// Ensures background data is loaded before navigating to a date beyond what's been fetched.
async function ensureDataLoadedForDate(dateStr) {
if (backgroundLoadComplete) return;
if (dateStr <= loadedThroughDate) return;
// User navigated ahead of loaded data — briefly show loading and wait
showLoading(true);
await backgroundLoadPromise;
showLoading(false);
}
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);
await ensureDataLoadedForDate(dateString);
const weekRange = getWeekRangeFromString(dateString);
const isSameWeek = weekRange.startGte === currentWeekStart && weekRange.endLte === currentWeekEnd;
if (isSameWeek) {
handleSameWeekDateSelection(dateString);
return;
}
currentWeekStart = weekRange.startGte;
currentWeekEnd = weekRange.endLte;
document.getElementById('autoPush').classList.remove('active');
if (selectedDoctor) {
const weekDoctorSlots = getSlotsForWeek().filter(s => s.staff?.id === selectedDoctor);
if (weekDoctorSlots.length > 0) {
selectedDay = dateString;
userMessage = null;
} else {
selectedDoctor = null;
document.getElementById('selectedDoctorText').innerHTML = `
No doctor preference
`;
await handleDateSelection(dateString, allSlots);
}
} else {
await handleDateSelection(dateString, allSlots);
}
render();
}
async function loadWeekFromDateStringNoPreference(dateString) {
await ensureDataLoadedForDate(dateString);
const weekRange = getWeekRangeFromString(dateString);
currentWeekStart = weekRange.startGte;
currentWeekEnd = weekRange.endLte;
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');
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 {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
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) {
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;
}
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);
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) {
selectedDay = dateString;
userMessage = null;
document.getElementById('autoPush').classList.remove('active');
render();
}
async function handleDateSelection(dateString, slots) {
selectedDay = dateString;
userMessage = null;
document.getElementById('autoPush').classList.remove('active');
}
async function jumpToNextAvailableDay() {
console.log('=== JUMP BUTTON CLICKED ===');
console.log('Current selected day:', selectedDay);
const currentWeekEndDate = currentWeekEnd.match(/^(\d{4}-\d{2}-\d{2})/)[1];
const laterSlots = getSlotsOnOrAfterDate(selectedDay);
const futureSlotsThisWeek = laterSlots.filter(s => {
const slotDate = getSlotDateString(s);
return slotDate > selectedDay && slotDate <= currentWeekEndDate;
});
if (futureSlotsThisWeek.length > 0) {
selectedDay = getSlotDateString(futureSlotsThisWeek[0]);
userMessage = null;
render();
return;
}
await searchForwardForSlots(selectedDay);
}
async function jumpToNextAvailableDayForDoctor() {
console.log('=== JUMP DOCTOR DAY BUTTON CLICKED ===');
if (!selectedDoctor) return;
const currentWeekEndDate = currentWeekEnd.match(/^(\d{4}-\d{2}-\d{2})/)[1];
const doctorSlots = allSlots.filter(s => s.staff?.id === selectedDoctor);
const laterDoctorSlots = doctorSlots.filter(s => getSlotDateString(s) >= selectedDay);
const futureDoctorSlotsThisWeek = laterDoctorSlots.filter(s => {
const slotDate = getSlotDateString(s);
return slotDate > selectedDay && slotDate <= currentWeekEndDate;
});
if (futureDoctorSlotsThisWeek.length > 0) {
selectedDay = getSlotDateString(futureDoctorSlotsThisWeek[0]);
userMessage = null;
render();
return;
}
await searchForwardForSlotsForDoctor(selectedDay);
}
async function searchForwardForSlots(fromDateStr) {
console.log('Searching forward for slots from:', fromDateStr);
// If bg load is in-flight and we might need data beyond what's loaded, wait for it
if (!backgroundLoadComplete && fromDateStr >= loadedThroughDate) {
showLoading(true);
await backgroundLoadPromise;
showLoading(false);
}
const futureSlots = allSlots.filter(s => getSlotDateString(s) > fromDateStr);
if (futureSlots.length > 0) {
const firstSlotDateStr = getSlotDateString(futureSlots[0]);
const weekRange = getWeekRangeFromString(firstSlotDateStr);
currentWeekStart = weekRange.startGte;
currentWeekEnd = weekRange.endLte;
selectedDay = firstSlotDateStr;
userMessage = null;
render();
return;
}
render();
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);
if (!backgroundLoadComplete && fromDateStr >= loadedThroughDate) {
showLoading(true);
await backgroundLoadPromise;
showLoading(false);
}
const futureDoctorSlots = allSlots.filter(s =>
s.staff?.id === selectedDoctor && getSlotDateString(s) > fromDateStr
);
if (futureDoctorSlots.length > 0) {
const firstSlotDateStr = getSlotDateString(futureDoctorSlots[0]);
const weekRange = getWeekRangeFromString(firstSlotDateStr);
currentWeekStart = weekRange.startGte;
currentWeekEnd = weekRange.endLte;
selectedDay = firstSlotDateStr;
userMessage = null;
render();
return;
}
render();
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;
}
// ========================================
// 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);
loadWeekFromDateStringNoPreference(newDateStr);
}
// ========================================
// DATE PICKER
// ========================================
function openDatePicker() {
if (selectedDay) {
const [year, month] = selectedDay.split('-').map(Number);
datePickerYear = year;
datePickerMonth = month - 1;
} 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();
for (let i = 0; i < firstDayAdjusted; i++) {
const empty = document.createElement('div');
empty.className = 'date-picker-day empty';
daysContainer.appendChild(empty);
}
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 = '';
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 = `