Add in natural language time period input and tweak layout for financial overview

This commit is contained in:
2025-09-22 12:28:59 -04:00
parent 2ff325a132
commit 4776a112b6
2 changed files with 991 additions and 495 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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<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,
};
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;
};