calendarUtils.ts31.3 KBView on GitHub
import { getShadedColorHex } from '@/modules/cedar-os/src/styles/stylingUtils';
import type { CalendarEvent } from './types/calendar-types';
import type React from 'react';

// ─── Join window utils ────────────────────────────────────────────────────────

const FIFTEEN_MIN = 15 * 60 * 1000;

/**
 * The tighter window used by the next-steps meeting lists (NextStepsCard, the chat empty
 * state's conversation cards). Those lists are read as "what's coming up on this deal", not as
 * a launcher, so the Join affordance only earns its place right around the call itself.
 */
export const NEXT_STEPS_JOIN_WINDOW_MS = 10 * 60 * 1000;

/**
 * Core timing check: true if `now` is within `windowMs` (15 min by default) before `start`
 * through the same span after `end`. Use this when the caller already has plain Date objects
 * (e.g. NextCalendarEvent, ScheduledCalendarEvent).
 */
export function isWithinJoinWindow(
  start: Date,
  end: Date,
  now: Date = new Date(),
  windowMs: number = FIFTEEN_MIN,
): boolean {
  return now >= new Date(start.getTime() - windowMs) && now <= new Date(end.getTime() + windowMs);
}

/**
 * Returns true if the join button should be shown for a CalendarEvent.
 * Shows within 15 min before the event starts, throughout the event, and up to
 * 15 min after it ends. Does NOT apply to contexts that always show join (e.g. event details popover).
 */
export function shouldShowJoinButton(event: CalendarEvent, now: Date = new Date()): boolean {
  const start = event.start?.dateTime ? new Date(event.start.dateTime) : null;
  const end = event.end?.dateTime ? new Date(event.end.dateTime) : null;
  if (!start || !end) return false;
  return isWithinJoinWindow(start, end, now);
}

/**
 * Merge duplicate calendar events into a single event with multiple colored ribbons.
 *
 * Two events are considered duplicates when they share:
 *   1. The same Google event `id` (same event appearing on multiple calendars), OR
 *   2. The same summary + start dateTime + end dateTime (identical meetings added independently)
 *
 * The "primary" event kept is the one from the user's own calendar (non-external color),
 * with `mergedCalendarColors` collecting all the source calendar colors.
 */
export function mergeCalendarEvents(
  events: CalendarEvent[],
  calendarColors: Map<string, string>,
): CalendarEvent[] {
  const merged: CalendarEvent[] = [];
  // Track which event indices have already been merged into another
  const consumed = new Set<number>();

  for (let i = 0; i < events.length; i++) {
    if (consumed.has(i)) continue;

    const base = events[i];
    const baseStart = base.start?.dateTime;
    const baseEnd = base.end?.dateTime;
    const baseId = base.id;

    // Find all events that are duplicates of `base`
    const duplicateIndices: number[] = [];
    for (let j = i + 1; j < events.length; j++) {
      if (consumed.has(j)) continue;
      const candidate = events[j];
      const sameId = baseId && baseId === candidate.id;
      const sameTime =
        baseStart &&
        baseEnd &&
        baseStart === candidate.start?.dateTime &&
        baseEnd === candidate.end?.dateTime &&
        base.summary === candidate.summary;

      if (sameId || sameTime) {
        duplicateIndices.push(j);
        consumed.add(j);
      }
    }

    if (duplicateIndices.length === 0) {
      // No duplicates — push as-is
      merged.push(base);
      continue;
    }

    // Collect colors from all copies (base + duplicates)
    const allColors: string[] = [];

    const getColor = (event: CalendarEvent): string | undefined => {
      if (event.color) return event.color;
      if (event.calendarId) return calendarColors.get(event.calendarId);
      if (event.organizer?.email) return calendarColors.get(event.organizer.email);
      return undefined;
    };

    const baseColor = getColor(base);
    if (baseColor) allColors.push(baseColor);

    for (const di of duplicateIndices) {
      const dupColor = getColor(events[di]);
      if (dupColor && !allColors.includes(dupColor)) {
        allColors.push(dupColor);
      }
    }

    merged.push({
      ...base,
      mergedCalendarColors: allColors.length > 1 ? allColors : undefined,
    });
  }

  return merged;
}

/**
 * Check if two events overlap in time
 */
