← back to Ventura Corridor

src/server/appointments/slots.ts

130 lines

/**
 * Slot calculator — converts appt_availability rows + busy windows
 * (DB-booked + Google-Calendar busy) into the array of bookable
 * {slot_start, slot_end} ISO pairs for a given date.
 *
 * The owner's local timezone is assumed America/Los_Angeles for v1.
 * (Steve's universe is in PT; a future migration adds a tz column.)
 */
import { query } from '../../../db/pool.ts';
import { busy as gcalBusy } from './calendar.ts';

const OWNER_TZ = 'America/Los_Angeles';

interface AvailRow {
  day_of_week:       number;
  start_min:         number;
  end_min:           number;
  slot_duration_min: number;
}

interface DbBookedRow { slot_start: string; slot_end: string; }

/** Compute the UTC ISO instant for a given local-PT date at H:M minutes-from-midnight. */
function ptLocalToUTC(dateYYYYMMDD: string, minutesFromMidnight: number): Date {
  const hour   = Math.floor(minutesFromMidnight / 60);
  const minute = minutesFromMidnight % 60;
  // We compute the offset for the given local date by formatting an arbitrary
  // instant in the target tz and reading back its offset. This is the cleanest
  // pure-stdlib way to handle DST in Node without a deps tax.
  const parts = new Intl.DateTimeFormat('en-US', {
    timeZone:    OWNER_TZ,
    timeZoneName:'longOffset',
    year: 'numeric', month: '2-digit', day: '2-digit',
    hour: '2-digit', minute: '2-digit', second: '2-digit',
    hour12: false,
  }).formatToParts(new Date(`${dateYYYYMMDD}T${String(hour).padStart(2,'0')}:${String(minute).padStart(2,'0')}:00Z`));
  const tzPart = parts.find(p => p.type === 'timeZoneName')?.value || 'GMT-08:00';
  // tzPart looks like "GMT-07:00" or "GMT-08:00"
  const m = tzPart.match(/GMT([+-])(\d{2}):?(\d{2})/);
  const offsetMin = m ? (m[1] === '-' ? -1 : 1) * (parseInt(m[2],10)*60 + parseInt(m[3],10)) : -480;
  // Now construct: the local wall-clock instant is YYYY-MM-DDTHH:MM:00 in PT.
  // UTC instant = local - offset.
  const localMs = Date.UTC(
    parseInt(dateYYYYMMDD.slice(0,4),10),
    parseInt(dateYYYYMMDD.slice(5,7),10) - 1,
    parseInt(dateYYYYMMDD.slice(8,10),10),
    hour, minute, 0, 0,
  );
  return new Date(localMs - offsetMin * 60_000);
}

/** Day-of-week for a YYYY-MM-DD interpreted in PT. */
function dayOfWeekPT(dateYYYYMMDD: string): number {
  // Use noon-PT so DST edge cases don't bump the date.
  const noon = ptLocalToUTC(dateYYYYMMDD, 12 * 60);
  return new Date(noon).getUTCDay();   // Sun=0..Sat=6 in UTC; since we used noon, == PT day
}

export interface Slot {
  slot_start: string;   // ISO 8601 UTC
  slot_end:   string;   // ISO 8601 UTC
}

export async function computeSlots(
  ownerEmail: string,
  dateYYYYMMDD: string,
): Promise<Slot[]> {
  if (!/^\d{4}-\d{2}-\d{2}$/.test(dateYYYYMMDD)) return [];

  const dow = dayOfWeekPT(dateYYYYMMDD);
  const av = await query<AvailRow>(
    `SELECT day_of_week, start_min, end_min, slot_duration_min
       FROM appt_availability
      WHERE owner_email = $1 AND day_of_week = $2
      ORDER BY start_min ASC`,
    [ownerEmail, dow],
  );
  if (!av.rowCount) return [];

  // Build candidate slots in UTC
  const candidates: Slot[] = [];
  for (const row of av.rows) {
    for (let m = row.start_min; m + row.slot_duration_min <= row.end_min; m += row.slot_duration_min) {
      const s = ptLocalToUTC(dateYYYYMMDD, m);
      const e = ptLocalToUTC(dateYYYYMMDD, m + row.slot_duration_min);
      candidates.push({ slot_start: s.toISOString(), slot_end: e.toISOString() });
    }
  }
  if (!candidates.length) return [];

  // Window for busy lookups = the full local day
  const dayStart = ptLocalToUTC(dateYYYYMMDD, 0).toISOString();
  const dayEnd   = ptLocalToUTC(dateYYYYMMDD, 24 * 60).toISOString();

  // 1. DB-booked slots (active = confirmed | pending; cancelled ignored)
  const booked = await query<DbBookedRow>(
    `SELECT slot_start, slot_end FROM appt_appointments
      WHERE owner_email = $1
        AND status IN ('confirmed','pending')
        AND slot_start < $3 AND slot_end > $2`,
    [ownerEmail, dayStart, dayEnd],
  );

  // 2. Google Calendar busy windows (if owner has connected calendar)
  let gcal: { start: string; end: string }[] = [];
  try {
    gcal = await gcalBusy(ownerEmail, dayStart, dayEnd);
  } catch (e) {
    console.error('[appointments] gcalBusy threw, treating as empty:', e);
  }

  const busyWindows: { s: number; e: number }[] = [
    ...booked.rows.map(b => ({ s: Date.parse(b.slot_start), e: Date.parse(b.slot_end) })),
    ...gcal.map(b      => ({ s: Date.parse(b.start),      e: Date.parse(b.end)       })),
  ];

  // 3. Also drop any slot in the past
  const now = Date.now();

  return candidates.filter(c => {
    const cs = Date.parse(c.slot_start);
    const ce = Date.parse(c.slot_end);
    if (cs < now) return false;
    for (const b of busyWindows) {
      if (cs < b.e && ce > b.s) return false;   // overlap
    }
    return true;
  });
}