This workflow follows the Google Calendar → HTTP Request recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"name": "Rodopi Dent - Public Booking (Calendar)",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "public-booking",
"responseMode": "responseNode",
"options": {
"allowedOrigins": "*"
}
},
"id": "webhook",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
0
]
},
{
"parameters": {
"jsCode": "// Validate incoming booking data\nconst body = $input.first().json.body;\n\nconst required = ['patientName', 'patientPhone', 'date', 'startTime'];\nconst missing = required.filter(field => !body[field]);\n\nif (missing.length > 0) {\n return [{\n json: {\n valid: false,\n error: `\u041b\u0438\u043f\u0441\u0432\u0430\u0449\u0438 \u043f\u043e\u043b\u0435\u0442\u0430: ${missing.join(', ')}`\n }\n }];\n}\n\n// Validate phone number (Bulgarian format)\nconst phone = body.patientPhone.replace(/\\s/g, '');\nconst phoneRegex = /^(\\+359|0)[0-9]{9}$/;\nif (!phoneRegex.test(phone)) {\n return [{\n json: {\n valid: false,\n error: '\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0435\u043d \u043d\u043e\u043c\u0435\u0440. \u041c\u043e\u043b\u044f, \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u0439\u0442\u0435 \u0444\u043e\u0440\u043c\u0430\u0442: 0888123456 \u0438\u043b\u0438 +359888123456'\n }\n }];\n}\n\n// Validate date is in the future\nconst bookingDate = new Date(body.date + 'T' + body.startTime);\nconst now = new Date();\nif (bookingDate < now) {\n return [{\n json: {\n valid: false,\n error: '\u041d\u0435 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0437\u0430\u043f\u0430\u0437\u0438\u0442\u0435 \u0447\u0430\u0441 \u0432 \u043c\u0438\u043d\u0430\u043b\u043e\u0442\u043e'\n }\n }];\n}\n\n// Check if it's a working day\nconst dayOfWeek = new Date(body.date).getDay();\nif (dayOfWeek === 0 || dayOfWeek === 6) {\n return [{\n json: {\n valid: false,\n error: '\u0421\u044a\u0431\u043e\u0442\u0430 \u0438 \u043d\u0435\u0434\u0435\u043b\u044f \u0441\u0430 \u043f\u043e\u0447\u0438\u0432\u043d\u0438 \u0434\u043d\u0438'\n }\n }];\n}\n\n// Default duration is 30 minutes for new bookings (doctor will confirm actual duration)\nconst duration = 30;\n\nreturn [{\n json: {\n valid: true,\n patientName: body.patientName.trim(),\n patientPhone: phone,\n date: body.date,\n startTime: body.startTime,\n duration: duration,\n reason: (body.reason || '').trim(),\n notes: (body.notes || '').trim()\n }\n}];"
},
"id": "validate",
"name": "Validate Input",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
220,
0
]
},
{
"parameters": {
"conditions": {
"boolean": [
{
"value1": "={{ $json.valid }}",
"value2": true
}
]
}
},
"id": "is-valid",
"name": "Is Valid?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
440,
0
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ success: false, error: $json.error }) }}",
"options": {
"responseHeaders": {
"entries": [
{
"name": "Access-Control-Allow-Origin",
"value": "*"
}
]
}
}
},
"id": "respond-invalid",
"name": "Respond Invalid",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
660,
-100
]
},
{
"parameters": {
"operation": "getAll",
"calendar": {
"__rl": true,
"mode": "id",
"value": "rodopi.dent@gmail.com"
},
"returnAll": true,
"options": {
"timeMax": "={{ $json.date }}T23:59:59+02:00",
"timeMin": "={{ $json.date }}T00:00:00+02:00",
"singleEvents": true
}
},
"id": "get-calendar",
"name": "Get Calendar Events",
"type": "n8n-nodes-base.googleCalendar",
"typeVersion": 1.2,
"position": [
660,
100
],
"credentials": {
"googleCalendarOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"jsCode": "// Get the booking request\nconst booking = $('Validate Input').first().json;\nconst bookingDate = booking.date;\nconst bookingTime = booking.startTime;\n\n// Helper function to convert to Europe/Sofia timezone and get minutes\nfunction toSofiaMinutes(isoString) {\n if (!isoString) return null;\n const d = new Date(isoString);\n const options = {\n timeZone: 'Europe/Sofia',\n hour: '2-digit',\n minute: '2-digit',\n hour12: false\n };\n const formatter = new Intl.DateTimeFormat('en-CA', options);\n const parts = formatter.formatToParts(d);\n const hour = parseInt(parts.find(p => p.type === 'hour')?.value || '0');\n const minute = parseInt(parts.find(p => p.type === 'minute')?.value || '0');\n return hour * 60 + minute;\n}\n\nfunction timeToMinutes(time) {\n const [hours, minutes] = time.split(':').map(Number);\n return hours * 60 + minutes;\n}\n\nconst bookingStart = timeToMinutes(bookingTime);\n\n// Get existing calendar events from Get Calendar Events node\nconst calendarEvents = $('Get Calendar Events').all();\n\n// Check for exact same start time AND calculate max available minutes\nlet hasExactConflict = false;\nlet conflictWith = null;\nlet nextEventStart = 18 * 60; // Default: end of working day (18:00)\n\nfor (const item of calendarEvents) {\n const event = item.json;\n \n // Skip cancelled events\n if (event.status === 'cancelled') continue;\n \n let existingStart = null;\n \n if (event.start?.dateTime) {\n // CONVERT TO SOFIA TIMEZONE\n existingStart = toSofiaMinutes(event.start.dateTime);\n } else if (event.start?.date) {\n // All-day event blocks entire day\n hasExactConflict = true;\n conflictWith = '\u0446\u0435\u043b\u043e\u0434\u043d\u0435\u0432\u043d\u043e \u0441\u044a\u0431\u0438\u0442\u0438\u0435';\n break;\n }\n \n if (existingStart === null) continue;\n \n // Check for exact same start time\n if (bookingStart === existingStart) {\n hasExactConflict = true;\n const startHour = Math.floor(existingStart / 60);\n const startMin = existingStart % 60;\n conflictWith = `${startHour.toString().padStart(2, '0')}:${startMin.toString().padStart(2, '0')}`;\n break;\n }\n \n // Find the earliest event that starts AFTER our booking start time\n if (existingStart > bookingStart && existingStart < nextEventStart) {\n nextEventStart = existingStart;\n }\n}\n\nif (hasExactConflict) {\n return [{\n json: {\n conflict: true,\n error: `\u0422\u043e\u0437\u0438 \u0447\u0430\u0441 (${bookingTime}) \u0432\u0435\u0447\u0435 \u0435 \u0437\u0430\u0435\u0442. \u041c\u043e\u043b\u044f \u0438\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0434\u0440\u0443\u0433 \u0447\u0430\u0441.`\n }\n }];\n}\n\n// Calculate max available minutes until next event\nconst maxAvailableMinutes = nextEventStart - bookingStart;\n\n// Determine which buttons to show\nconst can30 = maxAvailableMinutes >= 30;\nconst can60 = maxAvailableMinutes >= 60;\n\nconst id = 'apt_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 8);\n\nreturn [{\n json: {\n conflict: false,\n id: id,\n patientName: booking.patientName,\n patientPhone: booking.patientPhone,\n date: booking.date,\n startTime: booking.startTime,\n duration: booking.duration,\n reason: booking.reason,\n notes: booking.notes,\n maxAvailableMinutes: maxAvailableMinutes,\n can30: can30,\n can60: can60\n }\n}];"
},
"id": "check-conflict",
"name": "Check Conflict",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
100
]
},
{
"parameters": {
"conditions": {
"boolean": [
{
"value1": "={{ $json.conflict }}",
"value2": true
}
]
}
},
"id": "has-conflict",
"name": "Has Conflict?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
1100,
100
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ success: false, error: $json.error }) }}",
"options": {
"responseHeaders": {
"entries": [
{
"name": "Access-Control-Allow-Origin",
"value": "*"
}
]
}
}
},
"id": "respond-conflict",
"name": "Respond Conflict",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1320,
0
]
},
{
"parameters": {
"jsCode": "// Prepare calendar event data\nconst booking = $json;\n\n// Calculate end time\nconst [hours, minutes] = booking.startTime.split(':').map(Number);\nconst startMinutes = hours * 60 + minutes;\nconst endMinutes = startMinutes + booking.duration;\nconst endHours = Math.floor(endMinutes / 60);\nconst endMins = endMinutes % 60;\nconst endTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;\n\n// Build description with structured data\nlet description = `\ud83d\udcde \u0422\u0435\u043b: ${booking.patientPhone}\\n`;\nif (booking.reason) {\n description += `\ud83d\udccb \u041f\u0440\u0438\u0447\u0438\u043d\u0430: ${booking.reason}\\n`;\n}\nif (booking.notes) {\n description += `\ud83d\udcdd \u0411\u0435\u043b\u0435\u0436\u043a\u0438: ${booking.notes}\\n`;\n}\ndescription += `\\n\u23f3 \u0421\u0442\u0430\u0442\u0443\u0441: \u0427\u0410\u041a\u0410\u0429 (pending)\\n`;\ndescription += `\ud83c\udd94 ID: ${booking.id}\\n`;\ndescription += `\ud83d\udcc5 \u0421\u044a\u0437\u0434\u0430\u0434\u0435\u043d: ${new Date().toLocaleString('bg-BG')}`;\n\nreturn [{\n json: {\n summary: `\u23f3 ${booking.patientName}`,\n description: description,\n startDateTime: `${booking.date}T${booking.startTime}:00`,\n endDateTime: `${booking.date}T${endTime}:00`,\n colorId: '5', // Yellow for pending\n id: booking.id,\n patientName: booking.patientName,\n patientPhone: booking.patientPhone\n }\n}];"
},
"id": "prepare-event",
"name": "Prepare Event Data",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1320,
200
]
},
{
"parameters": {
"calendar": {
"__rl": true,
"mode": "id",
"value": "rodopi.dent@gmail.com"
},
"start": "={{ $json.startDateTime }}",
"end": "={{ $json.endDateTime }}",
"additionalFields": {
"summary": "={{ $json.summary }}",
"description": "={{ $json.description }}",
"colorId": "={{ $json.colorId }}"
}
},
"id": "create-calendar-event",
"name": "Create Calendar Event",
"type": "n8n-nodes-base.googleCalendar",
"typeVersion": 1.2,
"position": [
1540,
200
],
"credentials": {
"googleCalendarOAuth2Api": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"jsCode": "// Prepare success response\nconst calendarEvent = $input.first().json;\nconst preparedData = $('Prepare Event Data').first().json;\n\nreturn [{\n json: {\n success: true,\n message: '\u0427\u0430\u0441\u044a\u0442 \u0435 \u0437\u0430\u043f\u0430\u0437\u0435\u043d \u0443\u0441\u043f\u0435\u0448\u043d\u043e! \u041e\u0447\u0430\u043a\u0432\u0430\u0439\u0442\u0435 \u043f\u043e\u0442\u0432\u044a\u0440\u0436\u0434\u0435\u043d\u0438\u0435.',\n booking: {\n id: preparedData.id,\n googleEventId: calendarEvent.id,\n patientName: preparedData.patientName,\n patientPhone: preparedData.patientPhone,\n date: preparedData.startDateTime.split('T')[0],\n startTime: preparedData.startDateTime.split('T')[1].slice(0, 5),\n status: 'pending'\n }\n }\n}];"
},
"id": "prepare-response",
"name": "Prepare Response",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1760,
200
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify($json) }}",
"options": {
"responseHeaders": {
"entries": [
{
"name": "Access-Control-Allow-Origin",
"value": "*"
}
]
}
}
},
"id": "respond-success",
"name": "Respond Success",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1980,
200
]
},
{
"parameters": {
"conditions": {
"boolean": [
{
"value1": "={{ $('Check Conflict').first().json.can60 }}",
"value2": true
}
]
}
},
"id": "can-60-check",
"name": "Can 60 min?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
1760,
400
]
},
{
"parameters": {
"chatId": "2146283697",
"text": "=\ud83e\uddb7 *\u041d\u043e\u0432\u0430 \u0437\u0430\u044f\u0432\u043a\u0430 \u0437\u0430 \u0447\u0430\u0441!*\n\n\ud83d\udc64 \u041f\u0430\u0446\u0438\u0435\u043d\u0442: {{ $('Prepare Event Data').first().json.patientName }}\n\ud83d\udcde \u0422\u0435\u043b\u0435\u0444\u043e\u043d: {{ $('Prepare Event Data').first().json.patientPhone }}\n\ud83d\udcc5 \u0414\u0430\u0442\u0430: {{ $('Prepare Event Data').first().json.startDateTime.split('T')[0] }}\n\ud83d\udd50 \u0427\u0430\u0441: {{ $('Prepare Event Data').first().json.startDateTime.split('T')[1].slice(0, 5) }}\n\ud83d\udccb \u041f\u0440\u0438\u0447\u0438\u043d\u0430: {{ $('Validate Input').first().json.reason || '\u041d\u0435 \u0435 \u043f\u043e\u0441\u043e\u0447\u0435\u043d\u0430' }}\n\u23f1\ufe0f \u0421\u0432\u043e\u0431\u043e\u0434\u043d\u043e \u0432\u0440\u0435\u043c\u0435: {{ $('Check Conflict').first().json.maxAvailableMinutes }} \u043c\u0438\u043d.\n\n\u23f3 *\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u043f\u0440\u043e\u0434\u044a\u043b\u0436\u0438\u0442\u0435\u043b\u043d\u043e\u0441\u0442:*",
"additionalFields": {
"parse_mode": "Markdown",
"replyMarkup": "inlineKeyboard",
"inlineKeyboard": {
"rows": [
{
"row": [
{
"text": "30 \u043c\u0438\u043d",
"callbackData": "PLACEHOLDER_30"
},
{
"text": "60 \u043c\u0438\u043d",
"callbackData": "PLACEHOLDER_60"
}
]
},
{
"row": [
{
"text": "\u274c \u041e\u0442\u043a\u0430\u0436\u0438",
"callbackData": "PLACEHOLDER_CANCEL"
}
]
}
]
}
}
},
"id": "telegram-full",
"name": "Telegram Full Options",
"type": "n8n-nodes-base.telegram",
"typeVersion": 1.2,
"position": [
1980,
350
],
"credentials": {
"telegramApi": {
"name": "<your credential>"
}
},
"notes": "CALLBACK DATA:\n30 \u043c\u0438\u043d: confirm_30_{{ $('Prepare Event Data').first().json.id }}\n60 \u043c\u0438\u043d: confirm_60_{{ $('Prepare Event Data').first().json.id }}\n\u041e\u0442\u043a\u0430\u0436\u0438: cancel_{{ $('Prepare Event Data').first().json.id }}"
},
{
"parameters": {
"chatId": "2146283697",
"text": "=\ud83e\uddb7 *\u041d\u043e\u0432\u0430 \u0437\u0430\u044f\u0432\u043a\u0430 \u0437\u0430 \u0447\u0430\u0441!*\n\n\ud83d\udc64 \u041f\u0430\u0446\u0438\u0435\u043d\u0442: {{ $('Prepare Event Data').first().json.patientName }}\n\ud83d\udcde \u0422\u0435\u043b\u0435\u0444\u043e\u043d: {{ $('Prepare Event Data').first().json.patientPhone }}\n\ud83d\udcc5 \u0414\u0430\u0442\u0430: {{ $('Prepare Event Data').first().json.startDateTime.split('T')[0] }}\n\ud83d\udd50 \u0427\u0430\u0441: {{ $('Prepare Event Data').first().json.startDateTime.split('T')[1].slice(0, 5) }}\n\ud83d\udccb \u041f\u0440\u0438\u0447\u0438\u043d\u0430: {{ $('Validate Input').first().json.reason || '\u041d\u0435 \u0435 \u043f\u043e\u0441\u043e\u0447\u0435\u043d\u0430' }}\n\u26a0\ufe0f *\u0421\u0432\u043e\u0431\u043e\u0434\u043d\u043e \u0432\u0440\u0435\u043c\u0435: \u0441\u0430\u043c\u043e {{ $('Check Conflict').first().json.maxAvailableMinutes }} \u043c\u0438\u043d!*\n\n\u23f3 *\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435:*",
"additionalFields": {
"parse_mode": "Markdown",
"replyMarkup": "inlineKeyboard",
"inlineKeyboard": {
"rows": [
{
"row": [
{
"text": "30 \u043c\u0438\u043d",
"callbackData": "PLACEHOLDER_30"
},
{
"text": "\ud83d\udcde \u0417\u0430 \u043e\u0431\u0430\u0436\u0434\u0430\u043d\u0435",
"callbackData": "PLACEHOLDER_CALLBACK"
}
]
},
{
"row": [
{
"text": "\u274c \u041e\u0442\u043a\u0430\u0436\u0438",
"callbackData": "PLACEHOLDER_CANCEL"
}
]
}
]
}
}
},
"id": "telegram-limited",
"name": "Telegram Limited (30 only)",
"type": "n8n-nodes-base.telegram",
"typeVersion": 1.2,
"position": [
1980,
500
],
"credentials": {
"telegramApi": {
"name": "<your credential>"
}
},
"notes": "CALLBACK DATA:\n30 \u043c\u0438\u043d: confirm_30_{{ $('Prepare Event Data').first().json.id }}\n\u0417\u0430 \u043e\u0431\u0430\u0436\u0434\u0430\u043d\u0435: callback_{{ $('Prepare Event Data').first().json.id }}\n\u041e\u0442\u043a\u0430\u0436\u0438: cancel_{{ $('Prepare Event Data').first().json.id }}"
},
{
"parameters": {
"method": "POST",
"url": "https://n8n.simeontsvetanovn8nworkflows.site/webhook/send-sms",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"phone\": \"{{ $('Prepare Event Data').first().json.patientPhone }}\",\n \"template\": \"booking_received\",\n \"date\": \"{{ $('Prepare Event Data').first().json.startDateTime.split('T')[0] }}\",\n \"time\": \"{{ $('Prepare Event Data').first().json.startDateTime.split('T')[1].slice(0, 5) }}\",\n \"patientName\": \"{{ $('Prepare Event Data').first().json.patientName }}\"\n}",
"options": {}
},
"id": "send-sms-received",
"name": "Send SMS to Patient",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1760,
500
],
"continueOnFail": true
},
{
"parameters": {
"method": "POST",
"url": "https://n8n.simeontsvetanovn8nworkflows.site/webhook/patients-upsert",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"name\": \"{{ $('Prepare Event Data').first().json.patientName }}\",\n \"phone\": \"{{ $('Prepare Event Data').first().json.patientPhone }}\"\n}",
"options": {}
},
"id": "save-patient",
"name": "Save Patient to Sheet",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1760,
650
],
"continueOnFail": true
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "Validate Input",
"type": "main",
"index": 0
}
]
]
},
"Validate Input": {
"main": [
[
{
"node": "Is Valid?",
"type": "main",
"index": 0
}
]
]
},
"Is Valid?": {
"main": [
[
{
"node": "Respond Invalid",
"type": "main",
"index": 0
}
],
[
{
"node": "Get Calendar Events",
"type": "main",
"index": 0
}
]
]
},
"Get Calendar Events": {
"main": [
[
{
"node": "Check Conflict",
"type": "main",
"index": 0
}
]
]
},
"Check Conflict": {
"main": [
[
{
"node": "Has Conflict?",
"type": "main",
"index": 0
}
]
]
},
"Has Conflict?": {
"main": [
[
{
"node": "Respond Conflict",
"type": "main",
"index": 0
}
],
[
{
"node": "Prepare Event Data",
"type": "main",
"index": 0
}
]
]
},
"Prepare Event Data": {
"main": [
[
{
"node": "Create Calendar Event",
"type": "main",
"index": 0
}
]
]
},
"Create Calendar Event": {
"main": [
[
{
"node": "Prepare Response",
"type": "main",
"index": 0
},
{
"node": "Can 60 min?",
"type": "main",
"index": 0
},
{
"node": "Send SMS to Patient",
"type": "main",
"index": 0
},
{
"node": "Save Patient to Sheet",
"type": "main",
"index": 0
}
]
]
},
"Can 60 min?": {
"main": [
[
{
"node": "Telegram Full Options",
"type": "main",
"index": 0
}
],
[
{
"node": "Telegram Limited (30 only)",
"type": "main",
"index": 0
}
]
]
},
"Prepare Response": {
"main": [
[
{
"node": "Respond Success",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
googleCalendarOAuth2ApitelegramApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Rodopi Dent - Public Booking (Calendar). Uses googleCalendar, telegram, httpRequest. Webhook trigger; 17 nodes.
Source: https://github.com/Georgi-Piskov/RODOPI-DENT/blob/f071a84326ea5adf54e4eb20a2ba3e34aac5b728/n8n-workflows/16-public-booking.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
qualiopi. Uses airtable, telegram, emailSend, httpRequest. Webhook trigger; 51 nodes.
PsyCardv2. Uses executeCommand, telegram, readBinaryFile, googleDrive. Webhook trigger; 41 nodes.
[](https://www.linkedin.com/in/mosaab-yassir-lafrimi/)[](https://t.me/joevenner)
How it works • Webhook triggers from content creation system in Airtable • Downloads media (images/videos) from Airtable URLs • Uploads media to Postiz cloud storage • Schedules or publishes content a
clients kept booking meetings during my prayer times. i'd either miss a prayer or scramble to reschedule. the problem wasn't the clients — it was that my calendar had no blocked windows for salah. i n