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