Improve dashboard maintainability before the next release
The dashboard had several oversized orchestration, component, and CSS files that made product-copy changes and mobile/performance work risky. This refactor preserves behavior while splitting scan terminal CSS, opportunity helpers, future forecast panels, history/detail charts, and probability/model sections into smaller ownership boundaries. Constraint: No user-visible version bump because this batch is architecture and performance cleanup, not a release announcement. Rejected: Rewrite dashboard state management in the same batch | too broad for a safe upload after CSS and component splitting. Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep new component/CSS boundaries instead of moving product copy back into the large dashboard files. Tested: npm run build; npm run test:business; git diff --check Not-tested: Browser visual smoke test after push
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
||||
import { getModelView, getProbabilityView } from "@/lib/model-utils";
|
||||
import {
|
||||
formatTemperatureValue,
|
||||
normalizeTemperatureSymbol,
|
||||
} from "@/lib/temperature-utils";
|
||||
import { getWindowPhaseMeta, type PhaseMeta } from "./opportunity-window-phase";
|
||||
import {
|
||||
getDetailForRow,
|
||||
getDetailViewDate,
|
||||
} from "./opportunity-detail";
|
||||
import {
|
||||
formatThreshold,
|
||||
normalizeProbability,
|
||||
} from "./opportunity-format";
|
||||
import { formatModelClusterRange } from "./opportunity-model-summary";
|
||||
import {
|
||||
extractNumbers,
|
||||
getTargetRange,
|
||||
normalizeBucketLabel,
|
||||
} from "./opportunity-target";
|
||||
|
||||
export function getBucketText(bucket: { label?: string | null; bucket?: string | null; range?: string | null }) {
|
||||
return [bucket.label, bucket.bucket, bucket.range]
|
||||
.map((value) => String(value || "").trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function bucketMatchesRow(
|
||||
bucket: {
|
||||
label?: string | null;
|
||||
bucket?: string | null;
|
||||
range?: string | null;
|
||||
value?: number | string | null;
|
||||
},
|
||||
row: ScanOpportunityRow,
|
||||
tempSymbol?: string | null,
|
||||
) {
|
||||
const targetLabel = normalizeBucketLabel(row.target_label, tempSymbol);
|
||||
const bucketLabels = getBucketText(bucket).map((label) =>
|
||||
normalizeBucketLabel(label, tempSymbol),
|
||||
);
|
||||
if (targetLabel && bucketLabels.some((label) => label === targetLabel)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const rawTargetLabel = String(row.target_label || "");
|
||||
const targetNumbers = extractNumbers(rawTargetLabel);
|
||||
const targetValue =
|
||||
row.target_value ?? row.target_threshold ?? row.target_lower ?? row.target_upper ?? targetNumbers[0] ?? null;
|
||||
if (targetValue == null || !Number.isFinite(Number(targetValue))) return false;
|
||||
|
||||
const bucketNumbers = [
|
||||
...(bucket.value != null && Number.isFinite(Number(bucket.value))
|
||||
? [Number(bucket.value)]
|
||||
: []),
|
||||
...getBucketText(bucket).flatMap(extractNumbers),
|
||||
];
|
||||
const matchesNumber = bucketNumbers.some(
|
||||
(value) => Math.abs(Number(value) - Number(targetValue)) < 0.05,
|
||||
);
|
||||
if (!matchesNumber) return false;
|
||||
|
||||
const targetIsUpper =
|
||||
/(\+|以上|or\s*above|above|greater|>=|≥)/i.test(rawTargetLabel) ||
|
||||
(row.target_lower != null && row.target_upper == null);
|
||||
const targetIsLower =
|
||||
/(<=|≤|below|or\s*below|以下)/i.test(rawTargetLabel) ||
|
||||
(row.target_upper != null && row.target_lower == null);
|
||||
const bucketRaw = getBucketText(bucket).join(" ");
|
||||
const bucketIsUpper = /(\+|以上|or\s*above|above|greater|>=|≥|inf|∞)/i.test(bucketRaw);
|
||||
const bucketIsLower = /(<=|≤|below|or\s*below|以下|-inf|-∞)/i.test(bucketRaw);
|
||||
|
||||
if (targetIsUpper || bucketIsUpper) return targetIsUpper === bucketIsUpper;
|
||||
if (targetIsLower || bucketIsLower) return targetIsLower === bucketIsLower;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getDetailBucketEventProbability(
|
||||
detail: CityDetail | null,
|
||||
row: ScanOpportunityRow,
|
||||
tempSymbol?: string | null,
|
||||
) {
|
||||
if (!detail) return null;
|
||||
const view = getProbabilityView(detail, getDetailViewDate(detail, row));
|
||||
const buckets = Array.isArray(view.probabilitiesAll)
|
||||
? view.probabilitiesAll
|
||||
: [];
|
||||
if (!buckets.length) return null;
|
||||
const matched = buckets.find((bucket) => bucketMatchesRow(bucket, row, tempSymbol));
|
||||
return normalizeProbability(matched?.probability);
|
||||
}
|
||||
|
||||
export type OpportunityGroup = {
|
||||
key: string;
|
||||
cityName: string;
|
||||
date?: string | null;
|
||||
tempSymbol?: string | null;
|
||||
debLabel: string;
|
||||
peakLabel: string;
|
||||
peakProbability?: number | null;
|
||||
phaseMeta: PhaseMeta;
|
||||
localTime?: string | null;
|
||||
remainingMinutes?: number | null;
|
||||
rows: ScanOpportunityRow[];
|
||||
};
|
||||
|
||||
export function buildOpportunityGroups(
|
||||
rows: ScanOpportunityRow[],
|
||||
locale: string,
|
||||
cityDetailsByName?: Record<string, CityDetail>,
|
||||
): OpportunityGroup[] {
|
||||
const groups = new Map<string, OpportunityGroup>();
|
||||
for (const row of rows) {
|
||||
const tempSymbol = normalizeTemperatureSymbol(row.target_unit || row.temp_symbol);
|
||||
const detail = getDetailForRow(row, cityDetailsByName);
|
||||
const cityName = getLocalizedCityName(
|
||||
row.city,
|
||||
row.city_display_name || row.display_name || row.city,
|
||||
locale,
|
||||
);
|
||||
const date = detail ? getDetailViewDate(detail, row) : row.selected_date || row.local_date || "";
|
||||
const key = `${row.city || cityName}|${date}`;
|
||||
const modelView = detail ? getModelView(detail, date) : null;
|
||||
const debPrediction = modelView?.deb ?? row.deb_prediction ?? null;
|
||||
const modelClusterLabel = formatModelClusterRange(
|
||||
modelView?.models || row.model_cluster_sources,
|
||||
tempSymbol,
|
||||
);
|
||||
const existing = groups.get(key);
|
||||
if (!existing) {
|
||||
groups.set(key, {
|
||||
key,
|
||||
cityName,
|
||||
date,
|
||||
tempSymbol,
|
||||
debLabel:
|
||||
debPrediction != null
|
||||
? formatTemperatureValue(Number(debPrediction), tempSymbol, { digits: 1 })
|
||||
: "--",
|
||||
peakLabel: modelClusterLabel,
|
||||
peakProbability: null,
|
||||
phaseMeta: getWindowPhaseMeta(row, locale),
|
||||
localTime: row.local_time,
|
||||
remainingMinutes: row.remaining_window_minutes,
|
||||
rows: [row],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
existing.rows.push(row);
|
||||
if (existing.peakLabel === "--" && modelClusterLabel !== "--") {
|
||||
existing.peakLabel = modelClusterLabel;
|
||||
}
|
||||
}
|
||||
return Array.from(groups.values()).map((group) => ({
|
||||
...group,
|
||||
rows: [...group.rows].sort(
|
||||
(a, b) =>
|
||||
Number(b.edge_percent ?? -Infinity) - Number(a.edge_percent ?? -Infinity) ||
|
||||
Number(b.final_score ?? -Infinity) - Number(a.final_score ?? -Infinity),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
export function getBucketDisplayLabel(
|
||||
row: ScanOpportunityRow,
|
||||
locale: string,
|
||||
tempSymbol?: string | null,
|
||||
) {
|
||||
const isEn = locale === "en-US";
|
||||
const { lower, upper } = getTargetRange(row);
|
||||
if (lower != null && upper == null) {
|
||||
const value = formatTemperatureValue(lower, tempSymbol);
|
||||
return isEn ? `${value} or higher` : `${value} 以上`;
|
||||
}
|
||||
if (upper != null && lower == null) {
|
||||
const value = formatTemperatureValue(upper, tempSymbol);
|
||||
return isEn ? `${value} or lower` : `${value} 以下`;
|
||||
}
|
||||
if (lower != null && upper != null && Math.abs(lower - upper) > 0.01) {
|
||||
return `${formatTemperatureValue(lower, tempSymbol)} ~ ${formatTemperatureValue(upper, tempSymbol)}`;
|
||||
}
|
||||
return formatThreshold(row, tempSymbol);
|
||||
}
|
||||
Reference in New Issue
Block a user