fix(reports): correct chart labels and bucket rendering

This commit is contained in:
2026-04-27 16:43:54 +03:30
parent 233457f04e
commit ea793033df

View File

@@ -12,6 +12,26 @@ import {
import type { ChartReportResponse, CurrencyTotal, ReportChartBucket } from "../../api/reports"; import type { ChartReportResponse, CurrencyTotal, ReportChartBucket } from "../../api/reports";
import { useTranslation } from "../../hooks/useTranslation"; import { useTranslation } from "../../hooks/useTranslation";
const FA_MONTHS = [
"فروردین",
"اردیبهشت",
"خرداد",
"تیر",
"مرداد",
"شهریور",
"مهر",
"آبان",
"آذر",
"دی",
"بهمن",
"اسفند",
];
const normalizeDigits = (value: string) =>
value
.replace(/[۰-۹]/g, (digit) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(digit)))
.replace(/[٠-٩]/g, (digit) => String("٠١٢٣٤٥٦٧٨٩".indexOf(digit)));
const toPersianDigits = (value: string) => const toPersianDigits = (value: string) =>
value.replace(/\d/g, (digit) => "۰۱۲۳۴۵۶۷۸۹"[Number(digit)] || digit); value.replace(/\d/g, (digit) => "۰۱۲۳۴۵۶۷۸۹"[Number(digit)] || digit);
@@ -42,26 +62,53 @@ const formatIsoDate = (value: Date) => {
const monthKeyFromDate = (value: Date) => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}`; const monthKeyFromDate = (value: Date) => `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}`;
const labelFormatters = { const getPersianDateParts = (value: Date) => {
dayShort: (value: Date, lang: "en" | "fa") => const parts = new Intl.DateTimeFormat("fa-IR-u-ca-persian", {
new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", { year: "numeric",
weekday: "short", month: "numeric",
}).format(value), day: "numeric",
dayLong: (value: Date, lang: "en" | "fa") => }).formatToParts(value);
new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", {
weekday: "long", return {
month: "short", year: Number(normalizeDigits(parts.find((part) => part.type === "year")?.value || "")),
day: "numeric", month: Number(normalizeDigits(parts.find((part) => part.type === "month")?.value || "")),
}).format(value), day: Number(normalizeDigits(parts.find((part) => part.type === "day")?.value || "")),
monthShort: (value: Date, lang: "en" | "fa") => };
new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", { };
month: "short",
}).format(value), const getDailyAxisLabel = (date: Date, lang: "en" | "fa") => {
monthLong: (value: Date, lang: "en" | "fa") => if (lang === "fa") {
new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", { return toPersianDigits(String(getPersianDateParts(date).day));
month: "long", }
year: "numeric", return String(date.getDate());
}).format(value), };
const getDailyTooltipLabel = (date: Date, lang: "en" | "fa") =>
new Intl.DateTimeFormat(lang === "fa" ? "fa-IR-u-ca-persian" : "en-US", {
weekday: "long",
month: "long",
day: "numeric",
}).format(date);
const getMonthlyAxisLabel = (bucketKey: string, lang: "en" | "fa") => {
if (lang === "fa") {
const [, month] = bucketKey.split("-").map(Number);
return FA_MONTHS[(month || 1) - 1] || bucketKey;
}
const [year, month] = bucketKey.split("-").map(Number);
return new Intl.DateTimeFormat("en-US", { month: "short" }).format(new Date(year, (month || 1) - 1, 1));
};
const getMonthlyTooltipLabel = (bucketKey: string, lang: "en" | "fa") => {
if (lang === "fa") {
const [year, month] = bucketKey.split("-").map(Number);
const monthName = FA_MONTHS[(month || 1) - 1] || bucketKey;
return `${monthName} ${toPersianDigits(String(year))}`;
}
const [year, month] = bucketKey.split("-").map(Number);
return new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric" }).format(
new Date(year, (month || 1) - 1, 1),
);
}; };
const buildDailyBuckets = (fromDate: string, toDate: string, existing: ReportChartBucket[], lang: "en" | "fa") => { const buildDailyBuckets = (fromDate: string, toDate: string, existing: ReportChartBucket[], lang: "en" | "fa") => {
@@ -76,7 +123,7 @@ const buildDailyBuckets = (fromDate: string, toDate: string, existing: ReportCha
result.push( result.push(
existingBucket ?? { existingBucket ?? {
bucket_key: key, bucket_key: key,
bucket_label: labelFormatters.dayShort(cursor, lang), bucket_label: getDailyAxisLabel(cursor, lang),
total_seconds: 0, total_seconds: 0,
total_duration: "00:00:00", total_duration: "00:00:00",
}, },
@@ -86,12 +133,44 @@ const buildDailyBuckets = (fromDate: string, toDate: string, existing: ReportCha
return result.map((bucket) => ({ return result.map((bucket) => ({
...bucket, ...bucket,
bucket_label: labelFormatters.dayShort(parseIsoDate(bucket.bucket_key), lang), bucket_label: getDailyAxisLabel(parseIsoDate(bucket.bucket_key), lang),
})); }));
}; };
const buildMonthlyBuckets = (fromDate: string, toDate: string, existing: ReportChartBucket[], lang: "en" | "fa") => { const buildMonthlyBuckets = (fromDate: string, toDate: string, existing: ReportChartBucket[], lang: "en" | "fa") => {
const map = new Map(existing.map((bucket) => [bucket.bucket_key, bucket])); const map = new Map(existing.map((bucket) => [bucket.bucket_key, bucket]));
if (lang === "fa") {
const result: ReportChartBucket[] = [];
const start = getPersianDateParts(parseIsoDate(fromDate));
const end = getPersianDateParts(parseIsoDate(toDate));
let year = start.year;
let month = start.month;
while (year < end.year || (year === end.year && month <= end.month)) {
const key = `${year}-${String(month).padStart(2, "0")}`;
const existingBucket = map.get(key);
result.push(
existingBucket ?? {
bucket_key: key,
bucket_label: getMonthlyAxisLabel(key, lang),
total_seconds: 0,
total_duration: "00:00:00",
},
);
month += 1;
if (month > 12) {
month = 1;
year += 1;
}
}
return result.map((bucket) => ({
...bucket,
bucket_label: getMonthlyAxisLabel(bucket.bucket_key, lang),
}));
}
const result: ReportChartBucket[] = []; const result: ReportChartBucket[] = [];
const start = parseIsoDate(fromDate); const start = parseIsoDate(fromDate);
const end = parseIsoDate(toDate); const end = parseIsoDate(toDate);
@@ -104,7 +183,7 @@ const buildMonthlyBuckets = (fromDate: string, toDate: string, existing: ReportC
result.push( result.push(
existingBucket ?? { existingBucket ?? {
bucket_key: key, bucket_key: key,
bucket_label: labelFormatters.monthShort(cursor, lang), bucket_label: getMonthlyAxisLabel(key, lang),
total_seconds: 0, total_seconds: 0,
total_duration: "00:00:00", total_duration: "00:00:00",
}, },
@@ -112,24 +191,19 @@ const buildMonthlyBuckets = (fromDate: string, toDate: string, existing: ReportC
cursor.setMonth(cursor.getMonth() + 1); cursor.setMonth(cursor.getMonth() + 1);
} }
return result.map((bucket) => { return result.map((bucket) => ({
const [year, month] = bucket.bucket_key.split("-").map(Number); ...bucket,
const date = new Date(year, (month || 1) - 1, 1); bucket_label: getMonthlyAxisLabel(bucket.bucket_key, lang),
return { }));
...bucket,
bucket_label: labelFormatters.monthShort(date, lang),
};
});
}; };
const formatTooltipLabel = (payload: ReportChartBucket | undefined, lang: "en" | "fa", period: string) => { const formatTooltipLabel = (payload: ReportChartBucket | undefined, lang: "en" | "fa", period: string) => {
if (!payload) return ""; if (!payload) return "";
const useMonth = period === "this_year" || period === "half_year_first" || period === "half_year_second"; const useMonth = period === "this_year" || period === "half_year_first" || period === "half_year_second";
if (useMonth) { if (useMonth) {
const [year, month] = payload.bucket_key.split("-").map(Number); return getMonthlyTooltipLabel(payload.bucket_key, lang);
return labelFormatters.monthLong(new Date(year, (month || 1) - 1, 1), lang);
} }
return labelFormatters.dayLong(parseIsoDate(payload.bucket_key), lang); return getDailyTooltipLabel(parseIsoDate(payload.bucket_key), lang);
}; };
function ChartTooltip({ function ChartTooltip({
@@ -153,7 +227,7 @@ function ChartTooltip({
return ( return (
<div className="rounded-2xl border border-slate-200 bg-white/95 px-3 py-2 shadow-xl shadow-slate-200/60 backdrop-blur dark:border-slate-700 dark:bg-slate-900/95 dark:shadow-black/30"> <div className="rounded-2xl border border-slate-200 bg-white/95 px-3 py-2 shadow-xl shadow-slate-200/60 backdrop-blur dark:border-slate-700 dark:bg-slate-900/95 dark:shadow-black/30">
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">{label}</div> <div className="text-xs font-semibold text-slate-500 dark:text-slate-400">{label}</div>
<div className="mt-1 text-sm font-semibold text-slate-900 dark:text-white"> <div className="mt-1 text-sm font-semibold text-slate-900 dark:text-white">
{totalSecondsLabel}: {localizeDigits(hours.toFixed(hours >= 10 ? 0 : 1), lang)} {totalSecondsLabel}: {localizeDigits(hours.toFixed(hours >= 10 ? 0 : 1), lang)}
</div> </div>
@@ -181,7 +255,7 @@ export function ReportsChartPanel({
? buildMonthlyBuckets(data.scope.from_date, data.scope.to_date, data.buckets, lang) ? buildMonthlyBuckets(data.scope.from_date, data.scope.to_date, data.buckets, lang)
: buildDailyBuckets(data.scope.from_date, data.scope.to_date, data.buckets, lang); : buildDailyBuckets(data.scope.from_date, data.scope.to_date, data.buckets, lang);
const chartMinWidth = Math.max(640, buckets.length * (useMonthlyBuckets ? 88 : 42)); const chartMinWidth = Math.max(640, buckets.length * (useMonthlyBuckets ? 92 : 44));
const interval = useMonthlyBuckets ? 0 : buckets.length > 20 ? Math.ceil(buckets.length / 10) - 1 : 0; const interval = useMonthlyBuckets ? 0 : buckets.length > 20 ? Math.ceil(buckets.length / 10) - 1 : 0;
return ( return (
@@ -224,21 +298,23 @@ export function ReportsChartPanel({
<div className="overflow-x-auto pb-2"> <div className="overflow-x-auto pb-2">
<div className="h-[300px] min-w-full sm:h-[360px]" style={{ minWidth: `${chartMinWidth}px` }}> <div className="h-[300px] min-w-full sm:h-[360px]" style={{ minWidth: `${chartMinWidth}px` }}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={buckets} barCategoryGap={useMonthlyBuckets ? "28%" : "18%"} margin={{ top: 8, right: 12, bottom: 8, left: 0 }}> <BarChart data={buckets} barCategoryGap={useMonthlyBuckets ? "26%" : "18%"} margin={{ top: 8, right: 18, bottom: 16, left: 10 }}>
<CartesianGrid strokeDasharray="4 6" stroke="currentColor" className="text-slate-200 dark:text-slate-800" vertical={false} /> <CartesianGrid strokeDasharray="4 6" stroke="currentColor" className="text-slate-200 dark:text-slate-800" vertical={false} />
<XAxis <XAxis
dataKey="bucket_label" dataKey="bucket_label"
interval={interval} interval={interval}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
height={40} height={48}
tickMargin={10}
tick={{ fontSize: 11, fill: "#64748b" }} tick={{ fontSize: 11, fill: "#64748b" }}
/> />
<YAxis <YAxis
tickFormatter={(value) => formatSecondsTick(value, lang)} tickFormatter={(value) => formatSecondsTick(value, lang)}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
width={44} width={54}
tickMargin={10}
tick={{ fontSize: 11, fill: "#64748b" }} tick={{ fontSize: 11, fill: "#64748b" }}
/> />
<Tooltip <Tooltip