diff --git a/.gitignore b/.gitignore index 0fdbf65..a91f146 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ service-account*.json .claude/settings.local.json data/ site/cal/ +site/index.html +site/next.json diff --git a/CLAUDE.md b/CLAUDE.md index a086141..39fbd8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ Node 24 runs the TypeScript in `src/` directly (type stripping, no build). Impor `tsc` is for type checking only. Tests use the built-in `node:test`. - `npm run fetch`: fetch upcoming launches and those of the last 30 days from Launch Library 2, write `data/snapshot.json`. -- `npm run build`: render `data/snapshot.json` into `site/cal/{all,crewed,starship,no-starlink}.ics`. +- `npm run build`: render `data/snapshot.json` into `site/cal/{all,crewed,starship,no-starlink}.ics`, `site/index.html` (from `src/page.html`) and `site/next.json`. - `npm run sync [-- --dry-run] [-- --calendar all=]`: sync `data/snapshot.json` into the Google calendars. Key from env `GOOGLE_SERVICE_ACCOUNT_KEY` (JSON) or `GOOGLE_SERVICE_ACCOUNT_KEY_FILE` (locally `%USERPROFILE%\.launchcal\sa.json`, never in the repo). - `npm test`, `npm run typecheck`. @@ -18,8 +18,9 @@ Node 24 runs the TypeScript in `src/` directly (type stripping, no build). Impor - `src/launch.ts`: launchcal's own `Launch` model. `src/ll2.ts`: LL2 client and normaliser. `src/fetch.ts`: fetch entry point. - `src/calendars.ts`: the 4 calendars and their filters (ids are public URLs, never rename). `src/event.ts`: format-neutral event text and timing, shared by ICS and the Google sync. `src/ics.ts`: RFC 5545 writer. `src/build.ts`: build entry point. - `src/gcal.ts`: Google event mapping and the insert/update/delete plan (launch id + hash in private extended properties; events older than the snapshot window are kept). `src/google.ts`: service account JWT and Calendar API calls. `src/sync.ts`: sync entry point. +- `src/page.ts` + `src/page.html`: landing page cards (Google add link, webcal, ICS) and `next.json`. Static, committed: `site/style.css`, `site/app.js` (countdown, copy), `site/fonts/` (D-DIN, OFL), `site/img/` (NASA public domain), `site/favicon.svg`. Preview: `launchcal-site` in the workspace-root `.claude/launch.json` (port 4173, serves `site/`). - `test/fixtures/ll2-upcoming.json` (5) and `ll2-previous.json` (1): real, unedited LL2 detailed records (recorded 2026-10-01). -- `data/snapshot.json`, `site/cal/*.ics`: generated, git-ignored. `site/` is deployed to Pages by `.github/workflows/publish.yml` (every 2h, on push to main, manual); its `sync` job runs `npm run sync` in the `google-calendar` environment (secret `GOOGLE_SERVICE_ACCOUNT_KEY`). +- `data/snapshot.json`, `site/cal/*.ics`, `site/index.html`, `site/next.json`: generated, git-ignored. `site/` is deployed to Pages by `.github/workflows/publish.yml` (every 2h, on push to main, manual); its `sync` job runs `npm run sync` in the `google-calendar` environment (secret `GOOGLE_SERVICE_ACCOUNT_KEY`). - `.github/workflows/ci.yml`: typecheck + tests, required check on `main`. Actions are pinned by commit SHA. ## Known facts diff --git a/site/app.js b/site/app.js new file mode 100644 index 0000000..139f6ac --- /dev/null +++ b/site/app.js @@ -0,0 +1,81 @@ +// Copy buttons: copy the absolute ICS URL instead of opening the feed; where the clipboard is blocked, show it to copy by hand. +const copyStatus = document.getElementById('copy-status'); +document.addEventListener('click', async (event) => { + const link = event.target.closest('[data-copy]'); + if (!link) return; + event.preventDefault(); + try { + await navigator.clipboard.writeText(link.href); + } catch { + window.prompt('Copy this calendar link:', link.href); + return; + } + link.dataset.label ??= link.textContent; + link.textContent = 'Copied'; + copyStatus.textContent = 'Link copied'; + clearTimeout(link.copyTimer); + link.copyTimer = setTimeout(() => { + link.textContent = link.dataset.label; + copyStatus.textContent = ''; + }, 1500); +}); + +// 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') + .then((res) => (res.ok ? res.json() : [])) + .then((launches) => { + let current = null; + const tick = () => { + const now = Date.now(); + const launch = launches.find((item) => Date.parse(item.end) > now); + if (!launch) { + panel.hidden = true; + return; + } + if (launch !== current) { + current = launch; + show(launch); + } + const untilStart = Date.parse(launch.start) - now; + document.getElementById('next-heading').textContent = launch.timed && untilStart <= 0 ? 'Just launched' : 'Next launch'; + document.getElementById('next-clock').textContent = launch.timed ? clock(untilStart) : ''; + }; + tick(); + setInterval(tick, 1000); + }) + .catch(() => { panel.hidden = true; }); + +function show(launch) { + const title = document.getElementById('next-title'); + title.textContent = launch.title.replace(/^๐Ÿš€\s*/, '').replace(/\s\((TBD|TBC|Hold)\)$/, ''); + if (!launch.confirmed) { + const tag = document.createElement('span'); + tag.className = 'tag'; + tag.textContent = 'Not confirmed'; + title.append(tag); + } + const start = new Date(launch.start); + document.getElementById('next-when').textContent = launch.timed + ? start.toLocaleString(undefined, { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }) + : `${start.toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' })}, time not set yet`; + const meta = document.getElementById('next-meta'); + meta.textContent = launch.location; + if (launch.url) { + const watch = document.createElement('a'); + watch.href = launch.url; + watch.textContent = 'Webcast'; + watch.rel = 'noopener'; + meta.append(' ยท ', watch); + } + panel.hidden = false; +} + +/** "T- 2d 04:13:22" before liftoff, "T+ 00:12:03" after. */ +function clock(ms) { + const sign = ms > 0 ? 'T-' : 'T+'; + const total = Math.floor(Math.abs(ms) / 1000); + const days = Math.floor(total / 86400); + const hms = [Math.floor(total / 3600) % 24, Math.floor(total / 60) % 60, total % 60].map((n) => String(n).padStart(2, '0')).join(':'); + return `${sign} ${days > 0 ? `${days}d ` : ''}${hms}`; +} diff --git a/site/favicon.svg b/site/favicon.svg new file mode 100644 index 0000000..a7d9938 --- /dev/null +++ b/site/favicon.svg @@ -0,0 +1 @@ + diff --git a/site/fonts/D-DIN-Bold.woff2 b/site/fonts/D-DIN-Bold.woff2 new file mode 100644 index 0000000..fbb1406 Binary files /dev/null and b/site/fonts/D-DIN-Bold.woff2 differ diff --git a/site/fonts/D-DIN.woff2 b/site/fonts/D-DIN.woff2 new file mode 100644 index 0000000..67ad269 Binary files /dev/null and b/site/fonts/D-DIN.woff2 differ diff --git a/site/fonts/OFL-1.1.txt b/site/fonts/OFL-1.1.txt new file mode 100644 index 0000000..baa6aaa --- /dev/null +++ b/site/fonts/OFL-1.1.txt @@ -0,0 +1,102 @@ +Copyright (C) 2017 Datto Inc. https://www.datto.com/fonts/d-din, +with Reserved Font Names "D-DIN". + +Copyright (C) 2017 Datto Inc. https://www.datto.com/fonts/d-din, +with Reserved Font Names "D-DIN Condensed". + +Copyright (C) 2017 Datto Inc. https://www.datto.com/fonts/d-din, +with Reserved Font Names "D-DIN Expanded". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + + + diff --git a/site/img/crs-20-launch.jpg b/site/img/crs-20-launch.jpg new file mode 100644 index 0000000..3b58a65 Binary files /dev/null and b/site/img/crs-20-launch.jpg differ diff --git a/site/index.html b/site/index.html deleted file mode 100644 index 3a766e9..0000000 --- a/site/index.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - launchcal - - -

