From 28dd5bd619c14409d0faec8ebfaee6ed44ff27c7 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 25 Sep 2026 06:31:43 +0000 Subject: [PATCH] Find a property, campaign or project without scrolling for it The three list pages render every row the account owns, so finding one means scrolling: every property on the portfolio table, every campaign on the ads page, every project on the dashboard. Each now has a filter box that narrows the rows already in the page. Client-side on purpose. The rows stay server components; the box only decides which of them are hidden, so no keystroke waits on the eleven RPCs behind the analytics page, and the figures above each list keep describing the whole account rather than silently re-scoping to whatever was typed. Matching is case-insensitive substrings, ANDed across whitespace, over everything the row shows: name, url, status, plus what is only rendered as a badge. So "autoblog" narrows the dashboard to the projects that have it on, and "credits" finds the campaigns that stopped for want of them. Co-Authored-By: Claude Opus 5 (1M context) --- app/(app)/dashboard/ads/page.tsx | 307 +++++++++++++---------- app/(app)/dashboard/analytics/page.tsx | 82 +++++-- app/(app)/dashboard/page.tsx | 322 ++++++++++++++----------- components/list-filter.tsx | 155 ++++++++++++ lib/list-filter.ts | 64 +++++ tests/list-filter.test.ts | 65 +++++ 6 files changed, 690 insertions(+), 305 deletions(-) create mode 100644 components/list-filter.tsx create mode 100644 lib/list-filter.ts create mode 100644 tests/list-filter.test.ts diff --git a/app/(app)/dashboard/ads/page.tsx b/app/(app)/dashboard/ads/page.tsx index 01f0b46..51eee55 100644 --- a/app/(app)/dashboard/ads/page.tsx +++ b/app/(app)/dashboard/ads/page.tsx @@ -7,6 +7,12 @@ import { AccountTrend } from "@/components/ads/account-trend"; import { RangeTabs } from "@/components/ads/range-tabs"; import { StatSpark } from "@/components/ads/stat-spark"; import { StatsUnavailable } from "@/components/stats-unavailable"; +import { + ListFilter, + ListFilterEmpty, + ListFilterInput, + ListFilterRow, +} from "@/components/list-filter"; import { MediaSplitCard } from "@/components/ads/media-split-card"; import { deliveredClicks, @@ -121,6 +127,25 @@ export default async function AdsPage({ const today = utcToday(); const totals = sumSeries(series); + // Resolved once, up here, because the badge label is also part of what the + // filter box matches: typing "credits" finds the campaigns that stopped for + // want of them, which the raw status column does not say. + const displayById = new Map( + campaigns.map( + (c) => [c.id, campaignDisplayStatus(c, today, creditsAvailable)] as const, + ), + ); + const campaignFilterItems = campaigns.map((c) => ({ + id: c.id, + text: [ + c.name, + c.destination_domain ?? "", + c.ref_slug, + c.status, + displayById.get(c.id)?.label ?? "", + ].join(" "), + })); + return (
@@ -142,153 +167,167 @@ export default async function AdsPage({ you fund a daily budget — publishers earn crypto for the clicks.

- {campaigns.length > 0 && ( - <> - {/* One filter row, above everything it scopes: the stats, the chart - and the per-campaign figures all read the same slice. */} -
- - - - {range.hint} -
+ + {campaigns.length > 0 && ( + <> + {/* One filter row, above everything it scopes: the stats, the chart + and the per-campaign figures all read the same slice. - {statsFailed && } + The range tabs re-query the server; the name box next to them only + narrows the campaign list below, so the stats and chart keep + covering the whole account whatever is typed. */} +
+ + + + {range.hint} + {campaigns.length > 1 && ( + + )} +
- {/* Delivery first, revenue second. The tiles count every ad actually - shown — paid inventory plus free backfill — and name the split - underneath, so a range in which nothing was billable reports the - traffic it really carried instead of four zeros. Spend stays - strictly paid: it is money, and free backfill costs none. */} -
- } - /> - } - /> - - 0 - ? "nothing billable" - : undefined - } - spark={ p.spentCents} />} - /> -
+ {statsFailed && } - {/* Free backfill costs and earns nobody anything, so the reason it is - free is worth one line — otherwise a dashboard full of traffic and - an empty Spend tile reads as a billing fault. */} - {(totals.freeImpressions > 0 || totals.freeClicks > 0) && ( -

- Free-tier delivery is backfill: a campaign out of credits or daily - budget, or one running on a slot its own account owns. It fills - requests no paying advertiser wanted, bills nobody and earns nobody. -

- )} + {/* Delivery first, revenue second. The tiles count every ad actually + shown — paid inventory plus free backfill — and name the split + underneath, so a range in which nothing was billable reports the + traffic it really carried instead of four zeros. Spend stays + strictly paid: it is money, and free backfill costs none. */} +
+ } + /> + } + /> + + 0 + ? "nothing billable" + : undefined + } + spark={ p.spentCents} />} + /> +
-
- -
+ {/* Free backfill costs and earns nobody anything, so the reason it is + free is worth one line — otherwise a dashboard full of traffic and + an empty Spend tile reads as a billing fault. */} + {(totals.freeImpressions > 0 || totals.freeClicks > 0) && ( +

+ Free-tier delivery is backfill: a campaign out of credits or daily + budget, or one running on a slot its own account owns. It fills + requests no paying advertiser wanted, bills nobody and earns nobody. +

+ )} - {/* Which presentation the server chose, per fill. Below the chart - because it explains the delivery the chart plots rather than adding - a measure of its own. */} - {mediaSplitFailed ? (
- +
- ) : ( - - )} - - )} - {campaigns.length === 0 ? ( -
- No campaigns yet.{" "} - - Create your first ad - - . -
- ) : ( -
    - {campaigns.map((c) => { - // Range-scoped, so a row never contradicts the header above it. - const s = rangeById.get(c.id) ?? EMPTY_TOTALS; - // Same measure as the header tiles, or a campaign delivering only - // free backfill would read as a dead row under a live chart. - const impr = deliveredImpressions(s); - const clk = deliveredClicks(s); - const display = campaignDisplayStatus(c, today, creditsAvailable); - return ( -
  • -
    -
    - - {c.name} - -
    - {c.destination_domain} · {dollars(c.daily_budget_cents)}/day ·{" "} - {c.ref_slug} + {/* Which presentation the server chose, per fill. Below the chart + because it explains the delivery the chart plots rather than adding + a measure of its own. */} + {mediaSplitFailed ? ( +
    + +
    + ) : ( + + )} + + )} + + {campaigns.length === 0 ? ( +
    + No campaigns yet.{" "} + + Create your first ad + + . +
    + ) : ( +
      + {campaigns.map((c) => { + // Range-scoped, so a row never contradicts the header above it. + const s = rangeById.get(c.id) ?? EMPTY_TOTALS; + // Same measure as the header tiles, or a campaign delivering only + // free backfill would read as a dead row under a live chart. + const impr = deliveredImpressions(s); + const clk = deliveredClicks(s); + const display = displayById.get(c.id)!; + return ( + +
      +
      + + {c.name} + +
      + {c.destination_domain} · {dollars(c.daily_budget_cents)}/day ·{" "} + {c.ref_slug} +
      +
      +
      + + + + + {display.label} + + + Edit + +
      -
      - - - - - {display.label} - - - Edit - - -
      -
    -
    - - - - - {s.freeImpressions > 0 && ( +
    + + + + + {s.freeImpressions > 0 && ( + + )} + {/* The bid autobid is making for it right now; the campaign + page has the history and the reasons. */} + +
    + {!display.serving && ( +

    {display.hint}

    )} - - {/* The bid autobid is making for it right now; the campaign - page has the history and the reasons. */} - -
    - {!display.serving && ( -

    {display.hint}

    - )} -
  • - ); - })} -
- )} + + ); + })} + + )} + +
); } diff --git a/app/(app)/dashboard/analytics/page.tsx b/app/(app)/dashboard/analytics/page.tsx index 7b7a649..0bb9077 100644 --- a/app/(app)/dashboard/analytics/page.tsx +++ b/app/(app)/dashboard/analytics/page.tsx @@ -41,6 +41,12 @@ import { visitorsPartial, type VisitorTotals, } from "@/lib/tracker/visitors"; +import { + ListFilter, + ListFilterEmpty, + ListFilterInput, + ListFilterRow, +} from "@/components/list-filter"; import { TrackerAnalytics, type TrackerListItem, @@ -457,6 +463,17 @@ export default async function PortfolioAnalyticsPage({ (r) => r.samples === null && r.totals.humans > 0, ).length; + // The by-property table is the one list on this page long enough to need + // finding things in, so it gets a filter box. Everything above it — the + // verdict, the tiles, the stacked chart — stays portfolio-wide however the + // table is narrowed: those figures answer "how is the portfolio doing", and + // silently re-scoping them to a typed substring would be a different + // question. + const propertyFilterItems = rows.map(({ project }) => ({ + id: project.id, + text: [project.name, project.url, project.status].join(" "), + })); + return (
@@ -554,31 +571,42 @@ export default async function PortfolioAnalyticsPage({
-
-
-

By property

-

- Human visits per site against its own previous {days} days. - AI referrals are part of the human figure; bot crawls are - not. -

+ +
+
+

By property

+

+ Human visits per site against its own previous {days} days. + AI referrals are part of the human figure; bot crawls are + not. +

+
+
+ {rows.length > 1 && ( + + )} + + Manage projects + +
- - Manage projects - -
- - {missingSparklines > 0 && ( -

- Sparklines are shown for the {detailCount} highest-traffic - properties; {missingSparklines} more{" "} - {missingSparklines === 1 ? "has" : "have"} totals only over this - window. Pick a shorter range to see more of them. -

- )} + + + {missingSparklines > 0 && ( +

+ Sparklines are shown for the {detailCount} highest-traffic + properties; {missingSparklines} more{" "} + {missingSparklines === 1 ? "has" : "have"} totals only over + this window. Pick a shorter range to see more of them. +

+ )} +
{/* No projectId: this page aggregates across every project and @@ -705,8 +733,10 @@ function ProjectTrendTable({ rows }: { rows: ProjectRow[] }) { {rows.map(({ project, totals, visitors, trend, samples }) => ( - @@ -755,7 +785,7 @@ function ProjectTrendTable({ rows }: { rows: ProjectRow[] }) { {totals.bots.toLocaleString()} - + ))} diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index 2a541e4..9d668b9 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -7,6 +7,12 @@ import { BOTS_DEFINITION, HUMANS_DEFINITION, VISITORS_DEFINITION } from "@/lib/t import { fetchVisitorDailySeries } from "@/lib/tracker/visitors"; import { ProjectLogo } from "@/components/project-logo"; import { StatsUnavailable } from "@/components/stats-unavailable"; +import { + ListFilter, + ListFilterEmpty, + ListFilterInput, + ListFilterRow, +} from "@/components/list-filter"; import { backfillProjectLogo } from "@/app/actions/createProject"; import { getOrCreateDefaultOrg, isOrgWideRole, listUserOrgs, missingOrgSchema } from "@/lib/orgs"; import { listOrgTeam } from "@/app/actions/org-members"; @@ -132,6 +138,21 @@ export default async function DashboardPage({ const trafficByProject = traffic.data; const trafficFailed = traffic.failed; + // What the filter box matches on: everything the card shows apart from the + // traffic figures. The badges are in the text too, so "autoblog" narrows to + // the projects that have it on and "paused" to the paused ones. + const projectFilterItems = projects.map((p) => ({ + id: p.id, + text: [ + p.name, + p.url, + p.schedule, + p.status, + autoblogIds.has(p.id) ? "autoblog" : "", + socialIds.has(p.id) ? "social" : "", + ].join(" "), + })); + // Lazy backfill: any project still missing a logo gets one scraped // in the background on this dashboard hit. Fire-and-forget — the // tile shows a letter avatar until the next render after the write @@ -161,161 +182,172 @@ export default async function DashboardPage({ )}
-
-

Projects

-
- {FILTERS.map((f) => { - const active = f.id === status; - const n = counts[f.id]; - return ( - - {f.label} - {n > 0 && ( - - {n} - - )} - - ); - })} + +
+
+

Projects

+ {/* Filters the cards already on the page; the status tabs beside it + still go to the server, because the other statuses were never + fetched. */} + {projects.length > 1 && ( + + )} +
+
+ {FILTERS.map((f) => { + const active = f.id === status; + const n = counts[f.id]; + return ( + + {f.label} + {n > 0 && ( + + {n} + + )} + + ); + })} +
-
- - {projects && projects.length > 0 && trafficFailed && ( - - )} - {projects && projects.length > 0 ? ( -
    - {projects.map((p) => ( -
  • - -
    -
    - -
    -
    {p.name}
    -
    - {p.url} + {projects && projects.length > 0 && trafficFailed && ( + + )} + + {projects && projects.length > 0 ? ( +
      + {projects.map((p) => ( + + +
      +
      + +
      +
      {p.name}
      +
      + {p.url} +
      +
      + {autoblogIds.has(p.id) && ( + + Autoblog on + + )} + {socialIds.has(p.id) && ( + + Social on + + )} + {p.schedule} + {p.status !== "active" && ( + + {p.status} + + )} +
      -
      - {autoblogIds.has(p.id) && ( - - Autoblog on - - )} - {socialIds.has(p.id) && ( - - Social on - - )} - {p.schedule} - {p.status !== "active" && ( - - {p.status} - - )} -
      -
    - - {latestPosts.get(p.id) && ( - - )} -
    -
    -
    - {trafficFailed - ? "Traffic unavailable" - : visitorsByProject - ? `${sum(visitorsByProject.get(p.id)).toLocaleString()} human visitors` - : `${totalHumans(trafficByProject.get(p.id) ?? []).toLocaleString()} human events`} + + {latestPosts.get(p.id) && ( + -
    - {trafficFailed - ? "Query failed \u2014 not zero" - : `${visitorsByProject ? `${totalHumans(trafficByProject.get(p.id) ?? []).toLocaleString()} human events \u00b7 ` : ""}${totalBots(trafficByProject.get(p.id) ?? []).toLocaleString()} bot hits \u00b7 Past 7 days`} + )} +
    +
    +
    + {trafficFailed + ? "Traffic unavailable" + : visitorsByProject + ? `${sum(visitorsByProject.get(p.id)).toLocaleString()} human visitors` + : `${totalHumans(trafficByProject.get(p.id) ?? []).toLocaleString()} human events`} +
    +
    + {trafficFailed + ? "Query failed \u2014 not zero" + : `${visitorsByProject ? `${totalHumans(trafficByProject.get(p.id) ?? []).toLocaleString()} human events \u00b7 ` : ""}${totalBots(trafficByProject.get(p.id) ?? []).toLocaleString()} bot hits \u00b7 Past 7 days`} +
    + {!trafficFailed && ( + + )}
    - {!trafficFailed && ( - )} -
    - {orgSchemaReady && ( - - )} -
  • - ))} -
- ) : ( -

- {status === "active" ? ( - <> - No projects yet.{" "} - - Create one - - . - - ) : ( - `No ${status} projects.` - )} -

- )} + + ))} + + ) : ( +

+ {status === "active" ? ( + <> + No projects yet.{" "} + + Create one + + . + + ) : ( + `No ${status} projects.` + )} +

+ )} + +
diff --git a/components/list-filter.tsx b/components/list-filter.tsx new file mode 100644 index 0000000..45cc89c --- /dev/null +++ b/components/list-filter.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { + createContext, + useContext, + useId, + useMemo, + useState, + type ReactNode, +} from "react"; +import { matchingIds, type FilterItem } from "@/lib/list-filter"; + +/** + * A type-to-narrow filter over a list the server already rendered. + * + * Three pieces, so a page can put the box where it belongs while the rows stay + * server components: + * + * provider: owns the query + * + *
    {rows.map(r => …)}
+ * + *
+ * + * Nothing here fetches or navigates: the rows are already in the page, and a + * filter that hit the server would make every keystroke wait on eleven RPCs. + * It also means the aggregate figures above a list keep reporting the whole + * account — the filter narrows what you are looking at, not what was measured. + * + * Filtered-out rows keep their markup and take the `hidden` attribute rather + * than unmounting, so logos and sparklines do not reload when the box is + * cleared. That relies on the row's own classes not setting `display`, which + * would beat the UA rule behind `hidden`; every current caller is a plain + * block li or tr. + */ + +type FilterContext = { + query: string; + setQuery: (query: string) => void; + /** Ids to show, or null when the box is empty and everything shows. */ + visible: Set | null; + total: number; + matched: number; +}; + +const Ctx = createContext(null); + +function useFilter(component: string): FilterContext { + const value = useContext(Ctx); + if (!value) { + throw new Error(`<${component}> must be rendered inside `); + } + return value; +} + +export function ListFilter({ + items, + children, +}: { + /** Every row in the list, with the text it should match on. */ + items: readonly FilterItem[]; + children: ReactNode; +}) { + const [query, setQuery] = useState(""); + const visible = useMemo(() => matchingIds(items, query), [items, query]); + const value = useMemo( + () => ({ + query, + setQuery, + visible, + total: items.length, + matched: visible ? visible.size : items.length, + }), + [query, visible, items.length], + ); + return {children}; +} + +export function ListFilterInput({ + label, + noun, + className, +}: { + /** Accessible name, and the placeholder: "Filter projects". */ + label: string; + /** Plural noun for the count line: "3 of 21 projects". */ + noun: string; + className?: string; +}) { + const { query, setQuery, matched, total } = useFilter("ListFilterInput"); + const id = useId(); + const filtering = query.trim().length > 0; + return ( +
+ setQuery(event.target.value)} + // Escape clears without reaching for the mouse; the browser's own + // search-input clear button is not on every engine. + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + setQuery(""); + } + }} + aria-label={label} + placeholder={label} + autoComplete="off" + spellCheck={false} + className="w-44 rounded-lg border border-[var(--color-border)] bg-[var(--color-card)] px-3 py-1.5 text-sm text-[var(--color-fg)] placeholder:text-[var(--color-muted)] focus:outline-2 focus:outline-offset-1 focus:outline-[var(--color-accent)] sm:w-60" + /> + {filtering && ( + + {matched} of {total} {noun} + + )} +
+ ); +} + +export function ListFilterRow({ + id, + as: Tag = "div", + className, + children, +}: { + id: string; + as?: "div" | "li" | "tr"; + className?: string; + children: ReactNode; +}) { + const { visible } = useFilter("ListFilterRow"); + return ( + + ); +} + +/** The "nothing matched" line. Renders only when a query hid every row. */ +export function ListFilterEmpty({ noun }: { noun: string }) { + const { query, matched } = useFilter("ListFilterEmpty"); + const trimmed = query.trim(); + if (!trimmed || matched > 0) return null; + return ( +

+ No {noun} match “{trimmed}”. +

+ ); +} diff --git a/lib/list-filter.ts b/lib/list-filter.ts new file mode 100644 index 0000000..035d680 --- /dev/null +++ b/lib/list-filter.ts @@ -0,0 +1,64 @@ +/** + * Matching for the client-side filters on the dashboard lists. + * + * The dashboard lists (projects, ad campaigns, portfolio properties) are all + * rendered whole by the server: an account with forty properties gets forty + * cards, and finding one means scrolling. These helpers back a filter box that + * narrows the rendered list in the browser without a round trip — no new query, + * no new RPC, and the totals above the list keep describing the whole account + * rather than silently re-scoping to whatever was typed. + * + * Kept apart from the component so the matching rules are testable in the node + * test environment (tests/list-filter.test.ts); components/list-filter.tsx is + * the only caller. + */ + +/** One row of a filterable list: a stable id and everything it matches on. */ +export type FilterItem = { + id: string; + /** Name, URL, slug, status — whatever the row shows and a reader may type. */ + text: string; +}; + +/** + * Split a query into the terms every match must contain. + * + * Whitespace separates terms and they are ANDed, so "shop paused" finds the + * paused campaign for the shop rather than every row mentioning either word. + * Order does not matter, which is the point: the terms come from different + * parts of the row's text. + */ +export function filterTerms(query: string): string[] { + return query.toLowerCase().split(/\s+/).filter(Boolean); +} + +/** + * Case-insensitive substring match on every term. No terms matches anything. + * + * Terms are lowercased here as well as in `filterTerms`, so this holds up when + * called with a term that did not come through there. + */ +export function matchesTerms(text: string, terms: string[]): boolean { + if (terms.length === 0) return true; + const haystack = text.toLowerCase(); + return terms.every((term) => haystack.includes(term.toLowerCase())); +} + +/** + * The ids to keep for a query, or `null` when the query is empty. + * + * `null` rather than "every id": an empty box is the common case and means + * every row renders, including rows whose id was never registered. + */ +export function matchingIds( + items: readonly FilterItem[], + query: string, +): Set | null { + const terms = filterTerms(query); + if (terms.length === 0) return null; + const out = new Set(); + for (const item of items) { + if (matchesTerms(item.text, terms)) out.add(item.id); + } + return out; +} diff --git a/tests/list-filter.test.ts b/tests/list-filter.test.ts new file mode 100644 index 0000000..93cf5e3 --- /dev/null +++ b/tests/list-filter.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + filterTerms, + matchesTerms, + matchingIds, + type FilterItem, +} from "@/lib/list-filter"; + +// Shaped like what the dashboard pages pass: the row's name, url and status +// joined into one haystack. +const ITEMS: FilterItem[] = [ + { id: "p1", text: "Shop https://shop.example.com daily active" }, + { id: "p2", text: "Blog https://blog.example.com weekly paused" }, + { id: "p3", text: "Docs https://docs.other.dev daily active autoblog" }, +]; + +describe("filterTerms", () => { + it("lowercases and drops empty runs of whitespace", () => { + expect(filterTerms(" Shop Daily ")).toEqual(["shop", "daily"]); + }); + + it("is empty for a blank query, which matches everything", () => { + expect(filterTerms(" ")).toEqual([]); + expect(matchesTerms("anything", [])).toBe(true); + }); +}); + +describe("matchesTerms", () => { + it("matches case-insensitive substrings", () => { + expect(matchesTerms("Shop https://shop.example.com", ["SHOP"])).toBe(true); + expect(matchesTerms("Shop", ["sho"])).toBe(true); + expect(matchesTerms("Shop", ["shopping"])).toBe(false); + }); + + it("requires every term, in any order", () => { + const text = "Blog https://blog.example.com weekly paused"; + expect(matchesTerms(text, ["blog", "paused"])).toBe(true); + expect(matchesTerms(text, ["paused", "blog"])).toBe(true); + expect(matchesTerms(text, ["blog", "daily"])).toBe(false); + }); +}); + +describe("matchingIds", () => { + it("returns null for an empty query rather than every id", () => { + expect(matchingIds(ITEMS, "")).toBeNull(); + expect(matchingIds(ITEMS, " ")).toBeNull(); + }); + + it("narrows on name, url or status", () => { + expect(matchingIds(ITEMS, "shop")).toEqual(new Set(["p1"])); + expect(matchingIds(ITEMS, "example.com")).toEqual(new Set(["p1", "p2"])); + expect(matchingIds(ITEMS, "paused")).toEqual(new Set(["p2"])); + expect(matchingIds(ITEMS, "daily")).toEqual(new Set(["p1", "p3"])); + }); + + it("ANDs terms from different parts of the row", () => { + expect(matchingIds(ITEMS, "daily autoblog")).toEqual(new Set(["p3"])); + }); + + it("returns an empty set when nothing matches, which is not null", () => { + const result = matchingIds(ITEMS, "nothing-here"); + expect(result).toEqual(new Set()); + expect(result).not.toBeNull(); + }); +});