Skip to content

Instantly share code, notes, and snippets.

@DarkMatterMatt
Last active April 25, 2023 03:58
Show Gist options
  • Save DarkMatterMatt/5f45b7e0d84dc1ce535edf2b0750f1d7 to your computer and use it in GitHub Desktop.
Save DarkMatterMatt/5f45b7e0d84dc1ce535edf2b0750f1d7 to your computer and use it in GitHub Desktop.
/**
* Usage: add a bookmarklet to the following link. Then, open Calven and click the bookmark to load the extension.
* javascript:fetch('https://gist.githubusercontent.com/DarkMatterMatt/5f45b7e0d84dc1ce535edf2b0750f1d7/raw').then(r => r.text()).then(eval)
*/
(async () => {
if (!window.location.hostname.endsWith('.calven.com')) {
alert('Please run this bookmarklet on *.calven.com');
return;
}
const { fb } = window;
const userId = fb.apps[1].auth().currentUser.uid;
const getToken = async () => fb.apps[1].auth().currentUser.getIdToken();
const queryApi = async (url, postData) => {
const res = await fetch(`https://api.calven.app${url}`, {
method: postData && 'POST',
headers: {
Authorization: `Bearer ${await getToken()}`,
'Content-Type': postData && 'application/json',
},
body: postData && JSON.stringify(postData),
});
if (!res.ok) {
throw new Error(`Failed fetching: ${url}, [${res.status}]`);
}
return res.json();
};
// State loading/saving
const DAY = 1000 * 60 * 60 * 24;
const STATE_KEY = '__MM_Calven';
const log = (...args) => console.log('!!MM', ...args);
const loadState = () => JSON.parse(localStorage.getItem(STATE_KEY)) || {
version: 1,
};
const state = loadState();
const saveState = () => localStorage.setItem(STATE_KEY, JSON.stringify(state));
// Load user details
const userDetails = await queryApi(`/users/${userId}`);
log(`Loaded userDetails for userId: ${userId}`, userDetails);
// Element creation
const $header = document.createElement('div');
$header.style.position = 'fixed';
$header.style.top = '0';
$header.style.left = '0';
$header.style.width = '100%';
$header.style.zIndex = '9999999999';
$header.style.backgroundColor = '#ffe6ff';
$header.style.display = 'flex';
$header.style.justifyContent = 'center';
$header.style.alignItems = 'center';
$header.style.gap = '8px';
$header.innerText = 'Loading';
document.body.prepend($header);
const $locationSelect = document.createElement('select');
const $levelSelect = document.createElement('select');
const $placeSelect = document.createElement('select');
const $button = document.createElement('button');
// Loaders
const populateDropdown = ($select, options) => {
$select.children.length = 0;
for (const option of [{ name: 'SELECT', value: '' }, ...options]) {
const $option = document.createElement('option');
$option.innerText = option.name;
$option.value = option.value;
$select.appendChild($option);
}
};
const populateLocations = async () => {
const locations = await queryApi('/locations')
.then((j) => j.data)
.then((locations) => locations.sort((a, b) => {
if (a.countryCode !== b.countryCode) {
return a.countryCode.localeCompare(b.countryCode);
}
return a.address.localeCompare(b.address);
}));
log('Loaded locations', locations);
populateDropdown($locationSelect, locations.map((l) => ({ name: l.name, value: l.locationId })));
saveState();
};
const populateLevels = async (locationId) => {
state.locationId = locationId;
const levels = await queryApi(`/locations/${state.locationId}/levels`)
.then((j) => j.data)
.then((levels) => levels.sort((a, b) => a.name.localeCompare(b.name)));
log(`Loaded levels for location: ${state.locationId}`, levels);
populateDropdown($levelSelect, levels.map((l) => ({ name: l.name, value: l.levelId })));
saveState();
};
const populatePlaces = async (levelId) => {
state.levelId = levelId;
const zones = await queryApi(`/locations/${state.locationId}/levels/${state.levelId}/zones`)
.then((j) => j.data)
.then((zones) => zones.sort((a, b) => a.name.localeCompare(b.name)));
log(`Loaded zones for level: ${state.levelId}`, zones);
const places = [];
for (const zone of zones) {
const { zoneId } = zone;
const zonePlaces = await queryApi(`/locations/${state.locationId}/levels/${state.levelId}/zones/${zoneId}/places?limit=1000`)
.then((j) => j.data)
.then((place) => place.sort((a, b) => a.name.localeCompare(b.name)));
log(`Loaded places for zone: ${zone.zoneId}`, zonePlaces);
places.push(...zonePlaces.map((p) => ({ ...p, zoneId })));
}
populateDropdown($placeSelect, places.map((p) => ({ name: p.name, value: `${p.zoneId}:${p.placeId}` })));
saveState();
};
const setPlaceId = (zoneId, placeId) => {
state.zoneId = zoneId;
state.placeId = placeId;
saveState();
};
const toISOString = (date) => {
date = `${date}`;
return `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}T00:00:00.000Z`;
};
const toCalvenString = (date) => new Date(date).toISOString().split('T')[0].replace(/-/g, '');
const queryCurrentBookings = async () => {
const params = new URLSearchParams({
start: toCalvenString(Date.now() - DAY),
end: toCalvenString(Date.now() + 365 * DAY),
});
const results = await queryApi(`/users/${userId}/plans?${params}`);
return results.data.map(({ day, ...rest }) => ({
day: new Date(toISOString(day)),
...rest,
}));
};
const makeBooking = async (locationId, zoneId, placeId, date) => {
await queryApi(`/locations/${locationId}/locationPlans/${toCalvenString(date)}/zonePlans/${zoneId}/placeBookings`, {
day: toCalvenString(date),
placeId,
uid: userId,
});
};
const makeBookings = async (locationId, zoneId, placeId, date) => {
let lastSuccessful = date;
while (+date < +lastSuccessful + 7 * DAY) {
try {
await makeBooking(locationId, zoneId, placeId, date);
log('Made booking for', date);
lastSuccessful = date;
} catch {
// Ignore errors.
}
date = new Date(+date + DAY);
}
log('Finished making bookings until', lastSuccessful);
};
// Initialization
await populateLocations();
if (state.locationId) {
$locationSelect.value = state.locationId;
await populateLevels(state.locationId);
if (state.levelId) {
$levelSelect.value = state.levelId;
await populatePlaces(state.levelId);
if (state.zoneId && state.placeId) {
$placeSelect.value = `${state.zoneId}:${state.placeId}`;
setPlaceId(state.zoneId, state.placeId);
}
}
}
const currentBookings = await queryCurrentBookings();
log('Loaded current bookings', currentBookings);
const nextBookingDate = new Date(Math.max(...currentBookings.map((b) => +b.day)) + DAY);
log('Next booking date', nextBookingDate);
$button.innerText = `Book from ${nextBookingDate.toISOString().split('T')[0]}`;
// Event listeners
$locationSelect.addEventListener('change', (ev) => populateLevels(ev.target.value));
$levelSelect.addEventListener('change', (ev) => populatePlaces(ev.target.value));
$placeSelect.addEventListener('change', (ev) => setPlaceId(...ev.target.value.split(':')));
$button.addEventListener(
'click',
() => {
$button.disabled = true;
makeBookings(state.locationId, state.zoneId, state.placeId, nextBookingDate);
},
{ once: true },
);
$header.innerText = '';
$header.appendChild($locationSelect);
$header.appendChild($levelSelect);
$header.appendChild($placeSelect);
$header.appendChild($button);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment