@@ -311,6 +311,104 @@ const TargetDetailPage: Component = () => {
311311
312312 const selectNoDates = ( ) => setSelectedChartDates ( [ ] ) ;
313313
314+ const [ csvCopied , setCsvCopied ] = createSignal ( false ) ;
315+ const [ csvLoading , setCsvLoading ] = createSignal ( false ) ;
316+
317+ const copyMultiSessionAstrobinCsv = async ( ) => {
318+ const dates = selectedChartDates ( ) ;
319+ if ( dates . length === 0 ) return ;
320+
321+ const cache = sessionCache ( ) ;
322+ const missingDates = dates . filter ( ( d ) => ! cache [ d ] ) ;
323+
324+ if ( missingDates . length > 0 ) {
325+ setCsvLoading ( true ) ;
326+ try {
327+ const results = await Promise . all (
328+ missingDates . map ( ( d ) => api . getSessionDetail ( params . targetId , d ) )
329+ ) ;
330+ const newCache = { ...sessionCache ( ) } ;
331+ missingDates . forEach ( ( d , i ) => {
332+ newCache [ d ] = results [ i ] ;
333+ } ) ;
334+ setSessionCache ( newCache ) ;
335+ } catch ( e : any ) {
336+ showToast ( e ?. message ?? "Failed to load session details" , "error" ) ;
337+ setCsvLoading ( false ) ;
338+ return ;
339+ }
340+ setCsvLoading ( false ) ;
341+ }
342+
343+ const aliasMap = ctx . filterAliasMap ( ) ;
344+ const abFilterIds = ctx . settings ( ) ?. general . astrobin_filter_ids ?? { } ;
345+ const bortle = ctx . settings ( ) ?. general . astrobin_bortle ?? "" ;
346+
347+ const lookupAbId = ( filterName : string ) => {
348+ const canonical = aliasMap [ filterName ] ?? filterName ;
349+ return abFilterIds [ canonical ] ?? abFilterIds [ filterName ] ?? "" ;
350+ } ;
351+
352+ const median = ( vals : number [ ] ) => {
353+ if ( vals . length === 0 ) return null ;
354+ const s = [ ...vals ] . sort ( ( a , b ) => a - b ) ;
355+ const mid = Math . floor ( s . length / 2 ) ;
356+ return s . length % 2 ? s [ mid ] : ( s [ mid - 1 ] + s [ mid ] ) / 2 ;
357+ } ;
358+
359+ const header =
360+ "date,filter,number,duration,binning,gain,sensorCooling,fNumber,bortle,meanSqm,meanFwhm,temperature" ;
361+ const allRows : string [ ] = [ ] ;
362+ const updatedCache = sessionCache ( ) ;
363+
364+ for ( const date of dates ) {
365+ const d = updatedCache [ date ] ;
366+ if ( ! d ) continue ;
367+
368+ const buildRows = (
369+ filterDetails : typeof d . filter_details ,
370+ gain : number | null ,
371+ sensorTemp : number | null ,
372+ fwhm : number | null ,
373+ ambientTemp : number | null
374+ ) => {
375+ for ( const f of filterDetails ) {
376+ const filterId = lookupAbId ( f . filter_name ) ;
377+ const g = gain !== null ? gain : "" ;
378+ const cooling = sensorTemp !== null ? Math . round ( sensorTemp ) : "" ;
379+ const mFwhm = fwhm !== null ? fwhm . toFixed ( 2 ) : "" ;
380+ const temp = ambientTemp !== null ? ambientTemp . toFixed ( 2 ) : "" ;
381+ allRows . push (
382+ `${ d . session_date } ,${ filterId } ,${ f . frame_count } ,${ f . exposure_time ?? "" } ,,${ g } ,${ cooling } ,,${ bortle } ,,${ mFwhm } ,${ temp } `
383+ ) ;
384+ }
385+ } ;
386+
387+ if ( d . rigs . length > 1 ) {
388+ for ( const rig of d . rigs ) {
389+ const temps = rig . frames
390+ . map ( ( f ) => f . sensor_temp )
391+ . filter ( ( t ) : t is number => t !== null ) ;
392+ const fwhms = rig . frames
393+ . map ( ( f ) => f . fwhm )
394+ . filter ( ( v ) : v is number => v !== null ) ;
395+ const ambTemps = rig . frames
396+ . map ( ( f ) => f . ambient_temp )
397+ . filter ( ( v ) : v is number => v !== null ) ;
398+ buildRows ( rig . filter_details , rig . gain , median ( temps ) , median ( fwhms ) , median ( ambTemps ) ) ;
399+ }
400+ } else {
401+ buildRows ( d . filter_details , d . gain , d . sensor_temp , d . median_fwhm , d . median_ambient_temp ) ;
402+ }
403+ }
404+
405+ const csv = [ header , ...allRows ] . join ( "\n" ) ;
406+ navigator . clipboard . writeText ( csv ) . then ( ( ) => {
407+ setCsvCopied ( true ) ;
408+ setTimeout ( ( ) => setCsvCopied ( false ) , 2000 ) ;
409+ } ) ;
410+ } ;
411+
314412 const pendingLoads = new Set < string > ( ) ;
315413 const MAX_CONCURRENT_LOADS = 2 ;
316414 const loadQueue : string [ ] = [ ] ;
@@ -879,32 +977,43 @@ const TargetDetailPage: Component = () => {
879977 Each card is one imaging session on this target. Expand a card to see per-frame details (exposure, filter, camera temperature, HFR, guiding). Example: compare two sessions of the same target to pick the better one for stacking.
880978 </ p >
881979 </ HelpPopover >
980+ < Show when = { selectedChartDates ( ) . length > 0 } >
981+ < button
982+ class = "ml-auto text-tiny px-2 py-0.5 border border-theme-border rounded text-theme-text-tertiary hover:text-theme-text-primary hover:border-theme-accent transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
983+ onClick = { copyMultiSessionAstrobinCsv }
984+ disabled = { csvLoading ( ) }
985+ >
986+ { csvCopied ( )
987+ ? "Copied!"
988+ : csvLoading ( )
989+ ? "Loading..."
990+ : `AstroBin CSV (${ selectedChartDates ( ) . length } )` }
991+ </ button >
992+ </ Show >
882993 </ div >
883994 < div class = "overflow-x-auto" >
884995 < table class = "w-full min-w-[600px]" style = { { "border-collapse" : "separate" , "border-spacing" : "0 10px" } } >
885996 < thead >
886997 < tr class = "text-caption text-theme-text-tertiary uppercase tracking-wider" >
887- < Show when = { targetChartExpanded ( ) } >
888- < th class = "py-2 pl-4 pr-1 w-8" >
889- < input
890- type = "checkbox"
891- checked = { selectedChartDates ( ) . length === detail ( ) . sessions . length }
892- ref = { ( el ) => {
893- createEffect ( ( ) => {
894- const len = selectedChartDates ( ) . length ;
895- const total = detail ( ) . sessions . length ;
896- el . indeterminate = len > 0 && len < total ;
897- } ) ;
898- } }
899- onChange = { ( e ) => {
900- if ( e . currentTarget . checked ) selectAllDates ( ) ;
901- else selectNoDates ( ) ;
902- } }
903- class = "w-3.5 h-3.5 rounded border-theme-border cursor-pointer"
904- title = "Select all / none"
905- />
906- </ th >
907- </ Show >
998+ < th class = "py-2 pl-4 pr-1 w-8" >
999+ < input
1000+ type = "checkbox"
1001+ checked = { selectedChartDates ( ) . length === detail ( ) . sessions . length }
1002+ ref = { ( el ) => {
1003+ createEffect ( ( ) => {
1004+ const len = selectedChartDates ( ) . length ;
1005+ const total = detail ( ) . sessions . length ;
1006+ el . indeterminate = len > 0 && len < total ;
1007+ } ) ;
1008+ } }
1009+ onChange = { ( e ) => {
1010+ if ( e . currentTarget . checked ) selectAllDates ( ) ;
1011+ else selectNoDates ( ) ;
1012+ } }
1013+ class = "w-3.5 h-3.5 rounded border-theme-border cursor-pointer"
1014+ title = "Select all / none"
1015+ />
1016+ </ th >
9081017 < th class = "py-2 px-4 text-left font-medium" > Date ({ tzLabel ( ) } )</ th >
9091018 < th class = "py-2 px-2 text-right font-medium" > </ th >
9101019 < th class = "py-2 px-2 text-right font-medium" > Frames</ th >
@@ -947,7 +1056,7 @@ const TargetDetailPage: Component = () => {
9471056 detected_stars : visible ( "quality" , "detected_stars" ) ,
9481057 guiding_rms : visible ( "guiding" , "rms_total" ) ,
9491058 } }
950- showCheckbox = { targetChartExpanded ( ) }
1059+ showCheckbox = { true }
9511060 checked = { selectedChartDates ( ) . includes ( session . session_date ) }
9521061 onCheckChange = { ( ) => toggleChartDate ( session . session_date ) }
9531062 targetId = { params . targetId }
0 commit comments