diff --git a/admin-token-command.fish b/admin-token-command.fish new file mode 100644 index 0000000..88129c5 --- /dev/null +++ b/admin-token-command.fish @@ -0,0 +1,7 @@ +set -x ADMIN_TOKEN_SIGNING_SECRET 'Y/NRcCZFluKtSjKgxixDHgeFF0h24B8EdWYLjeviLRYJnnpDpJaphIuQBqfSx3I0NQDC5PqeZ8Z7aJDQAlMp9Q==' +set -x ADMIN_TOKEN_AUDIENCE 'xtablo-admin' +set -x ADMIN_OPERATOR_EMAIL 'ops@xtablo.com' +set -x ADMIN_OPERATOR_ID 'operator-1' +set -x ADMIN_OPERATOR_ROLE 'superadmin' + +node -e 'const {createHmac}=require("crypto"); const enc=(v)=>Buffer.from(JSON.stringify(v)).toString("base64url"); const now=Math.floor(Date.now()/1000); const claims={aud:process.env.ADMIN_TOKEN_AUDIENCE||"xtablo-admin",email:process.env.ADMIN_OPERATOR_EMAIL,exp:now+60*15,role:process.env.ADMIN_OPERATOR_ROLE||"superadmin",sub:process.env.ADMIN_OPERATOR_ID,type:"admin_access"}; const header=enc({alg:"HS256",typ:"JWT"}); const payload=enc(claims); const sig=createHmac("sha256",process.env.ADMIN_TOKEN_SIGNING_SECRET).update(`${header}.${payload}`).digest("base64url"); console.log(`${header}.${payload}.${sig}`);' diff --git a/admin-token-command.txt b/admin-token-command.txt new file mode 100644 index 0000000..5ca718f --- /dev/null +++ b/admin-token-command.txt @@ -0,0 +1,7 @@ +set ADMIN_TOKEN_SIGNING_SECRET 'Y/NRcCZFluKtSjKgxixDHgeFF0h24B8EdWYLjeviLRYJnnpDpJaphIuQBqfSx3I0NQDC5PqeZ8Z7aJDQAlMp9Q==' +set ADMIN_TOKEN_AUDIENCE 'xtablo-admin' +set ADMIN_OPERATOR_EMAIL 'ops@xtablo.com' +set ADMIN_OPERATOR_ID 'operator-1' +set ADMIN_OPERATOR_ROLE 'superadmin' + +node -e 'const {createHmac}=require("crypto"); const enc=(v)=>Buffer.from(JSON.stringify(v)).toString("base64url"); const now=Math.floor(Date.now()/1000); const claims={aud:process.env.ADMIN_TOKEN_AUDIENCE||"xtablo-admin",email:process.env.ADMIN_OPERATOR_EMAIL,exp:now+60*15,role:process.env.ADMIN_OPERATOR_ROLE||"superadmin",sub:process.env.ADMIN_OPERATOR_ID,type:"admin_access"}; const header=enc({alg:"HS256",typ:"JWT"}); const payload=enc(claims); const sig=createHmac("sha256",process.env.ADMIN_TOKEN_SIGNING_SECRET).update(`${header}.${payload}`).digest("base64url"); console.log(`${header}.${payload}.${sig}`);' diff --git a/admin-token-from-gcp.fish b/admin-token-from-gcp.fish new file mode 100644 index 0000000..4126cdd --- /dev/null +++ b/admin-token-from-gcp.fish @@ -0,0 +1,7 @@ +set -x ADMIN_TOKEN_SIGNING_SECRET (gcloud secrets versions access latest --secret=admin-token-signing-secret --project=xtablo | string collect) +set -x ADMIN_TOKEN_AUDIENCE 'xtablo-admin' +set -x ADMIN_OPERATOR_EMAIL 'ops@xtablo.com' +set -x ADMIN_OPERATOR_ID 'operator-1' +set -x ADMIN_OPERATOR_ROLE 'superadmin' + +node -e 'const {createHmac}=require("crypto"); const enc=(v)=>Buffer.from(JSON.stringify(v)).toString("base64url"); const now=Math.floor(Date.now()/1000); const claims={aud:process.env.ADMIN_TOKEN_AUDIENCE||"xtablo-admin",email:process.env.ADMIN_OPERATOR_EMAIL,exp:now+60*15,role:process.env.ADMIN_OPERATOR_ROLE||"superadmin",sub:process.env.ADMIN_OPERATOR_ID,type:"admin_access"}; const header=enc({alg:"HS256",typ:"JWT"}); const payload=enc(claims); const sig=createHmac("sha256",process.env.ADMIN_TOKEN_SIGNING_SECRET).update(`${header}.${payload}`).digest("base64url"); console.log(`${header}.${payload}.${sig}`);' diff --git a/admin-token-verify.fish b/admin-token-verify.fish new file mode 100644 index 0000000..24d692f --- /dev/null +++ b/admin-token-verify.fish @@ -0,0 +1,4 @@ +set -x TOKEN 'PASTE_TOKEN_HERE' +set -x SECRET 'PASTE_PROD_SECRET_HERE' + +node -e 'const {createHmac,timingSafeEqual}=require("crypto"); const token=process.env.TOKEN; const secret=process.env.SECRET; const [h,p,s]=token.split("."); const expected=createHmac("sha256", secret).update(`${h}.${p}`).digest(); const got=Buffer.from(s,"base64url"); console.log(expected.length===got.length && timingSafeEqual(expected,got) ? "signature-ok" : "signature-mismatch");' diff --git a/apps/admin/src/components/analytics/ChartBuilder.tsx b/apps/admin/src/components/analytics/ChartBuilder.tsx index 9bc80b4..1925de6 100644 --- a/apps/admin/src/components/analytics/ChartBuilder.tsx +++ b/apps/admin/src/components/analytics/ChartBuilder.tsx @@ -1,80 +1,224 @@ import type { + AdminDatasetFilters, AdminDatasetPoint, AdminDatasetResult, + AdminDatasetSeries, AdminDatasetSummary, } from "@xtablo/shared-types"; +type GrowthFiltersPanelProps = { + filters: AdminDatasetFilters; + onApply: () => void; + onChange: (nextFilters: AdminDatasetFilters) => void; + onReset: () => void; +}; + type ChartBuilderProps = { dataset: AdminDatasetResult | null; datasets: AdminDatasetSummary[]; + growthFilters: AdminDatasetFilters | null; + onApplyGrowthFilters: () => void; + onChangeGrowthFilters: (nextFilters: AdminDatasetFilters) => void; + onResetGrowthFilters: () => void; onSelectDatasetId: (datasetId: string) => void; selectedDatasetId: string | null; }; -function BarChart({ points }: { points: AdminDatasetPoint[] }) { - const maxValue = Math.max(...points.map((point) => point.value), 1); +const PLAN_OPTIONS = [ + { id: "solo", label: "Solo" }, + { id: "team", label: "Team" }, + { id: "annual", label: "Annual" }, +] as const; +const ONBOARDING_OPTIONS = [ + { id: "all", label: "All" }, + { id: "client", label: "Client" }, + { id: "temporary", label: "Temporary" }, + { id: "regular", label: "Regular" }, +] as const; + +function isGrowthDataset(dataset: AdminDatasetResult | null) { + return dataset?.id === "profile_growth"; +} + +function formatDateLabel(value: string | null) { + if (!value) { + return null; + } + + return new Intl.DateTimeFormat("en-US", { + day: "numeric", + month: "short", + year: "numeric", + }).format(new Date(`${value}T00:00:00.000Z`)); +} + +function buildGrowthFilterBadges(filters: AdminDatasetFilters) { + const badges = [ + filters.dateFrom || filters.dateTo + ? `Range: ${formatDateLabel(filters.dateFrom) ?? "Start"} to ${formatDateLabel(filters.dateTo) ?? "Now"}` + : "Range: All time", + `Grouping: ${filters.groupBy === "plan" ? "Plan" : "All users"}`, + `Access: ${ + filters.accessStatus === "all" + ? "Any" + : filters.accessStatus === "none" + ? "No access" + : filters.accessStatus + }`, + ]; + + if (!filters.plans.every((plan) => PLAN_OPTIONS.some((option) => option.id === plan))) { + badges.push(`Plans: ${filters.plans.join(", ")}`); + } else if (filters.plans.length === PLAN_OPTIONS.length) { + badges.push("Plans: All"); + } else { + badges.push( + `Plans: ${PLAN_OPTIONS.filter((option) => filters.plans.includes(option.id)) + .map((option) => option.label) + .join(", ")}` + ); + } + + if (filters.onboardingStates.includes("all")) { + badges.push("Profiles: All"); + } else { + badges.push( + `Profiles: ${ONBOARDING_OPTIONS.filter((option) => + filters.onboardingStates.includes(option.id) + ) + .map((option) => option.label) + .join(", ")}` + ); + } + + return badges; +} + +function EmptyChartState({ message }: { message: string }) { return ( -
- {points.map((point) => ( -
-
-
-

{point.value}

-

- {point.label} -

-
-
- ))} +
+

No Data

+

{message}

); } -function LineChart({ points }: { points: AdminDatasetPoint[] }) { - const width = 560; - const height = 220; - const maxValue = Math.max(...points.map((point) => point.value), 1); - const polyline = points - .map((point, index) => { - const x = points.length === 1 ? width / 2 : (index / (points.length - 1)) * width; - const y = height - (point.value / maxValue) * (height - 24) - 12; - return `${x},${y}`; - }) - .join(" "); +function MultiSeriesLineChart({ + points, + series = [], +}: { + points: AdminDatasetPoint[]; + series?: AdminDatasetSeries[]; +}) { + const width = 760; + const height = 320; + const chartSeries = series.length > 0 ? series : [{ id: "total", label: "Total", points }]; + const labels = Array.from( + new Set(chartSeries.flatMap((entry) => entry.points.map((point) => point.label))) + ).sort((left, right) => left.localeCompare(right)); + + if (labels.length === 0) { + return ( + + ); + } + + const maxValue = Math.max( + ...chartSeries.flatMap((entry) => entry.points.map((point) => point.value)), + 1 + ); + + const xForLabel = (label: string) => { + const index = labels.indexOf(label); + return labels.length <= 1 ? width / 2 : (index / (labels.length - 1)) * (width - 56) + 28; + }; + + const yForValue = (value: number) => height - (value / maxValue) * (height - 64) - 28; return ( -
- - - {points.map((point, index) => { - const x = points.length === 1 ? width / 2 : (index / (points.length - 1)) * width; - const y = height - (point.value / maxValue) * (height - 24) - 12; +
+
+ + {[0, 0.25, 0.5, 0.75, 1].map((ratio) => { + const y = yForValue(maxValue * ratio); + return ( + + + + {Math.round(maxValue * ratio)} + + + ); + })} - return ; - })} - -
- {points.map((point) => ( + {labels.map((label) => ( + + {label} + + ))} + + {chartSeries.map((entry) => { + const polyline = labels + .map((label) => { + const point = entry.points.find((candidate) => candidate.label === label); + return `${xForLabel(label)},${yForValue(point?.value ?? 0)}`; + }) + .join(" "); + + return ( + + + {labels.map((label) => { + const point = entry.points.find((candidate) => candidate.label === label); + return ( + + ); + })} + + ); + })} + +
+ +
+ {chartSeries.map((entry) => (
-

{point.label}

-

{point.value}

+ + {entry.label}
))}
@@ -84,7 +228,7 @@ function LineChart({ points }: { points: AdminDatasetPoint[] }) { function DonutChart({ points }: { points: AdminDatasetPoint[] }) { const total = points.reduce((sum, point) => sum + point.value, 0) || 1; - const palette = ["#172554", "#0f766e", "#b45309", "#7c2d12", "#475569"]; + const palette = ["#1736a3", "#0f766e", "#c06a12", "#7c3aed", "#be123c"]; let currentStop = 0; const gradientStops = points .map((point, index) => { @@ -98,9 +242,7 @@ function DonutChart({ points }: { points: AdminDatasetPoint[] }) {
@@ -130,12 +272,209 @@ function DonutChart({ points }: { points: AdminDatasetPoint[] }) { ); } +function BarChart({ points }: { points: AdminDatasetPoint[] }) { + const maxValue = Math.max(...points.map((point) => point.value), 1); + + return ( +
+ {points.map((point) => ( +
+
+
+

{point.value}

+

+ {point.label} +

+
+
+ ))} +
+ ); +} + +function GrowthFiltersPanel({ filters, onApply, onChange, onReset }: GrowthFiltersPanelProps) { + const togglePlan = (planId: string) => { + const nextPlans = filters.plans.includes(planId) + ? filters.plans.filter((entry) => entry !== planId) + : [...filters.plans, planId]; + + onChange({ + ...filters, + plans: nextPlans.length > 0 ? nextPlans : filters.plans, + }); + }; + + const toggleOnboarding = (state: string) => { + const isAll = state === "all"; + const nextStates: AdminDatasetFilters["onboardingStates"] = isAll + ? ["all"] + : filters.onboardingStates.includes(state as (typeof filters.onboardingStates)[number]) + ? filters.onboardingStates.filter( + (entry): entry is AdminDatasetFilters["onboardingStates"][number] => + entry !== state && entry !== "all" + ) + : [ + ...filters.onboardingStates.filter( + (entry): entry is AdminDatasetFilters["onboardingStates"][number] => entry !== "all" + ), + state as AdminDatasetFilters["onboardingStates"][number], + ]; + + onChange({ + ...filters, + onboardingStates: nextStates.length > 0 ? nextStates : ["all"], + }); + }; + + return ( +
+
+
+

Filters

+

Growth Query Controls

+
+
+ + +
+
+ +
+ + + + + + + +
+ +
+
+

Plans

+
+ {PLAN_OPTIONS.map((plan) => ( + + ))} +
+
+ +
+

Onboarding State

+
+ {ONBOARDING_OPTIONS.map((option) => ( + + ))} +
+
+
+
+ ); +} + export function ChartBuilder({ dataset, datasets, + growthFilters, + onApplyGrowthFilters, + onChangeGrowthFilters, + onResetGrowthFilters, onSelectDatasetId, selectedDatasetId, }: ChartBuilderProps) { + const total = dataset?.points.reduce((sum, point) => sum + point.value, 0) ?? 0; + const growthFilterBadges = + isGrowthDataset(dataset) && growthFilters ? buildGrowthFilterBadges(growthFilters) : []; + return (
@@ -155,6 +494,15 @@ export function ChartBuilder({ ))}
+ {isGrowthDataset(dataset) && growthFilters ? ( + + ) : null} + {dataset ? (
@@ -162,18 +510,52 @@ export function ChartBuilder({

Dataset

{dataset.label}

{dataset.description}

+ {growthFilterBadges.length ? ( +
+ {growthFilterBadges.map((badge) => ( + + {badge} + + ))} +
+ ) : null}

{dataset.dimensionLabel} x {dataset.metricLabel}

-

- {dataset.points.reduce((sum, point) => sum + point.value, 0)} -

+

{total}

- {dataset.chartType === "line" ? : null} + {dataset.summary?.length ? ( +
+ {dataset.summary.map((metric) => ( +
+

+ {metric.label} +

+

{metric.value}

+
+ ))} +
+ ) : null} + + {dataset.chartType === "line" ? ( + + ) : null} {dataset.chartType === "bar" ? : null} {dataset.chartType === "donut" ? : null} diff --git a/apps/admin/src/hooks/useAdminDatasets.ts b/apps/admin/src/hooks/useAdminDatasets.ts index b74bd4d..6b4d3b0 100644 --- a/apps/admin/src/hooks/useAdminDatasets.ts +++ b/apps/admin/src/hooks/useAdminDatasets.ts @@ -2,6 +2,10 @@ import type { AdminDatasetResult, AdminDatasetSummary } from "@xtablo/shared-typ import { useEffect, useState } from "react"; import { adminApi } from "../lib/api"; +type UseAdminDatasetsOptions = { + datasetQueryById?: Partial>; +}; + function getErrorMessage(error: unknown, fallbackMessage: string) { if (typeof error === "object" && error !== null && "response" in error) { const response = error.response; @@ -21,12 +25,13 @@ function getErrorMessage(error: unknown, fallbackMessage: string) { return fallbackMessage; } -export function useAdminDatasets() { +export function useAdminDatasets(options: UseAdminDatasetsOptions = {}) { const [datasets, setDatasets] = useState([]); const [selectedDatasetId, setSelectedDatasetId] = useState(null); const [dataset, setDataset] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const datasetQueryById = options.datasetQueryById ?? {}; useEffect(() => { let isMounted = true; @@ -70,8 +75,9 @@ export function useAdminDatasets() { setError(null); try { + const queryString = datasetQueryById[selectedDatasetId] ?? ""; const response = await adminApi.get( - `/admin/datasets/${selectedDatasetId}` + `/admin/datasets/${selectedDatasetId}${queryString}` ); if (!isMounted) { @@ -96,7 +102,7 @@ export function useAdminDatasets() { return () => { isMounted = false; }; - }, [selectedDatasetId]); + }, [datasetQueryById, selectedDatasetId]); return { dataset, diff --git a/apps/admin/src/lib/api.test.ts b/apps/admin/src/lib/api.test.ts index 7ed9724..bf0bef9 100644 --- a/apps/admin/src/lib/api.test.ts +++ b/apps/admin/src/lib/api.test.ts @@ -3,10 +3,15 @@ import { resolveAdminApiBaseUrl } from "./api"; describe("resolveAdminApiBaseUrl", () => { it("pins the deployed admin panel to the production api", () => { - expect(resolveAdminApiBaseUrl("production", "https://api-staging.xtablo.com/api/v1")).toBe( - "https://api.xtablo.com/api/v1" + expect( + resolveAdminApiBaseUrl( + "production", + "https://xablo-api-staging-636270553187.europe-west1.run.app/api/v1" + ) + ).toBe("https://xablo-api-636270553187.europe-west1.run.app/api/v1"); + expect(resolveAdminApiBaseUrl("production")).toBe( + "https://xablo-api-636270553187.europe-west1.run.app/api/v1" ); - expect(resolveAdminApiBaseUrl("production")).toBe("https://api.xtablo.com/api/v1"); }); it("keeps localhost for local development", () => { diff --git a/apps/admin/src/lib/api.ts b/apps/admin/src/lib/api.ts index 5ddfcdb..978768a 100644 --- a/apps/admin/src/lib/api.ts +++ b/apps/admin/src/lib/api.ts @@ -2,7 +2,7 @@ import { buildApi } from "@xtablo/shared"; import { getStoredAdminSession } from "./adminSession"; const LOCAL_ADMIN_API_BASE_URL = "http://localhost:8080/api/v1"; -const PRODUCTION_ADMIN_API_BASE_URL = "https://api.xtablo.com/api/v1"; +const PRODUCTION_ADMIN_API_BASE_URL = "https://xablo-api-636270553187.europe-west1.run.app/api/v1"; export function resolveAdminApiBaseUrl(mode = import.meta.env.MODE, _envApiUrl?: string) { if (mode === "development") { diff --git a/apps/admin/src/pages/AnalyticsStudioPage.test.tsx b/apps/admin/src/pages/AnalyticsStudioPage.test.tsx index f55d574..ba7c296 100644 --- a/apps/admin/src/pages/AnalyticsStudioPage.test.tsx +++ b/apps/admin/src/pages/AnalyticsStudioPage.test.tsx @@ -35,12 +35,20 @@ describe("AnalyticsStudioPage", () => { }; } - if (path === "/admin/datasets/profile_growth") { + if (path.startsWith("/admin/datasets/profile_growth")) { return { data: { chartType: "line", description: "New users over time.", dimensionLabel: "Created Day", + filters: { + accessStatus: "all", + dateFrom: "2026-04-01", + dateTo: "2026-04-30", + groupBy: "plan", + onboardingStates: ["all"], + plans: ["solo", "team", "annual"], + }, id: "profile_growth", label: "User Growth", metricLabel: "Users Created", @@ -48,6 +56,28 @@ describe("AnalyticsStudioPage", () => { { label: "2026-04-20", value: 2 }, { label: "2026-04-21", value: 4 }, ], + series: [ + { + id: "solo", + label: "Solo", + points: [ + { label: "2026-04-20", value: 1 }, + { label: "2026-04-21", value: 3 }, + ], + }, + { + id: "team", + label: "Team", + points: [ + { label: "2026-04-20", value: 1 }, + { label: "2026-04-21", value: 1 }, + ], + }, + ], + summary: [ + { id: "total_in_range", label: "Users in Range", value: "4" }, + { id: "active_access_users", label: "Active Access Users", value: "3" }, + ], }, }; } @@ -76,10 +106,85 @@ describe("AnalyticsStudioPage", () => { expect(await screen.findByText(/analytics studio/i)).toBeInTheDocument(); expect(await screen.findByRole("button", { name: /user growth/i })).toBeInTheDocument(); + expect(await screen.findByText(/users in range/i)).toBeInTheDocument(); + expect(await screen.findByLabelText(/date from/i)).toBeInTheDocument(); + expect(await screen.findByLabelText(/group by/i)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /plan mix/i })); await waitFor(() => expect(adminApi.get).toHaveBeenCalledWith("/admin/datasets/plan_mix")); expect(await screen.findByText(/total/i)).toBeInTheDocument(); }); + + it("applies analytics filters to the growth dataset request", async () => { + vi.mocked(adminApi.get).mockImplementation(async (path: string) => { + if (path === "/admin/datasets") { + return { + data: { + datasets: [ + { + description: "New users over time.", + id: "profile_growth", + label: "User Growth", + }, + ], + }, + }; + } + + if (path.startsWith("/admin/datasets/profile_growth")) { + return { + data: { + chartType: "line", + description: "New users over time.", + dimensionLabel: "Created Day", + filters: { + accessStatus: "all", + dateFrom: "2026-04-01", + dateTo: "2026-04-30", + groupBy: "plan", + onboardingStates: ["all"], + plans: ["solo", "team", "annual"], + }, + id: "profile_growth", + label: "User Growth", + metricLabel: "Users Created", + points: [ + { label: "2026-04-20", value: 2 }, + { label: "2026-04-21", value: 4 }, + ], + series: [ + { + id: "solo", + label: "Solo", + points: [ + { label: "2026-04-20", value: 1 }, + { label: "2026-04-21", value: 3 }, + ], + }, + ], + summary: [{ id: "total_in_range", label: "Users in Range", value: "4" }], + }, + }; + } + + throw new Error(`Unexpected path: ${path}`); + }); + + render(); + + expect(await screen.findByLabelText(/date from/i)).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText(/date from/i), { target: { value: "2026-04-10" } }); + fireEvent.change(screen.getByLabelText(/date to/i), { target: { value: "2026-04-25" } }); + fireEvent.change(screen.getByLabelText(/access/i), { target: { value: "active" } }); + fireEvent.click(screen.getByRole("checkbox", { name: /team/i })); + fireEvent.click(screen.getByRole("button", { name: /apply filters/i })); + + await waitFor(() => + expect(adminApi.get).toHaveBeenCalledWith( + "/admin/datasets/profile_growth?dateFrom=2026-04-10&dateTo=2026-04-25&groupBy=plan&plans=solo%2Cannual&onboardingStates=all&accessStatus=active" + ) + ); + }); }); diff --git a/apps/admin/src/pages/AnalyticsStudioPage.tsx b/apps/admin/src/pages/AnalyticsStudioPage.tsx index 1f696f6..9c9426c 100644 --- a/apps/admin/src/pages/AnalyticsStudioPage.tsx +++ b/apps/admin/src/pages/AnalyticsStudioPage.tsx @@ -1,11 +1,67 @@ +import type { AdminDatasetFilters } from "@xtablo/shared-types"; +import { useEffect, useMemo, useState } from "react"; import { ChartBuilder } from "../components/analytics/ChartBuilder"; import { SavedDashboardList } from "../components/analytics/SavedDashboardList"; import { useAdminDatasets } from "../hooks/useAdminDatasets"; import { savedDashboardPresets } from "../registry/datasets"; +const DEFAULT_GROWTH_FILTERS: AdminDatasetFilters = { + accessStatus: "all", + dateFrom: null, + dateTo: null, + groupBy: "plan", + onboardingStates: ["all"], + plans: ["solo", "team", "annual"], +}; + +function buildDatasetQuery(filters: AdminDatasetFilters) { + const params = new URLSearchParams(); + + if (filters.dateFrom) { + params.set("dateFrom", filters.dateFrom); + } + if (filters.dateTo) { + params.set("dateTo", filters.dateTo); + } + + params.set("groupBy", filters.groupBy); + params.set("plans", filters.plans.join(",")); + params.set("onboardingStates", filters.onboardingStates.join(",")); + params.set("accessStatus", filters.accessStatus); + + return `?${params.toString()}`; +} + +function areFiltersEqual(left: AdminDatasetFilters, right: AdminDatasetFilters) { + return JSON.stringify(left) === JSON.stringify(right); +} + export function AnalyticsStudioPage() { + const [draftGrowthFilters, setDraftGrowthFilters] = + useState(DEFAULT_GROWTH_FILTERS); + const [appliedGrowthFilters, setAppliedGrowthFilters] = + useState(DEFAULT_GROWTH_FILTERS); + + const datasetQueryById = useMemo( + () => ({ + profile_growth: buildDatasetQuery(appliedGrowthFilters), + }), + [appliedGrowthFilters] + ); + const { dataset, datasets, error, isLoading, selectedDatasetId, setSelectedDatasetId } = - useAdminDatasets(); + useAdminDatasets({ datasetQueryById }); + + useEffect(() => { + if ( + dataset?.id === "profile_growth" && + dataset.filters && + !areFiltersEqual(dataset.filters, appliedGrowthFilters) + ) { + setDraftGrowthFilters(dataset.filters); + setAppliedGrowthFilters(dataset.filters); + } + }, [appliedGrowthFilters, dataset]); return (
@@ -25,6 +81,13 @@ export function AnalyticsStudioPage() { setAppliedGrowthFilters(draftGrowthFilters)} + onChangeGrowthFilters={setDraftGrowthFilters} + onResetGrowthFilters={() => { + setDraftGrowthFilters(DEFAULT_GROWTH_FILTERS); + setAppliedGrowthFilters(DEFAULT_GROWTH_FILTERS); + }} onSelectDatasetId={setSelectedDatasetId} selectedDatasetId={selectedDatasetId} /> diff --git a/apps/api/src/__tests__/routes/adminDatasets.test.ts b/apps/api/src/__tests__/routes/adminDatasets.test.ts index 59c8b6d..c3442df 100644 --- a/apps/api/src/__tests__/routes/adminDatasets.test.ts +++ b/apps/api/src/__tests__/routes/adminDatasets.test.ts @@ -61,4 +61,34 @@ describe("Admin Datasets Router", () => { points: expect.any(Array), }); }); + + it("returns a filterable growth dataset with grouped series and summary metrics", async () => { + const res = await app.request( + "/admin/datasets/profile_growth?dateFrom=2026-01-01&dateTo=2026-12-31&groupBy=plan&plans=solo,team&onboardingStates=client&accessStatus=active", + { + headers: { + Authorization: `Bearer ${sessionToken}`, + }, + } + ); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toMatchObject({ + chartType: "line", + id: "profile_growth", + summary: expect.arrayContaining([ + expect.objectContaining({ id: "total_in_range" }), + expect.objectContaining({ id: "active_access_users" }), + ]), + filters: expect.objectContaining({ + accessStatus: "active", + dateFrom: "2026-01-01", + dateTo: "2026-12-31", + groupBy: "plan", + onboardingStates: ["client"], + plans: ["solo", "team"], + }), + series: expect.any(Array), + }); + }); }); diff --git a/apps/api/src/routers/adminDatasets.ts b/apps/api/src/routers/adminDatasets.ts index 3c6444d..035bdcb 100644 --- a/apps/api/src/routers/adminDatasets.ts +++ b/apps/api/src/routers/adminDatasets.ts @@ -1,8 +1,32 @@ -import type { AdminDatasetResult } from "@xtablo/shared-types"; +import type { + AdminDatasetAccessStatus, + AdminDatasetFilters, + AdminDatasetMetric, + AdminDatasetPoint, + AdminDatasetResult, + AdminDatasetSeries, +} from "@xtablo/shared-types"; import { Hono } from "hono"; import { getAdminDatasetDefinition, listAdminDatasets } from "../helpers/adminRegistry.js"; +import { normalizePlan } from "../helpers/helpers.js"; import type { BaseEnv } from "../types/app.types.js"; +const PLAN_ORDER = ["solo", "team", "annual"] as const; +const SERIES_COLORS = ["#1736a3", "#0f766e", "#c06a12", "#7c3aed", "#be123c"]; + +type GrowthProfileRow = { + created_at: string | null; + id: string; + is_client: boolean; + is_temporary: boolean; + plan: string | null; +}; + +type AccessRow = { + is_active: boolean | null; + user_id: string; +}; + function bucketByDay(values: Array) { const counts = new Map(); @@ -20,6 +44,141 @@ function bucketByDay(values: Array) { .map(([label, value]) => ({ label, value })); } +function buildDateRangeFilters(query: Record) { + const dateFrom = query.dateFrom?.trim() || null; + const dateTo = query.dateTo?.trim() || null; + + return { dateFrom, dateTo }; +} + +function parseListFilter( + rawValue: string | undefined, + allowedValues: readonly T[], + defaultValues: readonly T[] +) { + if (!rawValue) { + return [...defaultValues]; + } + + const parsed = rawValue + .split(",") + .map((value) => value.trim()) + .filter((value): value is T => allowedValues.includes(value as T)); + + return parsed.length > 0 ? parsed : [...defaultValues]; +} + +function parseGrowthFilters(query: Record): AdminDatasetFilters { + const { dateFrom, dateTo } = buildDateRangeFilters(query); + const groupBy = query.groupBy === "none" ? "none" : "plan"; + const accessStatus = (["all", "active", "inactive", "none"] as const).includes( + query.accessStatus as AdminDatasetAccessStatus + ) + ? (query.accessStatus as AdminDatasetAccessStatus) + : "all"; + + return { + accessStatus, + dateFrom, + dateTo, + groupBy, + onboardingStates: parseListFilter( + query.onboardingStates, + ["all", "client", "temporary", "regular"] as const, + ["all"] as const + ), + plans: parseListFilter(query.plans, PLAN_ORDER, PLAN_ORDER), + }; +} + +function onboardingStateForProfile(profile: Pick) { + if (profile.is_temporary) { + return "temporary" as const; + } + + if (profile.is_client) { + return "client" as const; + } + + return "regular" as const; +} + +function buildAccessStateByUser(rows: AccessRow[]) { + const states = new Map(); + + rows.forEach((row) => { + const current = states.get(row.user_id); + + if (row.is_active) { + states.set(row.user_id, "active"); + return; + } + + if (!current) { + states.set(row.user_id, "inactive"); + } + }); + + return states; +} + +function bucketRowsByDay(rows: GrowthProfileRow[]): AdminDatasetPoint[] { + return bucketByDay(rows.map((row) => row.created_at)); +} + +function buildGrowthSeries(rows: GrowthProfileRow[], plans: string[]): AdminDatasetSeries[] { + return plans.map((plan, index) => { + const points = bucketRowsByDay(rows.filter((row) => normalizePlan(row.plan) === plan)); + + return { + color: SERIES_COLORS[index % SERIES_COLORS.length], + id: plan, + label: plan.charAt(0).toUpperCase() + plan.slice(1), + points, + }; + }); +} + +function formatMetricValue(value: number) { + return new Intl.NumberFormat("en-US").format(value); +} + +function buildGrowthSummary( + rows: GrowthProfileRow[], + accessStates: Map +): AdminDatasetMetric[] { + const activeAccessUsers = rows.filter((row) => accessStates.get(row.id) === "active").length; + const clientProfiles = rows.filter((row) => row.is_client).length; + const temporaryProfiles = rows.filter((row) => row.is_temporary).length; + const teamPlanUsers = rows.filter((row) => normalizePlan(row.plan) === "team").length; + + return [ + { id: "total_in_range", label: "Users in Range", value: formatMetricValue(rows.length) }, + { + id: "active_access_users", + label: "Active Access Users", + tone: "positive", + value: formatMetricValue(activeAccessUsers), + }, + { + id: "client_profiles", + label: "Client Profiles", + value: formatMetricValue(clientProfiles), + }, + { + id: "team_plan_users", + label: "Team Plan Users", + tone: "warning", + value: formatMetricValue(teamPlanUsers), + }, + { + id: "temporary_profiles", + label: "Temporary Profiles", + value: formatMetricValue(temporaryProfiles), + }, + ]; +} + function bucketByValue(values: Array, emptyLabel: string) { const counts = new Map(); @@ -55,30 +214,92 @@ function bucketTabloAccess(rows: Array<{ is_active: boolean | null; is_admin: bo type AdminDatasetPayload = Pick< AdminDatasetResult, - "chartType" | "dimensionLabel" | "metricLabel" | "points" + "chartType" | "dimensionLabel" | "filters" | "metricLabel" | "points" | "series" | "summary" >; async function getDatasetPoints( datasetId: string, - supabase: BaseEnv["Variables"]["supabase"] + supabase: BaseEnv["Variables"]["supabase"], + query: Record ): Promise { switch (datasetId) { case "profile_growth": { - const { data, error } = await supabase + const filters = parseGrowthFilters(query); + let profileQuery = supabase .from("profiles") - .select("created_at") + .select("id,created_at,plan,is_client,is_temporary") .order("created_at", { ascending: true }) - .limit(365); + .limit(2000); + + if (filters.dateFrom) { + profileQuery = profileQuery.gte("created_at", `${filters.dateFrom}T00:00:00.000Z`); + } + + if (filters.dateTo) { + profileQuery = profileQuery.lte("created_at", `${filters.dateTo}T23:59:59.999Z`); + } + + const { data, error } = await profileQuery; if (error) { throw new Error(error.message); } + const { data: accessRows, error: accessError } = await supabase + .from("tablo_access") + .select("user_id,is_active") + .limit(5000); + + if (accessError) { + throw new Error(accessError.message); + } + + const accessStates = buildAccessStateByUser((accessRows ?? []) as AccessRow[]); + const filteredProfiles = ((data ?? []) as GrowthProfileRow[]).filter((row) => { + const normalizedPlan = normalizePlan(row.plan); + if (!filters.plans.includes(normalizedPlan)) { + return false; + } + + const onboardingState = onboardingStateForProfile(row); + if ( + !filters.onboardingStates.includes("all") && + !filters.onboardingStates.includes(onboardingState) + ) { + return false; + } + + const accessState = accessStates.get(row.id) ?? "none"; + if (filters.accessStatus !== "all" && accessState !== filters.accessStatus) { + return false; + } + + return true; + }); + + const series = + filters.groupBy === "plan" + ? buildGrowthSeries(filteredProfiles, filters.plans) + : [ + { + color: SERIES_COLORS[0], + id: "all", + label: "All Users", + points: bucketRowsByDay(filteredProfiles), + }, + ]; + + const aggregatePoints = + filters.groupBy === "plan" ? bucketRowsByDay(filteredProfiles) : (series[0]?.points ?? []); + return { chartType: "line", dimensionLabel: "Created Day", + filters, metricLabel: "Users Created", - points: bucketByDay((data ?? []).map((row) => row.created_at)), + points: aggregatePoints, + series, + summary: buildGrowthSummary(filteredProfiles, accessStates), }; } case "plan_mix": { @@ -137,7 +358,7 @@ export const getAdminDatasetsRouter = () => { } try { - const dataset = await getDatasetPoints(datasetId, supabase); + const dataset = await getDatasetPoints(datasetId, supabase, c.req.query()); return c.json( { diff --git a/apps/external/tsconfig.tsbuildinfo b/apps/external/tsconfig.tsbuildinfo index 0b37a6d..22943ee 100644 --- a/apps/external/tsconfig.tsbuildinfo +++ b/apps/external/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/custommodal.tsx","./src/embeddedbookingpage.tsx","./src/floatingbookingwidget.tsx","./src/userstoreprovider.tsx","./src/i18n.ts","./src/main.tsx","./src/routes.tsx","./src/vite-env.d.ts","./src/lib/api.ts","./src/lib/supabase.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/custommodal.tsx","./src/embeddedbookingpage.tsx","./src/floatingbookingwidget.tsx","./src/userstoreprovider.tsx","./src/i18n.ts","./src/main.tsx","./src/routes.tsx","./src/setuptests.ts","./src/vite-env.d.ts","./src/viteconfig.test.ts","./src/lib/api.ts","./src/lib/supabase.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/apps/main/src/hooks/client_invites.ts b/apps/main/src/hooks/client_invites.ts index 71c6e5a..584fb96 100644 --- a/apps/main/src/hooks/client_invites.ts +++ b/apps/main/src/hooks/client_invites.ts @@ -22,10 +22,10 @@ export const usePendingClientInvites = (tabloId: string) => { return useQuery({ queryKey: ["client-invites", tabloId], queryFn: async () => { - const { data } = await api.get( + const { data } = await api.get<{ invites: PendingClientInvite[] }>( `/api/v1/client-invites/${tabloId}/pending` ); - return data; + return data.invites; }, enabled: !!tabloId && !!session, }); diff --git a/apps/main/src/pages/tablo-details.tsx b/apps/main/src/pages/tablo-details.tsx index f198ce3..0c7375b 100644 --- a/apps/main/src/pages/tablo-details.tsx +++ b/apps/main/src/pages/tablo-details.tsx @@ -386,9 +386,9 @@ export const TabloDetailsPage = () => { members={members} etapes={etapes} currentUser={currentUser} - pendingInvites={pendingInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))} - isInvitingUser={isInvitingUser} - isCancellingInvite={isCancellingInvite} + pendingInvites={pendingClientInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))} + isInvitingUser={isCreatingClientInvite} + isCancellingInvite={isCancellingClientInvite} onCreateTask={(task) => createTask(task)} onUpdateTask={(task) => updateTask(task)} onDeleteTask={(taskId) => deleteTask(taskId)} @@ -396,9 +396,11 @@ export const TabloDetailsPage = () => { onUpdateTablo={(data) => updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined) } - onInviteUser={inviteUser} + onInviteUser={(params) => + createClientInvite({ tabloId: params.tablo_id, email: params.email }) + } onCancelInvite={(params) => - cancelInvite({ ...params, inviteId: Number(params.inviteId) }) + cancelClientInvite({ tabloId: params.tabloId, inviteId: Number(params.inviteId) }) } /> )} @@ -415,9 +417,9 @@ export const TabloDetailsPage = () => { foldersError={foldersError as Error | null} currentUser={currentUser} members={members} - pendingInvites={pendingInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))} - isInvitingUser={isInvitingUser} - isCancellingInvite={isCancellingInvite} + pendingInvites={pendingClientInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))} + isInvitingUser={isCreatingClientInvite} + isCancellingInvite={isCancellingClientInvite} isCreatingFolder={isCreatingFolder} isUpdatingFolder={isUpdatingFolder} onCreateFile={(params) => uploadFile(params).then(() => undefined)} @@ -429,9 +431,11 @@ export const TabloDetailsPage = () => { onUpdateTablo={(data) => updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined) } - onInviteUser={inviteUser} + onInviteUser={(params) => + createClientInvite({ tabloId: params.tablo_id, email: params.email }) + } onCancelInvite={(params) => - cancelInvite({ ...params, inviteId: Number(params.inviteId) }) + cancelClientInvite({ tabloId: params.tabloId, inviteId: Number(params.inviteId) }) } /> )} @@ -454,16 +458,18 @@ export const TabloDetailsPage = () => { error={eventsError as Error | null} currentUser={currentUser} members={members} - pendingInvites={pendingInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))} - isInvitingUser={isInvitingUser} - isCancellingInvite={isCancellingInvite} + pendingInvites={pendingClientInvites?.map((inv) => ({ ...inv, id: String(inv.id) }))} + isInvitingUser={isCreatingClientInvite} + isCancellingInvite={isCancellingClientInvite} onCreateEvent={() => undefined} onUpdateTablo={(data) => updateTabloAsync({ ...data, name: data.name ?? undefined }).then(() => undefined) } - onInviteUser={inviteUser} + onInviteUser={(params) => + createClientInvite({ tabloId: params.tablo_id, email: params.email }) + } onCancelInvite={(params) => - cancelInvite({ ...params, inviteId: Number(params.inviteId) }) + cancelClientInvite({ tabloId: params.tabloId, inviteId: Number(params.inviteId) }) } /> )} diff --git a/packages/shared-types/src/admin.types.ts b/packages/shared-types/src/admin.types.ts index bc85f9c..f7f6f42 100644 --- a/packages/shared-types/src/admin.types.ts +++ b/packages/shared-types/src/admin.types.ts @@ -62,6 +62,12 @@ export type AdminOverviewResponse = { export type AdminDatasetChartType = "bar" | "line" | "donut"; +export type AdminDatasetGroupBy = "none" | "plan"; + +export type AdminDatasetAccessStatus = "all" | "active" | "inactive" | "none"; + +export type AdminDatasetOnboardingState = "all" | "client" | "temporary" | "regular"; + export type AdminDatasetSummary = { description: string; id: string; @@ -73,11 +79,37 @@ export type AdminDatasetPoint = { value: number; }; +export type AdminDatasetSeries = { + color?: string; + id: string; + label: string; + points: AdminDatasetPoint[]; +}; + +export type AdminDatasetMetric = { + id: string; + label: string; + tone?: "default" | "positive" | "warning"; + value: string; +}; + +export type AdminDatasetFilters = { + accessStatus: AdminDatasetAccessStatus; + dateFrom: string | null; + dateTo: string | null; + groupBy: AdminDatasetGroupBy; + onboardingStates: AdminDatasetOnboardingState[]; + plans: string[]; +}; + export type AdminDatasetResult = AdminDatasetSummary & { chartType: AdminDatasetChartType; dimensionLabel: string; + filters?: AdminDatasetFilters; metricLabel: string; points: AdminDatasetPoint[]; + series?: AdminDatasetSeries[]; + summary?: AdminDatasetMetric[]; }; export type AdminActionField = { diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 8c42acc..75a0d1c 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -5,10 +5,16 @@ export type { AdminActionField, AdminActionRunResponse, AdminActionSummary, + AdminDatasetAccessStatus, AdminDatasetChartType, + AdminDatasetFilters, + AdminDatasetGroupBy, + AdminDatasetMetric, AdminDatasetPoint, AdminDatasetResult, + AdminDatasetSeries, AdminDatasetSummary, + AdminDatasetOnboardingState, AdminOverviewAlert, AdminOverviewMetric, AdminOverviewResponse,