Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
.venv
__pycache__
frontend/node_modules
frontend/dist
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FLASK_APP=infoscreen:create_app
FLASK_APP=infoscreen.app:create_app
FLASK_DEBUG=0

# EFA_URL: base URL of your transit operator's EFA endpoint (e.g. one
Expand Down
5 changes: 5 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,8 @@ updates:
directory: "/"
schedule:
interval: "weekly"

- package-ecosystem: "npm"
directory: "/frontend"
schedule:
interval: "weekly"
13 changes: 11 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
.venv/
.env

*.pyc
__pycache__/

instance/
boards.json

.pytest_cache/
.ruff_cache/
Expand All @@ -16,4 +16,13 @@ build/
*.egg-info/

INSTANCE-DIRECTORY/
node_modules/

# OS
.DS_Store
Thumbs.db

# Env
.env
.env.*
!.env.example
!.env.test
8 changes: 8 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"recommendations": [
"svelte.svelte-vscode",
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss",
"dbaeumer.vscode-eslint"
]
}
10 changes: 6 additions & 4 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{
"python.testing.pytestArgs": [
"tests"
],
"python.testing.pytestArgs": ["tests"],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
"python.testing.pytestEnabled": true,

"files.associations": {
"*.css": "tailwindcss"
}
}
76 changes: 57 additions & 19 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,35 +1,48 @@
# Agent Instructions

Self-hosted Flask departure board for EFA-based public transport APIs. Built for
kiosk-style displays (e.g. a wall-mounted screen showing next departures).
Self-hosted Flask + Svelte departure board for EFA-based public transport APIs.
Built for kiosk-style displays (e.g. a wall-mounted screen showing next departures
for a configurable set of boards).

## Environment

- **Python**: 3.14, managed with `uv` (not pip/venv directly)
- **Package layout**: `src/` layout, package is `infoscreen`
- **Shell**: zsh
- **Frontend**: Svelte 5 (runes) + Vite + TypeScript + Tailwind CSS v4, in `frontend/`,
managed with `npm`
- **Shell**: PowerShell (Windows), zsh (Linux)

## Repository structure

```text
src/infoscreen/
__init__.py # Flask app factory (create_app)
config.py # env-var loader (init_app) — fail-fast on missing required vars
efa.py # EFA transit API client (sync httpx, module-level TTL cache)
departure.py # blueprint: /departure, /departure_table routes
templates/
static/
tests/ # pytest, respx for HTTP mocking — never hits a live API
docker-compose.yml # local dev, reads ./.env (untracked)
Dockerfile # multi-stage uv build; NO CMD (compose supplies the run command)
__init__.py # empty -- do not put app logic here
app.py # Flask app factory (create_app), serves the built Svelte SPA at "/"
config.py # env-var loader (init_app) -- fail-fast on missing required vars
efa_client.py # EFA transit API client (sync httpx, module-level singleton client)
departure.py # blueprint: /departure, /departure_table, /api/* routes
templates/ # Jinja fallback templates (used when the SPA isn't built)
static/ # frontend/dist gets copied here during the Docker build
frontend/ # Svelte 5 + Vite + TypeScript SPA
src/App.svelte # fetches /api/boards + /api/departures at runtime
tests/ # pytest, respx for HTTP mocking -- never hits a live API
docker-compose.yml # local dev, reads ./.env (untracked), mounts ./instance -> /app/instance
Dockerfile # multi-stage build: frontend-builder (node) -> builder (uv) -> final
```

## Commands

- Install deps: `uv sync`
- Run tests: `uv run pytest`
- Lint/format: `pre-commit run --all-files` (ruff check + format, yaml/toml checks, etc.)
- Run locally: `cp .env.example .env` (fill in values), then `docker compose up -d --build`, then visit `http://localhost:8080/`
- Install backend deps: `uv sync`
- Install frontend deps: `cd frontend && npm ci`
- Run backend tests: `uv run pytest`
- Backend lint/format: `pre-commit run --all-files` (ruff check + format, yaml/toml checks, etc.)
- Frontend format/typecheck/lint: from `frontend/`, `npm run format`, `npm run check`, `npm run lint`
- Run locally (containerized): `cp .env.example .env` (fill in values), `cp boards.example.json instance/boards.json`,
then `docker compose up -d --build` (or `podman compose up -d --build` -- both are used
interchangeably against the same `docker-compose.yml`), then visit `http://localhost:8080/`
- Run locally (bare metal): `uv sync`, `cp .env.example .env`, `cp boards.example.json src/instance/boards.json`,
`uv run flask --app infoscreen.app run --debug`; separately `cd frontend && npm run dev` for the SPA dev server
(proxies `/api/*` to the Flask dev server on `:5000`)
- Regenerate lockfile after touching dependencies: `uv lock`

