Skip to content

Latest commit

 

History

213 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Chronos Ledger

Chronos Ledger


Organization Schedule & Attendance Management, self-hosted, offline-first, production-ready.

CI License Docker: Backend Docker: Web Docker Hub PRs Welcome Views


The Problem

Universities and organizations track attendance on paper, manage timetables in Excel, and learn of staff absences only when members complain. Organization networks are unreliable. Staff don't know where their colleagues are. Visitors have no formal check-in system.

Chronos Ledger solves all of this in a single, self-hosted, Docker-deployable stack that runs entirely on your organization intranet, no cloud subscription, no data leaving your network.


Key Metrics

Metric Value
REST API endpoints 30+
Role-specific dashboards 4
Database tables 10
Offline capability Reads cached; attendance marks queue and auto-sync
Setup time < 5 minutes
Runtime dependencies Docker + Docker Compose only
License Apache 2.0

Quick Start: One Command

# Clone, configure, and launch everything
git clone https://github.com/Life-Experimentalist/chronos-ledger.git
cd chronos-ledger
chmod +x setup.sh && ./setup.sh

setup.sh auto-detects your LAN IP, generates cryptographic secrets, and launches all services. When it finishes:

  • App → http://<your-server-ip>
  • API docs → http://<your-server-ip>/docs
  • Login → admin@org.internal, with the password setup.sh printed (also in .env as INITIAL_ADMIN_PASSWORD)

Want to demo it to someone? ./setup.sh --build --demo boots it with sample data; docs/demo.md has the five-minute walkthrough.

Or pull a published image (no build required)

curl -fsSL https://raw.githubusercontent.com/Life-Experimentalist/chronos-ledger/main/docker-compose.prod.yml \
  -o docker-compose.prod.yml

export JWT_SECRET_SIGNING_KEY=$(openssl rand -hex 32)
export DB_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=' | head -c 32)
export GHCR_OWNER=Life-Experimentalist

docker compose -f docker-compose.prod.yml up -d

Pin any release: VERSION=v1.2.0 docker compose -f docker-compose.prod.yml up -d

The compose file pulls from GHCR, which does not rate-limit anonymous pulls of a public image. The same two images go to Docker Hub as well, for anyone who would rather not type a registry prefix:

docker pull vkrishna04/chronos-ledger-backend
docker pull vkrishna04/chronos-ledger-web

Architecture

  Clients (PWA)                 Organization Server
  ┌─────────────┐              ┌────────────────────────────────────────┐
  │ Member     │              │  Docker network: chronos_net           │
  │ Staff     │──HTTPS/WSS──▶│  ┌──────────┐     ┌────────────────┐  │
  │ Admin       │              │  │  Nginx   │────▶│  FastAPI       │  │
  │ Guest Kiosk │              │  │  :80/443 │     │  :8000         │  │
  └─────────────┘              │  └──────────┘     └───────┬────────┘  │
                               │       │                   │           │
                               │  Static files         ┌───┴──────┐    │
                               │  (Next.js export)     │PostgreSQL│    │
                               │                       │ Redis    │    │
                               └───────────────────────┴──────────┴────┘

Full diagrams with Mermaid charts: docs/architecture.md


Feature Highlights

Geofenced Attendance

Members mark attendance via GPS. The server runs a Haversine distance check, plus an altitude delta (|Δalt| <= 4m) when both the device and the room report an altitude, so that somebody a floor above or below does not register. Altitude is optional on both sides: a fix without one is judged on the horizontal radius alone. The client refuses to mark at all when the reported accuracy is worse than 30m, and the server refuses a fix coarser than GEOFENCE_ACCURACY_FACTOR times the fence radius, which by default is the same 30m on a 15m fence.

4-Tier Staff Location Resolution

