diff --git a/app/api/rpc/[command]/get_publication_analytics.ts b/app/api/rpc/[command]/get_publication_analytics.ts index 94f9ea73..749e6e78 100644 --- a/app/api/rpc/[command]/get_publication_analytics.ts +++ b/app/api/rpc/[command]/get_publication_analytics.ts @@ -43,24 +43,22 @@ export const get_publication_analytics = makeRoute({ }; } - const origin = `https://${domain}/`; - const [trafficResult, referrersResult, pagesResult] = await Promise.all([ tinybird.publicationTraffic.query({ - domain: origin, + domain, ...(from ? { date_from: from } : {}), ...(to ? { date_to: to } : {}), ...(path ? { path } : {}), }), tinybird.publicationTopReferrers.query({ - domain: origin, + domain, ...(from ? { date_from: from } : {}), ...(to ? { date_to: to } : {}), ...(path ? { path } : {}), limit: 10, }), tinybird.publicationTopPages.query({ - domain: origin, + domain, ...(from ? { date_from: from } : {}), ...(to ? { date_to: to } : {}), limit: 20, diff --git a/app/api/rpc/[command]/get_publication_subscribers_timeseries.ts b/app/api/rpc/[command]/get_publication_subscribers_timeseries.ts index 4cd860f1..81bd65a4 100644 --- a/app/api/rpc/[command]/get_publication_subscribers_timeseries.ts +++ b/app/api/rpc/[command]/get_publication_subscribers_timeseries.ts @@ -56,7 +56,6 @@ export const get_publication_subscribers_timeseries = makeRoute({ dailyCounts[day] = (dailyCounts[day] || 0) + 1; } - const days = Object.keys(dailyCounts).sort(); let cumulative = 0; // If we have a from filter, get the count of subscriptions before that date @@ -69,10 +68,27 @@ export const get_publication_subscribers_timeseries = makeRoute({ cumulative = count || 0; } - const timeseries = days.map((day) => { - cumulative += dailyCounts[day]; - return { day, total_subscribers: cumulative }; - }); + // Build timeseries over the full date range, filling gaps with the + // running cumulative so the chart always has data points to display. + const timeseries: { day: string; total_subscribers: number }[] = []; + if (from && to) { + const cursor = new Date(from); + cursor.setUTCHours(0, 0, 0, 0); + const end = new Date(to); + end.setUTCHours(0, 0, 0, 0); + while (cursor <= end) { + const key = cursor.toISOString().slice(0, 10); + cumulative += dailyCounts[key] || 0; + timeseries.push({ day: key, total_subscribers: cumulative }); + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + } else { + const days = Object.keys(dailyCounts).sort(); + for (const day of days) { + cumulative += dailyCounts[day]; + timeseries.push({ day, total_subscribers: cumulative }); + } + } return { result: { timeseries } }; }, diff --git a/app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx b/app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx index e8fd4097..cee7fd33 100644 --- a/app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx +++ b/app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx @@ -2,7 +2,7 @@ import { ArrowRightTiny } from "components/Icons/ArrowRightTiny"; import { UpgradeContent } from "../UpgradeModal"; import { Popover } from "components/Popover"; import { DatePicker } from "components/DatePicker"; -import { useMemo, useState } from "react"; +import { Fragment, useMemo, useState } from "react"; import { useLocalizedDate } from "src/hooks/useLocalizedDate"; import type { DateRange } from "react-day-picker"; import { usePublicationData } from "./PublicationSWRProvider"; @@ -21,6 +21,30 @@ import { } from "recharts"; type ReferrerType = { referrer_host: string; pageviews: number }; +type TrafficMetric = "pageviews" | "visitors"; + +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 endOfDay(date: Date): Date { + let d = new Date(date); + d.setHours(23, 59, 59, 999); + return d; +} function fillDailyGaps( data: T[], @@ -28,8 +52,7 @@ function fillDailyGaps( from?: Date, to?: Date, ): T[] { - let start = - from || (data.length > 0 ? new Date(data[0].day) : null); + 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; @@ -55,17 +78,24 @@ export const PublicationAnalytics = (props: { let isPro = useIsPro(); let { data: publication } = usePublicationData(); - let [dateRange, setDateRange] = useState({ from: undefined }); + let [dateRange, setDateRange] = useState(() => { + let from = new Date(); + from.setDate(from.getDate() - 7); + return { from, to: new Date() }; + }); + let [datePreset, setDatePreset] = useState("Last Week"); let [selectedPost, setSelectedPost] = useState< { title: string; path: string } | undefined >(undefined); let [selectedReferror, setSelectedReferror] = useState< ReferrerType | undefined >(undefined); + let [trafficMetric, setTrafficMetric] = + useState("visitors"); let publicationUri = publication?.publication?.uri; - let { data: analyticsData } = useSWR( + let { data: analyticsData, isLoading: analyticsLoading } = useSWR( publicationUri ? [ "publication-analytics", @@ -79,14 +109,14 @@ export const PublicationAnalytics = (props: { let res = await callRPC("get_publication_analytics", { publication_uri: publicationUri!, ...(dateRange.from ? { from: dateRange.from.toISOString() } : {}), - ...(dateRange.to ? { to: dateRange.to.toISOString() } : {}), + ...(dateRange.to ? { to: endOfDay(dateRange.to).toISOString() } : {}), ...(selectedPost ? { path: `/${selectedPost.path}` } : {}), }); return res?.result; }, ); - let { data: subscribersData } = useSWR( + let { data: subscribersData, isLoading: subscribersLoading } = useSWR( publicationUri ? [ "publication-subscribers-timeseries", @@ -99,7 +129,7 @@ export const PublicationAnalytics = (props: { let res = await callRPC("get_publication_subscribers_timeseries", { publication_uri: publicationUri!, ...(dateRange.from ? { from: dateRange.from.toISOString() } : {}), - ...(dateRange.to ? { to: dateRange.to.toISOString() } : {}), + ...(dateRange.to ? { to: endOfDay(dateRange.to).toISOString() } : {}), }); return res?.result; }, @@ -109,9 +139,9 @@ export const PublicationAnalytics = (props: { () => fillDailyGaps( analyticsData?.traffic || [], - (day) => ({ day, pageviews: 0 }), + (day) => ({ day, pageviews: 0, visitors: 0 }), dateRange.from, - dateRange.to, + dateRange.to ?? new Date(), ), [analyticsData?.traffic, dateRange.from, dateRange.to], ); @@ -125,8 +155,19 @@ export const PublicationAnalytics = (props: { return (
+
+ + +
-
-

Subscribers

- +

Traffic

+ + + {selectedReferror && ( + <> + +
+ {" "} + {selectedReferror.referrer_host} +
+ + )} +
+ +
+ +
-
-
-
-

Traffic

- - - {selectedReferror && ( - <> - -
- {" "} - {selectedReferror.referrer_host} -
- - )} -
- -
-
- - {" "} -
+

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 (
@@ -210,21 +277,35 @@ const SubscribersChart = (props: {
- - + + @@ -233,8 +314,34 @@ const SubscribersChart = (props: { }; const TrafficChart = (props: { - data: { day: string; pageviews: number }[]; + 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 (
@@ -245,22 +352,61 @@ const TrafficChart = (props: { return (
- - - + + + + { + 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}
+
+ ); + }} /> - +
@@ -268,6 +414,33 @@ const TrafficChart = (props: { ); }; +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; @@ -365,6 +538,9 @@ const DateRangeSelector = (props: { pubStartDate: string | undefined; dateRange: DateRange; setDateRange: (dateRange: DateRange) => void; + datePreset: string | null; + setDatePreset: (preset: string | null) => void; + showBackground?: boolean; }) => { let buttonClass = "rounded-md px-1 text-sm border border-accent-contrast text-accent-contrast"; @@ -390,17 +566,24 @@ const DateRangeSelector = (props: { ); function handleDateChange(range: DateRange | undefined) { - if (range) props.setDateRange(range); + if (range) { + props.setDateRange(range); + props.setDatePreset(null); + } } return ( - {props.dateRange.from === undefined - ? "All Time" - : `${startDate} - ${endDate}`} +
+ {props.datePreset + ? props.datePreset + : props.dateRange.from === undefined + ? "All Time" + : `${startDate} - ${endDate}`}
} > @@ -408,6 +591,7 @@ const DateRangeSelector = (props: { +
+ + ); + })} +
+
+ ); +}; + +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[]; setSelectedReferror: (ref: ReferrerType) => void; selectedReferror: ReferrerType | undefined; + isLoading: boolean; }) => { return ( -
- {props.refferors.map((ref) => { - let selected = ref === props.selectedReferror; - return ( - <> - -
- - ); - })} +
+

Top Referrers

+
+ {props.isLoading && } + {!props.isLoading && props.refferors.length === 0 && ( +
No referrer data
+ )} + {props.refferors.map((ref) => { + let selected = ref === props.selectedReferror; + return ( + + +
+
+ ); + })} +
); }; diff --git a/lib/tinybird.ts b/lib/tinybird.ts index e9457032..8262b5a7 100644 --- a/lib/tinybird.ts +++ b/lib/tinybird.ts @@ -98,10 +98,11 @@ export const publicationTraffic = defineEndpoint("publication_traffic", { sql: ` SELECT toDate(fromUnixTimestamp64Milli(timestamp)) AS day, - count() AS pageviews + count() AS pageviews, + uniq(deviceId) AS visitors FROM analytics_events WHERE eventType = 'pageview' - AND origin = {{String(domain)}} + AND domain(origin) = {{String(domain)}} {% if defined(date_from) %} AND fromUnixTimestamp64Milli(timestamp) >= parseDateTimeBestEffort({{String(date_from)}}) {% end %} @@ -119,6 +120,7 @@ export const publicationTraffic = defineEndpoint("publication_traffic", { output: { day: t.date(), pageviews: t.uint64(), + visitors: t.uint64(), }, }); @@ -148,9 +150,9 @@ export const publicationTopReferrers = defineEndpoint( count() AS pageviews FROM analytics_events WHERE eventType = 'pageview' - AND origin = {{String(domain)}} + AND domain(origin) = {{String(domain)}} AND referrer != '' - AND domain(referrer) != domain({{String(domain)}}) + AND domain(referrer) != {{String(domain)}} {% if defined(date_from) %} AND fromUnixTimestamp64Milli(timestamp) >= parseDateTimeBestEffort({{String(date_from)}}) {% end %} @@ -200,7 +202,7 @@ export const publicationTopPages = defineEndpoint("publication_top_pages", { count() AS pageviews FROM analytics_events WHERE eventType = 'pageview' - AND origin = {{String(domain)}} + AND domain(origin) = {{String(domain)}} {% if defined(date_from) %} AND fromUnixTimestamp64Milli(timestamp) >= parseDateTimeBestEffort({{String(date_from)}}) {% end %}