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 = { type ChartPoint = {
label: string; label: string;
timestamp: string | null; timestamp: string | null;
grossSales: number; grossSales: number | null;
netRevenue: number; netRevenue: number | null;
cogs: number; cogs: number | null;
profit: number; profit: number | null;
margin: number; margin: number | null;
tooltipLabel: string; tooltipLabel: string;
isFuture: boolean;
}; };
const chartColors: Record<ChartSeriesKey, string> = { const chartColors: Record<ChartSeriesKey, string> = {
@@ -293,6 +294,7 @@ type AggregatedTrendPoint = {
cogs: number; cogs: number;
profit: number; profit: number;
margin: number; margin: number;
isFuture: boolean;
}; };
const pad = (value: number) => value.toString().padStart(2, "0"); const pad = (value: number) => value.toString().padStart(2, "0");
@@ -419,10 +421,113 @@ const aggregateTrendPoints = (points: RawTrendPoint[], groupBy: GroupByOption):
cogs: bucket.cogs, cogs: bucket.cogs,
profit, profit,
margin, 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 monthsBetween = (start: Date, end: Date) => {
const diffMs = Math.max(0, end.getTime() - start.getTime()); const diffMs = Math.max(0, end.getTime() - start.getTime());
const monthApprox = diffMs / (1000 * 60 * 60 * 24 * 30); const monthApprox = diffMs / (1000 * 60 * 60 * 24 * 30);
@@ -545,6 +650,45 @@ const FinancialOverview = () => {
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); 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 yearOptions = useMemo(() => generateYearOptions(12), []);
const rawTrendPoints = useMemo<RawTrendPoint[]>(() => { const rawTrendPoints = useMemo<RawTrendPoint[]>(() => {
@@ -552,6 +696,7 @@ const FinancialOverview = () => {
return []; return [];
} }
const rangeBoundary = effectiveRangeEnd?.getTime() ?? null;
const toNumber = (value: number | null | undefined) => const toNumber = (value: number | null | undefined) =>
typeof value === "number" && Number.isFinite(value) ? value : 0; typeof value === "number" && Number.isFinite(value) ? value : 0;
@@ -588,8 +733,9 @@ const FinancialOverview = () => {
}; };
}) })
.filter((value): value is RawTrendPoint => Boolean(value)) .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()); .sort((a, b) => a.date.getTime() - b.date.getTime());
}, [data]); }, [data, effectiveRangeEnd]);
useEffect(() => { useEffect(() => {
if (!groupByAuto) { if (!groupByAuto) {
@@ -603,13 +749,16 @@ const FinancialOverview = () => {
start: rawTrendPoints[0].date, start: rawTrendPoints[0].date,
end: rawTrendPoints[rawTrendPoints.length - 1].date, end: rawTrendPoints[rawTrendPoints.length - 1].date,
}; };
} else if (viewMode === "custom") { } else if (requestRange) {
range = computePeriodRange(customPeriod); range = {
} else { start: new Date(requestRange.start),
const end = new Date(); end: new Date(requestRange.end),
const start = new Date(end); };
start.setDate(end.getDate() - 29); } else if (selectedRange) {
range = { start, end }; range = {
start: new Date(selectedRange.start),
end: new Date(selectedRange.end),
};
} }
if (!range) { if (!range) {
@@ -622,7 +771,7 @@ const FinancialOverview = () => {
if (suggested !== groupBy) { if (suggested !== groupBy) {
setGroupBy(suggested); setGroupBy(suggested);
} }
}, [groupByAuto, rawTrendPoints, viewMode, customPeriod, groupBy]); }, [groupByAuto, rawTrendPoints, requestRange, selectedRange, groupBy]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -637,14 +786,13 @@ const FinancialOverview = () => {
if (viewMode === "rolling") { if (viewMode === "rolling") {
params.timeRange = "last30days"; params.timeRange = "last30days";
} else { } else {
const range = computePeriodRange(customPeriod); if (!selectedRange || !requestRange) {
if (!range) { setData(null);
setLoading(false);
return; return;
} }
params.timeRange = "custom"; params.timeRange = "custom";
params.startDate = range.start.toISOString(); params.startDate = requestRange.start.toISOString();
params.endDate = range.end.toISOString(); params.endDate = requestRange.end.toISOString();
} }
const response = (await acotService.getFinancials(params)) as FinancialResponse; const response = (await acotService.getFinancials(params)) as FinancialResponse;
@@ -683,7 +831,7 @@ const FinancialOverview = () => {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [viewMode, customPeriod]); }, [viewMode, selectedRange, requestRange]);
const cards = useMemo( const cards = useMemo(
() => { () => {
@@ -747,8 +895,9 @@ const FinancialOverview = () => {
); );
const aggregatedPoints = useMemo<AggregatedTrendPoint[]>(() => { const aggregatedPoints = useMemo<AggregatedTrendPoint[]>(() => {
return aggregateTrendPoints(rawTrendPoints, groupBy); const aggregated = aggregateTrendPoints(rawTrendPoints, groupBy);
}, [rawTrendPoints, groupBy]); return extendAggregatedTrendPoints(aggregated, groupBy, selectedRange, effectiveRangeEnd);
}, [rawTrendPoints, groupBy, selectedRange, effectiveRangeEnd]);
const chartData = useMemo<ChartPoint[]>(() => { const chartData = useMemo<ChartPoint[]>(() => {
if (!aggregatedPoints.length) { if (!aggregatedPoints.length) {
@@ -758,12 +907,13 @@ const FinancialOverview = () => {
return aggregatedPoints.map((point) => ({ return aggregatedPoints.map((point) => ({
label: point.label, label: point.label,
timestamp: point.timestamp, timestamp: point.timestamp,
grossSales: point.grossSales, grossSales: point.isFuture ? null : point.grossSales,
netRevenue: point.netRevenue, netRevenue: point.isFuture ? null : point.netRevenue,
cogs: point.cogs, cogs: point.isFuture ? null : point.cogs,
profit: point.profit, profit: point.isFuture ? null : point.profit,
margin: point.margin, margin: point.isFuture ? null : point.margin,
tooltipLabel: point.tooltipLabel, tooltipLabel: point.tooltipLabel,
isFuture: point.isFuture,
})); }));
}, [aggregatedPoints]); }, [aggregatedPoints]);
@@ -772,8 +922,27 @@ const FinancialOverview = () => {
return "Last 30 Days"; return "Last 30 Days";
} }
const label = formatPeriodRangeLabel(customPeriod); const label = formatPeriodRangeLabel(customPeriod);
return label || ""; if (!label) {
}, [viewMode, customPeriod]); 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]>(() => { const marginDomain = useMemo<[number, number]>(() => {
if (!metrics.margin || !chartData.length) { if (!metrics.margin || !chartData.length) {
@@ -782,7 +951,7 @@ const FinancialOverview = () => {
const values = chartData const values = chartData
.map((point) => point.margin) .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) { if (!values.length) {
return [0, 100]; return [0, 100];
@@ -813,6 +982,7 @@ const FinancialOverview = () => {
cogs: point.cogs, cogs: point.cogs,
profit: point.profit, profit: point.profit,
margin: point.margin, margin: point.margin,
isFuture: point.isFuture,
})), })),
[aggregatedPoints] [aggregatedPoints]
); );
@@ -935,8 +1105,6 @@ const FinancialOverview = () => {
}); });
}, [customPeriod]); }, [customPeriod]);
const isSeriesVisible = (series: ChartSeriesKey) => Boolean(metrics[series]);
const toggleMetric = (series: ChartSeriesKey) => { const toggleMetric = (series: ChartSeriesKey) => {
setMetrics((prev) => ({ setMetrics((prev) => ({
...prev, ...prev,
@@ -1031,27 +1199,27 @@ const FinancialOverview = () => {
</TableCell> </TableCell>
{metrics.grossSales && ( {metrics.grossSales && (
<TableCell className="px-6 py-3 whitespace-nowrap text-center font-medium"> <TableCell className="px-6 py-3 whitespace-nowrap text-center font-medium">
{formatCurrency(row.grossSales, 0)} {row.isFuture ? "—" : formatCurrency(row.grossSales, 0)}
</TableCell> </TableCell>
)} )}
{metrics.netRevenue && ( {metrics.netRevenue && (
<TableCell className="px-6 py-3 whitespace-nowrap text-center font-medium"> <TableCell className="px-6 py-3 whitespace-nowrap text-center font-medium">
{formatCurrency(row.netRevenue, 0)} {row.isFuture ? "—" : formatCurrency(row.netRevenue, 0)}
</TableCell> </TableCell>
)} )}
{metrics.profit && ( {metrics.profit && (
<TableCell className="px-6 py-3 whitespace-nowrap text-center font-medium"> <TableCell className="px-6 py-3 whitespace-nowrap text-center font-medium">
{formatCurrency(row.profit, 0)} {row.isFuture ? "—" : formatCurrency(row.profit, 0)}
</TableCell> </TableCell>
)} )}
{metrics.cogs && ( {metrics.cogs && (
<TableCell className="px-6 py-3 whitespace-nowrap text-center font-medium"> <TableCell className="px-6 py-3 whitespace-nowrap text-center font-medium">
{formatCurrency(row.cogs, 0)} {row.isFuture ? "—" : formatCurrency(row.cogs, 0)}
</TableCell> </TableCell>
)} )}
{metrics.margin && ( {metrics.margin && (
<TableCell className="px-6 py-3 whitespace-nowrap text-center font-medium"> <TableCell className="px-6 py-3 whitespace-nowrap text-center font-medium">
{formatPercentage(row.margin, 1)} {row.isFuture ? "—" : formatPercentage(row.margin, 1)}
</TableCell> </TableCell>
)} )}
</TableRow> </TableRow>
@@ -1454,8 +1622,9 @@ const FinancialTooltip = ({ active, payload, label }: TooltipProps<number, strin
return null; return null;
} }
const resolvedLabel = const basePoint = payload[0]?.payload as ChartPoint | undefined;
(payload[0]?.payload as ChartPoint | undefined)?.tooltipLabel ?? label; const resolvedLabel = basePoint?.tooltipLabel ?? label;
const isFuturePoint = basePoint?.isFuture ?? false;
return ( return (
<div className="rounded-md border border-border/60 bg-white dark:bg-gray-900/80 px-3 py-2 shadow-lg"> <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) => { {payload.map((entry, index) => {
const key = (entry.dataKey ?? "") as ChartSeriesKey; const key = (entry.dataKey ?? "") as ChartSeriesKey;
const rawValue = entry.value; const rawValue = entry.value;
const formattedValue = let formattedValue: string;
typeof rawValue === "number"
? key === "margin" if (isFuturePoint || rawValue == null) {
? formatPercentage(rawValue, 1) formattedValue = "—";
: formatCurrency(rawValue, 0) } else if (typeof rawValue === "number") {
: rawValue; formattedValue = key === "margin" ? formatPercentage(rawValue, 1) : formatCurrency(rawValue, 0);
} else if (typeof rawValue === "string") {
formattedValue = rawValue;
} else {
formattedValue = "—";
}
return ( return (
<div key={`${key}-${index}`} className="flex items-center justify-between gap-4"> <div key={`${key}-${index}`} className="flex items-center justify-between gap-4">