Always know where staff are, in priority order:

  1. Override: a state_override:<id> value an integration writes to Redis. Chronos never writes one itself, and the tier is skipped when Redis is down.
  2. The generated day: the room of a slot they cover, or of their own slot when nobody else covers it. OFF_SITE when that day marks them on leave.
  3. Weekly timetable: the room of a slot they lead, on a date the nightly job has not generated yet.
  4. Base station: OFF_SITE on a day of approved leave, otherwise their assigned base station.

The full rules are in docs/architecture.md.

Reverse RSVP Absence System

Presence is the default state. Staff file absences rather than confirming presence. Requests route to the line manager for approval. Approved absences cascade ON_LEAVE to every affected ledger entry and fire a WebSocket notification to the staff member.

Offline-First PWA

Organization Wi-Fi drops. Chronos Ledger keeps working:

  • Attendance marks queue to IndexedDB and flush on reconnect (Background Sync where available, the app itself otherwise)
  • Today's schedule cached locally for 12 hours
  • Class reminders fire up to 20 minutes before start, even with the app closed, via periodicsync in the service worker

Real-Time WebSocket Hub

JWT-authenticated persistent connections. Guest handshake requests, absence approvals, and ledger state changes arrive in milliseconds, no polling.

CSV Bulk Import

Drop a member-centric CSV on the Admin dashboard. One upload creates/updates users, activity offerings, master timetable slots, and member registrations atomically and idempotently.


Role Dashboards

Role Path Core capabilities
Super Admin /admin/dashboard CSV import, cycle management, proxy assignment, user provisioning
Unit Admin /admin/dashboard Absence approvals, ledger overrides for own unit
Staff /staff/dashboard Availability switcher, attendance matrix, absence requests, guest desk
Member /member/dashboard Live timeline, geofenced self-mark, staff locator, offline queue
Guest /guest/kiosk No visitor login, check-in form, real-time staff notification

Tech Stack

Layer Technology
Backend Python 3.11 · FastAPI 0.141 · SQLAlchemy 2 · Alembic · APScheduler
Auth PyJWT 2.14 · bcrypt 5.0 (no CVE-affected packages)
Database PostgreSQL 17
Cache / PubSub Redis 7.4
Frontend Next.js 16 App Router · React 19 · TypeScript · Tailwind CSS
State Zustand · React Hook Form · Zod
PWA @ducanh2912/next-pwa (Workbox) · IndexedDB (idb) · Web Push (VAPID)
Reverse proxy Nginx 1.27
Packaging uv (Python) · npm (Node.js)
Container Docker 24 · Docker Compose 2.20
CI/CD GitHub Actions · GHCR · Docker Hub
Releases Release Please (semver, CHANGELOG)

Project Structure

