time.ts48.3 KBView on GitHub
/**
 * Time and Date Utilities for CRM Module
 * Centralized location for all time-related formatting and conversion functions
 */

import {
  format,
  addWeeks,
  startOfWeek,
  endOfWeek,
  addDays,
  addMonths,
  differenceInMonths,
  setHours,
  setMinutes,
} from 'date-fns';
// `toISODateString` moved to ./date-format (which stays dependency-free) so the crm/utils barrel
// can reach it without dragging chrono-node and fuse.js onto the critical path. Re-exported here
// because callers already import it from ./time; ./time is not on the eager graph, so the
// re-export costs nothing.
import { toISODateString } from '@/modules/crm/utils/date-format';
export { toISODateString };
import * as chrono from 'chrono-node';
import Fuse from 'fuse.js';
import { getBrowserTimezone, convertTimezone } from '@/lib/timezones';
import { getTimezoneName, parseTimezoneAbbreviation } from '@/lib/timezone-abbreviations';

/**
 * Default hour to use when user selects a calendar day without specifying a time.
 * 9 AM is a reasonable default for most task scheduling scenarios.
 */
export const DEFAULT_HOUR_FOR_DAY_SELECTION = 6;

/**
 * Safely parses a date string or Date object, treating timestamps without timezone as UTC.
 *
 * PostgreSQL TIMESTAMP columns (without timezone) are serialized to JSON as strings like
 * "2026-02-05T14:30:00" (no 'Z' suffix). When browsers parse these, they interpret them
 * as local time, which can cause dates to appear in the wrong day for users in certain
 * timezones (e.g., "Sent email tomorrow" for a past event).
 *
 * This function ensures such timestamps are treated as UTC by appending 'Z' if missing.
 *
 * @param date - A Date object, ISO string, or null/undefined
 * @returns A Date object, or undefined if input is null/undefined/invalid
 */
export function parseAsUTC(date: Date | string | null | undefined): Date | undefined {
  if (!date) return undefined;

  // If already a Date object, return as-is
  if (date instanceof Date) {
    return isNaN(date.getTime()) ? undefined : date;
  }

  // If it's a string, check if it needs timezone correction
  let dateString = date;

  // PostgreSQL TIMESTAMP columns may use space separator instead of 'T' (e.g. "2026-02-06 05:49:21")
  // Normalize to ISO 8601 format first
  if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(dateString)) {
    dateString = dateString.replace(' ', 'T');
  }

  // If the string has 'T' but no timezone indicator (Z, +, or -), treat as UTC
  if (
    dateString.includes('T') &&
    !dateString.endsWith('Z') &&
    !/[+-]\d{2}:\d{2}$/.test(dateString)
  ) {
    dateString = dateString + 'Z';
  }

  const parsed = new Date(dateString);
  return isNaN(parsed.getTime()) ? undefined : parsed;
}


/**
 * Options for formatRelativeDate
 */
export interface FormatRelativeDateOptions {
  /**
   * If true, skip hour/minute granularity and use day-level precision only.
   * Useful for task due dates where "Today" is more appropriate than "7h ago".
   */
  dayGranularityOnly?: boolean;
}

/**
 * Format a date as relative text that handles both past and future dates.
 * Uses calendar-day based logic (not hour-precise) for human-friendly display.
 *
 * Examples:
 * - Past: "5m ago", "3h ago", "Yesterday", "3 days ago", "Last Monday", "Last week"
 * - Future: "in 2 days", "Tomorrow", "Next Monday", "Next week"
 * - Far dates: "Dec 1" (absolute format)
 *
 * @param date - Date to format (Date object, ISO string, or null/undefined)
 * @param options - Formatting options
 * @returns Formatted date string
 */
export function formatRelativeDate(
  date: Date | string | null | undefined,
  options: FormatRelativeDateOptions = {},
): string {
  if (!date) {
    return 'Never';
  }

  const targetDate = parseAsUTC(date);

  // Handle invalid dates
  if (!targetDate) {
    return 'Invalid date';
  }

  const now = new Date();

  // For recent past (< 24 hours), show time-based precision (unless dayGranularityOnly)
  const diffMs = targetDate.getTime() - now.getTime();
  const absDiffMs = Math.abs(diffMs);
  const isFuture = diffMs > 0;

  // The calendar day both halves of this function compare on.
  //
  // Use UTC day components to avoid local timezone shifting dates
  // (e.g., 2026-03-06T00:00:00Z becoming March 5th in US timezones).
  // BOTH sides must read the same clock: taking `now` in local time while the
  // target is read in UTC made any evening-UTC timestamp look a day ahead, so a
  // touch from hours ago rendered as "Tomorrow" for US users.
  //
  // Hoisted above the recent-past block because that block needs it too. It used to ask
  // date-fns `isToday`, which reads the LOCAL calendar — the exact mixture this comment
  // warns about, left in the one branch nobody re-checked. West of UTC the two disagree for
  // the whole evening: at 00:37 UTC on the 31st a touch from 3h ago is still the 31st in UTC
  // but yesterday locally, so `isToday` was false, the "3h ago" branch was skipped, and the
  // day math below — which had them on the SAME day — answered "Today". Every US user saw a
  // meeting from this afternoon labelled as if the hour did not matter. CI never caught it
  // because every runner is UTC, where the two calendars agree.
  const nowDayMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
  const targetDayMs = Date.UTC(targetDate.getUTCFullYear(), targetDate.getUTCMonth(), targetDate.getUTCDate());
  const isSameDay = targetDayMs === nowDayMs;

  // PAST DATES - Recent times (< 24 hours ago)
  // Skip this block if dayGranularityOnly is true
  if (!isFuture && !options.dayGranularityOnly) {
    const diffMinutes = Math.floor(absDiffMs / (1000 * 60));
    const diffHours = Math.floor(absDiffMs / (1000 * 60 * 60));

    if (diffMinutes < 1) return 'Just now';
    if (diffMinutes < 60) return `${diffMinutes}m ago`;
    if (diffHours < 24 && isSameDay) return `${diffHours}h ago`;
  }

  // Calendar-day based comparisons for everything else.
  // This ensures "Tomorrow" means any time tomorrow, not "24+ hours from now".

  // Calculate difference in calendar days (not 24-hour periods)
  const diffDays = Math.round((targetDayMs - nowDayMs) / (1000 * 60 * 60 * 24));

  // PAST DATES (calendar day-based)
  if (diffDays < 0) {
    const absDays = Math.abs(diffDays);

    if (absDays === 0) return 'Today'; // Same day (shouldn't hit this due to isFuture check)
    if (absDays === 1) return 'Yesterday';
    if (absDays <= 3) return `${absDays} days ago`;

    // 4-7 days ago (last week, specific day)
    if (absDays <= 7) {
      const lastWeekStart = startOfWeek(addWeeks(now, -1), { weekStartsOn: 0 });
      const lastWeekEnd = endOfWeek(addWeeks(now, -1), { weekStartsOn: 0 });
      if (targetDate >= lastWeekStart && targetDate <= lastWeekEnd) {
        return `Last ${format(targetDate, 'EEEE')}`; // e.g., "Last Monday"
      }
      return `${absDays} days ago`;
    }

    if (absDays <= 14) return 'Last week';

    // Beyond 2 weeks - show absolute date
    // Include year if the date is more than ~12 months old
    const pastMonths = differenceInMonths(now, targetDate);
    return format(targetDate, pastMonths >= 12 ? 'MMMM d, yyyy' : 'MMMM d');
  }

  // FUTURE DATES (calendar day-based)
  if (diffDays === 0) return 'Today';
  if (diffDays === 1) return 'Tomorrow';
  if (diffDays >= 2 && diffDays <= 6) return `in ${diffDays} days`;

  // Next week (7-13 days)
  if (diffDays >= 7 && diffDays <= 13) {
    const nextWeekStart = startOfWeek(addWeeks(now, 1), { weekStartsOn: 0 });
    const nextWeekEnd = endOfWeek(addWeeks(now, 1), { weekStartsOn: 0 });
    if (targetDate >= nextWeekStart && targetDate <= nextWeekEnd) {
      return `Next ${format(targetDate, 'EEEE')}`; // e.g., "Next Monday"
    }
    return 'Next week';
  }

  // Beyond 2 weeks - show absolute date
  // Include year if the date is more than ~12 months away
  const futureMonths = differenceInMonths(targetDate, now);
  return format(targetDate, futureMonths >= 12 ? 'MMMM d, yyyy' : 'MMMM d');
}