launchcal

-

Unofficial, auto-updating calendars of SpaceX launches. Coming soon.

- -

Not affiliated with SpaceX. Launch data: The Space Devs.

- - diff --git a/site/style.css b/site/style.css new file mode 100644 index 0000000..c65f52e --- /dev/null +++ b/site/style.css @@ -0,0 +1,341 @@ +@font-face { + font-family: 'D-DIN'; + src: url('fonts/D-DIN.woff2') format('woff2'); + font-weight: 400; + font-display: swap; +} + +@font-face { + font-family: 'D-DIN'; + src: url('fonts/D-DIN-Bold.woff2') format('woff2'); + font-weight: 700; + font-display: swap; +} + +:root { + --bg: #000; + --fg: #fff; + --muted: #a3a3a3; + --line: rgba(255, 255, 255, 0.18); + --gutter: 16px; + color-scheme: dark; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font: 400 17px/1.6 'D-DIN', 'Arial Narrow', Arial, sans-serif; + -webkit-font-smoothing: antialiased; +} + +a { + color: inherit; +} + +h1, +h2, +h3, +.wordmark, +.btn { + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +h1 { + font-size: clamp(2.4rem, 7vw, 4.25rem); + line-height: 1.02; + margin: 0 0 24px; +} + +h2 { + font-size: 0.95rem; + letter-spacing: 0.2em; + color: var(--muted); + margin: 0 0 24px; +} + +h3 { + font-size: 1.15rem; + margin: 0 0 8px; +} + +/* Hero: full-bleed photo behind the text on phones, a tall panel beside it on wide screens. */ +.hero { + position: relative; + min-height: 100svh; + display: flex; + align-items: flex-end; + overflow: hidden; +} + +.hero-photo { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + object-position: 50% 40%; +} + +.hero::after { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(to bottom, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.55) 40%, rgba(0, 0, 0, 0.92) 75%, #000 100%); +} + +.hero-text { + position: relative; + z-index: 1; + width: 100%; + max-width: 640px; + padding: 96px var(--gutter) 48px; +} + +.wordmark { + position: absolute; + top: 24px; + left: var(--gutter); + margin: 0; + font-size: 1rem; + letter-spacing: 0.35em; +} + +.lede { + max-width: 34em; + margin: 0 0 32px; + color: #e5e5e5; +} + +@media (min-width: 900px) { + .hero { + align-items: center; + min-height: 100vh; + } + + .hero-photo { + left: auto; + width: 46%; + object-position: 50% 45%; + } + + .hero::after { + background: linear-gradient(to right, #000 0%, #000 54%, rgba(0, 0, 0, 0.55) 62%, rgba(0, 0, 0, 0) 78%), + linear-gradient(to top, #000 0%, rgba(0, 0, 0, 0) 20%); + } + + .hero-text { + max-width: none; + width: 54%; + padding: 96px 48px 96px max(48px, calc((100vw - 1200px) / 2 + 48px)); + } + + .wordmark { + top: 40px; + left: max(48px, calc((100vw - 1200px) / 2 + 48px)); + } +} + +/* Outline buttons that fill on hover and keyboard focus. */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 48px; + padding: 12px 22px; + border: 1px solid var(--fg); + background: transparent; + color: var(--fg); + font-family: inherit; + font-size: 0.85rem; + letter-spacing: 0.14em; + text-decoration: none; + text-align: center; + cursor: pointer; + transition: background-color 0.2s, color 0.2s; +} + +.btn:hover, +.btn:focus-visible { + background: var(--fg); + color: var(--bg); +} + +.btn:focus-visible, +a:focus-visible { + outline: 2px solid var(--fg); + outline-offset: 3px; +} + +.btn-primary { + border-width: 2px; +} + +.card .btn-primary { + background: var(--fg); + color: var(--bg); +} + +.card .btn-primary:hover, +.card .btn-primary:focus-visible { + background: transparent; + color: var(--fg); +} + +.btn-quiet { + border-color: var(--line); + color: var(--muted); +} + +.next { + margin-top: 48px; + padding-top: 24px; + border-top: 1px solid var(--line); +} + +.next h2 { + margin-bottom: 12px; +} + +.next p { + margin: 0; +} + +.next-title { + font-weight: 700; + font-size: 1.3rem; + line-height: 1.3; +} + +.next-when, +.next-meta { + color: var(--muted); +} + +.next .next-clock { + margin: 12px 0 8px; + font-weight: 700; + font-size: clamp(1.8rem, 5vw, 2.6rem); + letter-spacing: 0.06em; + font-variant-numeric: tabular-nums; +} + +.tag { + display: inline-block; + margin-left: 8px; + padding: 0 8px; + border: 1px solid var(--muted); + font-size: 0.75rem; + letter-spacing: 0.12em; + text-transform: uppercase; + vertical-align: middle; +} + +main, +footer { + max-width: 1200px; + margin: 0 auto; + padding: 0 var(--gutter); +} + +.calendars, +.faq { + padding: 72px 0; + border-top: 1px solid var(--line); +} + +.cards { + display: grid; + gap: 16px; +} + +@media (min-width: 700px) { + main, + footer { + padding: 0 48px; + } + + .cards { + grid-template-columns: repeat(2, 1fr); + } +} + +.card { + display: flex; + flex-direction: column; + padding: 28px 24px; + border: 1px solid var(--line); +} + +.card p { + margin: 0 0 24px; + color: var(--muted); + flex: 1; +} + +.actions { + display: grid; + gap: 10px; +} + +.faq dl { + margin: 0; + max-width: 46em; +} + +.faq dt { + font-weight: 700; + margin-top: 24px; +} + +.faq dt:first-child { + margin-top: 0; +} + +.faq dd { + margin: 4px 0 0; + color: var(--muted); +} + +footer { + padding-top: 32px; + padding-bottom: 48px; + border-top: 1px solid var(--line); + font-size: 0.9rem; + color: var(--muted); +} + +footer p { + margin: 0 0 8px; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + .btn { + transition: none; + } +} diff --git a/src/build.ts b/src/build.ts index b0c0c84..5f60a1d 100644 --- a/src/build.ts +++ b/src/build.ts @@ -3,6 +3,10 @@ import { CALENDAR_DESCRIPTION, CALENDARS } from './calendars.ts'; import { toEvent, type CalendarEvent } from './event.ts'; import { toIcs } from './ics.ts'; import type { Launch } from './launch.ts'; +import { nextLaunches, renderPage } from './page.ts'; + +/** How many upcoming launches `next.json` carries, so the page can move on when one ends before the next build. */ +const NEXT_COUNT = 5; const launches = JSON.parse(await readFile('data/snapshot.json', 'utf8')) as Launch[]; @@ -13,7 +17,8 @@ const feeds = CALENDARS.map((calendar) => ({ .map(toEvent) .filter((event): event is CalendarEvent => event !== null), })); -if (feeds.find((feed) => feed.calendar.id === 'all')!.events.length === 0) { +const all = feeds.find((feed) => feed.calendar.id === 'all')!.events; +if (all.length === 0) { throw new Error('No launch has a usable date; refusing to publish empty calendars'); } @@ -22,3 +27,8 @@ for (const { calendar, events } of feeds) { await writeFile(`site/cal/${calendar.id}.ics`, toIcs(calendar.name, CALENDAR_DESCRIPTION, events)); console.log(`site/cal/${calendar.id}.ics: ${events.length} events`); } + +await writeFile('site/index.html', renderPage(await readFile('src/page.html', 'utf8'), CALENDARS)); +const next = nextLaunches(all, new Date(), NEXT_COUNT); +await writeFile('site/next.json', JSON.stringify(next) + '\n'); +console.log(`site/index.html, site/next.json: ${next.length} upcoming`); diff --git a/src/calendars.ts b/src/calendars.ts index f4e6022..3aad3b4 100644 --- a/src/calendars.ts +++ b/src/calendars.ts @@ -4,6 +4,8 @@ import type { Launch } from './launch.ts'; export interface CalendarDef { id: string; name: string; + /** One line for the landing page card. */ + blurb: string; /** Public Google Calendar id the sync writes to; public like `id`, never change it once published. */ googleId: string; includes: (launch: Launch) => boolean; @@ -16,24 +18,28 @@ export const CALENDARS: CalendarDef[] = [ { id: 'all', name: 'SpaceX launches', + blurb: 'Every SpaceX launch with a known date: Falcon 9, Falcon Heavy and Starship.', googleId: '24f5ae18242c0bd83698f9ee64ec2cc2da185cc5357718efe3bb1d2d800c42bf@group.calendar.google.com', includes: () => true, }, { id: 'crewed', name: 'SpaceX crewed launches', + blurb: 'Flights with people aboard: Crew Dragon missions to the ISS and private crews.', googleId: 'cbc8fd4367b790cf6e18fbf78abb919112e54936254d3b3b6bdfdda45ea3786d@group.calendar.google.com', includes: (launch) => launch.crew.length > 0 || (launch.spacecraft?.startsWith('Crew Dragon') ?? false), }, { id: 'starship', name: 'Starship launches', + blurb: 'Every Starship flight, test or operational.', googleId: 'a72ea698dbec5c3fc71148b928dd5b1c12fef27f98676bfe7b4fcc4ef444a2fa@group.calendar.google.com', includes: (launch) => launch.rocket === 'Starship', }, { id: 'no-starlink', name: 'SpaceX launches without Starlink', + blurb: 'Everything except routine Starlink deployments. Starship flights stay in.', googleId: 'c1884006e046fbd621afe8756f6b4ec003f2ac9e9590ca380f511ddeaab24470@group.calendar.google.com', includes: (launch) => launch.rocket === 'Starship' || !launch.name.startsWith('Starlink'), }, diff --git a/src/page.html b/src/page.html new file mode 100644 index 0000000..dfd268a --- /dev/null +++ b/src/page.html @@ -0,0 +1,67 @@ + + + + + + launchcal: SpaceX launches in your calendar + + + + + + + + + + + + +
+ A Falcon 9 rocket lifting off at night, its flame reflected in the water in front of the launch pad. +
+

launchcal

+

SpaceX launches
in your calendar

+

Free, unofficial calendars that keep themselves up to date. Scrubs and new launch times show up within hours, in your own time zone.

+ Choose a calendar + + +
+
+ +
+
+

Pick a calendar

+
+ +
+
+ +
+

Good to know

+
+
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)?
+
When only the day is known, the launch is an all-day event. "(TBD)" means the date is not confirmed yet. Launches known only to the month are left out until they firm up.
+
Will it fill my calendar with alerts?
+
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.
+
+
+
+ + +

+ + diff --git a/src/page.ts b/src/page.ts new file mode 100644 index 0000000..646c446 --- /dev/null +++ b/src/page.ts @@ -0,0 +1,52 @@ +import type { CalendarDef } from './calendars.ts'; +import type { CalendarEvent } from './event.ts'; + +export const SITE_URL = 'https://launchcal.github.io'; + +/** The fields of an event the landing page shows in its "next launch" panel. */ +export type NextLaunch = Pick; + +/** Google's own share link: opens "Add this calendar?" for a signed-in user. */ +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'); + const cards = calendars.map((calendar) => { + const ics = `${SITE_URL}/cal/${calendar.id}.ics`; + const name = escapeHtml(calendar.name); + return ``; + }); + return template.replace('', () => cards.join('\n')); +} + +/** The next `count` events that have not ended at `now`, earliest start first; webcast links other than https are dropped. */ +export function nextLaunches(events: CalendarEvent[], now: Date, count: number): NextLaunch[] { + return events + .filter((event) => Date.parse(event.end) > now.getTime()) + .sort((a, b) => Date.parse(a.start) - Date.parse(b.start)) + .slice(0, count) + .map(({ title, start, end, timed, confirmed, location, url }) => ({ + title, + start, + end, + timed, + confirmed, + location, + url: url?.startsWith('https://') ? url : null, + })); +} + +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} diff --git a/test/page.test.ts b/test/page.test.ts new file mode 100644 index 0000000..e9abcb1 --- /dev/null +++ b/test/page.test.ts @@ -0,0 +1,129 @@ +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'; + +const calendar: CalendarDef = { + id: 'crewed', + name: 'SpaceX crewed launches', + blurb: 'Flights with people aboard.', + googleId: 'cbc8fd4367b790cf6e18fbf78abb919112e54936254d3b3b6bdfdda45ea3786d@group.calendar.google.com', + includes: () => true, +}; + +function event(title: string, start: string, end: string): CalendarEvent { + return { id: title, title, description: 'd', location: 'Pad', url: null, timed: true, start, end, confirmed: true }; +} + +describe('googleAddUrl', () => { + it('base64-encodes the calendar id without padding', () => { + assert.equal( + googleAddUrl('cbc8fd4367b790cf6e18fbf78abb919112e54936254d3b3b6bdfdda45ea3786d@group.calendar.google.com'), + 'https://calendar.google.com/calendar/u/0?cid=Y2JjOGZkNDM2N2I3OTBjZjZlMThmYmY3OGFiYjkxOTExMmU1NDkzNjI1NGQzYjNiNmJkZmRkYTQ1ZWEzNzg2ZEBncm91cC5jYWxlbmRhci5nb29nbGUuY29t', + ); + }); + + it('strips the padding a shorter id would get', () => { + assert.equal(googleAddUrl('ab'), 'https://calendar.google.com/calendar/u/0?cid=YWI'); + }); +}); + +describe('renderPage', () => { + const template = '
\n\n
'; + + it('renders a card with Google, webcal and copyable ICS links in place of the marker', () => { + const html = renderPage(template, [calendar]); + + assert.ok(!html.includes('')); + assert.ok(html.startsWith('
\n
')); + assert.ok(html.includes('

