diff --git a/scripts/fetch-github-data.js b/scripts/fetch-github-data.js index 9747d90..9955a23 100644 --- a/scripts/fetch-github-data.js +++ b/scripts/fetch-github-data.js @@ -21,6 +21,17 @@ const AUTOMATION_LOGIN_PATTERNS = [ const isAutomationAccount = (login) => AUTOMATION_LOGIN_PATTERNS.some((pattern) => pattern.test(login)); +// A snapshot older than this gets a loud build warning. It is not a build +// failure on purpose: a transient API or rate-limit failure must not block a +// deploy. Visibility is the fix — the community section renders updatedAt. +const STALE_AFTER_DAYS = 7; + +const ageInDays = (isoDate) => { + const then = new Date(isoDate).getTime(); + if (Number.isNaN(then)) return null; + return Math.floor((Date.now() - then) / 86400000); +}; + async function fetchGitHubData() { console.log('Fetching GitHub repository data at build time...'); @@ -38,6 +49,13 @@ async function fetchGitHubData() { openIssues: '5+', closedIssues: '20+' }; + // Timestamp of the data itself. Advanced only when this run really refreshes + // it, so a failed fetch cannot make a stale snapshot look fresh. + let updatedAt = new Date().toISOString(); + // Whether this run replaced the preserved snapshot with fresh API data. + let contributorsOk = false; + let statsOk = false; + let refreshed = false; // Preserve existing data if available if (fs.existsSync(OUTPUT_FILE)) { @@ -49,6 +67,9 @@ async function fetchGitHubData() { if (existing.stats) { stats = existing.stats; } + if (existing.updatedAt) { + updatedAt = existing.updatedAt; + } } catch (e) { // Ignore read errors } @@ -68,6 +89,7 @@ async function fetchGitHubData() { avatar_url: user.avatar_url, html_url: user.html_url })); + contributorsOk = true; } } else { console.warn(`Contributors API returned ${contribRes.status}`); @@ -92,14 +114,35 @@ async function fetchGitHubData() { const closedIssues = await closedIssuesRes.json(); if (closedIssues.total_count !== undefined) stats.closedIssues = String(closedIssues.total_count); } + + statsOk = prsRes.ok && openIssuesRes.ok && closedIssuesRes.ok; } catch (error) { console.warn('Warning: Error fetching GitHub data at build time, using fallback values:', error.message); } + refreshed = contributorsOk && statsOk; + if (refreshed) { + updatedAt = new Date().toISOString(); + } else { + const age = ageInDays(updatedAt); + console.warn( + `[github-data] Refresh failed — the site will render the snapshot from ${updatedAt}` + + (age === null ? '.' : age > 0 ? ` (${age} day(s) old).` : ' (today).') + ); + if (age !== null && age > STALE_AFTER_DAYS) { + console.warn( + `[github-data] That snapshot is past the ${STALE_AFTER_DAYS}-day threshold. ` + + 'The community section shows its date, so the staleness is visible on the site.' + ); + } + } + const payload = { contributors, stats, - updatedAt: new Date().toISOString() + updatedAt, + // true when the numbers above come from a previous run instead of this one + stale: !refreshed }; fs.mkdirSync(path.dirname(OUTPUT_FILE), { recursive: true }); diff --git a/website/src/components/CommunitySection/MonthlyPulse.module.css b/website/src/components/CommunitySection/MonthlyPulse.module.css index 19bc0e9..42fb5cc 100644 --- a/website/src/components/CommunitySection/MonthlyPulse.module.css +++ b/website/src/components/CommunitySection/MonthlyPulse.module.css @@ -116,6 +116,16 @@ letter-spacing: 0.5px; } +/* Timestamp of the snapshot the numbers come from — pulls up into the space + the grid reserves below itself so it reads as a caption to the stats. */ +.dataAge { + margin: -1.5rem 0 0; + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.65); + position: relative; + z-index: 2; +} + @media (max-width: 480px) { .pulseGrid { gap: 1rem; diff --git a/website/src/components/CommunitySection/MonthlyPulse.tsx b/website/src/components/CommunitySection/MonthlyPulse.tsx index db2ebd9..1b6145a 100644 --- a/website/src/components/CommunitySection/MonthlyPulse.tsx +++ b/website/src/components/CommunitySection/MonthlyPulse.tsx @@ -2,6 +2,23 @@ import React from 'react'; import styles from './MonthlyPulse.module.css'; import githubData from '@site/src/data/githubData.json'; +const MONTHS = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' +]; + +/** + * Format the snapshot timestamp in UTC from the ISO string. + * Doing it by hand instead of toLocaleDateString keeps the server-rendered and + * client-rendered output identical, so the date can never mismatch on hydration. + */ +function formatSnapshotDate(updatedAt?: string): string | null { + if (!updatedAt) return null; + const snapshot = new Date(updatedAt); + if (Number.isNaN(snapshot.getTime())) return null; + return `${snapshot.getUTCDate()} ${MONTHS[snapshot.getUTCMonth()]} ${snapshot.getUTCFullYear()}`; +} + export default function MonthlyPulse() { const stats = { mergedPRs: githubData.stats?.mergedPRs || '15+', @@ -9,6 +26,10 @@ export default function MonthlyPulse() { closedIssues: githubData.stats?.closedIssues || '20+' }; + const snapshotDate = formatSnapshotDate(githubData.updatedAt); + // The committed snapshot predates the `stale` field, so read it defensively. + const isStale = (githubData as { stale?: boolean }).stale === true; + return (
+ Data as of {snapshotDate} + {isStale ? ' — GitHub API unavailable at build time' : ''} +
+ )} ); }