export function eventsOverlap(event1: CalendarEvent, event2: CalendarEvent): boolean {
  const start1DateTime = event1.start?.dateTime;
  const end1DateTime = event1.end?.dateTime;
  const start2DateTime = event2.start?.dateTime;
  const end2DateTime = event2.end?.dateTime;

  if (!start1DateTime || !end1DateTime || !start2DateTime || !end2DateTime) {
    return false;
  }

  const start1 = new Date(start1DateTime).getTime();
  const end1 = new Date(end1DateTime).getTime();
  const start2 = new Date(start2DateTime).getTime();
  const end2 = new Date(end2DateTime).getTime();

  return start1 < end2 && start2 < end1;
}

/**
 * Get a unique key for an event (includes calendarId for events in multiple calendars)
 */
function getEventKey(event: CalendarEvent): string {
  const calendarId = (event as CalendarEvent & { calendarId?: string }).calendarId;
  return `${event.id}__${calendarId || 'default'}`;
}

/** Where an event sits in its collision group. Consumed via `getEventLaneGeometry`. */
export interface EventLayoutInfo {
  /** Number of lanes the event's collision group was packed into. */
  columns: number;
  /** 0-based index of the leftmost lane this event occupies. */
  column: number;
  /** How many lanes the event widens across (always >= 1). */
  span: number;
}

/** An event reduced to the numbers the layout cares about, so we parse each date once. */
interface TimedEvent {
  event: CalendarEvent;
  start: number;
  end: number;
}

/**
 * Floor applied to an event's duration for layout purposes only.
 *
 * A chip is never drawn shorter than ~14px, so a zero- or one-minute event still covers a
 * slice of the grid. Treating it as instantaneous would let a later event claim the same lane
 * and paint straight through it.
 */
const MIN_LAYOUT_DURATION_MS = 15 * 60 * 1000;

/**
 * How far a chip bleeds into the lane on its right, as a fraction of one lane's width.
 *
 * Google Calendar does not carve a column into hard, non-touching slices. Chips are wider
 * than their own lane and layer on top of one another, with the leftmost strip of every chip
 * — the strip carrying its title — always left uncovered. That is what keeps a three-way
 * stack readable: each event gets roughly 1.5 lanes of width instead of exactly 1, and every
 * event in the stack still announces itself.
 */
export const EVENT_LANE_BLEED = 0.5;

/**
 * Gap kept between the right edge of an event chip and the right edge of its day column.
 *
 * Google Calendar never lets a chip reach the column edge: the strip left over is what you
 * click (or drag from) to book a time that is already busy. Chips are laid out inside the
 * column minus this gutter rather than across its full width.
 */
export const EVENT_COLUMN_GUTTER_PX = 8;

/** Parse + sort: earliest first, and on a tie the longer event leads so it takes lane 0. */
function toTimedEvents(events: CalendarEvent[]): TimedEvent[] {
  const timed: TimedEvent[] = [];

  for (const event of events) {
    const startDateTime = event.start?.dateTime;
    const endDateTime = event.end?.dateTime;
    if (!startDateTime || !endDateTime) continue;

    const start = new Date(startDateTime).getTime();
    const end = new Date(endDateTime).getTime();
    if (Number.isNaN(start) || Number.isNaN(end)) continue;

    timed.push({ event, start, end: Math.max(end, start + MIN_LAYOUT_DURATION_MS) });
  }

  return timed.sort((a, b) => a.start - b.start || b.end - b.start - (a.end - a.start));
}

/**
 * Split start-sorted events into maximal runs of transitively-overlapping events.
 *
 * Because the input is sorted by start, an event beginning at or after every prior event's
 * end cannot touch the run so far — and neither can anything after it. So the group closes
 * there. This is a true connected-components split: A(9–10), B(10:30–11:30) and C(9:30–11)
 * land in one group even though A and B never touch each other directly.
 */
function groupByCollision(timed: TimedEvent[]): TimedEvent[][] {
  const groups: TimedEvent[][] = [];
  let current: TimedEvent[] = [];
  let groupEnd = -Infinity;

  for (const item of timed) {
    if (current.length > 0 && item.start >= groupEnd) {
      groups.push(current);
      current = [];
      groupEnd = -Infinity;
    }
    current.push(item);
    groupEnd = Math.max(groupEnd, item.end);
  }

  if (current.length > 0) groups.push(current);
  return groups;
}

