- Go 72.3%
- TypeScript 24%
- Shell 2.2%
- PLpgSQL 0.8%
- JavaScript 0.4%
- Other 0.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
Some checks failed
Release / vuln (push) Successful in 2m24s
CI / vuln (push) Successful in 3m52s
Release / drift (push) Successful in 4m55s
Release / fast (push) Successful in 5m23s
Release / build-test (push) Successful in 0s
Release / build-image (push) Successful in 6m0s
CI / drift (push) Failing after 16m39s
CI / fast (push) Successful in 26m22s
|
||
| .claude | ||
| .forgejo/workflows | ||
| api | ||
| deploy/quadlet | ||
| docs | ||
| scripts | ||
| web | ||
| .containerignore | ||
| .env.example | ||
| .gitignore | ||
| .tagme-authors.json | ||
| CHANGELOG.md | ||
| cliff.toml | ||
| compose.dev.yaml | ||
| README.md | ||
| Taskfile.yml | ||
Chronos — Suivi d'activité & CRA
Time-and-activity management for a small French consultancy team. Each person declares their worked hours and absences on a monthly grid, submits the month for manager approval, and the approved month becomes an immutable, versioned record that feeds a client-facing CRA, invoicing data, and payroll data.
Governed by Syntec (IDCC 1486), modalité 2. Approved months are working-time and financial records: immutability and audit attribution are requirements, not features.
- Go API · React SPA · PostgreSQL 18 · Ant Design
- French UI and French CRA output,
dd/MM/yyyy, comma decimals, EUR, Monday-first weeks — with a full i18n layer from day one - Code, schema and API identifiers in English
Prerequisites
| Tool | Version | Notes |
|---|---|---|
| Go | 1.27.1 | The four CLI tools (sqlc, goose, goi18n, golangci-lint) are pinned as tool directives in api/go.mod — run them with go tool <name>, never a globally installed copy. |
| Task | v3 | go install github.com/go-task/task/v3/cmd/task@latest |
| Podman | 6.x rootless | Or Docker. Used for the dev database and for integration tests. |
| Node | 22+ | For web/. Run npm ci inside web/ once, after cloning. |
Rootless podman, for the integration tests
The Go integration tests start a real PostgreSQL 18.4 container via testcontainers. Without the user socket they fail in a way that reads like a network problem rather than a configuration one:
systemctl --user enable --now podman.socket
export DOCKER_HOST="unix://$XDG_RUNTIME_DIR/podman/podman.sock"
export TESTCONTAINERS_RYUK_DISABLED=true # Ryuk is unreliable rootless
api/internal/db/testdb sets both variables itself when they are unset and the
podman socket is present, so go test ./... usually works with no setup. Export
them explicitly if you use a non-default socket path.
Because Ryuk is disabled, a test binary killed with SIGKILL leaves its
container running. Clear it with podman ps then podman rm -f <id>.
Quick Start
From a clean clone:
npm --prefix web ci # install the SPA's dependencies (once)
cp .env.example .env # see below — five variables have no Taskfile default
task db:up # start PostgreSQL 18.4 and wait for it to report healthy
task dev # run the API on :8080 and the SPA on :5173
A genuinely clean checkout has no .env, and task dev refuses to start
without five variables that carry no Taskfile default:
CHRONOS_PUBLIC_URL, CHRONOS_OIDC_ISSUER, CHRONOS_OIDC_CLIENT_ID,
CHRONOS_OIDC_CLIENT_SECRET, CHRONOS_DRILL_TOKEN (the full 16-variable
surface is in the Configuration table below). Without a live Authentik
instance to point at, .env.example's values enable the dev-only mock-auth
bypass instead: CHRONOS_PUBLIC_URL=https://localhost:8080,
CHRONOS_OIDC_ISSUER=https://unreachable.invalid/,
CHRONOS_OIDC_CLIENT_ID=placeholder,
CHRONOS_OIDC_CLIENT_SECRET=placeholder, CHRONOS_DEV_AUTH=true, and
CHRONOS_DRILL_TOKEN set to any non-empty value (for example the output of
openssl rand -hex 32).
Then open http://localhost:5173 — a French page, dd/MM/yyyy dates, comma
decimals, Monday-first weeks.
Check the API directly:
curl -s http://localhost:8080/api/v1/health
# {"status":"ok","schema_version":42}
schema_version reports the highest applied migration. The response
deliberately carries no field that would let an unauthenticated caller learn
how many people work at the company.
Stop the database with task db:down (the volume is kept).
Everyday commands
task # list every target
task check # the complete gate suite — check:fast then check:full
task check:fast # every gate that returns in seconds, no database container (< 30s)
task gates # only the convention scripts under scripts/, no Go/Node toolchain
task spec # regenerate api/openapi.yaml and web/src/api.d.ts from the Go types
task db:up # start the dev database
task db:down # stop it
task api:dev # API only, on :8080
task web:dev # SPA only, on :5173
Faster inner loop, without the container-backed tests:
cd api && go test ./... -short
The short-mode guard lives in api/internal/db/testdb, not in each test, so a
container can never start under -short. Run task check:fast after every
commit; save task check (or task check:full) for a wave boundary — it
starts real PostgreSQL containers and takes minutes, not seconds.
Continuous integration
.github/workflows/ci.yml runs task check on every push and pull request —
the same named targets documented above, not a restated command list, so the
local suite and CI cannot silently drift apart. It installs the pinned Go
toolchain and Node version, builds the pinned CLI tools from api/go.mod's
tool directives, and runs the container-backed tests against the runner's
own Docker daemon directly (the rootless-podman variables below are for local
dev only and are never exported in CI).
Gates
Every row is a task target (or an npm script task wraps) that fails the
build on a specific mistake. Read docs/conventions.md for the reason
behind each rule; this table is only the map from a red gate to what it is
protecting.
| Gate | Command | Protects against |
|---|---|---|
| Go build | task check:build |
A compile error — the cheapest possible signal, runs first |
| Go tests (short) | cd api && go test ./... -short |
Broken behaviour, container-free |
| Go tests (full) | task check:test |
The same, plus every container-backed integration assertion (enum membership, the Paris-DST round trip, rate overflow, the health smoke test) |
| Go lint | task check:lint |
A handler importing the generated dbgen package directly (depguard, FOUND-01's one bypass route); a switch over an enum missing a case (exhaustive); unchecked errors, unclosed rows, unclosed response bodies |
| Generated DB code drift | task check:sqlc |
internal/db/dbgen out of date with the hand-written SQL it was generated from |
| Generated API types drift | task check:spec |
api/openapi.yaml or web/src/api.d.ts out of date with the Go request/response types |
| Schema conventions | scripts/check-schema-conventions.sh (part of task gates) |
A bare timestamp/time column, real/double precision/money, or a deleted_at soft-delete column in a migration |
| Money conventions | scripts/check-money-conventions.sh (part of task gates) |
A binary float in the money/worktime path; an aggregate over a centime column with no ::bigint cast; a second minutes-to-days conversion point |
| Scope-all allow-list | scripts/check-scope-all.sh (part of task gates) |
An unscoped read (authz.ScopeAll()) that is not a justified, reviewed line in scripts/scope-all-allowlist.txt |
| Server i18n catalogue | scripts/check-go-catalogue.sh (part of task gates) |
A Go-side message added to internal/platform/i18n without re-extracting the TOML catalogue, or a blank catalogue value |
| Compliance documents | scripts/check-compliance-docs.sh (part of task gates) |
docs/compliance/dpia-screening.md or registre-des-traitements.md losing a legally required heading |
| Web lint | cd web && npm run lint |
A bare, untranslated French string as JSX text or in a human-facing attribute |
| Web typecheck | cd web && npm run typecheck |
An unknown t() translation key — a TypeScript error, not a runtime blank |
| Web i18n drift | cd web && npm run i18n:check |
A browser-side catalogue key added, orphaned, or edited without re-extracting (i18next-cli) |
| Web i18n empty values | cd web && npm run i18n:empty |
A blank catalogue value neither extractor-based gate above can see |
| Web tests | cd web && npm run test |
The locale contract (French, Monday-first), the formatter's separator codepoints, and every component test |
Configuration
Environment only — this is what Quadlet's Environment= hands the container in
production, and it keeps configuration out of the image.
| Variable | Default | Purpose |
|---|---|---|
CHRONOS_DATABASE_URL |
(required) | pgx/libpq connection string for the application pool — names chronos_app, not the bootstrap superuser |
CHRONOS_MIGRATION_DATABASE_URL |
(required) | Bootstrap-superuser DSN for the migration-only pool, opened only to run migrations at start then closed |
CHRONOS_PUBLIC_URL |
(required) | Public https origin — the sole source of the OIDC redirect URI |
CHRONOS_OIDC_ISSUER |
(required) | Full per-provider issuer, trailing slash included, byte-identical to the discovery document |
CHRONOS_OIDC_CLIENT_ID |
(required) | Identifies Chronos to the identity provider |
CHRONOS_OIDC_CLIENT_SECRET |
(required) | Confidential client secret, never reaches the browser |
CHRONOS_DRILL_TOKEN |
(required) | Shared secret the OPS-04 restore drill presents when posting its result, never defaulted |
CHRONOS_HTTP_ADDR |
:8080 |
API listen address |
CHRONOS_ENV |
dev |
dev selects the human-readable log handler; anything else logs JSON for journald |
CHRONOS_STANDARD_DAY_MINUTES |
480 |
The one numeric standard-day length in the tree; every minutes-to-days conversion resolves through it |
CHRONOS_COOKIE_SECURE |
true |
Session cookie's Secure flag — false only for plain-HTTP local dev |
CHRONOS_SESSION_LIFETIME |
10h |
Absolute session lifetime, independent of activity |
CHRONOS_TRUSTED_PROXY_CIDRS |
127.0.0.1/32,::1/128 |
Address range trusted to set X-Forwarded-For — used only for audit logging |
CHRONOS_DEV_AUTH |
false |
TEMPORARY dev-only mock-auth bypass, see Quick Start above — never enable outside local development |
CHRONOS_LOCALE |
fr-FR |
Read once at approval time, stored verbatim on the approved period_version row |
CHRONOS_TIMEZONE |
Europe/Paris |
Same read-once-at-approval note as Locale |
The database password is redacted whenever the config is rendered. Log
cfg.String(), never the struct — slog resolves an arbitrary value by
reflection and would print the password.
Layout
api/ Go API
cmd/chronos/ process wiring: env -> slog -> pgxpool -> goose -> chi/huma
cmd/genspec/ writes api/openapi.yaml from the Go types (`task spec`)
internal/authz/ ScopeFilter — the mandatory authorization seam
internal/auth/ OIDC client + server-side session lifecycle
internal/money/ money.Cents, money.FromRate — the one rounding site
internal/worktime/ worktime.Minutes/Hundredths, the one ÷8 conversion
internal/calendar/ jours fériés, computed algorithmically — no DB
internal/cra/ renders the CRA — Compte Rendu d'Activité — PDF
internal/platform/config/ env-only configuration surface
internal/platform/i18n/ server-side catalogue — go-i18n, embed.FS TOML
internal/platform/frcsv/ shared French-Excel-compatible CSV writer — BOM, semicolon delimiter, comma decimals
internal/platform/xlsx/ shared excelize style/writer helpers
internal/platform/pgxdecimal/ hand-rolled numeric-to-decimal.Decimal codec
internal/db/migrations/ goose SQL, embedded in the binary
internal/db/queries/ hand-written SQL, sqlc input
internal/db/dbgen/ sqlc OUTPUT — committed, never hand-edited
internal/db/testdb/ testcontainers helper (one container per package)
internal/store/ the adapter layer — the ONLY importer of dbgen
internal/httpapi/ huma operations; handlers construct the ScopeFilter
web/ React SPA
src/lib/format.ts the only formatting surface — the SPA formats, never computes
src/i18n/ browser-side catalogue — i18next, typed t()
eslint.config.mjs the bare-string i18n lint gate
i18next.config.mjs i18next-cli extraction config (NOT i18next-parser — deprecated, see docs/conventions.md)
scripts/ gate scripts — each is its own bare, unpiped command
docs/
conventions.md every convention this phase established, with its reason
deployment-contract.md orchestrator-agnostic deployment contract; docs/ops-runbook.md is its Quadlet-specific implementation
user-manual.fr.md end-user manual for staff & managers (French); English translation is user-manual.en.md
user-manual.en.md end-user manual for staff & managers (English translation)
admin-manual.fr.md administrator manual (French); English translation is admin-manual.en.md
admin-manual.en.md administrator manual (English translation)
ops-runbook.md Quadlet-specific deployment/operations runbook; audience is whoever operates the VPS
i18n-gate-demonstration.md red/green proof of every browser i18n gate (Phase 01-08)
compliance/ DPIA screening and registre des traitements (French, OPS-06)
.github/workflows/ci.yml runs `task check` on every push and pull request
compose.dev.yaml LOCAL DEV ONLY — never the VPS
Documentation
Role-specific manuals live under docs/. French is primary; English is a
translation of the same content.
| Audience | Manual |
|---|---|
| Staff & managers | docs/user-manual.fr.md (EN) |
| Admins | docs/admin-manual.fr.md (EN) |
| Operators | docs/ops-runbook.md |
Two rules worth knowing before you write code
Every scoped read carries an authz.ScopeFilter as its second argument.
Its fields are unexported, so it can only come from ScopeSelf or ScopeAll,
and the archived disposition must be stated explicitly with WithArchived.
Omitting the argument is a compile error; a zero-value filter is rejected at
runtime with ErrUnscopedQuery before any SQL is issued. ScopeAll() is the
escape hatch — grep -rn 'authz.ScopeAll()' api/ is meant to stay a short,
reviewable list.
Only internal/store may import internal/db/dbgen. A handler that imports
it directly routes around the seam entirely, and nothing in the type system
stops it.
Migrations
Migrations are embedded in the binary and applied at every start; a start with nothing pending is a no-op. There is no separate migration step to run.
api/internal/db/migrations/00001_foundations.sql is a one-way door in two
respects. PostgreSQL has no DROP VALUE — a wrong enum member costs a new
type plus a full table rewrite. And phase 8 freezes time_entry.id as the
snapshot diff key against records retained for three years. Add a new migration
file; never edit an applied one.