/**
 * Get color coding for historical dates (past events, last contacted, etc.)
 * Recent activity = green (good), older activity = progressively more severe colors (bad)
 * Future dates = green
 *
 * @param date - Date to get color for
 * @returns Tailwind CSS color classes
 */
export function getHistoryColor(date: Date | string | null | undefined): string {
  if (!date) {
    return 'bg-muted/50 text-muted-foreground hover:bg-muted/60 dark:hover:bg-muted/40';
  }

  const targetDate = parseAsUTC(date);

  // Handle invalid dates
  if (!targetDate) {
    return 'bg-muted/50 text-muted-foreground hover:bg-muted/60 dark:hover:bg-muted/40';
  }

  const now = Date.now();
  const targetTime = targetDate.getTime();
  const diffMs = now - targetTime; // Positive = past, Negative = future

  // Future dates = green (good)
  if (diffMs < 0) {
    return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300 hover:bg-emerald-200 dark:hover:bg-emerald-900/40';
  }

  // Convert to different units
  const diffMinutes = Math.floor(diffMs / (1000 * 60));
  const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
  const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
  // Less than 1 hour (show minutes) - very recent = green
  if (diffMinutes < 60) {
    return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300 hover:bg-emerald-200 dark:hover:bg-emerald-900/40';
  }
  // Less than 24 hours - still very recent = green
  if (diffHours < 24) {
    return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300 hover:bg-emerald-200 dark:hover:bg-emerald-900/40';
  }
  // 1-3 days ago - recent = green
  if (diffDays <= 3) {
    return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300 hover:bg-emerald-200 dark:hover:bg-emerald-900/40';
  }
  // 4-7 days ago - getting older = light green
  if (diffDays <= 7) {
    return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-900/40';
  }
  // 1-2 weeks ago = lime/yellow-green
  if (diffDays <= 14) {
    return 'bg-lime-100 text-lime-800 dark:bg-lime-900/30 dark:text-lime-300 hover:bg-lime-200 dark:hover:bg-lime-900/40';
  }
  // 2-4 weeks ago = yellow
  if (diffDays <= 30) {
    return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 hover:bg-yellow-200 dark:hover:bg-yellow-900/40';
  }
  // 1-2 months ago = amber
  if (diffDays <= 60) {
    return 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 hover:bg-amber-200 dark:hover:bg-amber-900/40';
  }
  // 2-3 months ago = orange
  if (diffDays <= 90) {
    return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300 hover:bg-orange-200 dark:hover:bg-orange-900/40';
  }
  // 3-6 months ago = rose
  if (diffDays <= 180) {
    return 'bg-rose-100 text-rose-800 dark:bg-rose-900/30 dark:text-rose-300 hover:bg-rose-200 dark:hover:bg-rose-900/40';
  }

  // 6+ months ago = red (very old)
  return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/40';
}

export function getHistoryTextColor(date: Date | string | null | undefined): string {
  if (!date) return 'text-muted-foreground';
  const targetDate = parseAsUTC(date);
  if (!targetDate) return 'text-muted-foreground';
  const diffDays = Math.floor((Date.now() - targetDate.getTime()) / (1000 * 60 * 60 * 24));
  if (diffDays <= 3) return 'text-emerald-600 dark:text-emerald-400';
  if (diffDays <= 7) return 'text-green-600 dark:text-green-400';
  if (diffDays <= 14) return 'text-lime-600 dark:text-lime-400';
  if (diffDays <= 30) return 'text-yellow-600 dark:text-yellow-500';
  if (diffDays <= 60) return 'text-amber-600 dark:text-amber-400';
  if (diffDays <= 90) return 'text-orange-600 dark:text-orange-400';
  if (diffDays <= 180) return 'text-rose-600 dark:text-rose-400';
  return 'text-red-600 dark:text-red-400';
}

export function getScheduledTextColor(date: Date | string | null | undefined): string {
  if (!date) return 'text-muted-foreground';
  const targetDate = parseAsUTC(date);
  if (!targetDate) return 'text-muted-foreground';
  const diffMs = targetDate.getTime() - Date.now();
  if (diffMs < 0) {
    const daysOverdue = Math.ceil(-diffMs / (1000 * 60 * 60 * 24));
    if (daysOverdue >= 3) return 'text-red-600 dark:text-red-400';
    return 'text-yellow-600 dark:text-yellow-500';
  }
  return 'text-emerald-600 dark:text-emerald-400';
}