/**
 * Pack one collision group into lanes, then let each event widen into the free space to its
 * right.
 *
 * Lane packing alone gives the group its minimum lane count. Expansion is what stops a single
 * busy moment from starving the rest of the group: three events colliding at noon force the
 * group to 3 lanes, but a 4pm event in the same group still stretches across all three
 * because nothing sits beside it.
 */
function layoutCollisionGroup(group: TimedEvent[], layout: Map<string, EventLayoutInfo>): void {
  const lanes: TimedEvent[][] = [];
  const placements: { item: TimedEvent; lane: number }[] = [];

  for (const item of group) {
    // Every event already in a lane starts no later than this one (the group is start-sorted),
    // so the lane is free precisely when all of its events have already ended.
    let lane = lanes.findIndex((laneItems) => laneItems.every((other) => other.end <= item.start));

    if (lane === -1) {
      lane = lanes.length;
      lanes.push([]);
    }

    lanes[lane].push(item);
    placements.push({ item, lane });
  }

  for (const { item, lane } of placements) {
    let span = 1;
    while (
      lane + span < lanes.length &&
      lanes[lane + span].every((other) => other.end <= item.start || other.start >= item.end)
    ) {
      span++;
    }

    layout.set(getEventKey(item.event), { columns: lanes.length, column: lane, span });
  }
}

/**
 * Calculate the Google-Calendar-style overlap layout for a day's events.
 *
 * Returns a map of event keys (id__calendarId) to the lane the event starts in, how many
 * lanes its collision group needs, and how far it widens. Turn that into CSS with
 * `getEventLaneGeometry`.
 */
export function calculateEventLayout(events: CalendarEvent[]): Map<string, EventLayoutInfo> {
  const layout = new Map<string, EventLayoutInfo>();

  for (const group of groupByCollision(toTimedEvents(events))) {
    layoutCollisionGroup(group, layout);
  }

  return layout;
}

/**
 * Translate a lane assignment into the chip's CSS geometry.
 *
 * `depth` is the paint order: lanes further right sit on top, so each chip covers the bleed of
 * the chip behind it while keeping its own left strip — and its title — exposed.
 */
export function getEventLaneGeometry(layout: EventLayoutInfo): {
  leftPercent: number;
  widthPercent: number;
  depth: number;
} {
  const laneWidth = 100 / layout.columns;
  const leftPercent = layout.column * laneWidth;

  const spannedLanes = Math.max(1, Math.min(layout.span, layout.columns - layout.column));
  // Only bleed when there is a lane to bleed into — the rightmost chip stops at the column edge.
  const bleed = layout.column + spannedLanes < layout.columns ? EVENT_LANE_BLEED : 0;
  const widthPercent = Math.min((spannedLanes + bleed) * laneWidth, 100 - leftPercent);

  return { leftPercent, widthPercent, depth: layout.column };
}

/**
 * Get the start of the week for a given date
 * If sundayStart is true, weeks always start on Sunday (Sun-Sat)
 * Otherwise, on Sundays the week starts from Sunday, all other days go back to Monday (Mon-Sun)
 */
export function getWeekStart(date: Date, sundayStart = false): Date {
  const d = new Date(date);
  const day = d.getDay();

  if (sundayStart) {
    // Always start from Sunday
    d.setDate(d.getDate() - day);
    d.setHours(0, 0, 0, 0);
    return d;
  }

  // On Sunday (day === 0), start from Sunday itself
  // On all other days, go back to Monday
  if (day === 0) {
    // It's Sunday - week starts from this Sunday
    d.setHours(0, 0, 0, 0);
    return d;
  }

  // Not Sunday - go back to Monday
  const diff = d.getDate() - day + 1;
  d.setDate(diff);
  d.setHours(0, 0, 0, 0);
  return d;
}

/**
 * Get all dates in a week starting from weekStart
 */
export function getWeekDates(weekStart: Date): Date[] {
  const dates: Date[] = [];
  for (let i = 0; i < 7; i++) {
    const date = new Date(weekStart);
    date.setDate(weekStart.getDate() + i);
    dates.push(date);
  }
  return dates;
}

