11"use client" ;
22
3- /**
4- * One run's results, through the data source: the raw rows (for the detail
5- * modal) plus the table projection the grids render.
6- *
7- * Polls while the run is in flight and stops at a terminal status, so an open
8- * results tab fills in as stages land.
9- */
10- import { useCallback , useEffect , useRef , useState } from "react" ;
3+ import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
114import { useToast } from "@/app/hooks/useToast" ;
125import { useAssessmentData } from "@/app/hooks/useAssessmentData" ;
136import {
147 getAsyncErrorMessage ,
158 jsonResultsToTableData ,
169 normalizeStatus ,
1710} from "@/app/lib/assessment/results" ;
11+ import {
12+ buildColumnOrder ,
13+ mergeSubmissionInputs ,
14+ type SubmissionInputs ,
15+ } from "@/app/lib/assessment/inputJoin" ;
16+ import { loadSubmissionInputs } from "@/app/lib/assessment/submissionInputs" ;
1817import {
1918 RESULTS_POLL_INTERVAL_MS ,
2019 SPREADSHEET_PREVIEW_ROW_LIMIT ,
2120 TERMINAL_ASSESSMENT_STATUSES ,
2221} from "@/app/lib/assessment/constants" ;
2322import type {
23+ AssessmentConfigRef ,
2424 AssessmentStatusValue ,
2525 BatchCounts ,
2626 ResultsTarget ,
2727} from "@/app/lib/types/assessment" ;
2828
29+ /** A fetched extra plus the id it was fetched for, so a stale one is spottable. */
30+ interface OwnedBy < T > {
31+ owner : string ;
32+ value : T ;
33+ }
34+
2935export interface UseRunResultsResult {
3036 results : Record < string , unknown > [ ] ;
3137 headers : string [ ] ;
@@ -44,39 +50,43 @@ export function useRunResults(
4450 const toast = useToast ( ) ;
4551 const data = useAssessmentData ( ) ;
4652 const [ results , setResults ] = useState < Record < string , unknown > [ ] > ( [ ] ) ;
47- const [ table , setTable ] = useState < { headers : string [ ] ; rows : string [ ] [ ] } > ( {
48- headers : [ ] ,
49- rows : [ ] ,
50- } ) ;
5153 const [ status , setStatus ] = useState < AssessmentStatusValue | null > ( null ) ;
5254 const [ counts , setCounts ] = useState < BatchCounts | null > ( null ) ;
5355 const [ totalItems , setTotalItems ] = useState ( 0 ) ;
56+ const [ submissionId , setSubmissionId ] = useState < string | null > ( null ) ;
57+ const [ config , setConfig ] = useState < AssessmentConfigRef | null > ( null ) ;
58+ const [ inputs , setInputs ] = useState < OwnedBy < SubmissionInputs > | null > ( null ) ;
59+ const [ outputSchema , setOutputSchema ] = useState < OwnedBy < Record <
60+ string ,
61+ unknown
62+ > | null > | null > ( null ) ;
5463 const [ isLoading , setIsLoading ] = useState ( true ) ;
5564 const [ error , setError ] = useState < string | null > ( null ) ;
5665 const warnedRef = useRef ( false ) ;
57- const cancelledRef = useRef ( false ) ;
66+ const targetRef = useRef < string | null > ( null ) ;
5867
5968 const assessmentId = target ?. assessment_id ?? null ;
6069 const method = target ?. method ?? null ;
70+ const targetKey = assessmentId && method ? `${ assessmentId } :${ method } ` : null ;
71+ const configKey = config ? `${ config . id } @${ config . version } ` : null ;
6172
6273 const load = useCallback ( async ( ) => {
6374 if ( ! assessmentId || ! method ) return ;
75+ const startedFor = `${ assessmentId } :${ method } ` ;
76+ const isStale = ( ) => targetRef . current !== startedFor ;
6477 try {
6578 const payload = await data . getRunResults ( {
6679 assessment_id : assessmentId ,
6780 method,
6881 } ) ;
69- if ( cancelledRef . current ) return ;
82+ if ( isStale ( ) ) return ;
7083
7184 setResults ( payload . rows ) ;
7285 setStatus ( payload . status ) ;
7386 setCounts ( payload . counts ) ;
7487 setTotalItems ( payload . total_items ) ;
75- setTable (
76- jsonResultsToTableData ( payload . rows , {
77- rowLimit : SPREADSHEET_PREVIEW_ROW_LIMIT ,
78- } ) ,
79- ) ;
88+ setSubmissionId ( payload . submission_id ) ;
89+ setConfig ( payload . config ) ;
8090 setError ( null ) ;
8191
8292 if (
@@ -89,28 +99,98 @@ export function useRunResults(
8999 ) ;
90100 }
91101 } catch ( caught ) {
92- if ( ! cancelledRef . current ) {
93- setError ( getAsyncErrorMessage ( "load results" , caught ) ) ;
94- }
102+ if ( ! isStale ( ) ) setError ( getAsyncErrorMessage ( "load results" , caught ) ) ;
95103 } finally {
96- if ( ! cancelledRef . current ) setIsLoading ( false ) ;
104+ if ( ! isStale ( ) ) setIsLoading ( false ) ;
97105 }
98106 } , [ assessmentId , data , method , toast ] ) ;
99107
100108 useEffect ( ( ) => {
101- cancelledRef . current = false ;
102- if ( ! assessmentId ) {
109+ if ( ! assessmentId || ! targetKey ) {
103110 setError ( "Invalid assessment id." ) ;
104111 setIsLoading ( false ) ;
105112 return ;
106113 }
107114
115+ targetRef . current = targetKey ;
116+ // The previous run's rows are not this run's; show nothing until it loads.
117+ setResults ( [ ] ) ;
118+ setStatus ( null ) ;
119+ setCounts ( null ) ;
120+ setTotalItems ( 0 ) ;
121+ setSubmissionId ( null ) ;
122+ setConfig ( null ) ;
123+ warnedRef . current = false ;
108124 setIsLoading ( true ) ;
109125 void load ( ) ;
126+
127+ return ( ) => {
128+ targetRef . current = null ;
129+ } ;
130+ } , [ assessmentId , load , targetKey ] ) ;
131+
132+ // The source rows, once per submission. Immutable, so polling never refetches.
133+ useEffect ( ( ) => {
134+ if ( ! submissionId ) return ;
135+ let cancelled = false ;
136+
137+ void loadSubmissionInputs ( data , submissionId , totalItems )
138+ . then ( ( loaded ) => {
139+ if ( ! cancelled && loaded . records . length > 0 ) {
140+ setInputs ( { owner : submissionId , value : loaded } ) ;
141+ }
142+ } )
143+ . catch ( ( ) => {
144+ // Source columns are additive; without them the results still stand.
145+ } ) ;
146+
110147 return ( ) => {
111- cancelledRef . current = true ;
148+ cancelled = true ;
112149 } ;
113- } , [ assessmentId , load ] ) ;
150+ } , [ data , submissionId , totalItems ] ) ;
151+
152+ // The output schema fixes column order, so it follows the config, not the rows.
153+ useEffect ( ( ) => {
154+ if ( ! config ?. id || ! configKey ) return ;
155+ let cancelled = false ;
156+
157+ void data
158+ . getAssessorVersion ( config . id , config . version )
159+ . then ( ( version ) => {
160+ if ( ! cancelled ) {
161+ setOutputSchema ( { owner : configKey , value : version . output_schema } ) ;
162+ }
163+ } )
164+ . catch ( ( ) => {
165+ // Without a schema the columns keep their discovered order.
166+ } ) ;
167+
168+ return ( ) => {
169+ cancelled = true ;
170+ } ;
171+ } , [ config ?. id , config ?. version , configKey , data ] ) ;
172+
173+ // A fetch that outlived its run must not colour the next one.
174+ const ownInputs =
175+ inputs && inputs . owner === submissionId ? inputs . value : null ;
176+ const ownSchema =
177+ outputSchema && outputSchema . owner === configKey
178+ ? outputSchema . value
179+ : null ;
180+
181+ const joined = useMemo (
182+ ( ) => ( ownInputs ? mergeSubmissionInputs ( results , ownInputs ) : results ) ,
183+ [ ownInputs , results ] ,
184+ ) ;
185+
186+ const table = useMemo (
187+ ( ) =>
188+ jsonResultsToTableData ( joined , {
189+ rowLimit : SPREADSHEET_PREVIEW_ROW_LIMIT ,
190+ columnOrder : buildColumnOrder ( ownInputs ?. headers ?? [ ] , ownSchema ) ,
191+ } ) ,
192+ [ joined , ownInputs , ownSchema ] ,
193+ ) ;
114194
115195 const isPolling =
116196 status !== null &&
@@ -123,7 +203,7 @@ export function useRunResults(
123203 } , [ isPolling , load ] ) ;
124204
125205 return {
126- results,
206+ results : joined ,
127207 headers : table . headers ,
128208 rows : table . rows ,
129209 status,
0 commit comments