65 Commits

Author SHA1 Message Date
1365dcf18e Coach lineup polish, referee level/position delete, feature-flag nav gating, per-job pause
- Coach line-up screen: more breathing room above the sheet, brighter event
  subtitle, and the "Schedule" button now matches the date input's height.
- Positions and referee levels can now be deleted from Settings (blocked with
  a friendly message if still in use on a roster/referee profile/inheritance
  chain).
- The Evaluations nav placeholder is now gated on the formbuilder flag, same
  as Forms itself, since the design reuses formbuilder underneath.
- Control panel: each scheduled platform job can now be paused/resumed
  individually (features.models.JobToggle), independent of the platform-wide
  Maintenance lock.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 11:17:46 +02:00
5889b3740e Add a Competitions card to the control panel's Features page
Competition rows (events.services.competitions' per-club data-source
gate) could previously only be managed through the Django admin.
Adds a card alongside Flags/Switches with create/edit/delete, mirroring
the Flags card's own modal pattern -- no cascading effects to weigh on
delete since Event.competition matches by name, not a foreign key.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 21:40:13 +02:00
59d1fce674 Add Club.website; rebuild Colours per D9's exact markup; add a mobile app preview
- club.website (URLField, blank) editable from both the club's own Identity
  page and the platform control panel's club form.
- Colours card rebuilt from the design canvas's literal D9 markup rather
  than the prior in-between version: swatch + mono hex merged into one
  bordered row (the swatch input and hex text input are the same real form
  field, just laid out together), and a contrast-check row of two colour-
  filled boxes instead of a plain text list -- labelled dynamically
  ("Black"/"White" on primary/secondary) since a club can pick a colour
  light enough that black, not white, is the real computed text colour.
- Logo card restyled to D9's dashed drop-zone look.
- Live preview gains a mobile app mock (marked "Coming soon") alongside the
  existing sidebar mock, using the same primary/secondary colours, ahead of
  that surface actually being built.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-20 09:13:03 +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
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
5d3dbebd77 Fix Safari rejecting the news publish-date field
<input type=datetime-local> with no step attribute defaults to
whole-minute granularity, but the value was rendered via the "c"
filter (full ISO, always includes seconds) -- NewsPublishForm's
initial=timezone.now made every render carry live, non-zero seconds,
which violates that implicit step. Chrome tolerates the mismatch;
Safari enforces it and silently refuses to accept the field, with no
server-side validation error to explain why. Format datetime-local
values at minute precision instead. Fixes every field using this
widget (event start/end/gathering/deadline, series dtstart/until),
not just the one this was noticed on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-16 09:54:48 +02:00
cb7f56709b Email a set-password link on claim approval, flash identically either way
Two additions to the parent-claim flow: Club.contact_email (set from the
control panel, next to legal_name), and an email sent when an admin approves a
claim -- a real one-time set-password link built with allauth's own token
generator, so it lands in the same flow the login page's own reset would send
a parent to rather than a second, parallel one that could drift out of step
with it.

Never allowed to fail the approval: the family link and the guardian row are
real either way, and a mail server being briefly unreachable must not cost a
parent their place in the queue. The admin gets a distinct warning telling
them the email didn't go and to have the parent use "Forgot your password?"
instead.

The public submission flash keeps the enumeration guarantee the claim form
itself was built around: worded and timed identically whether or not a
matching child was found, sent before any lookup happens at all, mentioning
the club's contact email when the club has set one. A test compares the
rendered flash across a matching and a non-matching submission byte for byte.

One test-writing trap worth recording: assertRedirects follows the redirect
itself by default, and its own probe GET consumed the one-shot flash message
before a later explicit GET in the same test could see it --
fetch_redirect_response=False avoids the double-fetch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:33:41 +02:00
744b623403 Separate guardians from members: a parent is not automatically a member
members/services/family.py enrolled a parent exactly like the child they were
registering, so every parent held a full membership: counted in the member
list, in the club and platform KPIs, and in the fee roll, with a fee record of
their own. ClubMembership.kind (member | guardian) separates the two.

A guardian is attached to the club only through their child. They hold the
login, can be contacted and can sit in a Group -- the stated exception -- but
they are not a member: no fee (clean() refuses one), absent from the member
list, the fee list and every member count, and not eligible for a roster or a
staff spot. A parent who also plays or coaches is a member who happens to be a
parent; the two facts are independent, which is why this is its own field
rather than inferred from FamilyMembership.role.

A field on ClubMembership rather than a separate model because everything that
answers "is this person attached to this club" already reads through that table
-- tenancy, groups, the club-wide event audience -- and a second kind of link
would need a parallel path through all of it. What changes is only who counts.

Two things that weren't obvious going in:

Excluding guardians had to be a subtraction, not a narrower filter. The obvious
move -- match only member-kind rows and drop the MEMBER-role branch, since an
active membership of any kind grants that role -- also hides someone the club
knows but hasn't signed up for a season yet, which is a real state the member
edit page supports. Two existing tests caught it. _guardians_only() subtracts
instead, so anyone who also plays, is on staff or runs the club stays visible.

Their tie to the club isn't seasonal but rides on a per-season row, so it has
to be carried forward or a parent silently drops off at the season boundary
while their child stays enrolled. Copied from the immediately preceding season
only, so a deliberate removal isn't resurrected from an older row.

The data migration reclassifies existing parents, deliberately skipping anyone
who plays, is on a team's staff or holds an elevated ClubRole -- demoting them
would strip them from their own team's roster eligibility. Anything ambiguous
stays a member, which an admin can flip; noticing someone quietly vanished is
much harder.

The import template gains a membership_kind column next to family_role (a
child marked guardian is refused), and the review screen shows what each row
will join as.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:11:41 +02:00
ffe8a3d301 Speed up and rationalise the test suite (158s -> 16s)
Nearly all of the wall clock was password hashing: there was no test-time
PASSWORD_HASHERS override, so Django's PBKDF2 default (~1.2M iterations) ran on
every create_user and every login, hundreds of times over. The fix lives in a
DiscoverRunner subclass wired in via TEST_RUNNER rather than a "test" in
sys.argv sniff in settings: a runner is only ever instantiated by `manage.py
test`, so there is no env var to mis-set and no import path by which a deployed
process can reach the weak hasher. Verified: outside the runner the hasher is
still PBKDF2. It also enables the cached template loader (the runner forces
DEBUG off *after* settings are read, so Django never turns it on by itself) and
silences django.request, whose 4xx/5xx logging buried real test output.

Second, the fixtures. Base classes were rebuilding a club, season, admin user,
membership, role and MFA authenticator once per test; those are read-only for
almost every test, so they move to setUpTestData and are built once per class.
Django hands each test its own deep copy and the per-test transaction rolls the
rows back, so the handful of tests that mutate them stay isolated -- proved with
--shuffle, --reverse and --parallel rather than assumed. Per-test work that
genuinely must stay per-test (client sign-ins, waffle cache clears that leak
across the transaction boundary) is left in setUp with a comment saying why.

Five tests removed, each strictly subsumed by another that asserts a superset;
their intent was folded into a comment on the survivor. Regression-pinning
tests -- the ones carrying comments naming the exact bug they catch -- were
left verbatim throughout.

Also closes a real gap this surfaced: teams had a cross-club position test for
TeamMembership but not for StaffAssignment, with an unused `other_coach`
fixture sitting there waiting for it.

Rejected: --parallel by default (every worker re-runs all 88 migrations, buying
~4s of wall clock for ~5x the CPU), and disabling migrations in tests (~3.5s,
but the schema would then come from models and the suite would stop catching a
broken migration).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 13:48:02 +02:00
0a9ac67b21 Fix widget attrs being dropped from rendered inputs, and redesign the referee PDF
field.html's "input" branch rendered type/class/name/value/placeholder only,
silently dropping every other widget attr -- so a NumberInput's step="any"
(added to let the per-km rate take values like 0.083) never reached the page,
and the browser fell back to whole-number-only validation. Pass widget attrs
through the same way the "select" branch already does.

Also gives the referee payment PDF a proper visual pass (accent header, an
info card for the game details, a real fee/km table with a grand-total row)
instead of the plain label/dotted-line layout, and lets the dashboard tile
show assigned referees with a "referee form" download button per tile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 12:34:33 +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
ae31c1d544 Add plan deletion, with a confirmation screen listing affected clubs
Due.plan is PROTECT -- a plan that has ever billed anyone can never
truly be removed, on purpose: amount/period_end/grace_until are
frozen on a Due precisely so a later change can't rewrite what was
actually charged, and losing the plan link off an old Due would do
exactly that to every historical invoice.

