add time range selector
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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't have any stats yet.
|
||||
You don'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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" },
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user