I’m trying to create an online booking sort of thing. I have this function set up to retrieve the available dates/times from my database, then compare them against times from my Google calendar using the google calendar api to display only the times that don’t conflict with any events marked busy. Everything works fine on my development server, but as soon as I upload to vercel for production, the times are off and therefore not properly being cross referenced with my Google calendar for conflicts. How can I get my production server to display the times correctly? I have tried using libraries like date-fns and specifying the timezone, but it still produced the same result in production. I’m using next.js so this is a server action being called and the result is displayed in my client component.
This is what my function looks like:
export const getAvailableAppointments = async (rmtLocationId, duration) => {
const db = await getDatabase();
const appointmentsCollection = db.collection("appointments");
// Convert duration to an integer
const durationMinutes = parseInt(duration, 10);
// Fetch appointments with the given rmtLocationId and status 'available'
const appointments = await appointmentsCollection
.find({
RMTLocationId: new ObjectId(rmtLocationId),
status: "available",
})
.toArray();
const availableTimes = [];
appointments.forEach((appointment) => {
const startTime = new Date(
`${appointment.appointmentDate}T${appointment.appointmentStartTime}`
);
const endTime = new Date(
`${appointment.appointmentDate}T${appointment.appointmentEndTime}`
);
let currentTime = new Date(startTime);
while (currentTime <= endTime) {
const nextTime = new Date(currentTime);
nextTime.setMinutes(currentTime.getMinutes() + durationMinutes);
if (nextTime <= endTime) {
availableTimes.push({
date: appointment.appointmentDate,
startTime: currentTime.toTimeString().slice(0, 5), // Format as HH:MM
endTime: nextTime.toTimeString().slice(0, 5), // Format as HH:MM
});
}
currentTime.setMinutes(currentTime.getMinutes() + 30); // Increment by 30 minutes
}
});
console.log("Available times:", availableTimes);
// Fetch busy times from Google Calendar
const now = new Date();
const oneMonthLater = new Date();
oneMonthLater.setMonth(now.getMonth() + 3);
const busyTimes = await calendar.freebusy.query({
requestBody: {
timeMin: now.toISOString(),
timeMax: oneMonthLater.toISOString(),
items: [{ id: GOOGLE_CALENDAR_ID }],
},
});
const busyPeriods = busyTimes.data.calendars[GOOGLE_CALENDAR_ID].busy;
// Convert busy times from UTC to local timezone and format them
const localBusyPeriods = busyPeriods.map((period) => {
const start = new Date(period.start);
const end = new Date(period.end);
// Add 30-minute buffer before and after the busy period
start.setMinutes(start.getMinutes() - 30);
end.setMinutes(end.getMinutes() + 30);
return {
date: start.toISOString().split("T")[0], // Extract date in YYYY-MM-DD format
startTime: start.toTimeString().slice(0, 5), // Format as HH:MM
endTime: end.toTimeString().slice(0, 5), // Format as HH:MM
};
});
console.log("Busy times (local with buffer):", localBusyPeriods);
// Filter out conflicting times
const filteredAvailableTimes = availableTimes.filter((available) => {
return !localBusyPeriods.some((busy) => {
return (
available.date === busy.date &&
((available.startTime >= busy.startTime &&
available.startTime < busy.endTime) ||
(available.endTime > busy.startTime &&
available.endTime <= busy.endTime) ||
(available.startTime <= busy.startTime &&
available.endTime >= busy.endTime))
);
});
});
console.log("Filtered available times:", filteredAvailableTimes);
// Filter out dates that are not greater than today
const today = new Date().toISOString().split("T")[0];
const futureAvailableTimes = filteredAvailableTimes.filter(
(available) => available.date > today
);
console.log("Future available times:", futureAvailableTimes);
// Sort the results by date
const sortedAvailableTimes = futureAvailableTimes.sort(
(a, b) => new Date(a.date) - new Date(b.date)
);
console.log("Sorted available times:", sortedAvailableTimes);
return sortedAvailableTimes;
};
This is an example of what the available times look like:
{ date: '2024-09-30', startTime: '13:00', endTime: '14:30' },
and this is an example of what the busy times from google calendar api look like after being formatted:
{ date: '2024-09-27', startTime: '12:30', endTime: '15:00' },
This is what my client component looks like:
"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { getAvailableAppointments, bookAppointment } from "@/app/_actions";
import { date } from "zod";
function BookMassageForm({ rmtSetup, user, healthHistory }) {
const router = useRouter();
const [currentStep, setCurrentStep] = useState(1);
const [appointmentTimes, setAppointmentTimes] = useState([]);
const [currentPage, setCurrentPage] = useState(0);
const [selectedAppointment, setSelectedAppointment] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [formData, setFormData] = useState({
location: "",
RMTLocationId: "",
duration: "",
appointmentTime: "",
workplace: "",
appointmentDate: "",
});
const handleInputChange = async (event) => {
const { name, value } = event.target;
setFormData((prevData) => ({ ...prevData, [name]: value }));
if (name === "location") {
const selectedSetup = rmtSetup.find(
(setup) => setup.formattedFormData.address.streetAddress === value
);
if (selectedSetup) {
setFormData((prevData) => ({
...prevData,
RMTLocationId: selectedSetup._id,
}));
}
}
};
useEffect(() => {
const fetchAppointments = async () => {
if (formData.RMTLocationId && formData.duration) {
setLoading(true);
setError(null);
try {
console.log(
`Fetching appointments for RMTLocationId: ${formData.RMTLocationId}, duration: ${formData.duration}`
);
const times = await getAvailableAppointments(
formData.RMTLocationId,
parseInt(formData.duration),
process.env.NEXT_PUBLIC_TIMEZONE
);
console.log("Fetched appointment times:", times);
// Sort the times array by date
const sortedTimes = times.sort(
(a, b) => new Date(a.date) - new Date(b.date)
);
console.log("Sorted appointment times:", sortedTimes);
// Group appointments by date
const groupedTimes = sortedTimes.reduce((acc, appointment) => {
const { date, startTime, endTime } = appointment;
if (!acc[date]) {
acc[date] = [];
}
acc[date].push({ startTime, endTime });
return acc;
}, {});
console.log("Grouped appointment times:", groupedTimes);
// Convert grouped times back to array format and format the dates and times
const groupedAppointments = Object.entries(groupedTimes).map(
([date, times]) => {
const formattedDate = new Date(
`${date}T00:00:00`
).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
const formattedTimes = times.map(({ startTime, endTime }) => {
const start = new Date(`${date}T${startTime}`);
const end = new Date(`${date}T${endTime}`);
return `${start.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "numeric",
hour12: true,
})} - ${end.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "numeric",
hour12: true,
})}`;
});
return {
date: formattedDate,
times: formattedTimes.sort((a, b) => a.localeCompare(b)),
};
}
);
console.log("Grouped appointments:", groupedAppointments);
setAppointmentTimes(groupedAppointments);
} catch (error) {
console.error("Error fetching appointment times:", error);
setError("Failed to fetch appointment times. Please try again.");
} finally {
setLoading(false);
}
}
};
fetchAppointments();
}, [formData.RMTLocationId, formData.duration]);
const renderAppointments = () => {
if (loading) {
return <p>Loading available appointments...</p>;
}
if (error) {
return <p className="text-red-500">{error}</p>;
}
if (!appointmentTimes || appointmentTimes.length === 0) {
return (
<p>
No available appointments found. Please try a different location or
duration.
</p>
);
}
const dates = appointmentTimes.slice(
currentPage * 5,
(currentPage + 1) * 5
);
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{dates.map((dateGroup, index) => (
<div key={index} className="bg-white shadow-md rounded-lg p-4">
<h4 className="text-lg font-semibold mb-2">{dateGroup.date}</h4>
<ul className="space-y-2">
{dateGroup.times.map((time, idx) => {
const isSelected =
selectedAppointment &&
selectedAppointment.date === dateGroup.date &&
selectedAppointment.time === time;
return (
<li
key={idx}
className={`cursor-pointer p-2 rounded transition-colors ${
isSelected
? "bg-blue-200 text-blue-800"
: "text-gray-700 hover:bg-gray-100"
}`}
onClick={() => {
setSelectedAppointment({
date: dateGroup.date,
time: time,
});
setFormData({
...formData,
appointmentTime: time,
appointmentDate: dateGroup.date,
});
}}
>
{time}
</li>
);
})}
</ul>
</div>
))}
</div>
);
};
const nextStep = () => {
setCurrentStep((prevStep) => prevStep + 1);
};
const prevStep = () => {
setCurrentStep((prevStep) => prevStep - 1);
};
return (
<form
action={async () => {
console.log("Submitting appointment:", formData);
await bookAppointment({
location: formData.location,
duration: formData.duration,
appointmentTime: formData.appointmentTime,
workplace: formData.workplace,
appointmentDate: formData.appointmentDate,
RMTLocationId: formData.RMTLocationId,
timezone: process.env.NEXT_PUBLIC_TIMEZONE,
});
}}
className="max-w-4xl mx-auto px-4 py-8 space-y-8"
>
{currentStep === 1 && (
<div className="space-y-4">
<h1 className="text-2xl sm:text-3xl">
Select the location where you would like to book a massage:
</h1>
<select
name="location"
value={formData.location}
onChange={handleInputChange}
required
className="w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-gray-800 focus:border-gray-800"
>
<option value="" disabled>
Select a location
</option>
{rmtSetup.map((setup, index) => (
<option
key={index}
value={setup.formattedFormData.address.streetAddress}
>
{setup.formattedFormData.address.locationName ||
setup.formattedFormData.address.streetAddress}
</option>
))}
</select>
<button
className="w-full sm:w-auto px-4 py-2 bg-gray-800 text-white rounded-md hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-800 focus:ring-offset-2"
type="button"
onClick={nextStep}
disabled={!formData.location}
>
Next
</button>
</div>
)}
{currentStep === 2 && (
<div className="space-y-4">
<h1 className="text-2xl sm:text-3xl">
What length of massage session would you like to book?
</h1>
<select
name="duration"
value={formData.duration}
onChange={handleInputChange}
className="w-full p-2 border border-gray-300 rounded-md shadow-sm focus:ring-gray-800 focus:border-gray-800"
>
<option value="" disabled>
Select a service
</option>
{rmtSetup
.find(
(setup) =>
setup.formattedFormData.address.streetAddress ===
formData.location
)
?.formattedFormData?.massageServices.map((service, index) => (
<option key={index} value={service.duration}>
{service.duration} minute {service.service} - ${service.price}{" "}
{service.plusHst ? "+HST" : ""}
</option>
))}
</select>
<div className="flex space-x-4">
<button
className="px-4 py-2 bg-gray-500 text-white rounded-md hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
type="button"
onClick={prevStep}
>
Back
</button>
<button
className="px-4 py-2 bg-gray-800 text-white rounded-md hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-800 focus:ring-offset-2"
type="button"
onClick={nextStep}
disabled={!formData.duration}
>
Next
</button>
</div>
</div>
)}
{currentStep === 3 && (
<div className="space-y-4">
<h1 className="text-2xl sm:text-3xl">
Select a date and time for your massage:
</h1>
{renderAppointments()}
{appointmentTimes.length > 0 && (
<div className="flex justify-between">
<button
type="button"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 0))}
disabled={currentPage === 0}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 disabled:opacity-50"
>
Previous
</button>
<button
type="button"
onClick={() => setCurrentPage((prev) => prev + 1)}
disabled={(currentPage + 1) * 5 >= appointmentTimes.length}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 disabled:opacity-50"
>
Next
</button>
</div>
)}
<div className="flex space-x-4">
<button
className="px-4 py-2 bg-gray-500 text-white rounded-md hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
type="button"
onClick={prevStep}
>
Back
</button>
<button
className="px-4 py-2 bg-gray-800 text-white rounded-md hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-800 focus:ring-offset-2"
type="button"
onClick={nextStep}
disabled={!selectedAppointment}
>
Next
</button>
</div>
</div>
)}
{currentStep === 4 && (
<div className="space-y-4">
<h1 className="text-2xl sm:text-3xl">
Does the following information look correct?
</h1>
<div className="bg-white shadow-md rounded-lg p-4 space-y-2">
<p>
<strong>Location:</strong> {formData.location}
</p>
<p>
<strong>Duration:</strong> {formData.duration} minutes
</p>
<p>
<strong>Date:</strong> {formatDate(formData.appointmentDate)}
</p>
<p>
<strong>Time:</strong> {formatTime(formData.appointmentTime)}
</p>
</div>
<div className="flex space-x-4">
<button
className="px-4 py-2 bg-gray-800 text-white rounded-md hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-800 focus:ring-offset-2"
type="submit"
>
Yes, Book Appointment
</button>
<button
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
type="button"
onClick={prevStep}
>
No, Go Back
</button>
</div>
</div>
)}
</form>
);
}
export default BookMassageForm;