"Delete" therefore means one of two things, chosen automatically
(billing/services/plans.py):
- never billed anyone -> the row is removed outright.
- has billing history -> soft-deleted (Plan.deleted_at, is_active
  off): hidden from every picker/listing via the new opt-in
  Plan.objects.visible(), but the row survives so old invoices still
  show what they were billed under.

Either way, every club currently on the plan is unsubscribed outright
-- its Subscription row deleted, not just its plan field cleared.
"No plan" was already a fully-understood state everywhere else in the
app, so this reuses it instead of inventing a new one.

Also handles the easy-to-miss second group: a club on a DIFFERENT
plan, mid-trial, configured to convert to the plan being deleted
(Subscription.post_trial_plan). Left alone that would try to convert
onto a hidden/gone plan later; instead that club's trial is ended now
(both trial fields cleared, per the CheckConstraint requiring them
together) so it needs a new plan picked by hand.

The confirmation screen is a real page, not a modal like every other
billing action -- naming exactly which clubs are affected, in both
groups, and that list can be long.
2026-08-08 20:01:30 +02:00
617271f0d0 Honor auto_archive in the reminder email subject and the trial form
Two gaps found while checking whether auto_archive is honored
end-to-end (archive_overdue_clubs and the on-screen banner already
got it right):

- reminder_subject.txt branched only on notice.level, so a club with
  auto_archive off -- one that will NEVER be archived -- still got
  "Action required: X is about to be archived" as its subject line,
  contradicting the correctly-worded body underneath. Now gated on
  notice.level == 'error' AND notice.will_archive.
- TrialForm had no auto_renew/auto_archive fields at all, so a trial
  could only ever be started on the service defaults (both True).
  The only way to change either afterwards was "Change plan", which
  ends the trial as a side effect. Added both, matching
  SubscriptionForm's existing pair.
2026-08-08 19:14:36 +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
fc6488ce55 Rework platform billing: per-plan clocks, grace from period start
Implements BILLING.md. The architecture was sound -- snapshot-on-Due,
dated prices, asymmetric dry-run commands are all kept -- so this
fixes the three hardcoded assumptions rather than rewriting.

The real defect: grace ran from period_END, so an annual club used
the whole unpaid year plus 45 days (~410 days) before anything
switched it off. Grace now runs from the period START, and every
clock is per-plan.

- Tier -> Plan (+ TierPrice -> PlanPrice, and every FK). Migration
  0004 is hand-written: run non-interactively, makemigrations emits
  DeleteModel+CreateModel and drops every price, subscription and
  due. Its two RemoveConstraints must come first, or SQLite's
  table-rebuild tries to render a constraint over a just-renamed
  column. Verified by round-tripping real rows through it.
- Plan gains duration_months / renewal_lead_days / grace_days /
  is_trial, with CheckConstraints and a matching clean() so the form
  reports an impossible plan instead of 500ing on IntegrityError.
- Existing dues keep their stored grace_until. Re-deriving it would
  put the date in the past for every open annual period and archive
  the entire paying customer base on the next --commit run.
- Trials take their length from the trial plan's own duration_months;
  start_trial() loses its trial_months argument.
- New BillingNotice service drives a club-facing warning: every level
  on the dashboard, and on every management page once urgent.
- send_billing_reminders emails club admins, once per escalation
  level so a daily cron is not a daily email. SMTP settings are
  env-driven and provider-agnostic; the backend defaults to console.
- Paying does not auto-restore an archived club -- the control panel
  surfaces a Reactivate prompt instead, since a club can also be
  archived by hand.
