From 4776a112b61ebc9a76c596d712d0808454ea31c5 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 22 Sep 2025 12:28:59 -0400 Subject: [PATCH] Add in natural language time period input and tweak layout for financial overview --- .../dashboard/FinancialOverview.tsx | 1102 +++++++++-------- inventory/src/utils/naturalLanguagePeriod.ts | 384 ++++++ 2 files changed, 991 insertions(+), 495 deletions(-) create mode 100644 inventory/src/utils/naturalLanguagePeriod.ts diff --git a/inventory/src/components/dashboard/FinancialOverview.tsx b/inventory/src/components/dashboard/FinancialOverview.tsx index 0bfc0fa..00a1ef3 100644 --- a/inventory/src/components/dashboard/FinancialOverview.tsx +++ b/inventory/src/components/dashboard/FinancialOverview.tsx @@ -3,11 +3,11 @@ import { acotService } from "@/services/dashboard/acotService"; import { Card, CardContent, - CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Select, SelectContent, @@ -18,7 +18,6 @@ import { import { Dialog, DialogContent, - DialogDescription, DialogHeader, DialogTitle, DialogTrigger, @@ -46,7 +45,17 @@ import { import type { TooltipProps } from "recharts"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Skeleton } from "@/components/ui/skeleton"; -import { ArrowUpRight, ArrowDownRight, Minus, TrendingUp, AlertCircle } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ArrowUpRight, ArrowDownRight, Minus, TrendingUp, AlertCircle, Calendar } from "lucide-react"; +import type { CustomPeriod } from "@/utils/naturalLanguagePeriod"; +import { + generateNaturalLanguagePreview, + parseNaturalLanguagePeriod, +} from "@/utils/naturalLanguagePeriod"; type TrendDirection = "up" | "down" | "flat"; @@ -110,28 +119,6 @@ type FinancialResponse = { trend: FinancialTrendPoint[]; }; -type MonthPeriod = { - type: "month"; - startYear: number; - startMonth: number; - count: number; -}; - -type QuarterPeriod = { - type: "quarter"; - startYear: number; - startQuarter: number; - count: number; -}; - -type YearPeriod = { - type: "year"; - startYear: number; - count: number; -}; - -type CustomPeriod = MonthPeriod | QuarterPeriod | YearPeriod; - type ChartSeriesKey = "income" | "cogs" | "profit" | "margin"; type GroupByOption = "day" | "month" | "quarter" | "year"; @@ -232,26 +219,6 @@ function formatPeriodRangeLabel(period: CustomPeriod): string { return period.count === 1 ? `${startYear}` : `${startYear} – ${endYear}`; } -function formatEndPeriodLabel(period: CustomPeriod, count: number): string { - const safe = ensureValidCustomPeriod(period); - switch (safe.type) { - case "month": { - const end = new Date(safe.startYear, safe.startMonth + count - 1, 1); - return formatMonthLabel(end.getFullYear(), end.getMonth()); - } - case "quarter": { - const startMonth = safe.startQuarter * 3; - const end = new Date(safe.startYear, startMonth + (count - 1) * 3, 1); - const endQuarter = Math.floor(end.getMonth() / 3); - return formatQuarterLabel(end.getFullYear(), endQuarter); - } - case "year": - default: { - const endYear = safe.startYear + count - 1; - return `${endYear}`; - } - } -} const formatCurrency = (value: number, minimumFractionDigits = 0) => { if (!Number.isFinite(value)) { @@ -599,10 +566,6 @@ const buildComparisonFromValues = (current?: number | null, previous?: number | }; }; -const generateYearOptions = (span: number) => { - const currentYear = new Date().getFullYear(); - return Array.from({ length: span }, (_, index) => currentYear - index); -}; const ensureValidCustomPeriod = (period: CustomPeriod): CustomPeriod => { if (period.count < 1) { @@ -663,13 +626,16 @@ const FinancialOverview = () => { const currentDate = useMemo(() => new Date(), []); const currentYear = currentDate.getFullYear(); - const [viewMode, setViewMode] = useState<"rolling" | "custom">("rolling"); const [customPeriod, setCustomPeriod] = useState({ type: "month", startYear: currentYear, startMonth: currentDate.getMonth(), count: 1, }); + const [isLast30DaysMode, setIsLast30DaysMode] = useState(true); + const [isPeriodPopoverOpen, setIsPeriodPopoverOpen] = useState(false); + const [naturalLanguageInput, setNaturalLanguageInput] = useState(""); + const [showSuggestions, setShowSuggestions] = useState(false); const [metrics, setMetrics] = useState>({ income: true, cogs: true, @@ -683,7 +649,7 @@ const FinancialOverview = () => { const [error, setError] = useState(null); const selectedRange = useMemo(() => { - if (viewMode === "rolling") { + if (isLast30DaysMode) { const end = new Date(currentDate); const start = new Date(currentDate); start.setHours(0, 0, 0, 0); @@ -693,7 +659,7 @@ const FinancialOverview = () => { } return computePeriodRange(customPeriod); - }, [viewMode, customPeriod, currentDate]); + }, [isLast30DaysMode, customPeriod, currentDate]); const effectiveRangeEnd = useMemo(() => { if (!selectedRange) { @@ -721,7 +687,6 @@ const FinancialOverview = () => { }; }, [selectedRange, effectiveRangeEnd]); - const yearOptions = useMemo(() => generateYearOptions(12), []); const rawTrendPoints = useMemo(() => { if (!data?.trend?.length) { @@ -827,7 +792,7 @@ const FinancialOverview = () => { try { const params: Record = {}; - if (viewMode === "rolling") { + if (isLast30DaysMode) { params.timeRange = "last30days"; } else { if (!selectedRange || !requestRange) { @@ -875,7 +840,7 @@ const FinancialOverview = () => { return () => { cancelled = true; }; - }, [viewMode, selectedRange, requestRange]); + }, [isLast30DaysMode, selectedRange, requestRange]); const cards = useMemo( () => { @@ -990,7 +955,7 @@ const FinancialOverview = () => { }, [aggregatedPoints]); const selectedRangeLabel = useMemo(() => { - if (viewMode === "rolling") { + if (isLast30DaysMode) { return "Last 30 Days"; } const label = formatPeriodRangeLabel(customPeriod); @@ -1014,7 +979,7 @@ const FinancialOverview = () => { }); return `${label} (through ${partialLabel})`; - }, [viewMode, customPeriod, selectedRange, effectiveRangeEnd]); + }, [isLast30DaysMode, customPeriod, selectedRange, effectiveRangeEnd]); const marginDomain = useMemo<[number, number]>(() => { if (!metrics.margin || !chartData.length) { @@ -1062,119 +1027,12 @@ const FinancialOverview = () => { const enableAutoGrouping = () => setGroupByAuto(true); - const handleViewModeChange = (mode: "rolling" | "custom") => { - enableAutoGrouping(); - setViewMode(mode); - }; - const handleGroupByChange = (value: string) => { setGroupBy(value as GroupByOption); setGroupByAuto(false); }; - const handleCustomTypeChange = (value: string) => { - const nextType = value as CustomPeriod["type"]; - setViewMode("custom"); - enableAutoGrouping(); - setCustomPeriod((prev) => { - const safePrev = ensureValidCustomPeriod(prev); - switch (nextType) { - case "month": { - const startMonth = safePrev.type === "month" ? safePrev.startMonth : currentDate.getMonth(); - return { - type: "month", - startYear: safePrev.startYear, - startMonth, - count: Math.min(safePrev.count, MONTH_COUNT_LIMIT), - }; - } - case "quarter": { - const startQuarter = - safePrev.type === "quarter" - ? safePrev.startQuarter - : Math.floor(currentDate.getMonth() / 3); - return { - type: "quarter", - startYear: safePrev.startYear, - startQuarter, - count: Math.min(Math.max(Math.ceil(safePrev.count / 3), 1), QUARTER_COUNT_LIMIT), - }; - } - case "year": - default: - return { - type: "year", - startYear: safePrev.startYear, - count: Math.min(Math.max(Math.ceil(safePrev.count / 12), 1), YEAR_COUNT_LIMIT), - }; - } - }); - }; - const handleStartYearChange = (value: string) => { - const nextYear = Number(value); - enableAutoGrouping(); - setCustomPeriod((prev) => { - const safePrev = ensureValidCustomPeriod(prev); - switch (safePrev.type) { - case "month": - return { ...safePrev, startYear: nextYear }; - case "quarter": - return { ...safePrev, startYear: nextYear }; - case "year": - default: - return { ...safePrev, startYear: nextYear }; - } - }); - }; - - const handleStartMonthChange = (value: string) => { - const nextMonth = Number(value); - enableAutoGrouping(); - setCustomPeriod((prev) => { - if (prev.type !== "month") { - return prev; - } - return { ...prev, startMonth: nextMonth }; - }); - }; - - const handleStartQuarterChange = (value: string) => { - const nextQuarter = Number(value); - enableAutoGrouping(); - setCustomPeriod((prev) => { - if (prev.type !== "quarter") { - return prev; - } - return { ...prev, startQuarter: nextQuarter }; - }); - }; - - const handleCountChange = (value: string) => { - const nextCount = Number(value); - enableAutoGrouping(); - setCustomPeriod((prev) => { - const safePrev = ensureValidCustomPeriod(prev); - return ensureValidCustomPeriod({ ...safePrev, count: nextCount }); - }); - }; - - const endPeriodOptions = useMemo(() => { - const safePrev = ensureValidCustomPeriod(customPeriod); - const limit = safePrev.type === "month" - ? MONTH_COUNT_LIMIT - : safePrev.type === "quarter" - ? QUARTER_COUNT_LIMIT - : YEAR_COUNT_LIMIT; - - return Array.from({ length: limit }, (_, index) => { - const count = index + 1; - return { - count, - label: formatEndPeriodLabel(safePrev, count), - }; - }); - }, [customPeriod]); const toggleMetric = (series: ChartSeriesKey) => { setMetrics((prev) => ({ @@ -1183,36 +1041,335 @@ const FinancialOverview = () => { })); }; - const canShowDetails = !error; + + const suggestions = [ + "last 30 days", + "this month", + "last month", + "this quarter", + "last quarter", + "this year", + "last year", + "last 3 months", + "last 6 months", + "last 2 quarters", + "Q1 2024", + "q1-q3 24", + "q1 24 - q2 25", + "January 2024", + "jan-24", + "jan-may 24", + "2023", + "2021-2023", + "21-23", + "January to March 2024", + "jan 2023 - may 2024" + ]; + + const filteredSuggestions = suggestions.filter(suggestion => + suggestion.toLowerCase().includes(naturalLanguageInput.toLowerCase()) && + suggestion.toLowerCase() !== naturalLanguageInput.toLowerCase() + ); + + const handleNaturalLanguageChange = (value: string) => { + setNaturalLanguageInput(value); + setShowSuggestions(value.length > 0); + }; + + const handleNaturalLanguageSubmit = (input: string) => { + const parsed = parseNaturalLanguagePeriod(input, currentDate); + if (parsed === "last30days") { + setIsLast30DaysMode(true); + setIsPeriodPopoverOpen(false); + } else if (parsed) { + setIsLast30DaysMode(false); + setCustomPeriod(parsed); + setIsPeriodPopoverOpen(false); + } + setNaturalLanguageInput(""); + setShowSuggestions(false); + }; + + const handleSuggestionClick = (suggestion: string) => { + setNaturalLanguageInput(suggestion); + handleNaturalLanguageSubmit(suggestion); + }; + + const handleQuickPeriod = (preset: string) => { + const now = new Date(); + const currentYear = now.getFullYear(); + const currentMonth = now.getMonth(); + const currentQuarter = Math.floor(currentMonth / 3); + + enableAutoGrouping(); + + switch (preset) { + case "last30days": + // For Last 30 Days, we keep the special mode but this is temporary + // The UI will show this as selected but the period inputs won't reflect it + setIsLast30DaysMode(true); + break; + case "thisMonth": + setIsLast30DaysMode(false); + setCustomPeriod({ + type: "month", + startYear: currentYear, + startMonth: currentMonth, + count: 1, + }); + break; + case "lastMonth": + setIsLast30DaysMode(false); + const lastMonth = currentMonth === 0 ? 11 : currentMonth - 1; + const lastMonthYear = currentMonth === 0 ? currentYear - 1 : currentYear; + setCustomPeriod({ + type: "month", + startYear: lastMonthYear, + startMonth: lastMonth, + count: 1, + }); + break; + case "thisQuarter": + setIsLast30DaysMode(false); + setCustomPeriod({ + type: "quarter", + startYear: currentYear, + startQuarter: currentQuarter, + count: 1, + }); + break; + case "lastQuarter": + setIsLast30DaysMode(false); + const lastQuarter = currentQuarter === 0 ? 3 : currentQuarter - 1; + const lastQuarterYear = currentQuarter === 0 ? currentYear - 1 : currentYear; + setCustomPeriod({ + type: "quarter", + startYear: lastQuarterYear, + startQuarter: lastQuarter, + count: 1, + }); + break; + case "thisYear": + setIsLast30DaysMode(false); + setCustomPeriod({ + type: "year", + startYear: currentYear, + count: 1, + }); + break; + default: + break; + } + }; + return ( -
-
+
+
- Financial Overview + Profit & Loss Overview - - {data?.dateRange?.label || "Key financial metrics for your selected period"} -
- {canShowDetails ? ( - detailRows.length ? ( - - - - +
+ {!error && ( + <> + + + + + +
+
Select Time Period
+ + {/* Quick Presets - Compact Grid */} +
+ + + + + + +
+ + + + {/* Natural Language Input */} +
+
Or type a custom period
+ +
+ handleNaturalLanguageChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleNaturalLanguageSubmit(naturalLanguageInput); + } + if (e.key === 'Escape') { + setNaturalLanguageInput(""); + setShowSuggestions(false); + } + }} + className="h-8 text-sm" + /> + + {/* Live Preview */} + {naturalLanguageInput && ( +
+ {(() => { + const parsed = parseNaturalLanguagePeriod(naturalLanguageInput, currentDate); + const preview = generateNaturalLanguagePreview(parsed); + + if (preview) { + return ( +
+ Recognized as: + + {preview} + +
+ ); + } else { + return ( +
+ Not recognized - try a different format +
+ ); + } + })()} +
+ )} + + {/* Suggestions Dropdown */} + {showSuggestions && filteredSuggestions.length > 0 && ( +
+ {filteredSuggestions.slice(0, 6).map((suggestion, index) => ( + + ))} +
+ )} +
+ + {/* Example suggestions when input is empty */} + {naturalLanguageInput === "" && ( +
+
Examples:
+
+ {suggestions.slice(0, 6).map((suggestion, index) => ( + + ))} +
+
+ )} +
+
+
+
+ + + + + - Financial Details - - Explore the grouped breakdown for the selected metrics. - -
+ + Financial Details + +
+
+ {SERIES_DEFINITIONS.map((series) => ( + + ))} +
+ - {SERIES_DEFINITIONS.map((series) => ( - - ))}
- -
- {detailRows.length ? ( -
- - - - Date +
+
+
+ + + + Date + + {metrics.income && ( + + Total Income + + )} + {metrics.cogs && ( + + COGS + + )} + {metrics.profit && ( + + Gross Profit + + )} + {metrics.margin && ( + + Margin + + )} + + + + {detailRows.map((row) => ( + + + {row.label || "—"} + {metrics.income && ( - Total Income + + {row.isFuture ? "—" : formatCurrency(row.income, 0)} + )} {metrics.cogs && ( - COGS + + {row.isFuture ? "—" : formatCurrency(row.cogs, 0)} + )} {metrics.profit && ( - Gross Profit + + {row.isFuture ? "—" : formatCurrency(row.profit, 0)} + )} {metrics.margin && ( - Margin + + {row.isFuture ? "—" : formatPercentage(row.margin, 1)} + )} - - - {detailRows.map((row) => ( - - - {row.label || "—"} - - {metrics.income && ( - - {row.isFuture ? "—" : formatCurrency(row.income, 0)} - - )} - {metrics.cogs && ( - - {row.isFuture ? "—" : formatCurrency(row.cogs, 0)} - - )} - {metrics.profit && ( - - {row.isFuture ? "—" : formatCurrency(row.profit, 0)} - - )} - {metrics.margin && ( - - {row.isFuture ? "—" : formatPercentage(row.margin, 1)} - - )} - - ))} - -
-
- ) : ( -
- No detailed data available for this range. -
- )} + ))} + + +
- ) : ( - - ) - ) : null} -
-
-
-
- - -
- {viewMode === "custom" && ( -
- - - - - {customPeriod.type === "month" && ( - - )} - - {customPeriod.type === "quarter" && ( - - )} - - -
+ )}
- Range: {selectedRangeLabel}
+ + {/* Show stats only if not in error state */} + {!error && + (loading ? ( + + ) : ( + cards.length > 0 && + ))} + + + {/* Show metric toggles only if not in error state */} + {!error && ( +
+
+ {SERIES_DEFINITIONS.map((series) => ( + + ))} +
+ + + + + +
+ )}
{loading ? (
-
) : error ? ( - + Error - {error} + + Failed to load financial data: {error} + + ) : !hasData ? ( +
+
+ +
+ No financial data available +
+
+ Try selecting a different time range +
+
+
) : ( <> - {cards.length ? : null} -
-
-
- Chart Metrics - -
-
- {SERIES_DEFINITIONS.map((series) => ( - - ))} -
-
-
- {!hasActiveMetrics ? ( - - ) : hasData ? ( - - - - - - - - - - - - - - - - - - +
+ {!hasActiveMetrics ? ( + + ) : ( + + + + + + + + + + + + + + + + + + + formatCurrency(value, 0)} + className="text-xs text-muted-foreground" + tick={{ fill: "currentColor" }} + /> + {metrics.margin && ( formatCurrency(value, 0)} + yAxisId="right" + orientation="right" + tickFormatter={(value: number) => formatPercentage(value, 0)} + domain={marginDomain} className="text-xs text-muted-foreground" + tick={{ fill: "currentColor" }} /> - {metrics.margin && ( - formatPercentage(value, 0)} - domain={marginDomain} - className="text-xs text-muted-foreground" - /> - )} - } /> - SERIES_LABELS[value as ChartSeriesKey] ?? value} /> - {metrics.income ? ( - - ) : null} - {metrics.cogs ? ( - - ) : null} - {metrics.profit ? ( - - ) : null} - {metrics.margin ? ( - - ) : null} - - - ) : ( - - )} -
+ )} + } /> + SERIES_LABELS[value as ChartSeriesKey] ?? value} /> + {metrics.income ? ( + + ) : null} + {metrics.cogs ? ( + + ) : null} + {metrics.profit ? ( + + ) : null} + {metrics.margin ? ( + + ) : null} +
+
+ )}
)} @@ -1556,7 +1650,7 @@ function FinancialStatGrid({ }>; }) { return ( -
+
{cards.map((card) => ( ))} @@ -1578,12 +1672,12 @@ function FinancialStatCard({ accentClass: string; }) { return ( - - - {title} + + + {title} {trend?.label && ( {trend.direction === "up" ? ( - + ) : trend.direction === "down" ? ( - + ) : ( - + )} {trend.label} )} -
{value}
- {description &&
{description}
} +
+ {value} +
+ {description && ( +
{description}
+ )}
); @@ -1612,16 +1710,16 @@ function FinancialStatCard({ function SkeletonStats() { return ( -
+
{Array.from({ length: 4 }).map((_, index) => ( - + - + - - - + + + ))} @@ -1631,25 +1729,39 @@ function SkeletonStats() { function SkeletonChart() { return ( -
-
-
- {Array.from({ length: 5 }).map((_, index) => ( -
+
+
+
+ {/* Grid lines */} + {[...Array(6)].map((_, i) => ( +
))} -
-
- {Array.from({ length: 5 }).map((_, index) => ( - - ))} -
-
- {Array.from({ length: 6 }).map((_, index) => ( - - ))} -
-
- + {/* Y-axis labels */} +
+ {[...Array(6)].map((_, i) => ( + + ))} +
+ {/* X-axis labels */} +
+ {[...Array(7)].map((_, i) => ( + + ))} +
+ {/* Chart line */} +
+
+
diff --git a/inventory/src/utils/naturalLanguagePeriod.ts b/inventory/src/utils/naturalLanguagePeriod.ts new file mode 100644 index 0000000..2060077 --- /dev/null +++ b/inventory/src/utils/naturalLanguagePeriod.ts @@ -0,0 +1,384 @@ +export type MonthPeriod = { + type: "month"; + startYear: number; + startMonth: number; + count: number; +}; + +export type QuarterPeriod = { + type: "quarter"; + startYear: number; + startQuarter: number; + count: number; +}; + +export type YearPeriod = { + type: "year"; + startYear: number; + count: number; +}; + +export type CustomPeriod = MonthPeriod | QuarterPeriod | YearPeriod; + +export type NaturalLanguagePeriodResult = CustomPeriod | "last30days" | null; + +const MONTH_NAMES = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +] as const; + +const QUARTER_NAMES = ["Q1", "Q2", "Q3", "Q4"] as const; + +const MONTH_MAP: Record = { + 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, +}; + +const stripDelimiters = (value: string) => value.toLowerCase().trim().replace(/[,]/g, ""); + +const parseYear = (yearStr: string): number => { + const year = parseInt(yearStr, 10); + if (yearStr.length === 2) { + if (year <= 30) { + return 2000 + year; + } + return 1900 + year; + } + return year; +}; + +const findMonth = (monthStr: string): number | null => { + const month = MONTH_MAP[monthStr.toLowerCase()]; + return month ?? null; +}; + +export const parseNaturalLanguagePeriod = ( + input: string, + referenceDate: Date = new Date() +): NaturalLanguagePeriodResult => { + const lower = stripDelimiters(input); + const now = referenceDate; + const currentYear = now.getFullYear(); + const currentMonth = now.getMonth(); + const currentQuarter = Math.floor(currentMonth / 3); + + if (/^(last\s*)?30\s*days?$/i.test(lower)) { + return "last30days"; + } + + if (/^this\s+month$/i.test(lower)) { + return { + type: "month", + startYear: currentYear, + startMonth: currentMonth, + count: 1, + }; + } + + if (/^last\s+month$/i.test(lower)) { + const lastMonth = currentMonth === 0 ? 11 : currentMonth - 1; + const lastMonthYear = currentMonth === 0 ? currentYear - 1 : currentYear; + return { + type: "month", + startYear: lastMonthYear, + startMonth: lastMonth, + count: 1, + }; + } + + if (/^this\s+quarter$/i.test(lower)) { + return { + type: "quarter", + startYear: currentYear, + startQuarter: currentQuarter, + count: 1, + }; + } + + if (/^last\s+quarter$/i.test(lower)) { + const lastQuarter = currentQuarter === 0 ? 3 : currentQuarter - 1; + const lastQuarterYear = currentQuarter === 0 ? currentYear - 1 : currentYear; + return { + type: "quarter", + startYear: lastQuarterYear, + startQuarter: lastQuarter, + count: 1, + }; + } + + if (/^this\s+year$/i.test(lower)) { + return { + type: "year", + startYear: currentYear, + count: 1, + }; + } + + if (/^last\s+year$/i.test(lower)) { + return { + type: "year", + startYear: currentYear - 1, + count: 1, + }; + } + + const lastMatch = lower.match(/^last\s+(\d+)\s*(months?|quarters?|years?|mos?|qtrs?|yrs?)$/i); + if (lastMatch) { + const count = parseInt(lastMatch[1], 10); + const unit = lastMatch[2].toLowerCase(); + + let type: "month" | "quarter" | "year"; + if (/^(months?|mos?)$/i.test(unit)) { + type = "month"; + } else if (/^(quarters?|qtrs?)$/i.test(unit)) { + type = "quarter"; + } else { + type = "year"; + } + + if (type === "month") { + const startMonth = currentMonth - count + 1; + const startYear = currentYear + Math.floor(startMonth / 12); + return { + type: "month", + startYear, + startMonth: ((startMonth % 12) + 12) % 12, + count, + }; + } + + if (type === "quarter") { + const startQuarter = currentQuarter - count + 1; + const startYear = currentYear + Math.floor(startQuarter / 4); + return { + type: "quarter", + startYear, + startQuarter: ((startQuarter % 4) + 4) % 4, + count, + }; + } + + return { + type: "year", + startYear: currentYear - count + 1, + count, + }; + } + + const quarterMatch = lower.match(/^q([1-4])(?:\s+|-)(\d{2,4})$/); + if (quarterMatch) { + return { + type: "quarter", + startYear: parseYear(quarterMatch[2]), + startQuarter: parseInt(quarterMatch[1], 10) - 1, + count: 1, + }; + } + + const quarterRangeMatch = lower.match(/^q([1-4])\s*(?:-|–|—|to|through|until|thru)\s*q([1-4])(?:\s+|-)(\d{2,4})$/); + if (quarterRangeMatch) { + const startQ = parseInt(quarterRangeMatch[1], 10) - 1; + const endQ = parseInt(quarterRangeMatch[2], 10) - 1; + const year = parseYear(quarterRangeMatch[3]); + + let count = endQ - startQ + 1; + if (count <= 0) { + count += 4; + } + + return { + type: "quarter", + startYear: year, + startQuarter: startQ, + count, + }; + } + + const quarterRangeAcrossYearsMatch = lower.match( + /^q([1-4])(?:\s+|-)(\d{2,4})\s*(?:-|–|—|to|through|until|thru)\s*q([1-4])(?:\s+|-)(\d{2,4})$/ + ); + if (quarterRangeAcrossYearsMatch) { + const startQ = parseInt(quarterRangeAcrossYearsMatch[1], 10) - 1; + const startYear = parseYear(quarterRangeAcrossYearsMatch[2]); + const endQ = parseInt(quarterRangeAcrossYearsMatch[3], 10) - 1; + const endYear = parseYear(quarterRangeAcrossYearsMatch[4]); + + const totalQuarters = (endYear - startYear) * 4 + (endQ - startQ) + 1; + + if (totalQuarters > 0 && totalQuarters <= 20) { + return { + type: "quarter", + startYear, + startQuarter: startQ, + count: totalQuarters, + }; + } + } + + const yearMatch = lower.match(/^(\d{2,4})$/); + if (yearMatch) { + return { + type: "year", + startYear: parseYear(yearMatch[1]), + count: 1, + }; + } + + const yearRangeMatch = lower.match(/^(\d{2,4})\s*(?:-|–|—|to|through|until|thru)\s*(\d{2,4})$/); + if (yearRangeMatch) { + const startYear = parseYear(yearRangeMatch[1]); + const endYear = parseYear(yearRangeMatch[2]); + const count = endYear - startYear + 1; + + if (count > 0 && count <= 10) { + return { + type: "year", + startYear, + count, + }; + } + } + + const singleMonthMatch = lower.match(/^([a-z]+)(?:\s+|-)(\d{2,4})$/); + if (singleMonthMatch) { + const monthIndex = findMonth(singleMonthMatch[1]); + if (monthIndex !== null) { + return { + type: "month", + startYear: parseYear(singleMonthMatch[2]), + startMonth: monthIndex, + count: 1, + }; + } + } + + const monthRangeWithYearMatch = lower.match( + /^([a-z]+)\s*(?:-|–|—|to|through|until|thru)\s*([a-z]+)(?:\s+|-)(\d{2,4})$/ + ); + if (monthRangeWithYearMatch) { + const startMonthIndex = findMonth(monthRangeWithYearMatch[1]); + const endMonthIndex = findMonth(monthRangeWithYearMatch[2]); + const year = parseYear(monthRangeWithYearMatch[3]); + + if (startMonthIndex !== null && endMonthIndex !== null) { + let count = endMonthIndex - startMonthIndex + 1; + if (count <= 0) { + count += 12; + } + + return { + type: "month", + startYear: year, + startMonth: startMonthIndex, + count, + }; + } + } + + const monthRangeAcrossYearsMatch = lower.match( + /^([a-z]+)(?:\s+|-)(\d{2,4})\s*(?:-|–|—|to|through|until|thru)\s*([a-z]+)(?:\s+|-)(\d{2,4})$/ + ); + if (monthRangeAcrossYearsMatch) { + const startMonthIndex = findMonth(monthRangeAcrossYearsMatch[1]); + const startYear = parseYear(monthRangeAcrossYearsMatch[2]); + const endMonthIndex = findMonth(monthRangeAcrossYearsMatch[3]); + const endYear = parseYear(monthRangeAcrossYearsMatch[4]); + + if (startMonthIndex !== null && endMonthIndex !== null) { + const totalMonths = (endYear - startYear) * 12 + (endMonthIndex - startMonthIndex) + 1; + + if (totalMonths > 0 && totalMonths <= 60) { + return { + type: "month", + startYear, + startMonth: startMonthIndex, + count: totalMonths, + }; + } + } + } + + return null; +}; + +export const generateNaturalLanguagePreview = ( + parsed: NaturalLanguagePeriodResult +): string | null => { + if (!parsed) { + return null; + } + + if (parsed === "last30days") { + return "Last 30 days"; + } + + if (parsed.type === "year") { + if (parsed.count === 1) { + return `${parsed.startYear}`; + } + const endYear = parsed.startYear + parsed.count - 1; + return `${parsed.startYear} to ${endYear}`; + } + + if (parsed.type === "quarter") { + if (parsed.count === 1) { + return `${QUARTER_NAMES[parsed.startQuarter]} ${parsed.startYear}`; + } + const endQuarter = (parsed.startQuarter + parsed.count - 1) % 4; + const endYear = parsed.startYear + Math.floor((parsed.startQuarter + parsed.count - 1) / 4); + + if (parsed.startYear === endYear) { + return `${QUARTER_NAMES[parsed.startQuarter]} to ${QUARTER_NAMES[endQuarter]} ${parsed.startYear}`; + } + + return `${QUARTER_NAMES[parsed.startQuarter]} ${parsed.startYear} to ${QUARTER_NAMES[endQuarter]} ${endYear}`; + } + + if (parsed.type === "month") { + if (parsed.count === 1) { + return `${MONTH_NAMES[parsed.startMonth]} ${parsed.startYear}`; + } + const endMonth = (parsed.startMonth + parsed.count - 1) % 12; + const endYear = parsed.startYear + Math.floor((parsed.startMonth + parsed.count - 1) / 12); + + if (parsed.startYear === endYear) { + return `${MONTH_NAMES[parsed.startMonth]} to ${MONTH_NAMES[endMonth]} ${parsed.startYear}`; + } + + return `${MONTH_NAMES[parsed.startMonth]} ${parsed.startYear} to ${MONTH_NAMES[endMonth]} ${endYear}`; + } + + return null; +};