use-attendee-availability.ts11.1 KBView on GitHub
import type { CalendarEvent } from '../types/calendar-types';
import { useActiveConnection } from '@/hooks/use-connections';
import { useTRPC } from '@/providers/query-provider';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';

/**
 * The colours attendee overlays cycle through, in order of first appearance.
 *
 * Deliberately NOT the palette Google hands calendars: these blocks sit on the same grid as
 * the user's own events and have to read as "somebody else's time", so they need to be
 * distinguishable from the calendar colours rather than drawn from them. Each attendee keeps
 * their colour for as long as they are on the guest list, which is what makes the swatch on
 * their chip mean anything.
 */
const ATTENDEE_COLORS = [
  '#9333ea', // violet
  '#0891b2', // cyan
  '#c2410c', // burnt orange
  '#4d7c0f', // olive
  '#be123c', // rose
  '#4338ca', // indigo
] as const;

/** How an attendee's calendar answered, and what that means for the slot being composed. */
export type AttendeeAvailabilityStatus =
  /** Not in the user's own domain, so Cedar never asks Google about them. */
  | 'external'
  /** Free/busy is in flight. */
  | 'loading'
  /** Google would not say — no sharing, or not a real calendar. */
  | 'unknown'
  /** Internal, calendar readable, nothing booked over the proposed slot. */
  | 'free'
  /** Internal, calendar readable, already booked over the proposed slot. */
  | 'busy';

export interface AttendeeAvailability {
  email: string;
  status: AttendeeAvailabilityStatus;
  /** The swatch on this attendee's chip, matching their blocks on the grid. Internal only. */
  color?: string;
}

/** One booked interval on somebody else's calendar, ready to lay out on the grid. */
export interface AttendeeBusyInterval {
  email: string;
  color: string;
  start: string;
  end: string;
}

/** The local part of an email address is the half that varies; the domain is what we compare. */
function domainOf(email: string): string {
  const at = email.lastIndexOf('@');
  return at === -1 ? '' : email.slice(at + 1).toLowerCase();
}

/**
 * Whether `email` belongs to the same organisation as the signed-in account.
 *
 * Domain equality is the whole test. It is what "internal" means to the person scheduling —
 * and, not by coincidence, it is also the boundary Google draws for free/busy: a Workspace
 * publishes busy times across its own domain by default, so same-domain is very nearly the
 * same set as "calendars we can actually read".
 */
export function isInternalAttendee(email: string, ownerEmail: string | null | undefined): boolean {
  if (!ownerEmail) return false;
  const ownDomain = domainOf(ownerEmail);
  return !!ownDomain && domainOf(email) === ownDomain;
}

/** Two intervals overlap when each starts before the other ends. Touching endpoints do not. */
function overlaps(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
  return aStart < bEnd && bStart < aEnd;
}

/**
 * Whether any of `busy` covers the slot — the "(busy)" on the chip.
 *
 * Touching endpoints deliberately do NOT count: back-to-back meetings are the normal shape of
 * a working day, and calling 3:00 busy because something ends at 3:00 would mark almost every
 * slot busy and make the signal worthless.
 */
export function isBusyDuringSlot(
  busy: Array<{ start?: string | null; end?: string | null }> | null | undefined,
  slotStartISO: string,
  slotEndISO: string,
): boolean {
  const slotStart = new Date(slotStartISO).getTime();
  const slotEnd = new Date(slotEndISO).getTime();
  if (Number.isNaN(slotStart) || Number.isNaN(slotEnd)) return false;

  return (busy ?? []).some((slice) => {
    if (!slice.start || !slice.end) return false;
    const start = new Date(slice.start).getTime();
    const end = new Date(slice.end).getTime();
    if (Number.isNaN(start) || Number.isNaN(end)) return false;
    return overlaps(slotStart, slotEnd, start, end);
  });
}

/**
 * The window free/busy is fetched over, derived from the slot being composed alone.
 *
 * Two components need these blocks — the composer, to put a verdict on each guest's chip,
 * and the grid, to paint them — and they sit in different trees, so neither can hand the
 * other a time range. Deriving the window from the draft instead means both arrive at the
 * SAME query key and TanStack serves them one request. Snapping to the containing week is
 * what makes that hold while the draft is dragged around: only moving it to another week
 * changes the key, and that is a window genuinely worth refetching.
 */
function availabilityWindow(anchorISO: string | null | undefined): {
  timeMin: string;
  timeMax: string;
} {
  const anchor = anchorISO ? new Date(anchorISO) : new Date();
  const valid = Number.isNaN(anchor.getTime()) ? new Date() : anchor;

  const weekStart = new Date(valid);
  weekStart.setDate(weekStart.getDate() - weekStart.getDay());
  weekStart.setHours(0, 0, 0, 0);

  const start = new Date(weekStart);
  start.setDate(start.getDate() - 7);

  const end = new Date(weekStart);
  end.setDate(end.getDate() + 14);
  end.setHours(23, 59, 59, 999);

  return { timeMin: start.toISOString(), timeMax: end.toISOString() };
}

/**
 * Free/busy for the internal people on an event being composed, as both a verdict per guest
 * and a set of blocks to paint on the grid.
 *
 * This is the "can they even make it?" half of scheduling, which Cedar could only answer by
 * leaving the composer and looking. Google's freebusy endpoint is the right source rather
 * than listing each person's events: it answers for anyone in the domain without needing
 * read access to the events themselves, and it returns only the interval — no titles, no
 * guests — which is exactly the amount of a colleague's calendar the person scheduling is
 * entitled to see.
 *
 * External guests are skipped rather than queried and shown as failures. Google answers for
 * a stranger's calendar with an error, so asking would spend a round trip to learn what the
 * domain already told us.
 */
