An app to display events from a nextcloud calendar.
  • TypeScript 95%
  • Dockerfile 2.3%
  • Makefile 1.7%
  • JavaScript 1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jan Felix Wiebe 71d4afa49a
All checks were successful
deploy / build-and-push (release) Successful in 46s
CI / check (push) Successful in 1m45s
resolve each event once per request, compare instants numerically
v4.0.0 fetched the window in one REPORT per calendar but still expanded
every recurring master once per day, and a 7-day feed spent ~4 s of CPU
on the production host — not in the network (12 REPORTs take ~0.45 s
regardless of window size) but in dayjs: on a zoned instance isSame(),
isBefore(), isAfter(), startOf() and endOf() each re-parse the instant and
re-resolve the offset through the timezone plugin, and dayjs(date).tz(tz)
builds a fresh Intl formatter per call. Each costs ~0.25 ms locally and
several times that on Alpine's musl.

expandCalendarDays now resolves every VEVENT to its occurrences once over
the whole window and attributes them to days; the overlap test and the
allDay/openEnd flags compare valueOf() numbers; day bounds are resolved
once per request instead of once per document and day; an occurrence's
calendar date comes from a cached Intl formatter. Locally the 7-day
expansion drops from ~1.2 s to ~0.2 s with identical output.

Semantics unchanged except for one edge that is now process-timezone
independent: "exactly one day long" is a 24 h duration check instead of
isSame(end.subtract(1, "day")), which stepped a Date in the process zone.

Also corrects the claim that Nextcloud's time-range does not apply to
recurring events: Sabre does expand them when both bounds are given.
2026-09-09 23:17:26 +02:00
.forgejo/workflows drop next.js and serve the api from a plain node http server 2026-09-01 13:18:32 +02:00
demo added a later demo entry 2026-08-25 21:52:44 +02:00
deploy/podman drop next.js and serve the api from a plain node http server 2026-09-01 13:18:32 +02:00
src resolve each event once per request, compare instants numerically 2026-09-09 23:17:26 +02:00
.dockerignore drop next.js and serve the api from a plain node http server 2026-09-01 13:18:32 +02:00
.env.example drop next.js and serve the api from a plain node http server 2026-09-01 13:18:32 +02:00
.gitignore drop next.js and serve the api from a plain node http server 2026-09-01 13:18:32 +02:00
.nvmrc pin toolchain: pnpm 10, node >=20.9, .nvmrc 2026-08-17 01:47:25 +02:00
.prettierignore drop next.js and serve the api from a plain node http server 2026-09-01 13:18:32 +02:00
AGENTS.md resolve each event once per request, compare instants numerically 2026-09-09 23:17:26 +02:00
ARCHITECTURE.md resolve each event once per request, compare instants numerically 2026-09-09 23:17:26 +02:00
CLAUDE.md add project guidance for coding agents to AGENTS.md 2026-08-17 09:03:02 +02:00
config.demo.toml drop next.js and serve the api from a plain node http server 2026-09-01 13:18:32 +02:00
config.toml.example drop next.js and serve the api from a plain node http server 2026-09-01 13:18:32 +02:00
Dockerfile drop next.js and serve the api from a plain node http server 2026-09-01 13:18:32 +02:00
eslint.config.mjs drop next.js and serve the api from a plain node http server 2026-09-01 13:18:32 +02:00
LICENSE Create LICENSE 2024-07-04 23:46:08 +02:00
Makefile drop next.js and serve the api from a plain node http server 2026-09-01 13:18:32 +02:00
package.json upgrade to eslint 10 2026-09-01 19:49:12 +02:00
pnpm-lock.yaml upgrade to eslint 10 2026-09-01 19:49:12 +02:00
prettier.config.js drop next.js and serve the api from a plain node http server 2026-09-01 13:18:32 +02:00
README.md fetch each calendar once per request instead of once per day 2026-09-09 22:57:06 +02:00
tsconfig.build.json drop next.js and serve the api from a plain node http server 2026-09-01 13:18:32 +02:00
tsconfig.json drop next.js and serve the api from a plain node http server 2026-09-01 13:18:32 +02:00
vitest.config.ts update node-ical to 0.27 and drop the rrule workarounds 2026-08-18 15:57:51 +02:00

Event Display

A small HTTP service that serves the events from the Nextcloud calendars of the Stadtteilzentrum Nordstadt e.V. in Hannover as JSON.

It is the transition layer between CalDAV and the info-beamer signage package (package-buergerschule) that renders the schedule on the screen in the entry hall. Earlier versions rendered that screen themselves as a web app; the rendering moved into the signage package, and this service is now API-only. See ARCHITECTURE.md for how it works.

Development

Prerequisites

  • Node.js 22 or higher (24 LTS recommended, see .nvmrc)
  • pnpm 10 — run corepack enable to get the pinned version automatically (Node 25+ no longer bundles corepack; npm install -g pnpm works too)

Setup

  1. Clone the repository
  2. Copy the example config file and adjust it to your needs:
    cp config.toml.example config.toml
    
  3. Edit config.toml with your Nextcloud calendar URLs and credentials
  4. Install dependencies:
    pnpm install
    
  5. Start the server:
    make dev
    

The API is available at http://localhost:3000. The app looks for ./config.toml by default; set the CONFIG_PATH environment variable (e.g. in .env.local, see .env.example) to use a different file, and PORT to listen elsewhere.

