Improve landing performance and analytics funnel
This commit is contained in:
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user