Skip to content

Commit 7d86931

Browse files
committed
feat(dashboard): composable Chart/List v2 and overview traffic trends
- Add ui/composables/chart.tsx and list.tsx; chart-query-outcome helper - Migrate list/chart call sites from data-list/data-chart paths - Extract TrafficTrendsChart; wrap metrics chart with Chart shell + minimal loading - MetricsChart: embedded mode, drop unused title/description props - Update design-system, codebase-map, and query-outcome JSDoc
1 parent 48da01b commit 7d86931

File tree

26 files changed

+922
-419
lines changed

26 files changed

+922
-419
lines changed

.agents/skills/databuddy/references/codebase-map.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Use this file when the task spans multiple packages or when the right edit locat
1515
- client hooks
1616
- auth-aware frontend flows
1717
- query and mutation consumers
18-
- **DataList** (`components/data-list.tsx`): compound component for all list views. See `design-system.mdc` for patterns.
18+
- **List** (`components/ui/composables/list.tsx`) and **Chart** (`components/ui/composables/chart.tsx`): compound shells for list pages and charts; see `design-system.mdc` for patterns.
1919
- Agent chat: [`contexts/chat-context.tsx`](/Users/iza/Dev/Databuddy/apps/dashboard/contexts/chat-context.tsx)`useChat` must start with `messages: []` on server **and** first client paint; restore `getMessagesFromLocal` in `useLayoutEffect` and gate `saveMessagesToLocal` until `hasRestoredFromLocal`, or SSR/hydration will disagree (empty vs persisted thread). UI: [`agent-messages.tsx`](/Users/iza/Dev/Databuddy/apps/dashboard/app/(main)/websites/[id]/agent/_components/agent-messages.tsx) merges consecutive identical tool labels (`formatToolLabel`) when the same tool+input repeats (`· 2×`); bottom `StreamingIndicator` only when no assistant text **and** no active tool label (`displayMessage`), so the in-message `ToolStep` is not duplicated by the shimmer.
2020
- Analytics agent web search: [`web-search.ts`](/Users/iza/Dev/Databuddy/apps/api/src/ai/tools/web-search.ts) (Perplexity). Tool labels in dashboard [`tool-display.tsx`](/Users/iza/Dev/Databuddy/apps/dashboard/lib/tool-display.tsx).
2121

.cursor/rules/design-system.mdc

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,27 @@ This file is **intentionally small**. It grows as we lock patterns. **Interactio
2424
- Use `flex-wrap` with `gap-x-5 gap-y-1` so the bar wraps cleanly on mobile without breaking the 10px grid.
2525
- Canonical reference: `apps/dashboard/app/(main)/monitors/[id]/page.tsx` → `StatusIndicator` + stats bar.
2626

27-
## DataList — list pages
27+
## Composables v2 — `List` (list pages)
2828

29-
All list views use the `DataList` compound component (`components/data-list.tsx`). Wrap in `<DataList className="rounded bg-card">` — no border on root.
29+
**Prefer** `components/ui/composables/` for new list and chart shells — this is the v2 pattern meant to replace ad-hoc list/chart layouts elsewhere.
3030

31-
- **Row defaults:** `DataList.Row` defaults to `align="center"` (vertical center). Use `align="start"` when multi-line text should stay top-aligned (monitors, goals, anomalies). `w-full` is built-in — don't repeat it.
31+
All list views use the `List` compound component (`components/ui/composables/list.tsx`). Wrap in `<List className="rounded bg-card">` — no border on root.
32+
33+
- **Row defaults:** `List.Row` defaults to `align="center"` (vertical center). Use `align="start"` when multi-line text should stay top-aligned (monitors, goals, anomalies). `w-full` is built-in — don't repeat it.
3234
- **Cell defaults:** `shrink-0` and `min-w-0` are built-in — don't repeat them. `text-start` is the browser default — don't add it.
3335
- **Inactive/paused state:** `opacity-50` on the Row, not a badge.
3436
- **`pt-0.5`** on non-text cells (icons, stats, actions) to align with text baseline in `items-start` rows. Omit in `items-center` rows.
3537
- **Icon containers:** `size-8 rounded` with semantic `bg-{color}-500/10 text-{color}-600 dark:text-{color}-400`. Use a `TYPE_CONFIG` const map when multiple types exist.
36-
- **Skeletons:** plain `div`s, not `DataList.Row`. Merge name + secondary into one `flex-1` block.
38+
- **Skeletons:** plain `div`s, not `List.Row`. Merge name + secondary into one `flex-1` block.
3739
- Canonical refs: `monitor-row.tsx`, `funnel-item.tsx`, `goal-item.tsx`, `flags-list.tsx`.
3840

