case-calendar can render a static index.html alongside your ICS files —
a single landing page that lists every calendar, with one-click subscribe
buttons, the cases on each calendar, and (when AI summaries are enabled)
a short prose description for each. Drop it behind any HTTP server and
you’ve got a shareable URL for the whole project.
A live deployment is at casecalendar.net — look there for a sense of what the rendered page actually looks like with real cases, real summaries, and real subscribe buttons.
This page describes how to enable it and how to host it.
What you get
When index_path is set in config.yaml, every sync / serve /
emit writes an index.html to that path. The page is fully
self-contained — inline CSS and JS, no external requests, no CDN — so it
works behind any static HTTP server (or just opening the file directly in
a browser).
Features:
- Subscribe buttons for each calendar. When
public_base_urlis set, the buttons emit bothhttps://andwebcal://URLs so subscribers can pick whichever their calendar app prefers. (webcal://is the protocol most desktop calendar apps recognize as “open this as a subscription”.) - Per-case rows carry the court, docket number, filing date, last-filing date, and the AI summary paragraph when summaries are enabled. Multi-docket cases get one summary paragraph per docket, each prefixed with the docket number + court citation.
- Client-side sort. Each section has a dropdown (case name, date filed, last filing) and an ascending / descending toggle. Default is “Last filing” descending so a recent flurry of activity surfaces first.
- Dark mode. Respects
prefers-color-schemeby default, with a header toggle that overrides and persists the choice inlocalStorage. An inline pre-paint script in the<head>applies the saved preference before stylesheets load, so there’s no flash of the wrong theme. - Darkreader-compatible. The page declares
<meta name="color-scheme" content="light dark">paired with:root { color-scheme: light dark; }in CSS, so the Darkreader browser extension skips it instead of double-applying its filter. - Static legal disclaimer footer. Two sentences rendered by the page template, not the LLM — “Case descriptions are generated by AI and may contain mistakes” plus the presumption of innocence for criminal defendants.
Enabling the page
Three config keys, all optional, control the page:
index_path: out/index.html
public_base_url: https://calendars.example.com
site_title: "Federal court calendar feeds"
site_description: "Subscribable feeds for cybercrime and AI-litigation hearings, sourced from CourtListener / RECAP."
| Key | Purpose |
|---|---|
index_path |
Where to write the HTML file. Setting this turns the feature on. |
public_base_url |
The URL where the out/ directory is hosted. When set, subscribe buttons emit absolute URLs; otherwise they fall back to relative filenames (which still let you open the page locally but break one-click subscribe). |
site_title |
The page <h1> and <title>. |
site_description |
The <meta name="description"> content (used by search engines and link-preview cards). Keep under 160 characters. |
The page is rendered once at the end of every sync / serve /
emit — even when only one calendar’s content actually changed — because
the page is a cross-calendar view and sibling calendars can move in the
sort even when their own contents didn’t.
Hosting with Caddy
Caddy is the simplest option for serving the
out/ directory over HTTPS with Let’s Encrypt. The repo ships a working
Caddyfile
you can adapt:
calendars.example.com {
root * /opt/case-calendar/out
file_server {
index index.html
}
# .ics files should be served with the calendar MIME type so clients
# treat the download as a subscription rather than plain text.
@ics path *.ics
header @ics Content-Type "text/calendar; charset=utf-8"
# Subscribe URLs change rarely; short-circuit refreshes.
header Cache-Control "public, max-age=300"
}
Replace calendars.example.com and /opt/case-calendar/out with your own
values. Caddy needs inbound TCP/80 + TCP/443 reachable from the public
internet for the ACME HTTP-01 challenge; if you’re behind a firewall, use
the DNS-01 plugin instead.
Install it as the system Caddyfile and reload:
sudo ln -s /opt/case-calendar/Caddyfile /etc/caddy/Caddyfile
sudo systemctl reload caddy
Subscribers now point their calendar app at:
https://calendars.example.com/cybercrime.ics
…and the landing page is at:
https://calendars.example.com/
Combining with the webhook receiver
The same Caddy install can also reverse-proxy the webhook receiver on a sibling subdomain. The shipped Caddyfile has a commented-out template:
webhook.example.com {
reverse_proxy 127.0.0.1:8000
}
That keeps the public calendar feeds and the (sensitive) webhook endpoint on separate hostnames — Caddy gets one TLS certificate per hostname automatically, and your access logs separate the two surfaces cleanly. See real-time webhooks for the rest of the webhook setup.
Other static servers
The page is plain HTML — anything that serves files works:
- GitHub Pages —
out/could even be checked into a separate branch, though most users prefer a real server here. - nginx — set the
text/calendarMIME type for.ics(the default config often misses it). - Cloudflare Pages / Vercel / Netlify — point them at the
out/directory of a separate repo you sync to. - Just opening the file —
xdg-open out/index.htmlworks fine for local-only use, though subscribe buttons requirepublic_base_urlto be set to be one-click.
The page is fully self-contained, so the only server-side concern is the
MIME type for .ics files.
Cron note
If you’re running case-calendar sync on a cron and serving via Caddy,
nothing else is needed — every sync writes a fresh index.html. If you’re
running case-calendar serve for real-time webhooks, the index is
re-rendered on every delivery that changes a row plus a debounced
re-emit when AI summaries finish refreshing.
Next steps
- Configuration — the rest of
config.yaml. - AI case summaries — what gets rendered into each case row.
- Architecture — the rest of the design.