Skip to content

Instantly share code, notes, and snippets.

@H1D
Created October 20, 2025 15:49
Show Gist options
  • Select an option

  • Save H1D/d941a95c3e06a6aa6876c85af4c3348e to your computer and use it in GitHub Desktop.

Select an option

Save H1D/d941a95c3e06a6aa6876c85af4c3348e to your computer and use it in GitHub Desktop.
#!/usr/bin/env node
// Required parameters:
// @raycast.schemaVersion 1
// @raycast.title Get my available time slots
// @raycast.mode fullOutput
// Optional parameters:
// @raycast.icon πŸ“…
// @raycast.packageName Calendar Tools
// @raycast.argument1 { "type": "text", "placeholder": "Working days (default 7)", "optional": true }
// @raycast.argument2 { "type": "dropdown", "placeholder": "Timezone", "optional": true, "data": [{"title": "New York (US/12hr)", "value": "New York"}, {"title": "Los Angeles (US/12hr)", "value": "Los Angeles"}, {"title": "Chicago (US/12hr)", "value": "Chicago"}, {"title": "Denver (US/12hr)", "value": "Denver"}, {"title": "Toronto (12hr)", "value": "Toronto"}, {"title": "Mexico City (12hr)", "value": "Mexico City"}, {"title": "SΓ£o Paulo (24hr)", "value": "Sao Paulo"}, {"title": "Buenos Aires (24hr)", "value": "Buenos Aires"}, {"title": "London (24hr)", "value": "London"}, {"title": "Paris (24hr)", "value": "Paris"}, {"title": "Berlin (24hr)", "value": "Berlin"}, {"title": "Madrid (24hr)", "value": "Madrid"}, {"title": "Rome (24hr)", "value": "Rome"}, {"title": "Amsterdam (24hr)", "value": "Amsterdam"}, {"title": "Zurich (24hr)", "value": "Zurich"}, {"title": "Stockholm (24hr)", "value": "Stockholm"}, {"title": "Moscow (24hr)", "value": "Moscow"}, {"title": "Dubai (12hr)", "value": "Dubai"}, {"title": "Istanbul (24hr)", "value": "Istanbul"}, {"title": "Mumbai (12hr)", "value": "Mumbai"}, {"title": "Delhi (12hr)", "value": "Delhi"}, {"title": "Singapore (12hr)", "value": "Singapore"}, {"title": "Hong Kong (12hr)", "value": "Hong Kong"}, {"title": "Tokyo (24hr)", "value": "Tokyo"}, {"title": "Shanghai (12hr)", "value": "Shanghai"}, {"title": "Seoul (12hr)", "value": "Seoul"}, {"title": "Sydney (12hr)", "value": "Sydney"}, {"title": "Melbourne (12hr)", "value": "Melbourne"}, {"title": "Auckland (12hr)", "value": "Auckland"}] }
// Documentation:
// @raycast.description Fetch available meeting slots from Fantastical
const fs = require("fs");
const path = require("path");
const SCRIPT_NAME = "my-available-time-slots";
const LOG_DIR = path.join(process.cwd(), "logs", SCRIPT_NAME);
const LOG_FILE = path.join(LOG_DIR, "debug.log");
// !!! REPLACE WITH YOUR FANTASTICAL SCHEDULING PAGE URL !!!
const FANTASTICAL_URL =
"https://fantastical.app/YOUR-USERNAME/YOUR-MEETING-URL";
// !!! REPLACE WITH YOUR BROWSERBASE CREDENTIALS !!!
// Get these from https://www.browserbase.com/
const BROWSERBASE_PROJECT_ID = "YOUR-PROJECT-ID";
const BROWSERBASE_API_KEY = "YOUR-API-KEY";
// Timezone format mapping (24hr vs 12hr)
const TIMEZONE_24HR = [
"Amsterdam",
"Berlin",
"Paris",
"London",
"Madrid",
"Rome",
"Zurich",
"Stockholm",
"Moscow",
"Istanbul",
"Tokyo",
"Sao Paulo",
"Buenos Aires",
];
function uses24HourFormat(timezone) {
return TIMEZONE_24HR.some((tz) => timezone.includes(tz));
}
// Color codes
const color = {
reset: "\x1b[0m",
bold: "\x1b[1m",
cyan: "\x1b[36m",
green: "\x1b[32m",
yellow: "\x1b[33m",
red: "\x1b[31m",
bgGreen: "\x1b[42m",
white: "\x1b[97m",
};
// Setup logging
try {
if (!fs.existsSync(LOG_DIR)) {
fs.mkdirSync(LOG_DIR, { recursive: true });
}
if (fs.existsSync(LOG_FILE)) fs.unlinkSync(LOG_FILE);
} catch (err) {
// Silently fail
}
function logDebug(msg) {
const timestamp = new Date().toISOString();
const logMsg = `[${timestamp}] ${msg}`;
try {
fs.appendFileSync(LOG_FILE, logMsg + "\n");
} catch (err) {
// Silently fail
}
}
// Calculate working days
function getWorkingDaysDate(daysAhead) {
const dates = [];
let currentDate = new Date();
let count = 0;
while (count < daysAhead) {
currentDate.setDate(currentDate.getDate() + 1);
const dayOfWeek = currentDate.getDay();
// Skip weekends (0 = Sunday, 6 = Saturday)
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
dates.push(new Date(currentDate));
count++;
}
}
return dates[dates.length - 1]; // Return the last date
}
async function createBrowserbaseSession() {
logDebug("Creating Browserbase session...");
const response = await fetch("https://www.browserbase.com/v1/sessions", {
method: "POST",
headers: {
"x-bb-api-key": BROWSERBASE_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
projectId: BROWSERBASE_PROJECT_ID,
}),
});
if (!response.ok) {
const errorText = await response.text();
logDebug(`Failed to create session: ${response.status} ${errorText}`);
throw new Error(`Failed to create Browserbase session: ${response.status}`);
}
const json = await response.json();
logDebug(`Session created: ${json.id}`);
return json.id;
}
async function scrapeFantasticalSlots(sessionId, timezone, onProgress) {
logDebug(`Connecting to Browserbase session ${sessionId}...`);
// Use Playwright (same as Browserbase playground)
const { chromium } = require("playwright-core");
const browser = await chromium.connectOverCDP(
`wss://connect.browserbase.com?apiKey=${BROWSERBASE_API_KEY}&sessionId=${sessionId}`
);
logDebug("Browser connected");
try {
const context = browser.contexts()[0];
const page = context.pages()[0] || (await context.newPage());
logDebug(`Navigating to ${FANTASTICAL_URL}...`);
onProgress?.("πŸ“„ Loading Fantastical page...");
await page.goto(FANTASTICAL_URL, {
waitUntil: "networkidle",
timeout: 60000,
});
logDebug("Page loaded");
// Wait for React app to fully hydrate
await page.waitForSelector("#timezone-selector-dropdown", {
timeout: 10000,
});
logDebug("App hydrated");
onProgress?.("βœ… Page loaded");
// Get current timezone display
let currentTimezone = "Default";
try {
const tzText = await page.$eval(
"#timezone-selector-dropdown span",
(el) => el.textContent
);
currentTimezone = tzText;
logDebug(`Current timezone: ${currentTimezone}`);
} catch (err) {
logDebug(`Could not read current timezone: ${err.message}`);
}
// If timezone is provided, change it
if (timezone) {
logDebug(`Attempting to change timezone to ${timezone}...`);
onProgress?.(`⏰ Changing timezone to ${timezone}...`);
try {
// Click the timezone selector dropdown to open it
await page.click("#timezone-selector-dropdown");
logDebug("Clicked timezone selector");
// Wait for dropdown menu to appear
await page.waitForSelector(".dropdown-menu.show", {
state: "visible",
timeout: 3000,
});
logDebug("Dropdown menu visible");
// Click "Search Time Zones..." button and wait for input to appear
await page.evaluate(() => {
const menuItems = document.querySelectorAll(
'[data-sentry-component="TimezoneMenuDropdown"] li button'
);
for (const button of menuItems) {
if (button.textContent.includes("Search Time Zones")) {
button.click();
return;
}
}
});
logDebug("Clicked 'Search Time Zones...'");
// Wait for search input and type timezone
await page.waitForSelector('input[role="search"]', {
state: "visible",
timeout: 3000,
});
await page.type('input[role="search"]', timezone, { delay: 30 });
logDebug(`Typed timezone: ${timezone}`);
// Wait for timezone list to appear with results
await page.waitForSelector("ul._timezonesList_18e06_5 li", {
state: "visible",
timeout: 2000,
});
// Click the first result from the timezone list
const timezoneSelected = await page.evaluate(() => {
const list = document.querySelector("ul._timezonesList_18e06_5");
if (list) {
const firstItem = list.querySelector("li");
if (firstItem) {
const button = firstItem.querySelector("button");
if (button) {
const timezoneText = button.textContent.trim();
button.click();
return timezoneText;
}
}
}
return null;
});
if (timezoneSelected) {
logDebug(`Selected timezone: ${timezoneSelected}`);
currentTimezone = timezoneSelected;
// Wait for calendar to re-render with new timezone
await page.waitForLoadState("networkidle");
} else {
logDebug(`Could not find timezone in search results for ${timezone}`);
console.log(
`${color.yellow}⚠ Could not change timezone, using default${color.reset}`
);
}
} catch (err) {
logDebug(`Error changing timezone: ${err.message}`);
console.log(
`${color.yellow}⚠ Could not change timezone, using default${color.reset}`
);
}
}
// Scrape available slots with pagination support
logDebug("Scraping available slots...");
onProgress?.("πŸ“… Finding available dates...");
// Wait for the week strip to be fully loaded first
try {
await page.waitForSelector("div._weekStrip_del5y_5", {
visible: true,
timeout: 5000,
});
logDebug("Week strip loaded");
} catch (err) {
logDebug(`Week strip wait timeout: ${err.message}`);
}
const desiredDates = 20; // Look for up to 20 working dates
let allSlots = [];
let attempts = 0;
const maxAttempts = 6; // Max 6 weeks to check
while (allSlots.length < desiredDates && attempts < maxAttempts) {
// Scrape time slots from the current week strip
logDebug(`Scraping week strip (attempt ${attempts + 1})...`);
const slotsWithTimes = await page.evaluate(() => {
const results = [];
// Find all date columns in the week strip
const dateColumns = document.querySelectorAll(
"div._weekStrip_del5y_5 div._date_del5y_11"
);
dateColumns.forEach((column) => {
// Get the date info from header
const dayText = column
.querySelector("div._day_del5y_41")
?.textContent.trim();
const weekdayText = column
.querySelector("div._weekday_del5y_46")
?.textContent.trim();
if (!dayText) return;
// Find the slot list container
const slotList = column.querySelector("div._date_1ep5f_5");
if (!slotList) return;
// Get the full date from aria-label
const ariaLabel = slotList.getAttribute("aria-label");
const dateMatch = ariaLabel
? ariaLabel.match(/Meeting times for: (.+)/)
: null;
const fullDate = dateMatch
? dateMatch[1]
: `${weekdayText} ${dayText}`;
// Get all available time slots (NOT disabled)
const slotButtons = slotList.querySelectorAll(
"button._slot_1ep5f_11"
);
const availableTimes = [];
slotButtons.forEach((btn) => {
// Skip disabled slots
if (btn.hasAttribute("disabled")) return;
const timeText = btn.textContent.trim();
if (timeText && /\d{1,2}:\d{2}/.test(timeText)) {
availableTimes.push(timeText);
}
});
if (availableTimes.length > 0) {
results.push({
date: fullDate,
slots: availableTimes,
});
}
});
return results;
});
// Add new slots (avoid duplicates by date)
slotsWithTimes.forEach((slot) => {
if (!allSlots.some((s) => s.date === slot.date)) {
allSlots.push(slot);
}
});
logDebug(
`Found ${allSlots.length} dates with slots so far (attempt ${
attempts + 1
})`
);
// If we need more dates, click "Go to next week"
if (allSlots.length < desiredDates && attempts < maxAttempts - 1) {
const clicked = await page.evaluate(() => {
const nextButton = document.querySelector(
'button[aria-label="Go to next week"]'
);
if (nextButton && !nextButton.disabled) {
nextButton.click();
return true;
}
return false;
});
if (clicked) {
logDebug("Clicking 'Go to next week'...");
// Wait for week strip to update
await page.waitForLoadState("networkidle");
} else {
logDebug("No more weeks available (next button disabled)");
break;
}
}
attempts++;
}
const slots = allSlots;
logDebug(`Total: ${slots.length} dates with time slots`);
// Get page HTML for debugging if no slots found
if (slots.length === 0) {
const html = await page.content();
const debugFile = path.join(LOG_DIR, "page.html");
fs.writeFileSync(debugFile, html);
logDebug(`No slots found. Page HTML saved to ${debugFile}`);
}
await page.close();
await browser.close();
return { slots, timezone: currentTimezone };
} catch (err) {
logDebug(`Error during scraping: ${err.message}`);
await browser.close();
throw err;
}
}
async function main() {
console.log(
`${color.bold}${color.cyan}πŸ“… FANTASTICAL SLOT CHECKER${color.reset}\n`
);
logDebug("=== SESSION STARTED ===");
logDebug(`Script: ${process.argv[1]}`);
logDebug(`Working directory: ${process.cwd()}`);
// Parse arguments
const daysArg = process.argv[2] || "7";
const timezone = process.argv[3] || "";
const days = parseInt(daysArg, 10);
if (isNaN(days) || days < 1 || days > 90) {
console.error(
`${color.red}Error: Days must be a number between 1 and 90${color.reset}`
);
logDebug(`Invalid days argument: ${daysArg}`);
process.exit(1);
}
logDebug(`Arguments: days=${days}, timezone=${timezone || "(default)"}`);
console.log(`${color.bold}Configuration:${color.reset}`);
console.log(` URL: ${FANTASTICAL_URL}`);
console.log(` Days: Next ${days} working days`);
if (timezone) {
console.log(` Timezone: ${timezone}`);
}
console.log();
// Check for required dependencies
try {
require("playwright-core");
} catch (err) {
console.error(
`${color.red}Error: playwright-core is not installed${color.reset}`
);
console.error(
`${color.yellow}Run: npm install playwright-core${color.reset}`
);
logDebug("playwright-core not found");
process.exit(1);
}
try {
console.log(`${color.cyan}⏳ Creating browser session...${color.reset}`);
const sessionId = await createBrowserbaseSession();
const result = await scrapeFantasticalSlots(sessionId, timezone, (msg) =>
console.log(`${color.cyan}${msg}${color.reset}`)
);
const slots = result.slots;
const selectedTimezone = result.timezone;
// Helper functions for formatting output
function timeToMinutes(timeStr) {
const match = timeStr.match(/(\d{1,2}):(\d{2})\s*(AM|PM)?/i);
if (!match) return 0;
let hours = parseInt(match[1]);
const minutes = parseInt(match[2]);
const period = match[3];
if (period) {
if (period.toUpperCase() === "PM" && hours !== 12) hours += 12;
if (period.toUpperCase() === "AM" && hours === 12) hours = 0;
}
return hours * 60 + minutes;
}
function groupTimeSlots(slots) {
if (slots.length === 0) return [];
const ranges = [];
let rangeStart = slots[0];
let rangeEnd = slots[0];
for (let i = 1; i < slots.length; i++) {
const prevMinutes = timeToMinutes(slots[i - 1]);
const currMinutes = timeToMinutes(slots[i]);
if (currMinutes - prevMinutes === 30) {
rangeEnd = slots[i];
} else {
ranges.push({ start: rangeStart, end: rangeEnd });
rangeStart = slots[i];
rangeEnd = slots[i];
}
}
ranges.push({ start: rangeStart, end: rangeEnd });
return ranges;
}
function addThirtyMinutes(timeStr, use24hr = false) {
const minutes = timeToMinutes(timeStr);
const newMinutes = minutes + 30;
const hours = Math.floor(newMinutes / 60) % 24;
const mins = newMinutes % 60;
if (use24hr) {
return `${hours.toString().padStart(2, "0")}:${mins
.toString()
.padStart(2, "0")}`;
} else {
const period = hours >= 12 ? "PM" : "AM";
const displayHours = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
return `${displayHours}:${mins.toString().padStart(2, "0")} ${period}`;
}
}
function convertTo24Hour(timeStr) {
const match = timeStr.match(/(\d{1,2}):(\d{2})\s*(AM|PM)?/i);
if (!match) return timeStr;
let hours = parseInt(match[1]);
const minutes = match[2];
const period = match[3];
if (period) {
if (period.toUpperCase() === "PM" && hours !== 12) hours += 12;
if (period.toUpperCase() === "AM" && hours === 12) hours = 0;
}
return `${hours.toString().padStart(2, "0")}:${minutes}`;
}
function formatDate(dateStr) {
const match = dateStr.match(/(\w+), (\w+) (\d+), (\d+)/);
if (!match) return dateStr;
const [, weekday, month, day] = match;
return `${weekday}, ${month} ${day}`;
}
console.log();
console.log(
`${color.bold}${color.bgGreen}${color.white} AVAILABLE SLOTS ${color.reset}\n`
);
if (slots.length === 0) {
console.log(`${color.yellow}No available slots found${color.reset}`);
console.log(
`${color.yellow}Check the logs at: ${LOG_FILE}${color.reset}`
);
} else {
// Filter slots to only show requested number of working days
const filteredSlots = slots.slice(0, days);
// Extract timezone for display - handle "Amsterdam The Netherlands (GMT+2)" format
const tzMatch = selectedTimezone.match(/^(.+?)\s*\(/);
let tzDisplay = tzMatch ? tzMatch[1] : selectedTimezone.split("(")[0];
// Fix spacing issues like "AmsterdamThe Netherlands" -> "Amsterdam, The Netherlands"
tzDisplay = tzDisplay.replace(/([a-z])([A-Z])/g, "$1, $2").trim();
// Determine if we should use 24hr format
const use24hr = uses24HourFormat(selectedTimezone);
// Build output string for clipboard
let outputLines = [];
filteredSlots.forEach((dateGroup, index) => {
const formattedDate = formatDate(dateGroup.date);
const header = `πŸ—“ ${formattedDate} (timezone: ${tzDisplay})`;
console.log(`${color.bold}${header}${color.reset}`);
outputLines.push(header);
const ranges = groupTimeSlots(dateGroup.slots);
ranges.forEach((range) => {
let startTime = range.start;
let endTime = addThirtyMinutes(range.end, use24hr);
// Convert to 24hr if needed
if (use24hr) {
startTime = convertTo24Hour(startTime);
}
const timeRange = ` ${startTime} – ${endTime}`;
console.log(timeRange);
outputLines.push(timeRange);
});
if (index < filteredSlots.length - 1) {
console.log();
outputLines.push("");
}
});
// Copy to clipboard (macOS)
const clipboardText = outputLines.join("\n");
try {
const { spawnSync } = require("child_process");
spawnSync("pbcopy", [], {
input: clipboardText,
encoding: "utf-8",
env: { ...process.env, LC_ALL: "en_US.UTF-8" },
});
console.log();
console.log(`${color.green}πŸ“‹ Copied to clipboard${color.reset}`);
} catch (err) {
logDebug(`Could not copy to clipboard: ${err.message}`);
}
}
console.log();
console.log(`${color.green}βœ… Done${color.reset}`);
logDebug("=== SESSION COMPLETED SUCCESSFULLY ===");
} catch (err) {
console.error();
console.error(`${color.red}❌ Error: ${err.message}${color.reset}`);
console.error(`${color.yellow}Check logs at: ${LOG_FILE}${color.reset}`);
logDebug(`Error: ${err.message}`);
logDebug(`Stack: ${err.stack}`);
process.exit(1);
}
}
main();

Fantastical Available Time Slots Script

A Raycast script that fetches your available meeting slots from a Fantastical scheduling page and formats them nicely with timezone support.

Features

  • πŸ“… Scrapes available time slots from your Fantastical scheduling page
  • 🌍 Supports 30+ timezones with automatic 12hr/24hr format detection
  • πŸ“‹ Automatically copies formatted slots to clipboard
  • πŸ”„ Paginates through multiple weeks to find available slots
  • ⏰ Groups consecutive time slots into ranges for cleaner output

Prerequisites

  1. Node.js (v18 or higher recommended)
  2. Raycast (if using as a Raycast script)
  3. Browserbase account - Sign up at browserbase.com
  4. Fantastical scheduling page - You need a Fantastical.app scheduling URL

Installation

1. Install Dependencies

npm install playwright-core

2. Configure the Script

Open fantastical-slots.js and replace the following placeholders:

// !!! REPLACE WITH YOUR FANTASTICAL SCHEDULING PAGE URL !!!
const FANTASTICAL_URL =
  "https://fantastical.app/YOUR-USERNAME/YOUR-MEETING-URL";

// !!! REPLACE WITH YOUR BROWSERBASE CREDENTIALS !!!
// Get these from https://www.browserbase.com/
const BROWSERBASE_PROJECT_ID = "YOUR-PROJECT-ID";
const BROWSERBASE_API_KEY = "YOUR-API-KEY";

Getting Browserbase Credentials:

  1. Sign up at browserbase.com
  2. Create a new project
  3. Copy your Project ID and API Key from the dashboard

3. Make the Script Executable

chmod +x fantastical-slots-shareable.js

As a Raycast Script

  1. In Raycast preferences (⌘,), go to Extensions β†’ Script Commands
  2. Add your scripts directory via [Add Directories] button
  3. Place the script in that directory

License

MIT - Feel free to modify and share!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment