diff --git a/apps/web/package.json b/apps/web/package.json index 382fd0217..44d472a45 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,6 +18,7 @@ "@better-t-stack/types": "workspace:*", "@erquhart/convex-oss-stats": "^0.8.2", "@number-flow/react": "^0.5.11", + "@observablehq/plot": "^0.6.17", "@orama/orama": "^3.1.18", "@shikijs/transformers": "^3.22.0", "babel-plugin-react-compiler": "^1.0.0", @@ -43,7 +44,6 @@ "react-dom": "^19.2.4", "react-icons": "^5.5.0", "react-tweet": "^3.3.0", - "recharts": "2.15.4", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-mdx": "^3.1.1", diff --git a/apps/web/src/app/(home)/analytics/_components/analytics-header.tsx b/apps/web/src/app/(home)/analytics/_components/analytics-header.tsx index f77be72f7..fad815571 100644 --- a/apps/web/src/app/(home)/analytics/_components/analytics-header.tsx +++ b/apps/web/src/app/(home)/analytics/_components/analytics-header.tsx @@ -1,15 +1,44 @@ -import { format } from "date-fns"; -import { Terminal } from "lucide-react"; -import Link from "next/link"; +import { Activity, Radio } from "lucide-react"; import { cn } from "@/lib/utils"; +import { formatCompactNumber } from "./analytics-helpers"; + +const utcDateTimeFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + timeZone: "UTC", +}); + +const utcDateFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + timeZone: "UTC", +}); + +function formatUtcDateTime(value: string) { + return `${utcDateTimeFormatter.format(new Date(value))} UTC`; +} + +function formatUtcDate(value: string) { + return utcDateFormatter.format(new Date(value)); +} + export function AnalyticsHeader({ lastUpdated, + liveTotal, + trackingDays, legacy, connectionStatus, }: { lastUpdated: string | null; + liveTotal: number; + trackingDays: number; legacy: { total: number; avgPerDay: number; @@ -18,13 +47,11 @@ export function AnalyticsHeader({ }; connectionStatus: "online" | "connecting" | "reconnecting" | "offline"; }) { - const formattedDate = lastUpdated - ? format(new Date(lastUpdated), "MMM d, yyyy 'at' HH:mm") - : null; - const legacyDate = format(new Date(legacy.lastUpdatedIso), "MMM d, yyyy 'at' HH:mm"); + const formattedDate = lastUpdated ? formatUtcDateTime(lastUpdated) : null; + const legacyDate = formatUtcDate(legacy.lastUpdatedIso); const statusMeta = { online: { - label: "online", + label: "streaming", textClass: "text-primary", dotClass: "bg-primary", }, @@ -46,120 +73,104 @@ export function AnalyticsHeader({ }[connectionStatus]; return ( -
+
-
+
- +

CLI_ANALYTICS.JSON

-

- Real-time usage statistics from create-better-t-stack (powered by Convex) +

+ Observable Plot-powered usage analytics for Better T Stack, keeping the site’s existing + terminal-style visual language while making the charts much more readable.

-
-
-
- $ - status: - - - {statusMeta.label} - -
- {formattedDate && ( -
- last_event: - {formattedDate} -
- )} +
+
+ + + {statusMeta.label} + + + + anonymous telemetry +
-
- -
-
- > - No personal data collected - anonymous usage stats only -
-
- > - - Client event source:{" "} - - apps/cli/src/utils/analytics.ts - - -
-
- > - - Backend aggregation:{" "} - - packages/backend/convex/analytics.ts - - -
-
- > - - Website analytics dashboard:{" "} - - Umami - {" "} - - self-hosted on a Hostinger VPS - +
+
+
+ live projects +
+
+ {formatCompactNumber(liveTotal)} +
+

+ Convex-tracked creations since the telemetry cutover. +

-
-
-
- # - Legacy Data (pre-Convex) +
+
+ tracked span +
+
{trackingDays}
+

+ Calendar days represented in the live dataset. +

-
-
- Total:{" "} - {legacy.total.toLocaleString()} + +
+
+ archive total
-
- Avg/Day:{" "} - {legacy.avgPerDay.toFixed(1)} +
+ {formatCompactNumber(legacy.total)}
-
- As of:{" "} - {legacyDate} +

+ Historical pre-Convex dataset captured through {legacyDate}. +