41+
## Composables v2 — `Chart` (chart shells)
42+
43+
Use the `Chart` compound component (`components/ui/composables/chart.tsx`) with `chart-query-outcome.ts` the same way lists use `List` + `list-query-outcome`: wrap the shell in `<Chart>`, put Recharts (or any plot) inside `<Chart.Plot>`, optional `<Chart.Header>` / `<Chart.Footer>`, and drive loading / empty / error with `<Chart.Content query={…}>` or `outcome={…}`. Default loading is `<Chart.DefaultLoading />`. Canonical ref: `components/charts/simple-metrics-chart.tsx`.
44+
45+
- **Partial / in-progress period:** `SimpleMetricsChart` supports `partialLastSegment` (dashed stroke on the last segment via `use-dynamic-dasharray.ts`, same split rule as `metrics-chart.tsx`). Enable when the final bucket may still be filling (e.g. vitals trend). Main overview traffic chart uses `MetricsChart`, not `SimpleMetricsChart`.
46+
- **Stat card mini charts:** `StatCard` (`analytics/stat-card.tsx`) uses the `Chart` shell (`Chart` → `Chart.Plot` → `Chart.Footer` with `border-t-0` so the stat row matches the old card). `MiniChart` uses the same dash helper for **area** and **line** (not bar). `partialLastSegment` defaults to **true**; set `false` for fully historical series.
47+
3948
## Component reuse principle
4049

4150
Before building any new UI — a section, a pattern, a data display — **search the codebase for an existing component that does the same thing**. If one exists, use it. If it almost fits, extend it with props instead of forking. Duplicate implementations drift in styling and behavior over time.
@@ -49,6 +58,7 @@ Before building any new UI — a section, a pattern, a data display — **search
4958
- Dashboard UI: `apps/dashboard/` (components co-located with features unless shared).
5059
- Docs / marketing: `apps/docs/components/landing/` for shared section-level components; page-specific components stay co-located in their route folder.
5160
- Shared primitives: existing `@/components/ui` and layout folders—reuse before adding parallel systems.
61+
- **Composables v2:** list and chart shells live in `@/components/ui/composables/` (`list.tsx`, `chart.tsx`); prefer these for new work.
5262

5363
## Maintaining this rule (required)
5464

