mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Refactoring charts in statistics in organization.
This commit is contained in:
@@ -78,9 +78,6 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
const Placeholder = ({text}: { text: string }) => (
|
|
||||||
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">{text}</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
@@ -125,31 +122,12 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-4">
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
<Card className="w-full">
|
<EvolutionLineChart
|
||||||
<CardHeader>
|
data={sortedBackupsEvolution}
|
||||||
<CardTitle>Evolution of the number of backups</CardTitle>
|
/>
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{sortedBackupsEvolution.length > 0 ? (
|
|
||||||
<EvolutionLineChart data={sortedBackupsEvolution}/>
|
|
||||||
) : (
|
|
||||||
<Placeholder text="No backup data available"/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="w-full">
|
<PercentageLineChart
|
||||||
<CardHeader>
|
data={backupsRate}/>
|
||||||
<CardTitle>Success rate of backups</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{backupsRate.length > 0 ? (
|
|
||||||
<PercentageLineChart data={backupsRate}/>
|
|
||||||
) : (
|
|
||||||
<Placeholder text="No backup rate data available"/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Page>
|
</Page>
|
||||||
|
|||||||
@@ -1,47 +1,45 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
|
import {ChartConfig, ChartTooltip} from "@/components/ui/chart";
|
||||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
import {CartesianGrid, TooltipProps, XAxis, YAxis} from "recharts";
|
||||||
import { humanReadableDate } from "@/utils/date-formatting";
|
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 = {
|
type Data = {
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type evolutionLineChartProps = {
|
type Payload = {
|
||||||
data: Data[];
|
date: string;
|
||||||
|
count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function EvolutionLineChart(props: evolutionLineChartProps) {
|
type LineChartDatum = {
|
||||||
const { data } = props;
|
date: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
// const cumulativeData = data.reduce(
|
export type EvolutionLineChartProps = {
|
||||||
// (acc, backup) => {
|
data: Data[];
|
||||||
// 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) {
|
export function EvolutionLineChart(props: EvolutionLineChartProps) {
|
||||||
// acc[acc.length - 1].count += 1;
|
const {data} = props;
|
||||||
// } else {
|
|
||||||
// const lastCount = acc.length ? acc[acc.length - 1].count : 0;
|
const fakeData = generateFakeEvolutionData(14, 2, 10)
|
||||||
// acc.push({ date, count: lastCount + 1 });
|
|
||||||
// }
|
const dailyData = fakeData
|
||||||
//
|
|
||||||
// return acc;
|
|
||||||
// },
|
|
||||||
// [] as { date: string; count: number }[]
|
|
||||||
// );
|
|
||||||
const dailyData = data
|
|
||||||
.reduce((acc, backup) => {
|
.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);
|
const existing = acc.find(item => item.date === date);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.count += 1;
|
existing.count += 1;
|
||||||
} else {
|
} else {
|
||||||
acc.push({ date, count: 1 });
|
acc.push({date, count: 1});
|
||||||
}
|
}
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
@@ -51,40 +49,66 @@ export function EvolutionLineChart(props: evolutionLineChartProps) {
|
|||||||
const chartConfig = {
|
const chartConfig = {
|
||||||
date: {
|
date: {
|
||||||
label: "Date",
|
label: "Date",
|
||||||
color: "#2563eb",
|
|
||||||
},
|
},
|
||||||
count: {
|
count: {
|
||||||
label: "Number of backups",
|
label: "Number of backups ",
|
||||||
color: "#60a5fa",
|
|
||||||
},
|
},
|
||||||
} satisfies ChartConfig;
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
return (
|
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} />*/}
|
<LineChartCustom<LineChartDatum>
|
||||||
</LineChart>
|
config={chartConfig}
|
||||||
</ChartContainer>
|
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";
|
"use client";
|
||||||
|
|
||||||
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart";
|
import {ChartConfig, ChartTooltip} from "@/components/ui/chart";
|
||||||
import { EStatusSchema } from "@/db/schema/types";
|
import {EStatusSchema} from "@/db/schema/types";
|
||||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
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 = {
|
type Data = {
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -10,31 +12,26 @@ type Data = {
|
|||||||
_count: number;
|
_count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Payload = {
|
||||||
|
date: string
|
||||||
|
successRate: number
|
||||||
|
}
|
||||||
|
|
||||||
export type percentageLineChartProps = {
|
export type percentageLineChartProps = {
|
||||||
data: Data[];
|
data: Data[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PercentageLineChart(props: percentageLineChartProps) {
|
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(
|
const dailyStats = data.reduce(
|
||||||
(acc, backup) => {
|
(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;
|
const status = backup.status;
|
||||||
|
|
||||||
if (!acc[date]) {
|
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;
|
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 }>
|
{} as Record<string, { success: number; failed: number; total: number }>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Format data for the chart
|
|
||||||
const formattedData = Object.entries(dailyStats).map(([date, stats]) => ({
|
const formattedData = Object.entries(dailyStats).map(([date, stats]) => ({
|
||||||
date,
|
date,
|
||||||
successRate: (stats.success / stats.total) * 100,
|
successRate: (stats.success / stats.total) * 100,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
date: {
|
||||||
|
label: "Date",
|
||||||
|
},
|
||||||
|
successRate: {
|
||||||
|
label: "Success Rate",
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChartContainer config={chartConfig}>
|
|
||||||
<LineChart accessibilityLayer data={formattedData}>
|
<LineChartCustom<Payload>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
title="Success rate of backups"
|
||||||
<XAxis dataKey="date" />
|
config={chartConfig}
|
||||||
<YAxis domain={[0, 100]} tickFormatter={(tick) => `${tick}%`} />
|
data={formattedData}
|
||||||
<ChartTooltip
|
dataKey="successRate"
|
||||||
content={<ChartTooltipContent />}
|
margin={{
|
||||||
cursor={false}
|
left: -15,
|
||||||
defaultIndex={1}
|
right: 12,
|
||||||
formatter={(value, name) => (
|
}}
|
||||||
<div className="flex min-w-[130px] items-center text-xs text-muted-foreground">
|
>
|
||||||
{chartConfig[name as keyof typeof chartConfig]?.label || name}
|
{/*<CartesianGrid strokeDasharray="3 3" />*/}
|
||||||
<div className="ml-auto flex items-baseline gap-0.5 font-mono font-medium tabular-nums text-foreground">
|
<CartesianGrid vertical={false}/>
|
||||||
{value}
|
<XAxis
|
||||||
<span className="font-normal text-muted-foreground">%</span>
|
dataKey="date"
|
||||||
</div>
|
tickLine={false}
|
||||||
</div>
|
axisLine={false}
|
||||||
)}
|
tickMargin={8}
|
||||||
/>
|
tickFormatter={(value) =>
|
||||||
<Line type="step" dataKey="successRate" stroke="#8884d8" strokeWidth={2} />
|
formatDayOnly(new Date(value))
|
||||||
</LineChart>
|
}
|
||||||
</ChartContainer>
|
/>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
@@ -38,3 +38,12 @@ export function formatDateLastContact(lastContact: string | number | Date | null
|
|||||||
: "Never connected.";
|
: "Never connected.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatDayOnly(date: Date) {
|
||||||
|
return new Intl.DateTimeFormat(LOCALE, {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
timeZone: TIMEZONE,
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user