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