Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

# Someday

**Free to host calendar availability picker - open-source cal.com / calendly alternative built on [Google-Apps-Script](https://developers.google.com/apps-script) for Gmail users.**
Expand Down Expand Up @@ -112,6 +111,17 @@ __you may need to sign out of all accounts, and only into your target account__
- Authorization modal will pop up, 'Review permissions', select your account, you will see a warning, go to advanced, then Go to <your script>(unsafe) then click Allow
- if it worked, refresh the page/editor then run the function again and it should complete without issue.

5. **Calendar Access:**
- By default, the script uses your primary calendar
- To use other calendars, make sure they are added to your Google Calendar with appropriate permissions
- You can change which calendar to use by modifying the `CALENDAR` variable in `backend/src/app.ts`
- Note: The script needs at least read access to the calendar you specify
- To use multiple calendars, you'll need to add them as Script Properties in the Apps Script editor:
1. Open the script editor with `clasp open`
2. Go to Project Settings (⚙️ icon)
3. Under "Script Properties", click "Add Script Property"
4. Add a property named "CALENDARS" with a comma-separated list of calendar IDs

## Cheat Sheet

- `npm run deploy` - build and delpoy
Expand Down
57 changes: 30 additions & 27 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
const CALENDAR = "primary";
const TIME_ZONE = "America/New_York";
const CALENDARS: string[] = (() => {
const calendarsProp = PropertiesService.getScriptProperties().getProperty('CALENDARS');
try {
if (!calendarsProp) return ["primary"];
const parsed = JSON.parse(calendarsProp);
return Array.isArray(parsed) ? parsed : ["primary"];
} catch (e) {
Logger.log(`Error parsing CALENDARS property: ${e}`);
return ["primary"];
}
})();
const TIME_ZONE = "America/Los_Angeles";
// America/Los_Angeles
// America/Denver
// America/Chicago
Expand All @@ -8,7 +18,7 @@ const TIME_ZONE = "America/New_York";
const WORKDAYS = [1, 2, 3, 4, 5];
const WORKHOURS = {
start: 9,
end: 13,
end: 16,
};
const DAYS_IN_ADVANCE = 28;
//high numbered days in advance cause significant loading time slow down
Expand All @@ -29,7 +39,6 @@ function fetchAvailability(): {
const nearestTimeslot = new Date(
Math.floor(new Date().getTime() / TSDURMS) * TSDURMS
);
const calendarId = CALENDAR;
const now = nearestTimeslot;
const end = new Date(
Date.UTC(
Expand All @@ -42,15 +51,18 @@ function fetchAvailability(): {
const response = Calendar.Freebusy!.query({
timeMin: now.toISOString(),
timeMax: end.toISOString(),
items: [{ id: calendarId }],
items: CALENDARS.map((id: string) => ({ id })),
});

const events = (
(response as any).calendars[calendarId].busy as {
start: string;
end: string;
}[]
).map(({ start, end }) => ({ start: new Date(start), end: new Date(end) }));
const events = CALENDARS.map((calendarId: string) => {
const busyTimes = (response as any).calendars[calendarId].busy;
Logger.log(`Busy times for ${calendarId}: ${JSON.stringify(busyTimes)}`);
return busyTimes.map(({ start, end }: { start: string; end: string }) => ({
start: new Date(start),
end: new Date(end)
}));
}).reduce((acc, curr) => acc.concat(curr), []);

//get all timeslots between now and end date
const timeslots = [];
for (
Expand All @@ -66,7 +78,7 @@ function fetchAvailability(): {
if (startTZ.getHours() < WORKHOURS.start) continue;
if (startTZ.getHours() >= WORKHOURS.end) continue;
if (WORKDAYS.indexOf(startTZ.getDay()) < 0) continue;
if (events.some((event) => event.start < end && event.end > start)) {
if (events.some((event: { start: Date; end: Date }) => event.start < end && event.end > start)) {
continue;
}
timeslots.push(start.toISOString());
Expand All @@ -81,33 +93,26 @@ function bookTimeslot(
phone: string,
note: string
): string {
Logger.log(`Booking timeslot: ${timeslot} for ${name}`);
const calendarId = CALENDAR;
const calendarId = CALENDARS[0];
const startTime = new Date(timeslot);
if (isNaN(startTime.getTime())) {
throw new Error("Invalid start time");
}
const endTime = new Date(startTime.getTime());
endTime.setUTCMinutes(startTime.getUTCMinutes() + TIMESLOT_DURATION);

Logger.log(`Timeslot start: ${startTime}, end: ${endTime}`);

try {
const possibleEvents = Calendar.Freebusy!.query({
timeMin: startTime.toISOString(),
timeMax: endTime.toISOString(),
items: [{ id: calendarId }],
items: CALENDARS.map((id: string) => ({ id })),
});

const busy = (possibleEvents as any).calendars[calendarId].busy;
const hasConflict = CALENDARS.some((calId: string) =>
(possibleEvents as any).calendars[calId].busy.length > 0
);

if (
busy.some((event: { start: Date; end: Date }) => {
const eventStart = new Date(event.start.toString());
const eventEnd = new Date(event.end.toString());
return eventStart <= endTime && eventEnd >= startTime;
})
) {
if (hasConflict) {
throw new Error("Timeslot not available");
}

Expand All @@ -122,11 +127,9 @@ function bookTimeslot(
status: "confirmed",
}
);
Logger.log(`Event created: ${event.getId()}`);
return `Timeslot booked successfully`;
} catch (e) {
const error = e as Error;
Logger.log(`Failed to create event: ${error.message}`);
throw new Error(`Failed to create event: ${error.message}`);
}
}