Demo mode

To try the app without a Nextcloud instance, run

make demo

It starts the server with config.demo.toml, which points at the checked-in calendars in demo/ via file:// URLs — no credentials needed.

curl -s localhost:3000/api/events?days=2 | jq

Useful Commands

make help lists all tasks. The most useful ones:

make dev      # start the server with reload
make demo     # start against the checked-in demo calendars, no credentials needed
make check    # typecheck + lint + format check + tests (same as CI)
make test     # run the test suite
make build    # compile TypeScript to dist/
make start    # run the compiled server from dist/
make format   # format the codebase with prettier
make image    # build the container image locally with podman

Deployment

A container image is published to the Forgejo package registry whenever a release is published: git.hannover.ccc.de/buergerschule/event-display:latest (also tagged with the release tag and the commit SHA). It is built by CI from the Dockerfile.

The production deployment — rootless Podman with systemd Quadlets, pulling this image via podman auto-update — is documented in deploy/podman/README.md.

The service has no authentication of its own: it is bound to loopback and an external reverse proxy is the trust boundary. The signage package can send a shared secret as a Token: header (its api_token option), so the proxy can require that header on /api/*. Keep /api/health reachable unauthenticated from 127.0.0.1, or the container healthcheck fails.

Configuration

There is a config.toml to configure every important aspect of the app. The path for this file can be set using the CONFIG_PATH environment variable. If this variable is not set, the app will look for a config.toml in the current working directory.

Here is an example configuration:

[calendar]
# The timezone the calendars are interpreted in. Single source of truth for every day
# boundary and every formatted time — the app never uses the process timezone.
# (optional, default: "Europe/Berlin")
timeZone = "Europe/Berlin"
# The calendars to load events from
# name: The name of the room the calendar is for
# url: The url of the calendar (found as the "Internal URL" in the nextcloud calendar settings)
# location: The floor/location of the room, served as the event's `level` (optional, default: "")
# color: the colour served with the room's events (optional, default: "#ea6400")
calendars = [
    { name = "Raum A", url = "https://cloud.example.de/remote.php/dav/calendars/kalendar/raum-a/", location = "2. OG", color = "#2563eb" },
    { name = "Raum B", url = "https://cloud.example.de/remote.php/dav/calendars/kalendar/raum-b/", location = "EG" },
]
# events that carry one of these keywords in their iCal categories are private and are
# excluded from every API response
ignoreKeywords = ["!intern!", "!privat!"]
# keywords that are stripped from event titles and mark the event as "open end"
# (served as open_end = true) when found in the iCal categories
openEndKeywords = ["offen", "openend"]
# the auth information for the nextcloud instance (app password), the user must have read
# access to the calendars specified above
# type: The type of authentication, currently only "basic" is supported (optional)
auth = { type = "basic", username = "kalendar", password = "abcdefg" }

Unknown keys are ignored rather than rejected, so a config.toml still carrying the [frontend] section (or calendar.refreshInterval / calendar.timeout) from the days when this app rendered its own web UI keeps working unchanged.

API

GET /api/events

Schedule feed for the info-beamer signage package (package-buergerschule). Returns the events of yesterday, today and the following days as raw data — private events are excluded, but no timeout filter is applied (hiding ended events is the renderer's job) and no display strings for header or date are included.

Query parameters:

  • days (optional): number of lookahead days after today, integer 1..14, default 5. The response always contains 2 + days day entries (yesterday + today + lookahead); days without events carry an empty events array.

Example:

curl "http://localhost:3000/api/events?days=3"

Response shape, field semantics and the consuming sync service are documented in the package: package-buergerschule/docs/event-display-api.md. Invalid days returns HTTP 400. A calendar that cannot be fetched is logged (calendar <name>: … on stderr) and left out of the response; only when no calendar at all could be read does the endpoint answer HTTP 502.

GET /api/hours

Returns the start time of the first event and the end time of the last event of a given day as JSON. All-day events and private events (see ignoreKeywords) are excluded.

Query parameters:

  • date (optional): the day to query, in YYYY-MM-DD format. Defaults to the current day if omitted. The date is interpreted in the timezone configured under [calendar] timeZone.

Example:

curl "http://localhost:3000/api/hours?date=2026-06-10"

Example response:

{
  "date": "2026-06-10",
  "timeZone": "Europe/Berlin",
  "first": "2026-06-10T08:00:00+02:00",
  "last": "2026-06-10T18:00:00+02:00",
  "eventCount": 5
}
  • first / last: ISO 8601 timestamps of the earliest start and latest end, in the configured timezone (including its UTC offset). null if there are no matching events.
  • eventCount: the number of events considered.

A request with an invalid date returns HTTP 400. As with /api/events, a single failing calendar is logged and left out; HTTP 502 only when none could be read.

GET /api/health

Returns {"status":"ok"} without contacting Nextcloud. This is the container healthcheck target: it must stay green during an upstream outage, otherwise podman auto-update would read a Nextcloud outage as a broken image and roll back a healthy one.

Unknown paths return a JSON 404; methods other than GET/HEAD return 405.

License

This software is licensed under GNU Affero General Public License V3. For more details see LICENSE.

Financing

This software has been built from public money. For more info, why publicly funded software should be public code, visit https://publiccode.eu/, an initiative by Free Software Foundation Europe.