/**
 * Check if two dates are the same day
 */
export function isSameDay(date1: Date, date2: Date, timezone: string): boolean {
  const d1Str = date1.toLocaleDateString('en-US', {
    timeZone: timezone,
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
  });
  const d2Str = date2.toLocaleDateString('en-US', {
    timeZone: timezone,
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
  });
  return d1Str === d2Str;
}

/**
 * Get the start of a day in a specific timezone
 * Returns a Date object representing midnight (00:00:00) on the given day in the target timezone
 */
export function getStartOfDayInTimezone(date: Date, timezone: string): Date {
  return createDateInTimezone(date, 0, 0, timezone);
}

/**
 * Get the hour and minute components from a Date in a specific timezone
 * Returns the local time in that timezone, not the browser's timezone
 */
export function getTimeInTimezone(date: Date, timezone: string): { hour: number; minute: number } {
  const hour = parseInt(
    date.toLocaleString('en-US', {
      timeZone: timezone,
      hour: 'numeric',
      hour12: false,
    }),
  );
  const minute = parseInt(
    date.toLocaleString('en-US', {
      timeZone: timezone,
      minute: 'numeric',
    }),
  );
  return { hour, minute };
}

/**
 * Create a Date object at a specific time on a specific date in a timezone
 * This ensures the date/time combo is correct regardless of browser timezone
 *
 * For example: if we want "December 11, 2024 at 10:00 AM EST", this function
 * creates a Date object that when formatted in EST shows exactly that time.
 */
export function createDateInTimezone(
  baseDate: Date,
  hour: number,
  minute: number,
  timezone: string,
): Date {
  // Get the date components (year, month, day) in the target timezone
  const year = parseInt(baseDate.toLocaleString('en-US', { timeZone: timezone, year: 'numeric' }));
  const month = parseInt(
    baseDate.toLocaleString('en-US', { timeZone: timezone, month: 'numeric' }),
  );
  const day = parseInt(baseDate.toLocaleString('en-US', { timeZone: timezone, day: 'numeric' }));

  // We need to find the UTC time that, when displayed in the target timezone,
  // shows our desired local time. We do this by testing different UTC offsets.
  // This accounts for DST and timezone differences automatically.
  for (let utcHourOffset = -24; utcHourOffset <= 24; utcHourOffset++) {
    const testDate = new Date(Date.UTC(year, month - 1, day, hour - utcHourOffset, minute, 0));

    // Check what this UTC time looks like in the target timezone
    const displayedHour = parseInt(
      testDate.toLocaleString('en-US', {
        timeZone: timezone,
        hour: 'numeric',
        hour12: false,
      }),
    );
    const displayedDay = parseInt(
      testDate.toLocaleString('en-US', {
        timeZone: timezone,
        day: 'numeric',
      }),
    );

    // If it displays correctly, we found the right UTC time!
    if (displayedHour === hour && displayedDay === day) {
      return testDate;
    }
  }

  // Fallback (shouldn't reach here, but just in case)
  console.warn(
    `Failed to create date for ${year}-${month}-${day} ${hour}:${minute} in timezone ${timezone}`,
  );
  return new Date(Date.UTC(year, month - 1, day, hour, minute, 0));
}

/**
 * Handle resize start - sets up mouse move/up listeners
 */
