NobGit
public imalexnord read

Slot

Calendar-first scheduling powered by Cloudflare Workers and Google Calendar.

Languages

Repository composition by tracked source files.

TypeScript
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
time.ts
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;
}