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.

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.

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

GitHub →