2026-08-08 18:49:52 +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
6ad0d6658c Add trial subscriptions with automatic switch to a pre-selected plan
A club with no subscription yet can be started on a short trial (e.g.
2 months) from the control panel, on a tier picked up front for what
it switches to once the trial ends -- no manual follow-up needed. The
trial is a real billed period on a dedicated trial tier, reusing the
existing invoice/grace/archive machinery unchanged; the switch happens
in open_period() itself so it fires whether reached via the scheduled
renewal command or a platform admin's manual "Open period" click.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1gj3J1QPfP38XWpnpbFpy
2026-08-04 12:28:47 +02:00
68cad0c951 Add a home-location box to the club detail page in the control panel
Setting a club's home ground here creates/updates the same events.Location
row (flagged is_home) that the club's own Teams > Locations page shows and
edits -- no separate sync step, it's the same record either way. Also
fixes the shared form_field templatetag: django-countries' CountryField
widget reports as "lazyselect", which fell through to a broken plain
text input instead of rendering as a dropdown.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R1gj3J1QPfP38XWpnpbFpy
2026-08-04 12:27:48 +02:00
859e3e5f84 Fix file uploads in modal forms: multi-file input rendering + enctype
Two bugs, both invisible until the news photo upload modal exercised
them: the shared field template didn't recognise a custom multi-file
widget (it fell through to a plain, useless text input), and the modal
form itself had no enctype="multipart/form-data" -- so even a correctly
rendered file input would have posted filenames only, never content.
2026-08-03 21:37:04 +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
38947196c1 Group the club edit form's fields into labelled sections
Branding (logo, colours) and season settings now sit in their own
divider-separated groups instead of one flat grid, so the form reads
as distinct settings groups rather than an undifferentiated field list.
2026-08-03 18:47:09 +02:00
9b0af5800a Give outline buttons an explicit neutral color
Every plain btn-outline (Cancel, Edit, Search, Waive payment, ...) rode
on daisyUI's default outline color, which reads as primary-tinted in
some themes -- explicit btn-neutral keeps secondary actions visually
distinct from the real primary/error actions beside them.

Also gives the club edit form's fields an explicit layout (a spacer
next to the logo upload, season settings paired with branding) instead
of a generic field loop.
2026-08-03 17:05:59 +02:00
d5f45c9404 Make user-facing strings translatable
Statistics labels, shop help text, and the confirm/form modal defaults
were plain strings; wrap them per CLAUDE.md's i18n convention so the
app stays translation-ready as it's written.
2026-08-03 17:03:49 +02:00
8598fd2b46 Generate seasons per club instead of a hardcoded Aug-May window
Club now carries its own season_start and season_duration_months,
editable via controlpanel; generate_seasons chains each new season off
the day after the club's last one ends (or its configured start, for a
club with none yet) instead of assuming every club runs Aug 1 - May 31.

Adds --resync to the generate_seasons command to clean up seasons left
over from the old hardcoded rule -- removing any that don't match a
club's current settings and aren't still referenced by real data.
2026-08-03 17:03:03 +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
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
0223383c9d Fix flaky chart date windows: use calendar months, not 30-day steps
_monthly and signup_split approximated "N months ago" as N*30 days, which
drifts against real calendar months by several days a year. Late in some
months that drift undershot a full month, so the "dense 13-point series"
tests flaked depending on which day they ran (confirmed: every 28th+ of
most months). Switched to dateutil.relativedelta for exact calendar-month
arithmetic, which is stable on every day of every month.
2026-07-27 10:37:37 +02:00
75679dcc40 Fix club-metrics test copy after the dashboard relabel
"No coach" was renamed to "Teams without coach" and the "Unpaid, by
age" card was replaced by the fee-status pie chart; the test still
asserted the old strings.
2026-07-27 10:27:13 +02:00
88a6c63f9f Allow SVG club logos
ImageField validates uploads through Pillow, which can't read SVGs, so
a club crest that's a vector logo was rejected outright. Switches to a
plain FileField with an extension allowlist (png/jpg/jpeg/gif/webp/svg)
instead, and restricts the file picker to image types via the widget's
accept attribute.
2026-07-27 10:27:07 +02:00
075c2918b6 Add a secondary brand colour for clubs
Mirrors primary_color: a club-picked hex highlight used for accents like
the avatar-initials badge, with a computed readable text colour so a
pale pick doesn't produce white-on-yellow text. The shared contrast math
moves into _content_color_for so both colours use the same rule.
2026-07-27 10:26:31 +02:00
ccaa9e991a Add a generic form_field templatetag for daisyUI form rendering
_form_fields.html and club_form.html each hand-rolled the same
label/input/help-text/error markup per field, one copy per widget type,
which drifted out of sync between the two partials. {% form_field %}
picks the right daisyUI markup from the field's widget type in one
place, so every form that renders fields this way gets the same
behaviour (size modifiers, placeholder-as-label, required badges) for
free.
2026-07-27 10:24:28 +02:00
40255805c3 Modularize confirmation modal for destructive POST actions and add notify helper for concise message handling across the UI. 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
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
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
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
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
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