a tool for shared writing and social publishing

Add unique visitors metric, improve analytics dashboard UX

Add unique visitors (via deviceId) to Tinybird traffic query with a
pageviews/visitors toggle. Reorder dashboard to show traffic first,
default to last week date range with preset labels, and polish chart
styling and loading states.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+394 -109
+3 -5
app/api/rpc/[command]/get_publication_analytics.ts
··· 43 43 }; 44 44 } 45 45 46 - const origin = `https://${domain}/`; 47 - 48 46 const [trafficResult, referrersResult, pagesResult] = await Promise.all([ 49 47 tinybird.publicationTraffic.query({ 50 - domain: origin, 48 + domain, 51 49 ...(from ? { date_from: from } : {}), 52 50 ...(to ? { date_to: to } : {}), 53 51 ...(path ? { path } : {}), 54 52 }), 55 53 tinybird.publicationTopReferrers.query({ 56 - domain: origin, 54 + domain, 57 55 ...(from ? { date_from: from } : {}), 58 56 ...(to ? { date_to: to } : {}), 59 57 ...(path ? { path } : {}), 60 58 limit: 10, 61 59 }), 62 60 tinybird.publicationTopPages.query({ 63 - domain: origin, 61 + domain, 64 62 ...(from ? { date_from: from } : {}), 65 63 ...(to ? { date_to: to } : {}), 66 64 limit: 20,
+21 -5
app/api/rpc/[command]/get_publication_subscribers_timeseries.ts
··· 56 56 dailyCounts[day] = (dailyCounts[day] || 0) + 1; 57 57 } 58 58 59 - const days = Object.keys(dailyCounts).sort(); 60 59 let cumulative = 0; 61 60 62 61 // If we have a from filter, get the count of subscriptions before that date ··· 69 68 cumulative = count || 0; 70 69 } 71 70 72 - const timeseries = days.map((day) => { 73 - cumulative += dailyCounts[day]; 74 - return { day, total_subscribers: cumulative }; 75 - }); 71 + // Build timeseries over the full date range, filling gaps with the 72 + // running cumulative so the chart always has data points to display. 73 + const timeseries: { day: string; total_subscribers: number }[] = []; 74 + if (from && to) { 75 + const cursor = new Date(from); 76 + cursor.setUTCHours(0, 0, 0, 0); 77 + const end = new Date(to); 78 + end.setUTCHours(0, 0, 0, 0); 79 + while (cursor <= end) { 80 + const key = cursor.toISOString().slice(0, 10); 81 + cumulative += dailyCounts[key] || 0; 82 + timeseries.push({ day: key, total_subscribers: cumulative }); 83 + cursor.setUTCDate(cursor.getUTCDate() + 1); 84 + } 85 + } else { 86 + const days = Object.keys(dailyCounts).sort(); 87 + for (const day of days) { 88 + cumulative += dailyCounts[day]; 89 + timeseries.push({ day, total_subscribers: cumulative }); 90 + } 91 + } 76 92 77 93 return { result: { timeseries } }; 78 94 },
+363 -94
app/lish/[did]/[publication]/dashboard/PublicationAnalytics.tsx
··· 2 2 import { UpgradeContent } from "../UpgradeModal"; 3 3 import { Popover } from "components/Popover"; 4 4 import { DatePicker } from "components/DatePicker"; 5 - import { useMemo, useState } from "react"; 5 + import { Fragment, useMemo, useState } from "react"; 6 6 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 7 7 import type { DateRange } from "react-day-picker"; 8 8 import { usePublicationData } from "./PublicationSWRProvider"; ··· 21 21 } from "recharts"; 22 22 23 23 type ReferrerType = { referrer_host: string; pageviews: number }; 24 + type TrafficMetric = "pageviews" | "visitors"; 25 + 26 + const dayTickFormatter = new Intl.DateTimeFormat(undefined, { 27 + month: "short", 28 + day: "numeric", 29 + }); 30 + 31 + function formatDayTick(value: string) { 32 + let d = new Date(value + "T00:00:00"); 33 + if (isNaN(d.getTime())) return value; 34 + return dayTickFormatter.format(d); 35 + } 36 + 37 + function formatYTick(value: number) { 38 + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; 39 + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; 40 + return value.toLocaleString(); 41 + } 42 + 43 + function endOfDay(date: Date): Date { 44 + let d = new Date(date); 45 + d.setHours(23, 59, 59, 999); 46 + return d; 47 + } 24 48 25 49 function fillDailyGaps<T extends { day: string }>( 26 50 data: T[], ··· 28 52 from?: Date, 29 53 to?: Date, 30 54 ): T[] { 31 - let start = 32 - from || (data.length > 0 ? new Date(data[0].day) : null); 55 + let start = from || (data.length > 0 ? new Date(data[0].day) : null); 33 56 let end = 34 57 to || (data.length > 0 ? new Date(data[data.length - 1].day) : null); 35 58 if (!start || !end) return data; ··· 55 78 let isPro = useIsPro(); 56 79 57 80 let { data: publication } = usePublicationData(); 58 - let [dateRange, setDateRange] = useState<DateRange>({ from: undefined }); 81 + let [dateRange, setDateRange] = useState<DateRange>(() => { 82 + let from = new Date(); 83 + from.setDate(from.getDate() - 7); 84 + return { from, to: new Date() }; 85 + }); 86 + let [datePreset, setDatePreset] = useState<string | null>("Last Week"); 59 87 let [selectedPost, setSelectedPost] = useState< 60 88 { title: string; path: string } | undefined 61 89 >(undefined); 62 90 let [selectedReferror, setSelectedReferror] = useState< 63 91 ReferrerType | undefined 64 92 >(undefined); 93 + let [trafficMetric, setTrafficMetric] = 94 + useState<TrafficMetric>("visitors"); 65 95 66 96 let publicationUri = publication?.publication?.uri; 67 97 68 - let { data: analyticsData } = useSWR( 98 + let { data: analyticsData, isLoading: analyticsLoading } = useSWR( 69 99 publicationUri 70 100 ? [ 71 101 "publication-analytics", ··· 79 109 let res = await callRPC("get_publication_analytics", { 80 110 publication_uri: publicationUri!, 81 111 ...(dateRange.from ? { from: dateRange.from.toISOString() } : {}), 82 - ...(dateRange.to ? { to: dateRange.to.toISOString() } : {}), 112 + ...(dateRange.to ? { to: endOfDay(dateRange.to).toISOString() } : {}), 83 113 ...(selectedPost ? { path: `/${selectedPost.path}` } : {}), 84 114 }); 85 115 return res?.result; 86 116 }, 87 117 ); 88 118 89 - let { data: subscribersData } = useSWR( 119 + let { data: subscribersData, isLoading: subscribersLoading } = useSWR( 90 120 publicationUri 91 121 ? [ 92 122 "publication-subscribers-timeseries", ··· 99 129 let res = await callRPC("get_publication_subscribers_timeseries", { 100 130 publication_uri: publicationUri!, 101 131 ...(dateRange.from ? { from: dateRange.from.toISOString() } : {}), 102 - ...(dateRange.to ? { to: dateRange.to.toISOString() } : {}), 132 + ...(dateRange.to ? { to: endOfDay(dateRange.to).toISOString() } : {}), 103 133 }); 104 134 return res?.result; 105 135 }, ··· 109 139 () => 110 140 fillDailyGaps( 111 141 analyticsData?.traffic || [], 112 - (day) => ({ day, pageviews: 0 }), 142 + (day) => ({ day, pageviews: 0, visitors: 0 }), 113 143 dateRange.from, 114 - dateRange.to, 144 + dateRange.to ?? new Date(), 115 145 ), 116 146 [analyticsData?.traffic, dateRange.from, dateRange.to], 117 147 ); ··· 125 155 126 156 return ( 127 157 <div className="analytics flex flex-col gap-6"> 158 + <div className="flex justify-end gap-2"> 159 + <MetricToggle metric={trafficMetric} setMetric={setTrafficMetric} /> 160 + <DateRangeSelector 161 + dateRange={dateRange} 162 + setDateRange={setDateRange} 163 + datePreset={datePreset} 164 + setDatePreset={setDatePreset} 165 + pubStartDate={publication?.publication?.indexed_at} 166 + showBackground={props.showPageBackground} 167 + /> 168 + </div> 128 169 <div 129 - className={`analyticsSubCount rounded-lg border ${ 170 + className={`analyticsViewCount rounded-lg border ${ 130 171 props.showPageBackground 131 172 ? "border-border-light p-2" 132 173 : "border-transparent" ··· 137 178 : "transparent", 138 179 }} 139 180 > 140 - <div className="flex gap-2 justify-between items-center"> 141 - <h3>Subscribers</h3> 142 - <DateRangeSelector 143 - dateRange={dateRange} 144 - setDateRange={setDateRange} 145 - pubStartDate={publication?.publication?.indexed_at} 181 + <div className="flex items-center gap-2 pb-2 w-full"> 182 + <h3>Traffic</h3> 183 + <ArrowRightTiny className="text-border" /> 184 + <PostSelector 185 + selectedPost={selectedPost} 186 + setSelectedPost={setSelectedPost} 187 + /> 188 + {selectedReferror && ( 189 + <> 190 + <ArrowRightTiny className="text-border" /> 191 + <div className="text-tertiary"> 192 + {" "} 193 + {selectedReferror.referrer_host} 194 + </div> 195 + </> 196 + )} 197 + </div> 198 + <TrafficChart data={filledTraffic} isLoading={analyticsLoading} metric={trafficMetric} /> 199 + <div className="flex gap-4 mt-2"> 200 + <TopPages 201 + pages={analyticsData?.topPages || []} 202 + selectedPost={selectedPost} 203 + setSelectedPost={setSelectedPost} 204 + isLoading={analyticsLoading} 205 + /> 206 + <TopReferrors 207 + refferors={analyticsData?.topReferrers || []} 208 + setSelectedReferror={setSelectedReferror} 209 + selectedReferror={selectedReferror} 210 + isLoading={analyticsLoading} 146 211 /> 147 212 </div> 148 - <SubscribersChart data={subscribersData?.timeseries || []} /> 149 213 </div> 150 214 <div 151 - className={`analyticsViewCount rounded-lg border ${ 215 + className={`analyticsSubCount rounded-lg border ${ 152 216 props.showPageBackground 153 217 ? "border-border-light p-2" 154 218 : "border-transparent" ··· 159 223 : "transparent", 160 224 }} 161 225 > 162 - <div className="flex justify-between items-center gap-2 pb-2 w-full"> 163 - <div className="flex gap-2 items-center"> 164 - <h3>Traffic</h3> 165 - <ArrowRightTiny className="text-border" /> 166 - <PostSelector 167 - selectedPost={selectedPost} 168 - setSelectedPost={setSelectedPost} 169 - /> 170 - {selectedReferror && ( 171 - <> 172 - <ArrowRightTiny className="text-border" /> 173 - <div className="text-tertiary"> 174 - {" "} 175 - {selectedReferror.referrer_host} 176 - </div> 177 - </> 178 - )} 179 - </div> 180 - <DateRangeSelector 181 - dateRange={dateRange} 182 - setDateRange={setDateRange} 183 - pubStartDate={publication?.publication?.indexed_at} 184 - /> 185 - </div> 186 - <div className="flex gap-2"> 187 - <TrafficChart data={filledTraffic} /> 188 - <TopReferrors 189 - refferors={analyticsData?.topReferrers || []} 190 - setSelectedReferror={setSelectedReferror} 191 - selectedReferror={selectedReferror} 192 - />{" "} 193 - </div> 226 + <h3>Subscribers</h3> 227 + <SubscribersChart 228 + data={subscribersData?.timeseries || []} 229 + isLoading={subscribersLoading} 230 + /> 194 231 </div> 195 232 </div> 196 233 ); 197 234 }; 198 235 236 + const ChartSkeleton = () => ( 237 + <div className="aspect-video w-full grow animate-pulse"> 238 + <ResponsiveContainer width="100%" height="100%"> 239 + <AreaChart data={[]}> 240 + <CartesianGrid 241 + strokeDasharray="3 3" 242 + stroke="var(--color-border-light)" 243 + /> 244 + <XAxis tick={false} stroke="var(--color-border-light)" tickMargin={8} /> 245 + <YAxis tick={false} stroke="var(--color-border-light)" tickMargin={4} /> 246 + </AreaChart> 247 + </ResponsiveContainer> 248 + </div> 249 + ); 250 + 251 + const ListSkeleton = ({ rows = 4 }: { rows?: number }) => ( 252 + <div className="flex flex-col gap-2 animate-pulse"> 253 + {Array.from({ length: rows }).map((_, i) => ( 254 + <div key={i} className="flex justify-between items-center py-1.5 px-1"> 255 + <div className="h-4 bg-border-light rounded w-2/3" /> 256 + <div className="h-4 bg-border-light rounded w-8" /> 257 + </div> 258 + ))} 259 + </div> 260 + ); 261 + 199 262 const SubscribersChart = (props: { 200 263 data: { day: string; total_subscribers: number }[]; 264 + isLoading: boolean; 201 265 }) => { 266 + if (props.isLoading) { 267 + return <ChartSkeleton />; 268 + } 202 269 if (props.data.length === 0) { 203 270 return ( 204 271 <div className="aspect-video w-full border border-border grow flex items-center justify-center text-tertiary"> ··· 210 277 <div className="aspect-video w-full grow"> 211 278 <ResponsiveContainer width="100%" height="100%"> 212 279 <AreaChart data={props.data}> 213 - <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" /> 214 - <XAxis dataKey="day" tick={{ fontSize: 12 }} stroke="var(--tertiary)" /> 280 + <CartesianGrid 281 + strokeDasharray="3 3" 282 + stroke="var(--color-border-light)" 283 + /> 284 + <XAxis 285 + dataKey="day" 286 + tick={{ fontSize: 12, fill: "var(--color-secondary)" }} 287 + stroke="var(--color-border-light)" 288 + tickFormatter={formatDayTick} 289 + interval="preserveStartEnd" 290 + minTickGap={40} 291 + tickMargin={8} 292 + /> 215 293 <YAxis 216 - tick={{ fontSize: 12 }} 217 - stroke="var(--tertiary)" 294 + tick={{ fontSize: 12, fill: "var(--color-secondary)" }} 295 + stroke="var(--color-border-light)" 218 296 allowDecimals={false} 297 + tickFormatter={formatYTick} 298 + tickMargin={4} 219 299 /> 220 300 <Tooltip /> 221 301 <Area 222 302 type="monotone" 223 303 dataKey="total_subscribers" 224 304 name="Subscribers" 225 - stroke="var(--accent-contrast)" 226 - fill="var(--accent-contrast)" 305 + stroke="var(--color-accent-contrast)" 306 + fill="var(--color-accent-contrast)" 227 307 fillOpacity={0.1} 308 + isAnimationActive={false} 228 309 /> 229 310 </AreaChart> 230 311 </ResponsiveContainer> ··· 233 314 }; 234 315 235 316 const TrafficChart = (props: { 236 - data: { day: string; pageviews: number }[]; 317 + data: { day: string; pageviews: number; visitors: number }[]; 318 + isLoading: boolean; 319 + metric: TrafficMetric; 237 320 }) => { 321 + let today = new Date().toISOString().slice(0, 10); 322 + let metricLabel = props.metric === "pageviews" ? "pageviews" : "visitors"; 323 + 324 + let chartData = useMemo(() => { 325 + if (props.data.length === 0) return []; 326 + return props.data.map((d, i) => { 327 + let value = d[props.metric]; 328 + let isToday = d.day === today; 329 + let isBeforeToday = 330 + i === props.data.length - 2 && 331 + props.data[props.data.length - 1]?.day === today; 332 + return { 333 + day: d.day, 334 + // Solid line: all points except today 335 + complete: isToday ? undefined : value, 336 + // Dashed line: bridge from yesterday to today 337 + partial: isToday || isBeforeToday ? value : undefined, 338 + }; 339 + }); 340 + }, [props.data, props.metric, today]); 341 + 342 + if (props.isLoading) { 343 + return <ChartSkeleton />; 344 + } 238 345 if (props.data.length === 0) { 239 346 return ( 240 347 <div className="aspect-video w-full border border-border grow flex items-center justify-center text-tertiary"> ··· 245 352 return ( 246 353 <div className="aspect-video w-full grow"> 247 354 <ResponsiveContainer width="100%" height="100%"> 248 - <AreaChart data={props.data}> 249 - <CartesianGrid strokeDasharray="3 3" stroke="var(--border)" /> 250 - <XAxis dataKey="day" tick={{ fontSize: 12 }} stroke="var(--tertiary)" /> 355 + <AreaChart data={chartData}> 356 + <CartesianGrid 357 + strokeDasharray="3 3" 358 + stroke="var(--color-border-light)" 359 + /> 360 + <XAxis 361 + dataKey="day" 362 + tick={{ fontSize: 12, fill: "var(--color-secondary)" }} 363 + stroke="var(--color-border-light)" 364 + tickFormatter={formatDayTick} 365 + interval="preserveStartEnd" 366 + minTickGap={40} 367 + tickMargin={8} 368 + /> 251 369 <YAxis 252 - tick={{ fontSize: 12 }} 253 - stroke="var(--tertiary)" 370 + tick={{ fontSize: 12, fill: "var(--color-secondary)" }} 371 + stroke="var(--color-border-light)" 254 372 allowDecimals={false} 373 + tickFormatter={formatYTick} 374 + tickMargin={4} 255 375 /> 256 - <Tooltip /> 376 + <Tooltip 377 + content={({ active, payload, label }) => { 378 + if (!active || !payload?.length) return null; 379 + let pageviews = 380 + payload.find((p) => p.dataKey === "complete")?.value ?? 381 + payload.find((p) => p.dataKey === "partial")?.value; 382 + return ( 383 + <div className="rounded border border-border-light bg-white p-2 text-sm shadow-sm"> 384 + <div className="text-tertiary"> 385 + {formatDayTick(String(label))} 386 + </div> 387 + <div>{Number(pageviews).toLocaleString()} {metricLabel}</div> 388 + </div> 389 + ); 390 + }} 391 + /> 257 392 <Area 258 - type="monotone" 259 - dataKey="pageviews" 393 + type="linear" 394 + dataKey="complete" 260 395 name="Pageviews" 261 - stroke="var(--accent-contrast)" 262 - fill="var(--accent-contrast)" 396 + stroke="var(--color-accent-contrast)" 397 + fill="var(--color-accent-contrast)" 263 398 fillOpacity={0.1} 399 + isAnimationActive={false} 400 + /> 401 + <Area 402 + type="linear" 403 + dataKey="partial" 404 + name="partial" 405 + stroke="var(--color-accent-contrast)" 406 + strokeDasharray="4 4" 407 + fill="var(--color-accent-contrast)" 408 + fillOpacity={0.1} 409 + isAnimationActive={false} 264 410 /> 265 411 </AreaChart> 266 412 </ResponsiveContainer> ··· 268 414 ); 269 415 }; 270 416 417 + const MetricToggle = (props: { 418 + metric: TrafficMetric; 419 + setMetric: (m: TrafficMetric) => void; 420 + }) => { 421 + let options: { value: TrafficMetric; label: string }[] = [ 422 + { value: "visitors", label: "Visitors" }, 423 + { value: "pageviews", label: "Pageviews" }, 424 + ]; 425 + return ( 426 + <div className="flex rounded-md border border-border-light text-sm overflow-hidden"> 427 + {options.map((opt) => ( 428 + <button 429 + key={opt.value} 430 + onClick={() => props.setMetric(opt.value)} 431 + className={`px-2 py-1 ${ 432 + props.metric === opt.value 433 + ? "bg-accent-contrast text-white" 434 + : "bg-bg-page text-tertiary hover:text-primary" 435 + }`} 436 + > 437 + {opt.label} 438 + </button> 439 + ))} 440 + </div> 441 + ); 442 + }; 443 + 271 444 const PostSelector = (props: { 272 445 selectedPost: { title: string; path: string } | undefined; 273 446 setSelectedPost: (s: { title: string; path: string } | undefined) => void; ··· 365 538 pubStartDate: string | undefined; 366 539 dateRange: DateRange; 367 540 setDateRange: (dateRange: DateRange) => void; 541 + datePreset: string | null; 542 + setDatePreset: (preset: string | null) => void; 543 + showBackground?: boolean; 368 544 }) => { 369 545 let buttonClass = 370 546 "rounded-md px-1 text-sm border border-accent-contrast text-accent-contrast"; ··· 390 566 ); 391 567 392 568 function handleDateChange(range: DateRange | undefined) { 393 - if (range) props.setDateRange(range); 569 + if (range) { 570 + props.setDateRange(range); 571 + props.setDatePreset(null); 572 + } 394 573 } 395 574 396 575 return ( 397 576 <Popover 398 577 className={"w-fit"} 399 578 trigger={ 400 - <div className="text-tertiary ml-0"> 401 - {props.dateRange.from === undefined 402 - ? "All Time" 403 - : `${startDate} - ${endDate}`} 579 + <div 580 + className={`w-36 text-center text-tertiary text-sm border border-border-light rounded-md px-2 py-1 hover:border-border ${props.showBackground ? "bg-bg-page" : ""}`} 581 + > 582 + {props.datePreset 583 + ? props.datePreset 584 + : props.dateRange.from === undefined 585 + ? "All Time" 586 + : `${startDate} - ${endDate}`} 404 587 </div> 405 588 } 406 589 > ··· 408 591 <button 409 592 onClick={() => { 410 593 props.setDateRange({ from: undefined, to: undefined }); 594 + props.setDatePreset("All Time"); 411 595 }} 412 596 className={`${buttonClass}`} 413 597 > ··· 418 602 let from = new Date(); 419 603 from.setDate(from.getDate() - 7); 420 604 props.setDateRange({ from, to: new Date() }); 605 + props.setDatePreset("Last Week"); 421 606 }} 422 607 className={`${buttonClass}`} 423 608 > ··· 428 613 let from = new Date(); 429 614 from.setMonth(from.getMonth() - 1); 430 615 props.setDateRange({ from, to: new Date() }); 616 + props.setDatePreset("Last Month"); 431 617 }} 432 618 className={`${buttonClass}`} 433 619 > ··· 445 631 ); 446 632 }; 447 633 634 + const TopPages = (props: { 635 + pages: { path: string; pageviews: number }[]; 636 + selectedPost: { title: string; path: string } | undefined; 637 + setSelectedPost: (s: { title: string; path: string } | undefined) => void; 638 + isLoading: boolean; 639 + }) => { 640 + let { data } = usePublicationData(); 641 + let docsByPath = useMemo(() => { 642 + let map = new Map<string, { title: string; path: string }>(); 643 + for (let doc of data?.documents || []) { 644 + let path = doc.record.path || ""; 645 + let normalized = path.startsWith("/") ? path : `/${path}`; 646 + map.set(normalized, { title: doc.record.title, path }); 647 + } 648 + return map; 649 + }, [data?.documents]); 650 + 651 + return ( 652 + <div className="flex flex-col w-full"> 653 + <h4 className="text-sm font-bold text-tertiary pb-1">Top Pages</h4> 654 + <div className="h-64 overflow-y-auto"> 655 + {props.isLoading && <ListSkeleton />} 656 + {!props.isLoading && props.pages.length === 0 && ( 657 + <div className="text-tertiary text-sm">No page data</div> 658 + )} 659 + {props.pages.map((page) => { 660 + let doc = docsByPath.get(page.path); 661 + let isSelected = selectedPostPath(props.selectedPost) === page.path; 662 + return ( 663 + <Fragment key={page.path}> 664 + <button 665 + className={`w-full flex justify-between gap-4 px-1 py-1.5 items-center text-sm rounded-md ${isSelected ? "text-accent-contrast bg-[var(--accent-light)]" : ""}`} 666 + onClick={() => { 667 + if (isSelected) { 668 + props.setSelectedPost(undefined); 669 + } else { 670 + let path = page.path.replace(/^\//, ""); 671 + props.setSelectedPost({ 672 + title: doc?.title || page.path, 673 + path, 674 + }); 675 + } 676 + }} 677 + > 678 + <div className="truncate text-left"> 679 + {doc ? ( 680 + <> 681 + <div className="truncate">{doc.title}</div> 682 + <div className="truncate text-tertiary text-xs"> 683 + {page.path} 684 + </div> 685 + </> 686 + ) : ( 687 + <div className="truncate">{page.path}</div> 688 + )} 689 + </div> 690 + <div className="shrink-0 tabular-nums"> 691 + {page.pageviews.toLocaleString()} 692 + </div> 693 + </button> 694 + <hr className="border-border-light last:hidden" /> 695 + </Fragment> 696 + ); 697 + })} 698 + </div> 699 + </div> 700 + ); 701 + }; 702 + 703 + function selectedPostPath( 704 + post: { title: string; path: string } | undefined, 705 + ): string | undefined { 706 + if (!post) return undefined; 707 + return post.path.startsWith("/") ? post.path : `/${post.path}`; 708 + } 709 + 448 710 const TopReferrors = (props: { 449 711 refferors: ReferrerType[]; 450 712 setSelectedReferror: (ref: ReferrerType) => void; 451 713 selectedReferror: ReferrerType | undefined; 714 + isLoading: boolean; 452 715 }) => { 453 716 return ( 454 - <div className="topReferrors flex flex-col gap-0.5 w-full sm:w-xs"> 455 - {props.refferors.map((ref) => { 456 - let selected = ref === props.selectedReferror; 457 - return ( 458 - <> 459 - <button 460 - key={ref.referrer_host} 461 - className={`w-full flex justify-between gap-4 px-1 items-center text-right rounded-md ${selected ? "text-accent-contrast bg-[var(--accent-light)]" : ""}`} 462 - onClick={() => { 463 - props.setSelectedReferror(ref); 464 - }} 465 - > 466 - <div className="flex gap-2 items-center grow"> 467 - {ref.referrer_host} 468 - </div> 469 - {ref.pageviews.toLocaleString()} 470 - </button> 471 - <hr className="border-border-light last:hidden" /> 472 - </> 473 - ); 474 - })} 717 + <div className="topReferrors flex flex-col w-full"> 718 + <h4 className="text-sm font-bold text-tertiary pb-1">Top Referrers</h4> 719 + <div className="h-64 overflow-y-auto"> 720 + {props.isLoading && <ListSkeleton />} 721 + {!props.isLoading && props.refferors.length === 0 && ( 722 + <div className="text-tertiary text-sm">No referrer data</div> 723 + )} 724 + {props.refferors.map((ref) => { 725 + let selected = ref === props.selectedReferror; 726 + return ( 727 + <Fragment key={ref.referrer_host}> 728 + <button 729 + className={`w-full flex justify-between gap-4 px-1 py-1.5 items-center text-right text-sm rounded-md ${selected ? "text-accent-contrast bg-[var(--accent-light)]" : ""}`} 730 + onClick={() => { 731 + props.setSelectedReferror(ref); 732 + }} 733 + > 734 + <div className="flex gap-2 items-center grow"> 735 + {ref.referrer_host} 736 + </div> 737 + {ref.pageviews.toLocaleString()} 738 + </button> 739 + <hr className="border-border-light last:hidden" /> 740 + </Fragment> 741 + ); 742 + })} 743 + </div> 475 744 </div> 476 745 ); 477 746 };
+7 -5
lib/tinybird.ts
··· 98 98 sql: ` 99 99 SELECT 100 100 toDate(fromUnixTimestamp64Milli(timestamp)) AS day, 101 - count() AS pageviews 101 + count() AS pageviews, 102 + uniq(deviceId) AS visitors 102 103 FROM analytics_events 103 104 WHERE eventType = 'pageview' 104 - AND origin = {{String(domain)}} 105 + AND domain(origin) = {{String(domain)}} 105 106 {% if defined(date_from) %} 106 107 AND fromUnixTimestamp64Milli(timestamp) >= parseDateTimeBestEffort({{String(date_from)}}) 107 108 {% end %} ··· 119 120 output: { 120 121 day: t.date(), 121 122 pageviews: t.uint64(), 123 + visitors: t.uint64(), 122 124 }, 123 125 }); 124 126 ··· 148 150 count() AS pageviews 149 151 FROM analytics_events 150 152 WHERE eventType = 'pageview' 151 - AND origin = {{String(domain)}} 153 + AND domain(origin) = {{String(domain)}} 152 154 AND referrer != '' 153 - AND domain(referrer) != domain({{String(domain)}}) 155 + AND domain(referrer) != {{String(domain)}} 154 156 {% if defined(date_from) %} 155 157 AND fromUnixTimestamp64Milli(timestamp) >= parseDateTimeBestEffort({{String(date_from)}}) 156 158 {% end %} ··· 200 202 count() AS pageviews 201 203 FROM analytics_events 202 204 WHERE eventType = 'pageview' 203 - AND origin = {{String(domain)}} 205 + AND domain(origin) = {{String(domain)}} 204 206 {% if defined(date_from) %} 205 207 AND fromUnixTimestamp64Milli(timestamp) >= parseDateTimeBestEffort({{String(date_from)}}) 206 208 {% end %}