Refactoring charts in statistics in organization.

This commit is contained in:
charlesgauthereau
2025-12-30 16:22:34 +01:00
parent 475e404fae
commit f111946592
7 changed files with 329 additions and 121 deletions
@@ -1,47 +1,45 @@
"use client";
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
import { humanReadableDate } from "@/utils/date-formatting";
import {ChartConfig, ChartTooltip} from "@/components/ui/chart";
import {CartesianGrid, TooltipProps, XAxis, YAxis} from "recharts";
import {formatDayOnly} from "@/utils/date-formatting";
import {LineChartCustom} from "@/components/wrappers/dashboard/statistics/charts/line-chart";
import {generateFakeEvolutionData} from "@/components/wrappers/dashboard/statistics/charts/fake-data";
type Data = {
createdAt: Date;
};
export type evolutionLineChartProps = {
data: Data[];
type Payload = {
date: string;
count: number;
};
export function EvolutionLineChart(props: evolutionLineChartProps) {
const { data } = props;
type LineChartDatum = {
date: string
count: number
}
// const cumulativeData = data.reduce(
// (acc, backup) => {
// const date = backup.createdAt.toISOString().split("T")[0]; // Format as YYYY-MM-DD
//
// // Increment count for the current date or initialize it
// if (acc.length && acc[acc.length - 1].date === date) {
// acc[acc.length - 1].count += 1;
// } else {
// const lastCount = acc.length ? acc[acc.length - 1].count : 0;
// acc.push({ date, count: lastCount + 1 });
// }
//
// return acc;
// },
// [] as { date: string; count: number }[]
// );
const dailyData = data
export type EvolutionLineChartProps = {
data: Data[];
};
export function EvolutionLineChart(props: EvolutionLineChartProps) {
const {data} = props;
const fakeData = generateFakeEvolutionData(14, 2, 10)
const dailyData = fakeData
.reduce((acc, backup) => {
const date = backup.createdAt.toISOString().split("T")[0]; // Format: YYYY-MM-DD
const date = backup.createdAt.toISOString().split("T")[0];
// Find if the date already exists in the accumulator
const existing = acc.find(item => item.date === date);
if (existing) {
existing.count += 1;
} else {
acc.push({ date, count: 1 });
acc.push({date, count: 1});
}
return acc;
@@ -51,40 +49,66 @@ export function EvolutionLineChart(props: evolutionLineChartProps) {
const chartConfig = {
date: {
label: "Date",
color: "#2563eb",
},
count: {
label: "Number of backups",
color: "#60a5fa",
label: "Number of backups ",
},
} satisfies ChartConfig;
return (
<ChartContainer config={chartConfig}>
<LineChart
accessibilityLayer
data={dailyData}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => {
return humanReadableDate(new Date(value)).split(" ")[0]
}}
/>
<YAxis />
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
<Line dataKey="count" type="linear" stroke="#60a5fa" strokeWidth={2} dot={false} />
{/*<Line dataKey="count" type="linear" stroke="var(--color-desktop)" strokeWidth={2} dot={false} />*/}
</LineChart>
</ChartContainer>
<LineChartCustom<LineChartDatum>
config={chartConfig}
data={dailyData}
title="Evolution of the number of backups"
dataKey="count"
>
<CartesianGrid vertical={false}/>
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) =>
formatDayOnly(new Date(value))
}
/>
<YAxis tickLine={false}/>
<ChartTooltip
cursor={{strokeDasharray: "3 3"}}
content={<EvolutionTooltip/>}
/>
</LineChartCustom>
);
}
function EvolutionTooltip({
active,
payload,
}: TooltipProps<number, string>) {
if (!active || !payload || payload.length === 0) return null;
const data = payload[0].payload as Payload;
return (
<div className="rounded-lg border bg-background px-3 py-2 shadow-md">
<p className="text-sm font-medium">
{formatDayOnly(new Date(data.date))}
</p>
<div className="mt-1 flex items-center gap-2 text-sm">
<span className="h-2 w-2 rounded-full bg-[#fc6504]"/>
<span className="text-muted-foreground">Backups :</span>
<span className="ml-auto font-semibold">
{data.count}
</span>
</div>
</div>
);
}
@@ -0,0 +1,42 @@
type Data = {
createdAt: Date;
};
/**
* Generate fake backup events over a time range.
*
* @param days Number of days to generate
* @param minPerDay Minimum events per day
* @param maxPerDay Maximum events per day
*/
export function generateFakeEvolutionData(
days: number = 30,
minPerDay: number = 1,
maxPerDay: number = 8
): Data[] {
const result: Data[] = [];
const now = new Date();
for (let d = 0; d < days; d++) {
const day = new Date(now);
day.setDate(now.getDate() - d);
const events =
Math.floor(Math.random() * (maxPerDay - minPerDay + 1)) + minPerDay;
for (let i = 0; i < events; i++) {
const createdAt = new Date(day);
createdAt.setHours(
Math.floor(Math.random() * 24),
Math.floor(Math.random() * 60),
Math.floor(Math.random() * 60)
);
result.push({ createdAt });
}
}
return result.sort(
(a, b) => a.createdAt.getTime() - b.createdAt.getTime()
);
}
@@ -0,0 +1,106 @@
import {ChartConfig, ChartContainer} from "@/components/ui/chart";
import {Line, LineChart} from "recharts";
import {ReactNode, useState} from "react";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {PlaceholderChart} from "@/components/wrappers/dashboard/statistics/charts/utils/placeholder";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
type LineChartCustomProps<T> = {
config: ChartConfig,
data: T[],
children: ReactNode,
title: string,
dataKey: string,
margin?: { left: number; right: number }
}
export const LineChartCustom = <T extends { date: string }>({
config,
title,
data,
children,
dataKey,
margin
}: LineChartCustomProps<T>) => {
const [timeRange, setTimeRange] = useState("7d")
const filteredData = data.filter((item) => {
const date = new Date(item.date)
const referenceDate = new Date()
let daysToSubtract = 90
if (timeRange === "30d") {
daysToSubtract = 30
} else if (timeRange === "7d") {
daysToSubtract = 7
}
const startDate = new Date(referenceDate)
startDate.setDate(startDate.getDate() - daysToSubtract)
return date >= startDate
})
return (
<>
{data.length > 0 ? (
<Card className="w-full">
<CardHeader className="flex items-center gap-4 space-y-0 border-b py-0 sm:flex-row">
<CardTitle className="text-sm md:text-lg">{title}</CardTitle>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger
className=" rounded-lg sm:ml-auto sm:flex"
aria-label="Select a value"
>
<SelectValue placeholder="Last 3 months"/>
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="7d" className="rounded-lg">
Last 7 days
</SelectItem>
<SelectItem value="30d" className="rounded-lg">
Last 30 days
</SelectItem>
<SelectItem value="90d" className="rounded-lg">
Last 3 months
</SelectItem>
</SelectContent>
</Select>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6 md:pt-0 md:px-6 ">
<ChartContainer config={config}>
<LineChart
accessibilityLayer
data={filteredData}
margin={margin ? margin : {
left: -35,
right: 12,
}}
>
{children}
<Line
dataKey={dataKey}
type="linear"
strokeWidth={2}
stroke="#fc6504"
dot={false}
/>
</LineChart>
</ChartContainer>
</CardContent>
</Card>
) : (
<PlaceholderChart text="No backup data available"/>
)}
</>
)
}
@@ -1,8 +1,10 @@
"use client";
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
import { EStatusSchema } from "@/db/schema/types";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
import {ChartConfig, ChartTooltip} from "@/components/ui/chart";
import {EStatusSchema} from "@/db/schema/types";
import {LineChartCustom} from "@/components/wrappers/dashboard/statistics/charts/line-chart";
import {CartesianGrid, TooltipProps, XAxis, YAxis} from "recharts";
import {formatDayOnly} from "@/utils/date-formatting";
type Data = {
createdAt: Date;
@@ -10,31 +12,26 @@ type Data = {
_count: number;
};
type Payload = {
date: string
successRate: number
}
export type percentageLineChartProps = {
data: Data[];
};
export function PercentageLineChart(props: percentageLineChartProps) {
const { data } = props;
const {data} = props;
const chartConfig = {
date: {
label: "Date",
color: "#2563eb",
},
successRate: {
label: "Success Rate",
color: "#60a5fa",
},
} satisfies ChartConfig;
const dailyStats = data.reduce(
(acc, backup) => {
const date = backup.createdAt.toISOString().split("T")[0]; // Format YYYY-MM-DD
const date = backup.createdAt.toISOString().split("T")[0];
const status = backup.status;
if (!acc[date]) {
acc[date] = { success: 0, failed: 0, total: 0 };
acc[date] = {success: 0, failed: 0, total: 0};
}
acc[date][status === "success" ? "success" : "failed"] += backup._count;
@@ -45,34 +42,81 @@ export function PercentageLineChart(props: percentageLineChartProps) {
{} as Record<string, { success: number; failed: number; total: number }>
);
// Format data for the chart
const formattedData = Object.entries(dailyStats).map(([date, stats]) => ({
date,
successRate: (stats.success / stats.total) * 100,
}));
const chartConfig = {
date: {
label: "Date",
},
successRate: {
label: "Success Rate",
},
} satisfies ChartConfig;
return (
<ChartContainer config={chartConfig}>
<LineChart accessibilityLayer data={formattedData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis domain={[0, 100]} tickFormatter={(tick) => `${tick}%`} />
<ChartTooltip
content={<ChartTooltipContent />}
cursor={false}
defaultIndex={1}
formatter={(value, name) => (
<div className="flex min-w-[130px] items-center text-xs text-muted-foreground">
{chartConfig[name as keyof typeof chartConfig]?.label || name}
<div className="ml-auto flex items-baseline gap-0.5 font-mono font-medium tabular-nums text-foreground">
{value}
<span className="font-normal text-muted-foreground">%</span>
</div>
</div>
)}
/>
<Line type="step" dataKey="successRate" stroke="#8884d8" strokeWidth={2} />
</LineChart>
</ChartContainer>
<LineChartCustom<Payload>
title="Success rate of backups"
config={chartConfig}
data={formattedData}
dataKey="successRate"
margin={{
left: -15,
right: 12,
}}
>
{/*<CartesianGrid strokeDasharray="3 3" />*/}
<CartesianGrid vertical={false}/>
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) =>
formatDayOnly(new Date(value))
}
/>
<YAxis
tickLine={false}
domain={[0, 100]}
tickFormatter={(tick) => `${tick}%`}
/>
<ChartTooltip
defaultIndex={1}
cursor={{strokeDasharray: "3 3"}}
content={<PourcentTooltip/>}
/>
</LineChartCustom>
);
}
function PourcentTooltip({
active,
payload,
}: TooltipProps<number, string>) {
if (!active || !payload || payload.length === 0) return null;
const data = payload[0].payload as Payload;
return (
<div className="rounded-lg border bg-background px-3 py-2 shadow-md">
<p className="text-sm font-medium">
{formatDayOnly(new Date(data.date))}
</p>
<div className="mt-1 flex items-center gap-2 text-sm">
<span className="h-2 w-2 rounded-full bg-[#fc6504]"/>
<span className="text-muted-foreground">Success Rate :</span>
<span className="ml-auto font-semibold">
{data.successRate} %
</span>
</div>
</div>
);
}
@@ -0,0 +1,5 @@
export const PlaceholderChart = ({text}: { text: string }) => (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">{text}</div>
);
+9
View File
@@ -38,3 +38,12 @@ export function formatDateLastContact(lastContact: string | number | Date | null
: "Never connected.";
}
export function formatDayOnly(date: Date) {
return new Intl.DateTimeFormat(LOCALE, {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: TIMEZONE,
}).format(date);
}