From c7f0ae6392c71febd119d5f3ac3b857b4e06558d Mon Sep 17 00:00:00 2001 From: clavin Date: Sat, 1 Nov 2025 18:24:43 -0700 Subject: [PATCH 01/12] Remove redundant border on history page --- app/routes/history.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routes/history.tsx b/app/routes/history.tsx index eeb7b48..76f661c 100644 --- a/app/routes/history.tsx +++ b/app/routes/history.tsx @@ -206,7 +206,7 @@ export default function ReleaseHistory() {
-
+
Stable Release From 0ebb7e66d96537ac3d12db63c18fb7736d00af5e Mon Sep 17 00:00:00 2001 From: clavin Date: Sat, 1 Nov 2025 18:25:42 -0700 Subject: [PATCH 02/12] Add schedule page, data fetching, caching, and estimation logic --- app/data/dash/chromium-schedule.ts | 54 ++++++ app/data/release-schedule.ts | 291 +++++++++++++++++++++++++++++ app/helpers/version.ts | 46 +++++ app/root.tsx | 11 +- app/routes.ts | 1 + app/routes/schedule.tsx | 169 +++++++++++++++++ 6 files changed, 571 insertions(+), 1 deletion(-) create mode 100644 app/data/dash/chromium-schedule.ts create mode 100644 app/data/release-schedule.ts create mode 100644 app/helpers/version.ts create mode 100644 app/routes/schedule.tsx diff --git a/app/data/dash/chromium-schedule.ts b/app/data/dash/chromium-schedule.ts new file mode 100644 index 0000000..55f10af --- /dev/null +++ b/app/data/dash/chromium-schedule.ts @@ -0,0 +1,54 @@ +import memoize from '@keyvhq/memoize'; +import { getKeyvCache } from '../cache'; + +export interface ChromiumMilestoneSchedule { + earliestBeta: string; // YYYY-MM-DD + stableDate: string; // YYYY-MM-DD +} + +interface ChromiumDashResponse { + mstones: Array<{ + earliest_beta: string; // ISO timestamp + stable_date: string; // ISO timestamp + mstone: number; + }>; +} + +/** + * Fetch Chromium milestone schedule from chromiumdash API. + * @param milestone - Chromium milestone number (e.g., 140) + * @returns Object with earliestBeta and stableDate in YYYY-MM-DD format + */ +export const getMilestoneSchedule = memoize( + async (milestone: number): Promise => { + const response = await fetch( + `https://chromiumdash.appspot.com/fetch_milestone_schedule?mstone=${milestone}`, + ); + + if (!response.ok) { + throw new Error( + `Failed to fetch Chromium schedule for milestone ${milestone}: ${response.status}`, + ); + } + + const data = (await response.json()) as ChromiumDashResponse; + + if (!data.mstones || !Array.isArray(data.mstones) || data.mstones.length === 0) { + throw new Error(`No schedule data found for Chromium milestone ${milestone}`); + } + + const schedule = data.mstones[0]; + + return { + earliestBeta: schedule.earliest_beta.split('T')[0], + stableDate: schedule.stable_date.split('T')[0], + }; + }, + getKeyvCache('chromium-schedule'), + { + // Cache for 6 hours + ttl: 6 * 60 * 60 * 1_000, + // At 5 hours, refetch but serve stale data + staleTtl: 60 * 60 * 1_000, + }, +); diff --git a/app/data/release-schedule.ts b/app/data/release-schedule.ts new file mode 100644 index 0000000..0629137 --- /dev/null +++ b/app/data/release-schedule.ts @@ -0,0 +1,291 @@ +import { parse as parseSemver } from 'semver'; +import memoize from '@keyvhq/memoize'; +import { ElectronRelease, getReleasesOrUpdate } from './release-data'; +import { extractChromiumMilestone, extractNodeVersion, getPrereleaseType } from '~/helpers/version'; +import { getMilestoneSchedule } from './dash/chromium-schedule'; +import { getKeyvCache } from './cache'; + +export interface MajorReleaseSchedule { + version: string; // `${major}.0.0` + alphaDate: string | null; // YYYY-MM-DD -- some old versions didn't have alpha releases + betaDate: string; // YYYY-MM-DD + stableDate: string; // YYYY-MM-DD + eolDate: string; // YYYY-MM-DD + chromiumVersion: number; // milestone, aka major version + nodeVersion: string; // `${major}.${minor}` + status: 'active' | 'prerelease' | 'eol'; +} + +type AbsoluteMajorReleaseSchedule = Omit; + +interface MajorReleaseGroup { + major: number; + releases: ElectronRelease[]; + firstStable?: ElectronRelease; // Only used for Chromium milestone extraction +} + +const SCHEDULE_OVERRIDES: Map> = new Map([ + [ + '2.0.0', + { + betaDate: '2018-02-21', + stableDate: '2018-05-01', + }, + ], + [ + '3.0.0', + { + betaDate: '2018-06-21', + stableDate: '2018-09-18', + }, + ], + [ + '4.0.0', + { + betaDate: '2018-10-11', + stableDate: '2018-12-20', + }, + ], + [ + '5.0.0', + { + betaDate: '2019-01-22', + stableDate: '2019-04-23', + }, + ], + [ + '6.0.0', + { + betaDate: '2019-04-25', + }, + ], + [ + '15.0.0', + { + alphaDate: '2021-07-20', + }, + ], +]); + +/** + * Add days to a date string in YYYY-MM-DD format. + */ +const addDays = (dateString: string, days: number): string => { + const date = new Date(dateString + 'T00:00:00'); + date.setDate(date.getDate() + days); + return date.toISOString().split('T')[0]; +}; + +// Determine support window: 4 for v12-15, 3 for the rest +const getSupportWindow = (major: number): number => { + return major >= 12 && major <= 15 ? 4 : 3; +}; + +/** + * Get absolute schedule data (cacheable, not time-dependent). + */ +export const getAbsoluteSchedule = memoize( + async (): Promise => { + const allReleases = await getReleasesOrUpdate(); + + // Group releases by major version (filter to >= 2) + const majorGroups = new Map(); + + for (const release of allReleases) { + const major = parseSemver(release.version)?.major; + if (!major || major < 2) continue; + + if (!majorGroups.has(major)) { + majorGroups.set(major, { major, releases: [] }); + } + + const group = majorGroups.get(major)!; + group.releases.push(release); + + const prereleaseType = getPrereleaseType(release.version); + + // Track first stable release (last in iteration = first chronologically) + // Only used for extracting Chromium milestone + if (prereleaseType === 'stable') { + group.firstStable = release; + } + } + + // Build milestone map in forward pass + const milestoneMap = new Map(); + const sortedMajors = Array.from(majorGroups.keys()).sort((a, b) => a - b); + + for (const major of sortedMajors) { + const group = majorGroups.get(major)!; + + if (group.firstStable) { + // Use actual Chromium version from stable release + const milestone = extractChromiumMilestone(group.firstStable.chrome); + milestoneMap.set(major, milestone); + } else { + // Estimate: M(V) = M(V-1) + 2 + const prevMajor = major - 1; + const prevMilestone = milestoneMap.get(prevMajor); + + if (!prevMilestone) { + throw new Error( + `Cannot determine Chromium milestone for Electron ${major}: no stable release and no previous milestone`, + ); + } + + milestoneMap.set(major, prevMilestone + 2); + } + } + + // Helper to get stable date estimate + const getStableDate = async (major: number): Promise => { + const milestone = milestoneMap.get(major); + if (!milestone) { + throw new Error(`No milestone found for major ${major}`); + } + + const schedule = await getMilestoneSchedule(milestone); + return schedule.stableDate; + }; + + // Helper to get alpha date estimate + const getAlphaDate = async (major: number): Promise => { + // Estimate: previous major's stable + 2 days + // Note: There is no exact rule for alpha release dates at this time, + // but historically they have been released ~2 days after the previous stable. + const prevMajor = major - 1; + const prevStableDate = await getStableDate(prevMajor); + return addDays(prevStableDate, 2); + }; + + // Build absolute schedule data for each major + const schedule: AbsoluteMajorReleaseSchedule[] = []; + + for (const major of sortedMajors) { + const milestone = milestoneMap.get(major)!; + + // Get beta date from Chromium schedule + const chromiumSchedule = await getMilestoneSchedule(milestone); + const betaDate = chromiumSchedule.earliestBeta; + + // Get stable date from Chromium schedule + const stableDate = chromiumSchedule.stableDate; + + // Get alpha date (null for v14 and earlier, estimated for v15+) + let alphaDate: string | null; + if (major <= 14) { + // No alpha releases before v15 + alphaDate = null; + } else { + alphaDate = await getAlphaDate(major); + } + + // Extract Node.js version from latest release in major + const latestRelease = majorGroups.get(major)!.releases[0]; // Already sorted newest first + const nodeVersion = extractNodeVersion(latestRelease.node); + + const entry: AbsoluteMajorReleaseSchedule = { + version: `${major}.0.0`, + alphaDate, + betaDate, + stableDate, + chromiumVersion: milestone, + nodeVersion, + eolDate: '', // Placeholder, will be calculated + }; + + // Apply overrides early so they cascade to dependent calculations + const override = SCHEDULE_OVERRIDES.get(entry.version); + if (override) { + Object.assign(entry, override); + } + + schedule.push(entry); + } + + // Helper to get stable date for EOL calculation + const getStableDateForEOL = async (major: number): Promise => { + const entry = schedule.find((r) => r.version === `${major}.0.0`); + if (entry) { + return entry.stableDate; + } + + // Need to estimate - this major doesn't exist in our data yet + const maxMajor = Math.max(...schedule.map((r) => parseInt(r.version.split('.')[0], 10))); + const maxEntry = schedule.find((r) => r.version === `${maxMajor}.0.0`); + if (!maxEntry) { + throw new Error(`Cannot extrapolate milestone for major ${major}`); + } + + const diff = major - maxMajor; + const milestone = maxEntry.chromiumVersion + diff * 2; + const chromiumSchedule = await getMilestoneSchedule(milestone); + return chromiumSchedule.stableDate; + }; + + // Calculate EOL dates for all entries + for (const entry of schedule) { + const major = parseInt(entry.version.split('.')[0], 10); + const eolMajor = major + getSupportWindow(major); + entry.eolDate = await getStableDateForEOL(eolMajor); + } + + return schedule; + }, + getKeyvCache('absolute-schedule'), + { + // Cache for 60 seconds + ttl: 60_000, + // At 10 seconds, refetch but serve stale data + staleTtl: 10_000, + }, +); + +/** + * Get relative schedule data (time-dependent, includes status and EOL). + */ +export async function getRelativeSchedule(): Promise { + // Find latest major version + const allReleases = await getReleasesOrUpdate(); + const latestStableMajor = parseInt( + allReleases + .find((release) => getPrereleaseType(release.version) === 'stable') + ?.version.split('.')[0] || '0', + 10, + ); + + // Build final schedule with status + const absoluteData = await getAbsoluteSchedule(); + const schedule: MajorReleaseSchedule[] = []; + + for (const entry of absoluteData) { + const major = parseInt(entry.version.split('.')[0], 10); + + // Determine status based on support window + let status: 'active' | 'prerelease' | 'eol'; + const latestSupportWindow = getSupportWindow(latestStableMajor); + + if (major > latestStableMajor) { + // Future release + status = 'prerelease'; + } else if (major >= latestStableMajor - latestSupportWindow + 1) { + // Within support window + status = 'active'; + } else { + // Outside support window + status = 'eol'; + } + + schedule.push({ + ...entry, + status, + }); + } + + // Sort descending by major version + return schedule.sort((a, b) => { + const aMajor = parseInt(a.version.split('.')[0], 10); + const bMajor = parseInt(b.version.split('.')[0], 10); + return bMajor - aMajor; + }); +} diff --git a/app/helpers/version.ts b/app/helpers/version.ts new file mode 100644 index 0000000..aedfe77 --- /dev/null +++ b/app/helpers/version.ts @@ -0,0 +1,46 @@ +import { parse as parseSemver } from 'semver'; + +/** + * Get the prerelease type from a version string. + * @returns 'alpha', 'beta', 'nightly', 'stable', or undefined for unknown prerelease types + */ +export function getPrereleaseType( + version: string, +): 'alpha' | 'beta' | 'nightly' | 'stable' | undefined { + const parsed = parseSemver(version); + if (!parsed || parsed.prerelease.length === 0) { + return 'stable'; + } + const prereleaseType = parsed.prerelease[0]; + if (prereleaseType === 'alpha' || prereleaseType === 'beta' || prereleaseType === 'nightly') { + return prereleaseType; + } + // Silently ignore unknown prerelease types + return undefined; +} + +/** + * Extract Node.js major.minor version from a Node version string. + * @param nodeString - Node version string like "20.11.1" or "22.14.0" + * @returns Node version in "major.minor" format like "20.11" + */ +export function extractNodeVersion(nodeString: string): string { + const parts = nodeString.split('.'); + if (parts.length < 2) { + throw new Error(`Invalid Node.js version string: ${nodeString}`); + } + return `${parts[0]}.${parts[1]}`; +} + +/** + * Extract Chromium milestone (major version) from a Chrome version string. + * @param chromeString - Chrome version string like "116.0.5845.190" + * @returns Chromium milestone like 116 + */ +export function extractChromiumMilestone(chromeString: string): number { + const milestone = parseInt(chromeString.split('.')[0], 10); + if (isNaN(milestone)) { + throw new Error(`Invalid Chrome version string: ${chromeString}`); + } + return milestone; +} diff --git a/app/root.tsx b/app/root.tsx index 06a3f6a..9826631 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -3,7 +3,7 @@ import type { LinksFunction } from '@remix-run/node'; import './tailwind.css'; import { Logo } from '~/components/Logo'; -import { ArrowUpRight, History, Search } from 'lucide-react'; +import { ArrowUpRight, History, Search, CalendarPlus } from 'lucide-react'; import { useEffect } from 'react'; export const links: LinksFunction = () => []; @@ -22,6 +22,15 @@ const nav = [ ), path: '/history', }, + { + title: ( + <> + + Schedule + + ), + path: '/schedule', + }, { title: ( <> diff --git a/app/routes.ts b/app/routes.ts index 7e37a32..dbdf93f 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -28,4 +28,5 @@ export default [ route('compare/:fromVersion/:toVersion', 'routes/release/compare.tsx'), route(':version', 'routes/release/single.tsx'), ]), + route('schedule', 'routes/schedule.tsx'), ] satisfies RouteConfig; diff --git a/app/routes/schedule.tsx b/app/routes/schedule.tsx new file mode 100644 index 0000000..9eb8b12 --- /dev/null +++ b/app/routes/schedule.tsx @@ -0,0 +1,169 @@ +import type { LoaderFunctionArgs, MetaFunction } from '@remix-run/node'; +import { useLoaderData } from '@remix-run/react'; +import { Calendar, Info } from 'lucide-react'; +import { getRelativeSchedule, type MajorReleaseSchedule } from '~/data/release-schedule'; +import { prettyReleaseDate } from '~/helpers/time'; +import { guessTimeZoneFromRequest } from '~/helpers/timezone'; + +export const meta: MetaFunction = () => [ + { title: 'Schedule | Electron Releases' }, + { + name: 'description', + content: 'Schedule of Electron releases, from the next upcoming releases back to version 2.', + }, +]; + +function FormatDate({ + children: releaseDate, + timeZone, +}: { + children: string | null; // YYYY-MM-DD + timeZone: string; +}) { + if (releaseDate === null) { + return ; + } + + return {prettyReleaseDate({ fullDate: releaseDate + 'T00:00:00' }, timeZone)}; +} + +export const loader = async (args: LoaderFunctionArgs) => { + const timeZone = guessTimeZoneFromRequest(args.request); + const releases = await getRelativeSchedule(); + args.context.cacheControl = 'private, max-age=120'; + return { releases, timeZone }; +}; + +function Release({ release, timeZone }: { release: MajorReleaseSchedule; timeZone: string }) { + const statusColor = + release.status === 'prerelease' + ? 'bg-yellow-500' + : release.status === 'active' + ? 'bg-green-500' + : 'bg-slate-500'; + + return ( + + +
+
+ {release.version} +
+ + + {release.alphaDate} + + + {release.betaDate} + + + {release.stableDate} + + + {release.eolDate} + + + M{release.chromiumVersion} + + + v{release.nodeVersion} + {release.status === 'prerelease' ? '+' : ''} + + + ); +} + +export default function Schedule() { + const { releases, timeZone } = useLoaderData(); + + return ( +
+
+

+ + Release Schedule +

+

+ A complete schedule of Electron major releases showing key milestones including alpha, + beta, and stable release dates, as well as end-of-life dates and dependency versions.{' '} + + Learn more about Electron's release schedule + + . +

+
+ +
+
+
+
+ Stable (Supported) +
+
+
+ Prerelease +
+
+
+ End of Life +
+
+
+ +
+
+
+ + + + + + + + + + + + + + {releases.map((release) => ( + + ))} + +
+ Release + + Alpha + + Beta + + Stable + + End of Life + + Chromium + + Node.js +
+
+
+
+ +
+ +

+ Release dates are goals and may be adjusted at any time for significant reasons, such as + security bugfixes. +

+
+
+ ); +} From f46447c4f348991ff7409caed6d96e833ec2e5b1 Mon Sep 17 00:00:00 2001 From: clavin Date: Sat, 1 Nov 2025 18:42:52 -0700 Subject: [PATCH 03/12] Remove unclear `+` indicator, adjust disclaimer wording instead --- app/routes/schedule.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/routes/schedule.tsx b/app/routes/schedule.tsx index 9eb8b12..dd3c054 100644 --- a/app/routes/schedule.tsx +++ b/app/routes/schedule.tsx @@ -70,7 +70,6 @@ function Release({ release, timeZone }: { release: MajorReleaseSchedule; timeZon v{release.nodeVersion} - {release.status === 'prerelease' ? '+' : ''} ); @@ -161,7 +160,8 @@ export default function Schedule() {

Release dates are goals and may be adjusted at any time for significant reasons, such as - security bugfixes. + security bugfixes. Prerelease dependency versions (Chromium, Node.js) are estimates and + may be upgraded before the stable release.

From ca863971533cd6c363628418a548f2112ac344fe Mon Sep 17 00:00:00 2001 From: clavin Date: Sat, 1 Nov 2025 18:54:04 -0700 Subject: [PATCH 04/12] Simplify release-schedule.ts --- app/data/release-schedule.ts | 126 +++++++++-------------------------- 1 file changed, 33 insertions(+), 93 deletions(-) diff --git a/app/data/release-schedule.ts b/app/data/release-schedule.ts index 0629137..0f11f82 100644 --- a/app/data/release-schedule.ts +++ b/app/data/release-schedule.ts @@ -67,15 +67,6 @@ const SCHEDULE_OVERRIDES: Map> = n ], ]); -/** - * Add days to a date string in YYYY-MM-DD format. - */ -const addDays = (dateString: string, days: number): string => { - const date = new Date(dateString + 'T00:00:00'); - date.setDate(date.getDate() + days); - return date.toISOString().split('T')[0]; -}; - // Determine support window: 4 for v12-15, 3 for the rest const getSupportWindow = (major: number): number => { return major >= 12 && major <= 15 ? 4 : 3; @@ -137,64 +128,39 @@ export const getAbsoluteSchedule = memoize( } } - // Helper to get stable date estimate - const getStableDate = async (major: number): Promise => { - const milestone = milestoneMap.get(major); - if (!milestone) { - throw new Error(`No milestone found for major ${major}`); - } - - const schedule = await getMilestoneSchedule(milestone); - return schedule.stableDate; - }; - - // Helper to get alpha date estimate - const getAlphaDate = async (major: number): Promise => { - // Estimate: previous major's stable + 2 days - // Note: There is no exact rule for alpha release dates at this time, - // but historically they have been released ~2 days after the previous stable. - const prevMajor = major - 1; - const prevStableDate = await getStableDate(prevMajor); - return addDays(prevStableDate, 2); - }; - // Build absolute schedule data for each major const schedule: AbsoluteMajorReleaseSchedule[] = []; for (const major of sortedMajors) { const milestone = milestoneMap.get(major)!; - - // Get beta date from Chromium schedule const chromiumSchedule = await getMilestoneSchedule(milestone); - const betaDate = chromiumSchedule.earliestBeta; - // Get stable date from Chromium schedule - const stableDate = chromiumSchedule.stableDate; + // Alpha: previous major's stable + 2 days (null for v14 and earlier) + let alphaDate: string | null = null; + if (major > 14) { + const prevMilestone = milestoneMap.get(major - 1)!; + const prevSchedule = await getMilestoneSchedule(prevMilestone); - // Get alpha date (null for v14 and earlier, estimated for v15+) - let alphaDate: string | null; - if (major <= 14) { - // No alpha releases before v15 - alphaDate = null; - } else { - alphaDate = await getAlphaDate(major); + // Add 2 days to previous stable date + const prevStable = new Date(prevSchedule.stableDate + 'T00:00:00'); + prevStable.setDate(prevStable.getDate() + 2); + alphaDate = prevStable.toISOString().split('T')[0]; } - // Extract Node.js version from latest release in major - const latestRelease = majorGroups.get(major)!.releases[0]; // Already sorted newest first + const latestRelease = majorGroups.get(major)!.releases[0]; const nodeVersion = extractNodeVersion(latestRelease.node); const entry: AbsoluteMajorReleaseSchedule = { version: `${major}.0.0`, alphaDate, - betaDate, - stableDate, + betaDate: chromiumSchedule.earliestBeta, + stableDate: chromiumSchedule.stableDate, chromiumVersion: milestone, nodeVersion, eolDate: '', // Placeholder, will be calculated }; - // Apply overrides early so they cascade to dependent calculations + // Apply overrides early so they cascade to dependent calculations (e.g. EOL) const override = SCHEDULE_OVERRIDES.get(entry.version); if (override) { Object.assign(entry, override); @@ -203,31 +169,22 @@ export const getAbsoluteSchedule = memoize( schedule.push(entry); } - // Helper to get stable date for EOL calculation - const getStableDateForEOL = async (major: number): Promise => { - const entry = schedule.find((r) => r.version === `${major}.0.0`); - if (entry) { - return entry.stableDate; - } - - // Need to estimate - this major doesn't exist in our data yet - const maxMajor = Math.max(...schedule.map((r) => parseInt(r.version.split('.')[0], 10))); - const maxEntry = schedule.find((r) => r.version === `${maxMajor}.0.0`); - if (!maxEntry) { - throw new Error(`Cannot extrapolate milestone for major ${major}`); - } - - const diff = major - maxMajor; - const milestone = maxEntry.chromiumVersion + diff * 2; - const chromiumSchedule = await getMilestoneSchedule(milestone); - return chromiumSchedule.stableDate; - }; - - // Calculate EOL dates for all entries + // Calculate EOL dates for (const entry of schedule) { const major = parseInt(entry.version.split('.')[0], 10); const eolMajor = major + getSupportWindow(major); - entry.eolDate = await getStableDateForEOL(eolMajor); + const eolEntry = schedule.find((r) => r.version === `${eolMajor}.0.0`); + + if (eolEntry) { + entry.eolDate = eolEntry.stableDate; + } else { + // Extrapolate for future versions + const maxMajor = Math.max(...schedule.map((r) => parseInt(r.version.split('.')[0], 10))); + const maxEntry = schedule.find((r) => r.version === `${maxMajor}.0.0`)!; + const milestone = maxEntry.chromiumVersion + (eolMajor - maxMajor) * 2; // 2 milestones per major + const eolSchedule = await getMilestoneSchedule(milestone); + entry.eolDate = eolSchedule.stableDate; + } } return schedule; @@ -254,33 +211,16 @@ export async function getRelativeSchedule(): Promise { 10, ); - // Build final schedule with status const absoluteData = await getAbsoluteSchedule(); - const schedule: MajorReleaseSchedule[] = []; + const supportWindow = getSupportWindow(latestStableMajor); + const minActiveMajor = latestStableMajor - supportWindow + 1; - for (const entry of absoluteData) { + const schedule: MajorReleaseSchedule[] = absoluteData.map((entry) => { const major = parseInt(entry.version.split('.')[0], 10); - - // Determine status based on support window - let status: 'active' | 'prerelease' | 'eol'; - const latestSupportWindow = getSupportWindow(latestStableMajor); - - if (major > latestStableMajor) { - // Future release - status = 'prerelease'; - } else if (major >= latestStableMajor - latestSupportWindow + 1) { - // Within support window - status = 'active'; - } else { - // Outside support window - status = 'eol'; - } - - schedule.push({ - ...entry, - status, - }); - } + const status = + major > latestStableMajor ? 'prerelease' : major >= minActiveMajor ? 'active' : 'eol'; + return { ...entry, status }; + }); // Sort descending by major version return schedule.sort((a, b) => { From 1b1ddeb5dd6d0c3e4a675c227c5139162e7a41e4 Mon Sep 17 00:00:00 2001 From: clavin Date: Sun, 2 Nov 2025 12:43:07 -0800 Subject: [PATCH 05/12] Use full semver for Node.js version --- app/data/release-schedule.ts | 10 +++++----- app/helpers/version.ts | 13 ------------- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/app/data/release-schedule.ts b/app/data/release-schedule.ts index 0f11f82..cee43e9 100644 --- a/app/data/release-schedule.ts +++ b/app/data/release-schedule.ts @@ -1,7 +1,7 @@ import { parse as parseSemver } from 'semver'; import memoize from '@keyvhq/memoize'; import { ElectronRelease, getReleasesOrUpdate } from './release-data'; -import { extractChromiumMilestone, extractNodeVersion, getPrereleaseType } from '~/helpers/version'; +import { extractChromiumMilestone, getPrereleaseType } from '~/helpers/version'; import { getMilestoneSchedule } from './dash/chromium-schedule'; import { getKeyvCache } from './cache'; @@ -12,7 +12,7 @@ export interface MajorReleaseSchedule { stableDate: string; // YYYY-MM-DD eolDate: string; // YYYY-MM-DD chromiumVersion: number; // milestone, aka major version - nodeVersion: string; // `${major}.${minor}` + nodeVersion: string; // full semver status: 'active' | 'prerelease' | 'eol'; } @@ -147,8 +147,8 @@ export const getAbsoluteSchedule = memoize( alphaDate = prevStable.toISOString().split('T')[0]; } - const latestRelease = majorGroups.get(major)!.releases[0]; - const nodeVersion = extractNodeVersion(latestRelease.node); + const group = majorGroups.get(major)!; + const latestRelease = group.releases[0]; const entry: AbsoluteMajorReleaseSchedule = { version: `${major}.0.0`, @@ -156,7 +156,7 @@ export const getAbsoluteSchedule = memoize( betaDate: chromiumSchedule.earliestBeta, stableDate: chromiumSchedule.stableDate, chromiumVersion: milestone, - nodeVersion, + nodeVersion: group.firstStable?.node ?? latestRelease.node, eolDate: '', // Placeholder, will be calculated }; diff --git a/app/helpers/version.ts b/app/helpers/version.ts index aedfe77..06f7d72 100644 --- a/app/helpers/version.ts +++ b/app/helpers/version.ts @@ -19,19 +19,6 @@ export function getPrereleaseType( return undefined; } -/** - * Extract Node.js major.minor version from a Node version string. - * @param nodeString - Node version string like "20.11.1" or "22.14.0" - * @returns Node version in "major.minor" format like "20.11" - */ -export function extractNodeVersion(nodeString: string): string { - const parts = nodeString.split('.'); - if (parts.length < 2) { - throw new Error(`Invalid Node.js version string: ${nodeString}`); - } - return `${parts[0]}.${parts[1]}`; -} - /** * Extract Chromium milestone (major version) from a Chrome version string. * @param chromeString - Chrome version string like "116.0.5845.190" From 9ad78c8d9aedae5813c999054fbb3f85bbdcde7e Mon Sep 17 00:00:00 2001 From: clavin Date: Tue, 4 Nov 2025 21:53:02 -0800 Subject: [PATCH 06/12] Link to Chrome & Node.js release notes --- app/routes/schedule.tsx | 43 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/app/routes/schedule.tsx b/app/routes/schedule.tsx index dd3c054..152dadb 100644 --- a/app/routes/schedule.tsx +++ b/app/routes/schedule.tsx @@ -1,6 +1,7 @@ import type { LoaderFunctionArgs, MetaFunction } from '@remix-run/node'; import { useLoaderData } from '@remix-run/react'; -import { Calendar, Info } from 'lucide-react'; +import { ArrowUpRight, Calendar, Info } from 'lucide-react'; +import React from 'react'; import { getRelativeSchedule, type MajorReleaseSchedule } from '~/data/release-schedule'; import { prettyReleaseDate } from '~/helpers/time'; import { guessTimeZoneFromRequest } from '~/helpers/timezone'; @@ -27,6 +28,32 @@ function FormatDate({ return {prettyReleaseDate({ fullDate: releaseDate + 'T00:00:00' }, timeZone)}; } +function DependencyRelease({ + href, + release, + children, +}: { + href: string; + release: MajorReleaseSchedule; + children: React.ReactNode; +}) { + if (release.status === 'prerelease') { + return {children}; + } else { + return ( + + {children} + + + ); + } +} + export const loader = async (args: LoaderFunctionArgs) => { const timeZone = guessTimeZoneFromRequest(args.request); const releases = await getRelativeSchedule(); @@ -66,10 +93,20 @@ function Release({ release, timeZone }: { release: MajorReleaseSchedule; timeZon {release.eolDate} - M{release.chromiumVersion} + + M{release.chromiumVersion} + - v{release.nodeVersion} + + v{release.nodeVersion} + ); From 5e50eb5e692ae6a3930a68a07e9eaa9dc9a169e3 Mon Sep 17 00:00:00 2001 From: clavin Date: Tue, 4 Nov 2025 22:40:30 -0800 Subject: [PATCH 07/12] Add unique background patterns for rows by status --- app/routes/schedule.css | 55 +++++++++++++++++++++++++++++++++++++++++ app/routes/schedule.tsx | 14 ++++++++--- 2 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 app/routes/schedule.css diff --git a/app/routes/schedule.css b/app/routes/schedule.css new file mode 100644 index 0000000..ab81da6 --- /dev/null +++ b/app/routes/schedule.css @@ -0,0 +1,55 @@ +.bg-release-active { + background-color: color-mix(in srgb, theme(colors.green.500) 12%, transparent); +} + +.bg-release-active:hover { + background-color: color-mix(in srgb, theme(colors.green.500) 18%, transparent); +} + +.dark .bg-release-active { + background-color: color-mix(in srgb, theme(colors.green.500) 10%, transparent); +} + +.dark .bg-release-active:hover { + background-color: color-mix(in srgb, theme(colors.green.500) 15%, transparent); +} + +.bg-release-prerelease { + background-image: repeating-linear-gradient(45deg, + color-mix(in srgb, theme(colors.yellow.500) 8%, transparent), + color-mix(in srgb, theme(colors.yellow.500) 8%, transparent) 10px, + color-mix(in srgb, theme(colors.yellow.500) 14%, transparent) 10px, + color-mix(in srgb, theme(colors.yellow.500) 14%, transparent) 20px); +} + +.bg-release-prerelease:hover { + background-image: repeating-linear-gradient(45deg, + color-mix(in srgb, theme(colors.yellow.500) 12%, transparent), + color-mix(in srgb, theme(colors.yellow.500) 12%, transparent) 10px, + color-mix(in srgb, theme(colors.yellow.500) 18%, transparent) 10px, + color-mix(in srgb, theme(colors.yellow.500) 18%, transparent) 20px); +} + +.dark .bg-release-prerelease { + background-image: repeating-linear-gradient(45deg, + color-mix(in srgb, theme(colors.yellow.500) 5%, transparent), + color-mix(in srgb, theme(colors.yellow.500) 5%, transparent) 10px, + color-mix(in srgb, theme(colors.yellow.500) 8%, transparent) 10px, + color-mix(in srgb, theme(colors.yellow.500) 8%, transparent) 20px); +} + +.dark .bg-release-prerelease:hover { + background-image: repeating-linear-gradient(45deg, + color-mix(in srgb, theme(colors.yellow.500) 8%, transparent), + color-mix(in srgb, theme(colors.yellow.500) 8%, transparent) 10px, + color-mix(in srgb, theme(colors.yellow.500) 12%, transparent) 10px, + color-mix(in srgb, theme(colors.yellow.500) 12%, transparent) 20px); +} + +.bg-release-eol { + @apply opacity-50 bg-gray-100 dark:bg-gray-900/30; +} + +.bg-release-eol:hover { + @apply bg-gray-200 dark:bg-gray-700/50; +} \ No newline at end of file diff --git a/app/routes/schedule.tsx b/app/routes/schedule.tsx index 152dadb..fba1daf 100644 --- a/app/routes/schedule.tsx +++ b/app/routes/schedule.tsx @@ -6,6 +6,8 @@ import { getRelativeSchedule, type MajorReleaseSchedule } from '~/data/release-s import { prettyReleaseDate } from '~/helpers/time'; import { guessTimeZoneFromRequest } from '~/helpers/timezone'; +import './schedule.css'; + export const meta: MetaFunction = () => [ { title: 'Schedule | Electron Releases' }, { @@ -69,11 +71,15 @@ function Release({ release, timeZone }: { release: MajorReleaseSchedule; timeZon ? 'bg-green-500' : 'bg-slate-500'; + const bgClass = + release.status === 'eol' + ? 'bg-release-eol' + : release.status === 'prerelease' + ? 'bg-release-prerelease' + : 'bg-release-active'; + return ( - +
From 674907ac668fd2c83dd65d4515ef7257b4a587de Mon Sep 17 00:00:00 2001 From: clavin Date: Tue, 4 Nov 2025 22:44:14 -0800 Subject: [PATCH 08/12] Unify text size in rows --- app/routes/schedule.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/routes/schedule.tsx b/app/routes/schedule.tsx index fba1daf..cbab964 100644 --- a/app/routes/schedule.tsx +++ b/app/routes/schedule.tsx @@ -79,26 +79,26 @@ function Release({ release, timeZone }: { release: MajorReleaseSchedule; timeZon : 'bg-release-active'; return ( - +
{release.version}
- + {release.alphaDate} - + {release.betaDate} - + {release.stableDate} - + {release.eolDate} - + - + Date: Tue, 4 Nov 2025 23:08:11 -0800 Subject: [PATCH 09/12] Simplify styles --- app/routes/schedule.css | 8 ------- app/routes/schedule.tsx | 49 +++++++++++++++++++++-------------------- 2 files changed, 25 insertions(+), 32 deletions(-) diff --git a/app/routes/schedule.css b/app/routes/schedule.css index ab81da6..eca4a7a 100644 --- a/app/routes/schedule.css +++ b/app/routes/schedule.css @@ -45,11 +45,3 @@ color-mix(in srgb, theme(colors.yellow.500) 12%, transparent) 10px, color-mix(in srgb, theme(colors.yellow.500) 12%, transparent) 20px); } - -.bg-release-eol { - @apply opacity-50 bg-gray-100 dark:bg-gray-900/30; -} - -.bg-release-eol:hover { - @apply bg-gray-200 dark:bg-gray-700/50; -} \ No newline at end of file diff --git a/app/routes/schedule.tsx b/app/routes/schedule.tsx index cbab964..a36b12b 100644 --- a/app/routes/schedule.tsx +++ b/app/routes/schedule.tsx @@ -64,41 +64,42 @@ export const loader = async (args: LoaderFunctionArgs) => { }; function Release({ release, timeZone }: { release: MajorReleaseSchedule; timeZone: string }) { - const statusColor = - release.status === 'prerelease' - ? 'bg-yellow-500' - : release.status === 'active' - ? 'bg-green-500' - : 'bg-slate-500'; - - const bgClass = - release.status === 'eol' - ? 'bg-release-eol' - : release.status === 'prerelease' - ? 'bg-release-prerelease' - : 'bg-release-active'; + const styles = { + prerelease: { + row: 'bg-release-prerelease text-yellow-900 dark:text-yellow-100', + status: 'bg-yellow-500', + }, + active: { + row: 'bg-release-active text-green-900 dark:text-green-100', + status: 'bg-green-500', + }, + eol: { + row: 'opacity-50 bg-gray-100 dark:bg-gray-900/30 hover:bg-gray-200 dark:hover:bg-gray-700/50 text-gray-700 dark:text-gray-300', + status: 'bg-slate-500', + }, + }[release.status]; return ( - - + +
-
- {release.version} +
+ {release.version}
- - + + {release.alphaDate} - + {release.betaDate} - + {release.stableDate} - + {release.eolDate} - + - + Date: Tue, 4 Nov 2025 23:26:25 -0800 Subject: [PATCH 10/12] Add nightly and rename 'active to 'stable' --- app/data/release-schedule.ts | 19 ++++++++++++++--- app/routes/schedule.css | 40 ++++++++++++++++++++++++++++++++---- app/routes/schedule.tsx | 18 +++++++++++----- 3 files changed, 65 insertions(+), 12 deletions(-) diff --git a/app/data/release-schedule.ts b/app/data/release-schedule.ts index cee43e9..37943bf 100644 --- a/app/data/release-schedule.ts +++ b/app/data/release-schedule.ts @@ -13,7 +13,7 @@ export interface MajorReleaseSchedule { eolDate: string; // YYYY-MM-DD chromiumVersion: number; // milestone, aka major version nodeVersion: string; // full semver - status: 'active' | 'prerelease' | 'eol'; + status: 'stable' | 'prerelease' | 'nightly' | 'eol'; } type AbsoluteMajorReleaseSchedule = Omit; @@ -217,8 +217,21 @@ export async function getRelativeSchedule(): Promise { const schedule: MajorReleaseSchedule[] = absoluteData.map((entry) => { const major = parseInt(entry.version.split('.')[0], 10); - const status = - major > latestStableMajor ? 'prerelease' : major >= minActiveMajor ? 'active' : 'eol'; + + let status: MajorReleaseSchedule['status']; + if (major > latestStableMajor) { + const hasNonNightlyRelease = allReleases.find( + (release) => + release.version.startsWith(`${major}.`) && + getPrereleaseType(release.version) !== 'nightly', + ); + status = hasNonNightlyRelease ? 'prerelease' : 'nightly'; + } else if (major >= minActiveMajor) { + status = 'stable'; + } else { + status = 'eol'; + } + return { ...entry, status }; }); diff --git a/app/routes/schedule.css b/app/routes/schedule.css index eca4a7a..34b0198 100644 --- a/app/routes/schedule.css +++ b/app/routes/schedule.css @@ -1,16 +1,16 @@ -.bg-release-active { +.bg-release-stable { background-color: color-mix(in srgb, theme(colors.green.500) 12%, transparent); } -.bg-release-active:hover { +.bg-release-stable:hover { background-color: color-mix(in srgb, theme(colors.green.500) 18%, transparent); } -.dark .bg-release-active { +.dark .bg-release-stable { background-color: color-mix(in srgb, theme(colors.green.500) 10%, transparent); } -.dark .bg-release-active:hover { +.dark .bg-release-stable:hover { background-color: color-mix(in srgb, theme(colors.green.500) 15%, transparent); } @@ -45,3 +45,35 @@ color-mix(in srgb, theme(colors.yellow.500) 12%, transparent) 10px, color-mix(in srgb, theme(colors.yellow.500) 12%, transparent) 20px); } + +.bg-release-nightly { + background-image: repeating-linear-gradient(45deg, + color-mix(in srgb, theme(colors.purple.500) 8%, transparent), + color-mix(in srgb, theme(colors.purple.500) 8%, transparent) 10px, + color-mix(in srgb, theme(colors.purple.500) 14%, transparent) 10px, + color-mix(in srgb, theme(colors.purple.500) 14%, transparent) 20px); +} + +.bg-release-nightly:hover { + background-image: repeating-linear-gradient(45deg, + color-mix(in srgb, theme(colors.purple.500) 12%, transparent), + color-mix(in srgb, theme(colors.purple.500) 12%, transparent) 10px, + color-mix(in srgb, theme(colors.purple.500) 18%, transparent) 10px, + color-mix(in srgb, theme(colors.purple.500) 18%, transparent) 20px); +} + +.dark .bg-release-nightly { + background-image: repeating-linear-gradient(45deg, + color-mix(in srgb, theme(colors.purple.500) 5%, transparent), + color-mix(in srgb, theme(colors.purple.500) 5%, transparent) 10px, + color-mix(in srgb, theme(colors.purple.500) 8%, transparent) 10px, + color-mix(in srgb, theme(colors.purple.500) 8%, transparent) 20px); +} + +.dark .bg-release-nightly:hover { + background-image: repeating-linear-gradient(45deg, + color-mix(in srgb, theme(colors.purple.500) 8%, transparent), + color-mix(in srgb, theme(colors.purple.500) 8%, transparent) 10px, + color-mix(in srgb, theme(colors.purple.500) 12%, transparent) 10px, + color-mix(in srgb, theme(colors.purple.500) 12%, transparent) 20px); +} diff --git a/app/routes/schedule.tsx b/app/routes/schedule.tsx index a36b12b..8165e0b 100644 --- a/app/routes/schedule.tsx +++ b/app/routes/schedule.tsx @@ -39,9 +39,7 @@ function DependencyRelease({ release: MajorReleaseSchedule; children: React.ReactNode; }) { - if (release.status === 'prerelease') { - return {children}; - } else { + if (release.status === 'stable' || release.status === 'eol') { return ( ); + } else { + return {children}; } } @@ -65,12 +65,16 @@ export const loader = async (args: LoaderFunctionArgs) => { function Release({ release, timeZone }: { release: MajorReleaseSchedule; timeZone: string }) { const styles = { + nightly: { + row: 'bg-release-nightly text-purple-900 dark:text-purple-100', + status: 'bg-purple-500', + }, prerelease: { row: 'bg-release-prerelease text-yellow-900 dark:text-yellow-100', status: 'bg-yellow-500', }, - active: { - row: 'bg-release-active text-green-900 dark:text-green-100', + stable: { + row: 'bg-release-stable text-green-900 dark:text-green-100', status: 'bg-green-500', }, eol: { @@ -154,6 +158,10 @@ export default function Schedule() {
Prerelease
+
+
+ Nightly +
End of Life From 5d0b3cc3f9dbc321a0071943e1b13fdac5d0ccf9 Mon Sep 17 00:00:00 2001 From: clavin Date: Wed, 5 Nov 2025 09:25:55 -0800 Subject: [PATCH 11/12] Add EOL override for E22 --- app/data/release-schedule.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/data/release-schedule.ts b/app/data/release-schedule.ts index 37943bf..cc39843 100644 --- a/app/data/release-schedule.ts +++ b/app/data/release-schedule.ts @@ -65,6 +65,12 @@ const SCHEDULE_OVERRIDES: Map> = n alphaDate: '2021-07-20', }, ], + [ + '22.0.0', + { + eolDate: '2023-10-10', + }, + ], ]); // Determine support window: 4 for v12-15, 3 for the rest @@ -171,6 +177,11 @@ export const getAbsoluteSchedule = memoize( // Calculate EOL dates for (const entry of schedule) { + if (entry.eolDate !== '') { + // Already set via override + continue; + } + const major = parseInt(entry.version.split('.')[0], 10); const eolMajor = major + getSupportWindow(major); const eolEntry = schedule.find((r) => r.version === `${eolMajor}.0.0`); From d4283279eb810e5dcd3c5e513a2e1b1ce50e0110 Mon Sep 17 00:00:00 2001 From: clavin Date: Mon, 24 Nov 2025 22:56:04 -0800 Subject: [PATCH 12/12] Simplify, improve accuracy, add overrides Now correctly predicts alpha/beta dates for most versions. Deliberately corrects some beta release dates that were incorrectly published as Wednesday instead of Tuesday. --- app/data/release-schedule.ts | 93 ++++++++++++++++++++++++++++-------- 1 file changed, 72 insertions(+), 21 deletions(-) diff --git a/app/data/release-schedule.ts b/app/data/release-schedule.ts index cc39843..e93c6ef 100644 --- a/app/data/release-schedule.ts +++ b/app/data/release-schedule.ts @@ -24,6 +24,11 @@ interface MajorReleaseGroup { firstStable?: ElectronRelease; // Only used for Chromium milestone extraction } +// Schedule overrides for dates that deviate from calculated estimates: +// - v2-v5: Pre-Chromium alignment era (before standardized release cadence afaik) +// - v6-v14: Transition to modern release process +// - v15: Introduction of alpha releases +// - v16+: Minor adjustments from Chromium schedule predictions const SCHEDULE_OVERRIDES: Map> = new Map([ [ '2.0.0', @@ -63,14 +68,35 @@ const SCHEDULE_OVERRIDES: Map> = n '15.0.0', { alphaDate: '2021-07-20', + betaDate: '2021-09-01', + }, + ], + [ + '16.0.0', + { + betaDate: '2021-10-20', }, ], [ '22.0.0', { + // Policy exception: extended EOL to support extended end-of-life for Windows 7/8/8.1 eolDate: '2023-10-10', }, ], + [ + '28.0.0', + { + alphaDate: '2023-10-11', + betaDate: '2023-11-06', + }, + ], + [ + '32.0.0', + { + alphaDate: '2024-06-14', + }, + ], ]); // Determine support window: 4 for v12-15, 3 for the rest @@ -78,6 +104,12 @@ const getSupportWindow = (major: number): number => { return major >= 12 && major <= 15 ? 4 : 3; }; +const offsetDays = (dateStr: string, days: number): string => { + const date = new Date(dateStr + 'T00:00:00'); + date.setDate(date.getDate() + days); + return date.toISOString().split('T')[0]; +}; + /** * Get absolute schedule data (cacheable, not time-dependent). */ @@ -135,22 +167,40 @@ export const getAbsoluteSchedule = memoize( } // Build absolute schedule data for each major - const schedule: AbsoluteMajorReleaseSchedule[] = []; + const schedule = new Map(); for (const major of sortedMajors) { const milestone = milestoneMap.get(major)!; const chromiumSchedule = await getMilestoneSchedule(milestone); - // Alpha: previous major's stable + 2 days (null for v14 and earlier) + // Alpha/Beta pattern: + // | ------- | ------------------ | ------------------------- | + // | Version | Alpha | Beta | + // | ------- | ------------------ | ------------------------- | + // | v2-5 | None | History (overrides) | + // | v6-14 | None | Prev stable + 2 days | + // | v15+ | Prev stable + 2 | Chromium dates + offset | + // | ------- | ------------------ | ------------------------- | let alphaDate: string | null = null; - if (major > 14) { - const prevMilestone = milestoneMap.get(major - 1)!; - const prevSchedule = await getMilestoneSchedule(prevMilestone); - - // Add 2 days to previous stable date - const prevStable = new Date(prevSchedule.stableDate + 'T00:00:00'); - prevStable.setDate(prevStable.getDate() + 2); - alphaDate = prevStable.toISOString().split('T')[0]; + let betaDate: string; + if (major < 6) { + // (no alpha) + betaDate = ''; // Will be set by override + } else { + const prevStablePlus2 = offsetDays(schedule.get(major - 1)!.stableDate, 2); + + if (major < 15) { + // (no alpha) + betaDate = prevStablePlus2; + } else { + alphaDate = prevStablePlus2; + + // Chromium beta offset pattern: + // - M113 and below: beta on Thursdays, offset -2 to Tuesday + // - M114 and above: beta on Wednesdays, offset -1 to Tuesday + const betaOffset = milestone <= 113 ? -2 : -1; + betaDate = offsetDays(chromiumSchedule.earliestBeta, betaOffset); + } } const group = majorGroups.get(major)!; @@ -159,7 +209,7 @@ export const getAbsoluteSchedule = memoize( const entry: AbsoluteMajorReleaseSchedule = { version: `${major}.0.0`, alphaDate, - betaDate: chromiumSchedule.earliestBeta, + betaDate, stableDate: chromiumSchedule.stableDate, chromiumVersion: milestone, nodeVersion: group.firstStable?.node ?? latestRelease.node, @@ -172,11 +222,11 @@ export const getAbsoluteSchedule = memoize( Object.assign(entry, override); } - schedule.push(entry); + schedule.set(major, entry); } // Calculate EOL dates - for (const entry of schedule) { + for (const entry of schedule.values()) { if (entry.eolDate !== '') { // Already set via override continue; @@ -184,28 +234,29 @@ export const getAbsoluteSchedule = memoize( const major = parseInt(entry.version.split('.')[0], 10); const eolMajor = major + getSupportWindow(major); - const eolEntry = schedule.find((r) => r.version === `${eolMajor}.0.0`); + const eolEntry = schedule.get(eolMajor); if (eolEntry) { entry.eolDate = eolEntry.stableDate; } else { // Extrapolate for future versions - const maxMajor = Math.max(...schedule.map((r) => parseInt(r.version.split('.')[0], 10))); - const maxEntry = schedule.find((r) => r.version === `${maxMajor}.0.0`)!; + const maxMajor = Math.max(...Array.from(schedule.keys())); + const maxEntry = schedule.get(maxMajor)!; const milestone = maxEntry.chromiumVersion + (eolMajor - maxMajor) * 2; // 2 milestones per major const eolSchedule = await getMilestoneSchedule(milestone); entry.eolDate = eolSchedule.stableDate; } } - return schedule; + // NB: `Map.values()` iterates in insertion order (ascending major) + return Array.from(schedule.values()); }, getKeyvCache('absolute-schedule'), { - // Cache for 60 seconds - ttl: 60_000, - // At 10 seconds, refetch but serve stale data - staleTtl: 10_000, + // Cache for 2 hours + ttl: 2 * 60 * 60 * 1000, + // At 10 mineutes, refetch but serve stale data + staleTtl: 10 * 60 * 1000, }, );