// =============================================================================
// NATURAL LANGUAGE DATE SUGGESTION TYPES
// =============================================================================

/**
 * A date suggestion with display information
 */
export interface DateSuggestion {
  id: string;
  label: string;
  sublabel?: string;
  shortLabel?: string;
  date: Date;
  /** The source timezone if a timezone was specified in the input (e.g., "EST") */
  sourceTimezone?: string;
  /** True if the date was converted from a different timezone */
  wasConverted?: boolean;
  /**
   * Whether the INPUT named a time ("tuesday 3pm"), as opposed to a suggestion that landed on
   * `defaultHour` because only a day was given. Callers that already hold a time of their own —
   * an event being composed — use this to keep it instead of being reset to the default.
   */
  hasExplicitTime?: boolean;
}

/**
 * Common date option with keywords for fuzzy matching
 */
export interface CommonDateOption {
  id: string;
  keywords: string[];
  label: string;
  generator: () => Date;
  /**
   * Keep this option out of the list shown for an EMPTY input, while leaving its keywords
   * matchable. "Yesterday" is the case: nobody opens a date field meaning to pick the past —
   * on a calendar invite it is nonsense — but someone who types "yest" still means it.
   */
  excludeFromDefaults?: boolean;
}

// =============================================================================
// NATURAL LANGUAGE DATE SUGGESTION UTILITIES
// =============================================================================

/**
 * Get the next occurrence of a specific day of the week
 * @param dayOfWeek - 0 = Sunday, 1 = Monday, etc.
 * @param defaultHour - Default hour to set (default: DEFAULT_HOUR_FOR_DAY_SELECTION)
 * @returns Date for the next occurrence of that day
 */
export function getNextDayOfWeek(
  dayOfWeek: number,
  defaultHour: number = DEFAULT_HOUR_FOR_DAY_SELECTION,
): Date {
  const now = new Date();
  const daysUntil = (dayOfWeek - now.getDay() + 7) % 7 || 7;
  return setMinutes(setHours(addDays(now, daysUntil), defaultHour), 0);
}

/**
 * Get comprehensive list of common date options with keywords for fuzzy matching
 * These cover most natural language date expressions
 * @param defaultHour - Default hour to use for times (default: DEFAULT_HOUR_FOR_DAY_SELECTION)
 * @returns Array of common date options
 */
export function getCommonDateOptions(
  defaultHour: number = DEFAULT_HOUR_FOR_DAY_SELECTION,
): CommonDateOption[] {
  const now = new Date();

  return [
    // Relative days
    {
      id: 'today',
      keywords: ['today', 'tod', 'now', 'current', 'before tomorrow'],
      label: 'today',
      generator: () => now,
    },
    {
      id: 'tomorrow',
      keywords: ['tomorrow', 'tom', 'tmrw', 'tmr', 'next day', 'after today'],
      label: 'tomorrow',
      generator: () => setMinutes(setHours(addDays(now, 1), defaultHour), 0),
    },
    {
      id: 'yesterday',
      keywords: ['yesterday', 'yest', 'yes', 'last day', 'before today'],
      label: 'yesterday',
      generator: () => setMinutes(setHours(addDays(now, -1), defaultHour), 0),
      excludeFromDefaults: true,
    },
    {
      id: '2-days',
      keywords: ['2 days', 'two days', 'day after tomorrow', 'after tomorrow'],
      label: '2 days',
      generator: () => setMinutes(setHours(addDays(now, 2), defaultHour), 0),
    },
    {
      id: '3-days',
      keywords: ['3 days', 'three days', '3d', 'after 3 days'],
      label: '3 days',
      generator: () => setMinutes(setHours(addDays(now, 3), defaultHour), 0),
    },
    {
      id: 'week',
      keywords: ['1 week', 'one week', 'week', 'wk', '7 days', 'after week', 'in a week'],
      label: '1 week',
      generator: () => setMinutes(setHours(addDays(now, 7), defaultHour), 0),
    },
    {
      id: '2-weeks',
      keywords: ['2 weeks', 'two weeks', 'fortnight', '14 days', 'after 2 weeks', 'in 2 weeks'],
      label: '2 weeks',
      generator: () => setMinutes(setHours(addDays(now, 14), defaultHour), 0),
    },
    {
      id: 'month',
      keywords: ['1 month', 'one month', 'month', 'mon', '30 days', 'after month', 'in a month'],
      label: '1 month',
      generator: () => setMinutes(setHours(addMonths(now, 1), defaultHour), 0),
    },

    // Week references
    {
      id: 'next-week',
      keywords: [
        'next week',
        'nex week',
        'nxt week',
        'next wk',
        'following week',
        'after week',
        'after this week',
      ],
      label: 'next week',
      generator: () => setMinutes(setHours(getNextDayOfWeek(1, defaultHour), defaultHour), 0), // Next Monday
    },
    {
      id: 'this-weekend',
      keywords: ['this weekend', 'weekend', 'wknd', 'saturday', 'sat', 'after week'],
      label: 'this weekend',
      generator: () => setMinutes(setHours(getNextDayOfWeek(6, defaultHour), defaultHour), 0), // Saturday
    },
    {
      id: 'next-weekend',
      keywords: ['next weekend', 'nex weekend', 'following weekend', 'after weekend'],
      label: 'next weekend',
      generator: () =>
        setMinutes(setHours(addDays(getNextDayOfWeek(6, defaultHour), 7), defaultHour), 0),
    },

    // Specific days
    {
      id: 'next-monday',
      keywords: ['monday', 'mon', 'mo', 'next monday', 'after monday', 'coming monday'],
      label: 'monday',
      generator: () => getNextDayOfWeek(1, defaultHour),
    },
    {
      id: 'next-tuesday',
      keywords: ['tuesday', 'tue', 'tues', 'tu', 'next tuesday', 'after tuesday', 'coming tuesday'],
      label: 'tuesday',
      generator: () => getNextDayOfWeek(2, defaultHour),
    },
    {
      id: 'next-wednesday',
      keywords: ['wednesday', 'wed', 'we', 'next wednesday', 'after wednesday', 'coming wednesday'],
      label: 'wednesday',
      generator: () => getNextDayOfWeek(3, defaultHour),
    },
    {
      id: 'next-thursday',
      keywords: [
        'thursday',
        'thu',
        'thur',
        'thurs',
        'th',
        'next thursday',
        'after thursday',
        'coming thursday',
      ],
      label: 'thursday',
      generator: () => getNextDayOfWeek(4, defaultHour),
    },
    {
      id: 'next-friday',
      keywords: ['friday', 'fri', 'fr', 'next friday', 'after friday', 'coming friday'],
      label: 'friday',
      generator: () => getNextDayOfWeek(5, defaultHour),
    },
  ];
}

