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
@@ -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",
);
}