diff --git a/README.md b/README.md
index dd8d6e4..b6b3028 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,13 @@
-# launchcal
+# launchcal: SpaceX launch calendar
[](https://github.com/launchcal/launchcal.github.io/actions/workflows/ci.yml)
[](https://github.com/launchcal/launchcal.github.io/actions/workflows/publish.yml)
[](https://buymeacoffee.com/roma321m)
-Free, self-updating calendars of SpaceX launches. Subscribe once and every new launch, reschedule
-and scrub shows up in your calendar, in your own time zone.
+A free, self-updating SpaceX launch calendar for Google Calendar, Apple Calendar (iPhone, iPad, Mac)
+and Outlook. Subscribe once and every Falcon 9, Falcon Heavy, Starship and crewed Dragon launch,
+reschedule and scrub shows up in your calendar, in your own time zone. Separate calendars cover
+Starship flights only, crewed launches only, and everything except Starlink.
**Website: https://launchcal.github.io**
diff --git a/site/app.js b/site/app.js
index d1cc885..886aa03 100644
--- a/site/app.js
+++ b/site/app.js
@@ -21,6 +21,11 @@ document.addEventListener('click', async (event) => {
}, 1500);
});
+// Schedule: launches with a known time are rendered in UTC; show them in the visitor's time zone instead.
+for (const time of document.querySelectorAll('time[data-local]')) {
+ time.textContent = new Date(time.dateTime).toLocaleString(undefined, { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' });
+}
+
// Next launch: the first launch in next.json that has not ended on the visitor's clock.
const panel = document.getElementById('next');
fetch('next.json')
diff --git a/site/img/crs-20-launch-1277.webp b/site/img/crs-20-launch-1277.webp
new file mode 100644
index 0000000..713cef6
Binary files /dev/null and b/site/img/crs-20-launch-1277.webp differ
diff --git a/site/img/crs-20-launch-640.webp b/site/img/crs-20-launch-640.webp
new file mode 100644
index 0000000..6c9426e
Binary files /dev/null and b/site/img/crs-20-launch-640.webp differ
diff --git a/site/img/crs-20-launch-960.webp b/site/img/crs-20-launch-960.webp
new file mode 100644
index 0000000..6ac7c6d
Binary files /dev/null and b/site/img/crs-20-launch-960.webp differ
diff --git a/site/img/crs-20-launch.jpg b/site/img/crs-20-launch.jpg
deleted file mode 100644
index 3b58a65..0000000
Binary files a/site/img/crs-20-launch.jpg and /dev/null differ
diff --git a/site/img/og.jpg b/site/img/og.jpg
new file mode 100644
index 0000000..d2ee22f
Binary files /dev/null and b/site/img/og.jpg differ
diff --git a/site/img/screens/google-agenda.webp b/site/img/screens/google-agenda.webp
new file mode 100644
index 0000000..2d860e3
Binary files /dev/null and b/site/img/screens/google-agenda.webp differ
diff --git a/site/img/screens/google-event.webp b/site/img/screens/google-event.webp
new file mode 100644
index 0000000..abb487e
Binary files /dev/null and b/site/img/screens/google-event.webp differ
diff --git a/site/img/screens/google-week.webp b/site/img/screens/google-week.webp
new file mode 100644
index 0000000..5108cd8
Binary files /dev/null and b/site/img/screens/google-week.webp differ
diff --git a/site/privacy.html b/site/privacy.html
index 234280e..2d1cc17 100644
--- a/site/privacy.html
+++ b/site/privacy.html
@@ -5,6 +5,7 @@
Free, unofficial calendars that keep themselves up to date. Scrubs and new launch times show up within hours, in your own time zone.
+
A free, unofficial SpaceX launch calendar that keeps itself up to date: Falcon 9, Falcon Heavy, Starship and crewed flights. Scrubs and new launch times show up within hours, in your own time zone.
-
+ Every launch carries the details: time, pad, booster and landing, crew, webcast.
-
+ Upcoming launches on your phone, in your time zone.
-
+ Launches sit in your week next to everything else, marked as free time.
@@ -67,9 +71,21 @@
Pick a calendar
+
+
Upcoming SpaceX launches
+
The SpaceX launch schedule as it stands now. Dates are only as precise as they are known; launches without a firm day are not in the calendars yet.
+
+
+
Good to know
+
How do I add SpaceX launches to Google Calendar?
+
Pick a calendar above and press "Add to Google Calendar", then confirm. It appears in your calendar list on the web, Android and iPhone, and updates on its own.
+
How do I add them to my iPhone, Apple Calendar or Outlook?
+
Press "Apple / Outlook" to subscribe directly, or copy the ICS link. On iPhone: Settings → Calendar → Accounts → Add Account → Other → Add Subscribed Calendar. In Outlook: Add calendar → Subscribe from web.
+
Can I follow only Starship or crewed launches?
+
Yes. Besides every SpaceX launch, there are calendars for Starship flights, for crewed Dragon missions, and for everything except routine Starlink launches.
How fresh is it?
Launch data is checked every two hours. Google calendars update right away; Apple and Outlook refresh on their own schedule, usually a few times a day.
Why are some launches all-day or marked (TBD)?
@@ -78,6 +94,8 @@
Good to know
No. Events show as free time and have no reminders of their own; add one to any launch you want to watch.
How do I unsubscribe?
Remove the calendar in your calendar app, like any other subscribed calendar.
+
Where does the launch schedule come from?
+
From Launch Library 2 by The Space Devs, a community-run database of launches. launchcal is not affiliated with SpaceX.
diff --git a/src/page.ts b/src/page.ts
index 50d8651..8d2f67a 100644
--- a/src/page.ts
+++ b/src/page.ts
@@ -1,5 +1,6 @@
import type { CalendarDef } from './calendars.ts';
-import type { CalendarEvent } from './event.ts';
+import { TIMED_PRECISIONS, type CalendarEvent } from './event.ts';
+import type { Launch } from './launch.ts';
export const SITE_URL = 'https://launchcal.github.io';
@@ -20,9 +21,12 @@ export function googleAddUrl(googleId: string): string {
return `https://calendar.google.com/calendar/u/0?cid=${Buffer.from(googleId).toString('base64').replace(/=+$/, '')}`;
}
-/** Replaces the `` marker in the template with one card per calendar. */
-export function renderPage(template: string, calendars: CalendarDef[]): string {
- if (!template.includes('')) throw new Error('Page template has no marker');
+/**
+ * Fills the template's markers: `` with one card per calendar, `` with the next launch
+ * panel (app.js keeps it current), `` with the upcoming launches and `` with
+ * their JSON-LD. `upcoming` must be sorted by `net`.
+ */
+export function renderPage(template: string, calendars: CalendarDef[], next: NextLaunch | undefined, upcoming: Launch[]): string {
const cards = calendars.map((calendar) => {
const ics = `${SITE_URL}/cal/${calendar.id}.ics`;
const name = escapeHtml(calendar.name);
@@ -30,13 +34,107 @@ export function renderPage(template: string, calendars: CalendarDef[]): string {
`;
});
- return template.replace('', () => cards.join('\n'));
+ let html = fill(template, '', cards.join('\n'));
+ html = fill(html, '', renderNext(next));
+ html = fill(html, '', renderSchedule(upcoming));
+ return fill(html, '', structuredData(upcoming));
+}
+
+function fill(template: string, marker: string, html: string): string {
+ if (!template.includes(marker)) throw new Error(`Page template has no ${marker} marker`);
+ return template.replace(marker, () => html);
+}
+
+/** The panel as app.js would show it, in UTC, so it is in the HTML from the start and does not shift the page. */
+function renderNext(next: NextLaunch | undefined): string {
+ if (!next) {
+ return `
+
`);
+ const visible = `\n${items.slice(0, SCHEDULE_VISIBLE).join('\n')}\n`;
+ if (items.length <= SCHEDULE_VISIBLE) return visible;
+ return `${visible}
+
+Show all ${items.length} upcoming launches
+\n${items.slice(SCHEDULE_VISIBLE).join('\n')}\n
+`;
+}
+
+/** The launch date only as precise as Launch Library 2 knows it; timed launches carry `data-local` so the page can show local time. */
+function launchDate(launch: Launch): string {
+ const net = new Date(launch.net);
+ const year = net.getUTCFullYear();
+ const precision = launch.netPrecision;
+ const utc = (options: Intl.DateTimeFormatOptions) => new Intl.DateTimeFormat('en-GB', { ...options, timeZone: 'UTC' }).format(net);
+ if (TIMED_PRECISIONS.has(precision)) {
+ const text = `${utc({ weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' })}, ${utc({ hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })} UTC`;
+ return ``;
+ }
+ if (precision === 'DAY') return ``;
+ if (precision === 'M') return ``;
+ if (/^[QH][1-4]$/.test(precision)) return ``;
+ if (precision === 'DEC') return ``;
+ return ``;
+}
+
+function place(launch: Launch): string {
+ return launch.pad.startsWith('Unknown') ? launch.location : `${launch.pad}, ${launch.location}`;
+}
+
+/** schema.org WebSite plus an Event for each launch with a known day; vaguer launches are not events yet. */
+function structuredData(upcoming: Launch[]): string {
+ const events = upcoming
+ .filter((launch) => TIMED_PRECISIONS.has(launch.netPrecision) || launch.netPrecision === 'DAY')
+ .map((launch) => {
+ const webcast = launch.webcasts.find((cast) => cast.url.startsWith('https://'))?.url;
+ const site = { '@type': 'Place', name: launch.pad.startsWith('Unknown') ? launch.location : launch.pad, address: launch.location };
+ return {
+ '@type': 'Event',
+ name: `${launch.rocket} launch: ${launch.name}`,
+ startDate: launch.netPrecision === 'DAY' ? launch.net.slice(0, 10) : launch.net,
+ eventStatus: 'https://schema.org/EventScheduled',
+ eventAttendanceMode: webcast ? 'https://schema.org/MixedEventAttendanceMode' : 'https://schema.org/OfflineEventAttendanceMode',
+ location: webcast ? [site, { '@type': 'VirtualLocation', url: webcast }] : site,
+ organizer: { '@type': 'Organization', name: 'SpaceX', url: 'https://www.spacex.com' },
+ description: launch.description ?? `${launch.rocket} launch from ${place(launch)}.`,
+ image: `${SITE_URL}/img/og.jpg`,
+ };
+ });
+ const graph = [{ '@type': 'WebSite', name: 'launchcal', alternateName: 'SpaceX launch calendar', url: `${SITE_URL}/` }, ...events];
+ const json = JSON.stringify({ '@context': 'https://schema.org', '@graph': graph }).replace(/${json}`;
}
/** The next `count` events that have not ended at `now`, earliest start first; webcast links other than https are dropped. */
diff --git a/test/page.test.ts b/test/page.test.ts
index 89a7fd0..fd678b0 100644
--- a/test/page.test.ts
+++ b/test/page.test.ts
@@ -2,7 +2,8 @@ import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import type { CalendarDef } from '../src/calendars.ts';
import type { CalendarEvent } from '../src/event.ts';
-import { googleAddUrl, nextLaunches, renderPage } from '../src/page.ts';
+import type { Launch } from '../src/launch.ts';
+import { googleAddUrl, nextLaunches, renderPage, type NextLaunch } from '../src/page.ts';
const calendar: CalendarDef = {
id: 'crewed',
@@ -30,10 +31,10 @@ describe('googleAddUrl', () => {
});
describe('renderPage', () => {
- const template = '\n\n';
+ const template = '\n\n\n\n\n';
it('renders a card with Google, webcal and copyable ICS links in place of the marker', () => {
- const html = renderPage(template, [calendar]);
+ const html = renderPage(template, [calendar], undefined, []);
assert.ok(!html.includes(''));
assert.ok(html.startsWith('\n'));
@@ -45,22 +46,22 @@ describe('renderPage', () => {
});
it('renders one card per calendar in order', () => {
- const html = renderPage(template, [calendar, { ...calendar, id: 'all', name: 'SpaceX launches' }]);
+ const html = renderPage(template, [calendar, { ...calendar, id: 'all', name: 'SpaceX launches' }], undefined, []);
assert.equal(html.match(//g)!.length, 2);
assert.ok(html.indexOf('SpaceX crewed launches') < html.indexOf('
SpaceX launches
'));
});
it('opens Google in a new tab and names each link after its calendar', () => {
- const html = renderPage(template, [calendar]);
+ const html = renderPage(template, [calendar], undefined, []);
- assert.ok(html.includes('target="_blank" rel="noopener" aria-label="Add SpaceX crewed launches to Google Calendar"'));
- assert.ok(html.includes('aria-label="Subscribe to SpaceX crewed launches in Apple Calendar or Outlook"'));
- assert.ok(html.includes('aria-label="Copy the SpaceX crewed launches ICS link"'));
+ assert.ok(html.includes('target="_blank" rel="noopener" aria-label="Add to Google Calendar: SpaceX crewed launches"'));
+ assert.ok(html.includes('aria-label="Apple / Outlook: subscribe to SpaceX crewed launches"'));
+ assert.ok(html.includes('aria-label="Copy ICS link of SpaceX crewed launches"'));
});
it('puts a decorative icon before each button label, with the label in its own span for the copy feedback', () => {
- const html = renderPage(template, [calendar]);
+ const html = renderPage(template, [calendar], undefined, []);
assert.equal(html.match(/Add to Google Calendar'));
@@ -69,24 +70,182 @@ describe('renderPage', () => {
});
it('escapes calendar text', () => {
- const html = renderPage(template, [{ ...calendar, name: 'Crew & "friends"', blurb: 'Dragon & co' }]);
+ const html = renderPage(template, [{ ...calendar, name: 'Crew & "friends"', blurb: 'Dragon & co' }], undefined, []);
assert.ok(html.includes('
Crew <b>& "friends"</b>
'));
- assert.ok(html.includes('aria-label="Add Crew <b>& "friends"</b> to Google Calendar"'));
+ assert.ok(html.includes('aria-label="Add to Google Calendar: Crew <b>& "friends"</b>"'));
assert.ok(html.includes('
Dragon <i>& co</i>
'));
});
it('inserts text containing replacement patterns literally', () => {
- const html = renderPage(template, [{ ...calendar, blurb: "Costs $& and $' nothing" }]);
+ const html = renderPage(template, [{ ...calendar, blurb: "Costs $& and $' nothing" }], undefined, []);
assert.ok(html.includes("