I wanted to show my availability on this blog — a simple “here’s when I’m free” widget. Every existing solution required either handing my calendar to a third-party service (Calendly, Cal.com cloud) or setting up OAuth credentials. None of them fit a static blog where I just want a read-only JSON feed.

So I built Timeslot — a self-hosted service that exposes your availability as a clean API, without sharing your calendar details with anyone.

How It Works#

Timeslot does one thing: show when you’re free. No appointment booking, no email flows — just availability.

graph LR A[Google Calendar
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]
  1. Sync — Provide your private iCal URLs. Timeslot polls them every 15 minutes.
  2. Rules — Define your availability windows (e.g., Mon–Fri, 9 AM–5 PM).
  3. Query — The API subtracts busy blocks from your rules and returns free slots.
  4. Display — Your blog widget fetches the JSON and renders it.

Crucially, Timeslot only extracts busy/free status from your calendar — appointment titles, attendees, and descriptions never leave your server.

Why This Stack#

Go + single binary — one file to deploy, great stdlib for HTTP and concurrency, cross-compiles to ARM64 without fuss.

SQLite — no database server, no connection pools. Backups are cp timeslot.db timeslot.db.bak. On a personal tool processing one calendar, it’s the right choice.

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 embeds directly into Hugo, Jekyll, 11ty, or any static site.

Embedding in a Static Blog#

Here’s how this blog (meirong.dev/timeslot) integrates Timeslot — no framework, no dependencies, just a fetch call.

Hugo: rawhtml shortcode#

Hugo’s default Markdown renderer strips raw <script> and <style> tags for security. The workaround is a one-line 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 ~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 runs at slot.meirong.dev on my Oracle Cloud K3s cluster. The full stack:

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. Quick 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.

All four are filed as PRs upstream. Adding a /healthz endpoint (lightweight, no SQLite query) is also on the list — using /api/slots for every readiness probe is unnecessarily expensive.

Roadmap#

  • Multi-calendar aggregation
  • Pre-built widgets for Hugo, Jekyll, 11ty
  • Rate limiting on the public API
  • Helm chart fixes merged upstream

GitHub →