- TypeScript 95%
- Dockerfile 2.3%
- Makefile 1.7%
- JavaScript 1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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. |
||
| .forgejo/workflows | ||
| demo | ||
| deploy/podman | ||
| src | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| .nvmrc | ||
| .prettierignore | ||
| AGENTS.md | ||
| ARCHITECTURE.md | ||
| CLAUDE.md | ||
| config.demo.toml | ||
| config.toml.example | ||
| Dockerfile | ||
| eslint.config.mjs | ||
| LICENSE | ||
| Makefile | ||
| package.json | ||
| pnpm-lock.yaml | ||
| prettier.config.js | ||
| README.md | ||
| tsconfig.build.json | ||
| tsconfig.json | ||
| vitest.config.ts | ||
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 enableto get the pinned version automatically (Node 25+ no longer bundles corepack;npm install -g pnpmworks too)
Setup
- Clone the repository
- Copy the example config file and adjust it to your needs:
cp config.toml.example config.toml - Edit
config.tomlwith your Nextcloud calendar URLs and credentials - Install dependencies:
pnpm install - 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, integer1..14, default5. The response always contains2 + daysday entries (yesterday + today + lookahead); days without events carry an emptyeventsarray.
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, inYYYY-MM-DDformat. 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).nullif 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.