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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user