Deal with incomplete periods in financial overview
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user