- Python 63.4%
- TypeScript 31.6%
- CSS 4.1%
- Dockerfile 0.3%
- Shell 0.3%
- Other 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The Late section made the home screen a list of failures. Overdue work is still tracked and still reachable through Jobs and Reports, but it no longer greets the worker every morning. The empty state went with it: its two wordings differed only on whether late work existed to look at, so with the section gone there was nothing left for the distinction to say. In its place, at the foot of the page, the last seven days. Both kinds of entry land there -- a scheduled completion and a one-off logged after the fact are both work logs, so a single query over completed_on covers them without the split the agenda makes for today. Today itself is left out; Due today and Done today already account for it, and including it would put the same entry on screen twice. Scope follows the agenda's existing rule rather than inventing a second one: a worker sees their own week, a manager or admin sees the crew's. The rows are read-only. Undo stays on today's entries, where a mistake is fresh; a delete button under a thumb, next to work from six days ago, is the wrong affordance for a list meant to be read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ryeJg1hkcJ5WJPfgwV4gS |
||
| .forgejo/workflows | ||
| alembic | ||
| app | ||
| docker | ||
| scripts | ||
| tests | ||
| web | ||
| .env.example | ||
| .gitignore | ||
| alembic.ini | ||
| docker-compose.portainer.yml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| pyproject.toml | ||
| README.md | ||
Worklog
A shared account book for two people. A manager schedules work — one-off or recurring — and a worker logs when it actually got done. Alongside that, money moves in both directions: the worker fronts expenses to be reimbursed, and the manager bills the worker for things like rent. Either side can be applied against the other, so this month's reimbursement can come off next month's rent without anyone keeping a running total in their head.
Both roles see the same ledger and the same reports, and either can export a filtered period to a spreadsheet. The manager can also put documents on a shared shelf — a contract, a policy — for the worker to read.
Built for a phone, installable to the home screen, deployed as a Portainer stack.
What it does
Work
- Jobs that happen once, or daily / weekly / monthly / quarterly / yearly, with intervals ("every other Tuesday") and month-day anchors ("the last day").
- Month-end clamps rather than skips: a job anchored on the 31st falls on 28 February, it does not vanish from short months.
- A worker's Today screen shows what is late, what is due, and what is done, with one-tap completion, optional time and notes, and backdating.
- Work that was never on the schedule can be logged too, so reports stay honest.
Money
- Entries in two directions: owed to worker (reimbursements) and owed by worker (charges). Recurring charges like rent bill themselves on the day they fall due, never in advance.
- The manager records payments with the method, the date, and a note.
- Offsets apply one balance against the other. Both sides move together as a linked pair, and reversing one reverses both.
- Receipts can be photographed straight from the phone.
Documents
- The manager uploads a contract, a policy, a site map; everyone signed in can read it. Reading is open, uploading is not.
- PDFs and photos open in a tab, with a Save a copy alongside. Anything a browser would not render is sent as a download.
- What lands on disk is a server-generated name with an extension taken from the accepted type, never from the filename the browser sent.
Reports
- Filter by date range, job, person, and direction.
- Export to
.xlsx(multi-sheet: completed work, outstanding work, expenses, payments) or.csv. - Every timestamp is written in the reader's own timezone and in UTC.
Admin
- The first account created is automatically the administrator; it then creates everyone else.
- Every create, change and delete is recorded in an activity log, including who did it and from where. Password hashes and secrets are redacted.
- All settings live in the database behind the admin login — see below.
Configuration
There is deliberately almost nothing to configure by environment variable. Currency, timezone, schedule horizon, overdue grace, SMTP and session lifetime are all edited in the app under Admin → Settings, stored in the database, and take effect immediately without a redeploy.
The environment carries only what is needed to reach the database:
| Variable | Required | Purpose |
|---|---|---|
POSTGRES_PASSWORD |
yes | Used between the app and its own database. Any long random string. |
APP_PORT |
no | Host port to publish. Default 8079. Drop it when using a reverse proxy network. |
INSECURE_COOKIES |
no | true only when reaching the app over plain HTTP. Drops the Secure flag from the session cookie. Leave false behind HTTPS. |
The session signing key is generated on first start and stored in the database, so there is no secret to invent, paste, or lose.
Deploying with Portainer
CI builds the image and redeploys the stack on every push to master, so
day-to-day updating is just a push. The stack is created by hand once.
One-time setup
- In Portainer: Stacks → Add stack, name it
worklog(the deploy job finds it by that name), build method Web editor. - Paste in
docker-compose.portainer.ymlfrom this repository. - Under Environment variables, add:
POSTGRES_PASSWORD— letters and digits only. It is interpolated into a connection URL, where@ : /would break parsing.APP_PORT— e.g.8079(8080 is already in use on this host)INSECURE_COOKIES—trueonly if you reach it over plain HTTP
- Deploy the stack.
Then add these secrets to the repository in Forgejo (Settings → Actions → Secrets):
| Secret | What it is |
|---|---|
REGISTRY_USER |
Forgejo username, for pushing and pulling the image |
REGISTRY_PASSWORD |
A Forgejo token with package read/write |
PORTAINER_TOKEN |
A Portainer API key (My account → Access tokens) |
First boot creates the schema. Open the app and the setup screen asks you to create the administrator account.
The pipeline
.forgejo/workflows/build-and-deploy.yml runs on every push to master:
- test-backend — pytest against a real Postgres service container. The
ledger and recurrence code lean on
TIMESTAMPTZ, arrays andON CONFLICT, so SQLite would not exercise them. - test-frontend —
npm ci && npm run build, to surface a TypeScript error as a TypeScript error rather than a buried Docker build failure. - build — one image (React SPA compiled in stage 1, served by FastAPI in
stage 2), tagged
:latestand:<sha>, pushed togit.thenymans.com/brian/worklog. - deploy — tells the Portainer host to pull the new image, then
PUTs the stack. It finds the stack by name, and reuses the environment variables already stored on it, so passwords stay managed in the Portainer UI and are never pushed from CI.
The deploy job sends docker-compose.portainer.yml straight from the
repository, so there is one copy of the compose file rather than a duplicate
pasted into the workflow. Edit it here and the change ships on the next push.
It finds the stack by name and then reads the endpoint ID, the environment
variables and the stack type off the stack itself, so there is nothing to keep
in sync by hand. Both stack types work: a plain compose stack gets the file
above pushed to it, and a git-backed stack is redeployed from the repository
instead — for that one, set its compose path in Portainer to
docker-compose.portainer.yml, since the default docker-compose.yml builds
from source rather than pulling the image.
If Portainer refuses a deploy, the job prints its response body. A bare
exitcode 22 means a curl -f swallowed the message, which this no longer
does.
Deploying without CI
docker-compose.yml (as opposed to docker-compose.portainer.yml) builds from
source instead of pulling the image. Point a Portainer Repository stack at
it, or run docker compose up -d --build on the host.
Behind your existing reverse proxy
The stack publishes a port by default so it works out of the box. If something already terminates HTTPS on this host, switch to that instead:
- Create the shared network once, if you have not already:
docker network create proxy - In
docker-compose.yml, uncomment theproxynetwork at the bottom and underapp, then remove theports:block fromapp. - Point your proxy at
app:8000on that network.
Traefik — uncomment the labels: block on app and set your hostname.
Nginx Proxy Manager — add a proxy host with Forward Hostname app, Forward
Port 8000, and NPM's container on the proxy network. Turn on Websockets
Support; it is harmless here and avoids surprises later.
Caddy — worklog.example.com { reverse_proxy app:8000 }.
The app already trusts X-Forwarded-*, so client addresses appear correctly in
the activity log. Leave INSECURE_COOKIES unset (false) once HTTPS is in front.
Backups
Everything worth keeping is in two volumes: pgdata (the database) and
uploads (receipt photographs and shared documents).
# Database
docker compose exec -T db pg_dump -U worklog worklog | gzip > worklog-$(date +%F).sql.gz
# Receipts and documents
docker run --rm -v worklog_uploads:/data -v "$PWD":/backup alpine \
tar czf /backup/worklog-uploads-$(date +%F).tar.gz -C /data .
Restore the database into a fresh stack with:
gunzip -c worklog-2026-08-03.sql.gz | docker compose exec -T db psql -U worklog worklog
Running it locally
cp .env.example .env # set POSTGRES_PASSWORD, and INSECURE_COOKIES=true
docker compose up --build
Then open http://localhost:8080.
Development without Docker
python -m venv .venv && .venv/bin/pip install -e . && .venv/bin/pip install pytest httpx
docker run -d --name worklog-devdb -e POSTGRES_DB=worklog -e POSTGRES_USER=worklog \
-e POSTGRES_PASSWORD=devpass -p 55432:5432 postgres:17-alpine
export DATABASE_URL="postgresql+psycopg://worklog:devpass@localhost:55432/worklog"
export INSECURE_COOKIES=true
.venv/bin/alembic upgrade head
.venv/bin/uvicorn app.main:app --reload # API on :8000
cd web && npm install && npm run dev # UI on :5173, proxying /api
To fill an instance with a realistic scenario:
.venv/bin/python scripts/demo_seed.py http://127.0.0.1:8000
Tests
.venv/bin/python -m pytest
The suite needs the dev Postgres above; it creates and drops its own
worklog_test database.
How it is put together
app/
api/ route modules, one per area
models/ SQLAlchemy 2.0 tables
schemas/ Pydantic request and response shapes
services/
recurrence.py rrule expansion; the clamping rules live here
scheduling.py materialises rules into task and expense rows
ledger.py all balance arithmetic, in Decimal
export.py xlsx and csv writers
audit.py session listeners that populate the activity log
settings.py database-backed configuration
db.py engine, session, and the transactional route class
web/ React + TypeScript SPA, built into the image
A few decisions worth knowing about:
- Task occurrences are materialised, not computed. Recurrence rules are expanded into real rows across a rolling horizon, so filtering, reporting and export are ordinary SQL. Editing a schedule re-syncs future dates but never removes an occurrence that has work logged against it.
- Recurring charges are not generated ahead. A rent template only creates an expense once its date arrives; otherwise next quarter's rent would show up in today's balance as money already owed.
- Completion is derived, never stored. A task is done because a work log points at it. There is no status column to drift out of step.
- Offsets are pairs. Applying one balance to another writes a settlement on
each side sharing an
offset_group_id, so both balances move together and reversal is atomic. - The transaction commits before the response is sent. Committing in a
FastAPI
yielddependency happens after delivery, which let a client read back its own write and miss it. - A document is deleted, not deactivated. Jobs and expenses are retired rather than removed because work logs and settlements point at them; nothing points at a document, so there is no history for a soft delete to protect. The activity log keeps the record of what was removed and by whom.
- Times are stored in UTC throughout, and converted for display using each user's own timezone — which also decides what "today" means on their agenda.