From cf7cc6c885608e995129c6179a67f5dad312fa3f Mon Sep 17 00:00:00 2001 From: Arnaud Date: Sun, 19 Jul 2026 20:02:58 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Admin=20Insights=20page=20=E2=80=94=20c?= =?UTF-8?q?harts=20for=20signups,=20recipes,=20tiers,=20usage=20(v0.55.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Admin > Insights: signups/day and recipes-created/day (manual vs AI) over the last 30 days, users by tier, recipes by visibility, monthly AI call totals (6 months), and support tickets by status. Two hand-rolled SVG chart components (bar-chart.tsx, time-series-chart.tsx) instead of pulling in a charting library — avoids a new dependency and any React 19 peer-dep risk. Both ship a hover tooltip (crosshair for the time series, per-bar for the bar chart) and a "show as table" toggle per the dataviz method's accessibility requirement. Repurposed the app's existing (previously unused, grayscale-only) shadcn --chart-1..5 CSS variables with a validated categorical palette — run through the dataviz skill's CVD/contrast validator for both light and dark surfaces (both pass; three light-mode slots fall under 3:1 contrast by design, mitigated by the charts' direct value/axis labels). Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 5 + apps/web/app/admin/insights/page.tsx | 176 ++++++++++++++++++ apps/web/app/admin/layout.tsx | 3 +- apps/web/app/globals.css | 20 +- .../web/components/admin/charts/bar-chart.tsx | 156 ++++++++++++++++ .../admin/charts/time-series-chart.tsx | 121 ++++++++++++ apps/web/lib/changelog.ts | 9 +- apps/web/package.json | 2 +- package.json | 2 +- 9 files changed, 480 insertions(+), 14 deletions(-) create mode 100644 apps/web/app/admin/insights/page.tsx create mode 100644 apps/web/components/admin/charts/bar-chart.tsx create mode 100644 apps/web/components/admin/charts/time-series-chart.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index b09d08a..d019cbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. +## 0.55.0 — 2026-07-19 17:50 + +### Added +- New Admin > Insights page — signups and recipes-created over the last 30 days, users by tier, recipes by visibility, monthly AI usage, and support tickets by status. Charts are hand-built (hover tooltips, table-view fallback, colorblind-safe palette), no new dependency. + ## 0.54.1 — 2026-07-19 17:25 ### Added diff --git a/apps/web/app/admin/insights/page.tsx b/apps/web/app/admin/insights/page.tsx new file mode 100644 index 0000000..2020ae8 --- /dev/null +++ b/apps/web/app/admin/insights/page.tsx @@ -0,0 +1,176 @@ +import type { Metadata } from "next"; +import { db, users, recipes, userUsage, supportTickets, gte, sql } from "@epicure/db"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { BarChart } from "@/components/admin/charts/bar-chart"; +import { TimeSeriesChart } from "@/components/admin/charts/time-series-chart"; + +export const metadata: Metadata = {}; + +const DAYS = 30; + +function lastNDays(n: number): string[] { + const out: string[] = []; + const now = new Date(); + for (let i = n - 1; i >= 0; i--) { + const d = new Date(now); + d.setDate(d.getDate() - i); + out.push(d.toISOString().slice(0, 10)); + } + return out; +} + +function formatShortDate(d: string) { + const date = new Date(`${d}T00:00:00Z`); + return date.toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: "UTC" }); +} + +function lastNMonths(n: number): string[] { + const out: string[] = []; + const now = new Date(); + for (let i = n - 1; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + out.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`); + } + return out; +} + +function formatMonth(m: string) { + const [y, mo] = m.split("-"); + return new Date(Number(y), Number(mo) - 1, 1).toLocaleDateString(undefined, { month: "short", year: "2-digit" }); +} + +export default async function AdminInsightsPage() { + const since = new Date(); + since.setDate(since.getDate() - DAYS); + + const [ + signupRows, + recipeRows, + tierRows, + visibilityRows, + usageRows, + ticketRows, + ] = await Promise.all([ + db + .select({ day: sql`to_char(${users.createdAt}, 'YYYY-MM-DD')`, n: sql`count(*)::int` }) + .from(users) + .where(gte(users.createdAt, since)) + .groupBy(sql`1`), + db + .select({ + day: sql`to_char(${recipes.createdAt}, 'YYYY-MM-DD')`, + aiGenerated: recipes.aiGenerated, + n: sql`count(*)::int`, + }) + .from(recipes) + .where(gte(recipes.createdAt, since)) + .groupBy(sql`1`, recipes.aiGenerated), + db.select({ tier: users.tier, n: sql`count(*)::int` }).from(users).groupBy(users.tier), + db.select({ visibility: recipes.visibility, n: sql`count(*)::int` }).from(recipes).groupBy(recipes.visibility), + db + .select({ month: userUsage.month, n: sql`coalesce(sum(${userUsage.aiCallsUsed}), 0)::int` }) + .from(userUsage) + .groupBy(userUsage.month), + db.select({ status: supportTickets.status, n: sql`count(*)::int` }).from(supportTickets).groupBy(supportTickets.status), + ]); + + const signupByDay = new Map(signupRows.map((r) => [r.day, r.n])); + const signupSeries = lastNDays(DAYS).map((day) => ({ date: day, value: signupByDay.get(day) ?? 0 })); + + const recipesByDay = new Map(); + for (const r of recipeRows) { + const entry = recipesByDay.get(r.day) ?? { manual: 0, ai: 0 }; + if (r.aiGenerated) entry.ai += r.n; else entry.manual += r.n; + recipesByDay.set(r.day, entry); + } + const recipesSeries = lastNDays(DAYS).map((day) => { + const entry = recipesByDay.get(day) ?? { manual: 0, ai: 0 }; + return { label: formatShortDate(day), values: [entry.manual, entry.ai] }; + }); + + const TIER_ORDER = ["free", "pro", "family"] as const; + const tierByKey = new Map(tierRows.map((r) => [r.tier, r.n])); + const tierData = TIER_ORDER.map((tier) => ({ label: tier, values: [tierByKey.get(tier) ?? 0] })); + + const VISIBILITY_ORDER = ["private", "unlisted", "followers", "public"] as const; + const visByKey = new Map(visibilityRows.map((r) => [r.visibility, r.n])); + const visibilityData = VISIBILITY_ORDER.map((v) => ({ label: v, values: [visByKey.get(v) ?? 0] })); + + const usageByMonth = new Map(usageRows.map((r) => [r.month, r.n])); + const usageSeries = lastNMonths(6).map((month) => ({ date: month, value: usageByMonth.get(month) ?? 0 })); + + const STATUS_ORDER = ["open", "triaged", "closed"] as const; + const statusByKey = new Map(ticketRows.map((r) => [r.status, r.n])); + const statusData = STATUS_ORDER.map((s) => ({ label: s, values: [statusByKey.get(s) ?? 0] })); + + return ( +
+
+

Insights

+

Trends and breakdowns across the last {DAYS} days (or 6 months for usage).

+
+ +
+ + + New signups + Daily, last {DAYS} days + + + + + + + + + Recipes created + Daily, manual vs AI-generated + + + + + + + + + Users by tier + All-time + + + + + + + + + Recipes by visibility + All-time + + + + + + + + + AI calls + Monthly total across all users, last 6 months + + + + + + + + + Support tickets by status + All-time + + + + + +
+
+ ); +} diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/admin/layout.tsx index 36a4eb9..4a6d36f 100644 --- a/apps/web/app/admin/layout.tsx +++ b/apps/web/app/admin/layout.tsx @@ -3,11 +3,12 @@ import { headers } from "next/headers"; import { auth } from "@/lib/auth/server"; import { db, users, eq } from "@epicure/db"; import Link from "next/link"; -import { Shield, Users, BookOpen, Settings, BarChart3, ClipboardList, HardDrive, Bot, ArrowLeft, Gauge, Mail, Flag, History, LifeBuoy } from "lucide-react"; +import { Shield, Users, BookOpen, Settings, BarChart3, ClipboardList, HardDrive, Bot, ArrowLeft, Gauge, Mail, Flag, History, LifeBuoy, TrendingUp } from "lucide-react"; import { cn } from "@/lib/utils"; const adminNav = [ { href: "/admin", label: "Overview", icon: BarChart3 }, + { href: "/admin/insights", label: "Insights", icon: TrendingUp }, { href: "/admin/users", label: "Users", icon: Users }, { href: "/admin/invites", label: "Invites", icon: Mail }, { href: "/admin/recipes", label: "Recipes", icon: BookOpen }, diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 971deac..b681fc1 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -69,11 +69,11 @@ --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); + --chart-1: #2a78d6; + --chart-2: #008300; + --chart-3: #e87ba4; + --chart-4: #eda100; + --chart-5: #1baf7a; --radius: 0.625rem; --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.145 0 0); @@ -105,11 +105,11 @@ --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); + --chart-1: #3987e5; + --chart-2: #008300; + --chart-3: #d55181; + --chart-4: #c98500; + --chart-5: #199e70; --sidebar: oklch(0.205 0 0); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376); diff --git a/apps/web/components/admin/charts/bar-chart.tsx b/apps/web/components/admin/charts/bar-chart.tsx new file mode 100644 index 0000000..300a511 --- /dev/null +++ b/apps/web/components/admin/charts/bar-chart.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useState } from "react"; +import { cn } from "@/lib/utils"; + +const SERIES_COLORS = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)"]; + +export type BarChartGroup = { + label: string; + values: number[]; +}; + +/** Grouped vertical bar chart — hand-rolled SVG (no charting lib), series + * colors assigned by fixed index (never repainted if a filter changes + * which groups are visible). Includes a hover tooltip and a table-view + * toggle for the low-contrast-slot / no-color accessibility path. */ +export function BarChart({ + data, + seriesLabels, + formatValue = (n) => String(n), + height = 220, +}: { + data: BarChartGroup[]; + seriesLabels: string[]; + formatValue?: (n: number) => string; + height?: number; +}) { + const [hovered, setHovered] = useState(null); + const [showTable, setShowTable] = useState(false); + + const max = Math.max(1, ...data.flatMap((d) => d.values)); + const width = 600; + const paddingBottom = 28; + const paddingTop = 12; + const plotHeight = height - paddingBottom - paddingTop; + const groupWidth = width / Math.max(1, data.length); + const barGap = 2; + const barWidth = Math.max(4, (groupWidth - 16 - barGap * (seriesLabels.length - 1)) / seriesLabels.length); + + if (data.length === 0) { + return

