a tool for shared writing and social publishing
0

Configure Feed

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

add query params for analytics state

Jared Pereira (Mar 11, 2026, 5:46 PM EDT) f0d1d29c c17f446f

+219 -58
+130 -58
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 { Fragment, useMemo, useState } from "react"; 5 + import { Fragment, useCallback, useMemo, useState } from "react"; 6 6 import { useLocalizedDate } from "src/hooks/useLocalizedDate"; 7 7 import type { DateRange } from "react-day-picker"; 8 + import { useQueryState } from "src/hooks/useQueryState"; 8 9 import { usePublicationData } from "./PublicationSWRProvider"; 9 10 import { Combobox, ComboboxResult } from "components/Combobox"; 10 11 import { useIsPro, useCanSeePro } from "src/hooks/useEntitlement"; ··· 22 23 23 24 type ReferrerType = { referrer_host: string; pageviews: number }; 24 25 type TrafficMetric = "pageviews" | "visitors"; 26 + type DatePreset = "Last Week" | "Last Month" | "All Time"; 27 + type DateSelection = { range: DateRange; preset: DatePreset | null }; 25 28 26 29 const dayTickFormatter = new Intl.DateTimeFormat(undefined, { 27 30 month: "short", ··· 89 92 let canSeePro = useCanSeePro(); 90 93 91 94 let { data: publication } = usePublicationData(); 92 - let [dateRange, setDateRange] = useState<DateRange>(() => { 93 - let from = new Date(); 94 - from.setDate(from.getDate() - 7); 95 - return { from, to: new Date() }; 95 + 96 + // URL params: 97 + // ?date=all → All Time (no from/to bounds) 98 + // ?date=month → Last Month preset 99 + // ?from=YYYY-MM-DD&to=YYYY-MM-DD → custom range 100 + // (none) → default: Last Week 101 + let [dateState, setDateState] = useQueryState<DateSelection>({ 102 + fromParams: (get) => { 103 + let preset = get("date"); 104 + if (preset === "all") 105 + return { 106 + range: { from: undefined, to: undefined }, 107 + preset: "All Time", 108 + }; 109 + if (preset === "month") { 110 + let from = new Date(); 111 + from.setMonth(from.getMonth() - 1); 112 + return { range: { from, to: new Date() }, preset: "Last Month" }; 113 + } 114 + let fromParam = get("from"); 115 + let toParam = get("to"); 116 + if (fromParam || toParam) { 117 + return { 118 + range: { 119 + from: fromParam ? new Date(fromParam) : undefined, 120 + to: toParam ? new Date(toParam) : new Date(), 121 + }, 122 + preset: null, 123 + }; 124 + } 125 + let from = new Date(); 126 + from.setDate(from.getDate() - 7); 127 + return { range: { from, to: new Date() }, preset: "Last Week" }; 128 + }, 129 + toParams: ({ preset, range }) => { 130 + if (preset === "All Time") 131 + return { date: "all", from: null, to: null }; 132 + if (preset === "Last Month") 133 + return { date: "month", from: null, to: null }; 134 + if (preset === "Last Week") return { date: null, from: null, to: null }; 135 + return { 136 + date: null, 137 + from: range.from?.toISOString().slice(0, 10) ?? null, 138 + to: range.to?.toISOString().slice(0, 10) ?? null, 139 + }; 140 + }, 96 141 }); 97 - let [datePreset, setDatePreset] = useState<string | null>("Last Week"); 98 - let [selectedPost, setSelectedPost] = useState< 99 - { title: string; path: string } | undefined 100 - >(undefined); 101 - let [selectedReferror, setSelectedReferror] = useState< 102 - ReferrerType | undefined 103 - >(undefined); 104 - let [trafficMetric, setTrafficMetric] = 105 - useState<TrafficMetric>("visitors"); 142 + let { range: dateRange, preset: datePreset } = dateState; 143 + 144 + // ?post=<path> — filter traffic to a single published post (path is the stable ID) 145 + let [selectedPostPath, setSelectedPostPath] = useQueryState< 146 + string | undefined 147 + >({ 148 + fromParams: (get) => get("post") ?? undefined, 149 + toParams: (v) => ({ post: v ?? null }), 150 + }); 151 + let documents = publication?.documents; 152 + let selectedPost = useMemo(() => { 153 + if (!selectedPostPath) return undefined; 154 + let doc = documents?.find( 155 + (d) => (d.record.path || "") === selectedPostPath, 156 + ); 157 + return { 158 + title: doc?.record.title || selectedPostPath, 159 + path: selectedPostPath, 160 + }; 161 + }, [selectedPostPath, documents]); 162 + let setSelectedPost = useCallback( 163 + (post: { title: string; path: string } | undefined) => { 164 + setSelectedPostPath(post?.path); 165 + }, 166 + [setSelectedPostPath], 167 + ); 168 + 169 + // ?referrer=<host> — filter traffic to a single referring domain 170 + let [selectedReferrer, setSelectedReferrer] = useQueryState< 171 + string | undefined 172 + >({ 173 + fromParams: (get) => get("referrer") ?? undefined, 174 + toParams: (v) => ({ referrer: v ?? null }), 175 + }); 176 + 177 + // ?metric=pageviews — toggle between "visitors" (default) and "pageviews" 178 + let [trafficMetric, setTrafficMetric] = useQueryState<TrafficMetric>({ 179 + fromParams: (get) => 180 + get("metric") === "pageviews" ? "pageviews" : "visitors", 181 + toParams: (v) => ({ metric: v === "visitors" ? null : v }), 182 + }); 106 183 107 184 let publicationUri = publication?.publication?.uri; 108 185 ··· 114 191 dateRange.from?.toISOString(), 115 192 dateRange.to?.toISOString(), 116 193 selectedPost?.path, 117 - selectedReferror?.referrer_host, 194 + selectedReferrer, 118 195 ] 119 196 : null, 120 197 async () => { ··· 123 200 ...(dateRange.from ? { from: dateRange.from.toISOString() } : {}), 124 201 ...(dateRange.to ? { to: endOfDay(dateRange.to).toISOString() } : {}), 125 202 ...(selectedPost ? { path: `/${selectedPost.path}` } : {}), 126 - ...(selectedReferror 127 - ? { referrer_host: selectedReferror.referrer_host } 128 - : {}), 203 + ...(selectedReferrer ? { referrer_host: selectedReferrer } : {}), 129 204 }); 130 205 return res?.result; 131 206 }, ··· 185 260 <div className="analytics flex flex-col gap-6"> 186 261 <div className="flex justify-end gap-2"> 187 262 <DateRangeSelector 188 - dateRange={dateRange} 189 - setDateRange={setDateRange} 190 - datePreset={datePreset} 191 - setDatePreset={setDatePreset} 263 + dateState={dateState} 264 + setDateState={setDateState} 192 265 pubStartDate={publication?.publication?.indexed_at} 193 266 showBackground={props.showPageBackground} 194 267 /> ··· 213 286 selectedPost={selectedPost} 214 287 setSelectedPost={setSelectedPost} 215 288 /> 216 - {selectedReferror && ( 289 + {selectedReferrer && ( 217 290 <> 218 291 <ArrowRightTiny className="text-border" /> 219 292 <div className="text-tertiary"> 220 293 {" "} 221 - {selectedReferror.referrer_host} 294 + {selectedReferrer} 222 295 </div> 223 296 </> 224 297 )} ··· 233 306 /> 234 307 <TopReferrors 235 308 refferors={analyticsData?.topReferrers || []} 236 - setSelectedReferror={setSelectedReferror} 237 - selectedReferror={selectedReferror} 309 + setSelectedReferrer={setSelectedReferrer} 310 + selectedReferrer={selectedReferrer} 238 311 isLoading={analyticsLoading} 239 312 /> 240 313 </div> ··· 569 642 570 643 const DateRangeSelector = (props: { 571 644 pubStartDate: string | undefined; 572 - dateRange: DateRange; 573 - setDateRange: (dateRange: DateRange) => void; 574 - datePreset: string | null; 575 - setDatePreset: (preset: string | null) => void; 645 + dateState: DateSelection; 646 + setDateState: (state: DateSelection) => void; 576 647 showBackground?: boolean; 577 648 }) => { 649 + let { range: dateRange, preset: datePreset } = props.dateState; 578 650 let buttonClass = 579 651 "rounded-md px-1 text-sm border border-accent-contrast text-accent-contrast"; 580 652 581 653 let currentDate = new Date(); 582 654 583 655 let startDate = useLocalizedDate( 584 - props.dateRange.from?.toISOString() || 656 + dateRange.from?.toISOString() || 585 657 props.pubStartDate || 586 658 "2025-01-01T12:00:00.000Z", 587 659 { ··· 591 663 ); 592 664 593 665 let endDate = useLocalizedDate( 594 - (props.dateRange.to ?? currentDate).toISOString(), 666 + (dateRange.to ?? currentDate).toISOString(), 595 667 { 596 668 month: "short", 597 669 day: "numeric", 598 670 }, 599 671 ); 600 672 601 - function handleDateChange(range: DateRange | undefined) { 602 - if (range) { 603 - props.setDateRange(range); 604 - props.setDatePreset(null); 605 - } 606 - } 607 - 608 673 return ( 609 674 <Popover 610 675 className={"w-fit"} ··· 612 677 <div 613 678 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" : ""}`} 614 679 > 615 - {props.datePreset 616 - ? props.datePreset 617 - : props.dateRange.from === undefined 680 + {datePreset 681 + ? datePreset 682 + : dateRange.from === undefined 618 683 ? "All Time" 619 684 : `${startDate} - ${endDate}`} 620 685 </div> ··· 622 687 > 623 688 <div className="flex gap-2 pt-1"> 624 689 <button 625 - onClick={() => { 626 - props.setDateRange({ from: undefined, to: undefined }); 627 - props.setDatePreset("All Time"); 628 - }} 690 + onClick={() => 691 + props.setDateState({ 692 + range: { from: undefined, to: undefined }, 693 + preset: "All Time", 694 + }) 695 + } 629 696 className={`${buttonClass}`} 630 697 > 631 698 All ··· 634 701 onClick={() => { 635 702 let from = new Date(); 636 703 from.setDate(from.getDate() - 7); 637 - props.setDateRange({ from, to: new Date() }); 638 - props.setDatePreset("Last Week"); 704 + props.setDateState({ 705 + range: { from, to: new Date() }, 706 + preset: "Last Week", 707 + }); 639 708 }} 640 709 className={`${buttonClass}`} 641 710 > ··· 645 714 onClick={() => { 646 715 let from = new Date(); 647 716 from.setMonth(from.getMonth() - 1); 648 - props.setDateRange({ from, to: new Date() }); 649 - props.setDatePreset("Last Month"); 717 + props.setDateState({ 718 + range: { from, to: new Date() }, 719 + preset: "Last Month", 720 + }); 650 721 }} 651 722 className={`${buttonClass}`} 652 723 > ··· 656 727 <hr className="my-2 border-border-light" /> 657 728 <DatePicker 658 729 mode="range" 659 - selected={props.dateRange} 660 - onSelect={handleDateChange} 730 + selected={dateRange} 731 + onSelect={(range) => { 732 + if (range) props.setDateState({ range, preset: null }); 733 + }} 661 734 disabled={(date) => date > new Date()} 662 735 /> 663 736 </Popover> ··· 742 815 743 816 const TopReferrors = (props: { 744 817 refferors: ReferrerType[]; 745 - setSelectedReferror: (ref: ReferrerType | undefined) => void; 746 - selectedReferror: ReferrerType | undefined; 818 + setSelectedReferrer: (host: string | undefined) => void; 819 + selectedReferrer: string | undefined; 747 820 isLoading: boolean; 748 821 }) => { 749 822 return ( ··· 755 828 <div className="text-tertiary text-sm">No referrer data</div> 756 829 )} 757 830 {props.refferors.map((ref) => { 758 - let selected = 759 - props.selectedReferror?.referrer_host === ref.referrer_host; 831 + let selected = props.selectedReferrer === ref.referrer_host; 760 832 return ( 761 833 <Fragment key={ref.referrer_host}> 762 834 <button 763 835 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)]" : ""}`} 764 836 onClick={() => { 765 837 if (selected) { 766 - props.setSelectedReferror(undefined); 838 + props.setSelectedReferrer(undefined); 767 839 } else { 768 - props.setSelectedReferror(ref); 840 + props.setSelectedReferrer(ref.referrer_host); 769 841 } 770 842 }} 771 843 >
+89
src/hooks/useQueryState.ts
··· 1 + import { useCallback, useRef, useState } from "react"; 2 + import { useSearchParams } from "next/navigation"; 3 + 4 + /** 5 + * Syncs React state to URL query parameters so filter selections persist 6 + * across page refreshes and are shareable via URL. 7 + * 8 + * Uses `window.history.replaceState` for synchronous URL updates (no 9 + * Next.js router transition overhead). Next.js 14+ automatically syncs 10 + * its hooks (`useSearchParams`, etc.) when the history API is called. 11 + * 12 + * Each parameter is defined by a `QueryParam<T>` config that controls how 13 + * a value of type `T` is serialized to/from one or more query string keys. 14 + * 15 + * @example 16 + * // Simple string param (?metric=pageviews, default "visitors") 17 + * let [metric, setMetric] = useQueryState<"visitors" | "pageviews">({ 18 + * toParams: (v) => ({ metric: v === "visitors" ? null : v }), 19 + * fromParams: (get) => get("metric") === "pageviews" ? "pageviews" : "visitors", 20 + * }); 21 + * 22 + * // Optional value with multi-key serialization (?post=my-slug) 23 + * let [postPath, setPostPath] = useQueryState<string | undefined>({ 24 + * fromParams: (get) => get("post") ?? undefined, 25 + * toParams: (v) => ({ post: v ?? null }), 26 + * }); 27 + */ 28 + 29 + /** Configuration for a single piece of query-synced state. */ 30 + export type QueryParam<T> = { 31 + /** 32 + * Deserialize from query params → state value. 33 + * Called once on mount to initialize from the URL. 34 + * Should return the default value when no relevant params are present. 35 + * @param get - reads a single query param by key (returns `string | null`) 36 + */ 37 + fromParams: (get: (key: string) => string | null) => T; 38 + 39 + /** 40 + * Serialize state value → query param updates. 41 + * Return a record of key → string (to set) or key → null (to remove). 42 + * Keys set to `null` are deleted from the URL, keeping it clean for defaults. 43 + */ 44 + toParams: (value: T) => Record<string, string | null>; 45 + }; 46 + 47 + /** 48 + * Like `useState`, but the value is initialized from URL query params on mount 49 + * and the URL is updated synchronously via `history.replaceState` whenever 50 + * the setter is called. 51 + * 52 + * Returns `[value, setValue]` — a drop-in replacement for `useState`. 53 + */ 54 + export function useQueryState<T>( 55 + config: QueryParam<T>, 56 + ): [T, (value: T) => void] { 57 + let searchParams = useSearchParams(); 58 + 59 + // Use a ref for toParams so the setter callback identity is stable 60 + // even when config is an inline object literal. 61 + let toParamsRef = useRef(config.toParams); 62 + toParamsRef.current = config.toParams; 63 + 64 + let [value, _setValue] = useState<T>(() => 65 + config.fromParams((key) => searchParams.get(key)), 66 + ); 67 + 68 + let setValue = useCallback((next: T) => { 69 + _setValue(next); 70 + let updates = toParamsRef.current(next); 71 + // Read directly from window.location so we always merge against 72 + // the latest URL, even if multiple setters fire in the same frame. 73 + let params = new URLSearchParams(window.location.search); 74 + for (let [key, v] of Object.entries(updates)) { 75 + if (v === null) { 76 + params.delete(key); 77 + } else { 78 + params.set(key, v); 79 + } 80 + } 81 + let qs = params.toString(); 82 + let url = qs 83 + ? `${window.location.pathname}?${qs}` 84 + : window.location.pathname; 85 + window.history.replaceState(history.state, "", url); 86 + }, []); 87 + 88 + return [value, setValue]; 89 + }