Creating a Calendar Booking System from Scratch
Hướng dẫn chi tiết về Creating a Calendar Booking System from Scratch trong Vibe Coding dành cho None.
Creating a Calendar Booking System from Scratch: The Intermediate Guide to Temporal Architecture
The Scheduling Tax: Why We Build Our Own
We’ve all been there. You have a brilliant idea for a service-based platform, a consulting marketplace, or a niche community event site. You look at the market for scheduling solutions and find two extremes: expensive SaaS products that charge per user and lock your data in a proprietary silo, or “simple” plug-ins that crumble the moment you need a custom UI or a unique booking flow.
In the world of Vibe Coding, we prioritize speed, but we never sacrifice the “vibe”—that specific, polished feeling of a system that works exactly how you imagined it. Relying on an external iframe for your calendar isn’t just a design compromise; it’s a technical bottleneck. It prevents you from creating deep integrations, like automated loyalty discounts, custom multi-person availability, or complex resource-locking (e.g., booking a consultant and a specific meeting room simultaneously).
Building a calendar booking system is the quintessential “Intermediate” project. It moves beyond simple CRUD (Create, Read, Update, Delete) and into the realm of Temporal Logic. You aren’t just storing strings; you are managing the intersection of human availability, global timezones, and concurrency. This guide will show you how to architect a production-grade booking engine from the ground up.
Core Concepts: The Engine Under the Hood
To build a booking system that doesn’t fail the moment two people click “Book” at the same time, you need to understand three core pillars: Availability Windows, Temporal Arithmetic, and Concurrency Control.
1. Availability vs. Bookings
Beginners often try to store “Available Slots” in their database. This is a mistake. If you offer 30-minute meetings from 9:00 AM to 5:00 PM, and you store every single slot, your database will bloat instantly. Worse, if you decide to change your meeting length from 30 to 45 minutes, you have to delete and re-generate thousands of rows.
The Pro Approach: Store “Availability Rules” and “Actual Bookings.”
- Availability Rules: “User A is available Mondays 9-5 and Tuesdays 10-2.”
- Actual Bookings: “User B has booked User A on Monday from 10:00 to 10:30.”
The “Available Slots” you see on the screen are a computed view. Your code looks at the rules, subtracts the existing bookings, and generates the remaining gaps on the fly.
2. The Timezone Trap
If your system is used by people in different cities, timezones will destroy your logic if you aren’t disciplined.
- Rule #1: Store everything in the database in UTC.
- Rule #2: Only convert to the user’s local time at the Edge (the browser or the final API response layer).
- Rule #3: Use ISO 8601 strings. Never trust “10:00 AM” without an offset.
3. Concurrency (The Double-Booking Nightmare)
What happens if two users see the 2:00 PM slot and click “Confirm” at the exact same millisecond? In a naive system, both bookings are saved. In a Vibe Coding system, we use Database Transactions or Atomic Updates. We check if the slot is still free inside the same operation that saves the new booking.
The Practical Implementation: A Vibe Coding Workflow
We will use a modern stack: Next.js for the framework, Prisma as our ORM, and PostgreSQL (via Supabase or Neon) for our temporal data.
Step 1: The Schema
Your database needs to represent the relationship between the provider, their schedule, and the appointments.
// schema.prisma
model User {
id String @id @default(cuid())
name String
email String @unique
schedules Schedule[] // Recurring availability
appointments Appointment[] // Actual bookings
}
model Schedule {
id String @id @default(cuid())
userId String
dayOfWeek Int // 0-6 (Sunday-Saturday)
startTime String // "09:00"
endTime String // "17:00"
user User @relation(fields: [userId], references: [id])
}
model Appointment {
id String @id @default(cuid())
userId String
guestName String
guestEmail String
startTime DateTime // Stored in UTC
endTime DateTime // Stored in UTC
status String @default("CONFIRMED")
user User @relation(fields: [userId], references: [id])
}
Step 2: Generating the “Gaps” (The Logic)
This is the heart of the system. We need a function that takes a date and a user ID and returns a list of available 30-minute windows.
import { addMinutes, format, parse, startOfDay, isWithinInterval } from 'date-fns';
async function getAvailableSlots(userId: string, targetDate: Date) {
// 1. Fetch user's availability for that day of the week
const dayOfWeek = targetDate.getDay();
const availability = await db.schedule.findFirst({
where: { userId, dayOfWeek }
});
if (!availability) return [];
// 2. Fetch existing appointments for that date
const existingBookings = await db.appointment.findMany({
where: {
userId,
startTime: {
gte: startOfDay(targetDate),
lte: addMinutes(startOfDay(targetDate), 1440),
}
}
});
// 3. Generate potential slots
const slots = [];
let current = parse(availability.startTime, 'HH:mm', targetDate);
const end = parse(availability.endTime, 'HH:mm', targetDate);
while (current < end) {
const slotStart = current;
const slotEnd = addMinutes(current, 30); // 30-min duration
// Check if this slot overlaps with any existing booking
const isBooked = existingBookings.some(booking => {
return (
(slotStart >= booking.startTime && slotStart < booking.endTime) ||
(slotEnd > booking.startTime && slotEnd <= booking.endTime)
);
});
if (!isBooked) {
slots.push(format(slotStart, "HH:mm"));
}
current = slotEnd; // Move to next slot
}
return slots;
}
Step 3: Atomic Booking
To prevent double bookings, we wrap the “Check and Save” logic in a Prisma transaction.
async function createBooking(data: { userId: string, start: Date, end: Date, name: string }) {
return await db.$transaction(async (tx) => {
// Check if the slot was taken while the user was filling the form
const conflict = await tx.appointment.findFirst({
where: {
userId: data.userId,
OR: [
{ startTime: { lt: data.end, gte: data.start } },
{ endTime: { gt: data.start, lte: data.end } }
]
}
});
if (conflict) {
throw new Error("This slot was just taken! Please pick another time.");
}
return await tx.appointment.create({
data: {
userId: data.userId,
startTime: data.start,
endTime: data.end,
guestName: data.name,
guestEmail: data.email
}
});
});
}
Best Practices & Tips for the Intermediate Builder
1. The Buffer Period
Never book appointments back-to-back without a buffer. A 30-minute meeting usually needs a 5-10 minute “reset” period. Add a bufferTime column to your User table and include it in your slot generation logic. If a meeting ends at 2:30 PM and you have a 10-minute buffer, the next available slot shouldn’t start until 2:40 PM.
2. Optimistic UI
Booking a calendar slot can feel slow because of the database transaction and network latency. Use Optimistic UI patterns. When the user clicks “Confirm,” immediately show a loading state or a “placeholder” booking in the UI. If the API returns an error (like a race condition), roll back the UI state and show a clear message. This makes your app feel like a premium tool rather than a slow form.
3. Handling “Ghost” Bookings (Locks)
If your booking flow has multiple steps (e.g., Select Time -> Fill Info -> Payment), you might want to “hold” a slot for 10 minutes.
- Create a
PendingBookingtable with anexpiresAttimestamp. - Modify your
getAvailableSlotslogic to treatPendingBookingsas “Booked” if they haven’t expired. - Use a background job (like Upstash Workflow or a simple Cron) to clean up expired placeholders.
4. The “Minimum Notice” Constraint
Don’t let people book you for a meeting starting in 5 minutes. Implement a “Minimum Notice” rule (e.g., 2 hours). In your slot generator, filter out any slots that start within Date.now() + minNotice.
5. External Calendar Sync (The “Boss” Level)
A booking system is only useful if it knows about your other commitments (like that dentist appointment you put in Google Calendar).
- Use OAuth 2.0 to get access to the user’s Google or Outlook calendar.
- When generating slots, fetch the user’s “Busy” intervals from the Google Calendar API for that day.
- Treat these external busy blocks exactly like your internal
Appointmentrecords.
Conclusion: From Code to Vibe
Building a calendar system from scratch is a rite of passage for intermediate developers. It forces you to deal with the messy reality of time—the offsets, the overlaps, and the near-instantaneous race conditions of the web.
By moving away from third-party widgets and building your own temporal engine, you gain total control over the user experience. You can send custom SMS reminders via Twilio, generate dynamic Zoom links on the fly, and create a brand-aligned interface that flows perfectly with the rest of your platform.
In Vibe Coding, we don’t just solve problems; we build systems that feel inevitable. A custom booking system is the backbone of that inevitability. Now that you have the blueprint, go build a scheduling experience that actually respects your time—and your users’.