/**
 * Extract the date phrase and temporal modifier from input text
 * Handles patterns like "after tomorrow", "in 3 days", etc.
 * @param text - Raw input text
 * @returns Object with phrase and optional modifier
 */
export function extractDatePhraseAndModifier(text: string): { phrase: string; modifier?: string } {
  const lowerText = text.toLowerCase().trim();

  // Common temporal modifiers
  const modifiers = [
    'next',
    'after',
    'before',
    'in',
    'on',
    'at',
    'by',
    'until',
    'till',
    'from',
    'starting',
    'this',
    'last',
  ];

  for (const modifier of modifiers) {
    // Case 1: "after tomorrow" - modifier with space and content
    if (lowerText.startsWith(modifier + ' ')) {
      return {
        phrase: lowerText.substring(modifier.length + 1).trim(),
        modifier,
      };
    }

    // Case 2: "after" - just the modifier alone
    if (lowerText === modifier) {
      return {
        phrase: '', // Empty phrase means show all suggestions
        modifier,
      };
    }
  }

  return { phrase: lowerText };
}

// =============================================================================
// QUALIFIED PERIODS — "mid august", "end of the month", "early next week"
// =============================================================================

/**
 * Which part of a period the user meant.
 *
 * chrono has no concept of these: it parses "mid august" by finding "august" and dropping
 * "mid" on the floor, landing on the 1st. That is wrong in the ordinary case ("mid august"
 * is the 15th) and wrong twice over near a month boundary — on 12 August, chrono's
 * forwardDate pushes the discarded-qualifier result to 1 August of NEXT YEAR, so asking to
 * send something in three days scheduled it for eleven months out.
 */
export type PeriodQualifier = 'early' | 'mid' | 'late';

export interface QualifiedPeriodMatch {
  date: Date;
  /** Display label, e.g. "mid August". */
  label: string;
  qualifier: PeriodQualifier;
  /**
   * The period word this consumed ("august", "month", "next week"). Callers use it to
   * suppress chrono's competing parse of the same phrase, which would otherwise offer the
   * 1st of the month right underneath the correct answer.
   */
  periodToken=[redacted];
}

const QUALIFIER_PATTERNS: Array<{ pattern: RegExp; qualifier: PeriodQualifier }> = [
  { pattern: /^(?:early|beginning|start|first\s+half)(?:\s+(?:of|in))?\s+/, qualifier: 'early' },
  { pattern: /^(?:mid|middle|midway)(?:\s+(?:of|in))?\s+/, qualifier: 'mid' },
  { pattern: /^(?:late|end|latter(?:\s+half)?)(?:\s+(?:of|in))?\s+/, qualifier: 'late' },
];

const MONTH_INDEX_BY_NAME: Record<string, number> = {
  jan: 0, january: 0,
  feb: 1, february: 1,
  mar: 2, march: 2,
  apr: 3, april: 3,
  may: 4,
  jun: 5, june: 5,
  jul: 6, july: 6,
  aug: 7, august: 7,
  sep: 8, sept: 8, september: 8,
  oct: 9, october: 9,
  nov: 10, november: 10,
  dec: 11, december: 11,
};

/** Day of the month each qualifier points at. `late` resolves to the month's last day. */
function dayOfMonthFor(qualifier: PeriodQualifier, year: number, month: number): number {
  if (qualifier === 'early') return 1;
  if (qualifier === 'mid') return 15;
  return new Date(year, month + 1, 0).getDate();
}

/** Day of the week each qualifier points at, Monday-based. */
function weekdayOffsetFor(qualifier: PeriodQualifier): number {
  if (qualifier === 'early') return 0; // Monday
  if (qualifier === 'mid') return 2; // Wednesday
  return 4; // Friday
}

/**
 * Resolve "mid august", "end of the month", "early next week" and friends to a real date.
 *
 * Returns null when the input carries no qualified period, which is the common case — the
 * caller then proceeds with chrono exactly as before. This runs BEFORE chrono rather than
 * correcting it afterwards, because by the time chrono has answered, the qualifier is gone
 * and there is nothing left to correct against.
 *
 * Without an explicit year, the result rolls forward to the next occurrence if it has
 * already passed: "early august" typed on 12 August means next year's, but "mid august"
 * typed the same day means the 15th, three days out — which is the whole point.
 */
