No description
  • Python 63.4%
  • TypeScript 31.6%
  • CSS 4.1%
  • Dockerfile 0.3%
  • Shell 0.3%
  • Other 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Brian ee28d7ec8f
All checks were successful
Build and Deploy / test-frontend (push) Successful in 16s
Build and Deploy / test-backend (push) Successful in 1m18s
Build and Deploy / build (push) Successful in 42s
Build and Deploy / deploy (push) Successful in 19s
Trade the front page's Late list for a week of what got done
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
2026-09-04 10:50:15 -06:00
.forgejo/workflows Fix the Portainer deploy, and make it say why when it fails 2026-08-03 14:28:29 -06:00
alembic Add a shelf of documents the manager shares with the worker 2026-08-17 23:13:42 -06:00
app Trade the front page's Late list for a week of what got done 2026-09-04 10:50:15 -06:00
docker Worklog: recurring job tracking with two-way expense settlement 2026-08-03 12:07:09 -06:00
scripts Worklog: recurring job tracking with two-way expense settlement 2026-08-03 12:07:09 -06:00
tests Trade the front page's Late list for a week of what got done 2026-09-04 10:50:15 -06:00
web Trade the front page's Late list for a week of what got done 2026-09-04 10:50:15 -06:00
.env.example Worklog: recurring job tracking with two-way expense settlement 2026-08-03 12:07:09 -06:00
.gitignore Worklog: recurring job tracking with two-way expense settlement 2026-08-03 12:07:09 -06:00
alembic.ini Worklog: recurring job tracking with two-way expense settlement 2026-08-03 12:07:09 -06:00
docker-compose.portainer.yml Publish the Portainer stack on 8079 2026-08-03 14:32:15 -06:00
docker-compose.yml Worklog: recurring job tracking with two-way expense settlement 2026-08-03 12:07:09 -06:00
Dockerfile Worklog: recurring job tracking with two-way expense settlement 2026-08-03 12:07:09 -06:00
pyproject.toml Worklog: recurring job tracking with two-way expense settlement 2026-08-03 12:07:09 -06:00
README.md Add a shelf of documents the manager shares with the worker 2026-08-17 23:13:42 -06:00

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

  1. In Portainer: Stacks → Add stack, name it worklog (the deploy job finds it by that name), build method Web editor.
  2. Paste in docker-compose.portainer.yml from this repository.
  3. 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 — true only if you reach it over plain HTTP
  4. 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:

  1. test-backend — pytest against a real Postgres service container. The ledger and recurrence code lean on TIMESTAMPTZ, arrays and ON CONFLICT, so SQLite would not exercise them.
  2. test-frontend — npm ci && npm run build, to surface a TypeScript error as a TypeScript error rather than a buried Docker build failure.
  3. build — one image (React SPA compiled in stage 1, served by FastAPI in stage 2), tagged :latest and :<sha>, pushed to git.thenymans.com/brian/worklog.
  4. 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:

  1. Create the shared network once, if you have not already: docker network create proxy
  2. In docker-compose.yml, uncomment the proxy network at the bottom and under app, then remove the ports: block from app.
  3. Point your proxy at app:8000 on 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 yield dependency 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.