Keep dense dashboard lists stable during selection changes

Opportunity and calendar cards now isolate item rendering behind memoized components. This preserves the current action grouping and copy while preventing selection or parent dashboard updates from re-rendering every dense card body.

Constraint: Do not change ranking, grouping, or product wording in this performance pass.

Rejected: Introduce virtual list dependency | the current list size can benefit from memo boundaries first without new dependencies.

Confidence: high

Scope-risk: narrow

Reversibility: clean

Tested: TypeScript diagnostics for CalendarView and OpportunityOverview

Tested: npm run build

Not-tested: Browser profiler capture with a large production city set.
This commit is contained in:
2569718930@qq.com
2026-04-28 11:25:14 +08:00
parent 2e5bdfd1b6
commit 8d05812a7e
2 changed files with 83 additions and 57 deletions
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { memo, useEffect, useMemo, useState } from "react";
import { getWindowPhaseMeta } from "@/components/dashboard/OpportunityTable";
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
@@ -26,6 +26,12 @@ type CalendarActionGroup = {
sort: number;
};
type CalendarActionItem = {
row: ScanOpportunityRow;
meta: CalendarMeta;
reason: string;
};
function normalizeCalendarCityKey(value?: string | null) {
return String(value || "")
.trim()
@@ -291,6 +297,66 @@ function buildCalendarCoreReason(
: "先放入行动面板,等待下一轮观测";
}
const CalendarActionCard = memo(function CalendarActionCard({
item,
locale,
selected,
onSelectRow,
}: {
item: CalendarActionItem;
locale: string;
selected: boolean;
onSelectRow: (row: ScanOpportunityRow) => void;
}) {
const { row, meta, reason } = item;
const tempSymbol = row.temp_symbol || "°C";
const phaseMeta = getWindowPhaseMeta(row, locale);
return (
<button
type="button"
className={`scan-calendar-card peak-${meta.tone} ${selected ? "selected" : ""}`}
onClick={() => onSelectRow(row)}
>
<div className="scan-calendar-city">
{getLocalizedCityName(
row.city,
row.city_display_name || row.display_name || row.city,
locale,
)}
</div>
<div className="scan-calendar-countdown">
{meta.title}
{meta.localWindowLabel ? (
<small>
{locale === "en-US" ? "Your time: " : "本地时间:"}
{meta.localWindowLabel}
</small>
) : null}
<small>
{locale === "en-US" ? "City window: " : "城市窗口:"}
{meta.cityWindowLabel || meta.detail}
</small>
</div>
<p className="scan-calendar-reason">{reason}</p>
<div className="scan-calendar-action">
<span>{locale === "en-US" ? "DEB high" : "DEB 预测高点"}</span>
<b>
{row.deb_prediction != null
? formatTemperatureValue(row.deb_prediction, tempSymbol)
: "--"}
</b>
</div>
<div className="scan-calendar-meta">
<span>
{formatShortDate(row.selected_date || row.local_date, locale)} · {row.local_time || "--"}
</span>
<span>{phaseMeta.label}</span>
</div>
</button>
);
});
export function CalendarView({
rows,
locale,
@@ -324,7 +390,7 @@ export function CalendarView({
label: string;
subtitle: string;
sort: number;
items: Array<{ row: ScanOpportunityRow; meta: CalendarMeta; reason: string }>;
items: CalendarActionItem[];
}
>();
dedupeCalendarRows(rows).forEach((row) => {
@@ -385,57 +451,14 @@ export function CalendarView({
</div>
</div>
<div className="scan-calendar-grid">
{group.items.map(({ row, meta, reason }) => (
<button
key={row.id}
type="button"
className={`scan-calendar-card peak-${meta.tone} ${selectedRowId === row.id ? "selected" : ""}`}
onClick={() => onSelectRow(row)}
>
{(() => {
const tempSymbol = row.temp_symbol || "°C";
const phaseMeta = getWindowPhaseMeta(row, locale);
return (
<>
<div className="scan-calendar-city">
{getLocalizedCityName(
row.city,
row.city_display_name || row.display_name || row.city,
locale,
)}
</div>
<div className="scan-calendar-countdown">
{meta.title}
{meta.localWindowLabel ? (
<small>
{locale === "en-US" ? "Your time: " : "本地时间:"}
{meta.localWindowLabel}
</small>
) : null}
<small>
{locale === "en-US" ? "City window: " : "城市窗口:"}
{meta.cityWindowLabel || meta.detail}
</small>
</div>
<p className="scan-calendar-reason">{reason}</p>
<div className="scan-calendar-action">
<span>{locale === "en-US" ? "DEB high" : "DEB 预测高点"}</span>
<b>
{row.deb_prediction != null
? formatTemperatureValue(row.deb_prediction, tempSymbol)
: "--"}
</b>
</div>
<div className="scan-calendar-meta">
<span>
{formatShortDate(row.selected_date || row.local_date, locale)} · {row.local_time || "--"}
</span>
<span>{phaseMeta.label}</span>
</div>
</>
);
})()}
</button>
{group.items.map((item) => (
<CalendarActionCard
key={item.row.id}
item={item}
locale={locale}
selected={selectedRowId === item.row.id}
onSelectRow={onSelectRow}
/>
))}
</div>
</section>
@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { memo, useMemo } from "react";
import clsx from "clsx";
import type { ScanOpportunityRow, ScanTerminalResponse } from "@/lib/dashboard-types";
import { formatTemperatureValue } from "@/lib/dashboard-utils";
@@ -13,7 +13,7 @@ import {
pickOpportunitySections,
} from "@/components/dashboard/scan-terminal/decision-utils";
function OpportunityDecisionCard({
const OpportunityDecisionCard = memo(function OpportunityDecisionCard({
row,
locale,
selected,
@@ -91,7 +91,7 @@ function OpportunityDecisionCard({
</button>
</article>
);
}
});
export function OpportunityOverview({
rows,
@@ -116,7 +116,10 @@ export function OpportunityOverview({
}) {
const isEn = locale === "en-US";
const sections = useMemo(() => pickOpportunitySections(rows, locale), [locale, rows]);
const visibleSections = sections.filter((section) => section.rows.length > 0);
const visibleSections = useMemo(
() => sections.filter((section) => section.rows.length > 0),
[sections],
);
const summary = terminalData?.summary;
if (loading) {