export function handleResizeStart(
  e: React.MouseEvent,
  event: CalendarEvent,
  handle: 'top' | 'bottom',
  onMove: (newStart: string, newEnd: string) => void,
  onComplete: (didActuallyResize: boolean) => void,
) {
  e.preventDefault();
  const startY = e.clientY;
  const startDateTime = event.start?.dateTime;
  const endDateTime = event.end?.dateTime;

  if (!startDateTime || !endDateTime) {
    console.warn('Cannot resize event without start/end dateTime');
    return;
  }

  const originalStart = new Date(startDateTime);
  const originalEnd = new Date(endDateTime);

  // Track the final snapped times to compare with original
  let finalStart = originalStart;
  let finalEnd = originalEnd;

  const handleMouseMove = (moveEvent: MouseEvent) => {
    moveEvent.preventDefault();
    const deltaY = moveEvent.clientY - startY;
    // Each 20px = 15 minutes (roughly, will snap to nearest 15 min)
    const deltaMinutes = Math.round(deltaY / 20) * 15;

    let newStart = originalStart;
    let newEnd = originalEnd;

    if (handle === 'top') {
      newStart = new Date(originalStart.getTime() + deltaMinutes * 60000);
      // Ensure minimum 15 minutes duration
      if (newStart.getTime() >= originalEnd.getTime() - 15 * 60000) {
        newStart = new Date(originalEnd.getTime() - 15 * 60000);
      }
      newEnd = originalEnd;
    } else {
      newEnd = new Date(originalEnd.getTime() + deltaMinutes * 60000);
      // Ensure minimum 15 minutes duration
      if (newEnd.getTime() <= originalStart.getTime() + 15 * 60000) {
        newEnd = new Date(originalStart.getTime() + 15 * 60000);
      }
      newStart = originalStart;
    }

    // Snap to 15-minute intervals
    const snapToQuarter = (date: Date) => {
      const minutes = date.getMinutes();
      const snappedMinutes = Math.round(minutes / 15) * 15;
      const result = new Date(date);
      result.setMinutes(snappedMinutes);
      result.setSeconds(0);
      result.setMilliseconds(0);
      return result;
    };

    newStart = snapToQuarter(newStart);
    newEnd = snapToQuarter(newEnd);

    // Track the final values
    finalStart = newStart;
    finalEnd = newEnd;

    // Update local state during drag
    onMove(newStart.toISOString(), newEnd.toISOString());
  };

  const cleanup = () => {
    document.removeEventListener('mousemove', handleMouseMove);
    document.removeEventListener('mouseup', handleMouseUp);
    document.removeEventListener('keydown', handleKeyDown);
  };

  const handleMouseUp = () => {
    cleanup();

    // Check if the event actually changed (times are different after snapping)
    const startChanged = finalStart.getTime() !== originalStart.getTime();
    const endChanged = finalEnd.getTime() !== originalEnd.getTime();
    const didActuallyResize = startChanged || endChanged;

    // Pass whether actual resize occurred (times changed)
    onComplete(didActuallyResize);
  };

  const handleKeyDown = (keyEvent: KeyboardEvent) => {
    if (keyEvent.key === 'Escape') {
      keyEvent.preventDefault();
      cleanup();
      // Reset to original values
      onMove(originalStart.toISOString(), originalEnd.toISOString());
      // Don't call onComplete - we're canceling the resize
    }
  };

  document.addEventListener('mousemove', handleMouseMove);
  document.addEventListener('mouseup', handleMouseUp);
  document.addEventListener('keydown', handleKeyDown);
}

/**
 * Cedar Calendar Color ID mapping
 * Maps Google colorId (1-24) to Cedar's custom color palette
 *
 * Custom palette colors:
 * - Red:    #fecaca (red-200) with border #ef4444 (red-500)
 * - Blue:   #bfdbfe (blue-200) with border #3b82f6 (blue-500)
 * - Green:  #bbf7d0 (green-200) with border #22c55e (green-500)
 * - Purple: #e9d5ff (purple-200) with border #a855f7 (purple-500)
 * - Orange: #fed7aa (orange-200) with border #f97316 (orange-500)
 * - Pink:   #fbcfe8 (pink-200) with border #ec4899 (pink-500)
 * - Indigo: #c7d2fe (indigo-200) with border #6366f1 (indigo-500)
 */
const CEDAR_CALENDAR_COLORS: Record<
  string,
  { bgClass: string; borderClass: string; textClass: string; bg: string; border: string }