export function useAttendeeAvailability(params: {
  emails: string[];
  /** The slot each guest is judged free or busy against — the event being composed. */
  slot?: { start?: string | null; end?: string | null } | null;
  enabled?: boolean;
}): {
  attendees: AttendeeAvailability[];
  busyIntervals: AttendeeBusyInterval[];
  /** The busy intervals as grid-ready events, to merge into the calendar's own list. */
  overlayEvents: CalendarEvent[];
} {
  const { emails, slot, enabled = true } = params;
  const trpc = useTRPC();
  const { data: activeConnection } = useActiveConnection();
  const ownerEmail = activeConnection?.email;

  const { timeMin, timeMax } = useMemo(() => availabilityWindow(slot?.start), [slot?.start]);

  // Stable, de-duplicated, lower-cased: the query key below is derived from this, so an
  // attendee list that only changed order must not refetch.
  const internalEmails = useMemo(() => {
    const seen = new Set<string>();
    for (const email of emails) {
      const normalized = email.trim().toLowerCase();
      if (normalized && isInternalAttendee(normalized, ownerEmail)) seen.add(normalized);
    }
    return Array.from(seen).sort();
  }, [emails, ownerEmail]);

  const colorFor = useMemo(() => {
    const map = new Map<string, string>();
    internalEmails.forEach((email, index) => {
      map.set(email, ATTENDEE_COLORS[index % ATTENDEE_COLORS.length]);
    });
    return map;
  }, [internalEmails]);

  const freeBusyQuery = useQuery(
    trpc.calendar.getFreeBusy.queryOptions(
      { timeMin, timeMax, calendarIds: internalEmails },
      {
        enabled: enabled && internalEmails.length > 0,
        // Somebody else's calendar moves on its own, but not fast enough to be worth
        // refetching while a guest list is being typed.
        staleTime: 60 * 1000,
        // A calendar the user may not read fails the same way every time.
        retry: false,
      },
    ),
  );

  const busyIntervals = useMemo((): AttendeeBusyInterval[] => {
    const calendars = freeBusyQuery.data?.calendars;
    if (!calendars) return [];

    const intervals: AttendeeBusyInterval[] = [];
    for (const email of internalEmails) {
      // Google keys the response by the calendar id we asked for, so this is the same
      // lower-cased address — but it has been known to echo the address back in the
      // requester's casing, so fall back to a case-insensitive lookup before giving up.
      const entry =
        calendars[email] ??
        Object.entries(calendars).find(([id]) => id.toLowerCase() === email)?.[1];
      if (!entry?.busy) continue;

      const color = colorFor.get(email) ?? ATTENDEE_COLORS[0];
      for (const slice of entry.busy) {
        if (!slice.start || !slice.end) continue;
        intervals.push({ email, color, start: slice.start, end: slice.end });
      }
    }
    return intervals;
  }, [freeBusyQuery.data, internalEmails, colorFor]);

  const attendees = useMemo((): AttendeeAvailability[] => {
    const slotStart = slot?.start ? new Date(slot.start).getTime() : null;
    const slotEnd = slot?.end ? new Date(slot.end).getTime() : null;
    const hasSlot = slotStart !== null && slotEnd !== null && slotEnd > slotStart;
    const calendars = freeBusyQuery.data?.calendars;

    return emails.map((raw): AttendeeAvailability => {
      const email = raw.trim().toLowerCase();
      if (!isInternalAttendee(email, ownerEmail)) return { email: raw, status: 'external' };

      const color = colorFor.get(email);
      if (freeBusyQuery.isPending) return { email: raw, status: 'loading', color };
      if (freeBusyQuery.isError || !calendars) return { email: raw, status: 'unknown', color };

      const entry =
        calendars[email] ??
        Object.entries(calendars).find(([id]) => id.toLowerCase() === email)?.[1];
      // Google reports a calendar it could not read as an `errors` array on that calendar
      // rather than as a failed request, so an unreadable colleague arrives looking exactly
      // like a free one. Saying "free" there would be a confident lie.
      if (!entry || entry.errors?.length) return { email: raw, status: 'unknown', color };
      if (!hasSlot || !slot?.start || !slot?.end) return { email: raw, status: 'free', color };

      const isBusy = isBusyDuringSlot(entry.busy, slot.start, slot.end);
      return { email: raw, status: isBusy ? 'busy' : 'free', color };
    });
  }, [emails, ownerEmail, colorFor, freeBusyQuery.data, freeBusyQuery.isPending, freeBusyQuery.isError, slot?.start, slot?.end]);

  const overlayEvents = useMemo((): CalendarEvent[] => {
    return busyIntervals.map((interval, index) => ({
      // `attendee-busy-` keeps these out of every mutation path: `isClientOnlyEvent` in
      // hooks/use-event-manipulation.ts treats the prefix as "no calendar has heard of this
      // id", so clicking one can never send an update or a delete to Google.
      id: `attendee-busy-${interval.email}-${index}`,
      summary: interval.email.split('@')[0],
      start: { dateTime: interval.start },
      end: { dateTime: interval.end },
      calendarId: interval.email,
      color: interval.color,
      isProposed: false,
    }));
  }, [busyIntervals]);

  return { attendees, busyIntervals, overlayEvents };
}