Timeslot: A Self-Hosted Calendar Availability Feed for a Static Blog
目录
I wanted to show my availability on this blog — basically a simple “here’s when I’m free” widget. Most of the options I looked at either pushed me toward a third-party scheduling service (Calendly, Cal.com cloud) or required setting up OAuth credentials. Neither felt like a great fit for a static blog that only needed a read-only JSON feed.
So I built Timeslot — a small self-hosted service that exposes availability as a simple API while keeping the underlying calendar data on infrastructure I control.
How It Works#
Timeslot does one thing: show when you’re free. No appointment booking, no email workflows — just availability.
Apple Calendar
Fastmail] -->|iCal URL| B(Timeslot) B -->|Parse busy blocks| C[(SQLite)] C -->|Query available slots| B B -->|JSON API| D[Your Blog
Static Site] B -->|Admin UI| E[You]
- Sync — Provide your private iCal URLs. Timeslot polls them every 15 minutes.
- Rules — Define your availability windows (e.g., Mon–Fri, 9 AM–5 PM).
- Query — The API subtracts busy blocks from your rules and returns free slots.
- Display — Your blog widget fetches the JSON and renders it.
In the current design, Timeslot only extracts busy/free status from the calendar feed — appointment titles, attendees, and descriptions are not part of the public API.
Why This Stack#
Go + single binary — one file to deploy, a strong stdlib for HTTP and concurrency, and straightforward ARM64 cross-compilation.
SQLite — no database server, no connection pools. Backups can stay simple (cp timeslot.db timeslot.db.bak). For a personal tool that only tracks a small amount of availability data, this has been enough so far.
Key Features#
| Feature | Detail |
|---|---|
| Privacy | Only parses busy/free blocks — no event details exposed |
| Universal iCal | Works with Google, Apple, Outlook, Fastmail — no OAuth or API keys |
| Lightweight | ~20 MB RAM, starts instantly, runs on a Raspberry Pi |
| OpenTelemetry | Tracing and metrics built-in for homelab observability |
| Docker + Helm | Ready for container and Kubernetes deployments |
Getting Started#
Prerequisites: a private iCal URL from your calendar provider (Google: Settings → Calendar → Export) and a VPS, Raspberry Pi, or local machine.
Docker (recommended):
docker run -d \
-p 8080:8080 \
-v $(pwd)/timeslot.db:/app/timeslot.db \
-e ADMIN_USER=admin \
-e ADMIN_PASSWORD=change-me \
ghcr.io/meirongdev/timeslot:latest
Or grab the binary from releases:
./timeslot
Minimal config.json:
{
"listen_addr": ":8080",
"db_path": "./timeslot.db",
"admin_user": "admin",
"admin_password": "change-me",
"slot_duration_min": 30,
"timezone": "UTC"
}
Query the API:
GET /api/slots?from=2026-03-01T09:00&to=2026-03-01T17:00
The JSON response can be embedded into Hugo, Jekyll, 11ty, or any other static site.
Embedding in a Static Blog#
Here’s how this blog (meirong.dev/timeslot) integrates Timeslot — no framework, no extra front-end dependencies, just a fetch call.
Hugo: rawhtml shortcode#
Hugo’s default Markdown renderer strips raw <script> and <style> tags for security. In this repo, the workaround is a small shortcode that passes content through as-is:
{{/* layouts/shortcodes/rawhtml.html */}}
{{- .Inner | safeHTML -}}
Then in any .md page:
{{< rawhtml >}}
<div id="ts-widget">...</div>
<script>/* widget JS */</script>
{{< /rawhtml >}}
The Widget#
The widget is roughly 60 lines of vanilla JS. It fetches the next 14 days of slots, groups them by date, and renders a list:
(function () {
const API_BASE = "https://slot.meirong.dev/api";
const TZ = "Asia/Shanghai";
async function fetchSlots() {
const now = new Date().toISOString();
const in14d = new Date(Date.now() + 14 * 864e5).toISOString();
const resp = await fetch(`${API_BASE}/slots?from=${encodeURIComponent(now)}&to=${encodeURIComponent(in14d)}`);
const slots = await resp.json(); // [{ start, end }, ...]
// group slots by calendar date
const groups = {};
slots.forEach(slot => {
const key = new Date(slot.start).toLocaleDateString("zh-CN", { timeZone: TZ });
(groups[key] = groups[key] || []).push(slot);
});
// render date headers + time ranges
Object.entries(groups).forEach(([date, daySlots]) => {
renderDateHeader(date);
daySlots.forEach(slot => renderSlot(slot.start, slot.end));
});
}
fetchSlots();
})();
/api/slots returns a flat array of { start, end } ISO timestamps. The widget handles all the timezone formatting client-side — the API stays timezone-agnostic.
Other Static Site Generators#
The pattern is the same for Jekyll, 11ty, Astro, etc. — the widget is plain HTML + JS with no build step. The only difference is how each SSG handles raw HTML blocks:
| SSG | How to embed raw HTML |
|---|---|
| Hugo | rawhtml shortcode (shown above) |
| Jekyll | {% raw %} tag or .html include |
| 11ty | Nunjucks safe filter or .njk template |
| Astro | <Fragment set:html={...}> or .astro component |
Running It in Production (K3s + Homelab)#
Timeslot currently runs at slot.meirong.dev on my Oracle Cloud K3s cluster. The stack looks like this:
| Layer | Tool |
|---|---|
| Cluster | K3s on Oracle Cloud (ARM64, A1.Flex) |
| Ingress | Traefik via Gateway API (HTTPRoute) |
| Tunnel | Cloudflare Tunnel — no open ports |
| Auth | HTTP Basic Auth (built-in); /api/* is public |
| Secrets | HashiCorp Vault — read at deploy time |
| Monitoring | Uptime Kuma + OTel Collector → Grafana |
Hybrid GitOps Deployment#
Git (HTTPRoute, DNS, Homepage) ──→ kubectl apply -k ──→ cluster state
Vault (admin_password) ──→ just deploy-timeslot ──→ helm upgrade
What lives in Git (declarative, auditable): HTTPRoute, Cloudflare Terraform, Homepage entry, Uptime Kuma monitor.
What stays outside Git (intentional): admin_password → Vault secret/oracle-k3s/timeslot.
just deploy-timeslot # fetches chart, injects secret from Vault, runs helm upgrade
just deploy-manifests # applies HTTPRoute + Homepage entry via Kustomize
cd cloudflare && just apply # creates slot.meirong.dev DNS record
Full infra code: meirongdev/homelab
Helm Chart: Lessons Learned#
Deploying into a real cluster surfaced four issues in the upstream chart. A short summary:
1. Liveness probe hits /admin/ → CrashLoopBackOff
Kubernetes probes don’t send Basic Auth credentials. The pod restarts every 3 failures. Fix: default the probe to /api/slots (public endpoint) and make it configurable in values.yaml.
2. No existingSecret support
The chart always creates a Secret — this conflicts with external secret managers (Vault Agent, ESO) that create the same Secret name. Fix: add an existingSecret value to skip chart-managed Secret creation.
3. resources block misplaced in values.yaml
limits and requests are nested under service: instead of a top-level resources: key, so no resource limits are ever applied. Fix: move them to the correct top-level key.
4. sed delimiter breaks on passwords containing |
The init container uses | as the sed delimiter — a password with | causes a parse error. Better fix: mount the Secret as a file and read it at startup, removing the init container entirely.
At the time I wrote this, all four had already been filed upstream as PRs. Adding a /healthz endpoint (lightweight, no SQLite query) was also still on my list — using /api/slots for every readiness probe felt heavier than necessary.
Roadmap#
- Multi-calendar aggregation
- Pre-built widgets for Hugo, Jekyll, 11ty
- Rate limiting on the public API
- Helm chart fixes merged upstream