> = {
  // === Event Colors (1-11) - Using actual Google Calendar hex colors ===
  '1': {
    bgClass: 'bg-[#7986CB]',
    borderClass: 'border-l-[#5c6bc0]',
    textClass: 'text-indigo-100',
    bg: '#7986CB',
    border: '#5c6bc0',
  },
  '2': {
    bgClass: 'bg-[#33B679]',
    borderClass: 'border-l-[#2e7d32]',
    textClass: 'text-green-100',
    bg: '#33B679',
    border: '#2e7d32',
  },
  '3': {
    bgClass: 'bg-[#8E24AA]',
    borderClass: 'border-l-[#7b1fa2]',
    textClass: 'text-purple-100',
    bg: '#8E24AA',
    border: '#7b1fa2',
  },
  '4': {
    bgClass: 'bg-[#E67C73]',
    borderClass: 'border-l-[#d32f2f]',
    textClass: 'text-red-100',
    bg: '#E67C73',
    border: '#d32f2f',
  },
  '5': {
    bgClass: 'bg-[#F6BF26]',
    borderClass: 'border-l-[#f57c00]',
    textClass: 'text-orange-900',
    bg: '#F6BF26',
    border: '#f57c00',
  },
  '6': {
    bgClass: 'bg-[#F4511E]',
    borderClass: 'border-l-[#e64a19]',
    textClass: 'text-orange-100',
    bg: '#F4511E',
    border: '#e64a19',
  },
  '7': {
    bgClass: 'bg-[#039BE5]',
    borderClass: 'border-l-[#0288d1]',
    textClass: 'text-blue-100',
    bg: '#039BE5',
    border: '#0288d1',
  },
  '8': {
    bgClass: 'bg-[#616161]',
    borderClass: 'border-l-[#424242]',
    textClass: 'text-gray-100',
    bg: '#616161',
    border: '#424242',
  },
  '9': {
    bgClass: 'bg-[#3F51B5]',
    borderClass: 'border-l-[#303f9f]',
    textClass: 'text-indigo-100',
    bg: '#3F51B5',
    border: '#303f9f',
  },
  '10': {
    bgClass: 'bg-[#0B8043]',
    borderClass: 'border-l-[#2e7d32]',
    textClass: 'text-green-100',
    bg: '#0B8043',
    border: '#2e7d32',
  },
  '11': {
    bgClass: 'bg-[#D50000]',
    borderClass: 'border-l-[#c62828]',
    textClass: 'text-red-100',
    bg: '#D50000',
    border: '#c62828',
  },
  // === Calendar Colors (12-24) ===
  '12': {
    bgClass: 'bg-orange-500',
    borderClass: 'border-l-orange-700',
    textClass: 'text-orange-100',
    bg: '#f97316',
    border: '#c2410c',
  },
  '13': {
    bgClass: 'bg-red-200',
    borderClass: 'border-l-red-500',
    textClass: 'text-red-900',
    bg: '#fecaca',
    border: '#ef4444',
  },
  '14': {
    bgClass: 'bg-blue-200',
    borderClass: 'border-l-blue-500',
    textClass: 'text-blue-900',
    bg: '#bfdbfe',
    border: '#3b82f6',
  },
  '15': {
    bgClass: 'bg-blue-200',
    borderClass: 'border-l-blue-500',
    textClass: 'text-blue-900',
    bg: '#bfdbfe',
    border: '#3b82f6',
  },
  '16': {
    bgClass: 'bg-blue-500',
    borderClass: 'border-l-blue-700',
    textClass: 'text-blue-100',
    bg: '#3b82f6',
    border: '#1d4ed8',
  },
  '17': {
    bgClass: 'bg-indigo-200',
    borderClass: 'border-l-indigo-500',
    textClass: 'text-indigo-900',
    bg: '#c7d2fe',
    border: '#6366f1',
  },
  '18': {
    bgClass: 'bg-purple-500',
    borderClass: 'border-l-purple-700',
    textClass: 'text-purple-100',
    bg: '#a855f7',
    border: '#7e22ce',
  },
  '19': {
    bgClass: 'bg-indigo-200',
    borderClass: 'border-l-indigo-500',
    textClass: 'text-indigo-900',
    bg: '#c7d2fe',
    border: '#6366f1',
  },
  '20': {
    bgClass: 'bg-orange-200',
    borderClass: 'border-l-orange-500',
    textClass: 'text-orange-900',
    bg: '#fed7aa',
    border: '#f97316',
  },
  '21': {
    bgClass: 'bg-pink-500',
    borderClass: 'border-l-pink-700',
    textClass: 'text-pink-100',
    bg: '#ec4899',
    border: '#be185d',
  },
  '22': {
    bgClass: 'bg-rose-500',
    borderClass: 'border-l-rose-700',
    textClass: 'text-rose-100',
    bg: '#f43f5e',
    border: '#be123c',
  },
  '23': {
    bgClass: 'bg-purple-200',
    borderClass: 'border-l-purple-500',
    textClass: 'text-purple-900',
    bg: '#e9d5ff',
    border: '#a855f7',
  },
  '24': {
    bgClass: 'bg-purple-200',
    borderClass: 'border-l-purple-500',
    textClass: 'text-purple-900',
    bg: '#e9d5ff',
    border: '#a855f7',
  },
};