chronos-ledger/
├── .github/
│   ├── workflows/
│   │   ├── ci.yml          # Lint, tests, type-check, build, Trivy, publish, release
│   │   ├── cd.yml          # Build & push to GHCR + Docker Hub (main + releases)
│   │   └── release.yml     # Release Please semver releases (called by ci.yml)
│   └── dependabot.yml      # Automated dep updates (pip, npm, Docker, Actions)
├── assets/
│   ├── logo.svg            # Vector wordmark
│   ├── Logo.png            # Raster logo
│   └── Icon.png            # App icon
├── backend/                # FastAPI application (uv managed)
│   ├── pyproject.toml
│   ├── alembic/            # DB migrations
│   └── app/
│       ├── core/           # Config, DB, security, Redis, WebSocket manager
│       ├── models/         # SQLAlchemy ORM (9 tables)
│       ├── schemas/        # Pydantic request/response models
│       ├── api/v1/         # Route handlers per domain
│       ├── services/       # Business logic (geo-fence, RSVP, ingestion)
│       └── cron/           # Nightly ledger generator (APScheduler)
├── frontend/               # Next.js 16 PWA (npm managed)
│   ├── public/
│   │   ├── manifest.webmanifest
│   │   └── sw-custom.js    # Background sync + push + periodicsync
│   └── src/
│       ├── app/            # App Router pages per role
│       ├── components/     # Role-scoped UI components
│       ├── hooks/          # useWebSocket, useGeolocation, useAuth, useScheduleNotifications
│       ├── lib/            # Axios API client, IndexedDB helpers, auth utils
│       └── store/          # Zustand: auth + notifications
├── nginx/
│   ├── nginx.conf          # Reverse proxy + static serving + security headers
│   └── Dockerfile          # Multi-stage: Next.js build → nginx image (for GHCR)
├── docs/
│   ├── openapi.yaml        # Static OpenAPI 3.1 contract (all 30+ endpoints)
│   ├── architecture.md     # Mermaid system diagrams
│   ├── data-model.md       # Mermaid ERD (all 9 tables)
│   ├── flows.md            # Sequence diagrams (attendance, RSVP, guest, cron)
│   ├── api.md              # Human-readable API reference with examples
│   └── deployment.md       # Deployment guide with topology diagram
├── bin/
│   ├── chronos_intranet_autodiscover.sh
│   └── chronos_maintenance_vault.sh
├── docker-compose.yml      # Local dev / self-build (builds from source)
├── docker-compose.prod.yml # Production (pulls from GHCR, no build)
├── setup.sh                # One-command setup script
├── .env.example            # Environment template
└── version.txt             # Source of truth for semver (managed by Release Please)

Local Development

Backend

cd backend
uv sync                         # install deps (including dev)
uv run alembic upgrade head     # apply migrations
uv run uvicorn app.main:app --reload --port 8000

API: http://localhost:8000 · Swagger: http://localhost:8000/docs

Swagger and ReDoc follow DOCS_ENABLED, which is unset here, so they are on in development and off under APP_ENV=production.

Frontend

cd frontend
npm install
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1 \
NEXT_PUBLIC_WS_URL=ws://localhost:8000/ws \
npm run dev

App: http://localhost:3000


CI/CD Pipeline

Pull Request ──▶ ci.yml ──▶ ruff format + ruff check
                       ├──▶ pytest
                       ├──▶ eslint + tsc + next build
                       ├──▶ Trivy scan (critical fails the build)
                       └──▶ Docker build check (no push)

Push to main ──▶ ci.yml (all of the above)
                       ├──▶ every gate green ──▶ cd.yml
                       │                         └──▶ Push to GHCR + Docker Hub
                       │                               (sha tag + latest)
                       └──▶ every gate green ──▶ release.yml
                                                 └──▶ Release Please opens
                                                      or updates a release PR

Merge release PR ──▶ ci.yml ──▶ release.yml ──▶ GitHub Release created
                                            ├──▶ CHANGELOG.md updated
                                            ├──▶ version.txt bumped
                                            └──▶ cd.yml (version tag) ──▶ GHCR + Docker Hub, vX.Y.Z

Published images, one build pushed to both:

Registry Backend Web
GHCR (canonical, what docker-compose.prod.yml pulls) ghcr.io/life-experimentalist/chronos-ledger-backend ghcr.io/life-experimentalist/chronos-ledger-web
Docker Hub vkrishna04/chronos-ledger-backend vkrishna04/chronos-ledger-web

Both carry the same tags: latest, main, a short commit sha, and vX.Y.Z on a release. GHCR is the one the compose file points at because it does not rate-limit anonymous pulls of a public image; Docker Hub does. A fork that sets neither DOCKERHUB_NAMESPACE nor DOCKERHUB_TOKEN publishes to GHCR alone.

Every image carries an SBOM and build provenance, plus a Sigstore-signed SLSA attestation naming the workflow and commit it was built from:

gh attestation verify oci://ghcr.io/life-experimentalist/chronos-ledger-backend:latest \
  --owner Life-Experimentalist

docs/faq.md has the Docker Hub form and the buildkit attestations that ride along inside the image itself.


