Commit Graph

70 Commits

Author SHA1 Message Date
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
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
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
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
fc51e1903c Self-host Ubuntu, JetBrains Mono, Roboto and Tourney
Ubuntu becomes the default sans and JetBrains Mono the default mono (via
--font-sans / --font-mono, which Tailwind wires to the body and <code>
defaults). Roboto and Tourney are opt-in utilities: `font-roboto`, and
`font-tourney` for display numerals like jersey numbers.

Self-hosted rather than linked from Google's CDN: a CDN <link> sends every
visitor's IP to a third party on each page load, which is an avoidable GDPR
liability for an EU club platform, and it puts someone else's uptime in our
render path. Files come from the @fontsource packages.

Two subsets each, with unicode-range: plain `latin` cannot render the Polish,
Czech or Turkish letters that turn up in member names, and the range means
latin-ext is only fetched by pages that actually contain those glyphs.

Tourney is variable on two axes, so font-stretch is declared as a range next to
the weight range -- declare only the weight and the browser clamps the width
axis to its default, making `font-stretch: 125%` (a wide shirt number) silently
do nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 22:30:02 +02:00
334c706aec Reload the browser on template and static changes in dev
Adds django-browser-reload: runserver already restarts on Python changes, but
the browser had to be refreshed by hand for every template or CSS edit. It also
watches static/, so a Tailwind rebuild now refreshes the page on its own.

Mounted only under DEBUG -- it injects a script into every HTML response and
serves an open event stream, neither of which belongs in production; a test
holds that line. Its endpoint is exempt from RequireMFAMiddleware, otherwise a
not-yet-enrolled staff user has the stream redirected away and live reload dies
on the MFA enrolment page, which is exactly a page we are restyling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 18:32:19 +02:00
268cbe1e06 Manage platform admins and feature flags from the control panel
Features tab: create/edit flags, flip global switches, and toggle a flag per
club from the club detail page. Where `everyone` is set the per-club toggle is
replaced by a badge, because a toggle there would have no effect and so would
lie about what is on.

Admins tab: grant, promote, demote and revoke platform access. Gated on
is_superuser, not is_staff -- the panel itself is staff-accessible, so letting
staff grant is_superuser would collapse the two levels into one and stop
is_superuser being a boundary we can later hang anything on.

Two guardrails, enforced in the service so they hold regardless of caller:
you cannot strip your own access (you would lose the panel mid-click), and the
last superuser can never be demoted (the platform would be locked out of
itself). Granted users get an unusable password and must enrol 2FA before they
can sign in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:42:23 +02:00
eace903f05 chore: rebrand ClubManager -> RosterChief, add lucide icons
The clubmanager.app domain was taken, so the platform is now RosterChief
(rosterchief.app). Renames the Django project package clubmanager/ ->
rosterchief/ (git tracks it as a move, so history follows), every
`from rosterchief.base import ...`, the settings/wsgi/asgi module paths,
env vars (ROSTERCHIEF_BASE_DOMAIN / ROSTERCHIEF_RP_NAME), the MFA adapter
(RosterChiefMFAAdapter), brand text, and the docs.

Two things were deliberately NOT swept:
- club.models.ClubManager stays: it is the Django manager *for Club*, not the
  brand. A blind rename would have silently broken it.
- Migrations are untouched (history is not rewritten). The only reference was a
  cosmetic help_text, so a normal AlterField migration carries the new domain.

Note the WebAuthn RP ID is the base domain, so moving to rosterchief.app
cryptographically invalidates any passkey enrolled under the old one; they
cannot be migrated and must be re-enrolled. Nothing is in production, so the
real cost is zero.

Add django-lucide (from bsiebens/lucide) for icons: the theme toggle now swaps
sun/moon against the effective theme, and the control panel gets icons on its
tabs, actions and stat groups. Its classifiers stop at Django 5.0, but that is
stale metadata — verified rendering on Django 6 / Python 3.14.

Also add formbuilder, shop and controlpanel to ruff's known-first-party list,
which had drifted behind the apps that landed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:42:20 +02:00
6f66df3ba4 feat(ui): platform control panel and styled auth screens
Add the `controlpanel` app: a platform-wide (not club-scoped) admin panel for
creating clubs, archiving/restoring them, managing club admins, and per-club
statistics (members, teams & staff, events, shop). Statistics are annotated in
one query so the club list cannot fan out into N+1, and are returned as stat
*groups* so growing the domain means adding one entry.

Two access rules, both enforced by PlatformStaffRequiredMixin:
- staff only (is_staff/is_superuser); anonymous are sent to login, signed-in
  non-staff get a 403. Staff already need a second factor, so the panel is
  2FA-protected for free.
- base domain only: the panel manages *all* clubs, so it 404s if the tenant
  middleware resolved a club from the subdomain.

Granting admin to an unknown email creates the account (unusable password —
they set one via password reset) and the Member behind it, since a ClubRole
hangs off a Member. A member who already holds a role is promoted in place,
because there is only one role per member per club.

UI is Tailwind + daisyUI. allauth ships an element system, so overriding
allauth/layouts/base.html plus ~13 element partials restyles *every* auth and
2FA screen at once — login, signup, password reset, the 2FA challenge, TOTP
enrolment, passkeys and recovery codes — rather than templating 20+ pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:29:24 +02:00
848a8578de build(ui): add Tailwind v4 + daisyUI 5 pipeline
daisyUI is an npm plugin, so the standalone Tailwind CLI can't load it; this
adds a real build. Themes are configured as `light --default, dark
--prefersdark`, so dark follows the OS with no JavaScript, and an explicit
data-theme (set by the toggle) overrides it.

The built CSS is committed, so `uv run manage.py runserver` remains all a
Python dev needs; `npm run watch` is only for those touching styles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 15:29:10 +02:00
2ead824c5d Add accounts app: custom User, Member, families & guardianship
Introduce the foundational accounts app:
- Custom email-as-username User (AbstractBaseUser + PermissionsMixin) set
  as AUTH_USER_MODEL, decoupled from membership so children can be members
  without a login.
- Member model holding personal/roster data (names, contact email, phone +
  emergency phone via django-phonenumber-field, license number, DOB) with an
  optional link to a User.
- Family household grouping and directional Guardianship (guardian -> child)
  with uniqueness and no-self-guardian constraints.
- Custom UserAdmin plus Member/Family admin with inlines and autocomplete.
- Settings: register apps, AUTH_USER_MODEL, phonenumber defaults (BE/E164).
- Add CLAUDE.md and a tracked static/ directory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:08:51 +02:00