export function resolveQualifiedPeriod(
  input: string,
  now: Date = new Date(),
  options: { defaultHour?: number; hour?: number; minute?: number } = {},
): QualifiedPeriodMatch | null {
  const { defaultHour = DEFAULT_HOUR_FOR_DAY_SELECTION, hour, minute } = options;

  // Hyphens are how people actually type these ("mid-august", "end-of-month").
  const normalized = input.toLowerCase().replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim();

  const matched = QUALIFIER_PATTERNS.find(({ pattern }) => pattern.test(normalized));
  if (!matched) return null;

  const remainder = normalized
    .replace(matched.pattern, '')
    // Strip a time so "mid august 3pm" still resolves; the time comes in via `options`.
    .replace(/\b\d{1,2}(?::\d{2})?\s*(?:am|pm)\b/g, '')
    .replace(/\b\d{1,2}:\d{2}\b/g, '')
    .trim();
  if (!remainder) return null;

  const { qualifier } = matched;
  const applyTime = (date: Date): Date =>
    setMinutes(setHours(date, hour ?? defaultHour), hour !== undefined ? (minute ?? 0) : 0);

  // ── A named month, optionally with a year: "mid august", "end of sept 2027" ──────
  const monthMatch = remainder.match(/^(?:the\s+|this\s+)?([a-z]+)\.?(?:\s+(?:of\s+)?(\d{4}))?$/);
  if (monthMatch) {
    const monthIndex = MONTH_INDEX_BY_NAME[monthMatch[1]];
    if (monthIndex !== undefined) {
      const explicitYear = monthMatch[2] ? Number(monthMatch[2]) : undefined;
      let year = explicitYear ?? now.getFullYear();
      let date = applyTime(new Date(year, monthIndex, dayOfMonthFor(qualifier, year, monthIndex)));
      if (explicitYear === undefined && date < now) {
        year += 1;
        date = applyTime(new Date(year, monthIndex, dayOfMonthFor(qualifier, year, monthIndex)));
      }
      return {
        date,
        label: `${qualifier} ${format(date, 'MMMM')}`,
        qualifier,
        periodToken=[redacted],
      };
    }
  }

  // ── A relative month: "end of the month", "mid next month" ──────────────────────
  const relativeMonth = remainder.match(/^(the|this|next|current)?\s*month$/);
  if (relativeMonth) {
    const isNext = relativeMonth[1] === 'next';
    const base = isNext ? addMonths(now, 1) : now;
    let year = base.getFullYear();
    let month = base.getMonth();
    let date = applyTime(new Date(year, month, dayOfMonthFor(qualifier, year, month)));
    // "mid month" on the 20th means next month's — this one is behind us.
    if (!isNext && date < now) {
      const rolled = addMonths(date, 1);
      year = rolled.getFullYear();
      month = rolled.getMonth();
      date = applyTime(new Date(year, month, dayOfMonthFor(qualifier, year, month)));
    }
    return {
      date,
      label: `${qualifier} ${format(date, 'MMMM')}`,
      qualifier,
      periodToken=[redacted],
    };
  }

  // ── A relative week: "early next week", "end of the week" ───────────────────────
  const relativeWeek = remainder.match(/^(the|this|next|current)?\s*week$/);
  if (relativeWeek) {
    const isNext = relativeWeek[1] === 'next';
    const base = isNext ? addWeeks(now, 1) : now;
    const monday = startOfWeek(base, { weekStartsOn: 1 });
    let date = applyTime(addDays(monday, weekdayOffsetFor(qualifier)));
    if (!isNext && date < now) {
      date = applyTime(addDays(date, 7));
    }
    return {
      date,
      label: `${qualifier} ${isNext ? 'next week' : 'this week'}`,
      qualifier,
      periodToken=[redacted],
    };
  }

  // ── A year: "end of the year", "mid 2027" ───────────────────────────────────────
  const relativeYear = remainder.match(/^(?:(the|this|next|current)\s+year|(\d{4}))$/);
  if (relativeYear) {
    const explicitYear = relativeYear[2] ? Number(relativeYear[2]) : undefined;
    const year = explicitYear ?? now.getFullYear() + (relativeYear[1] === 'next' ? 1 : 0);
    const month = qualifier === 'early' ? 0 : qualifier === 'mid' ? 6 : 11;
    const day = qualifier === 'late' ? 31 : 1;
    let date = applyTime(new Date(year, month, day));
    if (explicitYear === undefined && relativeYear[1] !== 'next' && date < now) {
      date = applyTime(new Date(year + 1, month, day));
    }
    return {
      date,
      label: `${qualifier} ${format(date, 'yyyy')}`,
      qualifier,
      periodToken=[redacted],
    };
  }

  return null;
}

/**
 * Interface for parsed input with timezone information
 */
interface ParsedInputWithTimezone {
  /** Input with timezone abbreviation removed */
  cleanedInput: string;
  /** IANA timezone identifier if a valid timezone was found, null otherwise */
  timezone: string | null;
  /** The original timezone abbreviation that was found in the input */
  timezoneAbbr: string | null;
  /** The original unchanged input */
  originalInput: string;
}

/**
 * Extract timezone abbreviation from input and return cleaned input + timezone info.
 * Looks for timezone abbreviations at the end of the input string.
 * 
 * @param input - User input like "friday 3pm est" or "tomorrow 2:30pm PST"
 * @returns Object with cleaned input (timezone removed) and timezone info
 * 
 * @example
 * extractTimezoneFromInput("friday 3pm est")
 * // { cleanedInput: "friday 3pm", timezone: "America/New_York", timezoneAbbr: "EST", originalInput: "friday 3pm est" }
 * 
 * extractTimezoneFromInput("tomorrow 10am")
 * // { cleanedInput: "tomorrow 10am", timezone: null, timezoneAbbr: null, originalInput: "tomorrow 10am" }
 */
export function extractTimezoneFromInput(input: string): ParsedInputWithTimezone {
  // Match timezone abbreviations at the end of input
  // Pattern: "friday 3pm est" or "tomorrow 2:30pm PST"
  // Matches 2-5 letter words at the end, preceded by word boundary
  const tzPattern = /\b([A-Z]{2,5})\s*$/i;
  const match = input.match(tzPattern);
  
  if (match) {
    const abbreviation = match[1];
    const timezone = parseTimezoneAbbreviation(abbreviation);
    
    if (timezone) {
      return {
        cleanedInput: input.slice(0, match.index).trim(),
        timezone,
        timezoneAbbr: abbreviation.toUpperCase(),
        originalInput: input,
      };
    }
  }
  
  return {
    cleanedInput: input,
    timezone: null,
    timezoneAbbr: null,
    originalInput: input,
  };
}

/**
 * Validate that a chrono-parsed date matches expected day-of-week from input
 * Helps catch cases where chrono misinterprets abbreviations like "tu" as wrong day
 * @param input - Original input text
 * @param parsedDate - Date returned by chrono
 * @returns true if the parsed date matches expected day, false if mismatch detected
 */
export function validateChronoResult(input: string, parsedDate: Date): boolean {
  const lowerInput = input.toLowerCase();

  // Map of day abbreviations to day-of-week numbers (0 = Sunday, 1 = Monday, etc.)
  const dayAbbreviations: Record<string, number> = {
    'mo': 1,
    'monday': 1,
    'mon': 1,
    'tu': 2,
    'tuesday': 2,
    'tue': 2,
    'tues': 2,
    'we': 3,
    'wednesday': 3,
    'wed': 3,
    'th': 4,
    'thursday': 4,
    'thu': 4,
    'thur': 4,
    'thurs': 4,
    'fr': 5,
    'friday': 5,
    'fri': 5,
    'sa': 6,
    'saturday': 6,
    'sat': 6,
    'su': 0,
    'sunday': 0,
    'sun': 0,
  };

  // Check if input contains any day abbreviation
  for (const [abbrev, expectedDay] of Object.entries(dayAbbreviations)) {
    // Look for the abbreviation as a separate word (not part of another word)
    const regex = new RegExp(`\\b${abbrev}\\b`, 'i');
    if (regex.test(lowerInput)) {
      // If we found a day abbreviation, verify it matches the parsed date's day
      const actualDay = parsedDate.getDay();
      if (actualDay !== expectedDay) {
        // Mismatch detected - chrono parsed the wrong day
        return false;
      }
      // Match confirmed
      return true;
    }
  }

  // No day abbreviation found in input, so we can't validate
  // Return true to allow chrono's result
  return true;
}