Environment Reference

Variable Required Description
JWT_SECRET_SIGNING_KEY yes 64-char hex: openssl rand -hex 32. Production refuses to start on the placeholder or on anything under 32 characters.
DB_PASSWORD yes PostgreSQL password
ORG_TIMEZONE recommended IANA name of where the organization is, such as Asia/Kolkata. Decides what "today" means. Defaults to UTC, which is what a container runs. An offset is refused.
LABEL_STAFF and five more optional What the interface calls the engine's six nouns: LABEL_STAFF, LABEL_MEMBER, LABEL_ACTIVITY, LABEL_UNIT, LABEL_LEAD, LABEL_CYCLE. Blank means the engine's own neutral word (Staff, Member, Activity, Unit, Lead, Cycle). Labels only, never the schema and never the API, so they are safe to change on a running instance. There are no domain presets on purpose. docs/vocabulary.md explains why, and what labels never change.
PASSWORD_MIN_LENGTH optional Shortest password a person may choose, default 12. Applies to the first-login change and to any account an admin creates with a typed password. Generated passwords are random and not measured against it. Refused below 8.
RATE_LIMIT_ENABLED optional A budget per caller on sign-in, the visitor kiosk and the calendar feed. On by default. Counted in Redis, and if Redis is unreachable the limits stop applying rather than the requests failing.
RATE_LIMIT_LOGIN_PER_IP optional Failed sign-ins per calling address per 15 minutes, default 10. A correct password costs nothing. 0 turns it off.
RATE_LIMIT_LOGIN_PER_EMAIL optional Failed sign-ins per account per 15 minutes, default 5. 0 turns it off.
RATE_LIMIT_GUEST_CHECKIN optional Visitor check-ins per kiosk account per hour, default 300. 0 turns it off.
RATE_LIMIT_CALENDAR_FEED optional Calendar feed fetches per feed token per hour, default 60. 0 turns it off.
CSV_UPLOAD_MAX_MB optional Largest CSV POST /ingestion/upload-csv accepts, in MB, default 25. A bigger file is refused with 413 before anything is imported. nginx refuses requests over 25 MB on its own, so going past 25 means raising client_max_body_size in nginx/nginx.conf too.
GEOFENCE_ACCURACY_FACTOR optional How coarse a member's location fix may be, as a multiple of the session's fence radius, default 2 (30 m on the default 15 m radius). A mark whose reported accuracy is coarser gets 400. 0 turns the check off. A mark that reports no accuracy is not refused for it.
GUEST_RETENTION_DAYS optional How many days a visitor check-in is kept, default 0, which keeps them forever. Each one holds the visitor's name and phone number. Set a number of days, such as 90, and a job at 03:30 in ORG_TIMEZONE deletes the older check-ins every day, whether or not anyone acted on them. Refused below 0.
DOCS_ENABLED optional Whether /docs, /redoc and /openapi.json are served. Blank follows APP_ENV: off in production, on everywhere else. Set true to publish them from a production instance anyway.
FORWARDED_ALLOW_IPS optional Which upstream addresses uvicorn believes X-Forwarded-For from, default *. The app container publishes no ports, so nginx is the only way in. The sign-in limit needs it to tell callers apart from the proxy, and the proxy overwrites that header rather than appending to it, so a caller cannot forge an address.
VAPID_PUBLIC_KEY recommended Web Push: npx web-push generate-vapid-keys
VAPID_PRIVATE_KEY recommended Web Push
VAPID_CONTACT_EMAIL recommended Admin contact for push service
NEXT_PUBLIC_API_URL dev only Baked in at build time; defaults to /api/v1 in GHCR image
NEXT_PUBLIC_WS_URL dev only Defaults to /ws in GHCR image
APP_CORS_ORIGINS prod Comma-separated allowed origins
GHCR_OWNER prod only GitHub org/username for GHCR image pull
VERSION prod only Image tag to deploy (default: latest)

