@@ -16,9 +16,9 @@ import { alignTodayRecommendations, buildTodayMemberships,
1616import { usePolledData } from "../lib/poll.js" ;
1717import { availableToday , nextTodayRefreshDelay } from "../lib/today-plan.js" ;
1818import { freshnessStatus , waitingLabel } from "../lib/data-freshness.js" ;
19- import { decisionFrozen , gamePhase , PHASE_LABEL , recommendationOutcome } from "../lib/match-status.js" ;
19+ import { decisionFrozen , gamePhase , PHASE_LABEL , recommendationOutcome , scheduledAt } from "../lib/match-status.js" ;
2020import { predictionForGame } from "../lib/game-prediction.js" ;
21- import { estimateLiveProbability } from "../lib/bet-ledger .js" ;
21+ import { savedLivePrediction } from "../lib/saved-live-prediction .js" ;
2222import { commentaryMethod , directPickReason } from "../lib/recommendation.js" ;
2323import { compactTeamPlayerLine } from "../lib/team-preview.js" ;
2424import { deduplicateGameCards } from "../lib/game-dedup.js" ;
@@ -609,7 +609,7 @@ function MarketHistory({ rows }) {
609609 </ details > ;
610610}
611611
612- function Game ( { g, opts, wait, grades, lv, stale, generatedAt, year, todayMembership,
612+ export function Game ( { g, opts, wait, grades, lv, stale, generatedAt, year, todayMembership,
613613 todayOption, highlightedToday, onSaveBet } ) {
614614 // 같은 마켓의 두 선택지가 같은 등급이면 '=' — 어느 쪽을 사도 같아 고를 근거가 없다
615615 const tie = useMemo ( ( ) => {
@@ -626,25 +626,23 @@ function Game({ g, opts, wait, grades, lv, stale, generatedAt, year, todayMember
626626 // 모델 최대확률을 골라 새 추천을 만들지 않는다.
627627 const done = g . status === "정산" ;
628628 const liveClosed = g . _liveStarted === true ;
629+ const phase = gamePhase ( g , lv ) ;
630+ const kickoff = scheduledAt ( g ) ;
631+ const started = liveClosed || phase !== "upcoming" || ( kickoff != null && kickoff <= Date . now ( ) ) ;
632+ const locked = started || decisionFrozen ( g ) ;
633+ const saved = savedLivePrediction ( g , lv ) ;
629634 const predictionUnavailable = ! g . prediction_record
630635 && ( done || g . prediction_status === "prediction_ledger_required" ) ;
631636 const prediction = wait || stale || predictionUnavailable || g . _liveOddsChanged
632637 ? null : predictionForGame ( opts ) ;
633- const savedPick = g . prediction_record ;
634- const savedOption = savedPick && {
635- ...( opts . find ( ( o ) => o . selection_id === savedPick . selection_id ) || { } ) ,
636- selection_id : savedPick . selection_id , market : savedPick . market ,
637- label : savedPick . label || "" , 선택 : savedPick . selection , 배당 : savedPick . odds ,
638- } ;
639- const displayedOption = ( decisionFrozen ( g ) && savedOption )
640- || todayOption || prediction ?. option || null ;
638+ const displayedOption = ( locked && saved ?. option )
639+ || ( ! started ? todayOption || prediction ?. option : null ) || null ;
641640 const pick = displayedOption ? {
642641 o : displayedOption ,
643642 g : gradeOf ( grades , displayedOption [ "배당" ] ) ,
644643 tie : false ,
645644 } : null ;
646645 // 프로토 정산은 경기가 끝나고도 한참 뒤다. 그 사이를 실시간 점수가 메운다.
647- const phase = gamePhase ( g , lv ) ;
648646 // 필터 집계와 카드 본문이 반드시 같은 상태 판정을 사용해야 한다. 단순히
649647 // finished=false만 보면 갱신이 끊긴 중계 스냅샷이 카드에 영원히 LIVE로 남는다.
650648 const playing = phase === "live" ;
@@ -663,11 +661,11 @@ function Game({ g, opts, wait, grades, lv, stale, generatedAt, year, todayMember
663661 || ( outcome . source === "score" && g . sport === "sc" && lv ?. regular_time_score )
664662 || ( lv && lv . home_score != null && lv . away_score != null
665663 ? [ lv . home_score , lv . away_score ] : null ) ;
666- const analysis = wait || stale || predictionUnavailable || liveClosed
664+ const analysis = wait || stale || predictionUnavailable || started
667665 ? null : performanceAnalysis ( g , pick ?. o || null , displayCommentary ( g ) ) ;
668666 const decision = analysis ?. decision || buildDecisionViewModel ( g , pick ?. o || null ) ;
669667 const forecast = analysis ?. prediction ;
670- const fallbackForecast = disruption || ( g . _liveOddsChanged
668+ const fallbackForecast = disruption || ( started && ! saved ? "사전 예측 기록 없음" : g . _liveOddsChanged
671669 ? "배당 변경 · 재계산 대기"
672670 : stale
673671 ? "최신 데이터 확인 필요"
@@ -678,20 +676,8 @@ function Game({ g, opts, wait, grades, lv, stale, generatedAt, year, todayMember
678676 : wait
679677 ? "배당 발표 전"
680678 : "분석 자료 확인 중" ) ;
681- const openingProbability = Number (
682- decision ?. probability ?. final ?? pick ?. o ?. [ "예상적중확률" ] ?? pick ?. o ?. [ "시장확률" ] ,
683- ) ;
684- const liveProbability = playing && pick && Number . isFinite ( openingProbability )
685- ? estimateLiveProbability ( {
686- openingProbability,
687- game : { sport : g . sport } ,
688- selection : {
689- market : pick . o . market ,
690- label : pick . o . label || "" ,
691- choice : pick . o [ "선택" ] ,
692- } ,
693- } , lv )
694- : null ;
679+ const openingProbability = saved ?. openingProbability ?? null ;
680+ const liveProbability = playing ? saved ?. estimate : null ;
695681 const compactPlayers = compactTeamPlayerLine ( analysis ?. teamPreviews ) ;
696682 const pendingLabel = g . _liveOddsChanged ? "재계산" : stale ? "중단" : "보류" ;
697683 // 시작 전 산출물이 options=[]였던 경기는 해설에도 "배당 미발표"가 박혀 있다.
@@ -740,8 +726,10 @@ function Game({ g, opts, wait, grades, lv, stale, generatedAt, year, todayMember
740726 < span className = "flex gap-1.5" >
741727 { playing ? < >
742728 < span className = "live-score-badge" > < i /> LIVE < b > { lv . status_text || "진행 중" } </ b > </ span >
729+ { Number . isFinite ( openingProbability ) &&
730+ < OddsChip label = "사전 확률" value = { pct ( openingProbability ) } title = "경기 시작 전에 저장된 픽의 고정 확률" /> }
743731 { Number . isFinite ( liveProbability ?. probability ) &&
744- < OddsChip label = "현재 적중 " value = { pct ( liveProbability . probability ) }
732+ < OddsChip label = "현재 추정 " value = { pct ( liveProbability . probability ) }
745733 title = "사전 확률에 현재 점수와 남은 시간을 반영한 상황 추정치" /> }
746734 </ >
747735 : disruption ? < OddsChip label = "상태" value = { disruption . replace ( "경기 " , "" ) } />
@@ -764,6 +752,14 @@ function Game({ g, opts, wait, grades, lv, stale, generatedAt, year, todayMember
764752 </ span >
765753 </ summary >
766754 < div className = "match-detail" >
755+ { locked && saved && ! finished && (
756+ < div className = "mb-3 rounded border border-rule2 bg-panel px-3 py-2 text-[12px]" aria-label = "저장된 사전 예측" >
757+ < b > 경기 전 예측 픽 · { saved . option . market } { saved . option . label } { saved . option . 선택 } </ b >
758+ < p > 사전 확률 { Number . isFinite ( openingProbability ) ? pct ( openingProbability ) : "기록 없음" } · 당시 배당 { odds ( saved . option . 배당 ) } </ p >
759+ { playing && ! liveProbability && < small > 현재 추정 확률은 { saved . estimateStatus === "unsupported_market"
760+ ? "이 마켓에서 제공하지 않습니다." : "사전 확률과 최신 점수 자료가 모두 확인되어야 표시됩니다." } </ small > }
761+ </ div >
762+ ) }
767763 < MarketHistory rows = { g . _marketHistory } />
768764 { todayMembership && recommendationReason && (
769765 < div className = { `today-pick-signals ${ highlightedToday ? "is-recommended" : "is-excluded" } ` } role = "note" >
0 commit comments