/**
 * Calculate relative luminance of a color (0 = darkest, 1 = lightest)
 */
function getColorLuminance(hexColor: string): number {
  const hex = hexColor.replace('#', '');
  const r = Number.parseInt(hex.substring(0, 2), 16) / 255;
  const g = Number.parseInt(hex.substring(2, 4), 16) / 255;
  const b = Number.parseInt(hex.substring(4, 6), 16) / 255;

  // Apply gamma correction
  const rs = r <= 0.03928 ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4);
  const gs = g <= 0.03928 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4);
  const bs = b <= 0.03928 ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4);

  return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}

/**
 * Determine the color family of a hex color and return appropriate text class
 * Light backgrounds get dark text (900), dark backgrounds get light text (100)
 */
function getTextColorForHex(hexColor: string): string {
  // Remove # if present
  const hex = hexColor.replace('#', '');

  // Convert to RGB
  const r = Number.parseInt(hex.substring(0, 2), 16);
  const g = Number.parseInt(hex.substring(2, 4), 16);
  const b = Number.parseInt(hex.substring(4, 6), 16);

  // Determine if background is light or dark
  const luminance = getColorLuminance(hexColor);
  const isDark = luminance < 0.5;
  const shade = isDark ? '100' : '900';

  // Determine dominant color channel
  const max = Math.max(r, g, b);
  const min = Math.min(r, g, b);

  // Calculate saturation to detect grays
  const saturation = max === 0 ? 0 : (max - min) / max;

  // If low saturation, it's gray
  if (saturation < 0.15) {
    return `text-gray-${shade}`;
  }

  // Determine hue-based color family
  if (r === max) {
    // Red-ish spectrum
    if (g > b * 1.5) {
      // More yellow/orange
      return `text-orange-${shade}`;
    } else if (b > g * 1.2) {
      // More pink/magenta
      return `text-pink-${shade}`;
    } else {
      // Pure red
      return `text-red-${shade}`;
    }
  } else if (g === max) {
    // Green-ish spectrum
    if (r > b * 1.3) {
      // More yellow
      return `text-orange-${shade}`;
    } else if (b > r * 1.2) {
      // More cyan
      return `text-blue-${shade}`;
    } else {
      // Pure green
      return `text-green-${shade}`;
    }
  } else {
    // Blue-ish spectrum
    if (r > g * 1.2) {
      // More purple/violet
      return `text-purple-${shade}`;
    } else if (g > r * 1.2) {
      // More cyan
      return `text-blue-${shade}`;
    } else {
      // Indigo/blue
      return `text-indigo-${shade}`;
    }
  }
}

/**
 * Generate a color for events based on Google Calendar colorId, calendar backgroundColor, or event title hash
 * Priority: 1. Event colorId, 2. Calendar backgroundColor, 3. Hash-based fallback
 */
