Tabs as genres The same data serving different questions, each with its own page. Tabs named after questions beat tabs named after data sources, because the reader arrives with a question rather than a table name. npx shadcn@latest add tabs button dropdown-menu npm i lucide-react Tokens this needs: --card, --card-foreground, --muted, --muted-foreground, --border, --chart-1 ──────────────────────────────────────────────────────────────────────── // GenreTabs.tsx ──────────────────────────────────────────────────────────────────────── import type { ReactNode } from "react"; import { ChevronDown } from "lucide-react"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; /** What every tab receives. Range and filters live above the strip and * carry across; a tab that kept its own would be a separate dashboard. */ export type Shared = { range: string; filter: string }; export type Genre = { id: string; /** Named from the reader's question, never from a table. */ name: string; /** The question, spelled out. Required: a tab that cannot state the * question it answers is named after a data source. Shown under the * strip so a reader knows what is on a tab before opening it. */ question: string; /** This tab's one-line answer right now. The overview is built from * these, which is what makes it a router rather than a leftovers drawer. */ answer: string; view: (shared: Shared) => ReactNode; }; /** With Overview, four to six tabs. Nine is a menu, and a menu is a sidebar. */ export type Genres = [Genre, Genre, Genre] | [Genre, Genre, Genre, Genre] | [Genre, Genre, Genre, Genre, Genre]; export const RANGES = ["24h", "7d", "28d", "90d"] as const; export function GenreTabs({ genres, topline, shared, onShared, onFilter, tab, onTab }: { genres: Genres; /** The top question's answer, for the reader who wants one number. */ topline: ReactNode; shared: Shared; onShared: (next: Shared) => void; onFilter: () => void; /** "overview" or a genre id. Put it in the URL so a link opens the right tab. */ tab: string; onTab: (id: string) => void; }) { const current = genres.find((g) => g.id === tab); return (

{shared.filter}

{RANGES.map((r) => onShared({ ...shared, range: r })}>last {r})}
Overview {genres.map((g) => ( {g.name} ))}
{current ? ( <>

{current.question}

{current.answer}

{current.view(shared)}
) : ( <> {/* Answer the top question, then route. */}
{genres.map((g) => ( ))}
{topline}
)}
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { useState } from "react"; import { GenreTabs, type Genres, type Shared } from "./GenreTabs"; const figure = (label: string, value: string) => (

{label}

{value}

); const grid = (...items: ReturnType[]) =>
{items}
; /** One publication's week, four readers. The editor, the growth lead, the * ad ops lead and whoever wants to know where people came from. */ const GENRES: Genres = [ { id: "content", name: "Content", question: "What did we publish, and how did it do?", answer: "Nine posts; three passed 20k reads and the Okafor interview did 41k.", view: (s) => grid(figure(`posts, last ${s.range}`, "9"), figure("top post reads", "41,200")) }, { id: "audience", name: "Audience", question: "Who are they, and are they coming back?", answer: "184k readers, 62% new; returning readers up 4 pts.", view: (s) => grid(figure(`readers, last ${s.range}`, "184,300"), figure("returning", "38%")) }, { id: "revenue", name: "Revenue", question: "What did it earn?", answer: "£12,400, with ad fill at 91% and 212 new members.", view: (s) => grid(figure(`revenue, last ${s.range}`, "£12,400"), figure("new members", "212")) }, { id: "sources", name: "Sources", question: "Where did readers come from?", answer: "Search 48%, newsletter 22%, social 14%; search up 9 pts.", view: (s) => grid(figure(`search share, last ${s.range}`, "48%"), figure("newsletter share", "22%")) }, ]; /** Lands on Overview. The range in the header is the range on every tab. */ export default function Demo() { const [shared, setShared] = useState({ range: "7d", filter: "konigi.com · all posts" }); const [tab, setTab] = useState("overview"); return ( 184,300 readers this week, up 6% on the week before.

} shared={shared} onShared={setShared} onFilter={() => {}} tab={tab} onTab={setTab} /> ); }