Commit Graph

110 Commits

Author SHA1 Message Date
a0226d51a9 Match the News list rows and filter chips to D8's exact spec
Pulled the literal markup from the design canvas (RosterChief Platform.dc.html)
rather than the prose summary: filter chips are 28px/6px-radius with a dark
ink background when active (not the club accent colour, and not a pill/
button shape), and each row is D8's real three-line anatomy -- status pill +
mono meta on their own line, a 20px condensed headline, then a muted author/
audience line -- not the single dense line from the last pass. It reads as a
list because rows are plain hairline-divided strips (#EEF0F3, exactly this
app's --color-rule), not because the type was shrunk.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 09:07:12 +02:00
e5427c31dd Widen News' filter row, drop nested count badges; row Attendance/Documents/Referee cards
News list: the left pane is wider (380px -> 460px) so the four status chips
fit on one line, and each chip's count is now plain text in the pill itself
("All 42") rather than a nested badge -- matches D8's own "All 42 / Drafts 3"
wording literally. Member detail: Attendance, Documents and Referee
eligibility now sit side by side in one row instead of stacking as three
full-width cards.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 09:03:37 +02:00
fc3901fb5a Add a live D9-style preview to the Club identity page
Two columns (form left, preview right, matching D9) with a mini mock of this
app's own sidebar + a sample button -- primary_color/secondary_color are
what those actually control (base.html's --tenant-* properties), so the
preview reflects a real surface rather than D9's aspirational App/Website/
Email tabs. Colour swatch pickers sync with the existing hex text fields in
both directions, and a contrast-ratio row mirrors Club._content_color_for's
exact WCAG math client-side, so the ✓/⚠ shown is the real number the server
computes on save.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 09:00:13 +02:00
8baf3f7b3d Add the D7 attendance sparkline to member detail; tighten the News list rows
Member detail: a 12-bar season attendance sparkline (present/absent/upcoming)
beside Present/Absent/No-reply totals, shown for any rostered non-guardian
member with events this season (events.services.attendance.member_attendance_
sparkline/_counts). News list: rows are one dense line (status pill inline
with the headline, everything else folded into a single meta line) instead
of the previous multi-line stacked layout that read as a small card per item.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 08:55:44 +02:00
331b0b8227 Redesign the News page around D8's list + preview split view
Replaces the flat news list with a D8-style two-pane layout: a filterable
list on the left (All/Drafts/Scheduled/Published chips with counts) and a
preview of whichever item is selected on the right, reusing the same
article/photos/publish markup news_detail.html already had (now factored
into _news_preview.html so both pages share it). Adds an NL/EN language
toggle to the article preview -- the club's two content languages are now
switchable in place, with a fallback note when a translation is missing,
instead of a separate "English" card that only appeared once translated.
The standalone news_detail.html permalink page is unchanged behaviourally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 08:30:24 +02:00
441367362d Build the D5 events calendar, redesign event forms, and polish the events UI
Adds the Week/Month/Season calendar to the Events page (team/group-scoped
visibility, kind-colored blocks, a repeating-series indicator) alongside the
existing table as a List view, and redesigns event/event-series creation
around a kind-first picker with progressive disclosure instead of one long
flat form. Also: an accessible radio-based kind picker (no redundant
select-next-to-cards), a fixed week-grid layout bug, a filterable single-list
redesign of the event detail page's RSVP/responses modal, a tidied-up
recurrence field layout, and assorted button/field height and width fixes
across the referee panel and event forms.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 01:08:53 +02:00
adf1120358 Checkpoint: management app redesign, onboarding/signup workflow, and events calendar backend
Large uncommitted body of work accumulated across sessions on this branch --
committing as a checkpoint so it's tracked and future worktree-isolated agents
see the real codebase instead of a stale ancestor commit. Covers the
management app's dedicated Tailwind theme and templates, the club onboarding
requirement/signup workflow (club/services/onboarding.py, requirement/status
models, sign-up dashboard), fee/status auto-activation decoupling, referee
management, and the new events calendar grid service layer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-19 23:34:43 +02:00
bff685966d Fix scrambled mobile layout on member detail and sweep the rest of the app
The mobile responsive pass still had real gaps: on member_detail.html,
dt/dd label-value rows (phone numbers, dates) squeezed onto one line
below `sm` instead of stacking, and guardian call/emergency-call
buttons -- whose labels embed the guardian's name -- used a bare
`grow` inside a non-wrapping flex row, pushing the whole row off
screen once a name was long enough. Both patterns are fixed here and
propagated to every other page with the same shape of bug:
event_detail.html and event_series_detail.html still had the
unmigrated dt/dd rows from before the first pass; sponsor_list.html's
URLs and parent_claim_list.html's emails could force a table-card
wider than the viewport (unbreakable strings, fixed with break-all);
parent_claim_list.html's Approve/Reject buttons could both land on
the left edge once the row wrapped (fixed with ms-auto); news_list.html's
team-badge list and news_detail.html's per-photo action buttons were
missing flex-wrap; team_detail.html had a stray table never converted
to table-cards and two unstacked stat lists; referee_management.html's
per-game referee badges were missing flex-wrap.

Also hide the three dashboard charts (signups, fee status, renewal
rate) below `lg` -- three squeezed canvases stacked on a phone added
scroll without adding readability the stat cards above don't already
give, and match family_list.html's search box to member_list.html's
icon-only-below-`sm` pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 10:35:26 +02:00
9f4a0ea687 Make the management app responsive on mobile
Replace the mobile nav's horizontal scroll strip (which wrapped into
an unreadable jumble because daisyUI's .menu sets flex-wrap: wrap and
.menu-horizontal never resets it) with a hamburger button that opens
a proper left-side drawer, mirrored in controlpanel for the same bug.
Move the theme toggle, "Management", and "Django admin" controls into
that drawer below `lg`, leaving only the account icon in the navbar,
so the club name has room to render in full instead of truncating to
initials.

Add a reusable `.table-cards` CSS pattern (app.css) that turns a list
table into a stack of labelled cards below `md`, and apply it to every
list/detail table in the management app -- members, families, teams,
memberships, events, groups, locations, opponents, positions, referee
levels/list, roles, sponsors, news, parent claims, team roster/staff,
and the shared family-members table. Tables revert to normal desktop
layout at `md` and up.

Member list gets extra passes: search submits icon-only below `sm` so
it fits next to the input, the Members/Guardians/Both tabs go
full-width, and last/first name merge into one column. Action-button
rows across every page stack full-width on mobile instead of wrapping
mid-button (shared fix in base.html). Home dashboard's upcoming-events
and news mini-tables become simple lists instead of overflowing
tables. Also: brand truncates instead of overflowing on a long club
name, a stray invalid xmlns attribute removed, a wrapping UUID badge
hidden below `sm` on team detail.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 09:55:08 +02:00
67841491a6 Fix mismatched fallback text colour on the initials badge/button
The initials badge and reset button hardcoded two independent
fallback literals -- a background (#ec4899 / #0ea5e9) and a text
colour (#ffffff) -- that were only ever chosen together for a club's
own colour via Club._content_color_for. #ffffff on #ec4899 or
#0ea5e9 actually contrasts worse than black by the same WCAG formula
the app already uses elsewhere (verified: 6.4:1 vs 3.3:1, and 7.6:1
vs 2.8:1). Added a contrast_color filter so the text colour is always
derived from whatever background hex is actually in play -- real
club colour or fallback alike -- instead of a second, independently
guessed literal that can silently drift out of sync with the first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 09:50:07 +02:00
ece39e51ed Rebuild compiled CSS for the new claims/families/pagination markup
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 23:57:36 +02:00
b2657bb15d Fix the claims page's mismatched control heights, and separate approve/reject
Two things, found together while looking at the row of controls under each
pending claim.

The child dropdown stood taller than the btn-sm/input-sm around it because
ClaimReviewForm.child hardcoded its own class="select select-bordered w-full"
on the widget. templatetags/field.html's select branch already builds the
full class list itself (base classes + the size modifier), so the widget's
own class rendered as a second, non-merging class="..." attribute right next
to the generated one -- select-sm was in the markup, just shadowed by a
duplicate attribute the browser never applied. No other Select field in the
app hardcodes a class this way, which is why nothing else had the problem.
Fixed by dropping it and passing size="small" through {% form_field %}
instead, the same way every other compact inline select in the app does.

Also pulled Approve and Reject apart with justify-between rather than letting
both sit in one flex-wrap run, so they stay on opposite sides of the row
(and don't end up adjacent on a wrap) rather than one stray click apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:40:52 +02:00
ca2b1a11b5 Add parent claims: onboarding a roster of children with no parents on file
The migration path for a club arriving with a list of children from a
federation export and no parent records. Children import without logins, each
into a family of their own -- that shape *is* the "nobody is responsible for
this child" state, so there's no unclaimed flag to drift out of step with
reality, and a family drops off the worklist by itself the moment a parent
joins it. `family_role=child` with a blank `family_group` asks for that; any
other lone role is still a mistake in the file.

Verification is a human decision, deliberately. A parent submits a public form
with the child's name and date of birth as free text -- no search, no
autocomplete, and the same response whether or not the child was found, because
the page needs no login and anything that resolved the child would turn it into
a way to enumerate the club's children. An admin matches it from a queue
against a shortlist that only ever contains children with nobody on file, so
approving can never quietly re-parent a child who already has one.

The alternatives were worse. A claim code needs a delivery channel the club may
not have and is a bearer token besides. Matching on name plus birthday hands out
someone else's child to whoever guesses a birthday. The club is the only party
that actually knows its own families.

That form is also the registration: open self-registration is now closed
(shadowing account_signup rather than removing the route, so the URL name
allauth's templates reverse still resolves). The account is created on
approval, not on submission, so a public form can't fill the user table. An
approved parent lands as a guardian -- login and family link, no membership, no
fee -- gets a password-reset link, and a minimal "my family" page.

One bug worth recording: families_awaiting_a_parent first used
annotate(Count(..., filter=...)) over a queryset already filtered on the same
join, so Django reused that join for the counts and a parent with no
ClubMembership of their own -- exactly what a newly linked guardian is -- went
uncounted, leaving the family unclaimed forever. Exists subqueries avoid it. A
test pins both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:18:26 +02:00
1d5286fbbf Rebuild team/group bulk add as a searchable row formset
The old page gave every eligible member a table row, which a club with a
hundred-plus members can't use, and its search was a GET round-trip that
discarded anything already ticked. Now you add rows: pick a person from a
searchable select, pick a position, fill in the details.

The position decides the role. Position.staff_position already distinguishes
them, so a staff position creates a StaffAssignment and anything else a
TeamMembership -- no separate "player or staff?" control that could disagree
with the position picked. Someone joining as both is two rows. Jersey number
and captaincy exist only on TeamMembership, so a staff row rejects them and the
row script greys them out, keyed off the data-staff marker PositionSelect
stamps on staff options.

All-or-nothing on submit: one bad row re-renders the page with every row still
filled in and the offending field flagged, rather than saving the good rows and
losing the rest -- a partial save costs far more when the rows were typed by
hand. Cross-row checks no single row can see (the same person twice, two rows
claiming one jersey) live on the formset's clean(); per-row checks live on the
row form. Eligibility is still never trusted from the POST.

Captain and alternate captain on one row is refused as self-contradictory, but
how many captains a team may have is deliberately left alone: neither the model
nor the single-add form constrains it, and inventing the rule in one entry path
only would be bypassable by adding players one at a time. A test pins that
absence so it reads as a decision rather than an oversight.

Two implementation notes worth keeping: rows are cloned from the template's
parsed content, not its innerHTML, because assigning "<tr>...</tr>" to a
detached <div> silently drops it; and these tables deliberately skip the usual
overflow-x-auto wrapper, which would make a scroll container that clips the
picker's dropdown for every row but the first couple. Removing a row leaves
TOTAL_FORMS alone -- Django reads a form whose fields are absent from the POST
as an unchanged extra and skips it, which is safe, unlike re-indexing live
inputs.

management/tests.py also carries the setUpTestData rationalisation from the
previous commit, which couldn't be split cleanly from the new bulk-add tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 13:48:19 +02:00
309bd4d83e Add referee assignment/eligibility system, team bulk-add, member Groups, and referee management dashboard with PDF export
Builds the referee workflow end to end: club-defined RefereeLevel/RefereeProfile
eligibility tied to teams, EventReferee assignment (member or external, with
fee/km payment tracking), an admin dashboard with KPI tiles, date-grouped game
tiles and range filters, and a downloadable payment form PDF modeled on the
club's existing paper document (using Club.legal_name when set). Also lands
team roster bulk-add, member mass-upload with family linking, and the
members.Group model, developed alongside this work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 21:39:19 +02:00
c2a4380b70 Lay the plan modal out in two columns instead of one tall one
PlanForm grew to seven fields with the billing rework, three of them
short day counts, and the modal ran off the bottom of the screen.

_modal_form.html gains two optional knobs -- box_class to widen the
box, two_columns to grid the fields -- so only the plan create/edit
modals opt in and every other modal renders exactly as before.
Textareas and file inputs still span the full width; below `sm` it
stays single-column, since two columns of form fields on a phone is
worse than a long scroll.

PlanForm's field order is chosen to suit that grid: pairing name with
duration and the two day-counts with each other fills every row
rather than leaving half of one empty next to the full-width
description.

Rebuilt static/css/app.css (unminified, matching how it is committed)
to pick up the new grid utilities.
2026-08-08 19:01:14 +02:00
2b180f3c39 Update .idea module files and refactor CSS to remove unused styles 2026-08-07 15:55:28 +02:00
cd4d487161 Add new CSS utilities and enhancements to daisyui component styles 2026-08-07 15:52:12 +02:00
30b464eb56 Update .idea module configuration and simplify CSS by removing unused classes
- Updated `.idea/rosterchief.iml` to reflect module name changes and added `.venv` exclusions.
- Cleaned up `app.css` by removing unused classes and properties from legacy styles.
2026-08-06 19:51:42 +02:00
98b8002a04 Add billing-ending banner, events CRUD, RBIHF import, public API, team photos, and sponsors
A large batch of club-management features built up over one session:

- Club dashboard banner warning admins 1 month before billing ends
- Full Events/EventSeries CRUD (recurrence builder, occurrence lifecycle,
  per-team permissions), with match->game rename and game-specific fields
  (score, competition, live status, external game ID)
- Django-admin competition dropdown, gated per-club by feature flag
- Auto-import of RBIHF fixtures (scrape -> diff -> preview -> confirm),
  with location/opponent dropdowns suggested from existing club data
- Feature-flag-gated Shop/Forms nav sections, reusing the same flag
  machinery for the RBIHF import button
- Team roster now scoped to members active this season or next, sorted and
  grouped by position
- Club sport type (ice hockey / other), shown in the control panel's club
  subtitle
- Per-season team photo upload from the team page
- New public read-only API (Django Ninja) at /api/v1/: news, team rosters,
  upcoming/live/per-team games, and sponsors -- auto-documented via Swagger
  UI, CORS-enabled for a club's own external website
- Club sponsors: admin-only CRUD (logo, URL, active date window) plus a
  date-windowed, optionally randomized API endpoint
- Assorted fixes: NullBooleanField dropdown rendering, cross-club event
  validation timing, searchable-select chip placement, btn-neutral ->
  default button style sweep, calendar-month chart windows

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1gj3J1QPfP38XWpnpbFpy
2026-08-06 17:36:04 +02:00
e6850232f0 Rework Teams/Positions access control and add full Locations/Opponents CRUD
Team managers/coaches now only see their own teams, can't create teams,
and can view (but not edit) positions -- admins keep full rights.
Locations and Opponents move from read-only stubs to full CRUD, gated
to admins and management-position staff, with a country dropdown
(django-countries) instead of free text. Also: the team list shows
player/staff counts, and deleting a news item's main photo promotes
another one instead of leaving the item without one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1gj3J1QPfP38XWpnpbFpy
2026-08-04 12:27:24 +02:00
18e3b0306d Redesign the management home page's KPIs and charts
Drop renewal rate from the top KPI bar; split "Upcoming events" in half
and add a "News" overview beside it (published items only, everyone
can see it, same as events). Signups-per-month, club fee status, and
renewal rate (now a pie chart matching the fee status one, not a bare
stat box) move into their own row and become admin-only like the fee
chart already was. The bottom stat-group row now sizes its columns to
how many groups actually render, instead of leaving a blank slot where
Shop would be for non-admins.
2026-08-03 22:40:06 +02:00
d9b4337319 Add a shared searchable multiselect combobox for team/member pickers
Extracts the "Grant role" member picker's typeahead combobox into a
reusable static/js/searchable-select.js (opt in via data-searchable on
the widget), and reuses it for the news form's teams field -- a proper
multiselect with removable pills and filter-as-you-type, replacing the
plain checkbox list. Needed forwarding widget attrs through the shared
select template, which never passed them to the rendered <select>.
2026-08-03 21:36:35 +02:00
ce35348b31 Build out Positions CRUD and rework the Roles page
Positions had list/create/edit already stubbed as list-only; give it real
forms, with a check mirroring the management_position_implies_staff_position
constraint so a bad combination reads as a form error, not a 500. Roles now
groups by role (excluding the MEMBER everyone holds automatically, which was
just noise), grants via a modal instead of a separate page, and its member
picker is a small typeahead combobox instead of a long native <select>.
2026-08-03 18:45:28 +02:00
062da00bb9 Add the management app: club-facing UI + real fee-payment tracking
Gives clubs a self-service /manage/ area for members, families, teams,
roles, and season memberships, alongside real fee-payment tracking
(FeePayment, record_payment/mark_as_paid/remaining_balance) so a
membership's paid status reflects actual money received instead of a
single manually-set flag.
2026-08-03 17:01:15 +02:00
febde41214 Let the club logo fill more of its ring, match it in the control panel
Drops the inner padding on the logo image so it fills the circle right
up to the ring, rather than floating small in the middle of it -- most
visible on logos with generous internal whitespace (e.g. some SVGs).

The control panel's club-detail page now frames the logo the same way.
It doesn't get the page-wide --color-primary override the club's own
site uses (the panel must never dress itself up as the club), so the
ring colour is set as a locally-scoped custom property on just this
element instead.
2026-07-27 11:15:08 +02:00
a86c35cbe7 Frame the club logo with a primary-coloured ring, sharpen its scaling
Wraps the uploaded logo in the same avatar shape as the initials
fallback (so the header doesn't jump in size depending on whether a
club has one) and adds a ring in the club's own primary colour as a
plain border around it.

Also hints the browser to use its higher-quality image scaler: a
club's raster logo is often much smaller than the badge it's shown in,
and the default upscaling in some engines reads as pixelated. Doesn't
affect SVG logos, which scale losslessly regardless.
2026-07-27 11:06:08 +02:00
c17e7b5059 Remove unused CSS utility classes and redundant theme variables
Cleans up unused Tailwind CSS classes, DaisyUI components, and theme variables from `static/css/app.css`. Also removes an unnecessary margin class in `field.html` for cleaner templates.
2026-07-27 10:56:30 +02:00
c1ba6e269b Rebuild compiled CSS to include bg-secondary / text-secondary-content
These are the club avatar's classes for the new secondary_color feature.
Tailwind only emits utility classes it sees used in a template scan, and
the dev watcher wasn't running when that avatar edit landed, so the
classes were silently absent from static/css/app.css: no background,
no text colour, just an empty (visually white) circle. A one-off build
regenerates them; both now correctly reference var(--color-secondary)
and var(--color-secondary-content) so a club's colour picks apply.
2026-07-27 10:37:43 +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
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
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
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
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
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
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
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
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