See .env.example for the full template.


First Boot Credentials

The Alembic seed migration creates one super-admin. It seeds a hash of a random string it throws away, so the account exists, nothing can log into it, and this repository publishes no password for anyone to find. What the first password is comes from your .env:

Field Value
Email admin@org.internal
Password INITIAL_ADMIN_PASSWORD from your .env

setup.sh generates that password and prints it once, in its summary. It is applied on boot while the account is still waiting for a password of its own, and does nothing once you have logged in and chosen one. Until you have, the account is refused by every endpoint except the password change itself, so the first login is the only thing it can do: use Admin Portal → Profile → Change Password, or the Onboarding Wizard, which opens on its own.


Documentation

Document Description
docs/openapi.yaml OpenAPI 3.1 contract, all endpoints, schemas, enums
docs/architecture.md System topology, module deps, location resolution, WebSocket lifecycle
docs/data-model.md ERD for all 10 database tables
docs/flows.md Sequence diagrams: attendance, RSVP, guest handshake, ledger cron
docs/api.md Human-readable API reference with examples
docs/deployment.md Deployment guide, cycle rollover, TLS, scaling
docs/hardware.md Hardware requirements, load test results, when to add replicas
docs/vocabulary.md What an instance calls things, and which value sets are closed
docs/faq.md FAQ & troubleshooting, setup, auth, CSV import, geofencing, CI/CD
docs/quickstart.md Call the REST API from any language: auth, keys, paging, idempotency
docs/roadmap.md Planned releases 0.13 to 0.15 with scope and acceptance checks
docs/distribution.md Planned installer, channels, drift handling and signing
docs/sdks.md Planned TypeScript, Python, Go and Rust client libraries
CONTRIBUTING.md Development setup, commit conventions, PR checklist
CODE_OF_CONDUCT.md Community standards and enforcement
SECURITY.md Vulnerability reporting and disclosure policy
CHANGELOG.md Release history (managed by Release Please)

Telemetry

Telemetry is off by default, a stock build sends nothing anywhere, and the ghcr.io image is built from nginx/Dockerfile with those same defaults. Nothing in a default install reaches a host you do not run.

If you turn it on, Chronos Ledger reports anonymous, aggregate view counts to a CFlair-Counter instance, a privacy-first, self-hostable counter. No IP addresses, usernames, or session data are collected or transmitted.

What is tracked, if you opt in

Event Project key
Landing page visits chronos-ledger-landing
Admin dashboard logins chronos-ledger-app

Opt in

Set both at build time, either is enough to keep it off:

NEXT_PUBLIC_TELEMETRY_ENABLED=true
NEXT_PUBLIC_TELEMETRY_ENDPOINT=https://counter.example.internal   # a CFlair-Counter you run

An empty endpoint disables the pings regardless of the enabled flag.

Per-browser opt out: once enabled, Admin Dashboard → Overview → Privacy & Telemetry toggles it off for that browser. The preference is stored locally and persists across sessions.


Contributing

Contributions are welcome. Please:

  1. Fork the repo and create a feature branch.
  2. Follow Conventional Commits so Release Please can generate the changelog.
  3. Open a PR: CI runs automatically (lint, tests, type-check, build, Trivy scan).
  4. All checks must pass before merge.

Commit examples:

feat: add unit-level attendance export to CSV
fix: resolve geofence false positive on altitude boundary
perf: cache staff location resolver result in Redis
security: upgrade cryptography to 44.0.1

License

Copyright 2026 Chronos Ledger Contributors

Licensed under the Apache License, Version 2.0.

About

A multi-dimensional timetable engine, self-hosted and offline-first. Geofenced attendance, a real-time WebSocket hub, bulk CSV timetable import, a guest kiosk, display labels you configure yourself, and one-command Docker setup. FastAPI · Next.js · PostgreSQL · Redis.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages