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..e93c6ef --- /dev/null +++ b/app/data/release-schedule.ts @@ -0,0 +1,306 @@ +import { parse as parseSemver } from 'semver'; +import memoize from '@keyvhq/memoize'; +import { ElectronRelease, getReleasesOrUpdate } from './release-data'; +import { extractChromiumMilestone, 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; // full semver + status: 'stable' | 'prerelease' | 'nightly' | 'eol'; +} + +type AbsoluteMajorReleaseSchedule = Omit; + +interface MajorReleaseGroup { + major: number; + releases: ElectronRelease[]; + 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', + { + 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', + 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 +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). + */ +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); + } + } + + // Build absolute schedule data for each major + const schedule = new Map(); + + for (const major of sortedMajors) { + const milestone = milestoneMap.get(major)!; + const chromiumSchedule = await getMilestoneSchedule(milestone); + + // 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; + 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)!; + const latestRelease = group.releases[0]; + + const entry: AbsoluteMajorReleaseSchedule = { + version: `${major}.0.0`, + alphaDate, + betaDate, + stableDate: chromiumSchedule.stableDate, + chromiumVersion: milestone, + nodeVersion: group.firstStable?.node ?? latestRelease.node, + eolDate: '', // Placeholder, will be calculated + }; + + // 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); + } + + schedule.set(major, entry); + } + + // Calculate EOL dates + for (const entry of schedule.values()) { + if (entry.eolDate !== '') { + // Already set via override + continue; + } + + const major = parseInt(entry.version.split('.')[0], 10); + const eolMajor = major + getSupportWindow(major); + const eolEntry = schedule.get(eolMajor); + + if (eolEntry) { + entry.eolDate = eolEntry.stableDate; + } else { + // Extrapolate for future versions + 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; + } + } + + // NB: `Map.values()` iterates in insertion order (ascending major) + return Array.from(schedule.values()); + }, + getKeyvCache('absolute-schedule'), + { + // Cache for 2 hours + ttl: 2 * 60 * 60 * 1000, + // At 10 mineutes, refetch but serve stale data + staleTtl: 10 * 60 * 1000, + }, +); + +/** + * 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, + ); + + const absoluteData = await getAbsoluteSchedule(); + const supportWindow = getSupportWindow(latestStableMajor); + const minActiveMajor = latestStableMajor - supportWindow + 1; + + const schedule: MajorReleaseSchedule[] = absoluteData.map((entry) => { + const major = parseInt(entry.version.split('.')[0], 10); + + 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 }; + }); + + // 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..06f7d72 --- /dev/null +++ b/app/helpers/version.ts @@ -0,0 +1,33 @@ +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 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 d55ab77..c4aecba 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, Menu, X } from 'lucide-react'; +import { ArrowUpRight, History, Search, CalendarPlus, Menu, X } from 'lucide-react'; import { useEffect, useState } 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/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 diff --git a/app/routes/schedule.css b/app/routes/schedule.css new file mode 100644 index 0000000..34b0198 --- /dev/null +++ b/app/routes/schedule.css @@ -0,0 +1,79 @@ +.bg-release-stable { + background-color: color-mix(in srgb, theme(colors.green.500) 12%, transparent); +} + +.bg-release-stable:hover { + background-color: color-mix(in srgb, theme(colors.green.500) 18%, transparent); +} + +.dark .bg-release-stable { + background-color: color-mix(in srgb, theme(colors.green.500) 10%, transparent); +} + +.dark .bg-release-stable: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-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 new file mode 100644 index 0000000..8165e0b --- /dev/null +++ b/app/routes/schedule.tsx @@ -0,0 +1,221 @@ +import type { LoaderFunctionArgs, MetaFunction } from '@remix-run/node'; +import { useLoaderData } from '@remix-run/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'; + +import './schedule.css'; + +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)}; +} + +function DependencyRelease({ + href, + release, + children, +}: { + href: string; + release: MajorReleaseSchedule; + children: React.ReactNode; +}) { + if (release.status === 'stable' || release.status === 'eol') { + return ( + + {children} + + + ); + } else { + return {children}; + } +} + +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 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', + }, + stable: { + row: 'bg-release-stable 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.alphaDate} + + + {release.betaDate} + + + {release.stableDate} + + + {release.eolDate} + + + + M{release.chromiumVersion} + + + + + v{release.nodeVersion} + + + + ); +} + +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 +
+
+
+ Nightly +
+
+
+ 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. Prerelease dependency versions (Chromium, Node.js) are estimates and + may be upgraded before the stable release. +

+
+
+ ); +}