93 Commits

Author SHA1 Message Date
40255805c3 Modularize confirmation modal for destructive POST actions and add notify helper for concise message handling across the UI. v0.1.0 2026-07-16 18:34:03 +02:00
127d0e338e Modularize and streamline billing templates; replace _billing_form.html with reusable modals and shared partials, and update styles and interactions for consistency and clarity. billing, feature management, and forms
Refactored club detail templates to modularize common UI components. Standardized layout, interactions, and styles across admin, billing, and feature cards for consistency and reusability.
2026-07-16 16:44:48 +02:00
83caa233d7 Replace doughnut chart with pie chart in fees visualization and clean up unused CSS styles. 2026-07-16 08:38:17 +02:00
fc7a349f8f Add "Open" button to club detail for direct access to club subdomain 2026-07-16 00:12:29 +02:00
f403128f57 Improve club detail layout: enhance logo display, adjust badge styles, refine card layouts, and optimize spacing 2026-07-16 00:06:54 +02:00
91270b0cf8 Add "Plan end" column to health table and simplify badge styles
The health table now includes a "Plan end" column showing the coverage end date ("-" if none). Simplified several badge styles for consistency, replacing "n/a" with "-" and updating waived badges to use a ghost-outline class.
2026-07-15 18:28:02 +02:00
19108407c6 Show the cover-end date for waived periods too, not just paid
The "until <date>" only appeared for a PAID due, because the annotation filtered
status=PAID. A WAIVED period is settled just the same — the club is covered for
that time, and its end is still when grace would start — so it belongs there too.

The annotation is now `covered_until` (furthest-out period end where status is PAID
or WAIVED) plus `covered_status`, read from the same ordered row so the table can
badge "paid" vs "waived" and still show the date for both. Rides the same single
query — assertNumQueries(1) still holds.