## Conventions
Expand All @@ -38,6 +51,16 @@ Dockerfile # multi-stage uv build; NO CMD (compose supplies the run co
place, station names, timezone) come from environment variables via `config.py`,
never from source. This includes test fixtures — use generic placeholders
(`TestCity`, `Central`, `North`, `example.invalid`), not real operator/station names.
- **Board layout is runtime config, not build-time.** Which boards to show (station,
line/direction filters, title, count) lives in `boards.json` inside the Flask
instance folder, read fresh on every `/api/boards` request by `_load_boards_config()`
in `departure.py` — never imported into the frontend bundle, so it can be edited or
swapped without a rebuild. Missing/invalid JSON degrades to an empty boards list,
never a 500. See [boards.example.json](boards.example.json) for the shape.
- `app.py`'s `create_app()` takes an `INSTANCE_PATH` env var to pin the instance
folder to a fixed, volume-mountable path in Docker; unset locally, Flask falls back
to its usual auto-detected `src/instance` path. Both `instance/` (repo root, for the
Docker Compose mount) and `src/instance/` (local dev) are git-ignored.
- `efa_client.py`'s HTTP client is a module-level singleton — keep request payloads
built fresh per call (no shared mutable state between requests). Response caching
lives in `departure.py`'s per-station `_cache` dict, not in `efa_client.py`. Tests
Expand All @@ -46,9 +69,24 @@ Dockerfile # multi-stage uv build; NO CMD (compose supplies the run co
- Station input from users is validated against the `EFA_STATIONS` allowlist
(invalid station → 400), and upstream EFA failures are caught (`EFAError`) and
rendered as a graceful fallback page, never a 500.
- The `Dockerfile` has no `CMD`; the actual serve command lives in `docker-compose.yml`
(`waitress-serve ... --call "infoscreen:create_app"`). Pass an equivalent command
explicitly when smoke-testing with a bare `docker run`.
- EFA's `departureList` can be present but `null` (not just absent) when a valid
station has no current departures — always use `data.get("departureList") or []`,
never `data.get("departureList", [])`.
- `target-version = "py314"` in `pyproject.toml`'s ruff config — PEP 758 syntax
(unparenthesized `except A, B:`) is valid and intentional in this codebase; don't
"fix" it to `except (A, B):`.
- The `Dockerfile`'s final stage has its own `CMD` (`waitress-serve ... --call
infoscreen.app:create_app`); `docker-compose.yml` doesn't override it. Pass an
equivalent command explicitly when smoke-testing with a bare `docker run`/`podman run`.

## Git workflow

PRs are managed with the [`gh stack`](https://github.com/github/gh-stack) CLI extension
for stacked PRs. Typical flow for a group of related changes:
`gh stack init <branch>` → `gh stack add -Am "<msg>"` per layer → `gh stack submit --auto`
(the `--auto` flag avoids an interactive PR-editor prompt) → after review,
`gh stack merge` → `gh stack sync --prune` → `git checkout main && git pull`.


## Git workflow

Expand Down
22 changes: 21 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
####################
# FRONTEND BUILDER #
####################

FROM node:26-slim AS frontend-builder

WORKDIR /frontend

COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci

COPY frontend ./
RUN npm run build

###########
# BUILDER #
###########
Expand All @@ -16,8 +30,11 @@ COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev

COPY src ./src
# The Svelte app is bundled as package data, served by Flask at static_url_path="".
COPY --from=frontend-builder /frontend/dist ./src/infoscreen/static
RUN uv sync --frozen --no-dev --no-editable


#########
# FINAL #
#########
Expand All @@ -29,6 +46,9 @@ RUN addgroup --system app && adduser --system --group app
ENV HOME=/home/app
WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH"
# Runtime config (e.g. boards.json) lives here, outside the image -- mount a
# volume at this path to change it without rebuilding.
ENV INSTANCE_PATH=/app/instance

COPY --from=builder /app/.venv ./.venv

Expand All @@ -40,4 +60,4 @@ EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/healthz')" || exit 1

CMD ["waitress-serve", "--listen=0.0.0.0:8080", "--call", "infoscreen:create_app"]
CMD ["waitress-serve", "--listen=0.0.0.0:8080", "--call", "infoscreen.app:create_app"]
4 changes: 4 additions & 0 deletions boards.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[
{ "station": "Central", "line": "U1", "title": "Central", "count": 5 },
{ "station": "North", "line": ["S1", "S2"], "direction": "H", "title": "North", "count": 5 }
]
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ services:
- "8080:8080"
env_file:
- ./.env
volumes:
- ./instance:/app/instance:ro
15 changes: 15 additions & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
node_modules

# OS
.DS_Store
Thumbs.db

# Env
.env
.env.*
!.env.example
!.env.test

# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
1 change: 1 addition & 0 deletions frontend/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
engine-strict=true
9 changes: 9 additions & 0 deletions frontend/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb

# Miscellaneous
/static/
41 changes: 41 additions & 0 deletions frontend/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import prettier from 'eslint-config-prettier';
import path from 'node:path';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import { defineConfig, includeIgnoreFile } from 'eslint/config';
import globals from 'globals';
import ts from 'typescript-eslint';

const gitignorePath = path.resolve(import.meta.dirname, '.gitignore');

export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
ts.configs.recommended,
svelte.configs.recommended,
prettier,
svelte.configs.prettier,
{
languageOptions: { globals: { ...globals.browser, ...globals.node } },
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser
}
}
},
{
// Override or add rule settings here, such as:
// 'svelte/button-has-type': 'error'
rules: {}
}
);
11 changes: 11 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Departures</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
Loading
Loading