@@ -612,34 +612,38 @@ async function runQuery() {
612612 releasePrimary ( ) ;
613613 if ( ! alive ( ) ) return ;
614614
615- setStatus ( "Fetching related hashtags…" ) ;
616- const trending = await apiGet ( "hashtags" , param ( { limit : "50" } ) , ctrl . signal ) ;
617- if ( ! alive ( ) ) return ;
618- // Related hashtags = the OTHER tags on the same changesets; drop the exact searched tag(s).
619- const searched = new Set ( state . hashtags . map ( ( h ) => String ( h ) . replace ( / ^ # / , "" ) . toLowerCase ( ) ) ) ;
620- state . hashtagTrends = ( trending || [ ] ) . filter (
621- ( t ) => ! searched . has ( String ( t . hashtag ) . replace ( / ^ # / , "" ) . toLowerCase ( ) )
622- ) ;
623- if ( typeof renderHashtagPieChart === "function" ) renderHashtagPieChart ( ) ;
624-
625- setStatus ( "Fetching editors…" ) ;
626- await fetchEditorStats ( ) ;
627- if ( ! alive ( ) ) return ;
615+ // Each secondary section is isolated so one failure can't blank the others or the primary content.
616+ await runSection ( "related hashtags" , ctrl , async ( ) => {
617+ setStatus ( "Fetching related hashtags…" ) ;
618+ const trending = await apiGet ( "hashtags" , param ( { limit : "50" } ) , ctrl . signal ) ;
619+ if ( ! alive ( ) ) return ;
620+ // drop the exact searched tag(s); keep the co-occurring ones
621+ const searched = new Set ( state . hashtags . map ( ( h ) => String ( h ) . replace ( / ^ # / , "" ) . toLowerCase ( ) ) ) ;
622+ state . hashtagTrends = ( trending || [ ] ) . filter (
623+ ( t ) => ! searched . has ( String ( t . hashtag ) . replace ( / ^ # / , "" ) . toLowerCase ( ) )
624+ ) ;
625+ if ( typeof renderHashtagPieChart === "function" ) renderHashtagPieChart ( ) ;
626+ } ) ;
628627
629- setStatus ( "Fetching tag breakdown…" ) ;
630- const tags = await apiGet ( "tags" , param ( { limit : "200" } ) , ctrl . signal ) ;
631- if ( ! alive ( ) ) return ;
632- state . tagRows = tags ;
633- renderOverviewDetails ( ) ;
628+ await runSection ( "editors" , ctrl , async ( ) => {
629+ setStatus ( "Fetching editors…" ) ;
630+ await fetchEditorStats ( ) ;
631+ } ) ;
632+
633+ await runSection ( "tag breakdown" , ctrl , async ( ) => {
634+ setStatus ( "Fetching tag breakdown…" ) ;
635+ const tags = await apiGet ( "tags" , param ( { limit : "200" } ) , ctrl . signal ) ;
636+ if ( ! alive ( ) ) return ;
637+ state . tagRows = tags ;
638+ renderOverviewDetails ( ) ;
639+ } ) ;
634640 } catch ( err ) {
635- if ( err ?. name !== "AbortError" ) {
641+ // Primary failure: state it in every primary section instead of leaving blank skeletons.
642+ if ( err ?. name !== "AbortError" && alive ( ) ) {
636643 console . warn ( "OSMSG query failed:" , err ) ;
637- // Surface a busy shed even when the first (summary) call is the one rejected, so the user is not
638- // left staring at loading skeletons.
639- if ( err ?. busy && alive ( ) ) {
640- showError ( err ) ;
641- $ ( "#ov-strip-totals" ) . innerHTML = "" ;
642- }
644+ showError ( err ) ;
645+ sectionMessage ( "#podium" , busyOrErrorMsg ( err ) ) ;
646+ sectionMessage ( "#ov-strip-totals" , busyOrErrorMsg ( err ) ) ;
643647 }
644648 } finally {
645649 releasePrimary ( ) ;
@@ -705,6 +709,7 @@ async function loadLeaderboardPage(setPodium = false, forceFetch = false) {
705709 console . warn ( "OSMSG leaderboard fetch failed:" , err ) ;
706710 state . lastError = err ;
707711 showError ( err ) ;
712+ if ( setPodium ) sectionMessage ( "#podium" , busyOrErrorMsg ( err ) ) ;
708713 } finally {
709714 clearTimeout ( timeout ) ;
710715 if ( state . lbInflight === ctrl ) state . lbInflight = null ;
@@ -717,6 +722,25 @@ function onSectionError(section, err, ctrl) {
717722 console . warn ( `OSMSG ${ section } fetch failed:` , err ) ;
718723}
719724
725+ // Run one secondary section in isolation so its failure can't abort the query or blank another section.
726+ async function runSection ( name , ctrl , fn ) {
727+ try {
728+ await fn ( ) ;
729+ } catch ( err ) {
730+ onSectionError ( name , err , ctrl ) ;
731+ }
732+ }
733+
734+ function sectionMessage ( sel , msg ) {
735+ const el = $ ( sel ) ;
736+ if ( el ) el . innerHTML = `<div class="section-msg">${ escapeHtml ( msg ) } </div>` ;
737+ }
738+
739+ function busyOrErrorMsg ( err ) {
740+ const busy = err ?. busy || err ?. status === 503 || err ?. name === "AbortError" || state . summary ;
741+ return busy ? "Server is busy, give it a moment and hit Extract again." : "Couldn't load these stats." ;
742+ }
743+
720744let toastTimer ;
721745function toast ( { msg, icon = "info" , err = false } = { } ) {
722746 const t = $ ( "#toast" ) ;
@@ -1428,24 +1452,17 @@ function showLoading() {
14281452}
14291453function showError ( err ) {
14301454 const tb = $ ( "#lb-body" ) ;
1431- if ( err ?. busy ) {
1432- tb . innerHTML = `<tr><td colspan="8"><div class="errbox">
1433- <i data-lucide="hourglass"></i>
1434- <h3>Server is busy right now</h3>
1435- <p style="margin-top:8px;color:#717D78">Too many queries are running at once. Give it a moment, then hit Search again.</p>
1436- </div></td></tr>` ;
1437- $ ( "#pagination" ) . hidden = true ;
1438- refreshIcons ( tb ) ;
1439- return ;
1440- }
1441- const msg = err ?. message || "Network error" ;
1442- const isAbort = err ?. name === "AbortError" ;
1455+ // A loaded overview proves the API is reachable, so a failure here is a slow/busy query, not "unreachable".
1456+ const busy = err ?. busy || err ?. status === 503 || err ?. name === "AbortError" || ! ! state . summary ;
1457+ const body = busy
1458+ ? `<p style="margin-top:8px;color:var(--muted)">This is a large query and the server is busy right now. Give it a moment, then hit Extract again.</p>`
1459+ : `<p style="margin-top:8px"><code>${ escapeHtml ( err ?. message || "Network error" ) } </code></p>
1460+ <p style="margin-top:14px;color:var(--muted)">The API may be unreachable. Check your connection, then hit Extract to try again.</p>
1461+ <p style="margin-top:18px"><a href="${ API_BASE } /docs/swagger" target="_blank" rel="noopener">Open the API docs <i data-lucide="external-link" class="ico-sm" style="vertical-align:-2px"></i></a></p>` ;
14431462 tb . innerHTML = `<tr><td colspan="8"><div class="errbox">
1444- <i data-lucide="cloud-off"></i>
1445- <h3>${ isAbort ? "Request timed out" : "Couldn't reach the OSMSG API" } </h3>
1446- <p style="margin-top:8px"><code style="font-family:var(--mono);font-size:12px;background:#F4F0E6;padding:2px 6px;border-radius:4px;color:#3A4744">${ escapeHtml ( msg ) } </code></p>
1447- <p style="margin-top:14px;color:#717D78">If this is a CORS error and you're hosting this page off the API origin, the API needs to allow your origin. Hit Search to try again.</p>
1448- <p style="margin-top:18px"><a href="${ API_BASE } /docs/swagger" target="_blank" rel="noopener">Open the API docs <i data-lucide="external-link" class="ico-sm" style="vertical-align:-2px"></i></a></p>
1463+ <i data-lucide="${ busy ? "hourglass" : "cloud-off" } "></i>
1464+ <h3>${ busy ? "Server is busy" : "Couldn't reach the OSMSG API" } </h3>
1465+ ${ body }
14491466 </div></td></tr>` ;
14501467 $ ( "#pagination" ) . hidden = true ;
14511468 refreshIcons ( tb ) ;
0 commit comments