SpaceX crewed launches

')); + assert.ok(html.includes('

Flights with people aboard.

')); + assert.ok(html.includes('href="https://calendar.google.com/calendar/u/0?cid=Y2JjOGZkNDM2N2I3OTBjZjZlMThmYmY3OGFiYjkxOTExMmU1NDkzNjI1NGQzYjNiNmJkZmRkYTQ1ZWEzNzg2ZEBncm91cC5jYWxlbmRhci5nb29nbGUuY29t"')); + assert.ok(html.includes('href="webcal://launchcal.github.io/cal/crewed.ics"')); + assert.ok(html.includes('href="https://launchcal.github.io/cal/crewed.ics" data-copy')); + }); + + it('renders one card per calendar in order', () => { + const html = renderPage(template, [calendar, { ...calendar, id: 'all', name: 'SpaceX launches' }]); + + 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]); + + 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"')); + }); + + it('escapes calendar text', () => { + const html = renderPage(template, [{ ...calendar, name: 'Crew & "friends"', blurb: 'Dragon & co' }]); + + 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('

Dragon <i>& co</i>

')); + }); + + it('inserts text containing replacement patterns literally', () => { + const html = renderPage(template, [{ ...calendar, blurb: "Costs $& and $' nothing" }]); + + assert.ok(html.includes("

Costs $& and $' nothing

")); + }); + + it('refuses a template without the marker', () => { + assert.throws(() => renderPage('
', [calendar]), /no marker/); + }); +}); + +describe('nextLaunches', () => { + const now = new Date('2026-10-01T16:00:00Z'); + const ended = event('ended', '2026-10-01T14:00:00Z', '2026-10-01T15:00:00Z'); + const endsNow = event('ends now', '2026-10-01T15:00:00Z', '2026-10-01T16:00:00Z'); + const inProgress = event('in progress', '2026-10-01T15:30:00Z', '2026-10-01T16:30:00Z'); + const later = event('later', '2026-10-03T10:00:00Z', '2026-10-03T11:00:00Z'); + const allDay = { ...event('all day', '2026-10-02', '2026-10-03'), timed: false }; + + it('keeps launches that have not ended, earliest first', () => { + const next = nextLaunches([later, ended, allDay, inProgress, endsNow], now, 5); + + assert.deepEqual(next.map((launch) => launch.title), ['in progress', 'all day', 'later']); + }); + + it('orders by start even when a later-starting launch ends first', () => { + const long = event('long', '2026-10-02T00:00:00Z', '2026-10-05T00:00:00Z'); + const short = event('short', '2026-10-03T00:00:00Z', '2026-10-03T01:00:00Z'); + + assert.deepEqual(nextLaunches([short, long], now, 5).map((launch) => launch.title), ['long', 'short']); + }); + + it('drops webcast links that are not https', () => { + const next = nextLaunches([{ ...later, url: 'javascript:alert(1)' }, { ...allDay, url: 'http://example.com/live' }], now, 5); + + assert.deepEqual(next.map((launch) => launch.url), [null, null]); + }); + + it('limits the count', () => { + assert.deepEqual(nextLaunches([later, allDay, inProgress], now, 2).map((launch) => launch.title), ['in progress', 'all day']); + }); + + it('carries only the fields the page shows', () => { + assert.deepEqual(nextLaunches([{ ...later, url: 'https://example.com/live' }], now, 1), [ + { + title: 'later', + start: '2026-10-03T10:00:00Z', + end: '2026-10-03T11:00:00Z', + timed: true, + confirmed: true, + location: 'Pad', + url: 'https://example.com/live', + }, + ]); + }); + + it('returns nothing when every launch has ended', () => { + assert.deepEqual(nextLaunches([ended, endsNow], now, 5), []); + }); +});