Cara Membuat Sistem Booking dengan Google Apps Script (Anti Double-Booking)
7 menit baca · Diperbarui 2026-07-22
Bikin aplikasi booking kelihatannya gampang: satu form, satu tabel, selesai. Tapi bagian yang benar-benar susah bukan menyimpan data, melainkan memastikan dua orang tidak merebut slot jam yang sama dalam hitungan detik. Itu masalah race condition klasik, dan di Google Apps Script ada satu alat khusus untuk menanganinya: LockService.
Di panduan ini kita bangun sistem booking utuh di atas Google Spreadsheet: cek slot kosong per tanggal, simpan reservasi lewat form Web App, cegah double-booking, lalu kirim email konfirmasi otomatis. Kalau kamu belum pernah menyentuh Apps Script sama sekali, baca dulu Google Apps Script untuk pemula dan cara baca-tulis data Sheet biar dasarnya nyambung.
Yang kita bangun
Alurnya sederhana dari sisi user, tapi ada beberapa lapisan di belakangnya:
- User pilih tanggal, sistem menampilkan jam yang masih kosong.
- User isi data lalu klik Booking.
- Server mengunci proses, cek ulang ketersediaan, simpan baris, lalu lepas kunci.
- Email konfirmasi terkirim otomatis ke user.
- Semua data tinggal di satu sheet, gampang dilihat dan diedit manual kalau perlu.
Skema data: satu sheet Booking
Kita cukup pakai satu tab bernama Booking. Slot yang tersedia dihitung langsung dari data booking yang sudah ada, jadi tidak perlu tabel slot terpisah. Isi baris pertama dengan header berikut:
- A - ID: kode unik booking, misal BK1721631000000
- B - DibuatPada: timestamp saat baris dibuat
- C - Tanggal: format yyyy-MM-dd
- D - Jam: format HH:mm, misal 09:00
- E - Nama
- F - Email
- G - Telepon
- H - Status: AKTIF atau BATAL
Cek ketersediaan slot
Bangun daftar semua slot yang mungkin dalam sehari, lalu buang yang sudah dipesan. Kuncinya: baca datanya borongan dengan satu getValues(), bukan getValue() di dalam loop. Membaca per sel di loop lambatnya berlipat karena tiap panggilan bolak-balik ke Sheet. Detailnya ada di panduan baca-tulis data Sheet.
// ==== Konfigurasi ====
const SHEET_BOOKING = 'Booking';
const JAM_BUKA = 9; // slot pertama 09:00
const JAM_TUTUP = 17; // slot terakhir mulai 16:00 (1 jam per slot)
function getSheet_() {
return SpreadsheetApp.getActive().getSheetByName(SHEET_BOOKING);
}
// Sheet bisa mengubah teks jadi Date/time otomatis. Dua helper ini
// menyamakan bentuknya jadi string lagi sebelum dibandingkan.
function normTanggal_(v) {
if (v instanceof Date) {
return Utilities.formatDate(v, Session.getScriptTimeZone(), 'yyyy-MM-dd');
}
return String(v).trim();
}
function normJam_(v) {
if (v instanceof Date) {
return Utilities.formatDate(v, Session.getScriptTimeZone(), 'HH:mm');
}
return String(v).trim();
}
// Kembalikan jam yang MASIH kosong untuk satu tanggal ('yyyy-MM-dd').
function getSlotTersedia(tanggal) {
const semuaSlot = [];
for (let j = JAM_BUKA; j < JAM_TUTUP; j++) {
semuaSlot.push(('0' + j).slice(-2) + ':00');
}
const sheet = getSheet_();
const lastRow = sheet.getLastRow();
if (lastRow < 2) return semuaSlot; // baru header, semua kosong
// Baca borongan kolom C..H sekali jalan, bukan getValue() per sel.
const data = sheet.getRange(2, 3, lastRow - 1, 6).getValues();
const terpakai = {};
data.forEach(function (row) {
const status = String(row[5]); // H
if (normTanggal_(row[0]) === tanggal && status !== 'BATAL') {
terpakai[normJam_(row[1])] = true; // D
}
});
return semuaSlot.filter(function (jam) { return !terpakai[jam]; });
}Fungsi ini nanti dipanggil dari form tiap kali user ganti tanggal. Booking berstatus BATAL sengaja dilewati supaya slotnya kembali terbuka.
Cegah double-booking dengan LockService
Ini bagian yang paling sering salah. Bayangkan dua user membuka slot 10:00 yang sama, keduanya lihat slot itu kosong, lalu klik Booking hampir bersamaan. Kalau kamu cuma cek ketersediaan di sisi form, dua-duanya lolos dan slotnya kepesan dobel. Solusinya: kunci proses tulis dengan LockService, dan cek ulang ketersediaan di dalam kunci itu.
// input: { tanggal, jam, nama, email, telepon }
function simpanBooking(input) {
const lock = LockService.getScriptLock();
try {
lock.waitLock(10000); // antre maksimal 10 detik
} catch (e) {
return { ok: false, pesan: 'Server sedang sibuk, coba lagi sebentar.' };
}
try {
// Cek ULANG di dalam lock. Ini inti anti double-booking:
// slot bisa direbut orang lain antara user buka form dan klik kirim.
if (getSlotTersedia(input.tanggal).indexOf(input.jam) === -1) {
return { ok: false, pesan: 'Slot ' + input.jam + ' baru saja dipesan orang lain.' };
}
const id = 'BK' + Date.now();
getSheet_().appendRow([
id, new Date(), input.tanggal, input.jam,
input.nama, input.email, input.telepon, 'AKTIF'
]);
kirimKonfirmasi_(input, id);
return { ok: true, id: id, pesan: 'Booking berhasil: ' + input.tanggal + ' jam ' + input.jam };
} catch (e) {
return { ok: false, pesan: 'Gagal menyimpan: ' + e.message };
} finally {
lock.releaseLock(); // WAJIB dilepas, walaupun tadi error
}
}
function kirimKonfirmasi_(input, id) {
if (!input.email) return;
MailApp.sendEmail(
input.email,
'Konfirmasi Booking ' + id,
'Halo ' + input.nama + ',\n\n' +
'Booking kamu diterima.\n' +
'Tanggal : ' + input.tanggal + '\n' +
'Jam : ' + input.jam + '\n' +
'Kode : ' + id + '\n\n' +
'Sampai jumpa!'
);
}Perhatikan releaseLock() ada di blok finally, jadi kunci selalu dilepas walaupun proses gagal di tengah. Kalau kunci lupa dilepas, request berikutnya bakal antre sampai timeout. Karena satu request cuma menyimpan satu baris, ini jauh dari batas eksekusi sekitar 6 menit di akun gratis. Yang perlu dihindari adalah menahan lock sambil melakukan pekerjaan berat.
Form Web App
Form-nya kita sajikan sebagai Web App lewat doGet(e) yang me-return HtmlService. Dari sisi client, cara memanggil fungsi server BUKAN pakai fetch, melainkan google.script.run. Fungsi itu asinkron: hasilnya diterima lewat withSuccessHandler, bukan nilai return biasa. Langkah publikasi Web App-nya ada di cara membuat Web App dari Spreadsheet, dan pola form yang enak dibuka dari HP dibahas di form input ke Spreadsheet dari HP.
/* ===== Code.gs ===== */
function doGet() {
return HtmlService.createHtmlOutputFromFile('form')
.setTitle('Booking Jadwal')
.addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
/* ===== form.html (buat file HTML baru bernama "form") ===== */
<!DOCTYPE html>
<html>
<head><base target="_top"></head>
<body>
<h3>Booking Jadwal</h3>
<input type="date" id="tanggal" onchange="muatSlot()">
<select id="jam"><option>Pilih tanggal dulu</option></select>
<input type="text" id="nama" placeholder="Nama">
<input type="email" id="email" placeholder="Email">
<input type="tel" id="telepon" placeholder="No. HP">
<button id="tombol" onclick="kirim()">Booking</button>
<p id="pesan"></p>
<script>
function muatSlot() {
var tgl = document.getElementById('tanggal').value;
if (!tgl) return;
var sel = document.getElementById('jam');
sel.innerHTML = '<option>Memuat...</option>';
// Panggil fungsi server lewat google.script.run, BUKAN fetch().
google.script.run.withSuccessHandler(function (slot) {
sel.innerHTML = slot.length
? slot.map(function (j) { return '<option value="' + j + '">' + j + '</option>'; }).join('')
: '<option>Penuh</option>';
}).getSlotTersedia(tgl);
}
function kirim() {
var tombol = document.getElementById('tombol');
tombol.disabled = true;
var payload = {
tanggal: document.getElementById('tanggal').value,
jam: document.getElementById('jam').value,
nama: document.getElementById('nama').value,
email: document.getElementById('email').value,
telepon: document.getElementById('telepon').value
};
google.script.run
.withSuccessHandler(function (res) {
document.getElementById('pesan').textContent = res.pesan;
tombol.disabled = false;
if (res.ok) muatSlot(); // segarkan daftar slot
})
.withFailureHandler(function (err) {
document.getElementById('pesan').textContent = 'Error: ' + err.message;
tombol.disabled = false;
})
.simpanBooking(payload);
}
</script>
</body>
</html>Tombol di-disable sebentar selama request jalan supaya user tidak mengklik dua kali dan mengirim booking ganda. Setelah sukses, daftar slot dimuat ulang sehingga jam yang barusan dipesan langsung hilang dari pilihan.
Deploy & gotcha yang bikin pusing
Beberapa hal ini gampang kelewat dan bikin form kelihatan rusak padahal kodenya benar:
- Deploy sebagai Web App dengan akses Anyone supaya form bisa dibuka publik tanpa login. Kalau butuh identitas, pakai Anyone with Google account.
- Di deploy anonim, Session.getActiveUser().getEmail() balik string kosong. Jangan andalkan itu untuk tahu siapa yang booking; ambil email dari field form.
- Eksekusi Apps Script dibatasi sekitar 6 menit di akun gratis. Untuk booking satu-satu ini aman, tapi hindari loop berat atau kirim banyak email dalam satu request.
- MailApp punya kuota kirim harian (akun gratis lebih kecil dari Workspace). Kalau volume tinggi, cek [batas kuota Apps Script](/panduan/batas-kuota-google-apps-script) dan pertimbangkan kirim konfirmasi lewat [trigger terjadwal](/panduan/jadwalkan-script-google-apps-script-trigger) supaya request booking tetap ringan.
Dari ide ke PRD sebelum ngoding
Kode di atas kelihatan pendek, tapi keputusan pentingnya banyak: bentuk skema sheet, daftar fungsi server, aturan status, dan cara mencegah double-booking. Kalau semua itu kamu tulis lebih dulu sebagai spesifikasi, hasil kodenya jauh lebih rapi. Itulah isi sebuah PRD, dan kalau kamu serahkan ke AI coding agent seperti Cursor atau Claude Code, hasilnya lebih presisi ketimbang menjelaskan lewat obrolan. Cara menyusunnya ada di cara membuat PRD untuk aplikasi.
Buat PRD Web GAS gratis di Skematik →Skematik punya mode Web GAS yang khusus mengeluarkan PRD Apps Script: struktur tab Spreadsheet sebagai skema data, daftar fungsi server, plus konvensi yang sadar batasan GAS (baca-tulis borongan, LockService, batas 6 menit). PRD-nya bahkan bisa di-export jadi setup-sheets.gs untuk membuat sheet-nya sekali klik. Mau lihat bentuk PRD jadinya dulu? Intip contohnya.
Lihat contoh PRD yang sudah jadi →