/**
 * Create a Fuse.js instance for fuzzy searching date options
 * @param options - Array of common date options to search
 * @returns Configured Fuse instance
 */
export function createDateFuseInstance(options?: CommonDateOption[]): Fuse<CommonDateOption> {
  const dateOptions = options || getCommonDateOptions();
  return new Fuse(dateOptions, {
    keys: ['keywords', 'label'],
    threshold: 0.4, // 0 = perfect match, 1 = match anything
    distance: 100,
    includeScore: true,
    minMatchCharLength: 1,
  });
}

/**
 * Configuration options for date suggestion generation
 */
export interface DateSuggestionConfig {
  /** Default hour for suggestions without specific times (default: DEFAULT_HOUR_FOR_DAY_SELECTION) */
  defaultHour?: number;
  /** Maximum number of suggestions to return (default: 5) */
  maxSuggestions?: number;
  /** Last used date to show as an option */
  lastUsedDate?: Date | null;
  /** Default suggestions to show when input is empty */
  defaultSuggestions?: DateSuggestion[];
  /** Fuse instance for fuzzy search (creates new if not provided) */
  fuseInstance?: Fuse<CommonDateOption>;
  /** Format for sublabels (default: "EEE, MMM dd, h:mm a") */
  sublabelFormat?: string;
  /** Whether to include year in sublabel (default: false for most, true for parsed dates) */
  includeYearInSublabel?: boolean;
}

/**
 * Generate date suggestions based on natural language input
 * Uses chrono for parsing and Fuse.js for fuzzy matching
 *
 * @param input - User's input text
 * @param config - Configuration options
 * @returns Array of date suggestions
 */
