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 };
}