Add ability to group by different periods to financial overview

This commit is contained in:
2025-09-18 12:29:10 -04:00
parent c61115f665
commit 5833779c10

View File

@@ -131,6 +131,8 @@ type CustomPeriod = MonthPeriod | QuarterPeriod | YearPeriod;
type ChartSeriesKey = "grossSales" | "netRevenue" | "cogs" | "profit" | "margin"; type ChartSeriesKey = "grossSales" | "netRevenue" | "cogs" | "profit" | "margin";
type GroupByOption = "day" | "month" | "quarter" | "year";
type ChartPoint = { type ChartPoint = {
label: string; label: string;
timestamp: string | null; timestamp: string | null;
@@ -139,6 +141,7 @@ type ChartPoint = {
cogs: number; cogs: number;
profit: number; profit: number;
margin: number; margin: number;
tooltipLabel: string;
}; };
const chartColors: Record<ChartSeriesKey, string> = { const chartColors: Record<ChartSeriesKey, string> = {
@@ -169,6 +172,13 @@ const SERIES_DEFINITIONS: Array<{
{ key: "margin", label: SERIES_LABELS.margin, type: "percentage" }, { key: "margin", label: SERIES_LABELS.margin, type: "percentage" },
]; ];
const GROUP_BY_CHOICES: Array<{ value: GroupByOption; label: string }> = [
{ value: "day", label: "Daily" },
{ value: "month", label: "Monthly" },
{ value: "quarter", label: "Quarterly" },
{ value: "year", label: "Yearly" },
];
const MONTHS = [ const MONTHS = [
"January", "January",
"February", "February",
@@ -264,22 +274,160 @@ const formatPercentage = (value: number, digits = 1, suffix = "%") => {
return `${value.toFixed(digits)}${suffix}`; return `${value.toFixed(digits)}${suffix}`;
}; };
function formatDetailLabel(timestamp: string | null, fallback = "") { type RawTrendPoint = {
if (!timestamp) { date: Date;
return fallback; grossSales: number;
netRevenue: number;
cogs: number;
profit: number;
};
type AggregatedTrendPoint = {
id: string;
label: string;
tooltipLabel: string;
detailLabel: string;
timestamp: string;
grossSales: number;
netRevenue: number;
cogs: number;
profit: number;
margin: number;
};
const pad = (value: number) => value.toString().padStart(2, "0");
const getGroupKey = (date: Date, groupBy: GroupByOption) => {
switch (groupBy) {
case "day":
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
case "month":
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}`;
case "quarter": {
const quarter = Math.floor(date.getMonth() / 3) + 1;
return `${date.getFullYear()}-Q${quarter}`;
}
case "year":
default:
return `${date.getFullYear()}`;
}
};
const formatQuarterRange = (start: Date) => {
const quarterIndex = Math.floor(start.getMonth() / 3);
const quarterLabel = QUARTERS[quarterIndex];
return `${quarterLabel} ${start.getFullYear()}`;
};
const buildGroupLabels = (start: Date, end: Date, groupBy: GroupByOption) => {
switch (groupBy) {
case "day": {
const axisLabel = start.toLocaleDateString("en-US", { month: "short", day: "numeric" });
const detailLabel = start.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
});
return { axisLabel, tooltipLabel: detailLabel, detailLabel };
}
case "month": {
const axisLabel = start.toLocaleDateString("en-US", { month: "short", year: "numeric" });
const detailLabel = start.toLocaleDateString("en-US", { month: "long", year: "numeric" });
return { axisLabel, tooltipLabel: detailLabel, detailLabel };
}
case "quarter": {
const axisLabel = formatQuarterRange(start);
let tooltipLabel = axisLabel;
if (start && end) {
const startLabel = start.toLocaleDateString("en-US", { month: "short" });
const endLabel = end.toLocaleDateString("en-US", { month: "short" });
tooltipLabel = `${axisLabel} (${startLabel} ${endLabel})`;
}
return { axisLabel, tooltipLabel, detailLabel: axisLabel };
}
case "year":
default: {
const axisLabel = start.getFullYear().toString();
return { axisLabel, tooltipLabel: axisLabel, detailLabel: axisLabel };
}
}
};
const aggregateTrendPoints = (points: RawTrendPoint[], groupBy: GroupByOption): AggregatedTrendPoint[] => {
if (!points.length) {
return [];
} }
const parsed = new Date(timestamp); const bucketMap = new Map<
if (Number.isNaN(parsed.getTime())) { string,
return fallback || timestamp; {
} key: string;
start: Date;
end: Date;
grossSales: number;
netRevenue: number;
cogs: number;
profit: number;
}
>();
return parsed.toLocaleDateString("en-US", { points.forEach((point) => {
weekday: "short", const key = getGroupKey(point.date, groupBy);
month: "short", const bucket = bucketMap.get(key);
day: "numeric", if (!bucket) {
bucketMap.set(key, {
key,
start: point.date,
end: point.date,
grossSales: point.grossSales,
netRevenue: point.netRevenue,
cogs: point.cogs,
profit: point.profit,
});
return;
}
if (point.date < bucket.start) {
bucket.start = point.date;
}
if (point.date > bucket.end) {
bucket.end = point.date;
}
bucket.grossSales += point.grossSales;
bucket.netRevenue += point.netRevenue;
bucket.cogs += point.cogs;
bucket.profit += point.profit;
}); });
}
return Array.from(bucketMap.values())
.sort((a, b) => a.start.getTime() - b.start.getTime())
.map((bucket) => {
const { axisLabel, tooltipLabel, detailLabel } = buildGroupLabels(bucket.start, bucket.end, groupBy);
const { netRevenue, profit } = bucket;
const margin = netRevenue !== 0 ? (profit / netRevenue) * 100 : 0;
return {
id: bucket.key,
label: axisLabel,
tooltipLabel,
detailLabel,
timestamp: bucket.start.toISOString(),
grossSales: bucket.grossSales,
netRevenue,
cogs: bucket.cogs,
profit,
margin,
};
});
};
const monthsBetween = (start: Date, end: Date) => {
const diffMs = Math.max(0, end.getTime() - start.getTime());
const monthApprox = diffMs / (1000 * 60 * 60 * 24 * 30);
return monthApprox;
};
const buildTrendLabel = ( const buildTrendLabel = (
comparison?: ComparisonValue | null, comparison?: ComparisonValue | null,
@@ -391,12 +539,91 @@ const FinancialOverview = () => {
profit: true, profit: true,
margin: true, margin: true,
}); });
const [groupBy, setGroupBy] = useState<GroupByOption>("day");
const [groupByAuto, setGroupByAuto] = useState<boolean>(true);
const [data, setData] = useState<FinancialResponse | null>(null); const [data, setData] = useState<FinancialResponse | null>(null);
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 yearOptions = useMemo(() => generateYearOptions(12), []); const yearOptions = useMemo(() => generateYearOptions(12), []);
const rawTrendPoints = useMemo<RawTrendPoint[]>(() => {
if (!data?.trend?.length) {
return [];
}
const toNumber = (value: number | null | undefined) =>
typeof value === "number" && Number.isFinite(value) ? value : 0;
return data.trend
.map((point) => {
const timestamp =
point.timestamp ??
(typeof point.date === "string"
? point.date
: point.date instanceof Date
? point.date.toISOString()
: null);
let date: Date | null = null;
if (timestamp) {
const parsed = new Date(timestamp);
if (!Number.isNaN(parsed.getTime())) {
date = parsed;
}
} else if (point.date instanceof Date && !Number.isNaN(point.date.getTime())) {
date = point.date;
}
if (!date) {
return null;
}
return {
date,
grossSales: toNumber(point.grossSales),
netRevenue: toNumber(point.netRevenue),
cogs: toNumber(point.cogs),
profit: toNumber(point.profit),
};
})
.filter((value): value is RawTrendPoint => Boolean(value))
.sort((a, b) => a.date.getTime() - b.date.getTime());
}, [data]);
useEffect(() => {
if (!groupByAuto) {
return;
}
let range: { start: Date; end: Date } | null = null;
if (rawTrendPoints.length > 1) {
range = {
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 };
}
if (!range) {
return;
}
const spanInMonths = monthsBetween(range.start, range.end);
const suggested = spanInMonths > 6 ? "month" : "day";
if (suggested !== groupBy) {
setGroupBy(suggested);
}
}, [groupByAuto, rawTrendPoints, viewMode, customPeriod, groupBy]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -519,46 +746,26 @@ const FinancialOverview = () => {
[data] [data]
); );
const aggregatedPoints = useMemo<AggregatedTrendPoint[]>(() => {
return aggregateTrendPoints(rawTrendPoints, groupBy);
}, [rawTrendPoints, groupBy]);
const chartData = useMemo<ChartPoint[]>(() => { const chartData = useMemo<ChartPoint[]>(() => {
if (!data?.trend?.length) { if (!aggregatedPoints.length) {
return []; return [];
} }
return data.trend.map((point) => { return aggregatedPoints.map((point) => ({
const timestamp = point.timestamp label: point.label,
?? (typeof point.date === "string" timestamp: point.timestamp,
? point.date grossSales: point.grossSales,
: point.date instanceof Date netRevenue: point.netRevenue,
? point.date.toISOString() cogs: point.cogs,
: null); profit: point.profit,
margin: point.margin,
let labelDate: Date | null = null; tooltipLabel: point.tooltipLabel,
if (timestamp) { }));
const parsed = new Date(timestamp); }, [aggregatedPoints]);
if (!Number.isNaN(parsed.getTime())) {
labelDate = parsed;
}
} else if (point.date instanceof Date && !Number.isNaN(point.date.getTime())) {
labelDate = point.date;
}
const label = labelDate
? labelDate.toLocaleDateString("en-US", { month: "short", day: "numeric" })
: typeof point.date === "string"
? point.date
: "";
return {
label,
timestamp,
grossSales: Number.isFinite(point.grossSales) ? point.grossSales : 0,
netRevenue: Number.isFinite(point.netRevenue) ? point.netRevenue : 0,
cogs: Number.isFinite(point.cogs) ? point.cogs : 0,
profit: Number.isFinite(point.profit) ? point.profit : 0,
margin: Number.isFinite(point.margin) ? point.margin : 0,
};
});
}, [data]);
const selectedRangeLabel = useMemo(() => { const selectedRangeLabel = useMemo(() => {
if (viewMode === "rolling") { if (viewMode === "rolling") {
@@ -595,57 +802,39 @@ const FinancialOverview = () => {
const hasActiveMetrics = useMemo(() => Object.values(metrics).some(Boolean), [metrics]); const hasActiveMetrics = useMemo(() => Object.values(metrics).some(Boolean), [metrics]);
const detailRows = useMemo(() => { const detailRows = useMemo(
if (!data?.trend?.length) { () =>
return [] as Array<{ aggregatedPoints.map((point) => ({
id: string; id: point.id,
label: string; label: point.detailLabel,
timestamp: string | null; timestamp: point.timestamp,
grossSales: number; grossSales: point.grossSales,
netRevenue: number; netRevenue: point.netRevenue,
cogs: number; cogs: point.cogs,
profit: number; profit: point.profit,
margin: number; margin: point.margin,
}>; })),
} [aggregatedPoints]
);
return data.trend.map((point, index) => {
const timestamp =
point.timestamp ??
(typeof point.date === "string"
? point.date
: point.date instanceof Date
? point.date.toISOString()
: null);
const fallbackLabel = chartData[index]?.label ?? (typeof point.date === "string" ? point.date : "");
const label = formatDetailLabel(timestamp, fallbackLabel);
const safe = (value: number | null | undefined) =>
typeof value === "number" && Number.isFinite(value) ? value : 0;
return {
id: timestamp ?? `row-${index}`,
label,
timestamp,
grossSales: safe(point.grossSales),
netRevenue: safe(point.netRevenue),
cogs: safe(point.cogs),
profit: safe(point.profit),
margin: safe(point.margin),
};
});
}, [data, chartData]);
const hasData = chartData.length > 0; const hasData = chartData.length > 0;
const enableAutoGrouping = () => setGroupByAuto(true);
const handleViewModeChange = (mode: "rolling" | "custom") => { const handleViewModeChange = (mode: "rolling" | "custom") => {
enableAutoGrouping();
setViewMode(mode); setViewMode(mode);
}; };
const handleGroupByChange = (value: string) => {
setGroupBy(value as GroupByOption);
setGroupByAuto(false);
};
const handleCustomTypeChange = (value: string) => { const handleCustomTypeChange = (value: string) => {
const nextType = value as CustomPeriod["type"]; const nextType = value as CustomPeriod["type"];
setViewMode("custom"); setViewMode("custom");
enableAutoGrouping();
setCustomPeriod((prev) => { setCustomPeriod((prev) => {
const safePrev = ensureValidCustomPeriod(prev); const safePrev = ensureValidCustomPeriod(prev);
switch (nextType) { switch (nextType) {
@@ -683,6 +872,7 @@ const FinancialOverview = () => {
const handleStartYearChange = (value: string) => { const handleStartYearChange = (value: string) => {
const nextYear = Number(value); const nextYear = Number(value);
enableAutoGrouping();
setCustomPeriod((prev) => { setCustomPeriod((prev) => {
const safePrev = ensureValidCustomPeriod(prev); const safePrev = ensureValidCustomPeriod(prev);
switch (safePrev.type) { switch (safePrev.type) {
@@ -699,6 +889,7 @@ const FinancialOverview = () => {
const handleStartMonthChange = (value: string) => { const handleStartMonthChange = (value: string) => {
const nextMonth = Number(value); const nextMonth = Number(value);
enableAutoGrouping();
setCustomPeriod((prev) => { setCustomPeriod((prev) => {
if (prev.type !== "month") { if (prev.type !== "month") {
return prev; return prev;
@@ -709,6 +900,7 @@ const FinancialOverview = () => {
const handleStartQuarterChange = (value: string) => { const handleStartQuarterChange = (value: string) => {
const nextQuarter = Number(value); const nextQuarter = Number(value);
enableAutoGrouping();
setCustomPeriod((prev) => { setCustomPeriod((prev) => {
if (prev.type !== "quarter") { if (prev.type !== "quarter") {
return prev; return prev;
@@ -719,6 +911,7 @@ const FinancialOverview = () => {
const handleCountChange = (value: string) => { const handleCountChange = (value: string) => {
const nextCount = Number(value); const nextCount = Number(value);
enableAutoGrouping();
setCustomPeriod((prev) => { setCustomPeriod((prev) => {
const safePrev = ensureValidCustomPeriod(prev); const safePrev = ensureValidCustomPeriod(prev);
return ensureValidCustomPeriod({ ...safePrev, count: nextCount }); return ensureValidCustomPeriod({ ...safePrev, count: nextCount });
@@ -778,9 +971,21 @@ const FinancialOverview = () => {
<DialogHeader className="flex-none"> <DialogHeader className="flex-none">
<DialogTitle className="text-gray-900 dark:text-gray-100">Financial Details</DialogTitle> <DialogTitle className="text-gray-900 dark:text-gray-100">Financial Details</DialogTitle>
<DialogDescription> <DialogDescription>
Explore the daily breakdown for the selected metrics. Explore the grouped breakdown for the selected metrics.
</DialogDescription> </DialogDescription>
<div className="flex flex-wrap justify-center gap-2 pt-4"> <div className="flex flex-wrap justify-center gap-2 pt-4">
<Select value={groupBy} onValueChange={handleGroupByChange}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Group By" />
</SelectTrigger>
<SelectContent>
{GROUP_BY_CHOICES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{SERIES_DEFINITIONS.map((series) => ( {SERIES_DEFINITIONS.map((series) => (
<Button <Button
key={series.key} key={series.key}
@@ -979,7 +1184,21 @@ const FinancialOverview = () => {
{cards.length ? <FinancialStatGrid cards={cards} /> : null} {cards.length ? <FinancialStatGrid cards={cards} /> : null}
<div className="space-y-4 mt-6"> <div className="space-y-4 mt-6">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<span className="text-sm font-medium text-muted-foreground">Chart Metrics</span> <div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-muted-foreground">Chart Metrics</span>
<Select value={groupBy} onValueChange={handleGroupByChange}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Group By" />
</SelectTrigger>
<SelectContent>
{GROUP_BY_CHOICES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{SERIES_DEFINITIONS.map((series) => ( {SERIES_DEFINITIONS.map((series) => (
<Button <Button
@@ -1235,9 +1454,12 @@ const FinancialTooltip = ({ active, payload, label }: TooltipProps<number, strin
return null; return null;
} }
const resolvedLabel =
(payload[0]?.payload as ChartPoint | undefined)?.tooltipLabel ?? label;
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">
<p className="text-xs font-semibold text-gray-900 dark:text-gray-100">{label}</p> <p className="text-xs font-semibold text-gray-900 dark:text-gray-100">{resolvedLabel}</p>
<div className="mt-1 space-y-1 text-xs"> <div className="mt-1 space-y-1 text-xs">
{payload.map((entry, index) => { {payload.map((entry, index) => {
const key = (entry.dataKey ?? "") as ChartSeriesKey; const key = (entry.dataKey ?? "") as ChartSeriesKey;