export function generateDateSuggestions(
  input: string,
  config: DateSuggestionConfig = {},
): DateSuggestion[] {
  const {
    defaultHour = DEFAULT_HOUR_FOR_DAY_SELECTION,
    maxSuggestions = 5,
    lastUsedDate,
    defaultSuggestions,
    fuseInstance,
    sublabelFormat = 'EEE, MMM dd, h:mm a',
  } = config;

  const allSuggestions: DateSuggestion[] = [];
  const now = new Date();

  // Extract timezone from input (e.g., "friday 3pm est" -> timezone: "America/New_York", cleaned: "friday 3pm")
  const { cleanedInput, timezone: inputTimezone, timezoneAbbr } = extractTimezoneFromInput(input);
  const userTimezone = getBrowserTimezone();
  const shouldConvertTimezone = inputTimezone && inputTimezone !== userTimezone;

  // Get common options with the configured default hour
  const commonOptions = getCommonDateOptions(defaultHour);
  const fuse = fuseInstance || createDateFuseInstance(commonOptions);

  // When input is empty, show default or common suggestions
  if (!cleanedInput.trim()) {
    if (defaultSuggestions) {
      return defaultSuggestions;
    }

    // Add last used if available and in the future
    if (lastUsedDate && lastUsedDate > now) {
      allSuggestions.push({
        id: 'last-used',
        label: 'last used',
        sublabel: format(lastUsedDate, sublabelFormat).toUpperCase(),
        date: lastUsedDate,
      });
    }

    // Add common suggestions
    allSuggestions.push(
      ...commonOptions
        .filter((opt) => !opt.excludeFromDefaults)
        .slice(0, maxSuggestions)
        .map((opt) => {
          const date = opt.generator();
          return {
            id: opt.id,
            label: opt.label,
            sublabel: format(date, sublabelFormat).toUpperCase(),
            date,
          };
        }),
    );

    return allSuggestions.slice(0, maxSuggestions);
  }

  // Step 1: Try parsing with chrono for complex/complete dates
  // Also extract time information even if day validation fails
  let extractedHour: number | undefined;
  let extractedMinute: number | undefined;

  const parsed = chrono.parse(cleanedInput, now, { forwardDate: true });

  // Read any time off the input before the loop, so the qualified-period suggestion below
  // can adopt it too ("mid august 3pm").
  for (const result of parsed) {
    const hour = result.start.get('hour');
    if (hour !== undefined && hour !== null) {
      extractedHour = hour;
      extractedMinute = result.start.get('minute') || 0;
      break;
    }
  }

  // Step 0: qualified periods ("mid august"), which chrono cannot express — it drops the
  // qualifier and answers the 1st. Resolved first so it heads the list, and so its period
  // token can silence chrono's competing parse of the same words.
  let qualifiedDate: Date | undefined;
  const qualified = resolveQualifiedPeriod(cleanedInput, now, {
    defaultHour,
    hour: extractedHour,
    minute: extractedMinute,
  });
  if (qualified) {
    qualifiedDate =
      shouldConvertTimezone && inputTimezone
        ? convertTimezone(qualified.date, inputTimezone, userTimezone)
        : qualified.date;
    let sublabel = format(qualifiedDate, 'EEE, MMM dd, yyyy h:mm a').toUpperCase();
    if (shouldConvertTimezone && timezoneAbbr) {
      sublabel += ` (from ${getTimezoneName(timezoneAbbr)})`;
    }
    allSuggestions.push({
      id: 'qualified-period',
      label: qualified.label,
      sublabel,
      date: qualifiedDate,
      sourceTimezone: timezoneAbbr || undefined,
      wasConverted: shouldConvertTimezone || undefined,
      hasExplicitTime: extractedHour !== undefined,
    });
  }

  if (parsed.length > 0) {
    parsed.forEach((result, index) => {
      let date = result.start.date();

      // The qualifier already answered for these words; chrono's version of them is the
      // wrong-by-construction 1st-of-the-month.
      if (qualified && result.text.toLowerCase().includes(qualified.periodToken)) {
        return;
      }

      // If timezone was specified, convert to user's timezone
      if (shouldConvertTimezone && inputTimezone) {
        date = convertTimezone(date, inputTimezone, userTimezone);
      }

      // Validate that chrono's result matches expected day-of-week from input
      // Use cleanedInput for validation (without timezone abbreviation)
      if (!validateChronoResult(cleanedInput, date)) {
        return;
      }

      // Build sublabel with timezone info if converted
      let sublabel = format(date, 'EEE, MMM dd, yyyy h:mm a').toUpperCase();
      if (shouldConvertTimezone && timezoneAbbr) {
        const timezoneName = getTimezoneName(timezoneAbbr);
        sublabel += ` (from ${timezoneName})`;
      }

      allSuggestions.push({
        id: `parsed-${index}`,
        label: input,
        sublabel,
        date,
        sourceTimezone: timezoneAbbr || undefined,
        wasConverted: shouldConvertTimezone || undefined,
        hasExplicitTime: result.start.isCertain('hour'),
      });

      // If no time specified, also suggest with default time
      if (!result.start.isCertain('hour')) {
        const withTime = setMinutes(setHours(date, defaultHour), 0);
        let sublabelWithTime = format(withTime, 'EEE, MMM dd, yyyy h:mm a').toUpperCase();
        if (shouldConvertTimezone && timezoneAbbr) {
          const timezoneName = getTimezoneName(timezoneAbbr);
          sublabelWithTime += ` (from ${timezoneName})`;
        }

        allSuggestions.push({
          id: `parsed-${index}-${defaultHour}am`,
          label: `${input} at ${defaultHour}am`,
          sublabel: sublabelWithTime,
          date: withTime,
          sourceTimezone: timezoneAbbr || undefined,
          wasConverted: shouldConvertTimezone || undefined,
          hasExplicitTime: true,
        });
      }
    });
  }

  // Step 2: Fuzzy search common date options
  const { phrase: extractedPhrase, modifier: temporalModifier } =
    extractDatePhraseAndModifier(cleanedInput);

  const fuzzyResults = new Set<CommonDateOption>();

  // If we have a temporal modifier but no phrase (e.g., just "after"), show all common options
  if (temporalModifier && !extractedPhrase) {
    const allOptions = commonOptions.slice(0, 10);
    allOptions.forEach((opt) => fuzzyResults.add(opt));
  } else {
    // Otherwise, fuzzy search with the phrase
    let searchPhrase = extractedPhrase || cleanedInput;

    // Strip out common time patterns to improve day matching
    // e.g., "tu 3pm" -> "tu", "wed 2:30pm" -> "wed"
    searchPhrase = searchPhrase
      .replace(/\s*\d{1,2}:\d{2}\s*(am|pm)?/gi, '') // Remove "2:30pm", "14:30"
      .replace(/\s*\d{1,2}\s*(am|pm)/gi, '') // Remove "3pm", "9am"
      .trim();

    const results = fuse.search(searchPhrase);
    results.forEach((result) => {
      fuzzyResults.add(result.item as CommonDateOption);
    });
  }

  fuzzyResults.forEach((option) => {
    let date = option.generator();

    // If chrono extracted a time (even though day was wrong), apply it to fuzzy match
    if (extractedHour !== undefined) {
      date.setHours(extractedHour, extractedMinute || 0, 0, 0);
    }

    // If timezone was specified, convert to user's timezone
    if (shouldConvertTimezone && inputTimezone) {
      date = convertTimezone(date, inputTimezone, userTimezone);
    }

    // Skip if we already have this date (from chrono)
    const alreadyExists = allSuggestions.some(
      (s) => Math.abs(s.date.getTime() - date.getTime()) < 60000, // Within 1 minute
    );

    if (!alreadyExists) {
      // Prepend temporal modifier to label if present
      const displayLabel = temporalModifier ? `${temporalModifier} ${option.label}` : option.label;
      const suggestionId = temporalModifier ? `${temporalModifier}-${option.id}` : option.id;

      // Build sublabel with timezone info if converted
      let sublabel = format(date, 'EEE, MMM dd, yyyy h:mm a').toUpperCase();
      if (shouldConvertTimezone && timezoneAbbr) {
        const timezoneName = getTimezoneName(timezoneAbbr);
        sublabel += ` (from ${timezoneName})`;
      }

      allSuggestions.push({
        id: suggestionId,
        label: displayLabel,
        sublabel,
        date,
        sourceTimezone: timezoneAbbr || undefined,
        wasConverted: shouldConvertTimezone || undefined,
      });
    }
  });

  // Step 3: If still less than 3, add fallback suggestions
  if (allSuggestions.length < 3) {
    const fallbackOptions = commonOptions.slice(0, 5);
    fallbackOptions.forEach((opt) => {
      if (allSuggestions.length >= maxSuggestions) return;

      const date = opt.generator();
      const alreadyExists = allSuggestions.some(
        (s) => Math.abs(s.date.getTime() - date.getTime()) < 60000,
      );

      if (!alreadyExists) {
        // Prepend temporal modifier to label if present
        const displayLabel = temporalModifier ? `${temporalModifier} ${opt.label}` : opt.label;
        const suggestionId = temporalModifier ? `${temporalModifier}-${opt.id}` : opt.id;

        allSuggestions.push({
          id: suggestionId,
          label: displayLabel,
          sublabel: format(date, 'EEE, MMM dd, yyyy h:mm a').toUpperCase(),
          date,
        });
      }
    });
  }

  // Remove duplicates by id (prefer keeping first occurrence)
  const seenIds = new Set<string>();
  const uniqueSuggestions = allSuggestions.filter((suggestion) => {
    if (seenIds.has(suggestion.id)) {
      return false;
    }
    seenIds.add(suggestion.id);
    return true;
  });

  return uniqueSuggestions.slice(0, maxSuggestions);
}

/**
 * Generate simple date suggestions for command bar style inputs
 * A lighter-weight version that returns simpler suggestion format
 *
 * @param input - User's input text
 * @param defaultHour - Default hour for suggestions (default: DEFAULT_HOUR_FOR_DAY_SELECTION)
 * @returns Array of simple date suggestions
 */
