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>
This commit is contained in:
2026-07-14 01:08:37 +02:00
parent 21d3947d08
commit f2b78bb1dd
6 changed files with 508 additions and 18 deletions

View File

@@ -25,6 +25,8 @@
--text-2xl--line-height: calc(2 / 1.5);
--text-3xl: 1.875rem;
--text-3xl--line-height: calc(2.25 / 1.875);
--text-4xl: 2.25rem;
--text-4xl--line-height: calc(2.5 / 2.25);
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
@@ -1545,6 +1547,23 @@
}
}
}
.indicator {
@layer daisyui.l1.l2.l3 {
position: relative;
display: inline-flex;
width: max-content;
:where(.indicator-item) {
z-index: 1;
position: absolute;
white-space: nowrap;
top: var(--indicator-t, 0);
bottom: var(--indicator-b, auto);
left: var(--indicator-s, auto);
right: var(--indicator-e, 0);
translate: var(--indicator-x, 50%) var(--indicator-y, -50%);
}
}
}
.table {
@layer daisyui.l1.l2.l3 {
font-size: 0.875rem;
@@ -3553,6 +3572,10 @@
font-size: var(--text-3xl);
line-height: var(--tw-leading, var(--text-3xl--line-height));
}
.text-4xl {
font-size: var(--text-4xl);
line-height: var(--tw-leading, var(--text-4xl--line-height));
}
.text-base {
font-size: var(--text-base);
line-height: var(--tw-leading, var(--text-base--line-height));
@@ -3664,6 +3687,9 @@
.text-sky-500 {
color: var(--color-sky-500);
}
.text-warning {
color: var(--color-warning);
}
.tabular-nums {
--tw-numeric-spacing: tabular-nums;
font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
@@ -3841,6 +3867,11 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.lg\:grid-cols-3 {
@media (width >= 64rem) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.lg\:grid-cols-4 {
@media (width >= 64rem) {
grid-template-columns: repeat(4, minmax(0, 1fr));