From 9b2c3d402173678dddc942fe682f40d7c5ec1497 Mon Sep 17 00:00:00 2001 From: celine Date: Thu, 21 May 2026 21:03:53 -0400 Subject: [PATCH] updating styles for the anaytics page --- .../dashboard/PublicationAnalytics.tsx | 859 ------------------ .../dashboard/analytics/ChartTooltip.tsx | 22 + .../analytics/PublicationAnalytics.tsx | 233 +++++ .../dashboard/analytics/Skeletons.tsx | 45 + .../dashboard/analytics/SubscribersChart.tsx | 71 ++ .../dashboard/analytics/TrafficChart.tsx | 349 +++++++ .../dashboard/analytics/dates.tsx | 213 +++++ .../dashboard/analytics/page.tsx | 18 +- components/Combobox.tsx | 2 +- components/DatePicker.tsx | 2 +- components/Icons/CalendarTiny.tsx | 21 + 11 files changed, 968 insertions(+), 867 deletions(-) delete mode 100644 app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx create mode 100644 app/lish/[did]/[publication]/dashboard/analytics/ChartTooltip.tsx create mode 100644 app/lish/[did]/[publication]/dashboard/analytics/PublicationAnalytics.tsx create mode 100644 app/lish/[did]/[publication]/dashboard/analytics/Skeletons.tsx create mode 100644 app/lish/[did]/[publication]/dashboard/analytics/SubscribersChart.tsx create mode 100644 app/lish/[did]/[publication]/dashboard/analytics/TrafficChart.tsx create mode 100644 app/lish/[did]/[publication]/dashboard/analytics/dates.tsx create mode 100644 components/Icons/CalendarTiny.tsx diff --git a/app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx b/app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx deleted file mode 100644 index a70155f3..00000000 --- a/app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx +++ /dev/null @@ -1,859 +0,0 @@ -import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; -import { UpgradeContent } from "../UpgradeModal"; -import { Popover } from "components/Popover"; -import { DatePicker } from "components/DatePicker"; -import { Fragment, useCallback, useMemo, useState } from "react"; -import { useLocalizedDate } from "src/hooks/useLocalizedDate"; -import type { DateRange } from "react-day-picker"; -import { useQueryState } from "src/hooks/useQueryState"; -import { usePublicationData } from "./PublicationSWRProvider"; -import { Combobox, ComboboxResult } from "components/Combobox"; -import { useIsPro, useCanSeePro } from "src/hooks/useEntitlement"; -import { callRPC } from "app/api/rpc/client"; -import useSWR from "swr"; -import { - AreaChart, - Area, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, -} from "recharts"; - -type ReferrerType = { referrer_host: string; pageviews: number }; -type TrafficMetric = "pageviews" | "visitors"; -type DatePreset = "Last Week" | "Last Month" | "All Time"; -export type DateSelection = { range: DateRange; preset: DatePreset | null }; - -// URL params: -// ?date=all → All Time (no from/to bounds) -// ?date=month → Last Month preset -// ?from=YYYY-MM-DD&to=YYYY-MM-DD → custom range -// (none) → default: Last Week -export function useAnalyticsDateState() { - return useQueryState({ - fromParams: (get) => { - let preset = get("date"); - if (preset === "all") - return { - range: { from: undefined, to: undefined }, - preset: "All Time", - }; - if (preset === "month") { - let from = new Date(); - from.setMonth(from.getMonth() - 1); - return { range: { from, to: new Date() }, preset: "Last Month" }; - } - let fromParam = get("from"); - let toParam = get("to"); - if (fromParam || toParam) { - return { - range: { - from: fromParam ? new Date(fromParam) : undefined, - to: toParam ? new Date(toParam) : new Date(), - }, - preset: null, - }; - } - let from = new Date(); - from.setDate(from.getDate() - 7); - return { range: { from, to: new Date() }, preset: "Last Week" }; - }, - toParams: ({ preset, range }) => { - if (preset === "All Time") - return { date: "all", from: null, to: null }; - if (preset === "Last Month") - return { date: "month", from: null, to: null }; - if (preset === "Last Week") return { date: null, from: null, to: null }; - return { - date: null, - from: range.from?.toISOString().slice(0, 10) ?? null, - to: range.to?.toISOString().slice(0, 10) ?? null, - }; - }, - }); -} - -const dayTickFormatter = new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", -}); - -function formatDayTick(value: string) { - let d = new Date(value + "T00:00:00"); - if (isNaN(d.getTime())) return value; - return dayTickFormatter.format(d); -} - -function formatYTick(value: number) { - if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; - if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; - return value.toLocaleString(); -} - -function niceYMax(dataMax: number): number { - if (dataMax <= 0) return 10; - let rough = dataMax / 4; - let mag = Math.pow(10, Math.floor(Math.log10(rough))); - let norm = rough / mag; - let step = - norm <= 1.5 ? mag : norm <= 3 ? 2 * mag : norm <= 7 ? 5 * mag : 10 * mag; - return Math.ceil(dataMax / step) * step + step; -} - -function endOfDay(date: Date): Date { - let d = new Date(date); - d.setHours(23, 59, 59, 999); - return d; -} - -function fillDailyGaps( - data: T[], - fill: (day: string) => T, - from?: Date, - to?: Date, -): T[] { - let start = from || (data.length > 0 ? new Date(data[0].day) : null); - let end = - to || (data.length > 0 ? new Date(data[data.length - 1].day) : null); - if (!start || !end) return data; - - let lookup = new Map(data.map((d) => [d.day, d])); - let result: T[] = []; - let cursor = new Date(start); - cursor.setHours(0, 0, 0, 0); - let endDate = new Date(end); - endDate.setHours(0, 0, 0, 0); - - while (cursor <= endDate) { - let key = cursor.toISOString().slice(0, 10); - result.push(lookup.get(key) ?? fill(key)); - cursor.setDate(cursor.getDate() + 1); - } - return result; -} - -export const PublicationAnalytics = (props: { - showPageBackground: boolean; - dateState: DateSelection; -}) => { - let isPro = useIsPro(); - let canSeePro = useCanSeePro(); - - let { data: publication } = usePublicationData(); - - let { dateState } = props; - let { range: dateRange } = dateState; - - // ?post= — filter traffic to a single published post (path is the stable ID) - let [selectedPostPath, setSelectedPostPath] = useQueryState< - string | undefined - >({ - fromParams: (get) => get("post") ?? undefined, - toParams: (v) => ({ post: v ?? null }), - }); - let documents = publication?.documents; - let selectedPost = useMemo(() => { - if (!selectedPostPath) return undefined; - let doc = documents?.find( - (d) => (d.record.path || "") === selectedPostPath, - ); - return { - title: doc?.record.title || selectedPostPath, - path: selectedPostPath, - }; - }, [selectedPostPath, documents]); - let setSelectedPost = useCallback( - (post: { title: string; path: string } | undefined) => { - setSelectedPostPath(post?.path); - }, - [setSelectedPostPath], - ); - - // ?referrer= — filter traffic to a single referring domain - let [selectedReferrer, setSelectedReferrer] = useQueryState< - string | undefined - >({ - fromParams: (get) => get("referrer") ?? undefined, - toParams: (v) => ({ referrer: v ?? null }), - }); - - // ?metric=pageviews — toggle between "visitors" (default) and "pageviews" - let [trafficMetric, setTrafficMetric] = useQueryState({ - fromParams: (get) => - get("metric") === "pageviews" ? "pageviews" : "visitors", - toParams: (v) => ({ metric: v === "visitors" ? null : v }), - }); - - let publicationUri = publication?.publication?.uri; - - let { data: analyticsData, isLoading: analyticsLoading } = useSWR( - publicationUri - ? [ - "publication-analytics", - publicationUri, - dateRange.from?.toISOString(), - dateRange.to?.toISOString(), - selectedPost?.path, - selectedReferrer, - ] - : null, - async () => { - let res = await callRPC("get_publication_analytics", { - publication_uri: publicationUri!, - ...(dateRange.from ? { from: dateRange.from.toISOString() } : {}), - ...(dateRange.to ? { to: endOfDay(dateRange.to).toISOString() } : {}), - ...(selectedPost ? { path: `/${selectedPost.path}` } : {}), - ...(selectedReferrer ? { referrer_host: selectedReferrer } : {}), - }); - return res?.result; - }, - ); - - let { data: subscribersData, isLoading: subscribersLoading } = useSWR( - publicationUri - ? [ - "publication-subscribers-timeseries", - publicationUri, - dateRange.from?.toISOString(), - dateRange.to?.toISOString(), - ] - : null, - async () => { - let res = await callRPC("get_publication_subscribers_timeseries", { - publication_uri: publicationUri!, - ...(dateRange.from ? { from: dateRange.from.toISOString() } : {}), - ...(dateRange.to ? { to: endOfDay(dateRange.to).toISOString() } : {}), - }); - return res?.result; - }, - ); - - let filledTraffic = useMemo( - () => - fillDailyGaps( - analyticsData?.traffic || [], - (day) => ({ day, pageviews: 0, visitors: 0 }), - dateRange.from, - dateRange.to ?? new Date(), - ), - [analyticsData?.traffic, dateRange.from, dateRange.to], - ); - - if (!canSeePro) return null; - - if (!isPro) - return ( -
- -
- ); - - return ( -
-
-
-

Traffic

- - - - {selectedReferrer && ( - <> - -
- {" "} - {selectedReferrer} -
- - )} -
- -
- - -
-
-
-

Subscribers

- -
-
- ); -}; - -const ChartSkeleton = () => ( -
- - - - - - - -
-); - -const ListSkeleton = ({ rows = 4 }: { rows?: number }) => ( -
- {Array.from({ length: rows }).map((_, i) => ( -
-
-
-
- ))} -
-); - -const SubscribersChart = (props: { - data: { day: string; total_subscribers: number }[]; - isLoading: boolean; -}) => { - if (props.isLoading) { - return ; - } - if (props.data.length === 0) { - return ( -
- No subscriber data -
- ); - } - return ( -
- - - - - niceYMax(max)]} - /> - - - - -
- ); -}; - -const TrafficChart = (props: { - data: { day: string; pageviews: number; visitors: number }[]; - isLoading: boolean; - metric: TrafficMetric; -}) => { - let today = new Date().toISOString().slice(0, 10); - let metricLabel = props.metric === "pageviews" ? "pageviews" : "visitors"; - - let chartData = useMemo(() => { - if (props.data.length === 0) return []; - return props.data.map((d, i) => { - let value = d[props.metric]; - let isToday = d.day === today; - let isBeforeToday = - i === props.data.length - 2 && - props.data[props.data.length - 1]?.day === today; - return { - day: d.day, - // Solid line: all points except today - complete: isToday ? undefined : value, - // Dashed line: bridge from yesterday to today - partial: isToday || isBeforeToday ? value : undefined, - }; - }); - }, [props.data, props.metric, today]); - - if (props.isLoading) { - return ; - } - if (props.data.length === 0) { - return ( -
- No traffic data -
- ); - } - return ( -
- - - - - niceYMax(max)]} - /> - { - if (!active || !payload?.length) return null; - let pageviews = - payload.find((p) => p.dataKey === "complete")?.value ?? - payload.find((p) => p.dataKey === "partial")?.value; - return ( -
-
- {formatDayTick(String(label))} -
-
- {Number(pageviews).toLocaleString()} {metricLabel} -
-
- ); - }} - /> - - -
-
-
- ); -}; - -const MetricToggle = (props: { - metric: TrafficMetric; - setMetric: (m: TrafficMetric) => void; -}) => { - let options: { value: TrafficMetric; label: string }[] = [ - { value: "visitors", label: "Visitors" }, - { value: "pageviews", label: "Pageviews" }, - ]; - return ( -
- {options.map((opt) => ( - - ))} -
- ); -}; - -const PostSelector = (props: { - selectedPost: { title: string; path: string } | undefined; - setSelectedPost: (s: { title: string; path: string } | undefined) => void; -}) => { - let { data } = usePublicationData(); - let { documents } = data || {}; - - let posts = useMemo( - () => - documents?.map((doc) => ({ - title: doc.record.title, - path: doc.record.path || "", - })), - [documents], - ); - - let [highlighted, setHighlighted] = useState(undefined); - let [searchValue, setSearchValue] = useState(""); - - let postTitles = posts?.map((p) => p.title); - let filteredTitles = useMemo( - () => - postTitles && - postTitles.filter((title) => - title.toLowerCase().includes(searchValue.toLowerCase()), - ), - [searchValue, postTitles], - ); - - let filteredTitlesWithClear = ["All Posts", ...(filteredTitles || [])]; - - return ( - - {props.selectedPost?.title ?? "All Posts"} - - } - results={filteredTitlesWithClear || []} - highlighted={highlighted} - setHighlighted={setHighlighted} - onSelect={() => { - if (highlighted === "All Posts" || !highlighted) { - props.setSelectedPost(undefined); - } else { - let post = posts?.find((p) => p.title === highlighted); - props.setSelectedPost(post); - } - }} - sideOffset={2} - searchValue={searchValue} - setSearchValue={setSearchValue} - showSearch - > - {filteredTitlesWithClear.map((title) => { - if (title === "All Posts") - return ( - <> - { - props.setSelectedPost(undefined); - }} - highlighted={highlighted} - setHighlighted={setHighlighted} - > - All Posts - - {filteredTitles && filteredTitles.length !== 0 && ( -
- )} - - ); - return ( - { - let post = posts?.find((p) => p.title === title); - props.setSelectedPost(post); - }} - highlighted={highlighted} - setHighlighted={setHighlighted} - > - {title} - - ); - })} -
- ); -}; - -export const DateRangeSelector = (props: { - pubStartDate: string | undefined; - dateState: DateSelection; - setDateState: (state: DateSelection) => void; - showBackground?: boolean; -}) => { - let { range: dateRange, preset: datePreset } = props.dateState; - let buttonClass = - "rounded-md px-1 text-sm border border-accent-contrast text-accent-contrast"; - - let currentDate = new Date(); - - let startDate = useLocalizedDate( - dateRange.from?.toISOString() || - props.pubStartDate || - "2025-01-01T12:00:00.000Z", - { - month: "short", - day: "numeric", - }, - ); - - let endDate = useLocalizedDate( - (dateRange.to ?? currentDate).toISOString(), - { - month: "short", - day: "numeric", - }, - ); - - return ( - - {datePreset - ? datePreset - : dateRange.from === undefined - ? "All Time" - : `${startDate} - ${endDate}`} -
- } - > -
- - - -
-
- { - if (range) props.setDateState({ range, preset: null }); - }} - disabled={(date) => date > new Date()} - /> - - ); -}; - -const TopPages = (props: { - pages: { path: string; pageviews: number }[]; - selectedPost: { title: string; path: string } | undefined; - setSelectedPost: (s: { title: string; path: string } | undefined) => void; - isLoading: boolean; -}) => { - let { data } = usePublicationData(); - let docsByPath = useMemo(() => { - let map = new Map(); - for (let doc of data?.documents || []) { - let path = doc.record.path || ""; - let normalized = path.startsWith("/") ? path : `/${path}`; - map.set(normalized, { title: doc.record.title, path }); - } - return map; - }, [data?.documents]); - - return ( -
-

Top Pages

-
- {props.isLoading && } - {!props.isLoading && props.pages.length === 0 && ( -
No page data
- )} - {props.pages.map((page) => { - let doc = docsByPath.get(page.path); - let isSelected = selectedPostPath(props.selectedPost) === page.path; - return ( - - -
-
- ); - })} -
-
- ); -}; - -function selectedPostPath( - post: { title: string; path: string } | undefined, -): string | undefined { - if (!post) return undefined; - return post.path.startsWith("/") ? post.path : `/${post.path}`; -} - -const TopReferrors = (props: { - refferors: ReferrerType[]; - setSelectedReferrer: (host: string | undefined) => void; - selectedReferrer: string | undefined; - isLoading: boolean; -}) => { - return ( -
-

Top Referrers

-
- {props.isLoading && } - {!props.isLoading && props.refferors.length === 0 && ( -
No referrer data
- )} - {props.refferors.map((ref) => { - let selected = props.selectedReferrer === ref.referrer_host; - return ( - - -
-
- ); - })} -
-
- ); -}; diff --git a/app/lish/[did]/[publication]/dashboard/analytics/ChartTooltip.tsx b/app/lish/[did]/[publication]/dashboard/analytics/ChartTooltip.tsx new file mode 100644 index 00000000..7687bb44 --- /dev/null +++ b/app/lish/[did]/[publication]/dashboard/analytics/ChartTooltip.tsx @@ -0,0 +1,22 @@ +import { formatDayTick } from "./dates"; + +export const ChartTooltip = (props: { + active?: boolean; + payload?: ReadonlyArray<{ value?: number | string; dataKey?: string | number }>; + label?: string | number; + unit: string; +}) => { + if (!props.active || !props.payload?.length) return null; + let value = props.payload.find((p) => p.value != null)?.value; + if (value == null) return null; + return ( +
+
+ {formatDayTick(String(props.label))} +
+
+ {Number(value).toLocaleString()} {props.unit} +
+
+ ); +}; diff --git a/app/lish/[did]/[publication]/dashboard/analytics/PublicationAnalytics.tsx b/app/lish/[did]/[publication]/dashboard/analytics/PublicationAnalytics.tsx new file mode 100644 index 00000000..7c555e6d --- /dev/null +++ b/app/lish/[did]/[publication]/dashboard/analytics/PublicationAnalytics.tsx @@ -0,0 +1,233 @@ +import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; +import { UpgradeContent } from "../../UpgradeModal"; +import { useCallback, useMemo } from "react"; +import { useQueryState } from "src/hooks/useQueryState"; +import { usePublicationData } from "../PublicationSWRProvider"; +import { useIsPro, useCanSeePro } from "src/hooks/useEntitlement"; +import { callRPC } from "app/api/rpc/client"; +import useSWR from "swr"; +import { DateSelection, endOfDay, fillDailyGaps } from "./dates"; +import { + MetricToggle, + PostSelector, + TopPages, + TopReferrors, + TrafficChart, + TrafficMetric, +} from "./TrafficChart"; +import { SubscribersChart } from "./SubscribersChart"; + +export const PublicationAnalytics = (props: { + showPageBackground: boolean; + dateState: DateSelection; +}) => { + let isPro = useIsPro(); + let canSeePro = useCanSeePro(); + + let { data: publication } = usePublicationData(); + + let { dateState } = props; + let { range: dateRange } = dateState; + + // ?post= — filter traffic to a single published post (path is the stable ID) + let [selectedPostPath, setSelectedPostPath] = useQueryState< + string | undefined + >({ + fromParams: (get) => get("post") ?? undefined, + toParams: (v) => ({ post: v ?? null }), + }); + let documents = publication?.documents; + let selectedPost = useMemo(() => { + if (!selectedPostPath) return undefined; + let doc = documents?.find( + (d) => (d.record.path || "") === selectedPostPath, + ); + return { + title: doc?.record.title || selectedPostPath, + path: selectedPostPath, + }; + }, [selectedPostPath, documents]); + let setSelectedPost = useCallback( + (post: { title: string; path: string } | undefined) => { + setSelectedPostPath(post?.path); + }, + [setSelectedPostPath], + ); + + // ?referrer= — filter traffic to a single referring domain + let [selectedReferrer, setSelectedReferrer] = useQueryState< + string | undefined + >({ + fromParams: (get) => get("referrer") ?? undefined, + toParams: (v) => ({ referrer: v ?? null }), + }); + + // ?metric=pageviews — toggle between "visitors" (default) and "pageviews" + let [trafficMetric, setTrafficMetric] = useQueryState({ + fromParams: (get) => + get("metric") === "pageviews" ? "pageviews" : "visitors", + toParams: (v) => ({ metric: v === "visitors" ? null : v }), + }); + + let publicationUri = publication?.publication?.uri; + + let { data: analyticsData, isLoading: analyticsLoading } = useSWR( + publicationUri + ? [ + "publication-analytics", + publicationUri, + dateRange.from?.toISOString(), + dateRange.to?.toISOString(), + selectedPost?.path, + selectedReferrer, + ] + : null, + async () => { + let res = await callRPC("get_publication_analytics", { + publication_uri: publicationUri!, + ...(dateRange.from ? { from: dateRange.from.toISOString() } : {}), + ...(dateRange.to ? { to: endOfDay(dateRange.to).toISOString() } : {}), + ...(selectedPost ? { path: `/${selectedPost.path}` } : {}), + ...(selectedReferrer ? { referrer_host: selectedReferrer } : {}), + }); + return res?.result; + }, + ); + + let { data: subscribersData, isLoading: subscribersLoading } = useSWR( + publicationUri + ? [ + "publication-subscribers-timeseries", + publicationUri, + dateRange.from?.toISOString(), + dateRange.to?.toISOString(), + ] + : null, + async () => { + let res = await callRPC("get_publication_subscribers_timeseries", { + publication_uri: publicationUri!, + ...(dateRange.from ? { from: dateRange.from.toISOString() } : {}), + ...(dateRange.to ? { to: endOfDay(dateRange.to).toISOString() } : {}), + }); + return res?.result; + }, + ); + + let filledTraffic = useMemo( + () => + fillDailyGaps( + analyticsData?.traffic || [], + (day) => ({ day, pageviews: 0, visitors: 0 }), + dateRange.from, + dateRange.to ?? new Date(), + ), + [analyticsData?.traffic, dateRange.from, dateRange.to], + ); + + if (!canSeePro) return null; + + if (!isPro) + return ( +
+ +
+ ); + + return ( +
+
+
+
+
+

Traffic

+
+ +
+
+
+ + + {selectedReferrer && ( + <> + +
+ {selectedReferrer} hello this is reddit +
+ + )} +
+
+
+ +
+
+ +
+
+ + +
+
+
+

Subscribers

+ +
+
+ ); +}; diff --git a/app/lish/[did]/[publication]/dashboard/analytics/Skeletons.tsx b/app/lish/[did]/[publication]/dashboard/analytics/Skeletons.tsx new file mode 100644 index 00000000..3bbbde6b --- /dev/null +++ b/app/lish/[did]/[publication]/dashboard/analytics/Skeletons.tsx @@ -0,0 +1,45 @@ +import { + AreaChart, + CartesianGrid, + ResponsiveContainer, + XAxis, + YAxis, +} from "recharts"; + +export const ChartSkeleton = () => ( +
+ + + + + + + +
+); + +export const ListSkeleton = ({ rows = 4 }: { rows?: number }) => ( +
+ {Array.from({ length: rows }).map((_, i) => ( +
+
+
+
+ ))} +
+); diff --git a/app/lish/[did]/[publication]/dashboard/analytics/SubscribersChart.tsx b/app/lish/[did]/[publication]/dashboard/analytics/SubscribersChart.tsx new file mode 100644 index 00000000..8001eb1b --- /dev/null +++ b/app/lish/[did]/[publication]/dashboard/analytics/SubscribersChart.tsx @@ -0,0 +1,71 @@ +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import { formatDayTick, formatYTick, niceYMax } from "./dates"; +import { ChartSkeleton } from "./Skeletons"; +import { ChartTooltip } from "./ChartTooltip"; + +export const SubscribersChart = (props: { + data: { day: string; total_subscribers: number }[]; + isLoading: boolean; +}) => { + if (props.isLoading) { + return ; + } + if (props.data.length === 0) { + return ( +
+ No subscriber data +
+ ); + } + return ( +
+ + + + + niceYMax(max)]} + /> + } + /> + + + +
+ ); +}; diff --git a/app/lish/[did]/[publication]/dashboard/analytics/TrafficChart.tsx b/app/lish/[did]/[publication]/dashboard/analytics/TrafficChart.tsx new file mode 100644 index 00000000..ccdc750e --- /dev/null +++ b/app/lish/[did]/[publication]/dashboard/analytics/TrafficChart.tsx @@ -0,0 +1,349 @@ +import { Fragment, useMemo, useState } from "react"; +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import { Combobox, ComboboxResult } from "components/Combobox"; +import { ToggleGroup } from "components/ToggleGroup"; +import { usePublicationData } from "../PublicationSWRProvider"; +import { formatDayTick, formatYTick, niceYMax } from "./dates"; +import { ChartSkeleton, ListSkeleton } from "./Skeletons"; +import { ChartTooltip } from "./ChartTooltip"; + +export type TrafficMetric = "pageviews" | "visitors"; +type ReferrerType = { referrer_host: string; pageviews: number }; + +export const TrafficChart = (props: { + data: { day: string; pageviews: number; visitors: number }[]; + isLoading: boolean; + metric: TrafficMetric; +}) => { + let today = new Date().toISOString().slice(0, 10); + let metricLabel = props.metric === "pageviews" ? "pageviews" : "visitors"; + + let chartData = useMemo(() => { + if (props.data.length === 0) return []; + return props.data.map((d, i) => { + let value = d[props.metric]; + let isToday = d.day === today; + let isBeforeToday = + i === props.data.length - 2 && + props.data[props.data.length - 1]?.day === today; + return { + day: d.day, + // Solid line: all points except today + complete: isToday ? undefined : value, + // Dashed line: bridge from yesterday to today + partial: isToday || isBeforeToday ? value : undefined, + }; + }); + }, [props.data, props.metric, today]); + + let xTicks = useMemo(() => { + if (chartData.length === 0) return undefined; + let len = chartData.length; + let target = 6; + let step = Math.max(1, Math.ceil((len - 1) / target)); + let ticks: string[] = []; + for (let i = 0; i < len; i += step) ticks.push(chartData[i].day); + let last = chartData[len - 1].day; + if (ticks[ticks.length - 1] !== last) ticks.push(last); + return ticks; + }, [chartData]); + + if (props.isLoading) { + return ; + } + if (props.data.length === 0) { + return ( +
+ No traffic data +
+ ); + } + return ( +
+ + + + + niceYMax(max)]} + /> + } + /> + + + + +
+ ); +}; + +export const MetricToggle = (props: { + metric: TrafficMetric; + setMetric: (m: TrafficMetric) => void; +}) => { + return ( + + value={props.metric} + onChange={props.setMetric} + className="shrink-0 p-0.5! rounded-[6px]!" + options={[ + { value: "visitors", label: "Visitors" }, + { value: "pageviews", label: "Page Views" }, + ]} + /> + ); +}; + +export const PostSelector = (props: { + selectedPost: { title: string; path: string } | undefined; + setSelectedPost: (s: { title: string; path: string } | undefined) => void; +}) => { + let { data } = usePublicationData(); + let { documents } = data || {}; + + let posts = useMemo( + () => + documents?.map((doc) => ({ + title: doc.record.title, + path: doc.record.path || "", + })), + [documents], + ); + + let [highlighted, setHighlighted] = useState(undefined); + let [searchValue, setSearchValue] = useState(""); + + let postTitles = posts?.map((p) => p.title); + let filteredTitles = useMemo( + () => + postTitles && + postTitles.filter((title) => + title.toLowerCase().includes(searchValue.toLowerCase()), + ), + [searchValue, postTitles], + ); + + let filteredTitlesWithClear = ["All Posts", ...(filteredTitles || [])]; + + return ( + + {props.selectedPost?.title ?? "All Posts"} + + } + results={filteredTitlesWithClear || []} + highlighted={highlighted} + setHighlighted={setHighlighted} + onSelect={() => { + if (highlighted === "All Posts" || !highlighted) { + props.setSelectedPost(undefined); + } else { + let post = posts?.find((p) => p.title === highlighted); + props.setSelectedPost(post); + } + }} + sideOffset={2} + searchValue={searchValue} + setSearchValue={setSearchValue} + showSearch + > + {filteredTitlesWithClear.map((title) => { + if (title === "All Posts") + return ( + <> + { + props.setSelectedPost(undefined); + }} + highlighted={highlighted} + setHighlighted={setHighlighted} + > + All Posts + + {filteredTitles && filteredTitles.length !== 0 && ( +
+ )} + + ); + return ( + { + let post = posts?.find((p) => p.title === title); + props.setSelectedPost(post); + }} + highlighted={highlighted} + setHighlighted={setHighlighted} + > + {title} + + ); + })} +
+ ); +}; + +export const TopPages = (props: { + pages: { path: string; pageviews: number }[]; + selectedPost: { title: string; path: string } | undefined; + setSelectedPost: (s: { title: string; path: string } | undefined) => void; + isLoading: boolean; +}) => { + let { data } = usePublicationData(); + let docsByPath = useMemo(() => { + let map = new Map(); + for (let doc of data?.documents || []) { + let path = doc.record.path || ""; + let normalized = path.startsWith("/") ? path : `/${path}`; + map.set(normalized, { title: doc.record.title, path }); + } + return map; + }, [data?.documents]); + + return ( +
+

Top Pages

+
+ {props.isLoading && } + {!props.isLoading && props.pages.length === 0 && ( +
No pages yet…
+ )} + {props.pages.map((page) => { + let doc = docsByPath.get(page.path); + let isSelected = selectedPostPath(props.selectedPost) === page.path; + return ( + + +
+
+ ); + })} +
+
+ ); +}; + +function selectedPostPath( + post: { title: string; path: string } | undefined, +): string | undefined { + if (!post) return undefined; + return post.path.startsWith("/") ? post.path : `/${post.path}`; +} + +export const TopReferrors = (props: { + refferors: ReferrerType[]; + setSelectedReferrer: (host: string | undefined) => void; + selectedReferrer: string | undefined; + isLoading: boolean; +}) => { + return ( +
+

Top Referrers

+
+ {props.isLoading && } + {!props.isLoading && props.refferors.length === 0 && ( +
No referrers yet…
+ )} + {props.refferors.map((ref) => { + let selected = props.selectedReferrer === ref.referrer_host; + return ( + + +
+
+ ); + })} +
+
+ ); +}; diff --git a/app/lish/[did]/[publication]/dashboard/analytics/dates.tsx b/app/lish/[did]/[publication]/dashboard/analytics/dates.tsx new file mode 100644 index 00000000..898a85b6 --- /dev/null +++ b/app/lish/[did]/[publication]/dashboard/analytics/dates.tsx @@ -0,0 +1,213 @@ +import { Popover } from "components/Popover"; +import { DatePicker } from "components/DatePicker"; +import { useLocalizedDate } from "src/hooks/useLocalizedDate"; +import type { DateRange } from "react-day-picker"; +import { useQueryState } from "src/hooks/useQueryState"; +import { DateTimeBlock } from "components/Blocks/DateTimeBlock"; +import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall"; +import { CalendarTiny } from "components/Icons/CalendarTiny"; +import { ArrowDownTiny } from "components/Icons/ArrowDownTiny"; +import { Separator } from "components/Layout"; + +type DatePreset = "Last Week" | "Last Month" | "All Time"; +export type DateSelection = { range: DateRange; preset: DatePreset | null }; + +export function useAnalyticsDateState() { + return useQueryState({ + fromParams: (get) => { + let preset = get("date"); + if (preset === "all") + return { + range: { from: undefined, to: undefined }, + preset: "All Time", + }; + if (preset === "month") { + let from = new Date(); + from.setMonth(from.getMonth() - 1); + return { range: { from, to: new Date() }, preset: "Last Month" }; + } + let fromParam = get("from"); + let toParam = get("to"); + if (fromParam || toParam) { + return { + range: { + from: fromParam ? new Date(fromParam) : undefined, + to: toParam ? new Date(toParam) : new Date(), + }, + preset: null, + }; + } + let from = new Date(); + from.setDate(from.getDate() - 7); + return { range: { from, to: new Date() }, preset: "Last Week" }; + }, + toParams: ({ preset, range }) => { + if (preset === "All Time") return { date: "all", from: null, to: null }; + if (preset === "Last Month") + return { date: "month", from: null, to: null }; + if (preset === "Last Week") return { date: null, from: null, to: null }; + return { + date: null, + from: range.from?.toISOString().slice(0, 10) ?? null, + to: range.to?.toISOString().slice(0, 10) ?? null, + }; + }, + }); +} + +const dayTickFormatter = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", +}); + +export function formatDayTick(value: string) { + let d = new Date(value + "T00:00:00"); + if (isNaN(d.getTime())) return value; + return dayTickFormatter.format(d); +} + +export function formatYTick(value: number) { + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; + return value.toLocaleString(); +} + +export function niceYMax(dataMax: number): number { + if (dataMax <= 0) return 10; + let rough = dataMax / 4; + let mag = Math.pow(10, Math.floor(Math.log10(rough))); + let norm = rough / mag; + let step = + norm <= 1.5 ? mag : norm <= 3 ? 2 * mag : norm <= 7 ? 5 * mag : 10 * mag; + return Math.ceil(dataMax / step) * step + step; +} + +export function endOfDay(date: Date): Date { + let d = new Date(date); + d.setHours(23, 59, 59, 999); + return d; +} + +export function fillDailyGaps( + data: T[], + fill: (day: string) => T, + from?: Date, + to?: Date, +): T[] { + let start = from || (data.length > 0 ? new Date(data[0].day) : null); + let end = + to || (data.length > 0 ? new Date(data[data.length - 1].day) : null); + if (!start || !end) return data; + + let lookup = new Map(data.map((d) => [d.day, d])); + let result: T[] = []; + let cursor = new Date(start); + cursor.setHours(0, 0, 0, 0); + let endDate = new Date(end); + endDate.setHours(0, 0, 0, 0); + + while (cursor <= endDate) { + let key = cursor.toISOString().slice(0, 10); + result.push(lookup.get(key) ?? fill(key)); + cursor.setDate(cursor.getDate() + 1); + } + return result; +} + +export const DateRangeSelector = (props: { + pubStartDate: string | undefined; + dateState: DateSelection; + setDateState: (state: DateSelection) => void; + showBackground?: boolean; +}) => { + let { range: dateRange, preset: datePreset } = props.dateState; + let buttonClass = + "rounded-md px-1 text-sm border border-accent-contrast text-accent-contrast"; + + let currentDate = new Date(); + + let startDate = useLocalizedDate( + dateRange.from?.toISOString() || + props.pubStartDate || + "2025-01-01T12:00:00.000Z", + { + month: "short", + day: "numeric", + }, + ); + + let endDate = useLocalizedDate((dateRange.to ?? currentDate).toISOString(), { + month: "short", + day: "numeric", + }); + + return ( + + + {datePreset + ? datePreset + : dateRange.from === undefined + ? "All Time" + : `${startDate} - ${endDate}`} + + + +
+ } + > +
+ + + +
+
+ { + if (range) props.setDateState({ range, preset: null }); + }} + disabled={(date) => date > new Date()} + /> + + ); +}; diff --git a/app/lish/[did]/[publication]/dashboard/analytics/page.tsx b/app/lish/[did]/[publication]/dashboard/analytics/page.tsx index 6c85cfef..5cbc0200 100644 --- a/app/lish/[did]/[publication]/dashboard/analytics/page.tsx +++ b/app/lish/[did]/[publication]/dashboard/analytics/page.tsx @@ -3,11 +3,8 @@ import { useRouter } from "next/navigation"; import { useEffect } from "react"; import { DashboardPageLayout } from "components/PageLayouts/DashboardPageLayout"; -import { - PublicationAnalytics, - DateRangeSelector, - useAnalyticsDateState, -} from "../PublicationAnalytics"; +import { PublicationAnalytics } from "./PublicationAnalytics"; +import { DateRangeSelector, useAnalyticsDateState } from "./dates"; import { NewDraftActionButton } from "../NewDraftButton"; import { usePublicationData, @@ -35,7 +32,16 @@ export default function AnalyticsPage() { } + mobileActions={ + <> + + + } publication={pubUri} showHeader={true} controls={ diff --git a/components/Combobox.tsx b/components/Combobox.tsx index 90ac1b04..ee2d5462 100644 --- a/components/Combobox.tsx +++ b/components/Combobox.tsx @@ -100,7 +100,7 @@ export const Combobox = ({ onOpenChange?.(newOpen); }} > - +
{trigger}
diff --git a/components/DatePicker.tsx b/components/DatePicker.tsx index e2f65b8a..50f83b78 100644 --- a/components/DatePicker.tsx +++ b/components/DatePicker.tsx @@ -36,7 +36,7 @@ export const DatePicker = (props: DayPickerProps) => { month_grid: "border-separate [border-spacing:2px]", weekdays: "text-secondary text-sm", selected: "bg-accent-1 text-accent-2 rounded-md font-bold", - range_middle: "bg-[var(--accent-light)]!", + range_middle: "bg-[var(--accent-light)]! text-accent-contrast", day: "h-8! w-8! text-center rounded-md sm:hover:bg-border-light", outside: "text-tertiary", today: "font-bold", diff --git a/components/Icons/CalendarTiny.tsx b/components/Icons/CalendarTiny.tsx new file mode 100644 index 00000000..1c2a0142 --- /dev/null +++ b/components/Icons/CalendarTiny.tsx @@ -0,0 +1,21 @@ +import { Props } from "./Props"; + +export const CalendarTiny = (props: Props) => { + return ( + + + + ); +}; -- 2.51.2