export function generateSimpleDateSuggestions(
  input: string,
  defaultHour: number = DEFAULT_HOUR_FOR_DAY_SELECTION,
): DateSuggestion[] {
  const now = new Date();

  // Extract timezone from input
  const { cleanedInput, timezone: inputTimezone, timezoneAbbr } = extractTimezoneFromInput(input);
  const userTimezone = getBrowserTimezone();
  const shouldConvertTimezone = inputTimezone && inputTimezone !== userTimezone;

  if (!cleanedInput.trim()) {
    // Show common suggestions when empty
    const commonSuggestions: DateSuggestion[] = [
      {
        id: 'today',
        label: 'Today',
        shortLabel: 'Today',
        date: now,
      },
      {
        id: 'tomorrow',
        label: 'Tomorrow',
        shortLabel: 'Tomorrow',
        date: addDays(now, 1),
      },
      {
        id: 'in-2-days',
        label: format(addDays(now, 2), 'EEEE'),
        shortLabel: 'In 2 days',
        date: addDays(now, 2),
      },
      {
        id: 'next-week',
        label: 'Next Monday',
        shortLabel: 'Next week',
        date: addDays(now, (8 - now.getDay()) % 7 || 7),
      },
      {
        id: 'next-month',
        label: format(addMonths(now, 1), 'MMMM d'),
        shortLabel: 'Next month',
        date: addMonths(now, 1),
      },
    ];

    return commonSuggestions;
  }

  // Parse natural language with chrono
  const parsed = chrono.parse(cleanedInput, now, { forwardDate: true });
  const newSuggestions: DateSuggestion[] = [];

  // Qualified periods ("mid august") first — chrono drops the qualifier and answers the
  // 1st, so it has to be resolved separately and its words kept away from chrono's result.
  const qualified = resolveQualifiedPeriod(cleanedInput, now, { defaultHour });
  if (qualified) {
    const date =
      shouldConvertTimezone && inputTimezone
        ? convertTimezone(qualified.date, inputTimezone, userTimezone)
        : qualified.date;
    newSuggestions.push({
      id: 'qualified-period',
      label: format(date, 'EEEE, MMMM d'),
      shortLabel: qualified.label,
      date,
      sourceTimezone: timezoneAbbr || undefined,
      wasConverted: shouldConvertTimezone || undefined,
    });
  }

  if (parsed.length > 0) {
    parsed.forEach((result, index) => {
      if (qualified && result.text.toLowerCase().includes(qualified.periodToken)) {
        return;
      }

      let date = result.start.date();

      // If timezone was specified, convert to user's timezone
      if (shouldConvertTimezone && inputTimezone) {
        date = convertTimezone(date, inputTimezone, userTimezone);
      }

      newSuggestions.push({
        id: `parsed-${index}`,
        label: format(date, 'EEEE, MMMM d'),
        date,
        sourceTimezone: timezoneAbbr || undefined,
        wasConverted: shouldConvertTimezone || undefined,
      });
    });
  }

  // Fuzzy search with common options
  const commonOptions = getCommonDateOptions(defaultHour);
  const fuse = createDateFuseInstance(commonOptions);
  const { phrase: extractedPhrase, modifier: temporalModifier } =
    extractDatePhraseAndModifier(cleanedInput);

  // If we have input, search for matches
  if (extractedPhrase || !temporalModifier) {
    const searchPhrase = extractedPhrase || cleanedInput;
    const results = fuse.search(searchPhrase);

    results.forEach((result) => {
      const option = result.item;
      let date = option.generator();

      // If timezone was specified, convert to user's timezone
      if (shouldConvertTimezone && inputTimezone) {
        date = convertTimezone(date, inputTimezone, userTimezone);
      }

      // Skip if already exists
      const alreadyExists = newSuggestions.some(
        (s) => Math.abs(s.date.getTime() - date.getTime()) < 60000,
      );

      if (!alreadyExists) {
        const displayLabel = temporalModifier
          ? `${temporalModifier} ${option.label}`
          : option.label;
        const suggestionId = temporalModifier ? `${temporalModifier}-${option.id}` : option.id;

        newSuggestions.push({
          id: suggestionId,
          label: format(date, 'EEEE, MMMM d'),
          shortLabel: displayLabel,
          date,
          sourceTimezone: timezoneAbbr || undefined,
          wasConverted: shouldConvertTimezone || undefined,
        });
      }
    });
  } else {
    // Just temporal modifier, show all options with modifier prefix
    commonOptions.slice(0, 5).forEach((opt) => {
      let date = opt.generator();

      // If timezone was specified, convert to user's timezone
      if (shouldConvertTimezone && inputTimezone) {
        date = convertTimezone(date, inputTimezone, userTimezone);
      }

      newSuggestions.push({
        id: `${temporalModifier}-${opt.id}`,
        label: format(date, 'EEEE, MMMM d'),
        shortLabel: `${temporalModifier} ${opt.label}`,
        date,
        sourceTimezone: timezoneAbbr || undefined,
        wasConverted: shouldConvertTimezone || undefined,
      });
    });
  }

  // Remove duplicates based on date (same day)
  const uniqueSuggestions = newSuggestions.filter(
    (suggestion, index, self) =>
      index ===
      self.findIndex((s) => format(s.date, 'yyyy-MM-dd') === format(suggestion.date, 'yyyy-MM-dd')),
  );

  return uniqueSuggestions.slice(0, 5);
}

/**
 * Get color coding for scheduled/future dates (next steps, scheduled actions, etc.)
 * Overdue by 3+ days = red, overdue by 0-2 days = yellow, future = green
 *
 * @param date - Date to get color for
 * @returns Tailwind CSS color classes
 */
export function getScheduledColor(date: Date | string | null | undefined): string {
  if (!date) {
    return 'bg-muted/50 text-muted-foreground hover:bg-muted/60 dark:hover:bg-muted/40';
  }

  const targetDate = parseAsUTC(date);

  // Handle invalid dates
  if (!targetDate) {
    return 'bg-muted/50 text-muted-foreground hover:bg-muted/60 dark:hover:bg-muted/40';
  }

  const now = Date.now();
  const targetTime = targetDate.getTime();
  const diffMs = targetTime - now; // Positive = future, Negative = past

  // Past dates (overdue)
  if (diffMs < 0) {
    const daysOverdue = Math.ceil(-diffMs / (1000 * 60 * 60 * 24));
    
    // Overdue by 3+ days = red
    if (daysOverdue >= 3) {
      return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/40';
    }
    
    // Overdue by 0-2 days = yellow
    return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 hover:bg-yellow-200 dark:hover:bg-yellow-900/40';
  }

  // Future dates = green
  return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300 hover:bg-emerald-200 dark:hover:bg-emerald-900/40';
}