+
+ +
+
+ + telemetry status
-
- Source:{" "} - {legacy.source} + +
+
+ Convex stream + {statusMeta.label} +
+
+ Latest event + + {formattedDate ?? "Waiting for first event"} + +
+
+ Archive snapshot + {legacyDate} +
-
+
); } diff --git a/apps/web/src/app/(home)/analytics/_components/analytics-helpers.ts b/apps/web/src/app/(home)/analytics/_components/analytics-helpers.ts new file mode 100644 index 000000000..20b950d67 --- /dev/null +++ b/apps/web/src/app/(home)/analytics/_components/analytics-helpers.ts @@ -0,0 +1,278 @@ +import { formatRgb, interpolate } from "culori"; +import { format } from "date-fns"; + +import type { + ComboMatrix, + Distribution, + ShareDistributionItem, + TimeSeriesPoint, + VersionDistribution, + WeekdayPoint, +} from "./types"; + +const compactNumberFormatter = new Intl.NumberFormat("en", { + notation: "compact", + maximumFractionDigits: 1, +}); + +const percentFormatter = new Intl.NumberFormat("en", { + style: "percent", + maximumFractionDigits: 0, +}); + +const precisePercentFormatter = new Intl.NumberFormat("en", { + style: "percent", + maximumFractionDigits: 1, +}); + +const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; + +export type PlotMargins = { + top: number; + right: number; + bottom: number; + left: number; +}; + +export function formatCompactNumber(value: number): string { + return compactNumberFormatter.format(value); +} + +export function formatPercent(value: number, precise = value > 0 && value < 0.1): string { + return (precise ? precisePercentFormatter : percentFormatter).format(value); +} + +export function formatDelta(value: number | null): string { + if (value === null) return "new"; + const formatted = `${Math.abs(value * 100).toFixed(Math.abs(value) >= 0.1 ? 0 : 1)}%`; + if (value === 0) return "0%"; + return `${value > 0 ? "+" : "-"}${formatted}`; +} + +export function formatDateLabel(date: string): string { + return format(new Date(`${date}T00:00:00`), "MMM d"); +} + +export function formatMonthLabel(month: string, pattern = "MMM yyyy"): string { + return format(new Date(`${month}-01T00:00:00`), pattern); +} + +export function formatHourLabel(hour: string): string { + return hour.replace(":00", ""); +} + +export function shortenLabel(value: string, maxLength = 18): string { + return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value; +} + +export function shortenMiddleLabel(value: string, maxLength = 18): string { + if (value.length <= maxLength) return value; + if (maxLength <= 5) return `${value.slice(0, Math.max(1, maxLength - 1))}…`; + + const suffixLength = Math.max(3, Math.floor((maxLength - 1) / 3)); + const prefixLength = Math.max(3, maxLength - suffixLength - 1); + + return `${value.slice(0, prefixLength)}…${value.slice(-suffixLength)}`; +} + +export function buildCompactCategoryLabels(values: string[], maxLength = 12): string[] { + const used = new Set(); + + return values.map((value) => { + const attempts = [ + shortenMiddleLabel(value, maxLength), + shortenMiddleLabel(value, maxLength + 2), + shortenMiddleLabel(value, maxLength + 4), + value, + ]; + + for (const candidate of attempts) { + if (!used.has(candidate)) { + used.add(candidate); + return candidate; + } + } + + const base = shortenMiddleLabel(value, Math.max(6, maxLength - 2)); + let suffix = 2; + let candidate = `${base}-${suffix}`; + + while (used.has(candidate)) { + suffix += 1; + candidate = `${base}-${suffix}`; + } + + used.add(candidate); + return candidate; + }); +} + +export function withShare(items: Distribution, total?: number): ShareDistributionItem[] { + const resolvedTotal = total ?? items.reduce((sum, item) => sum + item.value, 0); + if (resolvedTotal <= 0) { + return items.map((item) => ({ ...item, share: 0 })); + } + + return items.map((item) => ({ + ...item, + share: item.value / resolvedTotal, + })); +} + +export function versionWithShare( + items: Array<{ version: string; count: number }>, + total?: number, +): VersionDistribution { + const resolvedTotal = total ?? items.reduce((sum, item) => sum + item.count, 0); + if (resolvedTotal <= 0) { + return items.map((item) => ({ ...item, share: 0 })); + } + + return items.map((item) => ({ + ...item, + share: item.count / resolvedTotal, + })); +} + +export function buildWeekdayDistribution(timeSeries: TimeSeriesPoint[]): WeekdayPoint[] { + const seed = [ + { weekday: "Monday", shortLabel: "Mon", dayIndex: 1 }, + { weekday: "Tuesday", shortLabel: "Tue", dayIndex: 2 }, + { weekday: "Wednesday", shortLabel: "Wed", dayIndex: 3 }, + { weekday: "Thursday", shortLabel: "Thu", dayIndex: 4 }, + { weekday: "Friday", shortLabel: "Fri", dayIndex: 5 }, + { weekday: "Saturday", shortLabel: "Sat", dayIndex: 6 }, + { weekday: "Sunday", shortLabel: "Sun", dayIndex: 0 }, + ].map((day) => ({ ...day, count: 0, dayCount: 0 })); + + if (timeSeries.length === 0) { + return seed.map(({ dayCount: _dayCount, ...day }) => ({ + ...day, + averageDailyProjects: 0, + })); + } + + const countsByDate = new Map(timeSeries.map((point) => [point.date, point.count])); + const sortedDates = Array.from(countsByDate.keys()).sort((a, b) => a.localeCompare(b)); + const start = Date.parse(`${sortedDates[0]}T00:00:00Z`); + const end = Date.parse(`${sortedDates.at(-1)}T00:00:00Z`); + + if (Number.isNaN(start) || Number.isNaN(end) || end < start) { + return seed.map(({ dayCount: _dayCount, ...day }) => ({ + ...day, + averageDailyProjects: 0, + })); + } + + for (let timestamp = start; timestamp <= end; timestamp += MILLISECONDS_PER_DAY) { + const date = new Date(timestamp).toISOString().slice(0, 10); + const dayIndex = new Date(timestamp).getUTCDay(); + const target = seed.find((day) => day.dayIndex === dayIndex); + if (!target) continue; + target.count += countsByDate.get(date) || 0; + target.dayCount += 1; + } + + return seed.map(({ dayCount, ...day }) => ({ + ...day, + averageDailyProjects: dayCount > 0 ? day.count / dayCount : 0, + })); +} + +type MatrixOptions = { + distribution: ShareDistributionItem[]; + total: number; + xFromLabel: (name: string) => string; + yFromLabel: (name: string) => string; + xLimit?: number; + yLimit?: number; +}; + +export function buildComboMatrix({ + distribution, + total, + xFromLabel, + yFromLabel, + xLimit = 5, + yLimit = 5, +}: MatrixOptions): ComboMatrix { + const pairs = distribution + .map((item) => ({ + x: xFromLabel(item.name), + y: yFromLabel(item.name), + count: item.value, + })) + .filter((item) => item.x && item.y); + + const xTotals = new Map(); + const yTotals = new Map(); + const counts = new Map(); + + for (const pair of pairs) { + xTotals.set(pair.x, (xTotals.get(pair.x) || 0) + pair.count); + yTotals.set(pair.y, (yTotals.get(pair.y) || 0) + pair.count); + counts.set(`${pair.x}:::${pair.y}`, (counts.get(`${pair.x}:::${pair.y}`) || 0) + pair.count); + } + + const xDomain = Array.from(xTotals.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, xLimit) + .map(([name]) => name); + + const yDomain = Array.from(yTotals.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, yLimit) + .map(([name]) => name); + + const data = yDomain.flatMap((y) => + xDomain.map((x) => { + const count = counts.get(`${x}:::${y}`) || 0; + return { + x, + y, + count, + share: total > 0 ? count / total : 0, + }; + }), + ); + + return { + data, + xDomain, + yDomain, + maxValue: Math.max(...data.map((item) => item.count), 0), + }; +} + +export function splitComboLabel(value: string): [string, string] { + const [left = "none", ...rest] = value.split(" + "); + return [left.trim(), rest.join(" + ").trim() || "none"]; +} + +export function getTrendTone(deltaPercentage: number | null): "up" | "down" | "flat" { + if (deltaPercentage === null) return "up"; + if (deltaPercentage > 0.02) return "up"; + if (deltaPercentage < -0.02) return "down"; + return "flat"; +} + +export function interpolateColor(start: string, end: string, ratio: number): string { + const mixer = interpolate([start, end]); + return formatRgb(mixer(Math.min(1, Math.max(0, ratio)))) || end; +} + +export function isCompactPlot(width: number): boolean { + return width < 480; +} + +export function resolvePlotMargins( + width: number, + desktop: PlotMargins, + compact: PlotMargins, +): PlotMargins { + return isCompactPlot(width) ? compact : desktop; +} + +export function getPlotFontSize(width: number): string { + return isCompactPlot(width) ? "11px" : "12px"; +} diff --git a/apps/web/src/app/(home)/analytics/_components/analytics-page.tsx b/apps/web/src/app/(home)/analytics/_components/analytics-page.tsx index 2b9f8ddd0..73f88152e 100644 --- a/apps/web/src/app/(home)/analytics/_components/analytics-page.tsx +++ b/apps/web/src/app/(home)/analytics/_components/analytics-page.tsx @@ -2,6 +2,7 @@ import Footer from "../../_components/footer"; import { AnalyticsHeader } from "./analytics-header"; +import { AnalyticsSources } from "./analytics-sources"; import { DevToolsSection } from "./dev-environment-charts"; import { LiveLogs } from "./live-logs"; import { MetricsCards } from "./metrics-cards"; @@ -28,19 +29,25 @@ export default function AnalyticsPage({
- - + + + +
+ +
diff --git a/apps/web/src/app/(home)/analytics/_components/analytics-sources.tsx b/apps/web/src/app/(home)/analytics/_components/analytics-sources.tsx new file mode 100644 index 000000000..52647e27c --- /dev/null +++ b/apps/web/src/app/(home)/analytics/_components/analytics-sources.tsx @@ -0,0 +1,58 @@ +import { ArrowUpRight } from "lucide-react"; +import Link from "next/link"; + +export function AnalyticsSources() { + return ( +
+
+ source map +
+ +
+ + + CLI sender + apps/cli/src/utils/analytics.ts + + + + + + + Aggregator + packages/backend/convex/analytics.ts + + + + + + + Website analytics + Umami dashboard + + + +
+ +

+ No personal data is collected here. The page only surfaces aggregate CLI usage and a small + live event feed for recent anonymous activity. +

+
+ ); +} diff --git a/apps/web/src/app/(home)/analytics/_components/chart-card.tsx b/apps/web/src/app/(home)/analytics/_components/chart-card.tsx index a06e74948..459a8cfd3 100644 --- a/apps/web/src/app/(home)/analytics/_components/chart-card.tsx +++ b/apps/web/src/app/(home)/analytics/_components/chart-card.tsx @@ -1,28 +1,59 @@ import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; + export function ChartCard({ + eyebrow, title, description, + aside, children, + footer, + className, + contentClassName, }: { + eyebrow?: string; title: string; description: string; + aside?: ReactNode; children: ReactNode; + footer?: ReactNode; + className?: string; + contentClassName?: string; }) { return ( -
-
-
-
-
- $ - {title} +
+
+
+
+ {eyebrow ? ( + + {eyebrow} + + ) : null} +
+

+ {title} +

+

{description}

-

{description}

+ {aside ?
{aside}
: null}
- {children} + +
{children}
+ + {footer ? ( +
+ {footer} +
+ ) : null}
-
+ ); } diff --git a/apps/web/src/app/(home)/analytics/_components/dev-environment-charts.tsx b/apps/web/src/app/(home)/analytics/_components/dev-environment-charts.tsx index a05c75523..9561e9899 100644 --- a/apps/web/src/app/(home)/analytics/_components/dev-environment-charts.tsx +++ b/apps/web/src/app/(home)/analytics/_components/dev-environment-charts.tsx @@ -1,203 +1,277 @@ "use client"; -import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis } from "recharts"; +import * as Plot from "@observablehq/plot"; -import { - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, - type ChartConfig, -} from "@/components/ui/chart"; +import { cn } from "@/lib/utils"; +import { + buildCompactCategoryLabels, + formatPercent, + getPlotFontSize, + isCompactPlot, + resolvePlotMargins, +} from "./analytics-helpers"; import { ChartCard } from "./chart-card"; -import type { AggregatedAnalyticsData, Distribution, VersionDistribution } from "./types"; - -const CHART_COLORS = [ - "var(--chart-1)", - "var(--chart-2)", - "var(--chart-3)", - "var(--chart-4)", - "var(--chart-5)", -]; - -function getChartConfig(data: Distribution): ChartConfig { - const config: ChartConfig = {}; - for (const [index, item] of data.entries()) { - config[item.name] = { - label: item.name, - color: CHART_COLORS[index % CHART_COLORS.length], - }; - } - return config; -} +import { PlotChart } from "./plot-chart"; +import { PreferenceChartCard } from "./preference-chart-card"; +import { SectionHeader } from "./section-header"; +import type { AggregatedAnalyticsData, ShareDistributionItem, VersionDistribution } from "./types"; -function getVersionChartConfig(): ChartConfig { - return { - count: { label: "Count", color: "var(--chart-5)" }, - }; -} - -function VerticalBarChart({ data, height = 280 }: { data: Distribution; height?: number }) { - const chartConfig = getChartConfig(data); +function VersionCard({ + eyebrow, + title, + description, + data, +}: { + eyebrow: string; + title: string; + description: string; + data: VersionDistribution; +}) { + const ranking = data.slice(0, 6); return ( - - - - (value.length > 20 ? `${value.slice(0, 20)}…` : value)} - /> - - } /> - - {data.map((entry, index) => ( - - ))} - - - - ); -} - -function VersionBarChart({ data, height = 280 }: { data: VersionDistribution; height?: number }) { - const chartConfig = getVersionChartConfig(); + + { + const compact = isCompactPlot(width); + const shareMax = Math.max(...ranking.map((item) => item.share), 0.12); + const compactLabels = compact + ? buildCompactCategoryLabels( + ranking.map((item) => item.version), + width < 360 ? 8 : 10, + ) + : null; + const chartData = ranking.map((item, index) => ({ + ...item, + label: compact ? (compactLabels?.[index] ?? item.version) : item.version, + })); + const margins = resolvePlotMargins( + width, + { top: 16, right: 48, bottom: 28, left: 96 }, + { top: 12, right: 40, bottom: 12, left: 74 }, + ); - return ( - - - - (value.length > 7 ? `${value.slice(0, 7)}…` : value)} - /> - - } /> - - - + return Plot.plot({ + width, + height: compact + ? Math.max(200, chartData.length * 28 + 24) + : Math.max(220, chartData.length * 34 + 70), + marginTop: margins.top, + marginRight: margins.right, + marginBottom: margins.bottom, + marginLeft: margins.left, + style: { + background: "transparent", + color: palette.foreground, + fontFamily: "var(--font-mono)", + fontSize: getPlotFontSize(width), + }, + x: { + axis: compact ? null : undefined, + label: null, + domain: [0, shareMax * (compact ? 1.34 : 1.24)], + ticks: 4, + grid: !compact, + tickFormat: (value) => formatPercent(Number(value), true), + }, + y: { + label: null, + }, + marks: [ + Plot.barX(chartData, { + x: "share", + y: "label", + fill: palette.chart5, + rx: 10, + title: (item) => + `${item.version}\nShare: ${formatPercent(item.share, true)}\nProjects: ${item.count.toLocaleString()}`, + }), + Plot.text(chartData, { + x: "share", + y: "label", + text: (item) => formatPercent(item.share, true), + dx: compact ? 6 : 10, + textAnchor: "start", + fill: palette.foreground, + fontWeight: 600, + }), + ], + }); + }} + /> + ); } -function PieChartComponent({ data }: { data: Distribution }) { - const chartConfig = getChartConfig(data); +function SplitMeterCard({ + eyebrow, + title, + description, + data, +}: { + eyebrow: string; + title: string; + description: string; + data: ShareDistributionItem[]; +}) { + const yesShare = data.find((item) => item.name === "Yes")?.share ?? 0; + const noShare = data.find((item) => item.name === "No")?.share ?? 0; return ( - - - } /> - - {data.map((entry, index) => ( - - ))} - - } /> - - + +
+
+
+
+
+
+
+
+
+
+ yes +
+
{formatPercent(yesShare)}
+
+
+
+ no +
+
{formatPercent(noShare)}
+
+
+
+ ); } export function DevToolsSection({ data }: { data: AggregatedAnalyticsData }) { - const { - packageManagerDistribution, - gitDistribution, - installDistribution, - addonsDistribution, - examplesDistribution, - nodeVersionDistribution, - cliVersionDistribution, - webDeployDistribution, - serverDeployDistribution, - } = data; + const webDeployTargets = data.webDeployDistribution.filter((item) => item.name !== "none"); + const serverDeployTargets = data.serverDeployDistribution.filter((item) => item.name !== "none"); + const hasDeployTargets = webDeployTargets.length > 0 || serverDeployTargets.length > 0; return (
-
- DEV_TOOLS_AND_CONFIG - - [TOOLING] - -
-
+ + packages {data.summary.mostPopularPackageManager} • runtime{" "} + {data.summary.mostPopularRuntime} +
+ } + /> -
- - - +
+ + + +
- - - + {hasDeployTargets ? ( +
+ {webDeployTargets.length > 0 ? ( + + ) : null} - - - + {serverDeployTargets.length > 0 ? ( + + ) : null} +
+ ) : null} - - - +
+ + + +
- {addonsDistribution.length > 0 && ( - - 0 && data.examplesDistribution.length > 0 + ? "xl:grid-cols-2" + : "grid-cols-1", + )} + > + {data.addonsDistribution.length > 0 ? ( + - - )} - - {examplesDistribution.length > 0 && ( - - - - )} - - {(webDeployDistribution.length > 0 || serverDeployDistribution.length > 0) && ( -
- {webDeployDistribution.length > 0 && ( - - - - )} - {serverDeployDistribution.length > 0 && ( - - - - )} -
- )} + ) : null} - {cliVersionDistribution.length > 0 && ( - - - - )} + {data.examplesDistribution.length > 0 ? ( + + ) : null} +
); } diff --git a/apps/web/src/app/(home)/analytics/_components/metrics-cards.tsx b/apps/web/src/app/(home)/analytics/_components/metrics-cards.tsx index d58ddd5b7..b298c51e1 100644 --- a/apps/web/src/app/(home)/analytics/_components/metrics-cards.tsx +++ b/apps/web/src/app/(home)/analytics/_components/metrics-cards.tsx @@ -1,118 +1,269 @@ "use client"; import NumberFlow from "@number-flow/react"; -import { Code2, Database, Globe, Layers, Server, Terminal, TrendingUp, Zap } from "lucide-react"; +import * as Plot from "@observablehq/plot"; +import { format } from "date-fns"; +import { AreaChart, Flame, Gauge, Radar, Sparkles, Sunrise } from "lucide-react"; +import { cn } from "@/lib/utils"; + +import { + formatCompactNumber, + formatDateLabel, + formatDelta, + getPlotFontSize, + getTrendTone, + isCompactPlot, + resolvePlotMargins, + shortenLabel, +} from "./analytics-helpers"; +import { PlotChart } from "./plot-chart"; import type { AggregatedAnalyticsData } from "./types"; -type MetricCardProps = { - title: string; - value: string | number; - subtitle: string; +function MetricTile({ + label, + value, + detail, + icon, + tone = "default", +}: { + label: string; + value: string; + detail: string; icon: React.ReactNode; - highlight?: boolean; - animate?: boolean; -}; - -function MetricCard({ title, value, subtitle, icon, highlight, animate }: MetricCardProps) { + tone?: "default" | "success" | "warning"; +}) { return (
-
-
- - {icon} - {title} - -
- - {animate && typeof value === "number" ? ( - - ) : ( -
- {typeof value === "number" ? value.toLocaleString() : value} -
- )} - -
-

{subtitle}

-
+
+ + {label} + + {icon}
+
{value}
+

{detail}

); } export function MetricsCards({ data }: { data: AggregatedAnalyticsData }) { - const { summary, totalProjects, avgProjectsPerDay } = data; + const momentumTone = getTrendTone(data.momentum.deltaPercentage); + const sparklineData = + data.timeSeries.length > 0 + ? data.timeSeries + : [ + { + dateValue: new Date(), + count: 0, + rollingAverage: 0, + cumulativeProjects: 0, + date: new Date().toISOString().slice(0, 10), + }, + ]; return (
-
- KEY_METRICS -
-
+
+
+
+ + overview + + + live trendline + +
-
- } - highlight - animate - /> - } - highlight - animate - /> - } +
+
+
+
+ Convex total +
+ +

