public
imalexnord
read
Slot
Calendar-first scheduling powered by Cloudflare Workers and Google Calendar.
Languages
Repository composition by tracked source files.
TypeScript
79%
CSS
19%
SQL
2%
HTML
0%
Create file
Wiki Documentation
Clone
https://nobgit.com/user/imalexnord/slot.git
ssh://[email protected]:2222/user/imalexnord/slot.git
import type { BusyRange } from './types';
const formatterCache = new Map<string, Intl.DateTimeFormat>();
function formatter(timeZone: string): Intl.DateTimeFormat {
const cached = formatterCache.get(timeZone);
if (cached) return cached;
const next = new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
weekday: 'short',
});
formatterCache.set(timeZone, next);
return next;
}
export function partsInZone(date: Date, timeZone: string): Record<string, string> {
return Object.fromEntries(
formatter(timeZone)
.formatToParts(date)
.filter((part) => part.type !== 'literal')
.map((part) => [part.type, part.value]),
);
}
export function dateKeyInZone(date: Date, timeZone: string): string {
const parts = partsInZone(date, timeZone);
return `${parts.year}-${parts.month}-${parts.day}`;
}
export function addDaysToDateKey(dateKey: string, days: number): string {
const [year, month, day] = dateKey.split('-').map(Number);
return new Date(Date.UTC(year, month - 1, day + days, 12, 0, 0)).toISOString().slice(0, 10);
}
export function weekdayForDate(dateKey: string, timeZone: string): number {
const noon = zonedDateTimeToUtc(dateKey, '12:00', timeZone);
const label = partsInZone(noon, timeZone).weekday;
return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(label);
}
export function zonedDateTimeToUtc(dateKey: string, time: string, timeZone: string): Date {
const [year, month, day] = dateKey.split('-').map(Number);
const [hour, minute] = time.split(':').map(Number);
let guess = Date.UTC(year, month - 1, day, hour, minute, 0);
// Iterate to account for the zone offset, including DST transitions.
for (let i = 0; i < 3; i += 1) {
const parts = partsInZone(new Date(guess), timeZone);
const representedAsUtc = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour),
Number(parts.minute),
Number(parts.second),
);
const targetAsUtc = Date.UTC(year, month - 1, day, hour, minute, 0);
const delta = targetAsUtc - representedAsUtc;
if (delta === 0) break;
guess += delta;
}
return new Date(guess);
}
export function minutesToTime(total: number): string {
const hour = Math.floor(total / 60) % 24;
const minute = total % 60;
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
export function timeToMinutes(value: string): number {
const [hour, minute] = value.split(':').map(Number);
return hour * 60 + minute;
}
export function overlaps(start: number, end: number, busy: BusyRange[]): boolean {
return busy.some((range) => start < range.end && end > range.start);
}
export function addMinutes(timestamp: number, minutes: number): number {
return timestamp + minutes * 60_000;
}
export function slotKeys(start: number, end: number, incrementMinutes = 15): string[] {
const increment = incrementMinutes * 60_000;
const first = Math.floor(start / increment) * increment;
const last = Math.ceil(end / increment) * increment;
const keys: string[] = [];
for (let cursor = first; cursor < last; cursor += increment) {
keys.push(new Date(cursor).toISOString());
}
return keys;
}