341 lines
11 KiB
JavaScript
341 lines
11 KiB
JavaScript
const ANNUAL_WEEK_START_MONTH_INDEX = 5;
|
|
const ANNUAL_WEEK_START_DAY = 1;
|
|
const CURRENT_PERIOD_TOKENS = new Set([
|
|
"current",
|
|
"current-week",
|
|
"this-week",
|
|
"week-current",
|
|
"semana-atual",
|
|
"week atual",
|
|
]);
|
|
const CURRENT_MONTH_TOKENS = new Set([
|
|
"current",
|
|
"current-month",
|
|
"this-month",
|
|
"month-current",
|
|
"mes-atual",
|
|
"mês-atual",
|
|
"mes atual",
|
|
"mês atual",
|
|
]);
|
|
const CURRENT_QUARTER_TOKENS = new Set([
|
|
"current",
|
|
"current-quarter",
|
|
"this-quarter",
|
|
"quarter-current",
|
|
"quarter atual",
|
|
"trimestre-atual",
|
|
"trimestre atual",
|
|
]);
|
|
const CURRENT_FISCAL_YEAR_TOKENS = new Set([
|
|
"current",
|
|
"current-fy",
|
|
"this-fy",
|
|
"fy-current",
|
|
"fy atual",
|
|
"ano-fiscal-atual",
|
|
"ano fiscal atual",
|
|
]);
|
|
|
|
function quoteLiteral(value) {
|
|
return `'${String(value).replace(/'/g, "''")}'`;
|
|
}
|
|
|
|
function lastDayOfMonth(year, monthIndex) {
|
|
return new Date(year, monthIndex + 1, 0);
|
|
}
|
|
|
|
function formatIsoDate(date) {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
const day = String(date.getDate()).padStart(2, "0");
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
function addDays(date, days) {
|
|
const next = new Date(date.getTime());
|
|
next.setDate(next.getDate() + days);
|
|
return next;
|
|
}
|
|
|
|
function isLeapYear(year) {
|
|
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
}
|
|
|
|
function isValidIsoCalendarDate(raw) {
|
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
|
|
if (!match) return false;
|
|
const [, yearText, monthText, dayText] = match;
|
|
const year = Number(yearText);
|
|
const month = Number(monthText);
|
|
const day = Number(dayText);
|
|
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
|
|
return false;
|
|
}
|
|
if (month < 1 || month > 12) return false;
|
|
const daysInMonth = [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
return day >= 1 && day <= daysInMonth[month - 1];
|
|
}
|
|
|
|
function resolveDateInput(value, fallback) {
|
|
const raw = String(value || fallback || "").trim();
|
|
if (!raw) {
|
|
throw new Error("Date input is required.");
|
|
}
|
|
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
|
|
if (!isValidIsoCalendarDate(raw)) {
|
|
throw new Error(`Invalid calendar date "${raw}". Use a real date in YYYY-MM-DD format.`);
|
|
}
|
|
return { input: raw, isoDate: raw };
|
|
}
|
|
if (raw.toLowerCase() === "today") {
|
|
return { input: raw, isoDate: formatIsoDate(new Date()) };
|
|
}
|
|
const relativeMatch = /^(?:today)?([+-]\d+)d$/i.exec(raw);
|
|
if (relativeMatch) {
|
|
const days = Number(relativeMatch[1]);
|
|
return { input: raw, isoDate: formatIsoDate(addDays(new Date(), days)) };
|
|
}
|
|
throw new Error(
|
|
`Unsupported date token "${raw}". Use YYYY-MM-DD, today, +7d, -3d, or today+7d.`
|
|
);
|
|
}
|
|
|
|
function resolveMonthInput(value) {
|
|
const raw = String(value || "").trim();
|
|
if (CURRENT_MONTH_TOKENS.has(raw.toLowerCase())) {
|
|
const today = new Date();
|
|
const first = new Date(today.getFullYear(), today.getMonth(), 1);
|
|
const last = lastDayOfMonth(today.getFullYear(), today.getMonth());
|
|
return {
|
|
input: raw,
|
|
fromDate: formatIsoDate(first),
|
|
toDate: formatIsoDate(last),
|
|
};
|
|
}
|
|
if (!/^\d{4}-\d{2}$/.test(raw)) {
|
|
throw new Error(`Unsupported month token "${raw}". Use YYYY-MM, for example 2026-04.`);
|
|
}
|
|
const [yearText, monthText] = raw.split("-");
|
|
const year = Number(yearText);
|
|
const month = Number(monthText);
|
|
if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) {
|
|
throw new Error(`Unsupported month token "${raw}". Use YYYY-MM, for example 2026-04.`);
|
|
}
|
|
const first = new Date(year, month - 1, 1);
|
|
const last = lastDayOfMonth(year, month - 1);
|
|
return {
|
|
input: raw,
|
|
fromDate: formatIsoDate(first),
|
|
toDate: formatIsoDate(last),
|
|
};
|
|
}
|
|
|
|
function fiscalQuarterToDateWindow(fiscalYear, quarter) {
|
|
const normalizedFiscalYear = String(fiscalYear || "").trim().toUpperCase();
|
|
const normalizedQuarter = String(quarter || "").trim().toUpperCase();
|
|
const fiscalMatch = /^FY(\d{2,4})$/.exec(normalizedFiscalYear);
|
|
const quarterMatch = /^Q([1-4])$/.exec(normalizedQuarter);
|
|
if (!fiscalMatch || !quarterMatch) {
|
|
throw new Error(`Unsupported fiscal quarter token "${fiscalYear} ${quarter}". Use FY26-Q4 or Q4 FY26.`);
|
|
}
|
|
const endYear = fiscalMatch[1].length === 2 ? 2000 + Number(fiscalMatch[1]) : Number(fiscalMatch[1]);
|
|
const quarterNumber = Number(quarterMatch[1]);
|
|
const startYear = endYear - 1;
|
|
const quarterWindows = {
|
|
1: { from: new Date(startYear, 5, 1), to: lastDayOfMonth(startYear, 7) },
|
|
2: { from: new Date(startYear, 8, 1), to: lastDayOfMonth(startYear, 10) },
|
|
3: { from: new Date(startYear, 11, 1), to: lastDayOfMonth(endYear, 1) },
|
|
4: { from: new Date(endYear, 2, 1), to: lastDayOfMonth(endYear, 4) },
|
|
};
|
|
return {
|
|
fiscalYear: normalizedFiscalYear,
|
|
quarter: `Q${quarterNumber}`,
|
|
fromDate: formatIsoDate(quarterWindows[quarterNumber].from),
|
|
toDate: formatIsoDate(quarterWindows[quarterNumber].to),
|
|
};
|
|
}
|
|
|
|
export function resolveQuarterInput(value) {
|
|
const raw = String(value || "").trim();
|
|
const normalized = raw.toLowerCase();
|
|
if (CURRENT_QUARTER_TOKENS.has(normalized)) {
|
|
const today = new Date();
|
|
const cycleYear = resolveWeekYearInput("");
|
|
const monthIndex = today.getMonth();
|
|
let quarter = "Q1";
|
|
if (monthIndex >= 5 && monthIndex <= 7) {
|
|
quarter = "Q1";
|
|
} else if (monthIndex >= 8 && monthIndex <= 10) {
|
|
quarter = "Q2";
|
|
} else if (monthIndex === 11 || monthIndex <= 1) {
|
|
quarter = "Q3";
|
|
} else {
|
|
quarter = "Q4";
|
|
}
|
|
const fiscalYear = `FY${String(cycleYear + 1).padStart(2, "0").slice(-2)}`;
|
|
return {
|
|
input: raw,
|
|
...fiscalQuarterToDateWindow(fiscalYear, quarter),
|
|
};
|
|
}
|
|
const compactMatch = /^(FY\d{2,4})[-\s]Q([1-4])$/i.exec(raw);
|
|
if (compactMatch) {
|
|
return {
|
|
input: raw,
|
|
...fiscalQuarterToDateWindow(compactMatch[1], `Q${compactMatch[2]}`),
|
|
};
|
|
}
|
|
const reversedMatch = /^Q([1-4])[-\s]?(FY\d{2,4})$/i.exec(raw);
|
|
if (reversedMatch) {
|
|
return {
|
|
input: raw,
|
|
...fiscalQuarterToDateWindow(reversedMatch[2], `Q${reversedMatch[1]}`),
|
|
};
|
|
}
|
|
throw new Error(`Unsupported quarter token "${raw}". Use FY26-Q4, Q4 FY26, or current-quarter.`);
|
|
}
|
|
|
|
export function resolveWeekYearInput(value) {
|
|
const raw = String(value || "").trim();
|
|
if (!raw || CURRENT_FISCAL_YEAR_TOKENS.has(raw.toLowerCase())) {
|
|
const today = new Date();
|
|
const cycleStart = new Date(today.getFullYear(), ANNUAL_WEEK_START_MONTH_INDEX, ANNUAL_WEEK_START_DAY);
|
|
return today >= cycleStart ? today.getFullYear() : today.getFullYear() - 1;
|
|
}
|
|
if (!/^\d{4}$/.test(raw)) {
|
|
throw new Error(`Unsupported week year token "${raw}". Use the cycle start year YYYY, for example 2025.`);
|
|
}
|
|
return Number(raw);
|
|
}
|
|
|
|
export function resolveAnnualWeekInput(value, weekYear) {
|
|
const raw = String(value || "").trim().toLowerCase();
|
|
if (CURRENT_PERIOD_TOKENS.has(raw)) {
|
|
const year = resolveWeekYearInput(weekYear);
|
|
const cycleStart = new Date(year, ANNUAL_WEEK_START_MONTH_INDEX, ANNUAL_WEEK_START_DAY);
|
|
const cycleEnd = new Date(year + 1, ANNUAL_WEEK_START_MONTH_INDEX, 0);
|
|
const today = new Date();
|
|
const boundedToday =
|
|
today < cycleStart ? cycleStart : today > cycleEnd ? cycleEnd : today;
|
|
const week = Math.floor((boundedToday - cycleStart) / (7 * 24 * 60 * 60 * 1000)) + 1;
|
|
return resolveAnnualWeekInput(`w${week}`, year);
|
|
}
|
|
const match = raw.match(/^(?:w|week\s*)?([1-9]|[1-4]\d|5[0-3])$/);
|
|
if (!match) {
|
|
throw new Error(`Unsupported week token "${value}". Use 1..53, w1..w53, or week 1..week 53.`);
|
|
}
|
|
const week = Number(match[1]);
|
|
const year = resolveWeekYearInput(weekYear);
|
|
const cycleStart = new Date(year, ANNUAL_WEEK_START_MONTH_INDEX, ANNUAL_WEEK_START_DAY);
|
|
const cycleEnd = new Date(year + 1, ANNUAL_WEEK_START_MONTH_INDEX, 0);
|
|
const weekStart = addDays(cycleStart, (week - 1) * 7);
|
|
if (weekStart > cycleEnd) {
|
|
throw new Error(`Week ${week} is outside the ${year}-${year + 1} cycle. Use 1..53.`);
|
|
}
|
|
const provisionalWeekEnd = addDays(weekStart, 6);
|
|
const weekEnd = provisionalWeekEnd > cycleEnd ? cycleEnd : provisionalWeekEnd;
|
|
return {
|
|
input: String(value || "").trim(),
|
|
week,
|
|
weekLabel: `W${week}`,
|
|
year,
|
|
fromDate: formatIsoDate(weekStart),
|
|
toDate: formatIsoDate(weekEnd),
|
|
};
|
|
}
|
|
|
|
export function resolveDateWindow(args, defaults = { from: "today", to: "+7d" }) {
|
|
const hasSingleDate = Boolean(args.date);
|
|
const hasMonth = Boolean(args.month);
|
|
const hasWeek = Boolean(args.week);
|
|
const hasQuarter = Boolean(args.quarter);
|
|
const hasExplicitWindow = Boolean(args["from-date"] || args["to-date"]);
|
|
if (
|
|
[hasSingleDate, hasMonth, hasWeek, hasQuarter].filter(Boolean).length > 1 ||
|
|
((hasSingleDate || hasMonth || hasWeek || hasQuarter) && hasExplicitWindow)
|
|
) {
|
|
throw new Error(
|
|
"Use either --date, --month, --quarter, --week, or the pair --from-date/--to-date for date-window queries."
|
|
);
|
|
}
|
|
if (hasSingleDate) {
|
|
const exact = resolveDateInput(args.date);
|
|
return {
|
|
fromDateInput: exact.input,
|
|
toDateInput: exact.input,
|
|
fromDate: exact.isoDate,
|
|
toDate: exact.isoDate,
|
|
fromDateSql: `DATE ${quoteLiteral(exact.isoDate)}`,
|
|
toDateSql: `DATE ${quoteLiteral(exact.isoDate)}`,
|
|
preset: "single-date",
|
|
};
|
|
}
|
|
if (hasMonth) {
|
|
const month = resolveMonthInput(args.month);
|
|
return {
|
|
fromDateInput: month.input,
|
|
toDateInput: month.input,
|
|
fromDate: month.fromDate,
|
|
toDate: month.toDate,
|
|
fromDateSql: `DATE ${quoteLiteral(month.fromDate)}`,
|
|
toDateSql: `DATE ${quoteLiteral(month.toDate)}`,
|
|
preset: "calendar-month",
|
|
};
|
|
}
|
|
if (hasQuarter) {
|
|
const quarter = resolveQuarterInput(args.quarter);
|
|
return {
|
|
fromDateInput: quarter.input,
|
|
toDateInput: quarter.input,
|
|
fromDate: quarter.fromDate,
|
|
toDate: quarter.toDate,
|
|
fromDateSql: `DATE ${quoteLiteral(quarter.fromDate)}`,
|
|
toDateSql: `DATE ${quoteLiteral(quarter.toDate)}`,
|
|
preset: "fiscal-quarter",
|
|
fiscalYear: quarter.fiscalYear,
|
|
fiscalQuarter: quarter.quarter,
|
|
};
|
|
}
|
|
if (hasWeek) {
|
|
const week = resolveAnnualWeekInput(args.week, args["week-year"]);
|
|
return {
|
|
fromDateInput: week.input,
|
|
toDateInput: week.input,
|
|
fromDate: week.fromDate,
|
|
toDate: week.toDate,
|
|
fromDateSql: `DATE ${quoteLiteral(week.fromDate)}`,
|
|
toDateSql: `DATE ${quoteLiteral(week.toDate)}`,
|
|
preset: "annual-week",
|
|
week: week.week,
|
|
weekLabel: week.weekLabel,
|
|
weekYear: week.year,
|
|
};
|
|
}
|
|
const from = resolveDateInput(args["from-date"], defaults.from);
|
|
const to = resolveDateInput(args["to-date"], defaults.to);
|
|
if (from.isoDate > to.isoDate) {
|
|
throw new Error(`Invalid date window: from-date ${from.isoDate} is after to-date ${to.isoDate}.`);
|
|
}
|
|
return {
|
|
fromDateInput: from.input,
|
|
toDateInput: to.input,
|
|
fromDate: from.isoDate,
|
|
toDate: to.isoDate,
|
|
fromDateSql: `DATE ${quoteLiteral(from.isoDate)}`,
|
|
toDateSql: `DATE ${quoteLiteral(to.isoDate)}`,
|
|
preset: "date-window",
|
|
};
|
|
}
|
|
|
|
export function hasAnyDateWindowArgs(args) {
|
|
return Boolean(
|
|
args.date ||
|
|
args.month ||
|
|
args.quarter ||
|
|
args.week ||
|
|
args["from-date"] ||
|
|
args["to-date"]
|
|
);
|
|
}
|