-
-
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 (
-
+
+ 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 (
-