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}
-
-
-
- ))}
+
);
}
-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 (
-
-