Self-hostable time tracking — a Harvest / Kimai alternative that stays fully yours.
Horae is a time tracker you run on your own infrastructure. Log hours against clients and projects, submit and approve timesheets, and export billable reports — without handing your data to a SaaS vendor. It speaks a read-only, Harvest-compatible API so existing Harvest integrations and tooling keep working.
It is built as a single Rust + Dioxus fullstack application (server-rendered plus a WebAssembly SPA) backed by PostgreSQL and Axum. Correctness-critical domain logic — duration parsing, rounding, money, timesheet totals — lives in a dependency-free core crate and is unit-tested in isolation.
Horae is in active Phase-1 development. Expect the schema and API to keep moving.
- Timesheets — Day, Week, and Calendar views with weekly totals and a running timer.
- Clients & projects — manage clients, projects, per-project assignments, tasks, and budgets.
- Approvals — submit, approve, and reject time entries (manager and admin roles).
- Reports — grouped time reports with CSV and XLSX export.
- Invoices — draft invoices with CSV, XLSX, and PDF export.
- Harvest-compatible API — a read-only
/harvest/v2/*surface matching the Harvest v2 shape. - Auth — OIDC single sign-on in production; a one-click dev login for local work.
The authenticated SPA is organized by route:
| Route | Description |
|---|---|
/timesheet/:view |
Day / Week / Calendar timesheet with weekly totals (/ redirects here) |
/clients, /clients/:id |
Client list and detail |
/projects, /projects/:id |
Project list and detail with assignments |
/approvals |
Submit / approve / reject time (manager and admin) |
/reports |
Grouped time reports with CSV / XLSX export |
/invoices, /invoices/:id |
Invoice list and detail with CSV / XLSX / PDF export |
/admin/users |
User and task management (admin) |
/settings |
Organization and application settings |
Everything runs inside the Nix dev shell, which provides the Rust toolchain, dx
(the Dioxus CLI), sqlx-cli, PostgreSQL, and wasm-pack. A running PostgreSQL is required
for anything that touches the database.
The fastest way to see Horae is the bundled demo VM — it boots PostgreSQL, applies migrations, seeds sample data, and enables the one-click admin login:
nix run .#demoThen open http://localhost:3000/auth/login and choose Sign in as Admin.
nix develop # enter the dev shell
process-compose up # postgres + the app, on http://localhost:8080Or nix run .#dev to do both at once.
That is the whole loop. process-compose up starts PostgreSQL as a plain process, waits for
it, applies migrations, then runs the hot-reloading dev server. Demo data is seeded the first time,
so there is nothing to run by hand.
Database state lives in .data/postgres and survives restarts; rm -rf .data starts over.
process-compose down stops everything.
Migrations run on every process-compose up, and only the pending ones are executed. To apply
a new one without restarting the stack, run horae-migrate — it migrates and rebuilds the app,
which is needed because sqlx checks its queries at compile time.
pgweb runs alongside the app at http://localhost:8081 for
browsing and querying the database. To start only part of the stack, name the processes you
want — process-compose up postgres migrate gives you a database on its own, which is what the
integration tests need.
Open http://localhost:8080/auth/login and choose Sign in as Admin. The admin bypass is
only available when DEV_LOGIN=1 is set, which the dev stack does for you.
Running PostgreSQL in a VM instead
nix run .#postgres boots a NixOS VM running PostgreSQL (forwarding host :5432 and :2222).
It exercises the same NixOS module used for self-hosting, which makes it useful for checking
packaging changes, but it is slower to start and needs KVM. With it running, drive the app by
hand:
cargo run -p horae --features server -- migrate run # apply pending migrations
cargo run -p horae --features server -- seed # insert demo data (idempotent)
cd crates/horae && DEV_LOGIN=1 dx serve # dev server on :8080, hot reloadHorae is configured through environment variables.
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
postgres://localhost/horae |
PostgreSQL connection URL |
HORAE_HOST |
127.0.0.1 |
Bind address |
HORAE_PORT |
3000 |
Listen port |
HORAE_LOG |
info |
Log level (trace, debug, info, warn, error) |
SESSION_SECRET |
dev-secret-… |
Cookie signing secret — always set in production |
HORAE_SECURE_COOKIES |
0 |
Mark session cookies Secure (HTTPS only); set to 1 in production |
DEV_LOGIN |
0 |
1 enables the one-click admin login and bypasses OIDC (dev only) |
HORAE_PLUGINS_DIR |
plugins |
Directory scanned for plugins at startup |
Production authentication uses OIDC. It is enabled only when all four OIDC variables are set
(and DEV_LOGIN is off):
| Variable | Description |
|---|---|
HORAE_OIDC_ISSUER |
OIDC provider issuer URL |
HORAE_OIDC_CLIENT_ID |
OIDC client ID |
HORAE_OIDC_CLIENT_SECRET |
OIDC client secret |
HORAE_OIDC_REDIRECT_URL |
Callback URL registered with the provider |
HORAE_OIDC_ADDITIONAL_AUDIENCES |
Extra aud values to trust (comma-separated); optional |
HORAE_OIDC_BUTTON_LABEL |
Sign-in button text; defaults to Continue with SSO |
Horae ships as a NixOS module:
{
imports = [ horae.nixosModules.horae ];
services.horae = {
enable = true;
host = "127.0.0.1";
port = 3000;
database.createLocally = true; # provisions a local PostgreSQL and database
# secretKeyFile = "/run/secrets/horae-env";
# openFirewall = true;
};
}The module runs the server as a systemd service; you still apply migrations and (optionally)
seed with the horae CLI. Do not set DEV_LOGIN in a production deployment — configure OIDC
instead.
The server-feature binary doubles as an admin CLI (serve is the default when no subcommand
is given):
horae serve --host 0.0.0.0 --port 3000
horae migrate run # apply pending migrations
horae migrate reset --confirm # drop and re-create (dev only)
horae seed # insert demo data (idempotent)
horae user list
horae user create --email admin@example.com --name "Admin" --role adminEndpoints under /harvest/v2 mirror the Harvest API v2
response shape:
GET /harvest/v2/users/me
GET /harvest/v2/time_entries[?from=&to=&user_id=&project_id=&is_running=&page=&per_page=]
GET /harvest/v2/time_entries/{id}
GET /harvest/v2/projects[/{id}]
GET /harvest/v2/clients[/{id}]
GET /harvest/v2/tasks[/{id}]
GET /harvest/v2/users
Authentication is session-cookie based. Bearer-token auth is planned but not yet implemented.
GET /api/reports/export/{csv,xlsx}?from=YYYY-MM-DD&to=YYYY-MM-DD
GET /api/projects/export/{csv,xlsx}
GET /api/invoices/{id}/export/{csv,xlsx,pdf}
- One feature-gated app crate (
crates/horae/), two build targets.main.rsdefines threecfg-selected entry points:server(Axum + Tokio + the CLI),web(compiled to WASM), and a stub. Default features are empty, so builds and tests must select--features server(or usedx). Server-only modules (auth,cli,config,db,harvest,reports,seed,state, …) are gated; the shared UI modules compile for both targets. - A pure domain crate (
horae-core). Duration parsing, rounding, money, totals, and the entry state machine live here with no I/O dependencies, and are unit-tested in isolation. - Two API surfaces. The server layers custom Axum routes — health, exports, auth, and the
read-only Harvest API — on top of the Dioxus fullstack router, all under a Postgres-backed
session layer. The SPA performs mutations through session-authenticated Dioxus
#[server]functions. - PostgreSQL only. Migrations live in
crates/horae/migrations/and apply viasqlx.
Domain invariants worth knowing: durations are stored as integer minutes, money as integer minor units (cents) plus an ISO currency code (never floats), and primary keys are UUID v7.
See SPEC.md for the Phase-1 build spec and DESIGN.md for the design system and component conventions.
cargo test -p horae-core # pure domain unit tests (no DB, no features)
DATABASE_URL=… cargo test -p horae --features server # integration tests (need Postgres w/ CREATEDB)
cargo clippy -p horae --features server # lint
nix fmt # treefmt: rustfmt, taplo, nixpkgs-fmt, mdformatIntegration tests use #[sqlx::test] (each spins up a throwaway database, so the DB role needs
CREATEDB). nix build builds the package and nix flake check runs the formatting check plus a
full NixOS end-to-end test.
All SQL uses sqlx's compile-time-checked macros; after changing a query or migration, regenerate
the offline cache with cargo sqlx prepare --workspace -- --features server --all-targets and
commit .sqlx/.
The repository also ships agent skills under .agents/skills/ (Rust best practices, testing,
async patterns, and more) that capture the conventions this project follows.
Contributions are welcome. See CONTRIBUTING.md for development setup and guidelines, and SPEC.md for the current build plan.
Horae is licensed under the MIT License. See LICENSE for the full text.