apps/dashboard/app/(main)/monitors/page.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
import { useQuery } from "@tanstack/react-query";
1010
import { Suspense, useState } from "react";
1111
import { PageHeader } from "@/app/(main)/websites/_components/page-header";
12-
import { DataList } from "@/components/data-list";
12+
import { List } from "@/components/ui/composables/list";
1313
import { ErrorBoundary } from "@/components/error-boundary";
1414
import { FeatureAccessGate } from "@/components/feature-access-gate";
1515
import { MonitorRow } from "@/components/monitors/monitor-row";
@@ -134,9 +134,9 @@ export default function MonitorsPage() {
134134

135135
<FeatureAccessGate
136136
flagKey="monitors"
137-
loadingFallback={<DataList.DefaultLoading />}
137+
loadingFallback={<List.DefaultLoading />}
138138
>
139-
<DataList.Content<Monitor>
139+
<List.Content<Monitor>
140140
emptyProps={{
141141
action: {
142142
label: "Create Your First Monitor",
@@ -160,7 +160,7 @@ export default function MonitorsPage() {
160160
query={schedulesQuery as ListQuerySlice<Monitor>}
161161
>
162162
{(items) => (
163-
<DataList className="rounded bg-card">
163+
<List className="rounded bg-card">
164164
{items.map((monitor) => (
165165
<MonitorRow
166166
key={monitor.id}
@@ -170,9 +170,9 @@ export default function MonitorsPage() {
170170
schedule={monitor}
171171
/>
172172
))}
173-
</DataList>
173+
</List>
174174
)}
175-
</DataList.Content>
175+
</List.Content>
176176
</FeatureAccessGate>
177177

178178
{isSheetOpen && (

apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx

Lines changed: 12 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { CursorIcon } from "@phosphor-icons/react/dist/ssr/Cursor";
55
import { GlobeIcon } from "@phosphor-icons/react/dist/ssr/Globe";
66
import { TimerIcon } from "@phosphor-icons/react/dist/ssr/Timer";
77
import { UsersIcon } from "@phosphor-icons/react/dist/ssr/Users";
8-
import { WarningIcon } from "@phosphor-icons/react/dist/ssr/Warning";
98
import type { ColumnDef } from "@tanstack/react-table";
109
import { useAtom } from "jotai";
1110
import dynamic from "next/dynamic";
@@ -16,9 +15,7 @@ import {
1615
StatCard,
1716
UnauthorizedAccessError,
1817
} from "@/components/analytics";
19-
import { MetricsChartWithAnnotations } from "@/components/charts/metrics-chart-with-annotations";
2018
import { BrowserIcon, OSIcon } from "@/components/icon";
21-
import { SectionBrandOverlay } from "@/components/logo/section-brand-overlay";
2219
import { DataTable } from "@/components/table/data-table";
2320
import {
2421
createMetricColumns,
@@ -39,6 +36,7 @@ import {
3936
} from "../utils/analytics-helpers";
4037
import { PercentageBadge } from "../utils/technology-helpers";
4138
import type { FullTabProps, MetricPoint } from "../utils/types";
39+
import { TrafficTrendsChart } from "./overview/_components/traffic-trends-chart";
4240

4341
const GeoMapSection = dynamic(() =>
4442
import("./overview/_components/geo-map-section").then((mod) => ({
@@ -218,7 +216,7 @@ export function WebsiteOverviewTab({
218216
[dateRange.granularity, filters, previousPeriodRange]
219217
);
220218

221-
const { isLoading, error, getDataForQuery } = useBatchDynamicQuery(
219+
const { isLoading, isError, error, getDataForQuery } = useBatchDynamicQuery(
222220
websiteId,
223221
dateRange,
224222
queries
@@ -940,52 +938,16 @@ export function WebsiteOverviewTab({
940938
))}
941939
</div>
942940

943-
{/* Chart */}
944-
<div className="rounded border bg-sidebar">
945-
<div className="flex flex-row items-start justify-between gap-3 border-b px-3 py-2.5 sm:items-center sm:px-4 sm:py-3">
946-
<div className="min-w-0 flex-1">
947-
<h2 className="text-balance font-semibold text-base text-sidebar-foreground sm:text-lg">
948-
Traffic Trends
949-
</h2>
950-
<p className="text-pretty text-sidebar-foreground/70 text-xs sm:text-sm">
951-
{dateRange.granularity === "hourly" ? "Hourly" : "Daily"} traffic
952-
data
953-
</p>
954-
{dateRange.granularity === "hourly" && dateDiff > 7 && (
955-
<div className="mt-1 flex items-start gap-1 text-amber-600 text-xs">
956-
<WarningIcon
957-
className="mt-0.5 shrink-0"
958-
size={14}
959-
weight="fill"
960-
/>
961-
<span className="leading-relaxed">
962-
Large date ranges may affect performance
963-
</span>
964-
</div>
965-
)}
966-
</div>
967-
<SectionBrandOverlay layout="inline" />
968-
</div>
969-
<div className="overflow-x-auto">
970-
<MetricsChartWithAnnotations
971-
className="rounded border-0"
972-
data={chartData}
973-
dateRange={{
974-
startDate: new Date(dateRange.start_date),
975-
endDate: new Date(dateRange.end_date),
976-
granularity: dateRange.granularity as
977-
| "hourly"
978-
| "daily"
979-
| "weekly"
980-
| "monthly",
981-
}}
982-
height={isMobile ? 250 : 350}
983-
isLoading={isLoading}
984-
onRangeSelect={setDateRangeAction}
985-
websiteId={websiteId}
986-
/>
987-
</div>
988-
</div>
941+
<TrafficTrendsChart
942+
chartData={chartData}
943+
dateDiff={dateDiff}
944+
dateRange={dateRange}
945+
isError={isError}
946+
isLoading={isLoading}
947+
isMobile={isMobile}
948+
onRangeSelect={setDateRangeAction}
949+
websiteId={websiteId}
950+
/>
989951

990952
{/* Tables */}
991953
<div className="grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-2">
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"use client";
2+
3+
import { ChartLineIcon } from "@phosphor-icons/react/dist/ssr/ChartLine";
4+
import { WarningIcon } from "@phosphor-icons/react/dist/ssr/Warning";
5+
import { WarningCircleIcon } from "@phosphor-icons/react/dist/ssr/WarningCircle";
6+
import { useMemo } from "react";
7+
import { MetricsChartWithAnnotations } from "@/components/charts/metrics-chart-with-annotations";
8+
import type { ChartDataRow } from "@/components/charts/metrics-constants";
9+
import { SectionBrandOverlay } from "@/components/logo/section-brand-overlay";
10+
import { Chart } from "@/components/ui/composables/chart";
11+
import { Skeleton } from "@/components/ui/skeleton";
12+
import { chartQueryOutcome } from "@/lib/chart-query-outcome";
13+
import type { DateRange } from "../../../utils/types";
14+
15+
interface TrafficTrendsChartProps {
16+
websiteId: string;
17+
dateRange: DateRange;
18+
chartData: ChartDataRow[];
19+
dateDiff: number;
20+
isError: boolean;
21+
isLoading: boolean;
22+
isMobile: boolean;
23+
onRangeSelect: (range: { startDate: Date; endDate: Date }) => void;
24+
}
25+
26+
export function TrafficTrendsChart({
27+
websiteId,
28+
dateRange,
29+
chartData,
30+
dateDiff,
31+
isError,
32+
isLoading,
33+
isMobile,
34+
onRangeSelect,
35+
}: TrafficTrendsChartProps) {
36+
const outcome = useMemo(
37+
() =>
38+
chartQueryOutcome({
39+
data: chartData,
40+
isError,
41+
isPending: isLoading,
42+
isSuccess: !(isLoading || isError),
43+
}),
44+
[chartData, isError, isLoading]
45+
);
46+
47+
const plotHeight = isMobile ? 250 : 350;
48+
/** Matches `MetricsChart` plot wrapper (`height + 20`). */
49+
const plotRegionHeight = plotHeight + 20;
50+
51+
return (
52+
<Chart className="gap-0 border-sidebar-border bg-sidebar py-0">
53+
<Chart.Header
54+
className="border-sidebar-border/60 px-3 py-2.5 sm:items-center sm:px-4 sm:py-3"
55+
description={
56+
<>
57+
<p className="text-xs sm:text-sm">
58+
{dateRange.granularity === "hourly" ? "Hourly" : "Daily"} traffic
59+
data
60+
</p>
61+
{dateRange.granularity === "hourly" && dateDiff > 7 ? (
62+
<div className="mt-1 flex items-start gap-1 text-amber-600 text-xs">
63+
<WarningIcon
64+
className="mt-0.5 shrink-0"
65+
size={14}
66+
weight="fill"
67+
/>
68+
<span className="leading-relaxed">
69+
Large date ranges may affect performance
70+
</span>
71+
</div>
72+
) : null}
73+
</>
74+
}
75+
descriptionClassName="text-sidebar-foreground/70"
76+
title="Traffic Trends"
77+
titleClassName="font-semibold text-base text-sidebar-foreground sm:text-lg"
78+
>
79+
<SectionBrandOverlay layout="inline" />
80+
</Chart.Header>
81+
<Chart.Content<ChartDataRow[]>
82+
emptyProps={{
83+
description:
84+
"Your analytics data will appear here as visitors interact with your website",
85+
icon: <ChartLineIcon className="size-12" weight="duotone" />,
86+
title: "No data available",
87+
}}
88+
errorProps={{
89+
description: "We couldn’t load traffic data. Try again in a moment.",
90+
icon: <WarningCircleIcon className="size-12" weight="duotone" />,
91+
title: "Something went wrong",
92+
variant: "error",
93+
}}
94+
loading={
95+
<div className="overflow-x-auto">
96+
<div
97+
aria-hidden
98+
className="relative w-full"
99+
style={{ height: plotRegionHeight }}
100+
>
101+
<Skeleton className="absolute inset-0 rounded-none bg-sidebar-foreground/10" />
102+
</div>
103+
</div>
104+
}
105+
outcome={outcome}
106+
>
107+
{(series) => (
108+
<div className="overflow-x-auto">
109+
<MetricsChartWithAnnotations
110+
className="rounded-none border-0"
111+
data={series}
112+
dateRange={{
113+
startDate: new Date(dateRange.start_date),
114+
endDate: new Date(dateRange.end_date),
115+
granularity: (dateRange.granularity ?? "daily") as
116+
| "hourly"
117+
| "daily"
118+
| "weekly"
119+
| "monthly",
120+
}}
121+
embedded
122+
height={plotHeight}
123+
isLoading={false}
124+
onRangeSelect={onRangeSelect}
125+
websiteId={websiteId}
126+
/>
127+
</div>
128+
)}
129+
</Chart.Content>
130+
</Chart>
131+
);
132+
}

apps/dashboard/app/(main)/websites/[id]/_components/website-page-header.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,8 @@ export function WebsitePageHeader({
190190
<p>Unlimited on your current plan</p>
191191
) : withinLimit && typeof limit === "number" ? (
192192
<p className="max-w-xs">
193-
You've created {currentUsage} out of{" "}
194-
{formatLocaleNumber(limit)} available on your current plan.
193+
You've created {currentUsage} out of {formatLocaleNumber(limit)}{" "}
194+
available on your current plan.
195195
{currentUsage / limit >= 0.8 && (
196196
<>
197197
<br />

apps/dashboard/app/(main)/websites/[id]/anomalies/_components/anomalies-page-content.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { CheckCircleIcon, WarningIcon } from "@phosphor-icons/react";
44
import { useQuery } from "@tanstack/react-query";
55
import { use, useMemo } from "react";
6-
import { DataList } from "@/components/data-list";
6+
import { List } from "@/components/ui/composables/list";
77
import { listQueryOutcome } from "@/lib/list-query-outcome";
88
import { orpc } from "@/lib/orpc";
99
import { WebsitePageHeader } from "../../_components/website-page-header";
@@ -19,11 +19,11 @@ interface AnomaliesPageContentProps {
1919

2020
function AnomaliesListSkeleton() {
2121
return (
22-
<DataList className="rounded bg-card">
22+
<List className="rounded bg-card">
2323
{[1, 2, 3].map((i) => (
2424
<AnomalyItemSkeleton key={i} />
2525
))}
26-
</DataList>
26+
</List>
2727
);
2828
}
2929

@@ -97,7 +97,7 @@ export function AnomaliesPageContent({ params }: AnomaliesPageContentProps) {
9797
/>
9898

9999
<div className="min-h-0 flex-1 overflow-y-auto overscroll-none">
100-
<DataList.Content
100+
<List.Content
101101
emptyProps={{
102102
description:
103103
"No unusual patterns detected in the last hour compared to your 7-day baseline. We check pageviews, errors, and custom events automatically.",
@@ -114,16 +114,16 @@ export function AnomaliesPageContent({ params }: AnomaliesPageContentProps) {
114114
outcome={outcome}
115115
>
116116
{(list) => (
117-
<DataList className="rounded bg-card">
117+
<List className="rounded bg-card">
118118
{list.map((anomaly, idx) => (
119119
<AnomalyItem
120120
anomaly={anomaly}
121121
key={`${anomaly.metric}-${anomaly.eventName ?? ""}-${idx}`}
122122
/>
123123
))}
124-
</DataList>
124+
</List>
125125
)}
126-
</DataList.Content>
126+
</List.Content>
127127
</div>
128128
</div>
129129
);

0 commit comments

Comments
 (0)