Deal with incomplete periods in financial overview

This commit is contained in:
2025-09-18 13:00:23 -04:00
parent 5833779c10
commit 3991341376

View File

@@ -136,12 +136,13 @@ type GroupByOption = "day" | "month" | "quarter" | "year";
type ChartPoint = {
label: string;
timestamp: string | null;
grossSales: number;
netRevenue: number;
cogs: number;
profit: number;
margin: number;
grossSales: number | null;
netRevenue: number | null;
cogs: number | null;
profit: number | null;
margin: number | null;
tooltipLabel: string;
isFuture: boolean;
};
const chartColors: Record<ChartSeriesKey, string> = {
@@ -293,6 +294,7 @@ type AggregatedTrendPoint = {
cogs: number;
profit: number;
margin: number;
isFuture: boolean;
};
const pad = (value: number) => value.toString().padStart(2, "0");
@@ -419,10 +421,113 @@ const aggregateTrendPoints = (points: RawTrendPoint[], groupBy: GroupByOption):
cogs: bucket.cogs,
profit,
margin,
isFuture: false,
};
});
};
const alignToGroupStart = (date: Date, groupBy: GroupByOption) => {
const aligned = new Date(date);
aligned.setHours(0, 0, 0, 0);
switch (groupBy) {
case "month":
aligned.setDate(1);
break;
case "quarter": {
const quarterStartMonth = Math.floor(aligned.getMonth() / 3) * 3;
aligned.setMonth(quarterStartMonth, 1);
break;
}
case "year":
aligned.setMonth(0, 1);
break;
case "day":
default:
break;
}
return aligned;
};
const advanceGroupStart = (date: Date, groupBy: GroupByOption) => {
const next = new Date(date);
switch (groupBy) {
case "day":
next.setDate(next.getDate() + 1);
break;
case "month":
next.setMonth(next.getMonth() + 1, 1);
break;
case "quarter":
next.setMonth(next.getMonth() + 3, 1);
break;
case "year":
default:
next.setFullYear(next.getFullYear() + 1);
next.setMonth(0, 1);
break;
}
next.setHours(0, 0, 0, 0);
return next;
};
const computeBucketRange = (start: Date, groupBy: GroupByOption) => {
const bucketStart = alignToGroupStart(start, groupBy);
const nextStart = advanceGroupStart(bucketStart, groupBy);
const bucketEnd = new Date(nextStart.getTime() - 1);
return { start: bucketStart, end: bucketEnd };
};
const extendAggregatedTrendPoints = (
points: AggregatedTrendPoint[],
groupBy: GroupByOption,
fullRange: { start: Date; end: Date } | null,
effectiveRangeEnd: Date | null
) => {
if (!fullRange) {
return points;
}
const ordered = new Map(points.map((point) => [point.id, point]));
const results: AggregatedTrendPoint[] = [];
const start = alignToGroupStart(fullRange.start, groupBy);
const rangeEndMs = fullRange.end.getTime();
const effectiveEndMs = effectiveRangeEnd?.getTime() ?? null;
for (let cursor = start; cursor.getTime() <= rangeEndMs; cursor = advanceGroupStart(cursor, groupBy)) {
const key = getGroupKey(cursor, groupBy);
const existing = ordered.get(key);
if (existing) {
results.push(existing);
continue;
}
const { start: bucketStart, end: bucketEnd } = computeBucketRange(cursor, groupBy);
const { axisLabel, tooltipLabel, detailLabel } = buildGroupLabels(bucketStart, bucketEnd, groupBy);
const isFutureBucket = effectiveEndMs != null && cursor.getTime() >= effectiveEndMs;
results.push({
id: key,
label: axisLabel,
tooltipLabel,
detailLabel,
timestamp: bucketStart.toISOString(),
grossSales: 0,
netRevenue: 0,
cogs: 0,
profit: 0,
margin: 0,
isFuture: isFutureBucket,
});
}
return results;
};
const monthsBetween = (start: Date, end: Date) => {
const diffMs = Math.max(0, end.getTime() - start.getTime());
const monthApprox = diffMs / (1000 * 60 * 60 * 24 * 30);
@@ -545,6 +650,45 @@ const FinancialOverview = () => {
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const selectedRange = useMemo(() => {
if (viewMode === "rolling") {
const end = new Date(currentDate);
const start = new Date(currentDate);
start.setHours(0, 0, 0, 0);
end.setHours(23, 59, 59, 999);
start.setDate(start.getDate() - 29);
return { start, end };
}
return computePeriodRange(customPeriod);
}, [viewMode, customPeriod, currentDate]);
const effectiveRangeEnd = useMemo(() => {
if (!selectedRange) {
return null;
}
const rangeEndMs = selectedRange.end.getTime();
const currentMs = currentDate.getTime();
const startMs = selectedRange.start.getTime();
const clampedMs = Math.min(rangeEndMs, currentMs);
const safeEndMs = clampedMs < startMs ? startMs : clampedMs;
return new Date(safeEndMs);
}, [selectedRange, currentDate]);
const requestRange = useMemo(() => {
if (!selectedRange) {
return null;
}
const end = effectiveRangeEnd ?? selectedRange.end;
return {
start: new Date(selectedRange.start),
end: new Date(end),
};
}, [selectedRange, effectiveRangeEnd]);
const yearOptions = useMemo(() => generateYearOptions(12), []);
const rawTrendPoints = useMemo<RawTrendPoint[]>(() => {
@@ -552,6 +696,7 @@ const FinancialOverview = () => {
return [];
}
const rangeBoundary = effectiveRangeEnd?.getTime() ?? null;
const toNumber = (value: number | null | undefined) =>
typeof value === "number" && Number.isFinite(value) ? value : 0;
@@ -588,8 +733,9 @@ const FinancialOverview = () => {
};
})
.filter((value): value is RawTrendPoint => Boolean(value))
.filter((value) => (rangeBoundary == null ? true : value.date.getTime() <= rangeBoundary))
.sort((a, b) => a.date.getTime() - b.date.getTime());
}, [data]);
}, [data, effectiveRangeEnd]);
useEffect(() => {
if (!groupByAuto) {
@@ -603,13 +749,16 @@ const FinancialOverview = () => {
start: rawTrendPoints[0].date,
end: rawTrendPoints[rawTrendPoints.length - 1].date,
};
} else if (viewMode === "custom") {
range = computePeriodRange(customPeriod);
} else {
const end = new Date();
const start = new Date(end);
start.setDate(end.getDate() - 29);
range = { start, end };
} else if (requestRange) {
range = {
start: new Date(requestRange.start),
end: new Date(requestRange.end),
};
} else if (selectedRange) {
range = {
start: new Date(selectedRange.start),
end: new Date(selectedRange.end),
};
}
if (!range) {
@@ -622,7 +771,7 @@ const FinancialOverview = () => {
if (suggested !== groupBy) {
setGroupBy(suggested);
}
}, [groupByAuto, rawTrendPoints, viewMode, customPeriod, groupBy]);
}, [groupByAuto, rawTrendPoints, requestRange, selectedRange, groupBy]);
useEffect(() => {
let cancelled = false;
@@ -637,14 +786,13 @@ const FinancialOverview = () => {
if (viewMode === "rolling") {
params.timeRange = "last30days";
} else {
const range = computePeriodRange(customPeriod);
if (!range) {
setLoading(false);
if (!selectedRange || !requestRange) {
setData(null);
return;
}
params.timeRange = "custom";
params.startDate = range.start.toISOString();
params.endDate = range.end.toISOString();
params.startDate = requestRange.start.toISOString();
params.endDate = requestRange.end.toISOString();
}
const response = (await acotService.getFinancials(params)) as FinancialResponse;
@@ -683,7 +831,7 @@ const FinancialOverview = () => {
return () => {
cancelled = true;
};
}, [viewMode, customPeriod]);
}, [viewMode, selectedRange, requestRange]);
const cards = useMemo(
() => {
@@ -747,8 +895,9 @@ const FinancialOverview = () => {
);
const aggregatedPoints = useMemo<AggregatedTrendPoint[]>(() => {
return aggregateTrendPoints(rawTrendPoints, groupBy);
}, [rawTrendPoints, groupBy]);
const aggregated = aggregateTrendPoints(rawTrendPoints, groupBy);
return extendAggregatedTrendPoints(aggregated, groupBy, selectedRange, effectiveRangeEnd);
}, [rawTrendPoints, groupBy, selectedRange, effectiveRangeEnd]);
const chartData = useMemo<ChartPoint[]>(() => {
if (!aggregatedPoints.length) {
@@ -758,12 +907,13 @@ const FinancialOverview = () => {
return aggregatedPoints.map((point) => ({
label: point.label,
timestamp: point.timestamp,
grossSales: point.grossSales,
netRevenue: point.netRevenue,
cogs: point.cogs,
profit: point.profit,
margin: point.margin,
grossSales: point.isFuture ? null : point.grossSales,
netRevenue: point.isFuture ? null : point.netRevenue,
cogs: point.isFuture ? null : point.cogs,
profit: point.isFuture ? null : point.profit,
margin: point.isFuture ? null : point.margin,
tooltipLabel: point.tooltipLabel,
isFuture: point.isFuture,
}));
}, [aggregatedPoints]);
@@ -772,8 +922,27 @@ const FinancialOverview = () => {
return "Last 30 Days";
}
const label = formatPeriodRangeLabel(customPeriod);
return label || "";
}, [viewMode, customPeriod]);
if (!label) {
return "";
}
if (!selectedRange || !effectiveRangeEnd) {
return label;
}
const isPartial = effectiveRangeEnd.getTime() < selectedRange.end.getTime();
if (!isPartial) {
return label;
}
const partialLabel = effectiveRangeEnd.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
return `${label} (through ${partialLabel})`;
}, [viewMode, customPeriod, selectedRange, effectiveRangeEnd]);
const marginDomain = useMemo<[number, number]>(() => {
if (!metrics.margin || !chartData.length) {
@@ -782,7 +951,7 @@ const FinancialOverview = () => {
const values = chartData
.map((point) => point.margin)
.filter((value) => Number.isFinite(value)) as number[];
.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
if (!values.length) {
return [0, 100];
@@ -813,6 +982,7 @@ const FinancialOverview = () => {
cogs: point.cogs,
profit: point.profit,
margin: point.margin,
isFuture: point.isFuture,
})),
[aggregatedPoints]
);
@@ -935,8 +1105,6 @@ const FinancialOverview = () => {
});
}, [customPeriod]);
const isSeriesVisible = (series: ChartSeriesKey) => Boolean(metrics[series]);
const toggleMetric = (series: ChartSeriesKey) => {
setMetrics((prev) => ({
...prev,
@@ -1031,27 +1199,27 @@ const FinancialOverview = () => {
</TableCell>
{metrics.grossSales && (
<TableCell className="px-6 py-3 whitespace-nowrap text-center font-medium">
{formatCurrency(row.grossSales, 0)}
{row.isFuture ? "—" : formatCurrency(row.grossSales, 0)}
</TableCell>
)}
{metrics.netRevenue && (
<TableCell className="px-6 py-3 whitespace-nowrap text-center font-medium">
{formatCurrency(row.netRevenue, 0)}
{row.isFuture ? "—" : formatCurrency(row.netRevenue, 0)}
</TableCell>
)}
{metrics.profit && (
<TableCell className="px-6 py-3 whitespace-nowrap text-center font-medium">
{formatCurrency(row.profit, 0)}
{row.isFuture ? "—" : formatCurrency(row.profit, 0)}
</TableCell>
)}
{metrics.cogs && (
<TableCell className="px-6 py-3 whitespace-nowrap text-center font-medium">
{formatCurrency(row.cogs, 0)}
{row.isFuture ? "—" : formatCurrency(row.cogs, 0)}
</TableCell>
)}
{metrics.margin && (
<TableCell className="px-6 py-3 whitespace-nowrap text-center font-medium">
{formatPercentage(row.margin, 1)}
{row.isFuture ? "—" : formatPercentage(row.margin, 1)}
</TableCell>
)}
</TableRow>
@@ -1454,8 +1622,9 @@ const FinancialTooltip = ({ active, payload, label }: TooltipProps<number, strin
return null;
}
const resolvedLabel =
(payload[0]?.payload as ChartPoint | undefined)?.tooltipLabel ?? label;
const basePoint = payload[0]?.payload as ChartPoint | undefined;
const resolvedLabel = basePoint?.tooltipLabel ?? label;
const isFuturePoint = basePoint?.isFuture ?? false;
return (
<div className="rounded-md border border-border/60 bg-white dark:bg-gray-900/80 px-3 py-2 shadow-lg">
@@ -1464,12 +1633,17 @@ const FinancialTooltip = ({ active, payload, label }: TooltipProps<number, strin
{payload.map((entry, index) => {
const key = (entry.dataKey ?? "") as ChartSeriesKey;
const rawValue = entry.value;
const formattedValue =
typeof rawValue === "number"
? key === "margin"
? formatPercentage(rawValue, 1)
: formatCurrency(rawValue, 0)
: rawValue;
let formattedValue: string;
if (isFuturePoint || rawValue == null) {
formattedValue = "—";
} else if (typeof rawValue === "number") {
formattedValue = key === "margin" ? formatPercentage(rawValue, 1) : formatCurrency(rawValue, 0);
} else if (typeof rawValue === "string") {
formattedValue = rawValue;
} else {
formattedValue = "—";
}
return (
<div key={`${key}-${index}`} className="flex items-center justify-between gap-4">