+ Live tracked project creations. The cards around this chart focus on what changed + recently, not just what is biggest. +

+
+ +
+
+
+ average per day +
+
+ {data.avgProjectsPerDay.toFixed(1)} +
+
+
+
+ leading pair +
+
+ {shortenLabel(data.summary.topStack, 24)} +
+

+ strongest backend + frontend pairing +

+
+
+
+ +
+ { + const compact = isCompactPlot(width); + const margins = resolvePlotMargins( + width, + { top: 18, right: 18, bottom: 32, left: 46 }, + { top: 12, right: 10, bottom: 24, left: 32 }, + ); + + return Plot.plot({ + width, + height: compact ? 230 : 280, + marginTop: margins.top, + marginRight: margins.right, + marginBottom: margins.bottom, + marginLeft: margins.left, + style: { + background: "transparent", + color: palette.foreground, + fontFamily: "var(--font-mono)", + fontSize: getPlotFontSize(width), + }, + x: { + label: null, + ticks: compact ? 3 : 4, + tickFormat: (value) => format(new Date(value as Date), "MMM d"), + }, + y: { + label: null, + grid: true, + nice: true, + ticks: compact ? 3 : undefined, + tickFormat: (value) => formatCompactNumber(Number(value)), + }, + marks: [ + Plot.ruleY([0], { stroke: palette.border }), + Plot.areaY(sparklineData, { + x: "dateValue", + y: "count", + curve: "catmull-rom", + fill: palette.chart1, + fillOpacity: 0.18, + }), + Plot.lineY(sparklineData, { + x: "dateValue", + y: "count", + curve: "catmull-rom", + stroke: palette.chart1, + strokeWidth: 2.3, + }), + Plot.lineY(sparklineData, { + x: "dateValue", + y: "rollingAverage", + curve: "catmull-rom", + stroke: palette.chart2, + strokeWidth: 2, + }), + Plot.dot(sparklineData.slice(-1), { + x: "dateValue", + y: "count", + fill: palette.chart1, + r: 4, + }), + Plot.tip( + sparklineData, + Plot.pointerX({ + x: "dateValue", + y: "count", + title: (point) => + `${formatDateLabel(point.date)}\nProjects: ${point.count}\n7d avg: ${point.rollingAverage.toFixed(1)}`, + }), + ), + ], + }); + }} + /> +
+
+
+
+ +
+ } + tone={momentumTone === "up" ? "success" : momentumTone === "down" ? "warning" : "default"} /> - } + + } /> - } + + } + tone="warning" /> - } + + } /> - } + + } /> - } + + } />
diff --git a/apps/web/src/app/(home)/analytics/_components/plot-chart.tsx b/apps/web/src/app/(home)/analytics/_components/plot-chart.tsx new file mode 100644 index 000000000..653baf069 --- /dev/null +++ b/apps/web/src/app/(home)/analytics/_components/plot-chart.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { useEffect, useId, useRef, useState } from "react"; + +import { cn } from "@/lib/utils"; + +export type PlotPalette = { + background: string; + foreground: string; + muted: string; + border: string; + primary: string; + accent: string; + chart1: string; + chart2: string; + chart3: string; + chart4: string; + chart5: string; +}; + +export type PlotContext = { + id: string; + width: number; + palette: PlotPalette; +}; + +function readPlotPalette(element: HTMLElement): PlotPalette { + const styles = getComputedStyle(element); + + const get = (name: string, fallback: string) => styles.getPropertyValue(name).trim() || fallback; + + return { + background: get("--background", "#11111b"), + foreground: get("--foreground", "#cdd6f4"), + muted: get("--muted-foreground", "#a6adc8"), + border: get("--border", "#45475a"), + primary: get("--primary", "#cba6f7"), + accent: get("--accent", "#b4befe"), + chart1: get("--chart-1", "#cba6f7"), + chart2: get("--chart-2", "#4ade80"), + chart3: get("--chart-3", "#fbbf24"), + chart4: get("--chart-4", "#fb7185"), + chart5: get("--chart-5", "#38bdf8"), + }; +} + +export function PlotChart({ + ariaLabel, + className, + build, +}: { + ariaLabel: string; + className?: string; + build: (context: PlotContext) => HTMLElement | SVGElement; +}) { + const frameRef = useRef(null); + const mountRef = useRef(null); + const [width, setWidth] = useState(0); + const id = useId(); + const { resolvedTheme } = useTheme(); + + useEffect(() => { + const frame = frameRef.current; + if (!frame) return; + + const observer = new ResizeObserver(([entry]) => { + setWidth(Math.floor(entry.contentRect.width)); + }); + + observer.observe(frame); + setWidth(Math.floor(frame.getBoundingClientRect().width)); + + return () => observer.disconnect(); + }, []); + + useEffect(() => { + const frame = frameRef.current; + const mount = mountRef.current; + if (!frame || !mount || width < 180) return; + + // Keep build stable when parent state changes frequently so we do not rebuild + // the same Plot instance on every render. + const plot = build({ + id, + width, + palette: readPlotPalette(frame), + }); + + plot.classList.add("bts-plot"); + plot.setAttribute("aria-label", ariaLabel); + plot.setAttribute("role", "img"); + mount.replaceChildren(plot); + + return () => { + plot.remove(); + }; + }, [ariaLabel, build, id, resolvedTheme, width]); + + return ( +
+
+
+ ); +} diff --git a/apps/web/src/app/(home)/analytics/_components/preference-chart-card.tsx b/apps/web/src/app/(home)/analytics/_components/preference-chart-card.tsx new file mode 100644 index 000000000..c261d062e --- /dev/null +++ b/apps/web/src/app/(home)/analytics/_components/preference-chart-card.tsx @@ -0,0 +1,110 @@ +"use client"; + +import * as Plot from "@observablehq/plot"; + +import { + buildCompactCategoryLabels, + formatPercent, + getPlotFontSize, + isCompactPlot, + resolvePlotMargins, +} from "./analytics-helpers"; +import { ChartCard } from "./chart-card"; +import { PlotChart } from "./plot-chart"; +import type { ShareDistributionItem } from "./types"; + +type PreferenceChartCardProps = { + eyebrow: string; + title: string; + description: string; + data: ShareDistributionItem[]; + colorKey: "chart1" | "chart2" | "chart3" | "chart4" | "chart5"; + maxItems?: number; +}; + +export function PreferenceChartCard({ + eyebrow, + title, + description, + data, + colorKey, + maxItems = 5, +}: PreferenceChartCardProps) { + const ranking = data.slice(0, maxItems); + + return ( + + { + const compact = isCompactPlot(width); + const shareMax = Math.max(...ranking.map((item) => item.share), 0.2); + const fill = palette[colorKey]; + const compactLabels = compact + ? buildCompactCategoryLabels( + ranking.map((item) => item.name), + width < 360 ? 10 : 12, + ) + : null; + const chartData = ranking.map((item, index) => ({ + ...item, + label: compact ? (compactLabels?.[index] ?? item.name) : item.name, + })); + const margins = resolvePlotMargins( + width, + { top: 16, right: 48, bottom: 28, left: 128 }, + { top: 12, right: 42, bottom: 12, left: 92 }, + ); + + return Plot.plot({ + width, + height: compact + ? Math.max(200, chartData.length * 30 + 28) + : Math.max(220, chartData.length * 34 + 70), + marginTop: margins.top, + marginRight: margins.right, + marginBottom: margins.bottom, + marginLeft: margins.left, + style: { + background: "transparent", + color: palette.foreground, + fontFamily: "var(--font-mono)", + fontSize: getPlotFontSize(width), + }, + x: { + axis: compact ? null : undefined, + label: null, + domain: [0, shareMax * (compact ? 1.34 : 1.22)], + ticks: 4, + grid: !compact, + tickFormat: (value) => formatPercent(Number(value)), + }, + y: { + label: null, + }, + marks: [ + Plot.barX(chartData, { + x: "share", + y: "label", + fill, + rx: 10, + title: (item) => + `${item.name}\nShare: ${formatPercent(item.share)}\nProjects: ${item.value.toLocaleString()}`, + }), + Plot.text(chartData, { + x: "share", + y: "label", + text: (item) => formatPercent(item.share), + dx: compact ? 6 : 8, + textAnchor: "start", + fill: palette.foreground, + fontWeight: 600, + }), + ], + }); + }} + /> + + ); +} diff --git a/apps/web/src/app/(home)/analytics/_components/section-header.tsx b/apps/web/src/app/(home)/analytics/_components/section-header.tsx new file mode 100644 index 000000000..785129942 --- /dev/null +++ b/apps/web/src/app/(home)/analytics/_components/section-header.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from "react"; + +export function SectionHeader({ + label, + title, + description, + aside, +}: { + label: string; + title: string; + description: string; + aside?: ReactNode; +}) { + return ( +
+
+ {label.toUpperCase()} +
+ {aside} +
+
+

{title}

+

{description}

+
+
+ ); +} diff --git a/apps/web/src/app/(home)/analytics/_components/stack-configuration-charts.tsx b/apps/web/src/app/(home)/analytics/_components/stack-configuration-charts.tsx index eecb6c285..ee3427894 100644 --- a/apps/web/src/app/(home)/analytics/_components/stack-configuration-charts.tsx +++ b/apps/web/src/app/(home)/analytics/_components/stack-configuration-charts.tsx @@ -1,163 +1,196 @@ "use client"; -import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis, YAxis } from "recharts"; +import * as Plot from "@observablehq/plot"; import { - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, - type ChartConfig, -} from "@/components/ui/chart"; - + formatPercent, + getPlotFontSize, + interpolateColor, + isCompactPlot, + resolvePlotMargins, + shortenLabel, +} from "./analytics-helpers"; import { ChartCard } from "./chart-card"; -import type { AggregatedAnalyticsData, Distribution } from "./types"; - -const CHART_COLORS = [ - "var(--chart-1)", - "var(--chart-2)", - "var(--chart-3)", - "var(--chart-4)", - "var(--chart-5)", -]; - -function getChartConfig(data: Distribution): ChartConfig { - const config: ChartConfig = {}; - for (const [index, item] of data.entries()) { - config[item.name] = { - label: item.name, - color: CHART_COLORS[index % CHART_COLORS.length], - }; - } - return config; -} - -function BarChartComponent({ data, height = 280 }: { data: Distribution; height?: number }) { - const chartConfig = getChartConfig(data); - - return ( - - - - (value.length > 20 ? `${value.slice(0, 20)}…` : value)} - /> - - } /> - - {data.map((entry, index) => ( - - ))} - - - - ); -} - -function PieChartComponent({ data }: { data: Distribution }) { - const chartConfig = getChartConfig(data); - +import { PlotChart } from "./plot-chart"; +import { PreferenceChartCard } from "./preference-chart-card"; +import { SectionHeader } from "./section-header"; +import type { AggregatedAnalyticsData } from "./types"; + +function PairingMatrix({ + eyebrow, + title, + description, + matrix, + tone, +}: { + eyebrow: string; + title: string; + description: string; + matrix: AggregatedAnalyticsData["stackMatrix"]; + tone: "chart1" | "chart4"; +}) { return ( - - - } /> - - {data.map((entry, index) => ( - - ))} - - } /> - - + + { + const compact = isCompactPlot(width); + const maxValue = Math.max(matrix.maxValue, 1); + const fillEnd = tone === "chart1" ? palette.chart1 : palette.chart4; + const cells = matrix.data.map((item) => ({ + ...item, + tone: + item.count === 0 + ? palette.background + : interpolateColor(palette.border, fillEnd, item.count / maxValue), + })); + + const margins = resolvePlotMargins( + width, + { top: 18, right: 18, bottom: 54, left: 88 }, + { top: 12, right: 8, bottom: 38, left: 62 }, + ); + + return Plot.plot({ + width, + height: compact + ? Math.max(240, matrix.yDomain.length * 40 + 70) + : Math.max(280, matrix.yDomain.length * 54 + 90), + marginTop: margins.top, + marginRight: margins.right, + marginBottom: margins.bottom, + marginLeft: margins.left, + style: { + background: "transparent", + color: palette.foreground, + fontFamily: "var(--font-mono)", + fontSize: getPlotFontSize(width), + }, + x: { + label: null, + domain: matrix.xDomain, + tickFormat: (value) => shortenLabel(String(value), compact ? 8 : 16), + }, + y: { + label: null, + domain: matrix.yDomain, + tickFormat: (value) => shortenLabel(String(value), compact ? 8 : 16), + }, + marks: [ + Plot.cell(cells, { + x: "x", + y: "y", + fill: "tone", + inset: 2, + title: (item) => + `${item.y} + ${item.x}\nProjects: ${item.count.toLocaleString()}\nShare: ${formatPercent(item.share, true)}`, + }), + Plot.text( + cells.filter((item) => item.count > 0), + { + x: "x", + y: "y", + text: (item) => item.count.toLocaleString(), + fill: (item) => + item.count / maxValue > 0.5 ? palette.background : palette.foreground, + fontSize: compact ? 10 : 12, + fontWeight: 700, + }, + ), + ], + }); + }} + /> + ); } export function StackSection({ data }: { data: AggregatedAnalyticsData }) { - const { - popularStackCombinations, - frontendDistribution, - backendDistribution, - databaseDistribution, - ormDistribution, - dbSetupDistribution, - apiDistribution, - authDistribution, - runtimeDistribution, - databaseORMCombinations, - } = data; - return (
-
- STACK_CONFIGURATION - - [CORE_CHOICES] - -
+ + top stack {shortenLabel(data.summary.topStack, 28)} +
+ } + /> + +
+ +
- - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - +
+ + + +
- - - - - - - +
+ + + +
); } diff --git a/apps/web/src/app/(home)/analytics/_components/timeline-charts.tsx b/apps/web/src/app/(home)/analytics/_components/timeline-charts.tsx index 1d5e92d88..372897ba4 100644 --- a/apps/web/src/app/(home)/analytics/_components/timeline-charts.tsx +++ b/apps/web/src/app/(home)/analytics/_components/timeline-charts.tsx @@ -1,200 +1,385 @@ "use client"; -import { format, parseISO } from "date-fns"; -import { - Area, - AreaChart, - Bar, - BarChart, - CartesianGrid, - Cell, - Pie, - PieChart, - XAxis, - YAxis, -} from "recharts"; +import * as Plot from "@observablehq/plot"; +import { format } from "date-fns"; import { - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, - type ChartConfig, -} from "@/components/ui/chart"; - + formatCompactNumber, + formatDateLabel, + formatHourLabel, + formatMonthLabel, + getPlotFontSize, + interpolateColor, + isCompactPlot, + resolvePlotMargins, +} from "./analytics-helpers"; import { ChartCard } from "./chart-card"; -import type { AggregatedAnalyticsData, Distribution } from "./types"; - -const CHART_COLORS = [ - "var(--chart-1)", - "var(--chart-2)", - "var(--chart-3)", - "var(--chart-4)", - "var(--chart-5)", -]; - -function getChartConfig(data: Distribution): ChartConfig { - const config: ChartConfig = {}; - for (const [index, item] of data.entries()) { - config[item.name] = { - label: item.name, - color: CHART_COLORS[index % CHART_COLORS.length], - }; - } - return config; -} - -const areaChartConfig = { - count: { label: "Projects", color: "var(--chart-1)" }, -} satisfies ChartConfig; - -const barChartConfig = { - totalProjects: { label: "Total Projects", color: "var(--chart-2)" }, -} satisfies ChartConfig; - -const hourlyChartConfig = { - count: { label: "Projects", color: "var(--chart-3)" }, -} satisfies ChartConfig; - -function formatMonthLabel(monthKey: string, pattern: string): string { - const parsedMonth = parseISO(`${monthKey}-01`); - if (Number.isNaN(parsedMonth.getTime())) return monthKey; - return format(parsedMonth, pattern); -} +import { PlotChart } from "./plot-chart"; +import { SectionHeader } from "./section-header"; +import type { AggregatedAnalyticsData } from "./types"; export function TimelineSection({ data }: { data: AggregatedAnalyticsData }) { - const { timeSeries, monthlyTimeSeries, platformDistribution, hourlyDistribution } = data; - const platformChartConfig = getChartConfig(platformDistribution); + const peakDayLabel = data.momentum.peakDay ? formatDateLabel(data.momentum.peakDay.date) : "n/a"; + const busiestHourLabel = data.momentum.busiestHour + ? `${formatHourLabel(data.momentum.busiestHour.hour)} UTC` + : "n/a"; return (
-
- TIMELINE_ANALYSIS -
-
+ + peak day {peakDayLabel} • hot hour {busiestHourLabel} +
+ } + /> -
+
+ Last 7 days:{" "} + + {formatCompactNumber(data.momentum.last7Days)} + + {" • "} + Previous 7 days:{" "} + + {formatCompactNumber(data.momentum.previous7Days)} + + + } > - - - - format(parseISO(val), "d")} - interval="preserveStartEnd" - /> - - { - const item = payload?.[0]?.payload as { date: string } | undefined; - return item ? format(parseISO(item.date), "MMM d, yyyy") : ""; - }} - hideIndicator - /> - } - /> - - - + { + const compact = isCompactPlot(width); + const margins = resolvePlotMargins( + width, + { top: 18, right: 18, bottom: 32, left: 44 }, + { top: 12, right: 8, bottom: 24, left: 30 }, + ); + + return Plot.plot({ + width, + height: compact ? 240 : 320, + marginTop: margins.top, + marginRight: margins.right, + marginBottom: margins.bottom, + marginLeft: margins.left, + style: { + background: "transparent", + color: palette.foreground, + fontFamily: "var(--font-mono)", + fontSize: getPlotFontSize(width), + }, + x: { + label: null, + ticks: compact ? 3 : 5, + tickFormat: (value) => format(new Date(value as Date), "MMM d"), + }, + y: { + label: null, + grid: true, + nice: true, + ticks: compact ? 3 : undefined, + tickFormat: (value) => formatCompactNumber(Number(value)), + }, + marks: [ + Plot.ruleY([0], { stroke: palette.border }), + Plot.areaY(data.timeSeries, { + x: "dateValue", + y: "count", + curve: "catmull-rom", + fill: palette.chart1, + fillOpacity: 0.16, + }), + Plot.lineY(data.timeSeries, { + x: "dateValue", + y: "count", + curve: "catmull-rom", + stroke: palette.chart1, + strokeWidth: 2.4, + }), + Plot.lineY(data.timeSeries, { + x: "dateValue", + y: "rollingAverage", + curve: "catmull-rom", + stroke: palette.chart2, + strokeWidth: 2.2, + }), + Plot.dot(data.timeSeries.slice(-1), { + x: "dateValue", + y: "count", + fill: palette.chart1, + r: 4, + }), + Plot.dot( + data.momentum.peakDay + ? data.timeSeries.filter( + (point) => point.date === data.momentum.peakDay?.date, + ) + : [], + { + x: "dateValue", + y: "count", + fill: palette.chart3, + r: 4.5, + }, + ), + Plot.tip( + data.timeSeries, + Plot.pointerX({ + x: "dateValue", + y: "count", + title: (point) => + `${formatDateLabel(point.date)}\nProjects: ${point.count}\nRolling avg: ${point.rollingAverage.toFixed(1)}`, + }), + ), + ], + }); + }} + /> - - - - - formatMonthLabel(String(val), "MMM yy")} - /> - - formatMonthLabel(String(value), "MMMM yyyy")} - hideIndicator - /> - } - /> - - - + + Total live projects:{" "} + {data.totalProjects.toLocaleString()} + + } + > + { + const compact = isCompactPlot(width); + const compactTickStep = width < 420 ? 3 : 2; + const compactTicks = compact + ? data.monthlyTimeSeries + .filter( + (_, index) => + index % compactTickStep === 0 || + index === data.monthlyTimeSeries.length - 1, + ) + .map((point) => point.month) + : undefined; + const margins = resolvePlotMargins( + width, + { top: 18, right: 16, bottom: 42, left: 44 }, + { top: 12, right: 8, bottom: 28, left: 30 }, + ); + + return Plot.plot({ + width, + height: compact ? 240 : 320, + marginTop: margins.top, + marginRight: margins.right, + marginBottom: margins.bottom, + marginLeft: margins.left, + style: { + background: "transparent", + color: palette.foreground, + fontFamily: "var(--font-mono)", + fontSize: getPlotFontSize(width), + }, + x: { + type: "band", + label: null, + ticks: compactTicks, + tickFormat: (value) => + formatMonthLabel(String(value), compact ? "MMM yy" : "MMM yy"), + }, + y: { + label: null, + grid: true, + nice: true, + ticks: compact ? 3 : undefined, + tickFormat: (value) => formatCompactNumber(Number(value)), + }, + marks: [ + Plot.ruleY([0], { stroke: palette.border }), + Plot.barY(data.monthlyTimeSeries, { + x: "month", + y: "totalProjects", + fill: palette.chart4, + rx: 10, + title: (point) => + `${formatMonthLabel(point.month)}\nProjects: ${point.totalProjects.toLocaleString()}`, + }), + ], + }); + }} + /> +
- - - - } /> - - {platformDistribution.map((entry, index) => ( - - ))} - - } /> - - +
+ + Active days in the last 30:{" "} + {data.momentum.activeDaysLast30} + + } + > + { + const compact = isCompactPlot(width); + const margins = resolvePlotMargins( + width, + { top: 16, right: 16, bottom: 30, left: 44 }, + { top: 12, right: 8, bottom: 24, left: 30 }, + ); + + return Plot.plot({ + width, + height: compact ? 200 : 230, + marginTop: margins.top, + marginRight: margins.right, + marginBottom: margins.bottom, + marginLeft: margins.left, + style: { + background: "transparent", + color: palette.foreground, + fontFamily: "var(--font-mono)", + fontSize: getPlotFontSize(width), + }, + x: { + label: null, + }, + y: { + label: null, + grid: true, + ticks: compact ? 3 : undefined, + tickFormat: (value) => formatCompactNumber(Number(value)), + }, + marks: [ + Plot.ruleY([0], { stroke: palette.border }), + Plot.barY(data.weekdayDistribution, { + x: "shortLabel", + y: "averageDailyProjects", + fill: palette.chart2, + rx: 10, + title: (point) => + `${point.weekday}\nAverage: ${point.averageDailyProjects.toFixed(1)}\nTotal: ${point.count.toLocaleString()}`, + }), + ], + }); + }} + /> - - - - - val.replace(":00", "")} - /> - - `${value} UTC`} hideIndicator /> - } - /> - - - + + Busiest hour:{" "} + + {data.momentum.busiestHour + ? `${formatHourLabel(data.momentum.busiestHour.hour)} UTC` + : "n/a"} + + + } + > + { + const compact = isCompactPlot(width); + const margins = resolvePlotMargins( + width, + { top: 18, right: 18, bottom: 36, left: 42 }, + { top: 12, right: 8, bottom: 28, left: 30 }, + ); + const maxCount = Math.max(...data.hourlyDistribution.map((point) => point.count), 0); + const peakHour = data.momentum.busiestHour?.hour ?? null; + const hourlyBars = data.hourlyDistribution.map((point) => ({ + ...point, + tone: + point.count === 0 + ? palette.background + : point.hour === peakHour + ? palette.chart4 + : interpolateColor( + palette.border, + palette.chart3, + Math.max(0.2, point.count / maxCount), + ), + isPeak: point.hour === peakHour, + })); + + return Plot.plot({ + width, + height: compact ? 210 : 230, + marginTop: margins.top, + marginRight: margins.right, + marginBottom: margins.bottom, + marginLeft: margins.left, + style: { + background: "transparent", + color: palette.foreground, + fontFamily: "var(--font-mono)", + fontSize: getPlotFontSize(width), + }, + x: { + type: "band", + label: "UTC hour", + tickFormat: (value) => (compact && Number(value) % 2 === 1 ? "" : String(value)), + }, + y: { + label: null, + grid: true, + nice: true, + ticks: compact ? 3 : 4, + tickFormat: (value) => formatCompactNumber(Number(value)), + }, + marks: [ + Plot.ruleY([0], { stroke: palette.border }), + Plot.barY(hourlyBars, { + x: "label", + y: "count", + fill: "tone", + inset: 1.5, + rx: 5, + title: (point) => + `${formatHourLabel(point.hour)}:00 UTC\nProjects: ${point.count.toLocaleString()}`, + }), + Plot.text( + hourlyBars.filter((point) => point.isPeak && point.count > 0), + { + x: "label", + y: "count", + text: (point) => formatCompactNumber(point.count), + dy: -10, + fill: palette.foreground, + fontSize: compact ? 10 : 11, + fontWeight: 700, + }, + ), + ], + }); + }} + />
diff --git a/apps/web/src/app/(home)/analytics/_components/types.ts b/apps/web/src/app/(home)/analytics/_components/types.ts index fa51cba6e..a316749e4 100644 --- a/apps/web/src/app/(home)/analytics/_components/types.ts +++ b/apps/web/src/app/(home)/analytics/_components/types.ts @@ -1,39 +1,104 @@ -import type { ChartConfig } from "@/components/ui/chart"; +export type DistributionItem = { name: string; value: number }; +export type Distribution = DistributionItem[]; -export type Distribution = Array<{ name: string; value: number }>; -export type VersionDistribution = Array<{ version: string; count: number }>; -export type TimeSeriesData = Array<{ date: string; count: number }>; -export type MonthlyData = Array<{ month: string; totalProjects: number }>; -export type HourlyData = Array<{ hour: string; count: number }>; +export type ShareDistributionItem = DistributionItem & { + share: number; +}; + +export type VersionDistributionItem = { + version: string; + count: number; + share: number; +}; + +export type VersionDistribution = VersionDistributionItem[]; + +export type TimeSeriesPoint = { + date: string; + dateValue: Date; + count: number; + rollingAverage: number; + cumulativeProjects: number; +}; + +export type MonthlyPoint = { + month: string; + monthDate: Date; + totalProjects: number; + cumulativeProjects: number; +}; + +export type HourlyPoint = { + hour: string; + hourValue: number; + label: string; + count: number; +}; + +export type WeekdayPoint = { + weekday: string; + shortLabel: string; + dayIndex: number; + count: number; + averageDailyProjects: number; +}; + +export type ComboMatrixPoint = { + x: string; + y: string; + count: number; + share: number; +}; + +export type ComboMatrix = { + data: ComboMatrixPoint[]; + xDomain: string[]; + yDomain: string[]; + maxValue: number; +}; + +export type MomentumSnapshot = { + trackingDays: number; + last7Days: number; + previous7Days: number; + delta: number; + deltaPercentage: number | null; + activeDaysLast30: number; + peakDay: { date: string; count: number } | null; + busiestHour: { hour: string; count: number } | null; +}; export type AggregatedAnalyticsData = { lastUpdated: string | null; totalProjects: number; avgProjectsPerDay: number; - timeSeries: TimeSeriesData; - monthlyTimeSeries: MonthlyData; - hourlyDistribution: HourlyData; - platformDistribution: Distribution; - packageManagerDistribution: Distribution; - backendDistribution: Distribution; - databaseDistribution: Distribution; - ormDistribution: Distribution; - dbSetupDistribution: Distribution; - apiDistribution: Distribution; - frontendDistribution: Distribution; - authDistribution: Distribution; - runtimeDistribution: Distribution; - addonsDistribution: Distribution; - examplesDistribution: Distribution; - gitDistribution: Distribution; - installDistribution: Distribution; - webDeployDistribution: Distribution; - serverDeployDistribution: Distribution; - paymentsDistribution: Distribution; + timeSeries: TimeSeriesPoint[]; + monthlyTimeSeries: MonthlyPoint[]; + hourlyDistribution: HourlyPoint[]; + weekdayDistribution: WeekdayPoint[]; + platformDistribution: ShareDistributionItem[]; + packageManagerDistribution: ShareDistributionItem[]; + backendDistribution: ShareDistributionItem[]; + databaseDistribution: ShareDistributionItem[]; + ormDistribution: ShareDistributionItem[]; + dbSetupDistribution: ShareDistributionItem[]; + apiDistribution: ShareDistributionItem[]; + frontendDistribution: ShareDistributionItem[]; + authDistribution: ShareDistributionItem[]; + runtimeDistribution: ShareDistributionItem[]; + addonsDistribution: ShareDistributionItem[]; + examplesDistribution: ShareDistributionItem[]; + gitDistribution: ShareDistributionItem[]; + installDistribution: ShareDistributionItem[]; + webDeployDistribution: ShareDistributionItem[]; + serverDeployDistribution: ShareDistributionItem[]; + paymentsDistribution: ShareDistributionItem[]; nodeVersionDistribution: VersionDistribution; cliVersionDistribution: VersionDistribution; - popularStackCombinations: Distribution; - databaseORMCombinations: Distribution; + stackCombinationDistribution: ShareDistributionItem[]; + databaseORMCombinationDistribution: ShareDistributionItem[]; + stackMatrix: ComboMatrix; + databaseOrmMatrix: ComboMatrix; summary: { mostPopularFrontend: string; mostPopularBackend: string; @@ -43,10 +108,8 @@ export type AggregatedAnalyticsData = { mostPopularAuth: string; mostPopularPackageManager: string; mostPopularRuntime: string; + topStack: string; + topDatabasePair: string; }; + momentum: MomentumSnapshot; }; - -export const chartConfig = { - value: { label: "Projects", color: "var(--chart-1)" }, - count: { label: "Projects", color: "var(--chart-1)" }, -} satisfies ChartConfig; diff --git a/apps/web/src/app/(home)/analytics/analytics-client.tsx b/apps/web/src/app/(home)/analytics/analytics-client.tsx index 8bb7f9da3..242d35ca2 100644 --- a/apps/web/src/app/(home)/analytics/analytics-client.tsx +++ b/apps/web/src/app/(home)/analytics/analytics-client.tsx @@ -2,9 +2,17 @@ import { api } from "@better-t-stack/backend/convex/_generated/api"; import { type Preloaded, useConvexConnectionState, usePreloadedQuery } from "convex/react"; +import { useEffect, useState } from "react"; +import { + buildComboMatrix, + buildWeekdayDistribution, + splitComboLabel, + versionWithShare, + withShare, +} from "./_components/analytics-helpers"; import AnalyticsPage from "./_components/analytics-page"; -import type { AggregatedAnalyticsData, Distribution } from "./_components/types"; +import type { AggregatedAnalyticsData, Distribution, TimeSeriesPoint } from "./_components/types"; type PrecomputedStats = { totalProjects: number; @@ -104,11 +112,48 @@ function getCalendarDaySpanFromRange( return Math.floor((end - start) / MILLISECONDS_PER_DAY) + 1; } +function buildTimeSeries(dailyStats: DailyStats[]): TimeSeriesPoint[] { + const sorted = [...dailyStats].sort((a, b) => a.date.localeCompare(b.date)); + let cumulativeProjects = 0; + + return sorted.map((day, index) => { + cumulativeProjects += day.count; + const trailingWindow = sorted.slice(Math.max(0, index - 6), index + 1); + const rollingAverage = + trailingWindow.reduce((sum, point) => sum + point.count, 0) / trailingWindow.length; + + return { + date: day.date, + dateValue: new Date(`${day.date}T00:00:00`), + count: day.count, + rollingAverage, + cumulativeProjects, + }; + }); +} + +function buildMonthlyTimeSeries(monthlyStats: MonthlyStats["monthly"]) { + let cumulativeProjects = 0; + + return [...monthlyStats] + .sort((a, b) => a.month.localeCompare(b.month)) + .map((month) => { + cumulativeProjects += month.totalProjects; + return { + month: month.month, + monthDate: new Date(`${month.month}-01T00:00:00`), + totalProjects: month.totalProjects, + cumulativeProjects, + }; + }); +} + function buildFromPrecomputed( stats: PrecomputedStats, dailyStats: DailyStats[], monthlyStats: MonthlyStats, ): AggregatedAnalyticsData { + const totalProjects = stats.totalProjects; const backendDistribution = recordToDistribution(stats.backend); const frontendDistribution = recordToDistribution(stats.frontend); const databaseDistribution = recordToDistribution(stats.database); @@ -126,62 +171,107 @@ function buildFromPrecomputed( const paymentsDistribution = recordToDistribution(stats.payments); const gitDistribution = recordToDistribution(stats.git); const installDistribution = recordToDistribution(stats.install); - const nodeVersionDistribution = recordToDistribution(stats.nodeVersion).map((d) => ({ - version: d.name, - count: d.value, - })); - const cliVersionDistribution = recordToDistribution(stats.cliVersion) - .filter((d) => d.name !== "unknown") - .slice(0, 10) - .map((d) => ({ version: d.name, count: d.value })); - - const timeSeries = dailyStats - .map((d) => ({ date: d.date, count: d.count })) - .sort((a, b) => a.date.localeCompare(b.date)); - - const monthlyTimeSeries = monthlyStats.monthly; + const stackCombinationDistribution = withShare( + recordToDistribution(stats.stackCombinations), + totalProjects, + ); + const databaseORMCombinationDistribution = withShare( + recordToDistribution(stats.dbOrmCombinations), + totalProjects, + ); + + const timeSeries = buildTimeSeries(dailyStats); + const monthlyTimeSeries = buildMonthlyTimeSeries(monthlyStats.monthly); const calendarDaySpan = getCalendarDaySpanFromRange( monthlyStats.firstDate, monthlyStats.lastDate, - timeSeries, + dailyStats, ); - const hourlyDistribution = Array.from({ length: 24 }, (_, i) => { - const hour = String(i).padStart(2, "0"); - return { hour: `${hour}:00`, count: stats.hourlyDistribution[hour] || 0 }; + const hourlyDistribution = Array.from({ length: 24 }, (_, hourValue) => { + const hour = String(hourValue).padStart(2, "0"); + return { + hour: `${hour}:00`, + hourValue, + label: hour, + count: stats.hourlyDistribution[hour] || 0, + }; }); - const popularStackCombinations = recordToDistribution(stats.stackCombinations).slice(0, 8); - const databaseORMCombinations = recordToDistribution(stats.dbOrmCombinations).slice(0, 8); + const weekdayDistribution = buildWeekdayDistribution(timeSeries); + const nodeVersionDistribution = versionWithShare( + recordToDistribution(stats.nodeVersion).map((item) => ({ + version: item.name, + count: item.value, + })), + totalProjects, + ); + const cliVersionDistribution = versionWithShare( + recordToDistribution(stats.cliVersion) + .filter((item) => item.name !== "unknown") + .slice(0, 10) + .map((item) => ({ + version: item.name, + count: item.value, + })), + totalProjects, + ); + + const recent7Days = timeSeries.slice(-7).reduce((sum, point) => sum + point.count, 0); + const previous7Days = timeSeries.slice(-14, -7).reduce((sum, point) => sum + point.count, 0); + const delta = recent7Days - previous7Days; + const deltaPercentage = previous7Days > 0 ? delta / previous7Days : recent7Days > 0 ? null : 0; + const peakDay = timeSeries.reduce( + (max, point) => (max && max.count >= point.count ? max : point), + null, + ); + const busiestHourCandidate = hourlyDistribution.reduce< + (typeof hourlyDistribution)[number] | null + >((max, point) => (max && max.count >= point.count ? max : point), null); + const busiestHour = + busiestHourCandidate && busiestHourCandidate.count > 0 ? busiestHourCandidate : null; return { lastUpdated: new Date(stats.lastEventTime).toISOString(), - totalProjects: stats.totalProjects, - avgProjectsPerDay: stats.totalProjects / calendarDaySpan, + totalProjects, + avgProjectsPerDay: totalProjects / Math.max(calendarDaySpan, 1), timeSeries, monthlyTimeSeries, hourlyDistribution, - platformDistribution, - packageManagerDistribution, - backendDistribution, - databaseDistribution, - ormDistribution, - dbSetupDistribution, - apiDistribution, - frontendDistribution, - authDistribution, - runtimeDistribution, - addonsDistribution, - examplesDistribution, - gitDistribution, - installDistribution, - webDeployDistribution, - serverDeployDistribution, - paymentsDistribution, + weekdayDistribution, + platformDistribution: withShare(platformDistribution, totalProjects), + packageManagerDistribution: withShare(packageManagerDistribution, totalProjects), + backendDistribution: withShare(backendDistribution, totalProjects), + databaseDistribution: withShare(databaseDistribution, totalProjects), + ormDistribution: withShare(ormDistribution, totalProjects), + dbSetupDistribution: withShare(dbSetupDistribution, totalProjects), + apiDistribution: withShare(apiDistribution, totalProjects), + frontendDistribution: withShare(frontendDistribution, totalProjects), + authDistribution: withShare(authDistribution, totalProjects), + runtimeDistribution: withShare(runtimeDistribution, totalProjects), + addonsDistribution: withShare(addonsDistribution, totalProjects), + examplesDistribution: withShare(examplesDistribution, totalProjects), + gitDistribution: withShare(gitDistribution, totalProjects), + installDistribution: withShare(installDistribution, totalProjects), + webDeployDistribution: withShare(webDeployDistribution, totalProjects), + serverDeployDistribution: withShare(serverDeployDistribution, totalProjects), + paymentsDistribution: withShare(paymentsDistribution, totalProjects), nodeVersionDistribution, cliVersionDistribution, - popularStackCombinations, - databaseORMCombinations, + stackCombinationDistribution, + databaseORMCombinationDistribution, + stackMatrix: buildComboMatrix({ + distribution: stackCombinationDistribution, + total: totalProjects, + xFromLabel: (name) => splitComboLabel(name)[1], + yFromLabel: (name) => splitComboLabel(name)[0], + }), + databaseOrmMatrix: buildComboMatrix({ + distribution: databaseORMCombinationDistribution, + total: totalProjects, + xFromLabel: (name) => splitComboLabel(name)[1], + yFromLabel: (name) => splitComboLabel(name)[0], + }), summary: { mostPopularFrontend: getMostPopular(frontendDistribution), mostPopularBackend: getMostPopular(backendDistribution), @@ -191,10 +281,77 @@ function buildFromPrecomputed( mostPopularAuth: getMostPopular(authDistribution), mostPopularPackageManager: getMostPopular(packageManagerDistribution), mostPopularRuntime: getMostPopular(runtimeDistribution), + topStack: stackCombinationDistribution[0]?.name ?? "none", + topDatabasePair: databaseORMCombinationDistribution[0]?.name ?? "none", + }, + momentum: { + trackingDays: calendarDaySpan, + last7Days: recent7Days, + previous7Days, + delta, + deltaPercentage, + activeDaysLast30: timeSeries.filter((point) => point.count > 0).length, + peakDay: peakDay ? { date: peakDay.date, count: peakDay.count } : null, + busiestHour: busiestHour ? { hour: busiestHour.hour, count: busiestHour.count } : null, }, }; } +const emptyData: AggregatedAnalyticsData = { + lastUpdated: null, + totalProjects: 0, + avgProjectsPerDay: 0, + timeSeries: [], + monthlyTimeSeries: [], + hourlyDistribution: [], + weekdayDistribution: [], + platformDistribution: [], + packageManagerDistribution: [], + backendDistribution: [], + databaseDistribution: [], + ormDistribution: [], + dbSetupDistribution: [], + apiDistribution: [], + frontendDistribution: [], + authDistribution: [], + runtimeDistribution: [], + addonsDistribution: [], + examplesDistribution: [], + gitDistribution: [], + installDistribution: [], + webDeployDistribution: [], + serverDeployDistribution: [], + paymentsDistribution: [], + nodeVersionDistribution: [], + cliVersionDistribution: [], + stackCombinationDistribution: [], + databaseORMCombinationDistribution: [], + stackMatrix: { data: [], xDomain: [], yDomain: [], maxValue: 0 }, + databaseOrmMatrix: { data: [], xDomain: [], yDomain: [], maxValue: 0 }, + summary: { + mostPopularFrontend: "none", + mostPopularBackend: "none", + mostPopularDatabase: "none", + mostPopularORM: "none", + mostPopularAPI: "none", + mostPopularAuth: "none", + mostPopularPackageManager: "none", + mostPopularRuntime: "none", + topStack: "none", + topDatabasePair: "none", + }, + momentum: { + trackingDays: 0, + last7Days: 0, + previous7Days: 0, + delta: 0, + deltaPercentage: 0, + activeDaysLast30: 0, + peakDay: null, + busiestHour: null, + }, +}; + export function AnalyticsClient({ preloadedStats, preloadedDailyStats, @@ -208,49 +365,15 @@ export function AnalyticsClient({ const dailyStats = usePreloadedQuery(preloadedDailyStats); const monthlyStats = usePreloadedQuery(preloadedMonthlyStats); const connectionState = useConvexConnectionState(); - const connectionStatus = getConnectionStatus(connectionState); - - const data = stats - ? buildFromPrecomputed(stats, dailyStats, monthlyStats) - : { - lastUpdated: null, - totalProjects: 0, - avgProjectsPerDay: 0, - timeSeries: [], - monthlyTimeSeries: [], - hourlyDistribution: [], - platformDistribution: [], - packageManagerDistribution: [], - backendDistribution: [], - databaseDistribution: [], - ormDistribution: [], - dbSetupDistribution: [], - apiDistribution: [], - frontendDistribution: [], - authDistribution: [], - runtimeDistribution: [], - addonsDistribution: [], - examplesDistribution: [], - gitDistribution: [], - installDistribution: [], - webDeployDistribution: [], - serverDeployDistribution: [], - paymentsDistribution: [], - nodeVersionDistribution: [], - cliVersionDistribution: [], - popularStackCombinations: [], - databaseORMCombinations: [], - summary: { - mostPopularFrontend: "none", - mostPopularBackend: "none", - mostPopularDatabase: "none", - mostPopularORM: "none", - mostPopularAPI: "none", - mostPopularAuth: "none", - mostPopularPackageManager: "none", - mostPopularRuntime: "none", - }, - }; + const [hasHydrated, setHasHydrated] = useState(false); + + useEffect(() => { + setHasHydrated(true); + }, []); + + const connectionStatus = hasHydrated ? getConnectionStatus(connectionState) : "connecting"; + + const data = stats ? buildFromPrecomputed(stats, dailyStats, monthlyStats) : emptyData; const legacy = { total: 55434, diff --git a/apps/web/src/app/global.css b/apps/web/src/app/global.css index 7a56331a7..0eccfeeb6 100644 --- a/apps/web/src/app/global.css +++ b/apps/web/src/app/global.css @@ -384,138 +384,46 @@ animation: file-load 0.3s ease-out forwards; } -/* Chart styling to match terminal aesthetic */ -.recharts-wrapper { - font-family: var(--font-mono); -} - -.recharts-cartesian-axis-tick-value { - font-size: 0.75rem; - font-weight: 500; - color: hsl(var(--muted-foreground)); -} - -.recharts-cartesian-axis-line { - stroke: hsl(var(--border)); - stroke-width: 1; -} - -.recharts-cartesian-grid-horizontal line, -.recharts-cartesian-grid-vertical line { - stroke: hsl(var(--border)); - stroke-width: 1; - stroke-dasharray: 3 3; - opacity: 0.5; -} - -.recharts-tooltip-wrapper { - background: hsl(var(--popover)); - border: 1px solid hsl(var(--border)); - border-radius: var(--radius); - box-shadow: var(--shadow-lg); - padding: 0.5rem; - font-family: var(--font-mono); - font-size: 0.75rem; -} - -.recharts-tooltip-item { - color: hsl(var(--foreground)); -} - -.recharts-legend-item-text { - font-family: var(--font-mono); - font-size: 0.75rem; - color: hsl(var(--foreground)); -} - -.recharts-pie-label { - font-family: var(--font-mono); - font-size: 0.75rem; - font-weight: 500; - color: hsl(var(--foreground)); -} - -.recharts-bar-rectangle { - transition: opacity 0.2s ease; -} - -.recharts-bar-rectangle:hover { - opacity: 0.8; -} - -.recharts-pie-sector { - transition: opacity 0.2s ease; -} - -.recharts-pie-sector:hover { - opacity: 0.8; -} - -.recharts-area-curve { - transition: opacity 0.2s ease; -} - -.recharts-area-curve:hover { - opacity: 0.8; +.plot-frame figure, +.plot-frame svg { + width: 100%; } -/* Chart container styling */ -.chart-container { - border-radius: var(--radius); - overflow: hidden; +.plot-frame svg { + display: block; + overflow: visible; } -/* Terminal-style chart headers */ -.chart-header { - border-bottom: 1px solid hsl(var(--border)); - padding: 0.75rem 1rem; - background: hsl(var(--card)); +.bts-plot { + max-width: 100%; + --plot-background: color-mix(in srgb, var(--background) 96%, var(--foreground) 4%); } -.chart-header-title { +.bts-plot text, +.bts-plot .tick text { font-family: var(--font-mono); - font-size: 0.875rem; - font-weight: 600; - color: hsl(var(--foreground)); } -.chart-header-description { - font-size: 0.75rem; - color: hsl(var(--muted-foreground)); - margin-top: 0.25rem; +.bts-plot [aria-label="tip"] { + color: var(--foreground); } -/* Chart hover effects */ -.chart-card { - transition: all 0.2s ease; - border: 1px solid hsl(var(--border)); - border-radius: var(--radius); - overflow: hidden; +.bts-plot [aria-label="tip"] path { + fill: var(--plot-background); + stroke: color-mix(in srgb, var(--border) 92%, transparent); } -.chart-card:hover { - border-color: hsl(var(--primary)); - box-shadow: 0 0 0 1px hsl(var(--primary) / 0.1); +.bts-plot [aria-label="tip"] text, +.bts-plot [aria-label="tip"] tspan { + fill: var(--foreground); } -/* Chart loading state */ -.chart-loading { - display: flex; - align-items: center; - justify-content: center; - height: 300px; - color: hsl(var(--muted-foreground)); - font-family: var(--font-mono); - font-size: 0.875rem; +.bts-plot .domain, +.bts-plot .tick line { + stroke: color-mix(in srgb, var(--border) 80%, transparent); } -/* Chart error state */ -.chart-error { - display: flex; - align-items: center; - justify-content: center; - height: 300px; - color: hsl(var(--destructive)); - font-family: var(--font-mono); - font-size: 0.875rem; +.bts-plot .grid line { + stroke: color-mix(in srgb, var(--border) 55%, transparent); + stroke-dasharray: 3 4; } diff --git a/apps/web/src/components/ui/chart.tsx b/apps/web/src/components/ui/chart.tsx deleted file mode 100644 index 64ae40ae3..000000000 --- a/apps/web/src/components/ui/chart.tsx +++ /dev/null @@ -1,325 +0,0 @@ -"use client"; - -import * as React from "react"; -import * as RechartsPrimitive from "recharts"; - -import { cn } from "@/lib/utils"; - -// Format: { THEME_NAME: CSS_SELECTOR } -const THEMES = { light: "", dark: ".dark" } as const; - -export type ChartConfig = { - [k in string]: { - label?: React.ReactNode; - icon?: React.ComponentType; - } & ( - | { color?: string; theme?: never } - | { color?: never; theme: Record } - ); -}; - -type ChartContextProps = { - config: ChartConfig; -}; - -const ChartContext = React.createContext(null); - -function useChart() { - const context = React.useContext(ChartContext); - - if (!context) { - throw new Error("useChart must be used within a "); - } - - return context; -} - -function ChartContainer({ - id, - className, - children, - config, - ...props -}: React.ComponentProps<"div"> & { - config: ChartConfig; - children: React.ComponentProps["children"]; -}) { - const uniqueId = React.useId(); - const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; - - return ( - -
- - {children} -
-
- ); -} - -const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { - const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color); - - if (!colorConfig.length) { - return null; - } - - return ( -