export function getEventColor(
  event: CalendarEvent | string | null | undefined,
  calendarColors?: Map<string, string>,
): {
  bgClass: string;
  borderClass: string;
  textClass: string;
  // Legacy hex values for color picker and other uses
  bg: string;
  border: string;
} {
  // If event is an object, try to get colorId first
  if (event && typeof event === 'object') {
    const calEvent = event as CalendarEvent;

    // Priority 1: Event-specific colorId (1-24) - mapped to Cedar colors
    const colorId = calEvent.colorId as string | undefined;
    if (colorId && CEDAR_CALENDAR_COLORS[colorId]) {
      return CEDAR_CALENDAR_COLORS[colorId];
    }

    // Priority 2: Direct color field (for new events before saving)
    if (calEvent.color) {
      const borderColor = getShadedColorHex(calEvent.color, 40);
      return {
        bgClass: `bg-[${calEvent.color}]`,
        borderClass: `border-l-[${borderColor}]`,
        textClass: getTextColorForHex(calEvent.color),
        bg: calEvent.color,
        border: borderColor,
      };
    }

    // Priority 3: Calendar backgroundColor
    if (calendarColors) {
      // Try calendarId first (this tells us which calendar the event belongs to)
      let calendarColor = calEvent.calendarId ? calendarColors.get(calEvent.calendarId) : undefined;

      // Fall back to organizer email (for owned events without explicit calendarId)
      if (!calendarColor && calEvent.organizer?.email) {
        calendarColor = calendarColors.get(calEvent.organizer.email);
      }

      // Also try the htmlLink which contains the calendar ID
      if (!calendarColor && calEvent.htmlLink) {
        // htmlLink format: https://www.google.com/calendar/event?eid=...&ctz=...
        // Extract calendar ID from the URL
        const match = calEvent.htmlLink.match(/calendar\/u\/\d+\/r\/eventedit\/[^/]+\/([^?]+)/);
        if (match && match[1]) {
          calendarColor = calendarColors.get(decodeURIComponent(match[1]));
        }
      }

      // If still not found, try all calendar IDs (some might match)
      if (!calendarColor) {
        for (const [calId, color] of calendarColors.entries()) {
          // Check if event's organizer or creator email matches any calendar
          if (
            (calEvent.organizer?.email && calId === calEvent.organizer.email) ||
            (calEvent.creator?.email && calId === calEvent.creator.email) ||
            (calEvent.organizer?.email && calId.includes(calEvent.organizer.email)) ||
            (calEvent.organizer?.email && calEvent.organizer.email.includes(calId))
          ) {
            calendarColor = color;
            break;
          }
        }
      }

      if (calendarColor) {
        const borderColor = getShadedColorHex(calendarColor, 40);
        // Return Tailwind classes with arbitrary values for custom calendar colors
        return {
          bgClass: `bg-[${calendarColor}]`,
          borderClass: `border-l-[${borderColor}]`,
          textClass: getTextColorForHex(calendarColor), // Tinted text color matching color family
          bg: calendarColor,
          border: borderColor,
        };
      }
    }
  }

  // Fall back to hash-based color generation (all light backgrounds)
  const fallbackColors = [
    {
      bgClass: 'bg-red-200',
      borderClass: 'border-l-red-500',
      textClass: 'text-red-900',
      bg: '#fecaca',
      border: '#ef4444',
    },
    {
      bgClass: 'bg-blue-200',
      borderClass: 'border-l-blue-500',
      textClass: 'text-blue-900',
      bg: '#bfdbfe',
      border: '#3b82f6',
    },
    {
      bgClass: 'bg-green-200',
      borderClass: 'border-l-green-500',
      textClass: 'text-green-900',
      bg: '#bbf7d0',
      border: '#22c55e',
    },
    {
      bgClass: 'bg-purple-200',
      borderClass: 'border-l-purple-500',
      textClass: 'text-purple-900',
      bg: '#e9d5ff',
      border: '#a855f7',
    },
    {
      bgClass: 'bg-orange-200',
      borderClass: 'border-l-orange-500',
      textClass: 'text-orange-900',
      bg: '#fed7aa',
      border: '#f97316',
    },
    {
      bgClass: 'bg-pink-200',
      borderClass: 'border-l-pink-500',
      textClass: 'text-pink-900',
      bg: '#fbcfe8',
      border: '#ec4899',
    },
    {
      bgClass: 'bg-indigo-200',
      borderClass: 'border-l-indigo-500',
      textClass: 'text-indigo-900',
      bg: '#c7d2fe',
      border: '#6366f1',
    },
  ];

  // Extract title for hashing
  const title =
    typeof event === 'string' ? event : (event as CalendarEvent)?.summary || 'Untitled Event';

  let hash = 0;
  const titleText = title || 'Untitled Event';
  for (let i = 0; i < titleText.length; i++) {
    hash = titleText.charCodeAt(i) + ((hash << 5) - hash);
  }
  return fallbackColors[Math.abs(hash) % fallbackColors.length];
}

/**
 * Get ordinal suffix for day (1st, 2nd, 3rd, etc.)
 */
export function getOrdinalSuffix(day: number): string {
  if (day > 3 && day < 21) return 'th';
  switch (day % 10) {
    case 1:
      return 'st';
    case 2:
      return 'nd';
    case 3:
      return 'rd';
    default:
      return 'th';
  }
}