draftEvent.ts3.7 KBView on GitHub
import type { ConferenceKey } from './conferencing';
import type { CalendarEvent } from '../types/calendar-types';

/** The composer fields that live in React state rather than on the draft itself. */
export interface ComposerFields {
  summary: string;
  location: string;
  description: string;
  colorId: string | null;
  /** An RRULE, or the sentinel `'none'`. */
  recurrenceRule: string;
  conferenceKey=[redacted] | null;
}

/**
 * Fold the composer's own fields — and optionally a new time range — onto the draft event.
 *
 * This exists because **the draft is the only thing that survives the composer**. Moving the
 * event to a week that isn't on screen moves its block to a column the grid hasn't rendered;
 * the block unmounts, the popover pinned to it goes with it, and the form comes back
 * initialised from the draft. Send only the new times and it comes back holding only times —
 * which is exactly how picking "next tuesday" silently cleared the title someone had typed.
 *
 * Guests already lived on the draft for the same reason (see `setGuests`); this is the rest.
 */
export function draftWithComposerFields(
  draft: CalendarEvent,
  fields: ComposerFields,
  range?: { start: Date; end: Date },
): CalendarEvent {
  return {
    ...draft,
    // An empty box does not erase a title the draft arrived with (an agent proposal, a
    // "schedule follow-up") — the form has simply not caught up to it yet.
    summary: fields.summary || draft.summary,
    location: fields.location || undefined,
    description: fields.description || undefined,
    colorId: fields.colorId || undefined,
    recurrence: fields.recurrenceRule !== 'none' ? [fields.recurrenceRule] : undefined,
    // `wantsMeetLink` is the flag every producer of a draft writes; `conferenceType` says
    // WHICH. Both are read back when the form re-initialises.
    wantsMeetLink: fields.conferenceKey != null || undefined,
    conferenceType: fields.conferenceKey ?? undefined,
    ...(range
      ? {
          start: { ...draft.start, dateTime: range.start.toISOString() },
          end: { ...draft.end, dateTime: range.end.toISOString() },
        }
      : {}),
  };
}

/**
 * Does the composer hold anything the user actually put there?
 *
 * Times are not part of the answer: every draft is born with a start and an end, so they say
 * nothing about whether the user meant to keep it. See `emptySlotAction` for what turns on it.
 */
export function hasComposerContent(fields: ComposerFields, guestCount: number): boolean {
  return Boolean(
    fields.summary.trim() ||
      fields.location.trim() ||
      fields.description.trim() ||
      guestCount > 0 ||
      fields.conferenceKey ||
      fields.colorId ||
      fields.recurrenceRule !== 'none',
  );
}

/** What picking an empty slot should do to the calendar. */
export type EmptySlotAction = 'create' | 'move' | 'dismiss';

/**
 * The three things picking an empty slot can mean, and how to tell them apart.
 *
 * With no draft open it is a new event. With one open it MOVES that draft — the grid stays
 * live under the composer precisely so an event can be re-timed while it is being written.
 *
 * The exception is a draft with nothing in it: that click is the user abandoning it, not
 * re-timing it, so it does one thing — take the empty block away. On screen the composer's
 * own backdrop (see `EventComposerPopover`) usually catches that click first; this is the
 * same answer for the case it cannot, when the draft sits in a week the grid has scrolled
 * away from and has no block, and so no composer, to catch anything.
 */
export function emptySlotAction(args: {
  hasDraft: boolean;
  composerHasContent: boolean;
}): EmptySlotAction {
  if (!args.hasDraft) return 'create';
  if (!args.composerHasContent) return 'dismiss';
  return 'move';
}