Full matrix now: no plan -> n/a; paid -> "paid, until X"; waived -> "waived, until
X"; unpaid/partial -> the amount owed (no date); a paid-then-owing club shows the
amount, not the stale cover date. A subscribed club with no covering period at all
(only cancelled dues) shows a dash rather than a bare "paid".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 18:16:56 +02:00
10b113f244 Read the health-check port from the server's WEB_PORT
The probe now reads WEB_PORT from the server's .env — the same file compose reads —
and builds the health URL from it, falling back to 8001 (compose's own default) when
it is unset. No more passing HEALTH_URL by hand when the published port changes.

Parsed the way compose parses it: last assignment wins, surrounding quotes and
inline whitespace stripped, commented lines ignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:25:39 +02:00
5b51f2c945 Point HOME at /app so gunicorn stops erroring on boot
gunicorn 26's control server creates a socket in $HOME. The app user has no home
directory, so every boot logged "Permission denied: /home/rosterchief" — harmless
but noisy. /app is the workdir and already owned by the app user.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:18:10 +02:00
4d74f3fbde Always allow the loopback in ALLOWED_HOSTS, for health checks
/healthz is hit over 127.0.0.1 (the deploy probe) and localhost (the container's
own healthcheck) before any proxy has supplied a real Host header. With only the
public domain in ALLOWED_HOSTS, Django 400s both, the container is marked unhealthy
forever, and the deploy never goes green — which is how the first real deploy
failed.

The loopback is now appended unconditionally. It widens nothing: gunicorn binds to
the loopback only and the proxy owns the public domains, so nothing external can
present these hosts. Tested with a non-loopback ALLOWED_HOSTS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:15:08 +02:00
a1266378fc Fix deploy script: compose run was eating the rest of the heredoc
The whole remote script is fed to `ssh bash -s` as stdin (a heredoc). `docker
compose run` without -T attaches that stdin to the container, so it consumed every
line after the migrate — web was never restarted and no health check ran, yet the
script exited 0 and printed "Done". A deploy that half-ran and reported success.

`-T` plus `</dev/null` on the migrate command stops it reading the heredoc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:10:09 +02:00
a2bcb2f0c8 Add a one-command SSH deploy for the dev server
deploy/deploy-dev.sh deploys the test instance to home.siebens.org behind its
existing Caddy: from your machine, over one SSH session, it fetches the pushed
branch, builds, migrates explicitly, restarts web, and waits for /healthz.

- A hard reset to origin/<branch>, not a pull: a deploy target only receives
  deploys, so it should match the branch exactly rather than risk a merge conflict
  from drift no one meant to leave on the server.
- Refuses to deploy a branch with unpushed local commits — the server pulls from
  git, so that would ship stale code without saying so.
- Migrations run explicitly (dc run --rm web migrate), never from the entrypoint,
  and only `web` is recreated so db/redis keep running.
- Fails loudly if .env.production or .env is missing rather than booting a
  half-configured stack, and dumps recent web logs if the health check never passes.

Host/user/dir/branch all override via env vars. Documented in DEPLOYMENT.md with
the first-time server setup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:02:11 +02:00
9bc5377cc5 Drop the sidebar's "Platform" title, add top margin
Cosmetic: the section label was redundant with the panel it lives in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 12:58:24 +02:00
7c874aa87b Stack the paid-until date under the paid badge
The "until <date>" cover-end now sits on its own row beneath the paid badge,
right-aligned, rather than to its left on one line. Only the paid-with-plan branch
changed; overdue and n/a are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 09:48:20 +02:00
5283262e6b Show a paid club's cover end date in the health table
For a club on a plan whose dues are settled, the Dues cell now reads "until
<date> · paid" — the end of the current paid period, which is the day the grace
period would start if nothing renews. It is exactly the "when does this lapse?"
question the paid badge alone could not answer.

Driven by a new paid_until annotation: the furthest-out PAID period end, null when
the club owes or was never billed (so a fully-paid free tier shows just "paid",
and an owing club shows the amount, unchanged). It rides the SAME single query —
the assertNumQueries(1) test still holds — and the date is whitespace-nowrap so it
does not wrap in the narrow cell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:41:20 +02:00
65a2f741f6 Format: collapse a chained queryset to one line
ruff format, no behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:35:19 +02:00
975426a17f Match tests to the reworked dashboard, surface the renewals KPI
The club table was rebuilt (logo, status badges, Plan/Dues columns, an Edit
action) and the dashboard dropped the second chart, so three render tests were
asserting columns and a canvas that no longer exist. Updated to the current
layout — the service-level tests were already correct, since the annotations they
check still exist even where the template stopped rendering them.

Worked the renewals KPI into the billing card: "N awaiting renewal", shown only
when non-zero. It should sit at 0 in normal running — the cron renews clubs 30
days out and they fall past the horizon — so a number here means the job has
stopped and a club is about to use the platform free, which nothing else on the
page reveals because nothing has been billed yet.

Fixed two things in the WIP table while here: a debug line that printed the raw
grace/period/owed values into the Dues cell, and a missing {% empty %} clause
(so an empty list showed a headed table with no "no clubs" row, and empty_message
was dead). Removed the stale commented-out copy of the old table.

Answers "auto-renewed but unpaid?": it is not a special case. Renewal opens an
ordinary unpaid Due, which flows unpaid -> grace -> overdue -> archive like any
period — so the safety net that the never-billed club slipped past now fires,
because there is a due to be overdue. Tested both ways.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 08:34:49 +02:00
9a616c20e4 Auto-renew subscriptions before they lapse
Answers "does a plan renew itself?": until now, no — and that was a silent revenue
leak, not merely a missing convenience. A club whose period ended with its last due
PAID owes nothing, so dues_overdue() is empty, so archive_overdue_clubs never fires.
The club kept using the platform for free and no dashboard number went red, because
nothing was ever billed. The safety net only caught clubs you remembered to invoice.

`renew_subscriptions` (cron) issues the next period 30 days before the current one
ends, so the invoice lands before the period lapses and grace only matters for
genuine non-payers. It is idempotent by construction: a just-renewed club has a
latest period a year out, past the horizon, so a second run is a no-op.

It ACTS by default and previews with --dry-run — the opposite asymmetry to
archiving, and deliberately so. Archiving switches off a customer, so not-acting is
safe there; here, not-acting is the expensive failure, because an unbilled club is
also an unchased one. An unpriced tier fails that one club loudly (non-zero exit, so
cron mails you) without stopping the rest.

Opt-out per club via Subscription.auto_renew, mirroring auto_archive: off means you
invoice that club by hand. The dashboard gains a "renewals pending" count that
should sit at ~0 — a number here means cron has died and a club is about to go free,
which no other metric would reveal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 07:26:41 +02:00
2d43b0b903 Make the control panel a real app shell with a fixed sidebar
The navbar and the menu stay put; only the content scrolls.

That is a property of the layout, not a `sticky` class: the body is a flex column
pinned to the viewport with overflow hidden, and <main> is the single scrolling
region. The sidebar is a SIBLING of <main>, not inside it, so it sits outside that
region and cannot drift by construction. A sticky sidebar in a scrollable body
still slides on a long page, which is the failure this avoids.

The tabs become a daisyUI menu with an active indicator (`menu-active`) and the
hover/focus states `.menu` already provides. Sidebar below `lg` would be a menu
nobody can reach, so the same items render as a horizontal menu there -- from ONE
partial, because two copies of a link list is how a new section ends up reachable on
a desktop and invisible on a phone.

Auth pages leave the menu block empty and <main> simply takes the full width.

Verified in a browser: content scrolled 800px, sidebar and navbar moved 0px, and
the body is not scrollable at all. Below `lg` the sidebar hides and all five links
are still there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:06:31 +02:00
c42963c447 Do not let DEBUG=True take down a container that has no dev deps
The image installs with --no-dev, so django_browser_reload is absent. Settings and
urls both assumed DEBUG implied it was installed, so DJANGO_DEBUG=True in a
deployed container did not merely turn on debugging: the app refused to start, with
a ModuleNotFoundError that says nothing about the actual mistake.

Both now guard on the module being importable. Reproduced the failure locally by
hiding the package with DEBUG on, and confirmed the urlconf loads afterwards.

DEPLOYMENT.md says the obvious thing out loud: a test server is still a deployment
-- real TLS, real domain, real passkeys -- so DEBUG stays off there. The crash is
fixed; the reason to keep it off was never the crash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:14:53 +02:00
5d42691a10 Fix the image build: fetch the git dependency in its own stage
The build died at `uv sync`: django-lucide is our fork, declared as a git source
and pinned by the lock to a commit, so uv shells out to `git` to fetch it — and
python:3.14-slim has no git.

Installing git in the runtime image would have fixed it and left a build tool, plus
its dependency tree, in production for the sake of one package that is already
vendored into the venv by then. So the virtualenv is now built in a stage that has
git, and the finished .venv is copied into a runtime stage that does not. Same base
image, so the compiled wheels inside it stay ABI compatible.

Also drops the second `uv sync`, which installed the project itself: there is no
[build-system] and rosterchief is not a package — gunicorn imports it from the
working directory, exactly as it does locally.

Unverified end to end: still no container runtime on this machine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:07:54 +02:00
534d01d6fe Size the server, and cost an AWS three-node layout
For 1-5 clubs / 1000 members / 10 events per club per week: 2 vCPU, 4 GB, 40 GB.

The data does not size this box. Computed from the real schema, attendance
dominates (every event invites a squad, so one event is ~20 rows) and the whole
thing comes to ~40 MB/year -- 0.2 GB after five years. Invoices are rendered on
demand and never stored.

What sizes it is the processes, measured rather than guessed: gunicorn master plus
three workers is ~270 MB (~54 MB each), and the whole stack idles around 1.0-1.2 GB.
2 GB would run it; 4 GB is the recommendation because `docker compose build` is the
memory spike, not serving -- npm, uv and collectstatic together will OOM a 2 GB box
that is also running Postgres. Rendering an invoice adds ~50-100 MB to one worker
the first time, since WeasyPrint is imported lazily.

Also adds the AWS three-node layout for fun, with a cost table. Two things worth
knowing there: ACM issues the wildcard certificate free with Route 53 validation, so
the entire DNS-01 dance disappears; and a NAT Gateway would cost more than the
compute (~$32/month per AZ) if the tasks sit in private subnets.

The honest line at the end: ~$110-130/month on AWS against ~EUR 5 on a VPS, for a
database that is 200 MB after five years. The money buys resilience, not capacity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:25:40 +02:00
98e5f22873 Document running RosterChief beside another domain on one Caddy
The "behind an existing Caddy" section assumed the DNS plugin was being set up
from scratch. The realistic case is a box whose Caddy already does Cloudflare
DNS-01 for another domain, so it now covers that: set acme_dns once globally and
every site inherits it, or scope a token per zone with a snippet.

Leads with the failure that will actually happen -- a Cloudflare token is scoped to
named zones, so the existing one grants DNS:Edit on the domain it was made for and
nothing else, and the new site fails its challenge on a permissions error whose
text does not say so.

Also spells out that *.test.rosterchief.app does NOT match test.rosterchief.app: a
wildcard covers exactly one label, so leaving the bare host off the site line gives
the club subdomains a certificate and the control panel none.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:22:37 +02:00
d30b163122 Add maintenance mode: lock the platform down from the control panel
Closes every club subdomain with a 503 in that club's own colours, stands the
scheduled jobs down, and keeps open exactly what is needed to end it again.

The exemptions ARE the feature:

- /accounts/ stays open on the base domain. Close it too and you cannot sign in to
  turn maintenance off -- a lock-down with no key, fixable only from a shell.
- /healthz answers on every host. Close it and the load balancer decides the node
  is dead, stops routing to it, and takes the control panel down with everything
  else.
- migrate and collectstatic are NOT blocked. Maintenance is usually declared in
  order to run them; a blanket guard on BaseCommand would mean turning the mode off
  to do the work you turned it on for. Only the domain jobs (archive_overdue_clubs,
  extend_event_series, import_members_csv) refuse, and they exit non-zero so cron
  mails you -- a scheduled job that silently skips itself is how a month of billing
  goes missing.

The state is cached with a 10-second TTL, not for ever. Write-through makes the
flip instant for the shared Redis of a real deployment, and the TTL is the belt to
that braces: on a per-process cache -- a dev box with no Redis, or a misconfigured
deploy -- a lock-down that reached only one gunicorn worker would be worse than
useless. Live-verified: a club subdomain, its login page and the base domain all
503 while the control panel and the sign-in screens stay up.

Also adds the two deployment pieces asked for: compose.behind-proxy.yaml for a
dev/test box that already runs Caddy on :80 (app on the loopback, host Caddy proxies
to it -- and the host's Caddy still needs the DNS plugin, because the wildcard is
still a wildcard), and deploy/backup.sh + restore-check.sh with a cron schedule. The
backup writes to a .part file and only lands it once gzip -t says it is readable: a
truncated dump that looks like a backup is the failure you find on the day you need
it. The weekly restore rehearsal is the only line in that cron that proves the rest
work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:11:15 +02:00
c0a44093d9 Add a health check and the deployment runbook
/healthz checks the database and does a cache ROUND TRIP, not a ping. Both matter:
a node that cannot reach Postgres serves nothing, and a cache that accepts writes
and returns nothing would have waffle read every feature flag as unset -- so
"healthy" has to mean more than "the process is listening", or the load balancer
will keep feeding traffic to a node that only looks alive.

No auth and no tenant on it: the proxy, and later a load balancer, must reach it on
any host.

DEPLOYMENT.md is the runbook, and leads with the five things that make this app not
a generic Django deploy: the wildcard cert forces DNS-01 (Let's Encrypt will not
issue a wildcard over HTTP-01); Redis is required on one server, not two, because
of the per-process flag cache; SECURE_PROXY_SSL_HEADER plus Caddy's
X-Forwarded-Proto or WebAuthn and the SSL redirect both break; uploads must reach
object storage BEFORE the second app server, not during; and invoices need native
pango.

Also documents why the archive job ships with --commit off, why migrations are run
explicitly rather than from the entrypoint, and how to test the restore before the
day you need it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 09:45:21 +02:00
35d1ec45a7 Containerise: Dockerfile, Compose stack and wildcard TLS
One server now, the same image and env vars for many later: point
DJANGO_DATABASE_URL / DJANGO_REDIS_URL at central services, set a bucket, drop the
db and redis services, run several web containers behind a load balancer. No code
changes.

The wildcard certificate is what shapes this. Subdomain tenancy needs
*.rosterchief.app, and Let's Encrypt will not issue a wildcard over HTTP-01 -- only
DNS-01 -- so Caddy is built with a DNS provider plugin and needs an API token. That
single constraint is why the proxy is Caddy rather than the usual nginx+certbot.

The image apt-installs libpango and friends, which is what WeasyPrint binds to. The
PDF invoices that cannot render on a Mac without Homebrew work in the container by
construction.

Migrations are NOT run by the entrypoint: with more than one web container they
would race, and a starting gunicorn worker is a bad place to discover a failed
migration. Deploy runs them once, explicitly.

Two things the local build check caught, either of which would have failed the
image build at collectstatic (manifest storage treats a missing referenced file as
fatal):

- chart.js ended with a sourceMappingURL pointing at a .map we never vendored.
  Stripped, with an npm script so re-vendoring cannot bring it back.
- The Tailwind INPUT file lived at static/src/app.css, inside the served static
  tree, so collectstatic collected it and then choked on its @import "tailwindcss".
  It belongs outside: it is a build input, not an asset. Now assets/app.css.

Verified locally under gunicorn + WhiteNoise + manifest storage: pages serve and
the CSS comes back hashed. The image itself is unverified -- there is no container
runtime on this machine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 09:41:51 +02:00
e5a93194bf Make the app deployable: Postgres, shared cache, S3, HTTPS
Three things would have broken a deploy, all invisible until it happened:

- psycopg was missing. dj-database-url parses postgres:// happily, so the app
  would have started and died on its first query.
- No CACHES, so Django used LocMemCache -- private to one process. waffle caches
  each flag's targeting there, so under several gunicorn workers a toggle flipped
  in the control panel flushes ONE worker and the others keep serving the stale
  flag. That is a feature that "sometimes doesn't turn on", and it makes Redis a
  requirement of the first multi-worker deploy, not of the second server.
- Uploads (club logos) sat on local disk. Fine on one box; on two, a logo
  uploaded to node A 404s on node B. Storage now switches to S3 the moment a
  bucket is configured, so adding a server stays a config change.

HTTPS behind a proxy: SECURE_PROXY_SSL_HEADER is not optional once Caddy
terminates TLS -- without it Django thinks every request is plain HTTP,
request.is_secure() is false, WebAuthn disagrees with the browser about the
origin, and SECURE_SSL_REDIRECT turns into a loop. HSTS covers subdomains,
because every club is one.

The SSL/cookie flags default to off and are switched on by the production
environment on purpose: defaulting them to `not DEBUG` would redirect every test
request to https and break the suite wherever DEBUG is unset. `check --deploy` is
what catches a deploy that forgot them.

Static files are served by WhiteNoise from the app itself, so a second app server
needs no shared volume or CDN. Manifest storage is production-only: it demands a
collectstatic manifest that no test run has.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 09:37:36 +02:00
29d61eeea8 Manage billing from the control panel
A Billing tab (tiers, their dated prices, and everything we are owed), a billing
panel on each club (plan, periods, payment history, invoice), and the dues on the
dashboard and the club tables.

Every state change goes through the billing service, and a BillingError surfaces
as a message rather than a 500 -- so "that period is waived", "no price in force",
"already billed for that period" and a missing PDF library all explain themselves
instead of crashing.

The dashboard now separates the two pots of money that were previously one word.
"Revenue per month" was CLUB SHOP revenue -- members paying their clubs, which is
never ours -- sitting on our dashboard under a label that implied it was income.
It is now "Platform dues per month" (what clubs paid us) with the club-shop series
renamed club_revenue, and the club tables carry a Plan column and what each club
owes us, annotated in the same single query.

Rate changes are add-only in the UI as well as the model: the price form creates a
dated row and never edits the last one, and a test asserts that raising the rate
leaves an already-open period at the amount it was billed at.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:52:43 +02:00
60bfac9881 Add platform billing: tiers, dues, payments and invoices
RosterChief charging the clubs, which is a different domain from `shop` (a club
charging its members). Nothing here is club-scoped: these rows reference a Club,
they are not owned by one, and no club user ever sees them.

- Tier + TierPrice. Prices are dated, not keyed by year: a rate change is one row
  with a future active_from, and price_on(day) answers "what was in force then".
  A tier with no price yet returns None, which callers must treat as "cannot
  bill" -- never as free.
- Due: one rolling-year period per club, with a 45-day grace tail. The tier and
  the amount are SNAPSHOTS taken when the period opens. Raise the price and last
  year's period must still say what was actually charged; reading it back through
  the tier would silently rewrite financial history.
- DuePayment: partial payments accumulate. amount_paid is re-summed from the
  payments on every change, never incremented -- an increment drifts the moment a
  payment is deleted, and the drift still looks like money.
- Invoice: PDF via WeasyPrint, rendered on demand from the frozen snapshot. Only
  the number is stored, in one platform-wide series (unlike the shop's per-club
  order numbers), and re-issuing returns the existing one rather than burning a
  number -- a gap in an invoice series is a question you don't want to answer.
  WeasyPrint is imported lazily: it binds to native pango/cairo, and the app, the
  tests and every other page must still run on a machine without them.
- archive_overdue_clubs reports by default and archives only with --commit. That
  asymmetry is deliberate: this switches off paying customers, so a bad clock or a
  cron misconfiguration should cost an email, not a morning of angry clubs. A club
  with auto_archive off is spared entirely.

Renewal continues from the last period end, not from the payment date: a club that
pays two months late has still used those two months.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:45:15 +02:00
6899e203f6 Show club health on the clubs list too
The list page carried the same vanity counts the dashboard just lost. It now shows
active members, unpaid members, money owed, teams (flagging those nobody can pick a
squad for), upcoming events and admins, with the same No season / Dormant badges --
and the annotations survive search and the archived filter.

The table lives in one partial, included by both pages, so they cannot drift apart.
Archived clubs are badged "Archived" rather than "Dormant": their subdomain does not
resolve, so of course nothing is scheduled, and flagging that as a problem would be
noise on the one page where every row has it.

assertNumQueries(1) covers the list as well now -- health is annotated per club, so
the page must not fan out as clubs are added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:24:31 +02:00
3a6dc00e05 Guard against multi-line {# #} template comments
Django's {# #} is single-line only -- its lexer regex is not DOTALL -- so a
multi-line one is not a comment at all and renders to the page as text. It shipped
into the clubs list, where the archived row read:

    Probe Retired probe-retired {# An archived club's subdomain does not... #}

A test now walks every template and fails on a {# without a closing #} on the same
line, since this is an easy habit to fall back into and the failure is invisible
until someone looks at the rendered page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:24:19 +02:00
192fe5ad0e Split platform signups, and rework the club table into health
The platform signups chart gets the same stacked new/returning split as the club
one. "First season" is keyed on (club, member), never the member alone: the same
person can be new at one club while renewing at another, and collapsing that would
file their second club's very first signup as a renewal.

The dashboard's club table stops reporting vanity counts. A member total says
nothing you can act on; "no coach", "nothing scheduled", "€ owed" and "no admins"
each name something somebody has to go and fix. Columns are now active members,
unpaid members, money owed, teams (flagging those nobody can pick a squad for),
upcoming events, and admins -- with No season / Dormant badges on the club itself.

Every column is annotated in ONE query, each aggregate in its own subquery. That
is not stylistic: aggregates spanning different joins multiply each other's rows,
so a Sum of orders sitting next to a Count of memberships returns the club's debt
multiplied by its membership count. A test pins €100 against three memberships and
would catch it coming back as €300.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:17:26 +02:00
639807b2d2 Split club signups into new members and renewals
A "New members" card sits beside Renewal, and the signups chart becomes a stacked
bar: bar height stays "signups this month" while the split shows where they came
from.

New means "first-ever season at this club", not "signed up recently". A member who
lapsed for a year and came back is a renewal, and counting them as new would
flatter every recovery into growth. It is also per club, not per platform: someone
who plays for another club is still new here.

The split resolves each member's earliest season once up front rather than asking
per row, so the chart costs two queries instead of one per membership.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:12:01 +02:00
f2b78bb1dd Build the club metrics
The club page now leads with its own numbers that should be zero, then the health
signals underneath.

- Teams with no coach. Not a statistic but a defect in the club's setup: with
  nobody in a management position the access service grants no authority over that
  team, so nobody can pick the squad. A physio does not count -- the query keys on
  Position.management_position, and on this season only.
- Unrostered members: active, paid, and on no team.
- Unpaid money bucketed by age. "€250 overdue past 60 days" drives a phone call;
  "€250 outstanding" does not.
- Renewal rate -- last season's actives who signed up again. Exactly computable
  because memberships are season-scoped.
- Turnout, plus the share who never responded. Silence is not an absence, so it is
  excluded from turnout and reported separately: no-response is the leading
  indicator, since it measures whether members use the app at all.

Two of these return None rather than a number, deliberately: a club in its first
season has not failed to renew anyone, and a season with no past events has no
turnout. Rendering either as 0% would libel the club, so the page says why instead.

Money is pinned to two decimals -- SQLite's Sum() drops trailing zeros, so an
aggregate rendered "€250" next to a "€0.00" constant on the same card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:08:37 +02:00
21d3947d08 Label the revenue axis in euros
The y-axis ticks and the hover both carry the unit now — an axis in euros with a
bare number in the tooltip reads as two different quantities.

Two formatters, not one: the axis rounds to whole euros to keep the labels short,
but the tooltip keeps the cents. Rounding an amount someone is reading off a
chart to answer "how much did we take in March" is a lie, and €1.234,50 becoming
"€ 1.235" is exactly that.

Locale is nl-BE, so it renders "€ 1.234,50" rather than "€1,234.5".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 01:02:14 +02:00
016206a79e Build the platform metrics dashboard
The dashboard leads with the numbers that are supposed to be zero, because a
dashboard of healthy counts is one nobody opens:

- Clubs with no season covering today. Seasons scope memberships, rosters and
  events, so such a club cannot take a signup or schedule a match -- and it fails
  silently, nothing errors, it is just inert.
- Dormant clubs: nothing on the calendar for 30 days. Churn signal.
- Admins pending MFA. RequireMFAMiddleware redirects them to enrolment, so they
  are locked out of their own club until they act: a support queue, not a stat.
- Outstanding money across every club.

Then the shape of the business: an onboarding funnel (clubs → with members → with
a team → with events, which separates working clubs from shells), feature-flag
adoption per club, and two charts -- signups and revenue per month.

Charts use chart.js, self-hosted rather than pulled from a CDN, for the same
reason as the fonts: no third-party in the render path. Two things the browser
taught me: the canvas needs a height-bounded wrapper (with maintainAspectRatio
off it sizes to its parent, and a parent with no height grew it to 3489px), and
chart.js cannot read daisyUI's CSS variables, so the charts re-render on a
data-theme change or keep the light palette in dark mode.

The month series is zero-filled: a chart that skips empty months draws a smooth
line straight over a month in which nothing happened.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:59:28 +02:00
9127be0c42 Give every model created/modified timestamps
TimeStampedModel goes on UUIDModel, so all 28 domain models get row birthdays in
one place. Without them the dashboard can only ever describe the present: "42
members" is knowable, "members joined this month" is not, and no metric can show
direction.

Order dropped its own created/modified -- redeclaring a field from an abstract
base is an error, and its column survives as a plain AlterField (verbose_name
only), so no order data moves.

Note for reading early charts: auto_now_add backfills existing rows with a single
migration timestamp, so everything that predates this commit shares one birthday
and will show up as a spike at that instant rather than as real history.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 00:53:12 +02:00
1dd2e5099f Replace "usb" icons with "user-key" for WebAuthn buttons; refine button styles and layouts across templates 2026-07-14 00:31:22 +02:00
bf86654e72 Give every auth button an icon, and lay out the password and MFA screens
The button element now takes an `icon`, so a page gets one by passing
icon="name" rather than by hand-rolling its own button markup. Every button on
the account and MFA screens carries one; a test walks each page and asserts no
button is left bare.

Change password: labels dropped (allauth already sets a placeholder on each
field), the current password set apart from the new pair, help text kept on the
new password, and Forgot Password promoted from a bare link to an accent button.

MFA management: recovery-code actions are now ranked -- View is primary, Download
and Generate are outline. Generate silently invalidates the codes you already
hold, so it must not read as the obvious thing to click. Panel actions get
breathing room from the body text (card-actions mt-4).

Viewing recovery codes: Download and Generate sit side by side instead of
stacking.

TOTP activate: the code box loses its heading -- an otp field never takes a
visible label, the boxes say what they are -- and the authenticator secret gets
margin around it, since it is copied out by hand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:53:35 +02:00
0eb6838cba Make every cancel/back button an outline button
The filled base button reads as heavy as the primary next to it, so cancel is now
btn-outline everywhere it appears: sign out, the 2FA challenge, and the four
control-panel forms (which also gain the back-arrow icon the auth pages already
had).

For the record, the buttons were never different heights -- measured in a real
browser, every .btn on every page is 40px, anchor and button alike. It was the
fill, not the box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:32:27 +02:00
c9935d0a04 Add Cancel to the sign-out page, and icons to both buttons
Sign Out keeps btn-primary and gains a log-out icon; Cancel sits beside it as a
plain button with a back arrow.

Cancel links to "/" rather than the Referer header. "/" already resolves per
tenant -- club home on a club subdomain, control panel on the base domain --
whereas Referer can be absent or point off-site, which is not something to render
as a link unchecked.

It is an anchor, not a submit, so it cannot post the form: a test asserts that
clicking it leaves the session signed in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:21:56 +02:00
7900233f6d Restore the buttons on the sign-out and deactivate pages
The form element drew its action bar only when `no_visible_fields` was unset. That
attribute means the form has no visible *fields* -- logout and TOTP deactivate are
a bare csrf token plus a button -- and says nothing about its actions. So the bar,
and the only button on the page, was hidden on exactly the pages that exist to
offer that button. Sign Out could not be clicked at all.

The bar is now drawn when the actions slot has content, which is the condition
that was meant all along. Tests cover both pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:20:13 +02:00
add1ee87ae Put the otp input after its boxes
A seventh box appeared when the field took focus.

daisyUI positions each otp box with nth-child, which counts *every* child of the
container, not just the spans. With the input as the first child, all six boxes
shifted one stride right and the container matched :has(>span:nth-child(7)), so
it grew to seven strides wide. The phantom box was the ::after active-box marker
-- transparent until :focus-within gives it an outline -- sitting in the empty
stride that the off-by-one had opened up.

The input goes last, which is also how daisyUI's own examples order it. A test
counts the boxes ahead of the input so this cannot come back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:16:35 +02:00
fa213d95ee Drop the placeholder from the otp field
allauth sets placeholder="Code" on the field. Grey text sitting inside the otp
boxes reads as an already-typed code, so it goes -- the sr-only label outside the
box still names the field.

Suppressed with placeholder=False rather than by deleting the key: as_widget()
merges the widget's own attrs back in at render time, so popping it from a copy
does nothing. Django's attribute template omits attrs whose value is False.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:11:06 +02:00
addfc61a2c Style every MFA screen through the element system
The MFA pages (manage, TOTP activate/deactivate, recovery codes, security keys,
reauthenticate) are built almost entirely from allauth's `element` primitives, so
they are styled by overriding the elements rather than by rewriting eight page
templates. New allauth pages then inherit the look for free.

- field + img elements were missing entirely, so allauth fell back to bare HTML:
  the TOTP secret and recovery-code list rendered as unstyled inputs. The QR now
  sits on a white plate -- it is dark modules on a transparent ground, so on the
  dark theme it was dark-on-dark and phones could not scan it.
- button now honours the tags allauth sets. They were all flattened to
  btn-primary, which made "Deactivate" look exactly as safe as "View".
- the `code` field renders as a daisyUI otp wherever it appears, so the
  reauthenticate and activate pages get the same input as the login challenge.
  The boxes step aside past six characters: allauth accepts a TOTP code (6) or a
  recovery code (8) in that one field.

Fixes a crash: the security-key list does {% load humanize %}, which raised
TemplateSyntaxError because django.contrib.humanize was not installed. That page
500'd on every request; it is now installed and covered by a test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 23:07:44 +02:00
2b7b2b64db Fix empty allauth forms, and lay out the 2FA page
The 2FA code input was not invisible -- it was absent, along with the fields of
every other allauth form except login.

Cause: the `fields` element passed `attrs.exclude` straight into a filter. On a
page that never sets it, resolving a filter *argument* raises
VariableDoesNotExist; Django rescues that for the main variable of an expression
but not for a filter argument, and {% if %} then swallows it and reads the
condition as false. So every field was skipped. Login was the one page that
passes `exclude`, which is exactly why it kept working and hid the damage.
`exclude` is now pinned to a real variable first, with tests that render the
login, signup and password-reset forms and assert their inputs exist.

Two dangling buttons fixed while in here: `elements/form.html` dropped the `id`
attribute, so the out-of-band forms allauth generates (webauthn_form,
logout-from-stage) had no id for a button's `form` attribute to point at. "Use a
security key" submitted nothing.

Layout: the code is a daisyUI otp field, Cancel sits beside Sign In as a plain
button, both gain icons, and "Use a security key" becomes an accent button.

The otp boxes yield once more than six characters are typed. allauth accepts a
TOTP code (6) *or* a recovery code (8) in this one field, so hard-boxing it to
six would have locked out every recovery code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:59:00 +02:00
b3f153a2dc Lay out the login card and wire up the passkey button
Sign In and "Sign in with a passkey" (btn-accent) now sit side by side with
"Remember Me" on the same row, and the email/password block is given room above
and below.

The passkey button was dead. It submits a *different* form -- the hidden
`mfa_login` that allauth renders from its `extra_body` block -- and our layout
base never defined that block, so neither the form nor the webauthn script was
ever emitted and clicking the button did nothing. _base.html now has the block,
and there is a test asserting the form and script are on the page.

The `fields` element grows an `exclude`, so a page can lay a field out itself
(here: "remember", moved onto the button row). It splits on commas rather than
testing for a substring -- "password" is a substring of "password2", and a page
excluding one would otherwise silently drop the other.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:47:26 +02:00
7b18b39f49 Put icons in the login fields and drop their labels
The entrance forms lose their visible field labels and gain an icon inside each
field (mail, lock), and allauth's "Forgot your password?" link -- which is the
password field's help_text -- is spaced away from the input instead of sitting
flush against it.

Three things this depends on:

- allauth already passes `unlabeled=True` on the entrance forms and already sets
  a placeholder on every field there, so the visible label was redundant. The
  label is still emitted sr-only: a placeholder is not a label, and it vanishes
  as soon as you type.
- daisyUI's icon-in-field layout puts the `input` class on the *wrapping label*,
  so the input itself must carry only `grow` -- hence the optional css override on
  the daisy filter. `input` on both draws a box inside a box, and the error state
  belongs on the wrapper for the same reason.
- The help text now carries id="<auto_id>_helptext". Django points the input's
  aria-describedby at exactly that id, so without it the reference dangled and a
  screen reader never announced the password-reset link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:43:44 +02:00
e44933330d Give messages an icon, a bold title and soft styling
Each level now renders as a daisyUI soft alert: an icon, a bold heading and the
message text.

Django messages carry a level and a string -- there is no title field -- so the
heading comes from the level ("Done", "Careful", "Something went wrong"), and a
call site that wants a specific one passes it as extra_tags:

    messages.success(request, f"{club} is live.", extra_tags="Club created")

The lookup is keyed on level_tag, not tags. `tags` is extra_tags and level_tag
joined, so the old `message.tags == "error"` test would have stopped matching the
moment any message carried a custom title, and every alert would have quietly
rendered as blue info.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:35:06 +02:00
1a2bf257da Brand the auth screens per tenant
A club member signing in at ajax-united.rosterchief.app now sees their club's
logo, name and colours; the base domain keeps the RosterChief skin for the
control panel and Django admin.

The mechanism is `{% extends base_template %}` -- Django lets the parent be a
context variable, so the `branding` context processor picks the skin from
request.club and *every* auth screen allauth ships (login, password reset, MFA,
passkeys, and whatever it adds next) follows the tenant without a single one of
them knowing that clubs exist.

Templates split three ways: _base.html is the skeleton with no branding, and
_platform_base.html / _club_base.html dress it. The control panel extends the
platform base *explicitly* rather than through the variable, so a bug in
branding resolution can never dress the panel up as a club.

Club gains an optional logo and primary_color. Notes on both:

- No logo falls back to the club's initials, never the RosterChief mark, which
  would pass our branding off as theirs.
- Club colours land in an inline :root. daisyUI declares its theme variables
  inside `@layer base`, and unlayered styles beat every layered rule regardless
  of specificity, so this needs no !important. --color-primary-content is derived
  from WCAG relative luminance, so a club that picks pale yellow gets black text
  instead of invisible white.
- primary_color is a text input, not <input type="color">: a colour picker cannot
  express "no colour", so every club that never touched it would submit #000000
  and silently get a black theme.

"/" now resolves per tenant (club home, or hand off to the control panel), which
is why LOGIN_REDIRECT_URL can stay "/" and allauth needs no redirect adapter.

Also folds in the theme toggle gaining a third "auto" state and the logo
switching from `content:` to background-image (content-replacement on a real
element is not supported in Firefox), both of which lived in the base template
this commit replaces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:30:38 +02:00