add time range selector

This commit is contained in:
oiov
2025-03-28 20:47:29 +08:00
parent 755dbf9561
commit ee89917cde
10 changed files with 120 additions and 16 deletions
+37 -3
View File
@@ -10,6 +10,7 @@ import { WorldMapTopoJSON } from "@unovis/ts/maps";
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
import { getCountryName, getDeviceVendor } from "@/lib/contries";
import { DATE_DIMENSION_ENUMS } from "@/lib/enums";
import { isLink, removeUrlSuffix, timeAgo } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
@@ -24,6 +25,13 @@ import {
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const chartConfig = {
pv: {
@@ -132,7 +140,15 @@ function generateStatsList(
return statsList;
}
export function DailyPVUVChart({ data }: { data: UrlMeta[] }) {
export function DailyPVUVChart({
data,
timeRange,
setTimeRange,
}: {
data: UrlMeta[];
timeRange: string;
setTimeRange: React.Dispatch<React.SetStateAction<string>>;
}) {
const [activeChart, setActiveChart] =
React.useState<keyof typeof chartConfig>("pv");
@@ -194,12 +210,30 @@ export function DailyPVUVChart({ data }: { data: UrlMeta[] }) {
<Card className="rounded-t-none border-t-0">
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-2 sm:py-3">
<CardTitle>Daily Stats</CardTitle>
<CardTitle>Link Analytics</CardTitle>
<CardDescription>
Last visitor from {latestFrom} about {latestDate}.
</CardDescription>
</div>
<div className="flex">
<div className="flex items-center">
<Select
onValueChange={(value: string) => {
setTimeRange(value);
}}
name="time range"
defaultValue={timeRange}
>
<SelectTrigger className="mx-4 w-full shadow-inner">
<SelectValue placeholder="Select a time" />
</SelectTrigger>
<SelectContent>
{DATE_DIMENSION_ENUMS.map((e) => (
<SelectItem key={e.value} value={e.value}>
{e.label}
</SelectItem>
))}
</SelectContent>
</Select>
{["pv", "uv"].map((key) => {
const chart = key as keyof typeof chartConfig;
return (
+36 -4
View File
@@ -1,9 +1,18 @@
"use client";
import { useState } from "react";
import { UrlMeta, User } from "@prisma/client";
import useSWR from "swr";
import { DATE_DIMENSION_ENUMS } from "@/lib/enums";
import { fetcher } from "@/lib/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyPlaceholder } from "@/components/shared/empty-placeholder";
@@ -16,8 +25,9 @@ export interface UrlMetaProps {
}
export default function UserUrlMetaInfo({ user, action, urlId }: UrlMetaProps) {
const [timeRange, setTimeRange] = useState<string>("24h");
const { data, isLoading } = useSWR<UrlMeta[]>(
`${action}?id=${urlId}`,
`${action}?id=${urlId}&range=${timeRange}`,
fetcher,
{ focusThrottleInterval: 30000 }, // 30 seconds,
);
@@ -32,9 +42,27 @@ export default function UserUrlMetaInfo({ user, action, urlId }: UrlMetaProps) {
if (!data || data.length === 0) {
return (
<EmptyPlaceholder>
<EmptyPlaceholder.Title>No Stats</EmptyPlaceholder.Title>
<EmptyPlaceholder.Title>No Visits</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
You don&apos;t have any stats yet.
You don&apos;t have any visits yet in last {timeRange}.
<Select
onValueChange={(value: string) => {
setTimeRange(value);
}}
name="time range"
defaultValue={timeRange}
>
<SelectTrigger className="mt-4 w-full shadow-inner">
<SelectValue placeholder="Select a time" />
</SelectTrigger>
<SelectContent>
{DATE_DIMENSION_ENUMS.map((e) => (
<SelectItem key={e.value} value={e.value}>
{e.label}
</SelectItem>
))}
</SelectContent>
</Select>
</EmptyPlaceholder.Description>
</EmptyPlaceholder>
);
@@ -42,7 +70,11 @@ export default function UserUrlMetaInfo({ user, action, urlId }: UrlMetaProps) {
return (
<div className="animate-fade-down rounded-t-none">
<DailyPVUVChart data={data} />
<DailyPVUVChart
data={data}
timeRange={timeRange}
setTimeRange={setTimeRange}
/>
</div>
);
}
+2 -1
View File
@@ -9,6 +9,7 @@ export async function GET(req: Request) {
const url = new URL(req.url);
const urlId = url.searchParams.get("id");
const range = url.searchParams.get("range") || "24h";
if (!urlId) {
return Response.json("url id is required", {
@@ -16,7 +17,7 @@ export async function GET(req: Request) {
});
}
const data = await getUserUrlMetaInfo(urlId);
const data = await getUserUrlMetaInfo(urlId, range);
return Response.json(data);
} catch (error) {
+2 -2
View File
@@ -116,7 +116,7 @@ export function RecordForm({
});
if (!response.ok || response.status !== 200) {
toast.error("Update Failed", {
description: response.statusText,
description: await response.text(),
});
} else {
const res = await response.json();
@@ -142,7 +142,7 @@ export function RecordForm({
});
if (!response.ok || response.status !== 200) {
toast.error("Delete Failed", {
description: response.statusText,
description: await response.text(),
});
} else {
await response.json();
+3 -3
View File
@@ -88,7 +88,7 @@ export function UrlForm({
});
if (!response.ok || response.status !== 200) {
toast.error("Created Failed!", {
description: await response.json(),
description: await response.text(),
});
} else {
// const res = await response.json();
@@ -108,7 +108,7 @@ export function UrlForm({
});
if (!response.ok || response.status !== 200) {
toast.error("Update Failed", {
description: await response.json(),
description: await response.text(),
});
} else {
const res = await response.json();
@@ -132,7 +132,7 @@ export function UrlForm({
});
if (!response.ok || response.status !== 200) {
toast.error("Delete Failed", {
description: await response.json(),
description: await response.text(),
});
} else {
await response.json();
+1 -1
View File
@@ -203,7 +203,7 @@ export const countryMap = {
MO: "Macao,China",
PS: "Palestine, State of",
PR: "Puerto Rico",
TW: "Taiwan",
TW: "Taiwan,China",
XK: "Kosovo",
};
+11 -1
View File
@@ -3,6 +3,8 @@ import { UrlMeta, UserRole } from "@prisma/client";
import { prisma } from "@/lib/db";
import { getStartDate } from "../utils";
export interface ShortUrlFormData {
id?: string;
userId: string;
@@ -191,10 +193,18 @@ export async function deleteUserShortUrl(userId: string, urlId: string) {
});
}
export async function getUserUrlMetaInfo(urlId: string) {
export async function getUserUrlMetaInfo(
urlId: string,
dateRange: string = "",
) {
const startDate = getStartDate(dateRange);
return await prisma.urlMeta.findMany({
where: {
urlId,
...(startDate && {
createdAt: { gte: startDate },
}),
},
orderBy: { updatedAt: "asc" },
});
+20
View File
@@ -212,3 +212,23 @@ export const LOGS_LIMITEs_ENUMS = [
label: "1000",
},
];
export const TIME_RANGES: Record<string, number> = {
"24h": 24 * 60 * 60 * 1000, // 24小时的毫秒数
"7d": 7 * 24 * 60 * 60 * 1000,
"30d": 30 * 24 * 60 * 60 * 1000,
"90d": 90 * 24 * 60 * 60 * 1000,
"180d": 180 * 24 * 60 * 60 * 1000,
"365d": 365 * 24 * 60 * 60 * 1000,
};
export const DATE_DIMENSION_ENUMS = [
{ value: "24h", label: "Last 24 Hours" },
{ value: "7d", label: "Last 7 Days" },
{ value: "30d", label: "Last 30 Days" },
{ value: "60d", label: "Last 2 Months" },
{ value: "90d", label: "Last 3 Months" },
{ value: "180d", label: "Last 6 Months" },
{ value: "365d", label: "Last 1 Year" },
{ value: "All", label: "All the time" },
] as const;
+7
View File
@@ -9,6 +9,8 @@ import UAParser from "ua-parser-js";
import { env } from "@/env.mjs";
import { siteConfig } from "@/config/site";
import { TIME_RANGES } from "./enums";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
@@ -261,3 +263,8 @@ export function toCamelCase(str: string) {
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join("");
}
export const getStartDate = (range: string): Date | undefined => {
if (!range || !(range in TIME_RANGES)) return undefined;
return new Date(Date.now() - TIME_RANGES[range]);
};
+1 -1
View File
File diff suppressed because one or more lines are too long