Improve landing performance and analytics funnel

This commit is contained in:
2569718930@qq.com
2026-05-30 19:50:50 +08:00
parent 3fcda3f3cd
commit 9bcfd9d1eb
15 changed files with 336 additions and 95 deletions
+31 -1
View File
@@ -196,19 +196,49 @@ export function AccountCenter() {
useEffect(() => {
if (!authIsAuthenticated || !authUserId) return;
const actorKey = authUserId.toLowerCase();
const subscriptionPlanCode = String(backend?.subscription_plan_code || "").trim();
const subscriptionSource = String(backend?.subscription_source || "").trim();
const trialSubscription = Boolean(
backend?.subscription_is_trial === true ||
subscriptionPlanCode.toLowerCase().includes("trial") ||
subscriptionSource.toLowerCase().includes("trial"),
);
if (markAnalyticsOnce(`signup_completed:${actorKey}`, "local")) {
trackAppEvent("signup_completed", {
entry: "account_center",
user_id: authUserId,
});
}
if (markAnalyticsOnce(`signup_success:${actorKey}`, "local")) {
trackAppEvent("signup_success", {
entry: "account_center",
user_id: authUserId,
});
}
if (markAnalyticsOnce(`dashboard_active:${actorKey}`, "session")) {
trackAppEvent("dashboard_active", {
entry: "account_center",
user_id: authUserId,
});
}
}, [authIsAuthenticated, authUserId]);
if (trialSubscription && markAnalyticsOnce(`trial_created:${actorKey}`, "local")) {
trackAppEvent("trial_created", {
entry: "account_center",
user_id: authUserId,
subscription_plan_code: subscriptionPlanCode || null,
subscription_source: subscriptionSource || null,
subscription_expires_at: backend?.subscription_expires_at || null,
subscription_is_trial: backend?.subscription_is_trial === true,
});
}
}, [
authIsAuthenticated,
authUserId,
backend?.subscription_expires_at,
backend?.subscription_is_trial,
backend?.subscription_plan_code,
backend?.subscription_source,
]);
// ── Idle callback effect ──────────────────────────────
useEffect(() => {
@@ -203,6 +203,27 @@ export function runTests() {
accountCenterSource.includes('trackAppEvent("dashboard_active"'),
"account center must emit signup_completed and dashboard_active so the ops funnel has top-of-funnel data",
);
assert(
appAnalyticsSource.includes('| "landing_view"') &&
appAnalyticsSource.includes('| "enter_terminal"') &&
appAnalyticsSource.includes('| "login_start"') &&
appAnalyticsSource.includes('| "signup_success"') &&
appAnalyticsSource.includes('| "trial_created"') &&
appAnalyticsSource.includes('| "payment_start"') &&
appAnalyticsSource.includes('| "payment_success"'),
"app analytics must expose the standard growth funnel event names",
);
assert(
accountCenterSource.includes('trackAppEvent("signup_success"') &&
accountCenterSource.includes('trackAppEvent("trial_created"') &&
accountCenterSource.includes("subscription_is_trial"),
"account center must emit signup_success and trial_created for the standard funnel",
);
assert(
paymentFlowSource.includes('trackAppEvent("payment_start"') &&
paymentFlowSource.includes('trackAppEvent("payment_success"'),
"payment flow must emit payment_start and payment_success alongside legacy checkout events",
);
assert(
accountCenterSource.includes("isSubscriptionUnknown") &&
accountCenterSource.includes("subscriptionStatusLabel") &&
+14 -6
View File
@@ -174,6 +174,14 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
const planList = paymentConfig?.plans || [];
const effectivePlanList = planList;
const selectedPlan = effectivePlanList.find((plan) => plan.plan_code === selectedPlanCode) || effectivePlanList[0];
const trackPaymentStart = useCallback((payload: Record<string, unknown>) => {
trackAppEvent("checkout_started", payload);
trackAppEvent("payment_start", payload);
}, []);
const trackPaymentSuccess = useCallback((payload: Record<string, unknown>) => {
trackAppEvent("checkout_succeeded", payload);
trackAppEvent("payment_success", payload);
}, []);
const availableChainList: PaymentChainOption[] = useMemo(() => {
const configured = Array.isArray(paymentConfig?.chains) ? paymentConfig?.chains || [] : [];
@@ -345,7 +353,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
if (status === "confirmed") {
setPaymentError("");
setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHash)));
trackAppEvent("checkout_succeeded", {
trackPaymentSuccess({
entry: "account_center",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
@@ -363,7 +371,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
}
throw new Error(copy.paymentPendingTimeout);
},
[loadPaymentSnapshot, refreshEntitlementAfterPayment, selectedPlan?.plan_code],
[loadPaymentSnapshot, refreshEntitlementAfterPayment, selectedPlan?.plan_code, trackPaymentSuccess],
);
// ── createIntentAndPay ──────────────────────────────────
@@ -484,7 +492,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
const intentId = String(created.intent?.intent_id || "");
const txPayload = created.tx_payload;
if (!intentId || !txPayload?.to || !txPayload?.data) throw new Error(copy.intentPayloadInvalid);
trackAppEvent("checkout_started", {
trackPaymentStart({
entry: "account_center",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
@@ -582,7 +590,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
}
setPaymentInfo(copy.paymentConfirmed.replace("{txHash}", shortAddress(txHashNorm)));
trackAppEvent("checkout_succeeded", {
trackPaymentSuccess({
entry: "account_center",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
@@ -676,7 +684,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
.replace("{chain}", chainName)
.replace("{receiver}", shortAddress(direct.receiver_address)),
);
trackAppEvent("checkout_started", {
trackPaymentStart({
entry: "account_center_manual_transfer",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentId,
@@ -739,7 +747,7 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
setManualPayment(null);
setManualTxHash("");
setTxValidation({ loading: false, checked: false });
trackAppEvent("checkout_succeeded", {
trackPaymentSuccess({
entry: "account_center_manual_transfer",
plan_code: selectedPlan?.plan_code || "pro_monthly",
intent_id: intentIdVal,
@@ -14,7 +14,7 @@ import {
UserRound,
Users,
} from "lucide-react";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Fragment, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import type { CityListItem, ProAccessState, ScanOpportunityRow } from "@/lib/dashboard-types";
import { getInitialLocaleFromNavigator } from "@/lib/i18n";
import { isBrowserLocalFullAccess } from "@/lib/local-dev-access";
@@ -60,6 +60,7 @@ import {
cityListItemsToScanRows,
mergeScanRowsWithCityFallbackRows,
} from "@/components/dashboard/scan-terminal/city-fallback-rows";
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
function createEmptyAccess(loading = true): ProAccessState {
return {
@@ -1184,6 +1185,17 @@ function ScanTerminalScreen() {
timezoneOffsetSeconds: useLocalTimezoneDefault ? localTimezoneOffsetSeconds : null,
tradingRegion: selectedRegionKey,
});
useEffect(() => {
if (!hydrated || !isAuthenticated || !isPro) return;
const actorKey = String(proAccess.userId || "local").toLowerCase();
if (markAnalyticsOnce(`enter_terminal:${actorKey}`, "session")) {
trackAppEvent("enter_terminal", {
entry: "terminal",
user_id: proAccess.userId || null,
});
}
}, [hydrated, isAuthenticated, isPro, proAccess.userId]);
const handleRefresh = useCallback(() => {
clearCityDetailCache();
refreshScanTerminalManually();
@@ -1223,11 +1235,12 @@ function ScanTerminalScreen() {
return () => controller.abort();
}, [isPro]);
const [searchQuery, setSearchQuery] = useState("");
const deferredSearchQuery = useDeferredValue(searchQuery);
const searchInputRef = useRef<HTMLInputElement>(null);
const filteredRows = useMemo(() => {
if (!searchQuery.trim()) return rows;
const q = searchQuery.toLowerCase().trim();
if (!deferredSearchQuery.trim()) return rows;
const q = deferredSearchQuery.toLowerCase().trim();
return rows.filter((row) => {
const haystack = [
row.city,
@@ -1246,7 +1259,7 @@ function ScanTerminalScreen() {
.map((v) => String(v).toLowerCase());
return haystack.some((s) => s.includes(q));
});
}, [rows, searchQuery]);
}, [rows, deferredSearchQuery]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectedCity, setSelectedCity] = useState<string | null>(null);
@@ -1,7 +1,7 @@
"use client";
import clsx from "clsx";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { useLatestPatch, useSseResyncVersion } from "@/hooks/use-sse-patches";
import { Panel } from "@/components/dashboard/scan-terminal/Panel";
@@ -465,12 +465,12 @@ export function LiveTemperatureThresholdChart({
return series;
}, [series]);
const isSeriesVisible = (sKey: string) => {
const isSeriesVisible = useCallback((sKey: string) => {
if (userToggledKeys[sKey] !== undefined) {
return userToggledKeys[sKey];
}
return isTemperatureSeriesVisibleByDefault(city, sKey);
};
}, [city, userToggledKeys]);
const activeSeries = useMemo(() => {
return getActiveTemperatureSeries(
@@ -568,6 +568,62 @@ export function LiveTemperatureThresholdChart({
const subtitle = row ? (isEn ? "Live & Forecast" : "实测与预测") : "";
const handleZoomReset = useCallback(() => {
setZoomRange(null);
}, []);
const handleViewModeChange = useCallback((mode: "auto" | "full") => {
setViewMode(mode);
setZoomRange(null);
}, []);
const handleMouseDown = useCallback((e: any) => {
if (compact || !e) return;
if (typeof e.activeTooltipIndex === "number") {
setRefAreaLeft(e.activeTooltipIndex);
setRefAreaRight(e.activeTooltipIndex);
}
}, [compact]);
const handleMouseMove = useCallback((e: any) => {
if (compact || !e || refAreaLeft === null) return;
if (typeof e.activeTooltipIndex === "number") {
setRefAreaRight(e.activeTooltipIndex);
}
}, [compact, refAreaLeft]);
const handleMouseUp = useCallback(() => {
if (refAreaLeft === null || refAreaRight === null) {
setRefAreaLeft(null);
setRefAreaRight(null);
return;
}
let leftIdx = refAreaLeft;
let rightIdx = refAreaRight;
if (leftIdx > rightIdx) {
[leftIdx, rightIdx] = [rightIdx, leftIdx];
}
if (rightIdx - leftIdx >= 1) {
const originalStartIndex = visibleRange ? visibleRange[0] : 0;
const newStart = originalStartIndex + leftIdx;
const newEnd = originalStartIndex + rightIdx;
setZoomRange([newStart, newEnd]);
}
setRefAreaLeft(null);
setRefAreaRight(null);
}, [refAreaLeft, refAreaRight, visibleRange]);
const handleSeriesToggle = useCallback((seriesKey: string) => {
setUserToggledKeys((prev) => ({
...prev,
[seriesKey]: !isSeriesVisible(seriesKey),
}));
}, [isSeriesVisible]);
const panelTitle = row ? (
<div className="flex items-center gap-1">
<button
@@ -606,7 +662,7 @@ export function LiveTemperatureThresholdChart({
{zoomRange && (
<button
type="button"
onClick={() => setZoomRange(null)}
onClick={handleZoomReset}
className="px-2 py-0.5 text-[9px] font-bold rounded bg-slate-100 hover:bg-slate-200 text-slate-700 border border-slate-300 shadow-sm transition-all cursor-pointer"
>
{isEn ? "Reset Zoom" : "重置缩放"}
@@ -617,10 +673,7 @@ export function LiveTemperatureThresholdChart({
<button
key={mode}
type="button"
onClick={() => {
setViewMode(mode);
setZoomRange(null);
}}
onClick={() => handleViewModeChange(mode)}
className={clsx(
"px-2 py-0.5 text-[9px] font-bold rounded transition-all cursor-pointer",
viewMode === mode
@@ -670,45 +723,7 @@ export function LiveTemperatureThresholdChart({
</div>
)}
</div>
); const handleMouseDown = (e: any) => {
if (compact || !e) return;
if (typeof e.activeTooltipIndex === "number") {
setRefAreaLeft(e.activeTooltipIndex);
setRefAreaRight(e.activeTooltipIndex);
}
};
const handleMouseMove = (e: any) => {
if (compact || !e || refAreaLeft === null) return;
if (typeof e.activeTooltipIndex === "number") {
setRefAreaRight(e.activeTooltipIndex);
}
};
const handleMouseUp = () => {
if (refAreaLeft === null || refAreaRight === null) {
setRefAreaLeft(null);
setRefAreaRight(null);
return;
}
let leftIdx = refAreaLeft;
let rightIdx = refAreaRight;
if (leftIdx > rightIdx) {
[leftIdx, rightIdx] = [rightIdx, leftIdx];
}
if (rightIdx - leftIdx >= 1) {
const originalStartIndex = visibleRange ? visibleRange[0] : 0;
const newStart = originalStartIndex + leftIdx;
const newEnd = originalStartIndex + rightIdx;
setZoomRange([newStart, newEnd]);
}
setRefAreaLeft(null);
setRefAreaRight(null);
};
);
return (
<Panel
@@ -768,14 +783,9 @@ export function LiveTemperatureThresholdChart({
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onZoomReset={() => setZoomRange(null)}
onZoomReset={handleZoomReset}
isSeriesVisible={isSeriesVisible}
onSeriesToggle={(seriesKey) => {
setUserToggledKeys((prev) => ({
...prev,
[seriesKey]: !isSeriesVisible(seriesKey),
}));
}}
onSeriesToggle={handleSeriesToggle}
onShowRunwayDetailsChange={setShowRunwayDetails}
/>
</div>
@@ -1,7 +1,7 @@
"use client";
import clsx from "clsx";
import { useEffect, useRef, useState } from "react";
import { memo, useEffect, useRef, useState } from "react";
import {
Area,
CartesianGrid,
@@ -28,7 +28,7 @@ type CityThreshold = {
kind: "gte" | "lte";
};
export function TemperatureChartCanvas({
function TemperatureChartCanvasComponent({
isEn,
compact,
timeframe,
@@ -349,3 +349,5 @@ export function TemperatureChartCanvas({
</div>
);
}
export const TemperatureChartCanvas = memo(TemperatureChartCanvasComponent);
@@ -102,4 +102,20 @@ export function runTests() {
dashboardSource.indexOf('event === "TOKEN_REFRESHED"'),
"terminal auth listener must hydrate access from Supabase INITIAL_SESSION events during first navigation from the landing page",
);
assert(
dashboardSource.includes("useDeferredValue") &&
dashboardSource.includes("deferredSearchQuery") &&
dashboardSource.includes("[rows, deferredSearchQuery]"),
"terminal search must defer expensive row filtering so typing stays responsive",
);
assert(
dashboardSource.includes('trackAppEvent("enter_terminal"') &&
dashboardSource.includes('entry: "terminal"'),
"terminal must emit enter_terminal when an entitled user reaches the dashboard",
);
assert(
chartCanvasSource.includes("memo(") &&
chartCanvasSource.includes("TemperatureChartCanvasComponent"),
"temperature chart canvas must be memoized so unrelated terminal state does not remount Recharts",
);
}
@@ -21,6 +21,7 @@ import {
getSupabaseBrowserClient,
hasSupabasePublicEnv,
} from "@/lib/supabase/client";
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
const COVERAGE_EN = [
"Live airport observations",
@@ -76,6 +77,12 @@ function InstitutionalLandingScreen() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [authChecked, setAuthChecked] = useState(false);
useEffect(() => {
if (markAnalyticsOnce("landing_view", "session")) {
trackAppEvent("landing_view", { entry: "landing" });
}
}, []);
useEffect(() => {
if (!hasSupabasePublicEnv()) {
setAuthChecked(true);
@@ -90,6 +97,26 @@ function InstitutionalLandingScreen() {
const coverage = isEn ? COVERAGE_EN : COVERAGE_ZH;
const trackLoginStart = (mode: "login" | "signup") => {
trackAppEvent("login_start", {
entry: "landing",
mode,
next: "/terminal",
});
};
const trackEnterTerminal = (entry: string) => {
trackAppEvent("enter_terminal", {
entry,
authenticated: isAuthenticated,
});
};
const trackTerminalAuthStart = (entry: string, mode: "login" | "signup") => {
trackEnterTerminal(entry);
trackLoginStart(mode);
};
const platformCards = isEn
? [
{
@@ -176,6 +203,7 @@ function InstitutionalLandingScreen() {
<div className="flex items-center gap-2">
<Link
href="/terminal"
onClick={() => trackEnterTerminal("landing_header")}
className="inline-flex h-9 items-center gap-2 rounded-md bg-slate-950 px-3 text-sm font-semibold text-white hover:bg-slate-800"
>
{isEn ? "Open" : "进入"}
@@ -193,12 +221,14 @@ function InstitutionalLandingScreen() {
<>
<Link
href="/auth/login?next=%2Fterminal"
onClick={() => trackTerminalAuthStart("landing_header_login", "login")}
className="hidden h-9 items-center rounded-md px-3 text-sm font-semibold text-slate-600 hover:text-slate-950 sm:inline-flex"
>
{isEn ? "Log in" : "登录"}
</Link>
<Link
href="/auth/login?next=%2Fterminal&mode=signup"
onClick={() => trackTerminalAuthStart("landing_header_signup", "signup")}
className="inline-flex h-9 items-center gap-2 rounded-md bg-slate-950 px-3 text-sm font-semibold text-white hover:bg-slate-800"
>
{isEn ? "Start" : "开始使用"}
@@ -226,6 +256,11 @@ function InstitutionalLandingScreen() {
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
<Link
href={authChecked && isAuthenticated ? "/terminal" : "/auth/login?next=%2Fterminal"}
onClick={() =>
authChecked && isAuthenticated
? trackEnterTerminal("landing_hero")
: trackTerminalAuthStart("landing_hero_login", "login")
}
className="inline-flex h-11 items-center justify-center gap-2 rounded-md bg-slate-950 px-5 text-sm font-bold text-white shadow-sm hover:bg-slate-800"
>
{isEn ? "Open product" : "进入产品"}
@@ -252,11 +287,19 @@ function InstitutionalLandingScreen() {
<span className="h-2.5 w-2.5 rounded-full bg-[#06d6a0]" />
<span className="ml-2 text-xs font-semibold text-slate-400">polyweather.app/terminal</span>
</div>
<img
src="/static/web.png"
alt={isEn ? "PolyWeather terminal preview" : "PolyWeather 终端预览"}
className="mt-2 aspect-[16/9] w-full rounded-md border border-slate-100 object-cover object-top"
/>
<div className="mt-2 aspect-[16/9] overflow-hidden rounded-md border border-slate-100 bg-slate-100">
<img
src="/static/web.webp"
width="680"
height="340"
alt={isEn ? "PolyWeather terminal preview" : "PolyWeather 终端预览"}
className="h-full w-full object-cover object-top"
decoding="async"
fetchPriority="high"
loading="eager"
sizes="(min-width: 1024px) 960px, calc(100vw - 48px)"
/>
</div>
</div>
<div className="mx-auto mt-8 grid max-w-5xl gap-2 sm:grid-cols-2 lg:grid-cols-4">
@@ -6,18 +6,40 @@ function assert(condition: unknown, message: string) {
}
export function runTests() {
const root = projectRoot();
const source = fs.readFileSync(
path.join(projectRoot(), "components", "landing", "InstitutionalLandingPage.tsx"),
path.join(root, "components", "landing", "InstitutionalLandingPage.tsx"),
"utf8",
);
const appPageSource = fs.readFileSync(path.join(projectRoot(), "app", "page.tsx"), "utf8");
const appPageSource = fs.readFileSync(path.join(root, "app", "page.tsx"), "utf8");
const pngPath = path.join(root, "public", "static", "web.png");
const webpPath = path.join(root, "public", "static", "web.webp");
assert(source.includes("3 天免费试用"), "landing page must advertise the 3-day trial");
assert(source.includes("试用期权益和 Pro 一致,除了不显示付费 Telegram 群链接"), "landing page must state trial access matches Pro except the paid group link");
assert(!source.includes("高频刷新与 API 仍为 Pro 权益"), "landing page must not incorrectly exclude high-frequency refresh or API from trial access");
assert(source.includes("bg-[#fbfbfa]"), "landing page must use a light Notion-style background");
assert(source.includes("WeatherWorkflowIllustration"), "landing page must include a friendly illustration surface");
assert(source.includes("/static/web.png"), "landing page must show the product preview image");
assert(fs.existsSync(webpPath), "landing page must ship a WebP preview image for the LCP product screenshot");
assert(
fs.statSync(webpPath).size < fs.statSync(pngPath).size * 0.65,
"WebP preview must be materially smaller than the PNG LCP image",
);
assert(source.includes("/static/web.webp"), "landing page must load the lighter WebP product preview image");
assert(!source.includes('src="/static/web.png"'), "landing hero must not use the heavy PNG as its primary LCP image");
assert(
source.includes('width="680"') &&
source.includes('height="340"') &&
source.includes('fetchPriority="high"') &&
source.includes('decoding="async"'),
"landing product preview must expose stable intrinsic dimensions and high fetch priority",
);
assert(
source.includes('trackAppEvent("landing_view"') &&
source.includes('trackAppEvent("login_start"') &&
source.includes('trackAppEvent("enter_terminal"'),
"landing page must emit the top-of-funnel analytics events",
);
assert(source.includes("29.9") && source.includes("30 天"), "landing page must show monthly Pro pricing");
assert(source.includes("79.9") && source.includes("90 天"), "landing page must show quarterly Pro pricing");
assert(source.includes("20 USDC") && source.includes("+3500 积分"), "landing page must describe referral discount and reward");
+7
View File
@@ -4,6 +4,13 @@ const ANALYTICS_ENABLED =
process.env.NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS !== "false";
type TrackableAnalyticsEvent =
| "landing_view"
| "enter_terminal"
| "login_start"
| "signup_success"
| "trial_created"
| "payment_start"
| "payment_success"
| "signup_completed"
| "dashboard_active"
| "paywall_feature_clicked"
+8 -7
View File
@@ -28,14 +28,15 @@ export const opsApi = {
rates?: Record<string, number>;
window_days?: number;
}>(`/api/ops/analytics/funnel?days=${days}`);
const stepOrder = ["signup_completed", "dashboard_active", "paywall_feature_clicked", "paywall_viewed", "checkout_started", "checkout_succeeded"];
const stepOrder = ["landing_view", "enter_terminal", "login_start", "signup_success", "trial_created", "payment_start", "payment_success"];
const stepLabels: Record<string, string> = {
signup_completed: "注册",
dashboard_active: "活跃",
paywall_feature_clicked: "点击高级功能",
paywall_viewed: "看到付费墙",
checkout_started: "发起支付",
checkout_succeeded: "支付成功",
landing_view: "访问落地页",
enter_terminal: "进入终端",
login_start: "开始登录",
signup_success: "注册成功",
trial_created: "试用开通",
payment_start: "发起支付",
payment_success: "支付成功",
};
const steps = stepOrder.map((key, i) => {
const evt = raw?.events?.[key];
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+30 -13
View File
@@ -942,13 +942,28 @@ class DBManager:
since_dt = datetime.now() - timedelta(days=safe_days)
rows = self.list_app_analytics_events(limit=5000, since_iso=since_dt.isoformat())
event_names = [
"signup_completed",
"dashboard_active",
"paywall_feature_clicked",
"paywall_viewed",
"checkout_started",
"checkout_succeeded",
"landing_view",
"enter_terminal",
"login_start",
"signup_success",
"trial_created",
"payment_start",
"payment_success",
]
event_aliases = {
"landing_view": ("landing_view",),
"enter_terminal": ("enter_terminal", "dashboard_active"),
"login_start": ("login_start",),
"signup_success": ("signup_success", "signup_completed"),
"trial_created": ("trial_created",),
"payment_start": ("payment_start", "checkout_started"),
"payment_success": ("payment_success", "checkout_succeeded"),
}
alias_to_event = {
alias: event_name
for event_name, aliases in event_aliases.items()
for alias in aliases
}
summary: Dict[str, Dict[str, Any]] = {
name: {
"total": 0,
@@ -961,8 +976,9 @@ class DBManager:
user_sets: Dict[str, set[str]] = {name: set() for name in event_names}
for row in rows:
event_type = str(row.get("event_type") or "").strip().lower()
if event_type not in summary:
raw_event_type = str(row.get("event_type") or "").strip().lower()
event_type = alias_to_event.get(raw_event_type)
if not event_type:
continue
summary[event_type]["total"] += 1
user_id = str(row.get("user_id") or "").strip().lower()
@@ -996,11 +1012,12 @@ class DBManager:
"since": since_dt.isoformat(),
"events": summary,
"rates": {
"login_active_rate": _rate("dashboard_active", "signup_completed"),
"paywall_click_rate": _rate("paywall_feature_clicked", "dashboard_active"),
"paywall_view_rate": _rate("paywall_viewed", "paywall_feature_clicked"),
"checkout_start_rate": _rate("checkout_started", "paywall_viewed"),
"checkout_success_rate": _rate("checkout_succeeded", "checkout_started"),
"enter_terminal_rate": _rate("enter_terminal", "landing_view"),
"login_start_rate": _rate("login_start", "enter_terminal"),
"signup_success_rate": _rate("signup_success", "login_start"),
"trial_created_rate": _rate("trial_created", "signup_success"),
"payment_start_rate": _rate("payment_start", "trial_created"),
"payment_success_rate": _rate("payment_success", "payment_start"),
},
}
+44
View File
@@ -11,6 +11,7 @@ import web.routes as routes
import web.services.ops_api as ops_api
import web.scan_terminal_cache as scan_terminal_cache
import web.scan_terminal_service as scan_terminal_service
import web.services.city_runtime as city_runtime
from web.scan_terminal_cache import scan_terminal_cache_key
from src.database.runtime_state import TruthRecordRepository
@@ -60,6 +61,49 @@ def test_metrics_endpoint_returns_prometheus_payload():
assert 'polyweather_http_requests_total' in response.text
def test_standard_growth_funnel_events_are_trackable():
assert {
"landing_view",
"enter_terminal",
"login_start",
"signup_success",
"trial_created",
"payment_start",
"payment_success",
}.issubset(city_runtime.TRACKABLE_ANALYTICS_EVENTS)
def test_standard_growth_funnel_summary_order(monkeypatch):
from src.database.db_manager import DBManager
rows = [
{"id": 1, "event_type": "landing_view", "user_id": "", "client_id": "c1", "session_id": "s1"},
{"id": 2, "event_type": "enter_terminal", "user_id": "", "client_id": "c1", "session_id": "s1"},
{"id": 3, "event_type": "login_start", "user_id": "", "client_id": "c1", "session_id": "s1"},
{"id": 4, "event_type": "signup_success", "user_id": "u1", "client_id": "c1", "session_id": "s1"},
{"id": 5, "event_type": "trial_created", "user_id": "u1", "client_id": "c1", "session_id": "s1"},
{"id": 6, "event_type": "payment_start", "user_id": "u1", "client_id": "c1", "session_id": "s1"},
{"id": 7, "event_type": "payment_success", "user_id": "u1", "client_id": "c1", "session_id": "s1"},
]
monkeypatch.setattr(
DBManager,
"list_app_analytics_events",
lambda self, limit=5000, since_iso=None: rows,
)
summary = DBManager().get_app_analytics_funnel_summary(days=7)
assert list(summary["events"].keys()) == [
"landing_view",
"enter_terminal",
"login_start",
"signup_success",
"trial_created",
"payment_start",
"payment_success",
]
assert summary["rates"]["payment_success_rate"] == 1.0
def test_cities_endpoint_uses_denver_display_name_for_aurora_market():
response = client.get("/api/cities")
+7
View File
@@ -66,6 +66,13 @@ _DEB_RECENT_MIN_SAMPLES = 3
_daily_record_repo = DailyRecordRepository()
TRACKABLE_ANALYTICS_EVENTS = {
"landing_view",
"enter_terminal",
"login_start",
"signup_success",
"trial_created",
"payment_start",
"payment_success",
"signup_completed",
"dashboard_active",
"paywall_feature_clicked",