mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-19 21:00:40 +08:00
[codex] Add analytics overview filters (#1496)
## Summary
Adds richer analytics overview metrics and filterable dashboard
breakdowns.
- adds hourly overview series for the 1-day range
- adds country, referrer, browser, OS, and device filters to internal
metrics
- adds bounce rate, session duration, top countries, top browsers, top
operating systems, and device breakdowns
- updates the overview dashboard with filter chips, top-list cards,
animated metric states, and 1-day hourly chart support
- captures user agent on page-view analytics events, with a server-side
fallback for older clients
## Validation
Attempted targeted tests:
`pnpm test run
apps/backend/src/app/api/latest/internal/metrics/route.test.ts
'apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/analytics-chart-mode.test.ts'`
This did not reach Vitest in the temporary split worktree because
`node_modules` is not installed there and the repo pre-step failed at
`pnpm exec tsx ./scripts/generate-sdks.ts`.
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Adds analytics overview filters with optional date‑range bounds and
1‑day hourly charts, plus smoother, accessible animations across charts
and top lists. Improves correctness and stability with deterministic
caching, normalized inputs, client‑only user‑agent capture, and
globe/layout fixes.
- **New Features**
- Filterable analytics overview (country, referrer, browser, OS, device)
with normalized inputs and optional `since`/`until`; API/admin/dashboard
accept `AnalyticsOverviewFilters` with deterministic cache keys.
- 1‑day hourly charts (page views, visitors) and a metric mode toggle
(DAU, Visitors, Revenue); animated top‑lists and sparklines powered by
`motion` with reduced‑motion support.
- UI: filter chips/menu, clearer tooltips (incl. user metric cards),
optional interactive globe with dynamic camera distance; exported
`TooltipPortal` from `@hexclave/ui`.
- **Refactors & Bug Fixes**
- Event ingest: client sends `user_agent`; removed server‑side fallback;
added user‑agent filter‑fragment builder and tests.
- Metrics correctness: aligned hourly bounds to start of UTC hour;
derived 1‑day revenue total from daily series; resilient chart x‑axis
formatting; country filter options use analytics `top_regions`;
fixed‑'en' locale for top‑lists; added date‑range parsing/validation for
filters.
- UI/runtime: smoother pill/tab slider animations with guards for
missing Web APIs; added `containedHeight` to `PageLayout` and wired into
sidebar/session replays; globe disables zoom when non‑interactive.
- Misc: instrumentation runs only in Node (`process.env.NEXT_RUNTIME ===
"nodejs"`); analytics/overview page redirects with URL‑encoded
`projectId`; Docker: include `@hexclave/template` in `turbo prune` to
fix CI builds.
<sup>Written for commit 7fcd3558a5.
Summary will update on new commits.</sup>
<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1496?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Analytics filters (country, referrer, browser, OS, device); hourly
signup and active-user series; expanded hourly/daily analytics payloads
and top-lists UI.
* Chart metric modes (DAU, Visitors, Revenue), optional page-views
series, interactive globe support, animated Top Lists, and sparkline
animations.
* **Improvements**
* Better user-agent capture/normalization for batched events and
page-view tracking; reduced-motion aware animations; enhanced tooltips
and UI slider/tab indicators.
* Added motion library dependency.
* **Tests**
* New unit tests for analytics filters and chart metric mode behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: mantra <mantra@stack-auth.com>
This commit is contained in:
parent
4479758a68
commit
59daf1321c
@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getMetricsWindowBounds, isMetricsRevenueInvoiceStatus } from "./route";
|
||||
import {
|
||||
buildAnalyticsOverviewUserAgentFilterFragmentsForTest,
|
||||
getMetricsWindowBounds,
|
||||
isMetricsRevenueInvoiceStatus,
|
||||
normalizeAnalyticsOverviewFilters,
|
||||
} from "./route";
|
||||
|
||||
describe("internal metrics helpers", () => {
|
||||
it("only counts paid and succeeded invoices as revenue", () => {
|
||||
@ -17,4 +22,46 @@ describe("internal metrics helpers", () => {
|
||||
expect(since.toISOString()).toBe("2026-03-14T00:00:00.000Z");
|
||||
expect(untilExclusive.toISOString()).toBe("2026-04-14T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("normalizes analytics overview filters before adding them to ClickHouse params", () => {
|
||||
expect(normalizeAnalyticsOverviewFilters({
|
||||
country_code: " us ",
|
||||
referrer: " https://example.com ",
|
||||
browser: "",
|
||||
os: " macOS ",
|
||||
device: " Desktop ",
|
||||
since: " 2026-06-01T00:00:00.000Z ",
|
||||
until: "",
|
||||
})).toMatchInlineSnapshot(`
|
||||
{
|
||||
"browser": undefined,
|
||||
"country_code": "US",
|
||||
"device": "Desktop",
|
||||
"os": "macOS",
|
||||
"referrer": "https://example.com",
|
||||
"since": "2026-06-01T00:00:00.000Z",
|
||||
"until": undefined,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("builds deterministic user-agent filter fragments without a raw user-agent allowlist", () => {
|
||||
expect(buildAnalyticsOverviewUserAgentFilterFragmentsForTest({
|
||||
browser: "Chrome",
|
||||
os: "macOS",
|
||||
device: "Desktop",
|
||||
})).toMatchInlineSnapshot(`
|
||||
{
|
||||
"hasBrowserFilter": true,
|
||||
"hasDeviceFilter": true,
|
||||
"hasOsFilter": true,
|
||||
"params": {
|
||||
"browserFilter": "Chrome",
|
||||
"deviceFilter": "Desktop",
|
||||
"osFilter": "macOS",
|
||||
},
|
||||
"usesRawUserAgentAllowlist": false,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@ -20,7 +20,7 @@ import {
|
||||
MetricsPaymentsOverviewSchema,
|
||||
MetricsRecentUserSchema,
|
||||
} from "@hexclave/shared/dist/interface/admin-metrics";
|
||||
import { captureError, HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors";
|
||||
import { captureError, HexclaveAssertionError, StatusError } from "@hexclave/shared/dist/utils/errors";
|
||||
import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupRecord, yupString } from "@hexclave/shared/dist/schema-fields";
|
||||
import { userFullInclude, userPrismaToCrud, usersCrudHandlers } from "../../users/crud";
|
||||
|
||||
@ -362,6 +362,53 @@ async function loadTotalUsers(tenancy: Tenancy, now: Date, includeAnonymous: boo
|
||||
return out;
|
||||
}
|
||||
|
||||
async function loadHourlyUsers(tenancy: Tenancy, now: Date, includeAnonymous: boolean = false): Promise<DataPoints> {
|
||||
const latestHour = new Date(now);
|
||||
latestHour.setUTCMinutes(0, 0, 0);
|
||||
const since = new Date(latestHour.getTime() - 23 * 60 * 60 * 1000);
|
||||
const untilExclusive = new Date(latestHour.getTime() + 60 * 60 * 1000);
|
||||
const clickhouseClient = getClickhouseAdminClientForMetrics();
|
||||
|
||||
const result = await clickhouseClient.query({
|
||||
query: `
|
||||
SELECT
|
||||
toStartOfHour(signed_up_at) AS hour,
|
||||
count() AS hourly_users
|
||||
FROM analytics_internal.users FINAL
|
||||
WHERE project_id = {projectId:String}
|
||||
AND branch_id = {branchId:String}
|
||||
AND sync_is_deleted = 0
|
||||
AND signed_up_at >= {since:DateTime}
|
||||
AND signed_up_at < {untilExclusive:DateTime}
|
||||
AND ({includeAnonymous:UInt8} = 1 OR is_anonymous = 0)
|
||||
GROUP BY hour
|
||||
ORDER BY hour
|
||||
`,
|
||||
query_params: {
|
||||
projectId: tenancy.project.id,
|
||||
branchId: tenancy.branchId,
|
||||
since: formatClickhouseDateTimeParam(since),
|
||||
untilExclusive: formatClickhouseDateTimeParam(untilExclusive),
|
||||
includeAnonymous: includeAnonymous ? 1 : 0,
|
||||
},
|
||||
format: "JSONEachRow",
|
||||
});
|
||||
const rows = await result.json() as { hour: string, hourly_users: string | number }[];
|
||||
|
||||
const countByHour = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
countByHour.set(new Date(row.hour).toISOString().slice(0, 13), Number(row.hourly_users));
|
||||
}
|
||||
|
||||
const out: DataPoints = [];
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const hour = new Date(since.getTime() + i * 60 * 60 * 1000);
|
||||
const key = hour.toISOString().slice(0, 13);
|
||||
out.push({ date: `${key}:00:00.000Z`, activity: countByHour.get(key) ?? 0 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function loadDailyActiveUsers(tenancy: Tenancy, now: Date, includeAnonymous: boolean = false) {
|
||||
const todayUtc = new Date(now);
|
||||
todayUtc.setUTCHours(0, 0, 0, 0);
|
||||
@ -415,6 +462,53 @@ async function loadDailyActiveUsers(tenancy: Tenancy, now: Date, includeAnonymou
|
||||
return out;
|
||||
}
|
||||
|
||||
async function loadHourlyActiveUsers(tenancy: Tenancy, now: Date, includeAnonymous: boolean = false): Promise<DataPoints> {
|
||||
const latestHour = new Date(now);
|
||||
latestHour.setUTCMinutes(0, 0, 0);
|
||||
const since = new Date(latestHour.getTime() - 23 * 60 * 60 * 1000);
|
||||
const untilExclusive = new Date(latestHour.getTime() + 60 * 60 * 1000);
|
||||
const clickhouseClient = getClickhouseAdminClientForMetrics();
|
||||
const result = await clickhouseClient.query({
|
||||
query: `
|
||||
SELECT
|
||||
toStartOfHour(event_at) AS hour,
|
||||
uniqExact(assumeNotNull(user_id)) AS dau
|
||||
FROM analytics_internal.events
|
||||
WHERE event_type = '$token-refresh'
|
||||
AND project_id = {projectId:String}
|
||||
AND branch_id = {branchId:String}
|
||||
AND user_id IS NOT NULL
|
||||
AND event_at >= {since:DateTime}
|
||||
AND event_at < {untilExclusive:DateTime}
|
||||
AND ({includeAnonymous:UInt8} = 1 OR coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0)
|
||||
GROUP BY hour
|
||||
ORDER BY hour ASC
|
||||
`,
|
||||
query_params: {
|
||||
projectId: tenancy.project.id,
|
||||
branchId: tenancy.branchId,
|
||||
since: formatClickhouseDateTimeParam(since),
|
||||
untilExclusive: formatClickhouseDateTimeParam(untilExclusive),
|
||||
includeAnonymous: includeAnonymous ? 1 : 0,
|
||||
},
|
||||
format: "JSONEachRow",
|
||||
});
|
||||
|
||||
const rows: { hour: string, dau: number }[] = await result.json();
|
||||
const dauByHour = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
dauByHour.set(new Date(row.hour).toISOString().slice(0, 13), Number(row.dau));
|
||||
}
|
||||
|
||||
const out: DataPoints = [];
|
||||
for (let i = 0; i < 24; i += 1) {
|
||||
const hour = new Date(since.getTime() + i * 60 * 60 * 1000);
|
||||
const key = hour.toISOString().slice(0, 13);
|
||||
out.push({ date: `${key}:00:00.000Z`, activity: dauByHour.get(key) ?? 0 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function loadDailyActiveSplitFromClickhouse(options: {
|
||||
tenancy: Tenancy,
|
||||
now: Date,
|
||||
@ -1057,12 +1151,143 @@ async function loadSessionReplayAggregates(tenancy: Tenancy, since: Date): Promi
|
||||
};
|
||||
}
|
||||
|
||||
async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymous: boolean) {
|
||||
const DIRECT_REFERRER_LABEL = "(direct)";
|
||||
|
||||
export type AnalyticsOverviewFilters = {
|
||||
country_code?: string,
|
||||
referrer?: string,
|
||||
browser?: string,
|
||||
os?: string,
|
||||
device?: string,
|
||||
// ISO 8601 datetimes bounding the top-N breakdowns (referrers, regions,
|
||||
// browsers/OS/devices). Clamped to the analytics window server-side; the
|
||||
// daily/hourly series intentionally stay full-window so the dashboard can
|
||||
// compute previous-period deltas client-side.
|
||||
since?: string,
|
||||
until?: string,
|
||||
};
|
||||
|
||||
export function normalizeAnalyticsOverviewFilters(filters: AnalyticsOverviewFilters): AnalyticsOverviewFilters {
|
||||
const countryCode = filters.country_code?.trim().toUpperCase();
|
||||
const referrer = filters.referrer?.trim();
|
||||
const browser = filters.browser?.trim();
|
||||
const os = filters.os?.trim();
|
||||
const device = filters.device?.trim();
|
||||
const since = filters.since?.trim();
|
||||
const until = filters.until?.trim();
|
||||
return {
|
||||
country_code: countryCode || undefined,
|
||||
referrer: referrer || undefined,
|
||||
browser: browser || undefined,
|
||||
os: os || undefined,
|
||||
device: device || undefined,
|
||||
since: since || undefined,
|
||||
until: until || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseAnalyticsRangeBound(value: string | undefined, paramName: string): Date | undefined {
|
||||
if (value == null) return undefined;
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new StatusError(400, `Invalid ${paramName}: expected an ISO 8601 datetime, got ${JSON.stringify(value)}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const analyticsOverviewUserAgentSql = "toString(e.data.user_agent)";
|
||||
const analyticsOverviewViewportWidthSql = "toInt32(toInt64OrZero(toString(e.data.viewport_width)))";
|
||||
|
||||
const analyticsOverviewBrowserSql = `multiIf(
|
||||
positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'edg/') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'edge/') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'edga/') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'edgios/') > 0, 'Edge',
|
||||
positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'opr/') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'opera') > 0, 'Opera',
|
||||
positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'samsungbrowser') > 0, 'Samsung Internet',
|
||||
positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'firefox') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'fxios') > 0, 'Firefox',
|
||||
positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'crios') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'chrome') > 0, 'Chrome',
|
||||
positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'safari') > 0, 'Safari',
|
||||
'Other'
|
||||
)`;
|
||||
|
||||
const analyticsOverviewOsSql = `multiIf(
|
||||
positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'windows') > 0, 'Windows',
|
||||
positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'android') > 0, 'Android',
|
||||
positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'iphone') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'ipad') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'ipod') > 0, 'iOS',
|
||||
positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'mac os') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'macintosh') > 0, 'macOS',
|
||||
positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'cros') > 0, 'ChromeOS',
|
||||
positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'linux') > 0, 'Linux',
|
||||
'Other'
|
||||
)`;
|
||||
|
||||
const analyticsOverviewDeviceSql = `multiIf(
|
||||
positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'ipad') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'tablet') > 0 OR (positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'android') > 0 AND positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'mobile') = 0), 'Tablet',
|
||||
positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'mobile') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'iphone') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'ipod') > 0 OR positionCaseInsensitive(${analyticsOverviewUserAgentSql}, 'android') > 0, 'Mobile',
|
||||
${analyticsOverviewViewportWidthSql} > 0 AND ${analyticsOverviewViewportWidthSql} < 600, 'Mobile',
|
||||
${analyticsOverviewViewportWidthSql} >= 600 AND ${analyticsOverviewViewportWidthSql} < 1024, 'Tablet',
|
||||
'Desktop'
|
||||
)`;
|
||||
|
||||
function buildAnalyticsOverviewUserAgentFilterFragments(filters: AnalyticsOverviewFilters): {
|
||||
browserFragment: string,
|
||||
osFragment: string,
|
||||
deviceFragment: string,
|
||||
params: Record<string, string>,
|
||||
} {
|
||||
return {
|
||||
browserFragment: filters.browser ? `AND ${analyticsOverviewBrowserSql} = {browserFilter:String}` : "",
|
||||
osFragment: filters.os ? `AND ${analyticsOverviewOsSql} = {osFilter:String}` : "",
|
||||
deviceFragment: filters.device ? `AND ${analyticsOverviewDeviceSql} = {deviceFilter:String}` : "",
|
||||
params: {
|
||||
...(filters.browser ? { browserFilter: filters.browser } : {}),
|
||||
...(filters.os ? { osFilter: filters.os } : {}),
|
||||
...(filters.device ? { deviceFilter: filters.device } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAnalyticsOverviewUserAgentFilterFragmentsForTest(filters: AnalyticsOverviewFilters): {
|
||||
hasBrowserFilter: boolean,
|
||||
hasOsFilter: boolean,
|
||||
hasDeviceFilter: boolean,
|
||||
params: Record<string, string>,
|
||||
usesRawUserAgentAllowlist: boolean,
|
||||
} {
|
||||
const fragments = buildAnalyticsOverviewUserAgentFilterFragments(filters);
|
||||
const combinedFragments = [
|
||||
fragments.browserFragment,
|
||||
fragments.osFragment,
|
||||
fragments.deviceFragment,
|
||||
].join("\n");
|
||||
return {
|
||||
hasBrowserFilter: fragments.browserFragment.length > 0,
|
||||
hasOsFilter: fragments.osFragment.length > 0,
|
||||
hasDeviceFilter: fragments.deviceFragment.length > 0,
|
||||
params: fragments.params,
|
||||
usesRawUserAgentAllowlist: combinedFragments.includes("matchingUAs") || combinedFragments.includes("IN {"),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadAnalyticsOverview(
|
||||
tenancy: Tenancy,
|
||||
now: Date,
|
||||
includeAnonymous: boolean,
|
||||
filters: AnalyticsOverviewFilters = {},
|
||||
) {
|
||||
const todayUtc = new Date(now);
|
||||
todayUtc.setUTCHours(0, 0, 0, 0);
|
||||
const since = new Date(todayUtc.getTime() - METRICS_WINDOW_MS);
|
||||
const untilExclusive = new Date(todayUtc.getTime() + ONE_DAY_MS);
|
||||
|
||||
// Optional date range for the top-N breakdowns, clamped to the analytics
|
||||
// window (ClickHouse only has detail for the last METRICS_WINDOW_DAYS).
|
||||
// The daily/hourly series stay full-window — see AnalyticsOverviewFilters.
|
||||
const requestedRangeSince = parseAnalyticsRangeBound(filters.since, "filter_since");
|
||||
const requestedRangeUntil = parseAnalyticsRangeBound(filters.until, "filter_until");
|
||||
const rangeSince = new Date(Math.max(since.getTime(), requestedRangeSince?.getTime() ?? since.getTime()));
|
||||
const rangeUntilExclusive = new Date(Math.min(untilExclusive.getTime(), requestedRangeUntil?.getTime() ?? untilExclusive.getTime()));
|
||||
if (rangeSince.getTime() >= rangeUntilExclusive.getTime()) {
|
||||
throw new StatusError(400, "filter_since must be before filter_until after clamping to the analytics window");
|
||||
}
|
||||
|
||||
const clickhouseClient = getClickhouseAdminClientForMetrics();
|
||||
|
||||
// Session replay aggregates come from Postgres and have nothing to do with
|
||||
@ -1082,24 +1307,43 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo
|
||||
dailyPageViews: DataPoints,
|
||||
dailyClicks: DataPoints,
|
||||
dailyVisitors: DataPoints,
|
||||
hourlyPageViews: DataPoints,
|
||||
hourlyActiveUsers: DataPoints,
|
||||
hourlyVisitors: DataPoints,
|
||||
dailyBounceRate: DataPoints,
|
||||
dailyAvgSession: DataPoints,
|
||||
visitors: number,
|
||||
onlineLive: number,
|
||||
bounceRate: number,
|
||||
avgSessionSeconds: number,
|
||||
topReferrers: { referrer: string, visitors: number }[],
|
||||
topRegion: { country_code: string | null, region_code: string | null, count: number } | null,
|
||||
topRegions: { country_code: string, count: number }[],
|
||||
topBrowsers: { name: string, visitors: number }[],
|
||||
topOperatingSystems: { name: string, visitors: number }[],
|
||||
topDevices: { name: string, visitors: number }[],
|
||||
} | null = null;
|
||||
|
||||
try {
|
||||
// Explicit installed-check instead of inferring "analytics not enabled" from
|
||||
// a failed ClickHouse query: when the app isn't installed we skip ClickHouse
|
||||
// entirely and return the token-refresh fallback payload; when it IS
|
||||
// installed, every ClickHouse error propagates to the caller so the
|
||||
// dashboard renders its error state instead of plausible-looking zeros.
|
||||
const analyticsInstalled = tenancy.config.apps.installed["analytics"]?.enabled ?? false;
|
||||
|
||||
if (analyticsInstalled) try {
|
||||
// The `event_at >= since` bound on the inner subquery is load-bearing:
|
||||
// without it the GROUP BY hash table holds one row per ever-seen user.
|
||||
// Edge case: anonymous page-views by users with no token-refresh in the
|
||||
// last 30 days now coalesce to non-anonymous. The proper fix is to stamp
|
||||
// `is_anonymous` on page-view/click events at ingest and drop this join
|
||||
// entirely (the coalesce below short-circuits on the first non-null arg).
|
||||
const analyticsUserJoin = `
|
||||
const buildAnalyticsUserJoin = (includeCountry: boolean) => `
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
user_id,
|
||||
argMax(coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0), event_at) AS latest_is_anonymous
|
||||
${includeCountry ? ", argMax(CAST(data.ip_info.country_code, 'Nullable(String)'), event_at) AS latest_country" : ""}
|
||||
FROM analytics_internal.events
|
||||
WHERE event_type = '$token-refresh'
|
||||
AND project_id = {projectId:String}
|
||||
@ -1111,37 +1355,74 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo
|
||||
) AS token_refresh_users
|
||||
ON e.user_id = token_refresh_users.user_id
|
||||
`;
|
||||
const analyticsUserJoinForFilteredEvents = buildAnalyticsUserJoin(filters.country_code != null);
|
||||
const analyticsUserJoinWithCountry = buildAnalyticsUserJoin(true);
|
||||
const nonAnonymousAnalyticsUserFilter = "({includeAnonymous:UInt8} = 1 OR coalesce(CAST(e.data.is_anonymous, 'Nullable(UInt8)'), token_refresh_users.latest_is_anonymous, 0) = 0)";
|
||||
const [dailyEventResult, totalVisitorResult, referrerResult, topRegionResult, onlineResult] = await Promise.all([
|
||||
const analyticsContributingUserFilter = `e.user_id IS NOT NULL AND ${nonAnonymousAnalyticsUserFilter}`;
|
||||
|
||||
// Build per-dimension filter fragments; callers below opt out of the
|
||||
// fragment matching their own dimension so top-N queries don't collapse to
|
||||
// a single row (e.g. top_referrers must not also filter by referrer).
|
||||
const referrerFragment = filters.referrer
|
||||
? (filters.referrer === DIRECT_REFERRER_LABEL
|
||||
? `AND CAST(e.data.referrer, 'String') = ''`
|
||||
: `AND CAST(e.data.referrer, 'String') = {referrerFilter:String}`)
|
||||
: '';
|
||||
const countryFragment = filters.country_code
|
||||
? `AND upper(coalesce(token_refresh_users.latest_country, '')) = {countryFilter:String}`
|
||||
: '';
|
||||
const userAgentFilterFragments = buildAnalyticsOverviewUserAgentFilterFragments(filters);
|
||||
const uaFragment = [
|
||||
userAgentFilterFragments.browserFragment,
|
||||
userAgentFilterFragments.osFragment,
|
||||
userAgentFilterFragments.deviceFragment,
|
||||
].join(" ");
|
||||
|
||||
const sharedExtraFilters = `${referrerFragment} ${countryFragment} ${uaFragment}`.trim();
|
||||
const filterParams = {
|
||||
...(filters.referrer && filters.referrer !== DIRECT_REFERRER_LABEL ? { referrerFilter: filters.referrer } : {}),
|
||||
...(filters.country_code ? { countryFilter: filters.country_code } : {}),
|
||||
...userAgentFilterFragments.params,
|
||||
};
|
||||
const onlineFilteredUserFragment = sharedExtraFilters
|
||||
? `
|
||||
AND user_id IN (
|
||||
SELECT assumeNotNull(e.user_id)
|
||||
FROM analytics_internal.events AS e
|
||||
${filters.country_code != null ? analyticsUserJoinWithCountry : ""}
|
||||
WHERE e.event_type = '$page-view'
|
||||
AND e.project_id = {projectId:String}
|
||||
AND e.branch_id = {branchId:String}
|
||||
AND e.user_id IS NOT NULL
|
||||
AND e.event_at >= {since:DateTime}
|
||||
AND e.event_at < {untilExclusive:DateTime}
|
||||
${sharedExtraFilters}
|
||||
GROUP BY e.user_id
|
||||
)
|
||||
`
|
||||
: '';
|
||||
const [dailyEventResult, hourlyEventResult, totalVisitorResult, referrerResult, topRegionResult, onlineResult, sessionResult, userAgentResult] = await Promise.all([
|
||||
// Combined daily aggregates: page-view count, click count, and unique
|
||||
// visitors per day — one scan over the page-view/click event types.
|
||||
clickhouseClient.query({
|
||||
query: `
|
||||
SELECT
|
||||
toDate(e.event_at) AS day,
|
||||
countIf(
|
||||
e.event_type = '$page-view'
|
||||
AND e.user_id IS NOT NULL
|
||||
AND ${nonAnonymousAnalyticsUserFilter}
|
||||
) AS pv,
|
||||
countIf(
|
||||
e.event_type = '$click'
|
||||
AND e.user_id IS NOT NULL
|
||||
AND ${nonAnonymousAnalyticsUserFilter}
|
||||
) AS cl,
|
||||
countIf(e.event_type = '$page-view') AS pv,
|
||||
countIf(e.event_type = '$click') AS cl,
|
||||
uniqExactIf(
|
||||
assumeNotNull(e.user_id),
|
||||
e.event_type = '$page-view'
|
||||
AND e.user_id IS NOT NULL
|
||||
AND ${nonAnonymousAnalyticsUserFilter}
|
||||
) AS visitors
|
||||
FROM analytics_internal.events AS e
|
||||
${analyticsUserJoin}
|
||||
${analyticsUserJoinForFilteredEvents}
|
||||
WHERE e.event_type IN ('$page-view', '$click')
|
||||
AND e.project_id = {projectId:String}
|
||||
AND e.branch_id = {branchId:String}
|
||||
AND e.event_at >= {since:DateTime}
|
||||
AND e.event_at < {untilExclusive:DateTime}
|
||||
AND ${analyticsContributingUserFilter}
|
||||
${sharedExtraFilters}
|
||||
GROUP BY day
|
||||
ORDER BY day ASC
|
||||
`,
|
||||
@ -1151,25 +1432,63 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo
|
||||
projectId: tenancy.project.id,
|
||||
branchId: tenancy.branchId,
|
||||
includeAnonymous: includeAnonymous ? 1 : 0,
|
||||
...filterParams,
|
||||
},
|
||||
format: "JSONEachRow",
|
||||
}),
|
||||
clickhouseClient.query({
|
||||
query: `
|
||||
SELECT
|
||||
toStartOfHour(e.event_at) AS hour,
|
||||
countIf(e.event_type = '$page-view') AS pv,
|
||||
uniqExactIf(
|
||||
assumeNotNull(e.user_id),
|
||||
e.user_id IS NOT NULL
|
||||
AND ${nonAnonymousAnalyticsUserFilter}
|
||||
e.event_type IN ('$page-view', '$click')
|
||||
) AS active_users,
|
||||
uniqExactIf(
|
||||
assumeNotNull(e.user_id),
|
||||
e.event_type = '$page-view'
|
||||
) AS visitors
|
||||
FROM analytics_internal.events AS e
|
||||
${analyticsUserJoin}
|
||||
${analyticsUserJoinForFilteredEvents}
|
||||
WHERE e.event_type IN ('$page-view', '$click')
|
||||
AND e.project_id = {projectId:String}
|
||||
AND e.branch_id = {branchId:String}
|
||||
AND e.event_at >= {hourlySince:DateTime}
|
||||
AND e.event_at < {untilExclusive:DateTime}
|
||||
AND ${analyticsContributingUserFilter}
|
||||
${sharedExtraFilters}
|
||||
GROUP BY hour
|
||||
ORDER BY hour ASC
|
||||
`,
|
||||
query_params: {
|
||||
hourlySince: formatClickhouseDateTimeParam((() => {
|
||||
const latestHour = new Date(now);
|
||||
latestHour.setUTCMinutes(0, 0, 0);
|
||||
return new Date(latestHour.getTime() - 23 * 60 * 60 * 1000);
|
||||
})()),
|
||||
since: formatClickhouseDateTimeParam(since),
|
||||
untilExclusive: formatClickhouseDateTimeParam(untilExclusive),
|
||||
projectId: tenancy.project.id,
|
||||
branchId: tenancy.branchId,
|
||||
includeAnonymous: includeAnonymous ? 1 : 0,
|
||||
...filterParams,
|
||||
},
|
||||
format: "JSONEachRow",
|
||||
}),
|
||||
clickhouseClient.query({
|
||||
query: `
|
||||
SELECT
|
||||
uniqExact(assumeNotNull(e.user_id)) AS visitors
|
||||
FROM analytics_internal.events AS e
|
||||
${analyticsUserJoinForFilteredEvents}
|
||||
WHERE e.event_type = '$page-view'
|
||||
AND e.project_id = {projectId:String}
|
||||
AND e.branch_id = {branchId:String}
|
||||
AND e.user_id IS NOT NULL
|
||||
AND e.event_at >= {since:DateTime}
|
||||
AND e.event_at < {untilExclusive:DateTime}
|
||||
AND ${analyticsContributingUserFilter}
|
||||
${sharedExtraFilters}
|
||||
`,
|
||||
query_params: {
|
||||
since: formatClickhouseDateTimeParam(since),
|
||||
@ -1177,6 +1496,7 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo
|
||||
projectId: tenancy.project.id,
|
||||
branchId: tenancy.branchId,
|
||||
includeAnonymous: includeAnonymous ? 1 : 0,
|
||||
...filterParams,
|
||||
},
|
||||
format: "JSONEachRow",
|
||||
}),
|
||||
@ -1184,18 +1504,17 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo
|
||||
query: `
|
||||
SELECT
|
||||
nullIf(CAST(e.data.referrer, 'String'), '') AS referrer,
|
||||
uniqExactIf(
|
||||
assumeNotNull(e.user_id),
|
||||
e.user_id IS NOT NULL
|
||||
AND ${nonAnonymousAnalyticsUserFilter}
|
||||
) AS visitors
|
||||
uniqExact(assumeNotNull(e.user_id)) AS visitors
|
||||
FROM analytics_internal.events AS e
|
||||
${analyticsUserJoin}
|
||||
${analyticsUserJoinForFilteredEvents}
|
||||
WHERE e.event_type = '$page-view'
|
||||
AND e.project_id = {projectId:String}
|
||||
AND e.branch_id = {branchId:String}
|
||||
AND e.event_at >= {since:DateTime}
|
||||
AND e.event_at < {untilExclusive:DateTime}
|
||||
AND e.event_at >= {rangeSince:DateTime}
|
||||
AND e.event_at < {rangeUntilExclusive:DateTime}
|
||||
AND ${analyticsContributingUserFilter}
|
||||
${countryFragment}
|
||||
${uaFragment}
|
||||
GROUP BY referrer
|
||||
HAVING visitors > 0
|
||||
ORDER BY visitors DESC
|
||||
@ -1204,40 +1523,48 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo
|
||||
query_params: {
|
||||
since: formatClickhouseDateTimeParam(since),
|
||||
untilExclusive: formatClickhouseDateTimeParam(untilExclusive),
|
||||
rangeSince: formatClickhouseDateTimeParam(rangeSince),
|
||||
rangeUntilExclusive: formatClickhouseDateTimeParam(rangeUntilExclusive),
|
||||
projectId: tenancy.project.id,
|
||||
branchId: tenancy.branchId,
|
||||
includeAnonymous: includeAnonymous ? 1 : 0,
|
||||
...filterParams,
|
||||
},
|
||||
format: "JSONEachRow",
|
||||
}),
|
||||
// Top regions come from the same page-view population as the rest of the
|
||||
// analytics overview, but intentionally omit the country filter so the
|
||||
// country card still shows a distribution when one country is selected.
|
||||
clickhouseClient.query({
|
||||
query: `
|
||||
SELECT
|
||||
CAST(data.ip_info.country_code, 'Nullable(String)') AS country_code,
|
||||
CAST(data.ip_info.region_code, 'Nullable(String)') AS region_code,
|
||||
uniqExactIf(
|
||||
assumeNotNull(user_id),
|
||||
user_id IS NOT NULL
|
||||
AND ({includeAnonymous:UInt8} = 1 OR coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0)
|
||||
) AS visitors
|
||||
FROM analytics_internal.events
|
||||
WHERE event_type = '$token-refresh'
|
||||
AND project_id = {projectId:String}
|
||||
AND branch_id = {branchId:String}
|
||||
AND user_id IS NOT NULL
|
||||
AND event_at >= {since:DateTime}
|
||||
AND event_at < {untilExclusive:DateTime}
|
||||
GROUP BY country_code, region_code
|
||||
upper(coalesce(token_refresh_users.latest_country, '')) AS country_code,
|
||||
uniqExact(assumeNotNull(e.user_id)) AS visitors
|
||||
FROM analytics_internal.events AS e
|
||||
${analyticsUserJoinWithCountry}
|
||||
WHERE e.event_type = '$page-view'
|
||||
AND e.project_id = {projectId:String}
|
||||
AND e.branch_id = {branchId:String}
|
||||
AND e.event_at >= {rangeSince:DateTime}
|
||||
AND e.event_at < {rangeUntilExclusive:DateTime}
|
||||
AND ${analyticsContributingUserFilter}
|
||||
AND coalesce(token_refresh_users.latest_country, '') != ''
|
||||
${referrerFragment}
|
||||
${uaFragment}
|
||||
GROUP BY country_code
|
||||
HAVING visitors > 0
|
||||
ORDER BY visitors DESC
|
||||
LIMIT 1
|
||||
LIMIT ${TOP_REGIONS_PAGE_SIZE}
|
||||
`,
|
||||
query_params: {
|
||||
since: formatClickhouseDateTimeParam(since),
|
||||
untilExclusive: formatClickhouseDateTimeParam(untilExclusive),
|
||||
rangeSince: formatClickhouseDateTimeParam(rangeSince),
|
||||
rangeUntilExclusive: formatClickhouseDateTimeParam(rangeUntilExclusive),
|
||||
projectId: tenancy.project.id,
|
||||
branchId: tenancy.branchId,
|
||||
includeAnonymous: includeAnonymous ? 1 : 0,
|
||||
...filterParams,
|
||||
},
|
||||
format: "JSONEachRow",
|
||||
}),
|
||||
@ -1253,13 +1580,129 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo
|
||||
AND event_at >= {onlineSince:DateTime}
|
||||
AND event_at < {untilExclusive:DateTime}
|
||||
AND ({includeAnonymous:UInt8} = 1 OR coalesce(CAST(data.is_anonymous, 'Nullable(UInt8)'), 0) = 0)
|
||||
${onlineFilteredUserFragment}
|
||||
`,
|
||||
query_params: {
|
||||
onlineSince: formatClickhouseDateTimeParam(new Date(now.getTime() - 5 * 60 * 1000)),
|
||||
since: formatClickhouseDateTimeParam(since),
|
||||
untilExclusive: formatClickhouseDateTimeParam(untilExclusive),
|
||||
projectId: tenancy.project.id,
|
||||
branchId: tenancy.branchId,
|
||||
includeAnonymous: includeAnonymous ? 1 : 0,
|
||||
...filterParams,
|
||||
},
|
||||
format: "JSONEachRow",
|
||||
}),
|
||||
// Session aggregates keyed by session_replay_segment_id (one row per
|
||||
// browser tab/session): bounce rate (single-page-view sessions) and
|
||||
// average session duration per day.
|
||||
clickhouseClient.query({
|
||||
query: `
|
||||
WITH matching_sessions AS (
|
||||
SELECT
|
||||
e.session_replay_segment_id AS sid
|
||||
FROM analytics_internal.events AS e
|
||||
${analyticsUserJoinForFilteredEvents}
|
||||
WHERE e.session_replay_segment_id IS NOT NULL
|
||||
AND e.project_id = {projectId:String}
|
||||
AND e.branch_id = {branchId:String}
|
||||
AND e.event_at >= {since:DateTime}
|
||||
AND e.event_at < {untilExclusive:DateTime}
|
||||
AND e.event_type = '$page-view'
|
||||
AND ${analyticsContributingUserFilter}
|
||||
${sharedExtraFilters}
|
||||
GROUP BY sid
|
||||
),
|
||||
sessions AS (
|
||||
SELECT
|
||||
e.session_replay_segment_id AS sid,
|
||||
toDate(min(e.event_at)) AS session_day,
|
||||
countIf(e.event_type = '$page-view') AS pv,
|
||||
dateDiff('second', min(e.event_at), max(e.event_at)) AS duration_s
|
||||
FROM analytics_internal.events AS e
|
||||
WHERE e.session_replay_segment_id IN (SELECT sid FROM matching_sessions)
|
||||
AND e.project_id = {projectId:String}
|
||||
AND e.branch_id = {branchId:String}
|
||||
AND e.event_at >= {since:DateTime}
|
||||
AND e.event_at < {untilExclusive:DateTime}
|
||||
AND e.event_type IN ('$page-view', '$click')
|
||||
GROUP BY sid
|
||||
)
|
||||
SELECT
|
||||
session_day AS day,
|
||||
count() AS sessions,
|
||||
countIf(pv = 1) AS bounced,
|
||||
avg(duration_s) AS avg_duration_s
|
||||
FROM sessions
|
||||
GROUP BY day
|
||||
ORDER BY day ASC
|
||||
`,
|
||||
query_params: {
|
||||
since: formatClickhouseDateTimeParam(since),
|
||||
untilExclusive: formatClickhouseDateTimeParam(untilExclusive),
|
||||
projectId: tenancy.project.id,
|
||||
branchId: tenancy.branchId,
|
||||
includeAnonymous: includeAnonymous ? 1 : 0,
|
||||
...filterParams,
|
||||
},
|
||||
format: "JSONEachRow",
|
||||
}),
|
||||
// User-Agent buckets pulled from the same `$page-view` event stream so
|
||||
// visitor counts line up with the referrer / region cards on the overview.
|
||||
// `data.user_agent` is captured client-side (navigator.userAgent) only —
|
||||
// there is no server-side fallback — so older rows that pre-date capture
|
||||
// simply return empty here.
|
||||
clickhouseClient.query({
|
||||
query: `
|
||||
SELECT
|
||||
tupleElement(facet, 1) AS dimension,
|
||||
tupleElement(facet, 2) AS name,
|
||||
uniqExact(assumeNotNull(user_id)) AS visitors
|
||||
FROM (
|
||||
SELECT
|
||||
e.user_id AS user_id,
|
||||
${analyticsOverviewBrowserSql} AS browser,
|
||||
${analyticsOverviewOsSql} AS os,
|
||||
${analyticsOverviewDeviceSql} AS device
|
||||
FROM analytics_internal.events AS e
|
||||
${analyticsUserJoinForFilteredEvents}
|
||||
WHERE e.event_type = '$page-view'
|
||||
AND e.project_id = {projectId:String}
|
||||
AND e.branch_id = {branchId:String}
|
||||
AND e.event_at >= {rangeSince:DateTime}
|
||||
AND e.event_at < {rangeUntilExclusive:DateTime}
|
||||
AND ${analyticsContributingUserFilter}
|
||||
AND ${analyticsOverviewUserAgentSql} != ''
|
||||
${referrerFragment}
|
||||
${countryFragment}
|
||||
)
|
||||
ARRAY JOIN [
|
||||
('browser', browser),
|
||||
('os', os),
|
||||
('device', device)
|
||||
] AS facet
|
||||
WHERE ({browserFilterEnabled:UInt8} = 0 OR tupleElement(facet, 1) = 'browser' OR browser = {browserFilter:String})
|
||||
AND ({osFilterEnabled:UInt8} = 0 OR tupleElement(facet, 1) = 'os' OR os = {osFilter:String})
|
||||
AND ({deviceFilterEnabled:UInt8} = 0 OR tupleElement(facet, 1) = 'device' OR device = {deviceFilter:String})
|
||||
GROUP BY dimension, name
|
||||
HAVING visitors > 0
|
||||
ORDER BY dimension ASC, visitors DESC
|
||||
`,
|
||||
query_params: {
|
||||
since: formatClickhouseDateTimeParam(since),
|
||||
untilExclusive: formatClickhouseDateTimeParam(untilExclusive),
|
||||
rangeSince: formatClickhouseDateTimeParam(rangeSince),
|
||||
rangeUntilExclusive: formatClickhouseDateTimeParam(rangeUntilExclusive),
|
||||
projectId: tenancy.project.id,
|
||||
branchId: tenancy.branchId,
|
||||
includeAnonymous: includeAnonymous ? 1 : 0,
|
||||
...filterParams,
|
||||
browserFilterEnabled: filters.browser ? 1 : 0,
|
||||
browserFilter: filters.browser ?? "",
|
||||
osFilterEnabled: filters.os ? 1 : 0,
|
||||
osFilter: filters.os ?? "",
|
||||
deviceFilterEnabled: filters.device ? 1 : 0,
|
||||
deviceFilter: filters.device ?? "",
|
||||
},
|
||||
format: "JSONEachRow",
|
||||
}),
|
||||
@ -1275,55 +1718,147 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo
|
||||
clByDay.set(key, Number(row.cl));
|
||||
visitorByDay.set(key, Number(row.visitors));
|
||||
}
|
||||
const hourlyEventRows: { hour: string, pv: number, active_users: number, visitors: number }[] = await hourlyEventResult.json();
|
||||
const pageViewsByHour = new Map<string, number>();
|
||||
const activeUsersByHour = new Map<string, number>();
|
||||
const visitorsByHour = new Map<string, number>();
|
||||
for (const row of hourlyEventRows) {
|
||||
const key = new Date(row.hour).toISOString().slice(0, 13);
|
||||
pageViewsByHour.set(key, Number(row.pv));
|
||||
activeUsersByHour.set(key, Number(row.active_users));
|
||||
visitorsByHour.set(key, Number(row.visitors));
|
||||
}
|
||||
const hourlyPageViews: DataPoints = [];
|
||||
const hourlyActiveUsers: DataPoints = [];
|
||||
const hourlyVisitors: DataPoints = [];
|
||||
const latestHour = new Date(now);
|
||||
latestHour.setUTCMinutes(0, 0, 0);
|
||||
for (let i = 23; i >= 0; i--) {
|
||||
const hour = new Date(latestHour.getTime() - i * 60 * 60 * 1000);
|
||||
const key = hour.toISOString().slice(0, 13);
|
||||
const date = `${key}:00:00.000Z`;
|
||||
hourlyPageViews.push({ date, activity: pageViewsByHour.get(key) ?? 0 });
|
||||
hourlyActiveUsers.push({ date, activity: activeUsersByHour.get(key) ?? 0 });
|
||||
hourlyVisitors.push({ date, activity: visitorsByHour.get(key) ?? 0 });
|
||||
}
|
||||
const totalVisitorRows: { visitors: number }[] = await totalVisitorResult.json();
|
||||
const visitors = Number(totalVisitorRows[0]?.visitors ?? 0);
|
||||
|
||||
const sessionRows: { day: string, sessions: string | number, bounced: string | number, avg_duration_s: string | number | null }[] = await sessionResult.json();
|
||||
const sessionsByDay = new Map<string, { sessions: number, bounced: number, avg_duration_s: number }>();
|
||||
for (const row of sessionRows) {
|
||||
const key = row.day.split('T')[0];
|
||||
sessionsByDay.set(key, {
|
||||
sessions: Number(row.sessions),
|
||||
bounced: Number(row.bounced),
|
||||
avg_duration_s: Number(row.avg_duration_s ?? 0),
|
||||
});
|
||||
}
|
||||
|
||||
const dailyPageViews: DataPoints = [];
|
||||
const dailyClicks: DataPoints = [];
|
||||
const dailyVisitors: DataPoints = [];
|
||||
const dailyBounceRate: DataPoints = [];
|
||||
const dailyAvgSession: DataPoints = [];
|
||||
let totalSessions = 0;
|
||||
let totalBounced = 0;
|
||||
let totalDurationWeighted = 0;
|
||||
for (let i = 0; i <= METRICS_WINDOW_DAYS; i++) {
|
||||
const day = new Date(since.getTime() + i * ONE_DAY_MS);
|
||||
const key = day.toISOString().split('T')[0];
|
||||
dailyPageViews.push({ date: key, activity: pvByDay.get(key) ?? 0 });
|
||||
dailyClicks.push({ date: key, activity: clByDay.get(key) ?? 0 });
|
||||
dailyVisitors.push({ date: key, activity: visitorByDay.get(key) ?? 0 });
|
||||
const s = sessionsByDay.get(key);
|
||||
const sessions = s?.sessions ?? 0;
|
||||
const bounced = s?.bounced ?? 0;
|
||||
const avgDuration = s?.avg_duration_s ?? 0;
|
||||
dailyBounceRate.push({ date: key, activity: sessions > 0 ? Number(((bounced / sessions) * 100).toFixed(1)) : 0 });
|
||||
dailyAvgSession.push({ date: key, activity: Math.round(avgDuration) });
|
||||
totalSessions += sessions;
|
||||
totalBounced += bounced;
|
||||
totalDurationWeighted += avgDuration * sessions;
|
||||
}
|
||||
// Weighted (not arithmetic mean of dailies) so a high-traffic day counts
|
||||
// more than a 1-session day at 100% bounce.
|
||||
const bounceRate = totalSessions > 0 ? Number(((totalBounced / totalSessions) * 100).toFixed(1)) : 0;
|
||||
const avgSessionSeconds = totalSessions > 0 ? Number((totalDurationWeighted / totalSessions).toFixed(1)) : 0;
|
||||
|
||||
const referrers: { referrer: string | null, visitors: number }[] = await referrerResult.json();
|
||||
const topRegionRows: { country_code: string | null, region_code: string | null, visitors: number }[] = await topRegionResult.json();
|
||||
const topRegionRows: { country_code: string, visitors: number }[] = await topRegionResult.json();
|
||||
const onlineRows: { online: number }[] = await onlineResult.json();
|
||||
|
||||
const userAgentRows: { dimension: string, name: string, visitors: number | string }[] = await userAgentResult.json();
|
||||
const browserCounts = new Map<string, number>();
|
||||
const osCounts = new Map<string, number>();
|
||||
const deviceCounts = new Map<string, number>();
|
||||
for (const row of userAgentRows) {
|
||||
const visitors = Number(row.visitors);
|
||||
if (!Number.isFinite(visitors) || visitors <= 0) continue;
|
||||
if (row.dimension === "browser") {
|
||||
browserCounts.set(row.name, visitors);
|
||||
} else if (row.dimension === "os") {
|
||||
osCounts.set(row.name, visitors);
|
||||
} else if (row.dimension === "device") {
|
||||
deviceCounts.set(row.name, visitors);
|
||||
}
|
||||
}
|
||||
const toSortedTop = (m: Map<string, number>, limit: number) =>
|
||||
Array.from(m.entries())
|
||||
.map(([name, visitors]) => ({ name, visitors }))
|
||||
.sort((a, b) => b.visitors - a.visitors)
|
||||
.slice(0, limit);
|
||||
const topBrowsers = toSortedTop(browserCounts, 10);
|
||||
const topOperatingSystems = toSortedTop(osCounts, 10);
|
||||
const topDevices = toSortedTop(deviceCounts, 3);
|
||||
const topRegions = topRegionRows
|
||||
.map((row) => ({ country_code: row.country_code, count: Number(row.visitors) }))
|
||||
.filter((row) => row.country_code !== "" && row.count > 0);
|
||||
|
||||
clickhouseAggregates = {
|
||||
dailyPageViews,
|
||||
dailyClicks,
|
||||
dailyVisitors,
|
||||
hourlyPageViews,
|
||||
hourlyActiveUsers,
|
||||
hourlyVisitors,
|
||||
dailyBounceRate,
|
||||
dailyAvgSession,
|
||||
visitors,
|
||||
onlineLive: Number(onlineRows[0]?.online ?? 0),
|
||||
bounceRate,
|
||||
avgSessionSeconds,
|
||||
topReferrers: referrers.map((row) => ({
|
||||
referrer: row.referrer ?? '(direct)',
|
||||
visitors: Number(row.visitors),
|
||||
})),
|
||||
topRegion: topRegionRows[0] ? {
|
||||
country_code: topRegionRows[0].country_code,
|
||||
region_code: topRegionRows[0].region_code,
|
||||
region_code: null,
|
||||
count: Number(topRegionRows[0].visitors),
|
||||
} : null,
|
||||
topRegions,
|
||||
topBrowsers,
|
||||
topOperatingSystems,
|
||||
topDevices,
|
||||
};
|
||||
} catch (error) {
|
||||
// Only swallow real ClickHouse errors — that's the "analytics not enabled
|
||||
// for this project" path. Anything else is a real bug and should propagate.
|
||||
if (!(error instanceof ClickHouseError)) {
|
||||
throw error;
|
||||
}
|
||||
captureError("internal-metrics-analytics-overview-clickhouse-fallback", new HexclaveAssertionError(
|
||||
"Falling back to empty analytics overview due to ClickHouse query failure.",
|
||||
// The analytics app is installed, so a ClickHouse failure here is a real
|
||||
// error — capture with context, then propagate so the dashboard shows its
|
||||
// error state instead of silently rendering an empty overview.
|
||||
captureError("internal-metrics-analytics-overview-clickhouse", new HexclaveAssertionError(
|
||||
"Analytics overview ClickHouse queries failed for a project with the analytics app installed.",
|
||||
{
|
||||
cause: error,
|
||||
projectId: tenancy.project.id,
|
||||
branchId: tenancy.branchId,
|
||||
},
|
||||
));
|
||||
// Leave clickhouseAggregates as null — handled in the response builder below.
|
||||
// Rethrowing skips the `await replayPromise` below, so observe it here to
|
||||
// keep a concurrent Postgres failure from becoming an unhandled rejection.
|
||||
// (anonymousVisitorsPromise swallows its own failures and never rejects.)
|
||||
replayPromise.catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Postgres-backed session replay query has its own error surface — let it
|
||||
@ -1340,6 +1875,9 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo
|
||||
daily_page_views: [] as DataPoints,
|
||||
daily_clicks: [] as DataPoints,
|
||||
daily_visitors: [] as DataPoints,
|
||||
hourly_page_views: [] as DataPoints,
|
||||
hourly_active_users: [] as DataPoints,
|
||||
hourly_visitors: [] as DataPoints,
|
||||
daily_anonymous_visitors_fallback: anonymousVisitorsResult.dailyVisitors,
|
||||
daily_revenue: [] as Array<{ date: string, new_cents: number, refund_cents: number }>,
|
||||
total_revenue_cents: replayResult.totalRevenueCents,
|
||||
@ -1348,10 +1886,17 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo
|
||||
visitors: 0,
|
||||
anonymous_visitors_fallback: anonymousVisitorsResult.visitors,
|
||||
avg_session_seconds: replayResult.avgSessionSeconds,
|
||||
bounce_rate: 0,
|
||||
daily_bounce_rate: [] as DataPoints,
|
||||
daily_avg_session_seconds: [] as DataPoints,
|
||||
online_live: 0,
|
||||
revenue_per_visitor: 0,
|
||||
top_referrers: [],
|
||||
top_region: null,
|
||||
top_regions: [],
|
||||
top_browsers: [],
|
||||
top_operating_systems: [],
|
||||
top_devices: [],
|
||||
};
|
||||
}
|
||||
|
||||
@ -1366,6 +1911,9 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo
|
||||
daily_page_views: clickhouseAggregates.dailyPageViews,
|
||||
daily_clicks: clickhouseAggregates.dailyClicks,
|
||||
daily_visitors: clickhouseAggregates.dailyVisitors,
|
||||
hourly_page_views: clickhouseAggregates.hourlyPageViews,
|
||||
hourly_active_users: clickhouseAggregates.hourlyActiveUsers,
|
||||
hourly_visitors: clickhouseAggregates.hourlyVisitors,
|
||||
daily_anonymous_visitors_fallback: anonymousVisitorsResult.dailyVisitors,
|
||||
daily_revenue: [] as Array<{ date: string, new_cents: number, refund_cents: number }>,
|
||||
total_revenue_cents: replayResult.totalRevenueCents,
|
||||
@ -1373,13 +1921,20 @@ async function loadAnalyticsOverview(tenancy: Tenancy, now: Date, includeAnonymo
|
||||
recent_replays: replayResult.recent,
|
||||
visitors: clickhouseAggregates.visitors,
|
||||
anonymous_visitors_fallback: anonymousVisitorsResult.visitors,
|
||||
avg_session_seconds: replayResult.avgSessionSeconds,
|
||||
avg_session_seconds: clickhouseAggregates.avgSessionSeconds,
|
||||
bounce_rate: clickhouseAggregates.bounceRate,
|
||||
daily_bounce_rate: clickhouseAggregates.dailyBounceRate,
|
||||
daily_avg_session_seconds: clickhouseAggregates.dailyAvgSession,
|
||||
online_live: clickhouseAggregates.onlineLive,
|
||||
revenue_per_visitor: effectiveVisitors > 0
|
||||
? Number(((replayResult.totalRevenueCents / 100) / effectiveVisitors).toFixed(2))
|
||||
: 0,
|
||||
top_referrers: clickhouseAggregates.topReferrers,
|
||||
top_region: clickhouseAggregates.topRegion,
|
||||
top_regions: clickhouseAggregates.topRegions,
|
||||
top_browsers: clickhouseAggregates.topBrowsers,
|
||||
top_operating_systems: clickhouseAggregates.topOperatingSystems,
|
||||
top_devices: clickhouseAggregates.topDevices,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1471,6 +2026,7 @@ async function loadAuthOverview(tenancy: Tenancy, includeAnonymous: boolean, now
|
||||
|
||||
const RECENT_LIST_PAGE_SIZE = 100;
|
||||
const TOP_REFERRERS_PAGE_SIZE = 100;
|
||||
const TOP_REGIONS_PAGE_SIZE = 100;
|
||||
|
||||
export const GET = createSmartRouteHandler({
|
||||
metadata: {
|
||||
@ -1483,6 +2039,15 @@ export const GET = createSmartRouteHandler({
|
||||
}),
|
||||
query: yupObject({
|
||||
include_anonymous: yupString().oneOf(["true", "false"]).optional(),
|
||||
filter_country_code: yupString().optional(),
|
||||
filter_referrer: yupString().optional(),
|
||||
filter_browser: yupString().optional(),
|
||||
filter_os: yupString().optional(),
|
||||
filter_device: yupString().optional(),
|
||||
// ISO 8601 datetimes bounding the analytics top-N breakdowns (referrers,
|
||||
// regions, browsers/OS/devices); clamped to the analytics window.
|
||||
filter_since: yupString().optional(),
|
||||
filter_until: yupString().optional(),
|
||||
}),
|
||||
}),
|
||||
response: yupObject({
|
||||
@ -1493,6 +2058,8 @@ export const GET = createSmartRouteHandler({
|
||||
live_users: yupNumber().integer().defined(),
|
||||
daily_users: DataPointsSchema,
|
||||
daily_active_users: DataPointsSchema,
|
||||
hourly_users: DataPointsSchema,
|
||||
hourly_active_users: DataPointsSchema,
|
||||
users_by_country: yupRecord(yupString().defined(), yupNumber().defined()).defined(),
|
||||
active_users_by_country: MetricsActiveUsersByCountrySchema,
|
||||
// recently_registered/active are CRUD User objects passed through from
|
||||
@ -1511,10 +2078,21 @@ export const GET = createSmartRouteHandler({
|
||||
handler: async (req) => {
|
||||
const now = new Date();
|
||||
const includeAnonymous = req.query.include_anonymous === "true";
|
||||
const analyticsFilters = normalizeAnalyticsOverviewFilters({
|
||||
country_code: req.query.filter_country_code || undefined,
|
||||
referrer: req.query.filter_referrer || undefined,
|
||||
browser: req.query.filter_browser || undefined,
|
||||
os: req.query.filter_os || undefined,
|
||||
device: req.query.filter_device || undefined,
|
||||
since: req.query.filter_since || undefined,
|
||||
until: req.query.filter_until || undefined,
|
||||
});
|
||||
|
||||
const [
|
||||
dailyUsers,
|
||||
dailyActiveUsers,
|
||||
hourlyUsers,
|
||||
hourlyActiveUsers,
|
||||
usersByCountry,
|
||||
activeUsersByCountry,
|
||||
liveUsers,
|
||||
@ -1529,6 +2107,8 @@ export const GET = createSmartRouteHandler({
|
||||
] = await Promise.all([
|
||||
loadTotalUsers(req.auth.tenancy, now, includeAnonymous),
|
||||
loadDailyActiveUsers(req.auth.tenancy, now, includeAnonymous),
|
||||
loadHourlyUsers(req.auth.tenancy, now, includeAnonymous),
|
||||
loadHourlyActiveUsers(req.auth.tenancy, now, includeAnonymous),
|
||||
loadUsersByCountry(req.auth.tenancy, now, includeAnonymous),
|
||||
loadActiveUsersByCountry(req.auth.tenancy, now, includeAnonymous),
|
||||
loadLiveUsersCount(req.auth.tenancy, now, includeAnonymous),
|
||||
@ -1549,7 +2129,7 @@ export const GET = createSmartRouteHandler({
|
||||
loadAuthOverview(req.auth.tenancy, includeAnonymous, now),
|
||||
loadPaymentsOverview(req.auth.tenancy, now),
|
||||
loadEmailOverview(req.auth.tenancy, now),
|
||||
loadAnalyticsOverview(req.auth.tenancy, now, includeAnonymous),
|
||||
loadAnalyticsOverview(req.auth.tenancy, now, includeAnonymous, analyticsFilters),
|
||||
loadDailyRevenue(req.auth.tenancy, now),
|
||||
] as const);
|
||||
|
||||
@ -1567,6 +2147,8 @@ export const GET = createSmartRouteHandler({
|
||||
live_users: liveUsers,
|
||||
daily_users: dailyUsers,
|
||||
daily_active_users: dailyActiveUsers,
|
||||
hourly_users: hourlyUsers,
|
||||
hourly_active_users: hourlyActiveUsers,
|
||||
users_by_country: usersByCountry,
|
||||
active_users_by_country: activeUsersByCountry,
|
||||
recently_registered: recentlyRegistered,
|
||||
|
||||
@ -90,6 +90,7 @@
|
||||
"jose": "^6.1.3",
|
||||
"libsodium-wrappers": "^0.8.2",
|
||||
"lodash": "^4.17.21",
|
||||
"motion": "^12.39.0",
|
||||
"next": "16.1.7",
|
||||
"next-themes": "^0.2.1",
|
||||
"posthog-js": "^1.336.1",
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { toggleAnalyticsChartMetricMode } from "./analytics-chart-mode";
|
||||
|
||||
describe("toggleAnalyticsChartMetricMode", () => {
|
||||
it("clears the active metric when it is selected again", () => {
|
||||
expect(toggleAnalyticsChartMetricMode("dau", "dau")).toBe("default");
|
||||
});
|
||||
|
||||
it("selects the requested metric when another metric or the overview is active", () => {
|
||||
expect(toggleAnalyticsChartMetricMode("default", "visitors")).toBe("visitors");
|
||||
expect(toggleAnalyticsChartMetricMode("revenue", "dau")).toBe("dau");
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,12 @@
|
||||
export type AnalyticsChartMode = "default" | "dau" | "visitors" | "revenue";
|
||||
export type AnalyticsChartMetricMode = Exclude<AnalyticsChartMode, "default">;
|
||||
|
||||
export const ANALYTICS_CHART_METRIC_MODE_ORDER: readonly AnalyticsChartMetricMode[] = [
|
||||
"dau",
|
||||
"visitors",
|
||||
"revenue",
|
||||
];
|
||||
|
||||
export function toggleAnalyticsChartMetricMode(currentMode: AnalyticsChartMode, metricMode: AnalyticsChartMetricMode): AnalyticsChartMode {
|
||||
return currentMode === metricMode ? "default" : metricMode;
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
export function easeOutCubic(progress: number): number {
|
||||
return 1 - Math.pow(1 - progress, 3);
|
||||
}
|
||||
|
||||
export function prefersReducedMotion(): boolean {
|
||||
return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
@ -17,10 +17,10 @@ function captureGlobeErrorOnce(error: Error) {
|
||||
captureError("metrics-globe-error-boundary", error);
|
||||
}
|
||||
|
||||
export function GlobeSectionWithData({ includeAnonymous }: { includeAnonymous: boolean }) {
|
||||
export function GlobeSectionWithData({ includeAnonymous, interactive }: { includeAnonymous: boolean, interactive?: boolean }) {
|
||||
return (
|
||||
<ErrorBoundary errorComponent={GlobeErrorComponent}>
|
||||
<GlobeSectionWithMetrics includeAnonymous={includeAnonymous} />
|
||||
<GlobeSectionWithMetrics includeAnonymous={includeAnonymous} interactive={interactive} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@ -30,7 +30,7 @@ function GlobeErrorComponent(props: { error: Error }) {
|
||||
return <div className='text-center text-sm text-red-500'>Error initializing globe visualization. Please try updating your browser or enabling WebGL.</div>;
|
||||
}
|
||||
|
||||
function GlobeSectionWithMetrics({ includeAnonymous }: { includeAnonymous: boolean }) {
|
||||
function GlobeSectionWithMetrics({ includeAnonymous, interactive }: { includeAnonymous: boolean, interactive?: boolean }) {
|
||||
const adminApp = useAdminApp();
|
||||
const data = (adminApp as any)[hexclaveAppInternalsSymbol].useMetrics(includeAnonymous);
|
||||
|
||||
@ -41,6 +41,7 @@ function GlobeSectionWithMetrics({ includeAnonymous }: { includeAnonymous: boole
|
||||
countryData={data.users_by_country}
|
||||
totalUsers={data.total_users}
|
||||
activeUsersByCountry={data.active_users_by_country ?? {}}
|
||||
interactive={interactive}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -367,7 +367,7 @@ type SatelliteHandle = {
|
||||
lastCountryCheckAt: number,
|
||||
};
|
||||
|
||||
export function GlobeSection({ countryData, totalUsers, activeUsersByCountry, satelliteCount, children }: {countryData: Record<string, number>, totalUsers: number, activeUsersByCountry?: Record<string, MetricsRecentUser[]>, satelliteCount?: number, children?: React.ReactNode}) {
|
||||
export function GlobeSection({ countryData, totalUsers, activeUsersByCountry, satelliteCount, interactive, children }: {countryData: Record<string, number>, totalUsers: number, activeUsersByCountry?: Record<string, MetricsRecentUser[]>, satelliteCount?: number, interactive?: boolean, children?: React.ReactNode}) {
|
||||
const hasWaitedForIdle = useWaitForIdle(1000, 5000);
|
||||
if (!hasWaitedForIdle) {
|
||||
return <GlobeLoading devReason="waiting for cpu" />;
|
||||
@ -379,6 +379,7 @@ export function GlobeSection({ countryData, totalUsers, activeUsersByCountry, sa
|
||||
totalUsers={totalUsers}
|
||||
activeUsersByCountry={activeUsersByCountry ?? {}}
|
||||
satelliteCount={satelliteCount ?? 2}
|
||||
interactive={interactive ?? false}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
@ -473,7 +474,7 @@ function GlobeLoading(props: { devReason: string, className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, satelliteCount, children }: {countryData: Record<string, number>, totalUsers: number, activeUsersByCountry: Record<string, MetricsRecentUser[]>, satelliteCount: number, children?: React.ReactNode}) {
|
||||
function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, satelliteCount, interactive, children }: {countryData: Record<string, number>, totalUsers: number, activeUsersByCountry: Record<string, MetricsRecentUser[]>, satelliteCount: number, interactive: boolean, children?: React.ReactNode}) {
|
||||
const countries = use(countriesPromise);
|
||||
const projectId = useProjectId();
|
||||
const globeRef = useRef<GlobeMethods | undefined>(undefined);
|
||||
@ -686,15 +687,24 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
|
||||
if (!globeRef.current || !shouldShowGlobe) return;
|
||||
|
||||
const controls = globeRef.current.controls();
|
||||
controls.maxDistance = cameraDistance;
|
||||
controls.minDistance = cameraDistance;
|
||||
if (interactive) {
|
||||
controls.enableZoom = true;
|
||||
controls.minDistance = cameraDistance;
|
||||
// Large containers can push cameraDistance past 600; keep min <= max so
|
||||
// OrbitControls doesn't end up with an inverted zoom range.
|
||||
controls.maxDistance = Math.max(600, cameraDistance);
|
||||
} else {
|
||||
controls.enableZoom = false;
|
||||
controls.maxDistance = cameraDistance;
|
||||
controls.minDistance = cameraDistance;
|
||||
}
|
||||
globeRef.current.camera().position.z = cameraDistance;
|
||||
|
||||
// Update border size and trigger re-render when size changes
|
||||
const visualDiameter = calculateGlobeVisualDiameter(globeRef);
|
||||
setBorderSizeFromGlobe(visualDiameter);
|
||||
resumeRender();
|
||||
}, [cameraDistance, shouldShowGlobe, globeSize]);
|
||||
}, [cameraDistance, shouldShowGlobe, globeSize, interactive]);
|
||||
|
||||
|
||||
const totalUsersInCountries = Object.values(countryData).reduce((acc, curr) => acc + curr, 0);
|
||||
@ -1058,7 +1068,7 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className='relative mx-auto' style={{ width: squareSize || '100%', height: squareSize || '100%' }}>
|
||||
<div ref={rootRef} className='relative mx-auto overflow-hidden' style={{ width: squareSize || '100%', height: squareSize || '100%' }}>
|
||||
<div inert className='absolute inset-0 pointer-events-none'>
|
||||
<GlobeLoading
|
||||
devReason="not ready"
|
||||
@ -1079,7 +1089,7 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
|
||||
|
||||
{/* Globe Container - Premium 3D */}
|
||||
{shouldShowGlobe && (
|
||||
<div className='relative w-full h-full flex items-center justify-center'>
|
||||
<div className='relative w-full h-full overflow-hidden flex items-center justify-center'>
|
||||
{/* Border container - same approach as globe */}
|
||||
<div inert className='absolute top-0 left-0 right-0 pointer-events-none flex items-center justify-center'>
|
||||
{/* Inner square div - contain behavior (square, fills either width or height) */}
|
||||
@ -1160,10 +1170,15 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
|
||||
const controls = current.controls();
|
||||
controls.autoRotate = false;
|
||||
controls.autoRotateSpeed = 0.5;
|
||||
controls.maxDistance = cameraDistance;
|
||||
controls.minDistance = cameraDistance;
|
||||
if (interactive) {
|
||||
controls.minDistance = cameraDistance;
|
||||
controls.maxDistance = 600;
|
||||
} else {
|
||||
controls.maxDistance = cameraDistance;
|
||||
controls.minDistance = cameraDistance;
|
||||
}
|
||||
controls.dampingFactor = 0.15;
|
||||
controls.enableZoom = false;
|
||||
controls.enableZoom = interactive;
|
||||
controls.enableRotate = true;
|
||||
current.camera().position.z = cameraDistance;
|
||||
// Little Saint James Island, U.S. Virgin Islands
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
import { useRouter } from "@/components/router";
|
||||
import {
|
||||
cn,
|
||||
SimpleTooltip,
|
||||
Typography
|
||||
} from "@/components/ui";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
@ -21,7 +22,7 @@ import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "
|
||||
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
|
||||
import { UserAvatar } from '@hexclave/next';
|
||||
import { fromNow, isWeekend } from '@hexclave/shared/dist/utils/dates';
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { Area, Bar, BarChart, CartesianGrid, Cell, ComposedChart, Line, LineChart, Pie, PieChart, TooltipProps, XAxis, YAxis } from "recharts";
|
||||
|
||||
export type CustomDateRange = {
|
||||
@ -29,7 +30,7 @@ export type CustomDateRange = {
|
||||
to: Date,
|
||||
};
|
||||
|
||||
export type TimeRange = '7d' | '30d' | 'all' | 'custom';
|
||||
export type TimeRange = '1d' | '7d' | '30d' | 'all' | 'custom';
|
||||
|
||||
export type LineChartDisplayConfig = {
|
||||
name: string,
|
||||
@ -114,6 +115,19 @@ function parseChartDate(dateValue: string): Date {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function formatChartXAxisTick(value: string): string {
|
||||
let date: Date;
|
||||
try {
|
||||
date = parseChartDate(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
if (value.includes("T")) {
|
||||
return date.toLocaleTimeString("en-US", { hour: "numeric" });
|
||||
}
|
||||
return `${date.toLocaleDateString("en-US", { month: "short" })} ${date.getDate()}`;
|
||||
}
|
||||
|
||||
function formatDateRangeLabel(range: CustomDateRange | null): string {
|
||||
if (range == null) {
|
||||
return "Pick date range";
|
||||
@ -133,6 +147,9 @@ function filterPointsByTimeRange<T extends { date: string }>(
|
||||
if (timeRange === '7d') {
|
||||
return datapoints.slice(-7);
|
||||
}
|
||||
if (timeRange === '1d') {
|
||||
return datapoints.slice(-1);
|
||||
}
|
||||
if (timeRange === '30d') {
|
||||
return datapoints.slice(-30);
|
||||
}
|
||||
@ -238,6 +255,7 @@ export function ActivityBarChart({
|
||||
}) {
|
||||
const id = useId();
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const chartMotion = useChartMotionProps();
|
||||
|
||||
return (
|
||||
<ChartContainer
|
||||
@ -275,7 +293,7 @@ export function ActivityBarChart({
|
||||
dataKey="activity"
|
||||
fill="var(--color-activity)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
>
|
||||
{datapoints.map((entry, index) => {
|
||||
const isWeekendDay = isWeekend(parseChartDate(entry.date));
|
||||
@ -311,17 +329,7 @@ export function ActivityBarChart({
|
||||
fill: "hsl(var(--muted-foreground))",
|
||||
fontSize: compact ? 8 : 10,
|
||||
}}
|
||||
tickFormatter={(value) => {
|
||||
const date = parseChartDate(value);
|
||||
if (!isNaN(date.getTime())) {
|
||||
const month = date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
});
|
||||
const day = date.getDate();
|
||||
return `${month} ${day}`;
|
||||
}
|
||||
return value;
|
||||
}}
|
||||
tickFormatter={(value) => formatChartXAxisTick(value)}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
@ -427,6 +435,7 @@ export function StackedBarChartDisplay({
|
||||
}) {
|
||||
const id = useId();
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const chartMotion = useChartMotionProps();
|
||||
|
||||
const windowSize = Math.max(4, Math.round(datapoints.length / 2.5));
|
||||
const totals = datapoints.map(p => p.new + p.retained + p.reactivated);
|
||||
@ -480,7 +489,7 @@ export function StackedBarChartDisplay({
|
||||
allowEscapeViewBox={{ x: true, y: true }}
|
||||
wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }}
|
||||
/>
|
||||
<Bar dataKey="retained" stackId="split" fill="var(--color-retained)" radius={[0, 0, 0, 0]} isAnimationActive={false}>
|
||||
<Bar dataKey="retained" stackId="split" fill="var(--color-retained)" radius={[0, 0, 0, 0]} {...chartMotion}>
|
||||
{datapoints.map((entry, index) => {
|
||||
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
|
||||
const isActiveBar = hoveredIndex === index;
|
||||
@ -494,7 +503,7 @@ export function StackedBarChartDisplay({
|
||||
);
|
||||
})}
|
||||
</Bar>
|
||||
<Bar dataKey="reactivated" stackId="split" fill="var(--color-reactivated)" radius={[0, 0, 0, 0]} isAnimationActive={false}>
|
||||
<Bar dataKey="reactivated" stackId="split" fill="var(--color-reactivated)" radius={[0, 0, 0, 0]} {...chartMotion}>
|
||||
{datapoints.map((entry, index) => {
|
||||
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
|
||||
const isActiveBar = hoveredIndex === index;
|
||||
@ -508,7 +517,7 @@ export function StackedBarChartDisplay({
|
||||
);
|
||||
})}
|
||||
</Bar>
|
||||
<Bar dataKey="new" stackId="split" fill="var(--color-new)" radius={[4, 4, 0, 0]} isAnimationActive={false}>
|
||||
<Bar dataKey="new" stackId="split" fill="var(--color-new)" radius={[4, 4, 0, 0]} {...chartMotion}>
|
||||
{datapoints.map((entry, index) => {
|
||||
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
|
||||
const isActiveBar = hoveredIndex === index;
|
||||
@ -531,7 +540,7 @@ export function StackedBarChartDisplay({
|
||||
strokeDasharray="2.5 3.5"
|
||||
dot={false}
|
||||
activeDot={false}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
connectNulls={false}
|
||||
legendType="none"
|
||||
/>
|
||||
@ -544,7 +553,7 @@ export function StackedBarChartDisplay({
|
||||
strokeDasharray="2.5 3.5"
|
||||
dot={false}
|
||||
activeDot={{ r: 3.5, fill: "hsl(var(--foreground))", stroke: "hsl(var(--background))", strokeWidth: 1.5 }}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
connectNulls={false}
|
||||
legendType="none"
|
||||
/>
|
||||
@ -576,13 +585,7 @@ export function StackedBarChartDisplay({
|
||||
axisLine={false}
|
||||
interval={datapoints.length <= 7 ? 0 : "equidistantPreserveStart"}
|
||||
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: compact ? 8 : 10 }}
|
||||
tickFormatter={(value) => {
|
||||
const date = parseChartDate(value);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return `${date.toLocaleDateString("en-US", { month: "short" })} ${date.getDate()}`;
|
||||
}
|
||||
return value;
|
||||
}}
|
||||
tickFormatter={(value) => formatChartXAxisTick(value)}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ChartContainer>
|
||||
@ -595,12 +598,60 @@ export type ComposedDataPoint = {
|
||||
date: string,
|
||||
new_cents: number,
|
||||
refund_cents: number,
|
||||
page_views: number,
|
||||
visitors: number,
|
||||
dau: number,
|
||||
_showPageViews?: boolean,
|
||||
_showVisitors?: boolean,
|
||||
_showRevenue?: boolean,
|
||||
};
|
||||
|
||||
const OVERVIEW_CHART_ANIMATION_MS = 520;
|
||||
|
||||
type ChartMotionProps = {
|
||||
isAnimationActive: boolean,
|
||||
animationBegin: number,
|
||||
animationDuration: number,
|
||||
animationEasing: "ease-out",
|
||||
};
|
||||
|
||||
const enabledChartMotion: ChartMotionProps = {
|
||||
isAnimationActive: true,
|
||||
animationBegin: 0,
|
||||
animationDuration: OVERVIEW_CHART_ANIMATION_MS,
|
||||
animationEasing: "ease-out",
|
||||
};
|
||||
|
||||
const disabledChartMotion: ChartMotionProps = {
|
||||
isAnimationActive: false,
|
||||
animationBegin: 0,
|
||||
animationDuration: 0,
|
||||
animationEasing: "ease-out",
|
||||
};
|
||||
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const updatePrefersReducedMotion = () => setPrefersReducedMotion(mediaQuery.matches);
|
||||
|
||||
updatePrefersReducedMotion();
|
||||
mediaQuery.addEventListener("change", updatePrefersReducedMotion);
|
||||
return () => mediaQuery.removeEventListener("change", updatePrefersReducedMotion);
|
||||
}, []);
|
||||
|
||||
return prefersReducedMotion;
|
||||
}
|
||||
|
||||
function useChartMotionProps(): ChartMotionProps {
|
||||
return usePrefersReducedMotion() ? disabledChartMotion : enabledChartMotion;
|
||||
}
|
||||
|
||||
export type VisitorsHoverDataPoint = {
|
||||
date: string,
|
||||
page_views: number,
|
||||
@ -634,6 +685,10 @@ const composedChartConfig: ChartConfig = {
|
||||
label: "Unique Visitors",
|
||||
theme: { light: "hsl(210, 84%, 64%)", dark: "hsl(210, 84%, 72%)" },
|
||||
},
|
||||
page_views: {
|
||||
label: "Page Views",
|
||||
theme: { light: "hsl(189, 84%, 54%)", dark: "hsl(189, 84%, 68%)" },
|
||||
},
|
||||
revenue: {
|
||||
label: "Revenue",
|
||||
theme: { light: "hsl(268, 82%, 66%)", dark: "hsl(268, 82%, 74%)" },
|
||||
@ -652,6 +707,7 @@ function ComposedTooltip({ active, payload }: TooltipProps<number, string>) {
|
||||
: row.date;
|
||||
|
||||
const visitorsEnabled = row._showVisitors !== false;
|
||||
const pageViewsEnabled = row._showPageViews !== false;
|
||||
const revenueEnabled = row._showRevenue !== false;
|
||||
const revenueDollars = (row.new_cents / 100);
|
||||
const revenuePerVisitor = visitorsEnabled && revenueEnabled && row.visitors > 0 ? (revenueDollars / row.visitors) : null;
|
||||
@ -683,6 +739,16 @@ function ComposedTooltip({ active, payload }: TooltipProps<number, string>) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: "var(--color-page_views)" }} />
|
||||
<span className="text-xs text-muted-foreground">Page views</span>
|
||||
</div>
|
||||
<span className="font-mono text-xs font-semibold tabular-nums text-foreground">
|
||||
{pageViewsEnabled ? row.page_views.toLocaleString() : "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2.5 w-2.5 rounded-sm" style={{ backgroundColor: "var(--color-revenue)" }} />
|
||||
@ -724,12 +790,14 @@ function HighlightedLineDot({ cx, cy, fill }: HighlightDotProps) {
|
||||
export function ComposedAnalyticsChart({
|
||||
datapoints,
|
||||
showVisitors = true,
|
||||
showPageViews = true,
|
||||
showRevenue = true,
|
||||
height,
|
||||
compact = false,
|
||||
}: {
|
||||
datapoints: ComposedDataPoint[],
|
||||
showVisitors?: boolean,
|
||||
showPageViews?: boolean,
|
||||
showRevenue?: boolean,
|
||||
height?: number,
|
||||
compact?: boolean,
|
||||
@ -737,11 +805,12 @@ export function ComposedAnalyticsChart({
|
||||
const id = useId();
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const [hoveredX, setHoveredX] = useState<number | null>(null);
|
||||
const chartMotion = useChartMotionProps();
|
||||
const taggedDatapoints = useMemo(
|
||||
() => datapoints.map(d => ({ ...d, _showVisitors: showVisitors, _showRevenue: showRevenue })),
|
||||
[datapoints, showVisitors, showRevenue],
|
||||
() => datapoints.map(d => ({ ...d, _showPageViews: showPageViews, _showVisitors: showVisitors, _showRevenue: showRevenue })),
|
||||
[datapoints, showPageViews, showVisitors, showRevenue],
|
||||
);
|
||||
const maxVisitors = Math.max(...datapoints.map(d => Math.max(showVisitors ? d.visitors : 0, d.dau)), 1);
|
||||
const maxVisitors = Math.max(...datapoints.map(d => Math.max(showPageViews ? d.page_views : 0, showVisitors ? d.visitors : 0, d.dau)), 1);
|
||||
const maxRevenueCents = Math.max(...datapoints.map(d => showRevenue ? d.new_cents : 0), 1);
|
||||
const visitorTicks = niceAxisTicks(Math.ceil(maxVisitors * 1.1), 5);
|
||||
const revenueTicks = niceAxisTicks(Math.ceil(maxRevenueCents * 1.15), 5);
|
||||
@ -778,6 +847,9 @@ export function ComposedAnalyticsChart({
|
||||
<clipPath id={`visitors-highlight-clip-${id}`}>
|
||||
<rect x={hoveredX - 56} y={-1000} width={112} height={3000} />
|
||||
</clipPath>
|
||||
<clipPath id={`page-views-highlight-clip-${id}`}>
|
||||
<rect x={hoveredX - 56} y={-1000} width={112} height={3000} />
|
||||
</clipPath>
|
||||
<clipPath id={`dau-highlight-clip-${id}`}>
|
||||
<rect x={hoveredX - 56} y={-1000} width={112} height={3000} />
|
||||
</clipPath>
|
||||
@ -801,6 +873,26 @@ export function ComposedAnalyticsChart({
|
||||
allowEscapeViewBox={{ x: true, y: true }}
|
||||
wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="page_views"
|
||||
yAxisId="visitors"
|
||||
fill="var(--color-page_views)"
|
||||
fillOpacity={showPageViews ? (hoveredIndex == null ? 0.18 : 0.08) : 0}
|
||||
radius={[4, 4, 0, 0]}
|
||||
{...chartMotion}
|
||||
/>
|
||||
{showPageViews && hoveredIndex != null && hoveredX != null && (
|
||||
<Bar
|
||||
dataKey="page_views"
|
||||
yAxisId="visitors"
|
||||
fill="var(--color-page_views)"
|
||||
fillOpacity={0.5}
|
||||
radius={[4, 4, 0, 0]}
|
||||
isAnimationActive={false}
|
||||
style={{ clipPath: `url(#page-views-highlight-clip-${id})` }}
|
||||
legendType="none"
|
||||
/>
|
||||
)}
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="visitors"
|
||||
@ -812,7 +904,7 @@ export function ComposedAnalyticsChart({
|
||||
strokeOpacity={showVisitors ? (hoveredIndex == null ? 1 : 0.22) : 0}
|
||||
dot={false}
|
||||
activeDot={showVisitors ? <HighlightedLineDot fill="var(--color-visitors)" /> : false}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
/>
|
||||
{showVisitors && hoveredIndex != null && hoveredX != null && (
|
||||
<Line
|
||||
@ -840,7 +932,7 @@ export function ComposedAnalyticsChart({
|
||||
strokeOpacity={hoveredIndex == null ? 0.95 : 0.24}
|
||||
dot={false}
|
||||
activeDot={<HighlightedLineDot fill="var(--color-dau)" />}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
/>
|
||||
{hoveredIndex != null && hoveredX != null && (
|
||||
<Line
|
||||
@ -869,7 +961,7 @@ export function ComposedAnalyticsChart({
|
||||
strokeDasharray="4 4"
|
||||
dot={false}
|
||||
activeDot={showRevenue ? <HighlightedLineDot fill="var(--color-revenue)" /> : false}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
/>
|
||||
{showRevenue && hoveredIndex != null && hoveredX != null && (
|
||||
<Line
|
||||
@ -912,13 +1004,7 @@ export function ComposedAnalyticsChart({
|
||||
padding={{ left: 8, right: 8 }}
|
||||
interval={datapoints.length <= 7 ? 0 : "equidistantPreserveStart"}
|
||||
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: compact ? 8 : 10 }}
|
||||
tickFormatter={(value) => {
|
||||
const date = parseChartDate(value);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return `${date.toLocaleDateString("en-US", { month: "short" })} ${date.getDate()}`;
|
||||
}
|
||||
return value;
|
||||
}}
|
||||
tickFormatter={(value) => formatChartXAxisTick(value)}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ChartContainer>
|
||||
@ -987,6 +1073,7 @@ export function TimeRangeToggle({
|
||||
const customDateRangeHandler = onCustomDateRangeChange;
|
||||
|
||||
const options: { id: TimeRange, label: string }[] = [
|
||||
{ id: '1d', label: '1d' },
|
||||
{ id: '7d', label: '7d' },
|
||||
{ id: '30d', label: '30d' },
|
||||
{ id: 'all', label: 'All' },
|
||||
@ -1010,6 +1097,7 @@ export function TimeRangeToggle({
|
||||
glassmorphic={false}
|
||||
onSelect={(selectedId) => {
|
||||
if (
|
||||
selectedId === '1d' ||
|
||||
selectedId === '7d' ||
|
||||
selectedId === '30d' ||
|
||||
selectedId === 'all' ||
|
||||
@ -1182,6 +1270,8 @@ export function TabbedMetricsCard({
|
||||
totalAllTime,
|
||||
showTotal = false,
|
||||
stackedLegendItems,
|
||||
chartDataIsPreFiltered = false,
|
||||
headerTooltip,
|
||||
}: {
|
||||
config: LineChartDisplayConfig,
|
||||
chartData: DataPoint[],
|
||||
@ -1198,11 +1288,15 @@ export function TabbedMetricsCard({
|
||||
totalAllTime?: number,
|
||||
showTotal?: boolean,
|
||||
stackedLegendItems?: Array<{ key: string, label: string, color: string }>,
|
||||
chartDataIsPreFiltered?: boolean,
|
||||
headerTooltip?: string,
|
||||
}) {
|
||||
const [view, setView] = useState<'chart' | 'list'>('chart');
|
||||
|
||||
const filteredDatapoints = filterDatapointsByTimeRange(chartData, timeRange, customDateRange);
|
||||
const filteredStackedDatapoints = stackedChartData ? filterStackedDatapointsByTimeRange(stackedChartData, timeRange, customDateRange) : null;
|
||||
const filteredDatapoints = chartDataIsPreFiltered ? chartData : filterDatapointsByTimeRange(chartData, timeRange, customDateRange);
|
||||
const filteredStackedDatapoints = stackedChartData
|
||||
? (chartDataIsPreFiltered ? stackedChartData : filterStackedDatapointsByTimeRange(stackedChartData, timeRange, customDateRange))
|
||||
: null;
|
||||
|
||||
// Calculate total for the selected time range
|
||||
const total = filteredDatapoints.reduce((sum, point) => sum + point.activity, 0);
|
||||
@ -1255,6 +1349,9 @@ export function TabbedMetricsCard({
|
||||
gradient={tabsGradient}
|
||||
className="flex-1 min-w-0 border-0 [&>button]:rounded-none [&>button]:px-3 [&>button]:py-3.5 [&>button]:text-xs"
|
||||
/>
|
||||
{headerTooltip && (
|
||||
<SimpleTooltip tooltip={headerTooltip} type="info" className="ml-2" />
|
||||
)}
|
||||
|
||||
{view === 'chart' && showTotal && (
|
||||
<span className="text-sm font-semibold text-foreground tabular-nums">
|
||||
@ -1791,6 +1888,7 @@ export function CorrelationCard({
|
||||
const chartConfig: ChartConfig = Object.fromEntries(
|
||||
series.map(s => [s.key, { label: s.label, color: s.color }])
|
||||
);
|
||||
const chartMotion = useChartMotionProps();
|
||||
|
||||
return (
|
||||
<ChartCard gradientColor={gradientColor} className={cn("h-full", className)}>
|
||||
@ -1833,11 +1931,7 @@ export function CorrelationCard({
|
||||
tickMargin={6}
|
||||
interval="equidistantPreserveStart"
|
||||
tick={{ fill: "hsl(var(--muted-foreground))", fontSize: compact ? 8 : 10 }}
|
||||
tickFormatter={(value) => {
|
||||
const date = parseChartDate(value);
|
||||
if (isNaN(date.getTime())) return value;
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}}
|
||||
tickFormatter={(value) => formatChartXAxisTick(value)}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
@ -1860,7 +1954,7 @@ export function CorrelationCard({
|
||||
stroke={s.color}
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
@ -1982,9 +2076,11 @@ export function DonutChartDisplay({
|
||||
<div className={compact ? "p-4 pb-3" : "p-5 pb-4"}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Auth Methods
|
||||
</span>
|
||||
<SimpleTooltip tooltip="Shows which login methods users choose most often." inline className="w-fit">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Auth Methods
|
||||
</span>
|
||||
</SimpleTooltip>
|
||||
{!compact && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Login distribution
|
||||
@ -2092,6 +2188,7 @@ export function EmailStackedBarChartDisplay({
|
||||
}) {
|
||||
const id = useId();
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const chartMotion = useChartMotionProps();
|
||||
|
||||
const windowSize = Math.max(4, Math.round(datapoints.length / 2.5));
|
||||
const totals = datapoints.map(p => p.ok + p.error + p.in_progress);
|
||||
@ -2154,7 +2251,7 @@ export function EmailStackedBarChartDisplay({
|
||||
};
|
||||
const colorVar = dataKey === "ok" ? "ok" : dataKey === "in_progress" ? "in_progress" : "error";
|
||||
return (
|
||||
<Bar key={dataKey} dataKey={dataKey} stackId="split" fill={`var(--color-${colorVar})`} radius={[4, 4, 0, 0]} isAnimationActive={false}>
|
||||
<Bar key={dataKey} dataKey={dataKey} stackId="split" fill={`var(--color-${colorVar})`} radius={[4, 4, 0, 0]} {...chartMotion}>
|
||||
{datapoints.map((entry, index) => {
|
||||
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
|
||||
const isActiveBar = hoveredIndex === index;
|
||||
@ -2182,7 +2279,7 @@ export function EmailStackedBarChartDisplay({
|
||||
strokeDasharray="2.5 3.5"
|
||||
dot={false}
|
||||
activeDot={false}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
connectNulls={false}
|
||||
legendType="none"
|
||||
/>
|
||||
@ -2195,7 +2292,7 @@ export function EmailStackedBarChartDisplay({
|
||||
strokeDasharray="2.5 3.5"
|
||||
dot={false}
|
||||
activeDot={{ r: 3.5, fill: "hsl(var(--foreground))", stroke: "hsl(var(--background))", strokeWidth: 1.5 }}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
connectNulls={false}
|
||||
legendType="none"
|
||||
/>
|
||||
@ -2318,6 +2415,7 @@ export function VisitorsHoverChart({
|
||||
compact?: boolean,
|
||||
}) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const chartMotion = useChartMotionProps();
|
||||
const windowSize = Math.max(4, Math.round(datapoints.length / 2.5));
|
||||
const totals = datapoints.map((p) => p.page_views);
|
||||
const avgValues = rollingAvg(totals, windowSize);
|
||||
@ -2363,7 +2461,7 @@ export function VisitorsHoverChart({
|
||||
allowEscapeViewBox={{ x: true, y: true }}
|
||||
wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }}
|
||||
/>
|
||||
<Bar dataKey="page_views" stackId="visitors" fill="var(--color-page_views)" radius={[4, 4, 0, 0]} isAnimationActive={false}>
|
||||
<Bar dataKey="page_views" stackId="visitors" fill="var(--color-page_views)" radius={[4, 4, 0, 0]} {...chartMotion}>
|
||||
{datapoints.map((entry, index) => {
|
||||
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
|
||||
const isActiveBar = hoveredIndex === index;
|
||||
@ -2386,7 +2484,7 @@ export function VisitorsHoverChart({
|
||||
strokeDasharray="2.5 3.5"
|
||||
dot={false}
|
||||
activeDot={false}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
connectNulls={false}
|
||||
legendType="none"
|
||||
/>
|
||||
@ -2399,7 +2497,7 @@ export function VisitorsHoverChart({
|
||||
strokeDasharray="2.5 3.5"
|
||||
dot={false}
|
||||
activeDot={{ r: 3.5, fill: "hsl(var(--foreground))", stroke: "hsl(var(--background))", strokeWidth: 1.5 }}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
connectNulls={false}
|
||||
legendType="none"
|
||||
/>
|
||||
@ -2528,6 +2626,7 @@ export function RevenueHoverChart({
|
||||
compact?: boolean,
|
||||
}) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
const chartMotion = useChartMotionProps();
|
||||
const windowSize = Math.max(4, Math.round(datapoints.length / 2.5));
|
||||
const totals = datapoints.map((p) => p.new_cents + p.refund_cents);
|
||||
const avgValues = rollingAvg(totals, windowSize);
|
||||
@ -2578,7 +2677,7 @@ export function RevenueHoverChart({
|
||||
allowEscapeViewBox={{ x: true, y: true }}
|
||||
wrapperStyle={{ zIndex: 9999, pointerEvents: 'none' }}
|
||||
/>
|
||||
<Bar dataKey="new_cents_square" stackId="revenue" fill="var(--color-new_cents)" radius={[0, 0, 0, 0]} isAnimationActive={false}>
|
||||
<Bar dataKey="new_cents_square" stackId="revenue" fill="var(--color-new_cents)" radius={[0, 0, 0, 0]} {...chartMotion}>
|
||||
{datapoints.map((entry, index) => {
|
||||
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
|
||||
const isActiveBar = hoveredIndex === index;
|
||||
@ -2592,7 +2691,7 @@ export function RevenueHoverChart({
|
||||
);
|
||||
})}
|
||||
</Bar>
|
||||
<Bar dataKey="new_cents_rounded" stackId="revenue" fill="var(--color-new_cents)" radius={[4, 4, 0, 0]} isAnimationActive={false}>
|
||||
<Bar dataKey="new_cents_rounded" stackId="revenue" fill="var(--color-new_cents)" radius={[4, 4, 0, 0]} {...chartMotion}>
|
||||
{datapoints.map((entry, index) => {
|
||||
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
|
||||
const isActiveBar = hoveredIndex === index;
|
||||
@ -2606,7 +2705,7 @@ export function RevenueHoverChart({
|
||||
);
|
||||
})}
|
||||
</Bar>
|
||||
<Bar dataKey="refund_cents" stackId="revenue" fill="var(--color-refund_cents)" radius={[4, 4, 0, 0]} isAnimationActive={false}>
|
||||
<Bar dataKey="refund_cents" stackId="revenue" fill="var(--color-refund_cents)" radius={[4, 4, 0, 0]} {...chartMotion}>
|
||||
{datapoints.map((entry, index) => {
|
||||
const baseOpacity = isWeekend(parseChartDate(entry.date)) ? 0.5 : 1;
|
||||
const isActiveBar = hoveredIndex === index;
|
||||
@ -2629,7 +2728,7 @@ export function RevenueHoverChart({
|
||||
strokeDasharray="2.5 3.5"
|
||||
dot={false}
|
||||
activeDot={false}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
connectNulls={false}
|
||||
legendType="none"
|
||||
/>
|
||||
@ -2642,7 +2741,7 @@ export function RevenueHoverChart({
|
||||
strokeDasharray="2.5 3.5"
|
||||
dot={false}
|
||||
activeDot={{ r: 3.5, fill: "hsl(var(--foreground))", stroke: "hsl(var(--background))", strokeWidth: 1.5 }}
|
||||
isAnimationActive={false}
|
||||
{...chartMotion}
|
||||
connectNulls={false}
|
||||
legendType="none"
|
||||
/>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -15,7 +15,7 @@ export default function PageClient() {
|
||||
return <SetupPage toMetrics={() => setPage('metrics')} />;
|
||||
}
|
||||
case 'metrics': {
|
||||
return <MetricsPage toSetup={() => setPage('setup')} />;
|
||||
return <MetricsPage />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,431 @@
|
||||
'use client';
|
||||
|
||||
import { DesignAnalyticsCard, useInfiniteListWindow } from "@/components/design-components";
|
||||
import { Link } from "@/components/link";
|
||||
import { SimpleTooltip, Typography } from "@/components/ui";
|
||||
import { type AppId } from "@/lib/apps-frontend";
|
||||
import { type MetricsNamedCount, type MetricsTopReferrer } from "@/lib/hexclave-app-internals";
|
||||
import { GlobeIcon } from "@phosphor-icons/react";
|
||||
import type { Icon } from "@phosphor-icons/react";
|
||||
import { stringCompare } from "@hexclave/shared/dist/utils/strings";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { easeOutCubic, prefersReducedMotion } from "./animation-utils";
|
||||
|
||||
const TOP_LIST_ANIMATION_MS = 260;
|
||||
|
||||
function useAnimatedBarValues(rows: Array<{ id: string, value: number }>): Map<string, number> {
|
||||
const [animatedValues, setAnimatedValues] = useState(() => new Map(rows.map((row) => [row.id, row.value])));
|
||||
const previousValuesRef = useRef(animatedValues);
|
||||
|
||||
useEffect(() => {
|
||||
const nextValues = new Map(rows.map((row) => [row.id, row.value]));
|
||||
if (prefersReducedMotion()) {
|
||||
previousValuesRef.current = nextValues;
|
||||
setAnimatedValues(nextValues);
|
||||
return;
|
||||
}
|
||||
|
||||
const previousValues = previousValuesRef.current;
|
||||
const startedAt = performance.now();
|
||||
let frameId: number | null = null;
|
||||
|
||||
const renderFrame = (now: number) => {
|
||||
const linearProgress = Math.min(1, (now - startedAt) / TOP_LIST_ANIMATION_MS);
|
||||
const progress = easeOutCubic(linearProgress);
|
||||
setAnimatedValues(new Map(rows.map((row) => {
|
||||
const previous = previousValues.get(row.id) ?? 0;
|
||||
return [row.id, previous + (row.value - previous) * progress];
|
||||
})));
|
||||
|
||||
if (linearProgress < 1) {
|
||||
frameId = requestAnimationFrame(renderFrame);
|
||||
return;
|
||||
}
|
||||
|
||||
previousValuesRef.current = nextValues;
|
||||
setAnimatedValues(nextValues);
|
||||
};
|
||||
|
||||
frameId = requestAnimationFrame(renderFrame);
|
||||
return () => {
|
||||
if (frameId != null) {
|
||||
cancelAnimationFrame(frameId);
|
||||
}
|
||||
};
|
||||
}, [rows]);
|
||||
|
||||
return animatedValues;
|
||||
}
|
||||
|
||||
export function getReferrerHost(referrer: string): string | null {
|
||||
if (!referrer) return null;
|
||||
try {
|
||||
const url = new URL(/^https?:\/\//i.test(referrer) ? referrer : `https://${referrer}`);
|
||||
const host = url.hostname.toLowerCase();
|
||||
if (!host || !host.includes(".")) return null;
|
||||
return host;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function ReferrerFavicon({ host }: { host: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (failed) {
|
||||
return <span aria-hidden className="h-4 w-4 shrink-0 rounded-sm bg-foreground/[0.06]" />;
|
||||
}
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=32`}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => setFailed(true)}
|
||||
className="h-4 w-4 shrink-0 rounded-sm object-contain"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CountryFlag({ code }: { code: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const lower = code.toLowerCase();
|
||||
if (failed || !/^[a-z]{2}$/.test(lower)) {
|
||||
return (
|
||||
<span aria-hidden className="inline-flex h-4 w-5 shrink-0 items-center justify-center rounded-sm bg-foreground/[0.06] text-[9px] font-semibold tabular-nums text-muted-foreground">
|
||||
{code.toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`https://flagcdn.com/w40/${lower}.png`}
|
||||
srcSet={`https://flagcdn.com/w40/${lower}.png 1x, https://flagcdn.com/w80/${lower}.png 2x`}
|
||||
alt=""
|
||||
width={20}
|
||||
height={15}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => setFailed(true)}
|
||||
className="h-[15px] w-5 shrink-0 rounded-[2px] object-cover ring-1 ring-black/[0.08] dark:ring-white/[0.08]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function regionName(code: string): string {
|
||||
try {
|
||||
// Use a fixed locale so server and client render identical region names; the
|
||||
// dashboard UI is English-only, and navigator.language would cause hydration
|
||||
// mismatches for non-English users.
|
||||
const dn = new Intl.DisplayNames(["en"], { type: "region" });
|
||||
return dn.of(code.toUpperCase()) ?? code;
|
||||
} catch {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
function SetupAppPromptInline({
|
||||
projectId,
|
||||
appId,
|
||||
appLabel,
|
||||
metricLabel,
|
||||
}: {
|
||||
projectId: string,
|
||||
appId: AppId,
|
||||
appLabel: string,
|
||||
metricLabel: string,
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full items-center justify-center px-4 py-4">
|
||||
<div className="flex max-w-sm flex-col items-center gap-2 text-center">
|
||||
<Typography variant="secondary" className="text-xs">
|
||||
Enable{" "}
|
||||
<span className="font-semibold text-foreground">{appLabel}</span>{" "}
|
||||
in Explore Apps to track {metricLabel}.
|
||||
</Typography>
|
||||
<Link
|
||||
href={`/projects/${projectId}/apps/${appId}`}
|
||||
className="inline-flex items-center rounded-md bg-foreground/[0.08] px-3 py-1.5 text-[11px] font-medium text-foreground transition-colors duration-150 hover:bg-foreground/[0.12] hover:transition-none"
|
||||
>
|
||||
Open Explore Apps
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReferrersWithAnalyticsCard({
|
||||
topReferrers,
|
||||
analyticsEnabled,
|
||||
projectId,
|
||||
onSelectReferrer,
|
||||
selectedReferrer,
|
||||
headerTooltip,
|
||||
}: {
|
||||
topReferrers: MetricsTopReferrer[],
|
||||
analyticsEnabled: boolean,
|
||||
projectId: string,
|
||||
onSelectReferrer?: (referrer: string) => void,
|
||||
selectedReferrer?: string,
|
||||
headerTooltip?: string,
|
||||
}) {
|
||||
const listWindow = useInfiniteListWindow(topReferrers.length);
|
||||
const referrerBarRows = useMemo(
|
||||
() => topReferrers.map((item) => ({ id: item.referrer, value: item.visitors })),
|
||||
[topReferrers],
|
||||
);
|
||||
const animatedVisitorsByReferrer = useAnimatedBarValues(referrerBarRows);
|
||||
const max = topReferrers.length > 0 ? topReferrers[0].visitors : 0;
|
||||
|
||||
return (
|
||||
<DesignAnalyticsCard gradient="purple" className="h-full" chart={{ type: "none", tooltipType: "none", highlightMode: "none" }}>
|
||||
<div className="px-4 py-3 border-b border-foreground/[0.05]">
|
||||
<SimpleTooltip tooltip={headerTooltip} inline className="w-fit">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Top Referrers</span>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
<div ref={listWindow.scrollRef} className="p-4 pt-3 flex-1 min-h-0 max-h-[320px] overflow-y-auto flex flex-col gap-2">
|
||||
{!analyticsEnabled ? (
|
||||
<SetupAppPromptInline projectId={projectId} appId="analytics" appLabel="Analytics" metricLabel="referrer metrics" />
|
||||
) : topReferrers.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Typography variant="secondary" className="text-xs">No referrer data</Typography>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{topReferrers.slice(0, listWindow.visibleCount).map((item) => {
|
||||
const host = getReferrerHost(item.referrer);
|
||||
const clickable = onSelectReferrer != null;
|
||||
const isSelected = selectedReferrer === item.referrer;
|
||||
const animatedVisitors = animatedVisitorsByReferrer.get(item.referrer) ?? item.visitors;
|
||||
return (
|
||||
<div
|
||||
key={item.referrer}
|
||||
role={clickable ? "button" : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
onClick={clickable ? () => onSelectReferrer(item.referrer) : undefined}
|
||||
onKeyDown={clickable ? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelectReferrer(item.referrer);
|
||||
}
|
||||
} : undefined}
|
||||
className={`relative flex items-center justify-between rounded-lg px-2.5 py-1.5 overflow-hidden ${
|
||||
clickable ? "cursor-pointer transition-colors hover:bg-foreground/[0.04]" : ""
|
||||
} ${isSelected ? "ring-1 ring-purple-500/40" : ""}`}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-lg bg-purple-500/10 dark:bg-purple-400/10"
|
||||
style={{ width: max > 0 ? `${(animatedVisitors / max) * 100}%` : '0%' }}
|
||||
/>
|
||||
<span className="relative flex items-center gap-2 min-w-0 max-w-[70%]">
|
||||
{host ? (
|
||||
<ReferrerFavicon host={host} />
|
||||
) : (
|
||||
<span aria-hidden className="h-4 w-4 shrink-0 rounded-sm bg-foreground/[0.06]" />
|
||||
)}
|
||||
<span className="text-[11px] text-foreground truncate">{item.referrer}</span>
|
||||
</span>
|
||||
<span className="relative text-[11px] font-medium text-foreground tabular-nums">{item.visitors.toLocaleString()}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{listWindow.hasMore && (
|
||||
<div ref={listWindow.sentinelRef} className="py-2 text-center">
|
||||
<Typography variant="secondary" className="text-[10px]">
|
||||
Loading more...
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DesignAnalyticsCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopRegionsCard({
|
||||
usersByCountry,
|
||||
onSelectCountry,
|
||||
selectedCountry,
|
||||
headerTooltip,
|
||||
}: {
|
||||
usersByCountry: Record<string, number>,
|
||||
onSelectCountry?: (code: string) => void,
|
||||
selectedCountry?: string,
|
||||
headerTooltip?: string,
|
||||
}) {
|
||||
const entries = useMemo(
|
||||
() => Object.entries(usersByCountry)
|
||||
.filter(([code, count]) => code && Number.isFinite(count) && count > 0)
|
||||
.map(([code, count]) => ({ code: code.toUpperCase(), count }))
|
||||
.sort((a, b) => b.count - a.count || stringCompare(a.code, b.code)),
|
||||
[usersByCountry],
|
||||
);
|
||||
|
||||
const listWindow = useInfiniteListWindow(entries.length);
|
||||
const max = entries.length > 0 ? entries[0].count : 0;
|
||||
const regionBarRows = useMemo(
|
||||
() => entries.map((item) => ({ id: item.code, value: item.count })),
|
||||
[entries],
|
||||
);
|
||||
const animatedCountsByCode = useAnimatedBarValues(regionBarRows);
|
||||
|
||||
return (
|
||||
<DesignAnalyticsCard gradient="blue" className="h-full" chart={{ type: "none", tooltipType: "none", highlightMode: "none" }}>
|
||||
<div className="flex items-center gap-2 border-b border-foreground/[0.05] px-4 py-3">
|
||||
<GlobeIcon className="h-3.5 w-3.5 text-muted-foreground" weight="fill" />
|
||||
<SimpleTooltip tooltip={headerTooltip} inline className="w-fit">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Top Regions</span>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
<div ref={listWindow.scrollRef} className="p-4 pt-3 flex-1 min-h-0 max-h-[320px] overflow-y-auto flex flex-col gap-2">
|
||||
{entries.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Typography variant="secondary" className="text-xs">No region data</Typography>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{entries.slice(0, listWindow.visibleCount).map((item) => {
|
||||
const clickable = onSelectCountry != null;
|
||||
const isSelected = selectedCountry === item.code;
|
||||
const animatedCount = animatedCountsByCode.get(item.code) ?? item.count;
|
||||
return (
|
||||
<div
|
||||
key={item.code}
|
||||
role={clickable ? "button" : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
onClick={clickable ? () => onSelectCountry(item.code) : undefined}
|
||||
onKeyDown={clickable ? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelectCountry(item.code);
|
||||
}
|
||||
} : undefined}
|
||||
className={`relative flex items-center justify-between rounded-lg px-2.5 py-1.5 overflow-hidden ${
|
||||
clickable ? "cursor-pointer transition-colors hover:bg-foreground/[0.04]" : ""
|
||||
} ${isSelected ? "ring-1 ring-blue-500/40" : ""}`}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-lg bg-blue-500/10 dark:bg-blue-400/10"
|
||||
style={{ width: max > 0 ? `${(animatedCount / max) * 100}%` : '0%' }}
|
||||
/>
|
||||
<span className="relative flex items-center gap-2 min-w-0 max-w-[70%]">
|
||||
<CountryFlag code={item.code} />
|
||||
<span className="text-[11px] text-foreground truncate">{regionName(item.code)}</span>
|
||||
</span>
|
||||
<span className="relative text-[11px] font-medium text-foreground tabular-nums">{item.count.toLocaleString()}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{listWindow.hasMore && (
|
||||
<div ref={listWindow.sentinelRef} className="py-2 text-center">
|
||||
<Typography variant="secondary" className="text-[10px]">
|
||||
Loading more...
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DesignAnalyticsCard>
|
||||
);
|
||||
}
|
||||
|
||||
// Generic top-N named-count card. Reused for Browsers / Operating Systems /
|
||||
// Devices on the analytics overview so the three breakdowns share the same
|
||||
// row layout, bar tint, and empty-state behavior as the referrer card.
|
||||
export function TopNamedListCard({
|
||||
title,
|
||||
items,
|
||||
gradient,
|
||||
barClassName,
|
||||
Icon: HeaderIcon,
|
||||
emptyLabel,
|
||||
getRowIcon,
|
||||
onSelectItem,
|
||||
selectedItem,
|
||||
headerTooltip,
|
||||
}: {
|
||||
title: string,
|
||||
items: MetricsNamedCount[],
|
||||
gradient: "purple" | "blue" | "cyan" | "green" | "orange" | "slate",
|
||||
barClassName: string,
|
||||
Icon: Icon,
|
||||
emptyLabel: string,
|
||||
getRowIcon?: (name: string) => React.ReactNode,
|
||||
onSelectItem?: (name: string) => void,
|
||||
selectedItem?: string,
|
||||
headerTooltip?: string,
|
||||
}) {
|
||||
const listWindow = useInfiniteListWindow(items.length);
|
||||
const max = items.length > 0 ? items[0].visitors : 0;
|
||||
const namedBarRows = useMemo(
|
||||
() => items.map((item) => ({ id: item.name, value: item.visitors })),
|
||||
[items],
|
||||
);
|
||||
const animatedVisitorsByName = useAnimatedBarValues(namedBarRows);
|
||||
|
||||
return (
|
||||
<DesignAnalyticsCard gradient={gradient} className="h-full" chart={{ type: "none", tooltipType: "none", highlightMode: "none" }}>
|
||||
<div className="flex items-center gap-2 border-b border-foreground/[0.05] px-4 py-3">
|
||||
<HeaderIcon className="h-3.5 w-3.5 text-muted-foreground" weight="fill" />
|
||||
<SimpleTooltip tooltip={headerTooltip} inline className="w-fit">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">{title}</span>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
<div ref={listWindow.scrollRef} className="p-4 pt-3 flex-1 min-h-0 max-h-[320px] overflow-y-auto flex flex-col gap-2">
|
||||
{items.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Typography variant="secondary" className="text-xs">{emptyLabel}</Typography>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{items.slice(0, listWindow.visibleCount).map((item) => {
|
||||
const clickable = onSelectItem != null;
|
||||
const isSelected = selectedItem === item.name;
|
||||
const animatedVisitors = animatedVisitorsByName.get(item.name) ?? item.visitors;
|
||||
return (
|
||||
<div
|
||||
key={item.name}
|
||||
role={clickable ? "button" : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
onClick={clickable ? () => onSelectItem(item.name) : undefined}
|
||||
onKeyDown={clickable ? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelectItem(item.name);
|
||||
}
|
||||
} : undefined}
|
||||
className={`relative flex items-center justify-between rounded-lg px-2.5 py-1.5 overflow-hidden ${
|
||||
clickable ? "cursor-pointer transition-colors hover:bg-foreground/[0.04]" : ""
|
||||
} ${isSelected ? "ring-1 ring-foreground/30" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute inset-y-0 left-0 rounded-lg ${barClassName}`}
|
||||
style={{ width: max > 0 ? `${(animatedVisitors / max) * 100}%` : '0%' }}
|
||||
/>
|
||||
<span className="relative flex items-center gap-2 min-w-0 max-w-[70%]">
|
||||
{getRowIcon?.(item.name)}
|
||||
<span className="text-[11px] text-foreground truncate">{item.name}</span>
|
||||
</span>
|
||||
<span className="relative text-[11px] font-medium text-foreground tabular-nums">{item.visitors.toLocaleString()}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{listWindow.hasMore && (
|
||||
<div ref={listWindow.sentinelRef} className="py-2 text-center">
|
||||
<Typography variant="secondary" className="text-[10px]">
|
||||
Loading more...
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DesignAnalyticsCard>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata = {
|
||||
title: "Analytics Overview",
|
||||
};
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ projectId: string }> }) {
|
||||
const { projectId } = await params;
|
||||
redirect(`/projects/${encodeURIComponent(projectId)}`);
|
||||
}
|
||||
@ -9,6 +9,7 @@ export function PageLayout(props: {
|
||||
fillWidth?: boolean,
|
||||
noPadding?: boolean,
|
||||
allowContentOverflow?: boolean,
|
||||
containedHeight?: boolean,
|
||||
fullBleed?: boolean,
|
||||
wrapHeaderInCard?: boolean,
|
||||
} & ({
|
||||
@ -19,6 +20,7 @@ export function PageLayout(props: {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-1 min-h-0 flex-col", !props.noPadding && "py-4 px-4 sm:py-6 sm:px-6")}
|
||||
data-contained-height={props.containedHeight ? "true" : undefined}
|
||||
data-full-bleed={props.fullBleed ? "true" : undefined}
|
||||
>
|
||||
<div
|
||||
|
||||
@ -72,6 +72,11 @@ type RecordingRow = {
|
||||
eventCount: number,
|
||||
};
|
||||
|
||||
type RecordingListRow = {
|
||||
replay: RecordingRow,
|
||||
key: string,
|
||||
};
|
||||
|
||||
type ChunkRow = {
|
||||
id: string,
|
||||
batchId: string,
|
||||
@ -515,6 +520,10 @@ export default function PageClient({ initialReplayId, lockedUserId }: PageClient
|
||||
?? (standaloneReplay?.id === selectedRecordingId ? standaloneReplay : null),
|
||||
[recordings, selectedRecordingId, standaloneReplay],
|
||||
);
|
||||
const recordingListRows = useMemo<RecordingListRow[]>(
|
||||
() => recordings.map((replay) => ({ replay, key: replay.id })),
|
||||
[recordings],
|
||||
);
|
||||
|
||||
const hasAutoSelectedRef = useRef(false);
|
||||
const loadingMoreRef = useRef(false);
|
||||
@ -1474,18 +1483,19 @@ export default function PageClient({ initialReplayId, lockedUserId }: PageClient
|
||||
) : undefined}
|
||||
fillWidth
|
||||
noPadding
|
||||
containedHeight
|
||||
>
|
||||
<div className={cn("flex min-h-0 flex-1 flex-col gap-3", !isEmbedded && "px-4 sm:px-6")}>
|
||||
<SessionReplayLimitBanner />
|
||||
<PanelGroup data-walkthrough="analytics-replays" direction="horizontal" className={replaysPanelShellClass}>
|
||||
{!isStandaloneReplayPage && (
|
||||
<>
|
||||
<Panel defaultSize={25} minSize={16}>
|
||||
<div className="flex h-full flex-col bg-zinc-50 dark:bg-transparent">
|
||||
<Panel defaultSize={25} minSize={16} className="min-h-0">
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-zinc-50 dark:bg-transparent">
|
||||
<div className={replaysListChromeClass}>
|
||||
<div className="flex items-center justify-between gap-2 h-8">
|
||||
<Typography className="text-sm font-medium">
|
||||
Sessions{!loadingInitial && recordings.length > 0 ? ` (${recordings.length}${nextCursor ? "+" : ""})` : ""}
|
||||
Sessions{!loadingInitial && recordingListRows.length > 0 ? ` (${recordingListRows.length}${nextCursor ? "+" : ""})` : ""}
|
||||
</Typography>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@ -1761,7 +1771,7 @@ export default function PageClient({ initialReplayId, lockedUserId }: PageClient
|
||||
<div
|
||||
ref={listBoxRef}
|
||||
onScroll={onListScroll}
|
||||
className="flex-1 overflow-y-auto"
|
||||
className="flex-1 min-h-0 overflow-y-auto"
|
||||
>
|
||||
{loadingInitial ? (
|
||||
<div className="p-2 space-y-1">
|
||||
@ -1780,13 +1790,13 @@ export default function PageClient({ initialReplayId, lockedUserId }: PageClient
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1.5 space-y-0.5">
|
||||
{recordings.map((r) => {
|
||||
{recordingListRows.map(({ replay: r, key }) => {
|
||||
const isSelected = r.id === selectedRecordingId;
|
||||
const durationMs = r.lastEventAt.getTime() - r.startedAt.getTime();
|
||||
const duration = formatDurationMs(durationMs);
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
key={key}
|
||||
className={cn(
|
||||
"rounded-lg",
|
||||
isSelected
|
||||
@ -1839,8 +1849,8 @@ export default function PageClient({ initialReplayId, lockedUserId }: PageClient
|
||||
</>
|
||||
)}
|
||||
|
||||
<Panel defaultSize={isStandaloneReplayPage ? 100 : 75} minSize={35}>
|
||||
<div className="h-full flex flex-col bg-white dark:bg-background">
|
||||
<Panel defaultSize={isStandaloneReplayPage ? 100 : 75} minSize={35} className="min-h-0">
|
||||
<div className="h-full min-h-0 flex flex-col overflow-hidden bg-white dark:bg-background">
|
||||
{(standaloneReplayError || ms.downloadError || ms.playerError) && (
|
||||
<div className="p-3 space-y-2">
|
||||
{standaloneReplayError && <Alert variant="destructive">{standaloneReplayError}</Alert>}
|
||||
@ -1894,8 +1904,8 @@ export default function PageClient({ initialReplayId, lockedUserId }: PageClient
|
||||
</div>
|
||||
|
||||
{selectedRecordingId ? (
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
<div
|
||||
className="grid flex-1 grid-cols-[minmax(0,1fr)_260px] gap-px overflow-hidden bg-black/[0.06] dark:bg-border/40"
|
||||
style={{
|
||||
|
||||
@ -737,7 +737,7 @@ export default function SidebarLayout(props: { children?: React.ReactNode }) {
|
||||
<SpotlightSearchWrapper projectId={projectId} />
|
||||
|
||||
{/* Body Layout (Left Sidebar + Content + Right Companion) */}
|
||||
<div className="relative flex flex-1 items-start w-full">
|
||||
<div className="relative flex flex-1 items-start w-full has-[[data-contained-height]]:min-h-0 has-[[data-contained-height]]:items-stretch">
|
||||
{/* Left Sidebar - Sticky */}
|
||||
<aside
|
||||
className={cn(
|
||||
@ -753,13 +753,15 @@ export default function SidebarLayout(props: { children?: React.ReactNode }) {
|
||||
</aside>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 min-w-0 pt-1 pb-3 px-3 lg:pl-0 lg:pr-24 dark:py-0 dark:px-2 dark:pb-3 dark:lg:pr-24">
|
||||
<main className="flex-1 min-w-0 pt-1 pb-3 px-3 lg:pl-0 lg:pr-24 dark:py-0 dark:px-2 dark:pb-3 dark:lg:pr-24 has-[[data-contained-height]]:flex has-[[data-contained-height]]:min-h-0 has-[[data-contained-height]]:flex-col">
|
||||
<div className={cn(
|
||||
"relative flex min-w-0 flex-col overflow-visible has-[[data-full-bleed]]:h-full",
|
||||
// Light mode card styling (companion gutter is on <main>, not here — avoids empty card chrome behind Stack Companion)
|
||||
"min-h-[calc(100vh-4.5rem)] bg-white/80 backdrop-blur-xl shadow-[0_4px_24px_rgba(0,0,0,0.06),0_1px_4px_rgba(0,0,0,0.04)] rounded-2xl border border-black/[0.06]",
|
||||
// Dark mode: remove card styling
|
||||
"dark:bg-transparent dark:backdrop-blur-none dark:shadow-none dark:rounded-none dark:border-0",
|
||||
// Contained pages own their internal scroll regions, so the shell must pass down a finite flex height instead of sizing to content.
|
||||
"has-[[data-contained-height]]:flex-1 has-[[data-contained-height]]:min-h-0 has-[[data-contained-height]]:overflow-hidden",
|
||||
// Full-bleed pages (email editors etc.): remove card styling in light mode too
|
||||
"has-[[data-full-bleed]]:min-h-0 has-[[data-full-bleed]]:bg-transparent has-[[data-full-bleed]]:backdrop-blur-none has-[[data-full-bleed]]:shadow-none has-[[data-full-bleed]]:rounded-none has-[[data-full-bleed]]:border-0",
|
||||
)}>
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { DesignAnalyticsCard, type AnalyticsCardGradient } from "@/components/design-components";
|
||||
import { SimpleTooltip } from "@/components/ui";
|
||||
|
||||
type UserPageMetricCardDelta = {
|
||||
current: number,
|
||||
@ -15,6 +16,7 @@ type UserPageMetricCardSpark = {
|
||||
|
||||
type UserPageMetricCardProps = {
|
||||
label: string,
|
||||
tooltip?: string,
|
||||
value: string | number,
|
||||
description: string,
|
||||
gradient: AnalyticsCardGradient,
|
||||
@ -48,27 +50,162 @@ const GRADIENT_STROKE: Record<AnalyticsCardGradient, string> = {
|
||||
slate: "rgb(100 116 139)",
|
||||
};
|
||||
|
||||
function Sparkline({ values, color }: { values: number[], color: string }) {
|
||||
const gradId = `metric-spark-${useId()}`;
|
||||
if (values.length < 2) return null;
|
||||
const w = 100;
|
||||
const h = 32;
|
||||
const SPARKLINE_WIDTH = 100;
|
||||
const SPARKLINE_HEIGHT = 32;
|
||||
const SPARKLINE_PLOT_HEIGHT = SPARKLINE_HEIGHT - 2;
|
||||
const SPARKLINE_BASELINE = SPARKLINE_HEIGHT - 1;
|
||||
const SPARKLINE_ANIMATION_MS = 520;
|
||||
const sparklineRestState = {
|
||||
transform: "translate(0px, 0px) scale(1, 1)",
|
||||
opacity: 1,
|
||||
transitionEnabled: true,
|
||||
};
|
||||
|
||||
type SparklineGeometry = {
|
||||
valuesKey: string,
|
||||
linePath: string,
|
||||
areaPath: string,
|
||||
min: number,
|
||||
range: number,
|
||||
pointCount: number,
|
||||
};
|
||||
|
||||
type SparklineMotionState = {
|
||||
transform: string,
|
||||
opacity: number,
|
||||
transitionEnabled: boolean,
|
||||
};
|
||||
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const updatePrefersReducedMotion = () => setPrefersReducedMotion(mediaQuery.matches);
|
||||
|
||||
updatePrefersReducedMotion();
|
||||
mediaQuery.addEventListener("change", updatePrefersReducedMotion);
|
||||
return () => mediaQuery.removeEventListener("change", updatePrefersReducedMotion);
|
||||
}, []);
|
||||
|
||||
return prefersReducedMotion;
|
||||
}
|
||||
|
||||
function clampNumber(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function parseSparklineValues(valuesKey: string): number[] {
|
||||
if (valuesKey.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return valuesKey.split(",").map((value) => Number(value));
|
||||
}
|
||||
|
||||
function getSparklineGeometry(valuesKey: string): SparklineGeometry | null {
|
||||
const values = parseSparklineValues(valuesKey);
|
||||
if (values.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const max = Math.max(...values);
|
||||
const min = Math.min(...values);
|
||||
const flat = max === min;
|
||||
const range = flat ? 1 : max - min;
|
||||
const step = w / (values.length - 1);
|
||||
const step = SPARKLINE_WIDTH / (values.length - 1);
|
||||
const coords = values.map((v, i) => {
|
||||
const x = i * step;
|
||||
// Reserve 1px top/bottom so the stroke isn't clipped.
|
||||
const y = flat ? h / 2 : h - 1 - ((v - min) / range) * (h - 2);
|
||||
const y = flat ? SPARKLINE_HEIGHT / 2 : SPARKLINE_BASELINE - ((v - min) / range) * SPARKLINE_PLOT_HEIGHT;
|
||||
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
});
|
||||
const linePath = `M${coords.join(" L")}`;
|
||||
const areaPath = `${linePath} L${w},${h} L0,${h} Z`;
|
||||
const areaPath = `${linePath} L${SPARKLINE_WIDTH},${SPARKLINE_HEIGHT} L0,${SPARKLINE_HEIGHT} Z`;
|
||||
|
||||
return {
|
||||
valuesKey,
|
||||
linePath,
|
||||
areaPath,
|
||||
min,
|
||||
range,
|
||||
pointCount: values.length,
|
||||
};
|
||||
}
|
||||
|
||||
function getInitialSparklineMotion(previous: SparklineGeometry, current: SparklineGeometry): SparklineMotionState {
|
||||
if (previous.pointCount !== current.pointCount) {
|
||||
return {
|
||||
transform: "translate(0px, 3px) scale(0.98, 0.94)",
|
||||
opacity: 0.72,
|
||||
transitionEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
const scaleY = clampNumber(current.range / previous.range, 0.35, 2.4);
|
||||
const rawTranslateY = SPARKLINE_BASELINE
|
||||
- scaleY * SPARKLINE_BASELINE
|
||||
- ((current.min - previous.min) / previous.range) * SPARKLINE_PLOT_HEIGHT;
|
||||
const translateY = clampNumber(rawTranslateY, -SPARKLINE_HEIGHT, SPARKLINE_HEIGHT);
|
||||
|
||||
return {
|
||||
transform: `translate(0px, ${translateY.toFixed(2)}px) scale(1, ${scaleY.toFixed(4)})`,
|
||||
opacity: 0.88,
|
||||
transitionEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
function useSparklineMotion(geometry: SparklineGeometry | null): SparklineMotionState {
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
const previousGeometryRef = useRef<SparklineGeometry | null>(null);
|
||||
const [motionState, setMotionState] = useState<SparklineMotionState>(sparklineRestState);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (geometry == null) {
|
||||
previousGeometryRef.current = null;
|
||||
setMotionState(sparklineRestState);
|
||||
return;
|
||||
}
|
||||
|
||||
const previousGeometry = previousGeometryRef.current;
|
||||
previousGeometryRef.current = geometry;
|
||||
|
||||
if (previousGeometry == null || previousGeometry.valuesKey === geometry.valuesKey || prefersReducedMotion) {
|
||||
setMotionState(sparklineRestState);
|
||||
return;
|
||||
}
|
||||
|
||||
setMotionState(getInitialSparklineMotion(previousGeometry, geometry));
|
||||
const frameId = requestAnimationFrame(() => setMotionState(sparklineRestState));
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
}, [geometry, prefersReducedMotion]);
|
||||
|
||||
return motionState;
|
||||
}
|
||||
|
||||
function Sparkline({ values, color }: { values: number[], color: string }) {
|
||||
const gradId = `metric-spark-${useId()}`;
|
||||
const valuesKey = values.join(",");
|
||||
const geometry = useMemo(() => getSparklineGeometry(valuesKey), [valuesKey]);
|
||||
const motionState = useSparklineMotion(geometry);
|
||||
const motionStyle: CSSProperties = {
|
||||
transform: motionState.transform,
|
||||
transformBox: "view-box",
|
||||
transformOrigin: "left top",
|
||||
transition: motionState.transitionEnabled
|
||||
? `transform ${SPARKLINE_ANIMATION_MS}ms ease-out, opacity 180ms ease-out`
|
||||
: "none",
|
||||
opacity: motionState.opacity,
|
||||
};
|
||||
|
||||
if (geometry == null) return null;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
viewBox={`0 0 ${SPARKLINE_WIDTH} ${SPARKLINE_HEIGHT}`}
|
||||
preserveAspectRatio="none"
|
||||
className="h-8 w-full"
|
||||
aria-hidden
|
||||
@ -79,22 +216,25 @@ function Sparkline({ values, color }: { values: number[], color: string }) {
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={areaPath} fill={`url(#${gradId})`} />
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={1.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<g style={motionStyle}>
|
||||
<path d={geometry.areaPath} fill={`url(#${gradId})`} />
|
||||
<path
|
||||
d={geometry.linePath}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={1.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserPageMetricCard({
|
||||
label,
|
||||
tooltip,
|
||||
value,
|
||||
description,
|
||||
gradient,
|
||||
@ -104,6 +244,11 @@ export function UserPageMetricCard({
|
||||
const deltaInfo = delta ? formatDelta(delta) : null;
|
||||
const strokeColor = GRADIENT_STROKE[gradient];
|
||||
const showSpark = spark != null && spark.values.length >= 2;
|
||||
const labelNode = (
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider leading-tight">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<DesignAnalyticsCard
|
||||
@ -112,9 +257,11 @@ export function UserPageMetricCard({
|
||||
>
|
||||
<div className="flex flex-col gap-2 px-4 py-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider leading-tight">
|
||||
{label}
|
||||
</span>
|
||||
{tooltip == null ? labelNode : (
|
||||
<SimpleTooltip tooltip={tooltip} inline className="w-fit">
|
||||
{labelNode}
|
||||
</SimpleTooltip>
|
||||
)}
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
<span className="text-xl font-bold tabular-nums text-foreground leading-none">
|
||||
{value}
|
||||
|
||||
@ -24,15 +24,19 @@ export function SimpleTooltip(props: {
|
||||
<>{icon}{props.children}</>
|
||||
);
|
||||
|
||||
// Radix only opens tooltips on focus if the trigger is focusable — without
|
||||
// a tab stop the tooltip content is unreachable by keyboard.
|
||||
const triggerTabIndex = props.tooltip && !props.disabled ? 0 : undefined;
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={0} open={props.disabled ? false : undefined} disableHoverableContent={false}>
|
||||
<TooltipTrigger asChild>
|
||||
{props.inline ? (
|
||||
<span className={cn(props.className)}>
|
||||
<span tabIndex={triggerTabIndex} className={cn(props.className)}>
|
||||
{trigger}
|
||||
</span>
|
||||
) : (
|
||||
<div className={cn("flex items-center gap-1", props.className)}>
|
||||
<div tabIndex={triggerTabIndex} className={cn("flex items-center gap-1", props.className)}>
|
||||
{trigger}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -5,9 +5,14 @@ import { nicify } from "@hexclave/shared/dist/utils/strings";
|
||||
import "./polyfills";
|
||||
|
||||
export async function register() {
|
||||
if (getNextRuntime() === "nodejs") {
|
||||
// Next.js builds instrumentation for both Node.js and Edge. Keep the runtime
|
||||
// check inline so the Edge bundle does not follow this Node-only import.
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
if (getEnvBoolean("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT")) {
|
||||
globalThis.process.title = `Hexclave — Development Server (port ${getEnvVariable("PORT", "?")})`;
|
||||
|
||||
const { startRemoteDevelopmentEnvironmentLifecycle } = await import("./lib/remote-development-environment/manager");
|
||||
startRemoteDevelopmentEnvironmentLifecycle();
|
||||
} else {
|
||||
globalThis.process.title = `stack-dashboard:${getEnvVariable("NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX", "81")} (node/nextjs)`;
|
||||
}
|
||||
|
||||
@ -18,9 +18,11 @@ export type {
|
||||
MetricsDataPoint,
|
||||
MetricsEmailOverview,
|
||||
MetricsLoginMethodEntry,
|
||||
MetricsNamedCount,
|
||||
MetricsPaymentsOverview,
|
||||
MetricsRecentEmail,
|
||||
MetricsResponse,
|
||||
MetricsTopCountry,
|
||||
MetricsTopReferrer,
|
||||
MetricsTopRegion,
|
||||
MetricsUserCounts,
|
||||
@ -35,18 +37,49 @@ export type {
|
||||
* Returns the typed `MetricsResponse` shape derived from the same yup schemas
|
||||
* the backend route uses, so dashboard call sites do not need `as ...` casts.
|
||||
*/
|
||||
export function useMetricsOrThrow(adminApp: object, includeAnonymous: boolean): MetricsResponse {
|
||||
export type AnalyticsOverviewFilters = {
|
||||
country_code?: string,
|
||||
referrer?: string,
|
||||
browser?: string,
|
||||
os?: string,
|
||||
device?: string,
|
||||
// ISO 8601 datetimes bounding the analytics top-N breakdowns server-side
|
||||
// (top referrers / regions / browsers / OS / devices). The daily and hourly
|
||||
// series stay full-window so previous-period deltas can be computed locally.
|
||||
since?: string,
|
||||
until?: string,
|
||||
};
|
||||
|
||||
// The typed contract for the hooks the admin app exposes through the internals
|
||||
// symbol. The single `as` assertion in `getInternalsHookOrThrow` is the one
|
||||
// place the untyped internals object is narrowed to this contract — call sites
|
||||
// get inferred return types instead of casting each result.
|
||||
type AdminAppInternalsHooks = {
|
||||
useMetrics: (includeAnonymous: boolean, filters?: AnalyticsOverviewFilters) => MetricsResponse,
|
||||
useUserActivity: (userId: string) => UserActivityResponse,
|
||||
useMetricsUserCounts: () => MetricsUserCounts,
|
||||
};
|
||||
|
||||
function getInternalsHookOrThrow<K extends keyof AdminAppInternalsHooks>(adminApp: object, hookName: K): AdminAppInternalsHooks[K] {
|
||||
const internals = Reflect.get(adminApp, hexclaveAppInternalsSymbol);
|
||||
if (typeof internals !== "object" || internals == null || !("useMetrics" in internals)) {
|
||||
throw new HexclaveAssertionError("Admin app internals are unavailable: missing useMetrics");
|
||||
if (typeof internals !== "object" || internals == null || !(hookName in internals)) {
|
||||
throw new HexclaveAssertionError(`Admin app internals are unavailable: missing ${hookName}`);
|
||||
}
|
||||
|
||||
const useMetrics = internals.useMetrics;
|
||||
if (typeof useMetrics !== "function") {
|
||||
throw new HexclaveAssertionError("Admin app internals are unavailable: useMetrics is not callable");
|
||||
const hook = (internals as Record<string, unknown>)[hookName];
|
||||
if (typeof hook !== "function") {
|
||||
throw new HexclaveAssertionError(`Admin app internals are unavailable: ${hookName} is not callable`);
|
||||
}
|
||||
|
||||
return useMetrics(includeAnonymous) as MetricsResponse;
|
||||
return hook as AdminAppInternalsHooks[K];
|
||||
}
|
||||
|
||||
export function useMetricsOrThrow(
|
||||
adminApp: object,
|
||||
includeAnonymous: boolean,
|
||||
filters?: AnalyticsOverviewFilters,
|
||||
): MetricsResponse {
|
||||
return getInternalsHookOrThrow(adminApp, "useMetrics")(includeAnonymous, filters);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -56,29 +89,9 @@ export function useMetricsOrThrow(adminApp: object, includeAnonymous: boolean):
|
||||
* metrics endpoints use.
|
||||
*/
|
||||
export function useUserActivityOrThrow(adminApp: object, userId: string): UserActivityResponse {
|
||||
const internals = Reflect.get(adminApp, hexclaveAppInternalsSymbol);
|
||||
if (typeof internals !== "object" || internals == null || !("useUserActivity" in internals)) {
|
||||
throw new HexclaveAssertionError("Admin app internals are unavailable: missing useUserActivity");
|
||||
}
|
||||
|
||||
const useUserActivity = internals.useUserActivity;
|
||||
if (typeof useUserActivity !== "function") {
|
||||
throw new HexclaveAssertionError("Admin app internals are unavailable: useUserActivity is not callable");
|
||||
}
|
||||
|
||||
return useUserActivity(userId) as UserActivityResponse;
|
||||
return getInternalsHookOrThrow(adminApp, "useUserActivity")(userId);
|
||||
}
|
||||
|
||||
export function useMetricsUserCountsOrThrow(adminApp: object): MetricsUserCounts {
|
||||
const internals = Reflect.get(adminApp, hexclaveAppInternalsSymbol);
|
||||
if (typeof internals !== "object" || internals == null || !("useMetricsUserCounts" in internals)) {
|
||||
throw new HexclaveAssertionError("Admin app internals are unavailable: missing useMetricsUserCounts");
|
||||
}
|
||||
|
||||
const useMetricsUserCounts = internals.useMetricsUserCounts;
|
||||
if (typeof useMetricsUserCounts !== "function") {
|
||||
throw new HexclaveAssertionError("Admin app internals are unavailable: useMetricsUserCounts is not callable");
|
||||
}
|
||||
|
||||
return useMetricsUserCounts() as MetricsUserCounts;
|
||||
return getInternalsHookOrThrow(adminApp, "useMetricsUserCounts")();
|
||||
}
|
||||
|
||||
@ -32,7 +32,7 @@ COPY . .
|
||||
RUN tsx ./scripts/generate-sdks.ts
|
||||
|
||||
# Only prune backend (no dashboard)
|
||||
RUN turbo prune --scope=@hexclave/backend --docker
|
||||
RUN turbo prune --scope=@hexclave/backend --scope=@hexclave/template --docker
|
||||
|
||||
|
||||
# Build stage
|
||||
|
||||
@ -30,7 +30,7 @@ COPY . .
|
||||
RUN tsx ./scripts/generate-sdks.ts
|
||||
|
||||
# https://turbo.build/repo/docs/guides/tools/docker
|
||||
RUN turbo prune --scope=@hexclave/backend --scope=@hexclave/dashboard --docker
|
||||
RUN turbo prune --scope=@hexclave/backend --scope=@hexclave/dashboard --scope=@hexclave/template --docker
|
||||
|
||||
|
||||
FROM node-base AS builder
|
||||
|
||||
@ -26,7 +26,7 @@ COPY . .
|
||||
RUN tsx ./scripts/generate-sdks.ts
|
||||
|
||||
# https://turbo.build/repo/docs/guides/tools/docker
|
||||
RUN turbo prune --scope=@hexclave/backend --scope=@hexclave/dashboard --docker
|
||||
RUN turbo prune --scope=@hexclave/backend --scope=@hexclave/dashboard --scope=@hexclave/template --docker
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn, Spinner, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@hexclave/ui";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn, Spinner, Tooltip, TooltipContent, TooltipPortal, TooltipProvider, TooltipTrigger } from "@hexclave/ui";
|
||||
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
|
||||
import { useGlassmorphicDefault } from "./card";
|
||||
|
||||
@ -29,13 +28,21 @@ export type DesignPillToggleProps = {
|
||||
|
||||
type SizeClass = {
|
||||
button: string,
|
||||
iconOnlyButton: string,
|
||||
icon: string,
|
||||
};
|
||||
|
||||
type SliderMetrics = {
|
||||
left: number,
|
||||
width: number,
|
||||
};
|
||||
|
||||
const sliderTransition = "transform 200ms ease-out, width 200ms ease-out";
|
||||
|
||||
const sizeClasses = new Map<DesignPillToggleSize, SizeClass>([
|
||||
["sm", { button: "px-3 py-1.5 text-xs", icon: "h-3.5 w-3.5" }],
|
||||
["md", { button: "px-4 py-2 text-sm", icon: "h-4 w-4" }],
|
||||
["lg", { button: "px-5 py-2.5 text-sm", icon: "h-4 w-4" }],
|
||||
["sm", { button: "px-3 py-1.5 text-xs", iconOnlyButton: "h-7 w-7 text-xs", icon: "h-3.5 w-3.5" }],
|
||||
["md", { button: "px-4 py-2 text-sm", iconOnlyButton: "h-9 w-9 text-sm", icon: "h-4 w-4" }],
|
||||
["lg", { button: "px-5 py-2.5 text-sm", iconOnlyButton: "h-10 w-10 text-sm", icon: "h-4 w-4" }],
|
||||
]);
|
||||
|
||||
const gradientClasses = new Map<DesignPillToggleGradient, string>([
|
||||
@ -70,6 +77,10 @@ export function DesignPillToggle({
|
||||
const activeRingClass = getMapValueOrThrow(gradientClasses, gradient, "gradientClasses");
|
||||
|
||||
const [loadingOptionId, setLoadingOptionId] = useState<string | null>(null);
|
||||
const [sliderMetrics, setSliderMetrics] = useState<SliderMetrics | null>(null);
|
||||
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
||||
const toggleRef = useRef<HTMLDivElement | null>(null);
|
||||
const optionRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
|
||||
const handleClick = (optionId: string) => {
|
||||
const result = onSelect(optionId);
|
||||
@ -81,79 +92,138 @@ export function DesignPillToggle({
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 p-1 rounded-xl",
|
||||
glassmorphic
|
||||
? "bg-foreground/[0.04] backdrop-blur-sm"
|
||||
: "bg-black/[0.08] dark:bg-white/[0.04]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const isActive = selected === option.id;
|
||||
const Icon = option.icon;
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
||||
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const updatePrefersReducedMotion = () => setPrefersReducedMotion(mediaQuery.matches);
|
||||
|
||||
const pill = (
|
||||
<button
|
||||
type="button"
|
||||
key={option.id}
|
||||
onClick={() => handleClick(option.id)}
|
||||
disabled={loadingOptionId !== null}
|
||||
className={cn(
|
||||
"relative flex items-center gap-2 font-medium rounded-lg transition-all duration-150 hover:transition-none",
|
||||
sizeClass.button,
|
||||
isActive
|
||||
? cn(
|
||||
"bg-white dark:bg-background text-foreground shadow-sm ring-1",
|
||||
glassmorphic
|
||||
? "ring-foreground/[0.06] dark:bg-[hsl(240,71%,70%)]/10 dark:text-[hsl(240,71%,90%)] dark:ring-[hsl(240,71%,70%)]/20"
|
||||
: activeRingClass
|
||||
)
|
||||
: cn(
|
||||
"text-muted-foreground hover:text-foreground",
|
||||
glassmorphic
|
||||
? "hover:bg-background/50"
|
||||
: "hover:bg-black/[0.06] dark:hover:bg-white/[0.04]"
|
||||
)
|
||||
)}
|
||||
>
|
||||
{loadingOptionId === option.id && (
|
||||
<Spinner
|
||||
size={12}
|
||||
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
<span className={cn(
|
||||
"flex items-center gap-2",
|
||||
loadingOptionId === option.id && "invisible"
|
||||
)}>
|
||||
{Icon && <Icon className={sizeClass.icon} />}
|
||||
{showLabels && option.label}
|
||||
</span>
|
||||
</button>
|
||||
updatePrefersReducedMotion();
|
||||
mediaQuery.addEventListener("change", updatePrefersReducedMotion);
|
||||
|
||||
return () => mediaQuery.removeEventListener("change", updatePrefersReducedMotion);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const toggle = toggleRef.current;
|
||||
const selectedButton = optionRefs.current.get(selected);
|
||||
|
||||
if (!toggle || !selectedButton) {
|
||||
setSliderMetrics(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateSliderMetrics = () => {
|
||||
setSliderMetrics({
|
||||
left: selectedButton.offsetLeft,
|
||||
width: selectedButton.offsetWidth,
|
||||
});
|
||||
};
|
||||
|
||||
updateSliderMetrics();
|
||||
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const resizeObserver = new ResizeObserver(updateSliderMetrics);
|
||||
resizeObserver.observe(toggle);
|
||||
resizeObserver.observe(selectedButton);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [options, selected]);
|
||||
|
||||
const body = (
|
||||
<div
|
||||
ref={toggleRef}
|
||||
className={cn(
|
||||
"relative inline-flex items-center gap-1 p-1 rounded-xl",
|
||||
glassmorphic
|
||||
? "bg-foreground/[0.04] backdrop-blur-sm"
|
||||
: "bg-black/[0.08] dark:bg-white/[0.04]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{sliderMetrics != null && (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-y-1 left-0 z-0 rounded-lg bg-background shadow-sm ring-1",
|
||||
glassmorphic
|
||||
? "ring-foreground/[0.06] dark:bg-[hsl(240,71%,70%)]/10 dark:ring-[hsl(240,71%,70%)]/20"
|
||||
: activeRingClass
|
||||
)}
|
||||
style={{
|
||||
transition: prefersReducedMotion ? undefined : sliderTransition,
|
||||
transform: `translateX(${sliderMetrics.left}px)`,
|
||||
width: sliderMetrics.width,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{options.map((option) => {
|
||||
const isActive = selected === option.id;
|
||||
const Icon = option.icon;
|
||||
|
||||
const pill = (
|
||||
<button
|
||||
type="button"
|
||||
key={option.id}
|
||||
ref={(element) => {
|
||||
if (element) {
|
||||
optionRefs.current.set(option.id, element);
|
||||
} else {
|
||||
optionRefs.current.delete(option.id);
|
||||
}
|
||||
}}
|
||||
onClick={() => handleClick(option.id)}
|
||||
disabled={loadingOptionId !== null}
|
||||
aria-label={showLabels ? undefined : option.label}
|
||||
className={cn(
|
||||
"relative z-10 flex items-center gap-2 font-medium rounded-lg transition-all duration-150 hover:transition-none",
|
||||
showLabels ? sizeClass.button : sizeClass.iconOnlyButton,
|
||||
!showLabels && "justify-center p-0",
|
||||
isActive
|
||||
? cn("text-foreground", glassmorphic && "dark:text-[hsl(240,71%,90%)]")
|
||||
: cn(
|
||||
"text-muted-foreground hover:text-foreground",
|
||||
glassmorphic
|
||||
? "hover:bg-background/50"
|
||||
: "hover:bg-black/[0.06] dark:hover:bg-white/[0.04]"
|
||||
)
|
||||
)}
|
||||
>
|
||||
{loadingOptionId === option.id && (
|
||||
<Spinner
|
||||
size={12}
|
||||
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
<span className={cn(
|
||||
"flex items-center gap-2",
|
||||
loadingOptionId === option.id && "invisible"
|
||||
)}>
|
||||
{Icon && <Icon className={sizeClass.icon} />}
|
||||
{showLabels && option.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
if (!showLabels) {
|
||||
return (
|
||||
<Tooltip key={option.id} delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
{pill}
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent side="top">
|
||||
{option.label}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (!showLabels) {
|
||||
return (
|
||||
<Tooltip key={option.id} delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
{pill}
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent side="top">
|
||||
{option.label}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return pill;
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
return pill;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Tooltips require a TooltipProvider in scope. Wrap defensively so callers
|
||||
// outside an existing provider (e.g. inside a PageLayout actions slot) work.
|
||||
return showLabels ? body : <TooltipProvider>{body}</TooltipProvider>;
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { cn, Spinner } from "@hexclave/ui";
|
||||
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
|
||||
import { useGlassmorphicDefault } from "./card";
|
||||
@ -39,6 +39,13 @@ type GradientClass = {
|
||||
underline: string,
|
||||
};
|
||||
|
||||
type SliderMetrics = {
|
||||
left: number,
|
||||
width: number,
|
||||
};
|
||||
|
||||
const sliderTransition = "transform 200ms ease-out, width 200ms ease-out";
|
||||
|
||||
const tabSizeClasses = new Map<DesignTabsSize, TabSizeClass>([
|
||||
["sm", { button: "px-3 py-2 text-xs", badge: "text-[10px] px-1.5 py-0.5" }],
|
||||
["md", { button: "px-4 py-3 text-sm", badge: "text-xs px-1.5 py-0.5" }],
|
||||
@ -119,6 +126,10 @@ export function DesignCategoryTabs({
|
||||
const sizeClass = getMapValueOrThrow(tabSizeClasses, size, "tabSizeClasses");
|
||||
const gradientClass = getMapValueOrThrow(gradientClasses, gradient, "gradientClasses");
|
||||
const [loadingCategoryId, setLoadingCategoryId] = useState<string | null>(null);
|
||||
const [sliderMetrics, setSliderMetrics] = useState<SliderMetrics | null>(null);
|
||||
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
||||
const tabListRef = useRef<HTMLDivElement | null>(null);
|
||||
const tabButtonRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
|
||||
const handleSelect = (categoryId: string) => {
|
||||
const result = onSelect(categoryId);
|
||||
@ -130,6 +141,43 @@ export function DesignCategoryTabs({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
||||
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const updatePrefersReducedMotion = () => setPrefersReducedMotion(mediaQuery.matches);
|
||||
|
||||
updatePrefersReducedMotion();
|
||||
mediaQuery.addEventListener("change", updatePrefersReducedMotion);
|
||||
|
||||
return () => mediaQuery.removeEventListener("change", updatePrefersReducedMotion);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const tabList = tabListRef.current;
|
||||
const selectedButton = tabButtonRefs.current.get(selectedCategory);
|
||||
|
||||
if (!tabList || !selectedButton) {
|
||||
setSliderMetrics(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateSliderMetrics = () => {
|
||||
setSliderMetrics({
|
||||
left: selectedButton.offsetLeft,
|
||||
width: selectedButton.offsetWidth,
|
||||
});
|
||||
};
|
||||
|
||||
updateSliderMetrics();
|
||||
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const resizeObserver = new ResizeObserver(updateSliderMetrics);
|
||||
resizeObserver.observe(tabList);
|
||||
resizeObserver.observe(selectedButton);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [categories, selectedCategory]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@ -142,10 +190,34 @@ export function DesignCategoryTabs({
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
ref={tabListRef}
|
||||
className={cn(
|
||||
"flex min-h-0 min-w-0 items-center gap-1 overflow-x-auto flex-nowrap [&::-webkit-scrollbar]:hidden",
|
||||
"relative flex min-h-0 min-w-0 items-center gap-1 overflow-x-auto flex-nowrap [&::-webkit-scrollbar]:hidden",
|
||||
)}
|
||||
>
|
||||
{glassmorphic && sliderMetrics != null && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 left-0 z-0 rounded-lg bg-background shadow-sm ring-1 ring-black/[0.12] motion-reduce:transition-none dark:ring-white/[0.06]"
|
||||
style={{
|
||||
transition: prefersReducedMotion ? undefined : sliderTransition,
|
||||
transform: `translateX(${sliderMetrics.left}px)`,
|
||||
width: sliderMetrics.width,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!glassmorphic && sliderMetrics != null && (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-0 left-0 h-0.5 motion-reduce:transition-none",
|
||||
gradientClass.underline
|
||||
)}
|
||||
style={{
|
||||
transition: prefersReducedMotion ? undefined : sliderTransition,
|
||||
transform: `translateX(${sliderMetrics.left}px)`,
|
||||
width: sliderMetrics.width,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{categories.map((category) => {
|
||||
const isActive = selectedCategory === category.id;
|
||||
const badgeValue = category.badgeCount ?? category.count;
|
||||
@ -154,18 +226,22 @@ export function DesignCategoryTabs({
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
ref={(element) => {
|
||||
if (element) {
|
||||
tabButtonRefs.current.set(category.id, element);
|
||||
} else {
|
||||
tabButtonRefs.current.delete(category.id);
|
||||
}
|
||||
}}
|
||||
onClick={() => handleSelect(category.id)}
|
||||
disabled={loadingCategoryId !== null}
|
||||
className={cn(
|
||||
"font-medium transition-all duration-150 hover:transition-none relative flex flex-shrink-0 items-center justify-center gap-2 whitespace-nowrap",
|
||||
"font-medium transition-all duration-150 hover:transition-none relative z-10 flex flex-shrink-0 items-center justify-center gap-2 whitespace-nowrap",
|
||||
"hover:text-gray-900 dark:hover:text-gray-100",
|
||||
sizeClass.button,
|
||||
glassmorphic ? "rounded-lg" : "",
|
||||
isActive
|
||||
? cn(
|
||||
gradientClass.activeText,
|
||||
glassmorphic && "bg-white shadow-sm ring-1 ring-black/[0.12] dark:bg-background dark:ring-white/[0.06]"
|
||||
)
|
||||
? gradientClass.activeText
|
||||
: cn(
|
||||
"text-gray-700 dark:text-gray-400",
|
||||
glassmorphic && "rounded-lg hover:bg-white/50 dark:hover:bg-white/[0.06]",
|
||||
@ -200,9 +276,6 @@ export function DesignCategoryTabs({
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!glassmorphic && isActive && (
|
||||
<div className={cn("absolute bottom-0 left-0 right-0 h-0.5", gradientClass.underline)} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -361,11 +361,29 @@ export class HexclaveAdminInterface extends HexclaveServerInterface {
|
||||
);
|
||||
}
|
||||
|
||||
async getMetrics(includeAnonymous: boolean = false): Promise<MetricsResponse> {
|
||||
async getMetrics(
|
||||
includeAnonymous: boolean = false,
|
||||
filters?: {
|
||||
country_code?: string,
|
||||
referrer?: string,
|
||||
browser?: string,
|
||||
os?: string,
|
||||
device?: string,
|
||||
since?: string,
|
||||
until?: string,
|
||||
},
|
||||
): Promise<MetricsResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (includeAnonymous) {
|
||||
params.append('include_anonymous', 'true');
|
||||
}
|
||||
if (filters?.country_code) params.append('filter_country_code', filters.country_code);
|
||||
if (filters?.referrer) params.append('filter_referrer', filters.referrer);
|
||||
if (filters?.browser) params.append('filter_browser', filters.browser);
|
||||
if (filters?.os) params.append('filter_os', filters.os);
|
||||
if (filters?.device) params.append('filter_device', filters.device);
|
||||
if (filters?.since) params.append('filter_since', filters.since);
|
||||
if (filters?.until) params.append('filter_until', filters.until);
|
||||
const queryString = params.toString();
|
||||
const response = await this.sendAdminRequest(
|
||||
`/internal/metrics${queryString ? `?${queryString}` : ''}`,
|
||||
@ -374,7 +392,36 @@ export class HexclaveAdminInterface extends HexclaveServerInterface {
|
||||
},
|
||||
null,
|
||||
);
|
||||
return (await response.json()) as MetricsResponse;
|
||||
const body = (await response.json()) as MetricsResponse;
|
||||
// The yup schema's .optional().default(...) fallbacks only run during
|
||||
// backend response validation, not on this client-side cast — apply them
|
||||
// here too so the one-release-cycle tolerance for older servers that the
|
||||
// schema comments promise actually holds for dashboard consumers. The
|
||||
// Partial views widen the static type (which claims these are always
|
||||
// defined) to match what an older server can actually send.
|
||||
const rawBody: Partial<MetricsResponse> = body;
|
||||
const rawAnalytics: Partial<MetricsResponse["analytics_overview"]> = body.analytics_overview;
|
||||
return {
|
||||
...body,
|
||||
live_users: rawBody.live_users ?? 0,
|
||||
hourly_users: rawBody.hourly_users ?? [],
|
||||
hourly_active_users: rawBody.hourly_active_users ?? [],
|
||||
analytics_overview: {
|
||||
...body.analytics_overview,
|
||||
hourly_page_views: rawAnalytics.hourly_page_views ?? [],
|
||||
hourly_active_users: rawAnalytics.hourly_active_users ?? [],
|
||||
hourly_visitors: rawAnalytics.hourly_visitors ?? [],
|
||||
daily_anonymous_visitors_fallback: rawAnalytics.daily_anonymous_visitors_fallback ?? [],
|
||||
anonymous_visitors_fallback: rawAnalytics.anonymous_visitors_fallback ?? 0,
|
||||
top_regions: rawAnalytics.top_regions ?? [],
|
||||
bounce_rate: rawAnalytics.bounce_rate ?? 0,
|
||||
daily_bounce_rate: rawAnalytics.daily_bounce_rate ?? [],
|
||||
daily_avg_session_seconds: rawAnalytics.daily_avg_session_seconds ?? [],
|
||||
top_browsers: rawAnalytics.top_browsers ?? [],
|
||||
top_operating_systems: rawAnalytics.top_operating_systems ?? [],
|
||||
top_devices: rawAnalytics.top_devices ?? [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getUserActivity(userId: string): Promise<UserActivityResponse> {
|
||||
|
||||
@ -88,16 +88,31 @@ export const MetricsTopReferrerSchema = yupObject({
|
||||
visitors: yupNumber().integer().defined(),
|
||||
}).defined();
|
||||
|
||||
// Named-count breakdowns used by the analytics overview for top browsers,
|
||||
// operating systems, and device classes (Desktop / Mobile / Tablet).
|
||||
export const MetricsNamedCountSchema = yupObject({
|
||||
name: yupString().defined(),
|
||||
visitors: yupNumber().integer().defined(),
|
||||
}).defined();
|
||||
|
||||
export const MetricsTopRegionSchema = yupObject({
|
||||
country_code: yupString().nullable().defined(),
|
||||
region_code: yupString().nullable().defined(),
|
||||
count: yupNumber().integer().defined(),
|
||||
}).defined();
|
||||
|
||||
export const MetricsTopCountrySchema = yupObject({
|
||||
country_code: yupString().defined(),
|
||||
count: yupNumber().integer().defined(),
|
||||
}).defined();
|
||||
|
||||
export const MetricsAnalyticsOverviewSchema = yupObject({
|
||||
daily_page_views: MetricsDataPointsSchema,
|
||||
daily_clicks: MetricsDataPointsSchema,
|
||||
daily_visitors: MetricsDataPointsSchema,
|
||||
hourly_page_views: yupArray(MetricsDataPointSchema).optional().default([]),
|
||||
hourly_active_users: yupArray(MetricsDataPointSchema).optional().default([]),
|
||||
hourly_visitors: yupArray(MetricsDataPointSchema).optional().default([]),
|
||||
// Token-refresh-derived anonymous-visitor fallback. Populated only when the
|
||||
// analytics app isn't installed (no `$page-view` events) — counts DISTINCT
|
||||
// anonymous users per day from the events table. See
|
||||
@ -117,8 +132,20 @@ export const MetricsAnalyticsOverviewSchema = yupObject({
|
||||
revenue_per_visitor: yupNumber().defined(),
|
||||
top_referrers: yupArray(MetricsTopReferrerSchema).defined(),
|
||||
top_region: MetricsTopRegionSchema.nullable().defined(),
|
||||
// dev-fallback fields (only present in non-production environments)
|
||||
bounce_rate: yupNumber().optional(),
|
||||
top_regions: yupArray(MetricsTopCountrySchema).optional().default([]),
|
||||
// Weighted across the window: sum(bounced)/sum(sessions) * 100. .optional()
|
||||
// for one release cycle so older servers (that don't return it yet) don't
|
||||
// hard-fail validation; default to 0 so consumers can read unconditionally.
|
||||
bounce_rate: yupNumber().optional().default(0),
|
||||
daily_bounce_rate: yupArray(MetricsDataPointSchema).optional().default([]),
|
||||
daily_avg_session_seconds: yupArray(MetricsDataPointSchema).optional().default([]),
|
||||
// User-Agent-derived breakdowns for the analytics overview. Computed from the
|
||||
// `data.user_agent` blob on `$page-view` events (captured client-side only,
|
||||
// no server-side fallback). Optional + default-[] for one release cycle
|
||||
// so older clients / servers without UA capture don't fail validation.
|
||||
top_browsers: yupArray(MetricsNamedCountSchema).optional().default([]),
|
||||
top_operating_systems: yupArray(MetricsNamedCountSchema).optional().default([]),
|
||||
top_devices: yupArray(MetricsNamedCountSchema).optional().default([]),
|
||||
conversion_rate: yupNumber().optional(),
|
||||
deltas: yupMixed().optional(),
|
||||
}).defined();
|
||||
@ -176,6 +203,8 @@ export const MetricsResponseBodySchema = yupObject({
|
||||
live_users: yupNumber().integer().optional().default(0),
|
||||
daily_users: MetricsDataPointsSchema,
|
||||
daily_active_users: MetricsDataPointsSchema,
|
||||
hourly_users: yupArray(MetricsDataPointSchema).optional().default([]),
|
||||
hourly_active_users: yupArray(MetricsDataPointSchema).optional().default([]),
|
||||
users_by_country: yupRecord(yupString().defined(), yupNumber().defined()).defined(),
|
||||
active_users_by_country: MetricsActiveUsersByCountrySchema,
|
||||
// recently_registered/active are CRUD User objects passed through from the
|
||||
@ -206,6 +235,8 @@ export type MetricsEmailOverview = yup.InferType<typeof MetricsEmailOverviewSche
|
||||
export type MetricsDailyRevenuePoint = yup.InferType<typeof MetricsDailyRevenuePointSchema>;
|
||||
export type MetricsTopReferrer = yup.InferType<typeof MetricsTopReferrerSchema>;
|
||||
export type MetricsTopRegion = yup.InferType<typeof MetricsTopRegionSchema>;
|
||||
export type MetricsTopCountry = yup.InferType<typeof MetricsTopCountrySchema>;
|
||||
export type MetricsNamedCount = yup.InferType<typeof MetricsNamedCountSchema>;
|
||||
export type MetricsAnalyticsOverview = yup.InferType<typeof MetricsAnalyticsOverviewSchema>;
|
||||
export type MetricsLoginMethodEntry = yup.InferType<typeof MetricsLoginMethodEntrySchema>;
|
||||
export type MetricsRecentUser = yup.InferType<typeof MetricsRecentUserSchema>;
|
||||
|
||||
@ -100,8 +100,12 @@ export class _HexclaveAdminAppImplIncomplete<HasTokenStore extends boolean, Proj
|
||||
private readonly _svixTokenCache = createCache(async () => {
|
||||
return await this._interface.getSvixToken();
|
||||
});
|
||||
private readonly _metricsCache = createCache(async ([includeAnonymous]: [boolean]) => {
|
||||
return await this._interface.getMetrics(includeAnonymous);
|
||||
// Cache key serializes filters via URLSearchParams (sorted keys) so
|
||||
// DependenciesMap (identity-keyed per array slot) treats two equal
|
||||
// filter objects as the same deterministic string entry.
|
||||
private readonly _metricsCache = createCache(async ([includeAnonymous, filtersKey]: [boolean, string]) => {
|
||||
const filters = filtersKey ? Object.fromEntries(new URLSearchParams(filtersKey)) : undefined;
|
||||
return await this._interface.getMetrics(includeAnonymous, filters);
|
||||
});
|
||||
private readonly _userActivityCache = createCache(async ([userId]: [string]) => {
|
||||
return await this._interface.getUserActivity(userId);
|
||||
@ -568,8 +572,7 @@ export class _HexclaveAdminAppImplIncomplete<HasTokenStore extends boolean, Proj
|
||||
protected override async _refreshUsers() {
|
||||
await Promise.all([
|
||||
super._refreshUsers(),
|
||||
this._metricsCache.refresh([false]),
|
||||
this._metricsCache.refresh([true]),
|
||||
this._metricsCache.refreshWhere(() => true),
|
||||
this._metricsUserCountsCache.refresh([]),
|
||||
]);
|
||||
}
|
||||
@ -578,8 +581,20 @@ export class _HexclaveAdminAppImplIncomplete<HasTokenStore extends boolean, Proj
|
||||
return {
|
||||
...super[hexclaveAppInternalsSymbol],
|
||||
// IF_PLATFORM react-like
|
||||
useMetrics: (includeAnonymous: boolean = false): MetricsResponse => {
|
||||
return useAsyncCache(this._metricsCache, [includeAnonymous] as const, "adminApp.useMetrics()") as MetricsResponse;
|
||||
useMetrics: (
|
||||
includeAnonymous: boolean = false,
|
||||
filters?: { country_code?: string, referrer?: string, browser?: string, os?: string, device?: string, since?: string, until?: string },
|
||||
): MetricsResponse => {
|
||||
const filtersKey = (() => {
|
||||
if (filters == null) return "";
|
||||
const params = new URLSearchParams();
|
||||
for (const key of ["browser", "country_code", "device", "os", "referrer", "since", "until"] as const) {
|
||||
const v = filters[key];
|
||||
if (v != null) params.set(key, v);
|
||||
}
|
||||
return params.toString();
|
||||
})();
|
||||
return useAsyncCache(this._metricsCache, [includeAnonymous, filtersKey] as const, "adminApp.useMetrics()") as MetricsResponse;
|
||||
},
|
||||
useUserActivity: (userId: string): UserActivityResponse => {
|
||||
return useAsyncCache(this._userActivityCache, [userId] as const, "adminApp.useUserActivity()") as UserActivityResponse;
|
||||
|
||||
@ -124,6 +124,7 @@ export class EventTracker {
|
||||
viewport_height: window.innerHeight,
|
||||
screen_width: screenObject.width,
|
||||
screen_height: screenObject.height,
|
||||
user_agent: typeof navigator !== "undefined" ? navigator.userAgent : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -21,6 +21,8 @@ const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipPortal = TooltipPrimitive.Portal;
|
||||
|
||||
const TooltipContent = forwardRefIfNeeded<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
@ -37,4 +39,4 @@ const TooltipContent = forwardRefIfNeeded<
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipPortal, TooltipProvider };
|
||||
|
||||
@ -566,6 +566,9 @@ importers:
|
||||
lodash:
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.21
|
||||
motion:
|
||||
specifier: ^12.39.0
|
||||
version: 12.39.0(@emotion/is-prop-valid@1.3.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
next:
|
||||
specifier: 16.1.7
|
||||
version: 16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@ -13005,6 +13008,20 @@ packages:
|
||||
frame-ticker@1.0.3:
|
||||
resolution: {integrity: sha512-E0X2u2JIvbEMrqEg5+4BpTqaD22OwojJI63K7MdKHdncjtAhGRbCR8nJCr2vwEt9NWBPCPcu70X9smPviEBy8Q==}
|
||||
|
||||
framer-motion@12.39.0:
|
||||
resolution: {integrity: sha512-+vnLfzrv0MzjLzNl+nvNvR7jdg3q4cxxjz/YvzfifHl0TREtL00cs1RoMTxs+1PzLiEqZGV6gYsBY0oEAYZ24w==}
|
||||
peerDependencies:
|
||||
'@emotion/is-prop-valid': '*'
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@emotion/is-prop-valid':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
freestyle-sandboxes@0.1.6:
|
||||
resolution: {integrity: sha512-zfyJy+DgmheFjCAPYMklo7rpzvuxNP46rB0a9WfNBEmitYGE23nlbjyTy8qdrmVuCVCoMIDQQzzJRkyuh0Szqg==}
|
||||
deprecated: This package has been deprecated. Please use freestyle instead.
|
||||
@ -15055,6 +15072,26 @@ packages:
|
||||
monaco-editor@0.52.2:
|
||||
resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==}
|
||||
|
||||
motion-dom@12.39.0:
|
||||
resolution: {integrity: sha512-Xn7aAcGDhco/JZTXOub64UmaYn73C6J1Po7Fk+8EvkJsNGTqfhon6UJY53vJKXW5v5Zl8HrYsVxv6oPXeGoGLQ==}
|
||||
|
||||
motion-utils@12.39.0:
|
||||
resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
|
||||
|
||||
motion@12.39.0:
|
||||
resolution: {integrity: sha512-H4a+Ze+a9j+/NTla5ezfb/g9vmIOxC+viDj++NGDZyTZkdRKjiOz3kSv6TalRWM8ZmD2y/CfC6TkQc97ybyqSA==}
|
||||
peerDependencies:
|
||||
'@emotion/is-prop-valid': '*'
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@emotion/is-prop-valid':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
mri@1.2.0:
|
||||
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
||||
engines: {node: '>=4'}
|
||||
@ -32920,6 +32957,16 @@ snapshots:
|
||||
dependencies:
|
||||
simplesignal: 2.1.7
|
||||
|
||||
framer-motion@12.39.0(@emotion/is-prop-valid@1.3.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
motion-dom: 12.39.0
|
||||
motion-utils: 12.39.0
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@emotion/is-prop-valid': 1.3.1
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
|
||||
freestyle-sandboxes@0.1.6: {}
|
||||
|
||||
fresh@0.5.2: {}
|
||||
@ -35583,6 +35630,21 @@ snapshots:
|
||||
|
||||
monaco-editor@0.52.2: {}
|
||||
|
||||
motion-dom@12.39.0:
|
||||
dependencies:
|
||||
motion-utils: 12.39.0
|
||||
|
||||
motion-utils@12.39.0: {}
|
||||
|
||||
motion@12.39.0(@emotion/is-prop-valid@1.3.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
framer-motion: 12.39.0(@emotion/is-prop-valid@1.3.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@emotion/is-prop-valid': 1.3.1
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
|
||||
mri@1.2.0: {}
|
||||
|
||||
ms@2.0.0: {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user