No data yet.

; + } + + return ( +
+ {seriesLabels.length > 1 && ( +
+ {seriesLabels.map((label, i) => ( +
+ + {label} +
+ ))} +
+ )} + + {showTable ? ( +
+ + + + + {seriesLabels.map((label) => ( + + ))} + + + + {data.map((d) => ( + + + {d.values.map((v, i) => ( + + ))} + + ))} + +
Label{label}
{d.label}{formatValue(v)}
+
+ ) : ( +
+ + + {data.map((group, gi) => { + const gx = gi * groupWidth + 8; + return ( + setHovered(gi)} onMouseLeave={() => setHovered(null)}> + + {group.values.map((v, si) => { + const barHeight = Math.max(0, (v / max) * plotHeight); + const bx = gx + si * (barWidth + barGap); + const by = height - paddingBottom - barHeight; + return ( + + ); + })} + + {group.label} + + {seriesLabels.length === 1 && group.values[0]! > 0 && ( + + {formatValue(group.values[0]!)} + + )} + + ); + })} + + {hovered !== null && ( +
+

{data[hovered]!.label}

+ {data[hovered]!.values.map((v, i) => ( +

+ + {seriesLabels[i]}: {formatValue(v)} +

+ ))} +
+ )} +
+ )} + + +
+ ); +} diff --git a/apps/web/components/admin/charts/time-series-chart.tsx b/apps/web/components/admin/charts/time-series-chart.tsx new file mode 100644 index 0000000..3e99ea2 --- /dev/null +++ b/apps/web/components/admin/charts/time-series-chart.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useState, useRef } from "react"; +import { cn } from "@/lib/utils"; + +export type TimeSeriesPoint = { date: string; value: number }; + +/** Single-series area/line chart with a hover crosshair+tooltip and a + * table-view fallback — hand-rolled SVG, no charting lib. */ +export function TimeSeriesChart({ + data, + formatValue = (n) => String(n), + formatDate = (d) => d, + height = 200, +}: { + data: TimeSeriesPoint[]; + formatValue?: (n: number) => string; + formatDate?: (d: string) => string; + height?: number; +}) { + const [hovered, setHovered] = useState(null); + const [showTable, setShowTable] = useState(false); + const svgRef = useRef(null); + + const width = 600; + const paddingBottom = 24; + const paddingTop = 12; + const plotHeight = height - paddingBottom - paddingTop; + const max = Math.max(1, ...data.map((d) => d.value)); + const n = data.length; + + if (n === 0) { + return

No data yet.

; + } + + const xFor = (i: number) => (n === 1 ? width / 2 : (i / (n - 1)) * width); + const yFor = (v: number) => height - paddingBottom - (v / max) * plotHeight; + + const linePath = data.map((d, i) => `${i === 0 ? "M" : "L"} ${xFor(i)} ${yFor(d.value)}`).join(" "); + const areaPath = `${linePath} L ${xFor(n - 1)} ${height - paddingBottom} L ${xFor(0)} ${height - paddingBottom} Z`; + + function handleMove(e: React.MouseEvent) { + const svg = svgRef.current; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + const relX = (e.clientX - rect.left) / rect.width; + const i = Math.round(relX * (n - 1)); + setHovered(Math.max(0, Math.min(n - 1, i))); + } + + const labelStep = Math.max(1, Math.ceil(n / 6)); + + return ( +
+ {showTable ? ( +
+ + + + + + + + + {data.map((d) => ( + + + + + ))} + +
DateValue
{formatDate(d.date)}{formatValue(d.value)}
+
+ ) : ( +
+ setHovered(null)} + > + + + + {data.map((d, i) => ( + i % labelStep === 0 && ( + + {formatDate(d.date)} + + ) + ))} + {hovered !== null && ( + <> + + + + )} + + {hovered !== null && ( +
+

{formatDate(data[hovered]!.date)}

+

{formatValue(data[hovered]!.value)}

+
+ )} +
+ )} + + +
+ ); +} diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index cc00e4a..f8965ca 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -1,5 +1,5 @@ // Mirrors CHANGELOG.md at the repo root — update both together. -export const APP_VERSION = "0.54.1"; +export const APP_VERSION = "0.55.0"; export type ChangelogEntry = { version: string; @@ -11,6 +11,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.55.0", + date: "2026-07-19 17:50", + added: [ + "New Admin > Insights page — signups and recipes-created over the last 30 days, users by tier, recipes by visibility, monthly AI usage, and support tickets by status. Charts are hand-built (hover tooltips, table-view fallback, colorblind-safe palette validated against WCAG/CVD checks), no new dependency.", + ], + }, { version: "0.54.1", date: "2026-07-19 17:25", diff --git a/apps/web/package.json b/apps/web/package.json index b033b7c..313e001 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.54.1", + "version": "0.55.0", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index c235a2c..d58c4ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.54.1", + "version": "0.55.0", "private": true, "scripts": { "dev": "pnpm --filter web dev",