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