a tool for shared writing and social publishing
0

Configure Feed

Select the types of activity you want to include in your feed.

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>

Jared Pereira (Mar 4, 2026, 4:11 PM EST) 5125657c a1c8a4c0

+401 -116
+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 %}
+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 },
+370 -101
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"> 128 - <div 129 - className={`analyticsSubCount rounded-lg border ${ 130 - props.showPageBackground 131 - ? "border-border-light p-2" 132 - : "border-transparent" 133 - }`} 134 - style={{ 135 - backgroundColor: props.showPageBackground 136 - ? "rgba(var(--bg-page), var(--bg-page-alpha))" 137 - : "transparent", 138 - }} 139 - > 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} 146 - /> 147 - </div> 148 - <SubscribersChart data={subscribersData?.timeseries || []} /> 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 + /> 149 168 </div> 150 169 <div 151 170 className={`analyticsViewCount rounded-lg border ${ ··· 159 178 : "transparent", 160 179 }} 161 180 > 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} 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} 184 187 /> 188 + {selectedReferror && ( 189 + <> 190 + <ArrowRightTiny className="text-border" /> 191 + <div className="text-tertiary"> 192 + {" "} 193 + {selectedReferror.referrer_host} 194 + </div> 195 + </> 196 + )} 185 197 </div> 186 - <div className="flex gap-2"> 187 - <TrafficChart data={filledTraffic} /> 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 + /> 188 206 <TopReferrors 189 207 refferors={analyticsData?.topReferrers || []} 190 208 setSelectedReferror={setSelectedReferror} 191 209 selectedReferror={selectedReferror} 192 - />{" "} 210 + isLoading={analyticsLoading} 211 + /> 193 212 </div> 213 + </div> 214 + <div 215 + className={`analyticsSubCount rounded-lg border ${ 216 + props.showPageBackground 217 + ? "border-border-light p-2" 218 + : "border-transparent" 219 + }`} 220 + style={{ 221 + backgroundColor: props.showPageBackground 222 + ? "rgba(var(--bg-page), var(--bg-page-alpha))" 223 + : "transparent", 224 + }} 225 + > 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)" /> 251 - <YAxis 252 - tick={{ fontSize: 12 }} 253 - stroke="var(--tertiary)" 254 - allowDecimals={false} 355 + <AreaChart data={chartData}> 356 + <CartesianGrid 357 + strokeDasharray="3 3" 358 + stroke="var(--color-border-light)" 255 359 /> 256 - <Tooltip /> 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 + /> 369 + <YAxis 370 + tick={{ fontSize: 12, fill: "var(--color-secondary)" }} 371 + stroke="var(--color-border-light)" 372 + allowDecimals={false} 373 + tickFormatter={formatYTick} 374 + tickMargin={4} 375 + /> 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> 413 + </div> 414 + ); 415 + }; 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 + ))} 267 440 </div> 268 441 ); 269 442 }; ··· 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 };