2026-04-28 09:43:20 +08:00
"use client" ;
import clsx from "clsx" ;
2026-04-28 13:00:40 +08:00
import type { MouseEvent } from "react" ;
2026-04-28 09:43:20 +08:00
import { useEffect , useState } from "react" ;
2026-05-17 18:19:31 +08:00
import { AiCityTemperatureChart } from "@/components/dashboard/scan-terminal/AiCityTemperatureChart" ;
2026-04-28 09:43:20 +08:00
import { AiEvidencePanel } from "@/components/dashboard/scan-terminal/AiEvidencePanel" ;
import { CityCardHeader } from "@/components/dashboard/scan-terminal/CityCardHeader" ;
2026-04-28 13:00:40 +08:00
import { MobileDecisionCard } from "@/components/dashboard/scan-terminal/MobileDecisionCard" ;
2026-04-28 09:43:20 +08:00
import { ModelEvidencePanel } from "@/components/dashboard/scan-terminal/ModelEvidencePanel" ;
import { WeatherDecisionBand } from "@/components/dashboard/scan-terminal/WeatherDecisionBand" ;
import {
buildMarketDecisionView ,
buildWeatherDecisionView ,
resolveExpectedHighCandidate ,
} from "@/components/dashboard/scan-terminal/city-card-decision-utils" ;
2026-04-28 10:10:07 +08:00
import { buildCityDecisionState } from "@/components/dashboard/scan-terminal/city-decision-state" ;
2026-04-28 13:00:40 +08:00
import {
getAiReadCopy ,
getCityLoadingCopy ,
} from "@/components/dashboard/scan-terminal/decision-copy" ;
2026-04-28 09:43:20 +08:00
import { getPeakWindowLabel , normalizeCityKey } from "@/components/dashboard/scan-terminal/decision-utils" ;
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal" ;
import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types" ;
import {
useAiCityForecast ,
useCityMarketScan ,
} from "@/components/dashboard/scan-terminal/use-ai-city-card-data" ;
2026-05-17 14:33:23 +08:00
import { getDisplayAirportPrimary } from "@/lib/airport-observation-display" ;
2026-04-28 09:43:20 +08:00
import type { CityDetail , ScanOpportunityRow } from "@/lib/dashboard-types" ;
2026-04-28 12:07:43 +08:00
import { getModelView } from "@/lib/model-utils" ;
2026-04-28 11:45:34 +08:00
import { getTodayPaceView } from "@/lib/pace-utils" ;
2026-04-28 11:32:03 +08:00
import { formatTemperatureValue } from "@/lib/temperature-utils" ;
2026-04-28 09:43:20 +08:00
function toFiniteDecisionNumber ( value : unknown ) {
if ( value == null || value === "" ) return null ;
const numeric = Number ( value );
return Number . isFinite ( numeric ) ? numeric : null ;
}
2026-05-17 18:57:02 +08:00
function getRowModelEntries ( row : ScanOpportunityRow | null ) {
const sources = row ? . model_cluster_sources ;
if ( ! sources || typeof sources !== "object" ) return [];
return Object . entries ( sources )
. map (([ name , value ]) => [ name , Number ( value )] as const )
. filter (([, value ]) => Number . isFinite ( value ));
}
2026-04-28 09:43:20 +08:00
function parseEpochMs ( value : unknown ) {
if ( value == null || value === "" ) return null ;
const numeric = Number ( value );
if ( Number . isFinite ( numeric )) return numeric > 1 _000_000_000_000 ? numeric : numeric * 1000 ;
const parsed = new Date ( String ( value ));
return Number . isNaN ( parsed . getTime ()) ? null : parsed . getTime ();
}
function formatMetarReportTime ( detail : CityDetail | null , report : string , isEn : boolean ) {
const offsetSeconds = Number ( detail ? . utc_offset_seconds );
const epochMs =
parseEpochMs ( detail ? . airport_current ? . report_time ) ??
parseEpochMs ( detail ? . airport_current ? . obs_time_epoch ) ??
parseEpochMs ( detail ? . airport_current ? . obs_time ) ??
parseEpochMs ( detail ? . current ? . report_time ) ??
parseEpochMs ( detail ? . current ? . obs_time_epoch ) ??
parseEpochMs ( detail ? . current ? . obs_time );
if ( epochMs != null ) {
const utc = new Date ( epochMs );
const zText = ` ${ String ( utc . getUTCHours ()). padStart ( 2 , "0" ) } : ${ String (
utc . getUTCMinutes (),
). padStart ( 2 , "0" ) } Z` ;
if ( Number . isFinite ( offsetSeconds )) {
const local = new Date ( epochMs + offsetSeconds * 1000 );
const localText = ` ${ String ( local . getUTCHours ()). padStart ( 2 , "0" ) } : ${ String (
local . getUTCMinutes (),
). padStart ( 2 , "0" ) } ` ;
return isEn ? ` ${ zText } / local ${ localText } ` : ` ${ zText } / 当地 ${ localText } ` ;
}
return zText ;
}
const rawToken = String ( report || "" ). match ( /\b(\d{2})(\d{2})(\d{2})Z\b/i );
if ( ! rawToken ) return "" ;
const zText = ` ${ rawToken [ 2 ] } : ${ rawToken [ 3 ] } Z` ;
if ( ! Number . isFinite ( offsetSeconds )) return zText ;
const utcMinutes = Number ( rawToken [ 2 ]) * 60 + Number ( rawToken [ 3 ]);
if ( ! Number . isFinite ( utcMinutes )) return zText ;
const localMinutes = Math . round (
(( utcMinutes + offsetSeconds / 60 ) % 1440 + 1440 ) % 1440 ,
);
const localText = ` ${ String ( Math . floor ( localMinutes / 60 )). padStart ( 2 , "0" ) } : ${ String (
localMinutes % 60 ,
). padStart ( 2 , "0" ) } ` ;
return isEn ? ` ${ zText } / local ${ localText } ` : ` ${ zText } / 当地 ${ localText } ` ;
}
function normalizeMetarReadTime ( text : string , displayTime : string , isEn : boolean ) {
if ( ! text || ! displayTime ) return text ;
const timeLabel = isEn ? `report time ${ displayTime } ` : `报文时间 ${ displayTime } ` ;
return text
. replace ( /报文时间\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi , timeLabel )
. replace ( /report time\s*\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi , timeLabel )
. replace ( /\bat\s+\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/gi , `at ${ displayTime } ` );
}
function isHkoObservationCity ( detail? : CityDetail | null ) {
const source = String (
detail ? . current ? . settlement_source ||
detail ? . settlement_station ? . settlement_source ||
"" ,
)
. trim ()
. toLowerCase ();
return source === "hko" ;
}
function formatFreshnessAge ( value : unknown , isEn : boolean ) {
const minutes = Number ( value );
if ( ! Number . isFinite ( minutes ) || minutes < 0 ) return "" ;
if ( minutes < 1 ) return isEn ? "just now" : "刚刚" ;
if ( minutes < 60 ) {
const rounded = Math . max ( 1 , Math . round ( minutes ));
return isEn ? ` ${ rounded } m ago` : ` ${ rounded } 分钟前` ;
}
const hours = Math . floor ( minutes / 60 );
const remaining = Math . round ( minutes % 60 );
if ( remaining <= 0 ) return isEn ? ` ${ hours } h ago` : ` ${ hours } 小时前` ;
return isEn ? ` ${ hours } h ${ remaining } m ago` : ` ${ hours } 小时 ${ remaining } 分钟前` ;
}
function formatUpdateTime ( value : unknown , locale : string ) {
const epochMs = parseEpochMs ( value );
if ( epochMs == null ) return "" ;
const date = new Date ( epochMs );
const now = new Date ();
const sameDay = date . toDateString () === now . toDateString ();
const time = date . toLocaleTimeString ( locale === "en-US" ? "en-US" : "zh-CN" , {
hour : "2-digit" ,
minute : "2-digit" ,
});
if ( locale === "en-US" ) {
const day = sameDay
? "today"
: date . toLocaleDateString ( "en-US" , { month : "short" , day : "numeric" });
return ` ${ day } ${ time } updated` ;
}
const day = sameDay
? "今日"
: date . toLocaleDateString ( "zh-CN" , { month : "numeric" , day : "numeric" });
return ` ${ day } ${ time } 更新` ;
}
function buildObservationFreshnessValue ({
detail ,
displayTime ,
isEn ,
isHkoObservation ,
} : {
detail : CityDetail | null ;
displayTime : string ;
isEn : boolean ;
isHkoObservation : boolean ;
}) {
const stale = Boolean (
detail ? . metar_status ? . stale_for_today ||
detail ? . airport_current ? . stale_for_today ||
detail ? . current ? . observation_status === "stale" ,
);
if ( stale ) {
return isEn ? "stale; background only" : "已过旧,仅作背景参考" ;
}
const ageLabel = formatFreshnessAge (
isHkoObservation ? detail?.current?.obs_age_min : detail?.airport_current?.obs_age_min ?? detail ? . current ? . obs_age_min ,
isEn ,
);
if ( ageLabel ) return ageLabel ;
if ( displayTime ) return displayTime ;
return isEn ? "time pending" : "时间待确认" ;
}
function buildModelFreshnessValue ( detail : CityDetail | null , locale : string , isEn : boolean ) {
return (
formatUpdateTime ( detail ? . updated_at , locale ) ||
( isEn ? "latest run loaded" : "已加载最新模型" )
);
}
function buildMarketFreshnessValue ({
isEn ,
marketScan ,
marketStatus ,
} : {
isEn : boolean ;
marketScan : ReturnType < typeof useCityMarketScan >[ "marketScan" ];
marketStatus : ReturnType < typeof useCityMarketScan >[ "marketStatus" ];
}) {
if ( marketStatus === "loading" ) return isEn ? "syncing" : "同步中" ;
if ( ! marketScan ? . available ) return isEn ? "temporarily unavailable" : "暂不可用" ;
const quoteAgeMs = Number (
marketScan . quote_age_ms ??
marketScan . yes_token ? . quote_age_ms ??
marketScan . no_token ? . quote_age_ms ,
);
if ( Number . isFinite ( quoteAgeMs ) && quoteAgeMs >= 0 ) {
return formatFreshnessAge ( quoteAgeMs / 60 _000 , isEn );
}
return isEn ? "synced" : "已同步" ;
}
export function AiPinnedCityCard ({
item ,
detail ,
row ,
locale ,
collapsed ,
removing ,
onRefreshCityDetail ,
onRemove ,
onToggleCollapsed ,
} : {
item : AiPinnedCity ;
detail : CityDetail | null ;
row : ScanOpportunityRow | null ;
locale : string ;
collapsed : boolean ;
removing? : boolean ;
onRefreshCityDetail : ( cityName : string ) => Promise < void >;
onRemove : () => void ;
onToggleCollapsed : () => void ;
}) {
const isEn = locale === "en-US" ;
const displayName =
detail ? . display_name ||
row ? . city_display_name ||
row ? . display_name ||
item . displayName ||
item . cityName ;
const tempSymbol = detail ? . temp_symbol || row ? . temp_symbol || "°C" ;
const modelView = detail ? getModelView ( detail , detail . local_date ) : null ;
2026-05-17 18:57:02 +08:00
const detailModelEntries = modelView
2026-04-28 09:43:20 +08:00
? Object . entries ( modelView . models || {})
. map (([ name , value ]) => [ name , Number ( value )] as const )
. filter (([, value ]) => Number . isFinite ( value ))
: [];
2026-05-17 18:57:02 +08:00
const modelEntries = detailModelEntries . length ? detailModelEntries : getRowModelEntries ( row );
2026-04-28 09:43:20 +08:00
const modelValues = modelEntries . map (([, value ]) => value );
const modelMin = modelValues . length ? Math . min (... modelValues ) : null ;
const modelMax = modelValues . length ? Math . max (... modelValues ) : null ;
const paceView = detail ? getTodayPaceView ( detail , locale as "zh-CN" | "en-US" ) : null ;
const peakWindow =
paceView ? . peakWindowText ||
( row ? getPeakWindowLabel ( row ) : null ) ||
"--" ;
const deb = detail ? . deb ? . prediction ?? row ? . deb_prediction ?? null ;
const isHkoObservation = isHkoObservationCity ( detail );
2026-05-17 14:33:23 +08:00
const displayAirportPrimary = getDisplayAirportPrimary ( detail );
2026-04-28 09:43:20 +08:00
const currentTemp =
( isHkoObservation
? detail ? . current ? . temp ?? row?.current_temp
2026-05-17 14:33:23 +08:00
: displayAirportPrimary?.temp ??
2026-04-28 09:43:20 +08:00
detail ? . airport_current ? . temp ??
detail ? . current ? . temp ??
row ? . current_temp ) ?? null ;
const debNumber = toFiniteDecisionNumber ( deb );
const currentTempNumber = toFiniteDecisionNumber ( currentTemp );
const modelRange =
modelMin != null && modelMax != null
? ` ${ formatTemperatureValue ( modelMin , tempSymbol , { digits : 1 } )} ~ ${ formatTemperatureValue ( modelMax , tempSymbol , { digits : 1 } )}`
: "--" ;
const paceTone = paceView ? . biasTone || "neutral" ;
const paceText =
paceView ? . summary ||
( isEn
? "Waiting for intraday observations to compare against the DEB path."
: "等待更多日内实测,用来对照 DEB 预测路径。" );
const report = isHkoObservation
? ""
: detail ? . current ? . raw_metar || detail ? . airport_current ? . raw_metar || "" ;
const observationSourceZh = isHkoObservation ? "香港天文台观测" : "METAR 实测" ;
const observationSourceEn = isHkoObservation ? "HKO observations" : "METAR observations" ;
const detailCityName = detail ? . name || item . cityName ;
const [ refreshingDetail , setRefreshingDetail ] = useState ( false );
const { aiForecast , refreshAiForecast } = useAiCityForecast ({
detail ,
detailCityName ,
enabled : Boolean ( detail ),
isEn ,
locale ,
report ,
});
const { marketScan , marketStatus } = useCityMarketScan ({
detail ,
detailCityName ,
enabled : Boolean ( detail ),
});
const isRefreshing = refreshingDetail || aiForecast . status === "loading" ;
const [ isCompactCard , setIsCompactCard ] = useState ( false );
useEffect (() => {
if ( typeof window === "undefined" || ! window . matchMedia ) return ;
const media = window . matchMedia ( "(max-width: 820px)" );
const syncCompactMode = () => setIsCompactCard ( media . matches );
syncCompactMode ();
media . addEventListener ( "change" , syncCompactMode );
return () => media . removeEventListener ( "change" , syncCompactMode );
}, []);
const aiCityForecast = aiForecast . payload ? . city_forecast || null ;
2026-05-17 17:05:47 +08:00
const localizedFinalJudgment =
2026-04-28 09:43:20 +08:00
( isEn ? aiCityForecast?.final_judgment_en : aiCityForecast?.final_judgment_zh ) ||
( isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh ) ||
"" ;
2026-05-17 17:05:47 +08:00
const localizedMetarRead =
2026-04-28 09:43:20 +08:00
( isEn ? aiCityForecast?.metar_read_en : aiCityForecast?.metar_read_zh ) ||
"" ;
2026-05-17 17:05:47 +08:00
const localizedReasoning =
2026-04-28 09:43:20 +08:00
( isEn ? aiCityForecast?.reasoning_en : aiCityForecast?.reasoning_zh ) ||
"" ;
const localizedModelNote =
( isEn
? aiCityForecast?.model_cluster_note_en
: aiCityForecast?.model_cluster_note_zh ) || "" ;
const modelPreview = modelEntries
. slice ( 0 , 4 )
. map (([ name , value ]) => ` ${ name } ${ formatTemperatureValue ( value , tempSymbol , { digits : 1 } )}` )
. join ( isEn ? " / " : " / " );
const localModelSupportNote = modelEntries . length
? isEn
? modelEntries . length <= 2
? `Model support is sparse: only ${ modelEntries . length } sources are available ${ modelPreview ? ` ( ${ modelPreview } )` : "" } , so the read should lean more on DEB path and ${ observationSourceEn } .`
: `Model support: ${ modelEntries . length } sources cluster between ${ modelRange } ; ${ modelPreview } .`
: modelEntries . length <= 2
? `多模型支撑偏少:当前只有 ${ modelEntries . length } 个模型 ${ modelPreview ? `( ${ modelPreview } ) ` : "" } ,需要更重视 DEB 路径和 ${ observationSourceZh } 。`
: `多模型支撑: ${ modelEntries . length } 个模型集中在 ${ modelRange } ,代表模型为 ${ modelPreview } 。`
: isEn
? `Model support is unavailable, so this city must rely on DEB path and ${ observationSourceEn } .`
: `暂无可用多模型支撑,需要主要参考 DEB 路径和 ${ observationSourceZh } 。` ;
2026-05-17 18:57:02 +08:00
const rowAiPredictedMax =
toFiniteDecisionNumber ( row ? . ai_predicted_max ) ??
toFiniteDecisionNumber ( row ? . ai_predicted_high ) ??
toFiniteDecisionNumber ( row ? . cluster_median ) ??
debNumber ;
const aiPredictedMax =
toFiniteDecisionNumber ( aiCityForecast ? . predicted_max ) ?? rowAiPredictedMax ;
const aiRangeLow =
toFiniteDecisionNumber ( aiCityForecast ? . range_low ) ??
toFiniteDecisionNumber ( row ? . ai_predicted_low ) ??
modelMin ;
const aiRangeHigh =
toFiniteDecisionNumber ( aiCityForecast ? . range_high ) ??
toFiniteDecisionNumber ( row ? . ai_predicted_high ) ??
modelMax ;
const aiConfidence =
String ( aiCityForecast ? . confidence || row ? . ai_forecast_confidence || "" ). trim () ||
( rowAiPredictedMax != null ? ( isEn ? "fast" : "快速" ) : null );
2026-04-28 09:43:20 +08:00
const decisionExpectedHighNumber = resolveExpectedHighCandidate ({
aiPredictedMax ,
currentTemp : currentTempNumber ,
deb : debNumber ,
modelMax ,
modelMin ,
paceAdjustedHigh : paceView?.paceAdjustedHigh ?? null ,
});
const decisionView = buildWeatherDecisionView ({
aiCityForecast ,
currentTemp : currentTempNumber ,
deb : debNumber ,
isEn ,
localModelSupportNote ,
modelEntries ,
modelMax ,
modelMin ,
paceTone ,
paceView ,
peakWindow ,
tempSymbol ,
});
const expectedHighText =
decisionExpectedHighNumber != null
? formatTemperatureValue ( decisionExpectedHighNumber , tempSymbol , { digits : 1 })
: "--" ;
const debText =
debNumber != null
? formatTemperatureValue ( debNumber , tempSymbol , { digits : 1 })
: "--" ;
const marketDecisionView = buildMarketDecisionView ({
expectedHigh : decisionExpectedHighNumber ,
isEn ,
marketScan ,
marketStatus ,
tempSymbol ,
});
const aiMeta = aiCityForecast ? . _polyweather_meta || null ;
const guardReason = aiMeta ? . deterministic_guard_reason || {};
const observationStale = Boolean (
detail ? . metar_status ? . stale_for_today ||
detail ? . airport_current ? . stale_for_today ||
detail ? . current ? . observation_status === "stale" ||
guardReason . observation_stale ,
);
const observedHighBreak = Boolean (
guardReason . observed_high_break ||
( currentTempNumber != null &&
modelMax != null &&
currentTempNumber > modelMax + 0.2 ),
);
const observedLowBreak = Boolean ( guardReason . observed_low_break );
const observedLowLag = Boolean ( guardReason . observed_low_lag );
const peakHasPassed = Boolean (
guardReason . peak_has_passed ||
[ "past" , "post_peak" , "after_peak" ]. includes (
String (( row as { window_phase? : string | null } | null ) ? . window_phase || "" ). toLowerCase (),
),
);
const modelSpread = modelMax != null && modelMin != null ? modelMax - modelMin : null ;
const modelHighlyConsistent =
modelEntries . length >= 4 && modelSpread != null && modelSpread <= 2 ;
const needsNextBulletin =
! observationStale &&
! observedHighBreak &&
! observedLowBreak &&
! peakHasPassed &&
( observedLowLag || paceTone === "neutral" || aiForecast . status === "loading" );
const aiRuleEvidenceMode = Boolean (
aiForecast . status === "failed" ||
( aiForecast . status === "ready" && ! aiCityForecast ) ||
aiForecast . payload ? . degraded ||
aiMeta ? . fallback ,
);
2026-04-28 13:00:40 +08:00
const aiReadCopy = getAiReadCopy ({ isEn , isHkoObservation });
const aiReadInProgressText = aiReadCopy . inProgress ;
const aiReadCompleteText = aiReadCopy . complete ;
const aiRuleEvidenceText = aiReadCopy . ruleEvidence ;
2026-04-28 10:10:07 +08:00
const decisionState = buildCityDecisionState ({
aiCityForecast ,
aiForecast ,
aiRuleEvidenceMode ,
isEn ,
isHkoObservation ,
marketDecisionView ,
modelHighlyConsistent ,
needsNextBulletin ,
observationStale ,
observedHighBreak ,
observedLowBreak ,
observedLowLag ,
peakHasPassed ,
});
2026-04-28 09:43:20 +08:00
const dataFreshnessRows = [
{
label : isEn ? "Models" : "模型" ,
value : buildModelFreshnessValue ( detail , locale , isEn ),
2026-05-17 17:05:47 +08:00
tone : "fresh" as const ,
2026-04-28 09:43:20 +08:00
},
{
label : isEn ? "Market" : "市场价格" ,
value : buildMarketFreshnessValue ({ isEn , marketScan , marketStatus }),
tone :
marketDecisionView.status === "ready"
2026-05-17 17:05:47 +08:00
? ( "fresh" as const )
2026-04-28 09:43:20 +08:00
: marketDecisionView . status === "loading"
2026-05-17 17:05:47 +08:00
? ( "loading" as const )
: ( "stale" as const ),
2026-04-28 09:43:20 +08:00
},
];
const freshnessSeparator = isEn ? ": " : ": " ;
const localizedRisksRaw =
( isEn ? aiCityForecast?.risks_en : aiCityForecast?.risks_zh ) || [];
const localizedRisks = Array . isArray ( localizedRisksRaw )
? localizedRisksRaw
: localizedRisksRaw
? [ String ( localizedRisksRaw )]
: [];
const aiBullets = [
localizedMetarRead ,
localizedReasoning !== localizedFinalJudgment ? localizedReasoning : "" ,
localizedModelNote || localModelSupportNote ,
... localizedRisks ,
]. filter (( line ) => String ( line || "" ). trim ());
const fallbackAiReason =
( isEn ? aiForecast.payload?.reason_en : aiForecast.payload?.reason_zh ) ||
aiForecast . payload ? . reason ||
"" ;
const marketLineText =
marketDecisionView . status === "ready"
? ` ${ marketDecisionView . bucketLabel } · ${ marketDecisionView . priceText } `
: marketDecisionView . status === "loading"
? isEn
? "syncing"
: "同步中"
: isEn
? "temporarily unavailable"
: "暂不可用" ;
const collapseId = `ai-city-body- ${ normalizeCityKey ( item . cityName ) || item . addedAt } ` ;
2026-04-28 13:00:40 +08:00
const loadingCopy = getCityLoadingCopy ({ isEn , isHkoObservation });
const handleRefresh = ( event : MouseEvent < HTMLButtonElement >) => {
event . preventDefault ();
event . stopPropagation ();
if ( refreshingDetail ) return ;
setRefreshingDetail ( true );
void onRefreshCityDetail ( detailCityName )
. catch (() => {})
. finally (() => {
refreshAiForecast ();
setRefreshingDetail ( false );
});
};
const handleRemove = ( event : MouseEvent < HTMLButtonElement >) => {
event . preventDefault ();
event . stopPropagation ();
onRemove ();
};
2026-04-28 09:43:20 +08:00
return (
2026-04-28 10:10:07 +08:00
< article
2026-04-28 13:00:40 +08:00
className = { clsx (
"scan-ai-city-card" ,
isCompactCard && "scan-mobile-decision-card" ,
collapsed && ! isCompactCard && "collapsed" ,
removing && "removing" ,
)}
2026-05-10 14:21:10 +08:00
tabIndex = { - 1 }
2026-04-28 10:10:07 +08:00
data-ai-status = { decisionState . aiStatus }
data-evidence-quality = { decisionState . evidenceQuality }
data-market-status = { decisionState . marketStatus }
data-recommendation = { decisionState . recommendation }
data-urgency = { decisionState . urgency }
>
2026-04-28 13:00:40 +08:00
{ isCompactCard ? (
< MobileDecisionCard
aiBullets = { aiBullets }
aiCityForecast = { aiCityForecast }
2026-05-06 15:48:27 +08:00
aiConfidence = { aiConfidence }
2026-04-28 13:00:40 +08:00
aiForecast = { aiForecast }
2026-05-06 15:48:27 +08:00
aiPredictedMax = { aiPredictedMax }
aiRangeHigh = { aiRangeHigh }
aiRangeLow = { aiRangeLow }
2026-04-28 13:00:40 +08:00
aiReadCompleteText = { aiReadCompleteText }
aiReadInProgressText = { aiReadInProgressText }
aiRuleEvidenceMode = { aiRuleEvidenceMode }
aiRuleEvidenceText = { aiRuleEvidenceText }
2026-05-06 15:48:27 +08:00
debPrediction = { debNumber }
2026-04-28 13:00:40 +08:00
decisionState = { decisionState }
detail = { detail }
displayName = { displayName }
expectedHighText = { expectedHighText }
fallbackAiReason = { fallbackAiReason }
isEn = { isEn }
isHkoObservation = { isHkoObservation }
isRefreshing = { isRefreshing }
localModelSupportNote = { localModelSupportNote }
localizedFinalJudgment = { localizedFinalJudgment }
marketDecisionView = { marketDecisionView }
marketLineText = { marketLineText }
onRefresh = { handleRefresh }
onRemove = { handleRemove }
peakWindow = { peakWindow }
removing = { removing }
2026-05-06 15:48:27 +08:00
tempSymbol = { tempSymbol }
2026-04-28 13:00:40 +08:00
/>
) : (
<>
< CityCardHeader
aiStatusLabel = { decisionState . aiStatusLabel }
aiStatusTone = { decisionState . aiStatusTone }
collapseId = { collapseId }
collapsed = { collapsed }
debText = { debText }
detailLocalTime = { detail ? . local_time }
displayName = { displayName }
expectedHighText = { expectedHighText }
2026-04-28 09:43:20 +08:00
isEn = { isEn }
2026-04-28 13:00:40 +08:00
isRefreshing = { isRefreshing }
modelRange = { modelRange }
onRefresh = { handleRefresh }
onRemove = { handleRemove }
onToggleCollapsed = { onToggleCollapsed }
2026-04-28 09:43:20 +08:00
peakWindow = { peakWindow }
2026-04-28 13:00:40 +08:00
removing = { removing }
rowLocalTime = { row ? . local_time }
statusTags = { decisionState . badges }
2026-04-28 09:43:20 +08:00
/>
2026-04-28 13:00:40 +08:00
{ detail && ! collapsed ? (
< div className = "scan-ai-city-body" id = { collapseId }>
< WeatherDecisionBand
decisionView = { decisionView }
decisionWhyText = { decisionState . primaryReason }
isEn = { isEn }
marketDecisionView = { marketDecisionView }
marketLineText = { marketLineText }
paceDeltaText = { paceView ? . deltaText || "--" }
/>
2026-04-28 09:43:20 +08:00
2026-04-28 13:00:40 +08:00
< div className = "scan-ai-city-analysis-grid" >
2026-05-17 18:19:31 +08:00
< AiCityTemperatureChart detail = { detail } />
2026-04-28 13:00:40 +08:00
< AiEvidencePanel
aiBullets = { aiBullets }
aiCityForecast = { aiCityForecast }
2026-05-06 15:48:27 +08:00
aiConfidence = { aiConfidence }
2026-04-28 13:00:40 +08:00
aiForecast = { aiForecast }
2026-05-06 15:48:27 +08:00
aiPredictedMax = { aiPredictedMax }
aiRangeHigh = { aiRangeHigh }
aiRangeLow = { aiRangeLow }
2026-04-28 13:00:40 +08:00
aiReadCompleteText = { aiReadCompleteText }
aiReadInProgressText = { aiReadInProgressText }
aiRuleEvidenceMode = { aiRuleEvidenceMode }
aiRuleEvidenceText = { aiRuleEvidenceText }
2026-05-06 15:48:27 +08:00
debPrediction = { debNumber }
2026-04-28 13:00:40 +08:00
fallbackAiReason = { fallbackAiReason }
isEn = { isEn }
isHkoObservation = { isHkoObservation }
localModelSupportNote = { localModelSupportNote }
localizedFinalJudgment = { localizedFinalJudgment }
2026-05-06 15:48:27 +08:00
tempSymbol = { tempSymbol }
2026-04-28 13:00:40 +08:00
/>
</ div >
2026-04-28 09:43:20 +08:00
2026-04-28 13:00:40 +08:00
< ModelEvidencePanel detail = { detail } isEn = { isEn } />
</ div >
) : ! detail ? (
< div className = "scan-ai-city-loading" >
< LoadingSignal
title = { loadingCopy . title }
description = { loadingCopy . description }
compact
/>
</ div >
) : null }
</>
)}
2026-04-28 09:43:20 +08:00
</ article >
);
}