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>
This commit is contained in:
2026-07-14 00:59:28 +02:00
parent 9127be0c42
commit 016206a79e
8 changed files with 835 additions and 3 deletions

View File

@@ -187,6 +187,75 @@
}
}
@layer utilities {
.tooltip {
@layer daisyui.l1.l2.l3 {
position: relative;
display: inline-block;
--tt-bg: var(--color-neutral);
--tt-off: calc(100% + 0.5rem);
--tt-tail: calc(100% + 1px + 0.25rem);
--tt-tail-off: 0.5rem;
& > .tooltip-content, &[data-tip]:before {
position: absolute;
max-width: 20rem;
border-radius: var(--radius-field);
padding-inline: calc(0.25rem * 2);
padding-block: 0.25rem;
text-align: center;
white-space: normal;
color: var(--color-neutral-content);
opacity: 0%;
font-size: 0.875rem;
line-height: 1.25;
background-color: var(--tt-bg);
width: max-content;
pointer-events: none;
z-index: 2;
--tw-content: attr(data-tip);
content: var(--tw-content);
}
&:after {
opacity: 0%;
background-color: var(--tt-bg);
content: "";
pointer-events: none;
width: 0.625rem;
height: 0.25rem;
display: block;
position: absolute;
mask-repeat: no-repeat;
mask-position: -1px 0;
--mask-tooltip: url("data:image/svg+xml,%3Csvg width='10' height='4' viewBox='0 0 8 4' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.500009 1C3.5 1 3.00001 4 5.00001 4C7 4 6.5 1 9.5 1C10 1 10 0.499897 10 0H0C-1.99338e-08 0.5 0 1 0.500009 1Z' fill='black'/%3E%3C/svg%3E%0A");
mask-image: var(--mask-tooltip);
}
@media (prefers-reduced-motion: no-preference) {
& > .tooltip-content, &[data-tip]:before, &:after {
transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1) 75ms, transform 0.2s cubic-bezier(0.4, 0, 0.2, 1) 75ms;
}
}
&:is([data-tip]:not([data-tip=""]), :has(.tooltip-content:not(:empty))) {
&.tooltip-open, &:hover, &:has(:focus-visible) {
& > .tooltip-content, &[data-tip]:before, &:after {
opacity: 100%;
--tt-pos: 0rem;
@media (prefers-reduced-motion: no-preference) {
transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1) 0s, transform 0.2s cubic-bezier(0.4, 0, 0.2, 1) 0s;
}
}
}
}
}
@layer daisyui.l1.l2 {
> .tooltip-content, &[data-tip]:before {
transform: translateX(var(--tt-trans, -50%)) translateY(var(--tt-pos, 0.25rem));
inset: auto auto var(--tt-off) 50%;
}
&:after {
transform: translateX(var(--tt-trans, -50%)) translateY(var(--tt-pos, 0.25rem));
inset: auto auto var(--tt-tail) 50%;
}
}
}
.tab {
@layer daisyui.l1.l2.l3 {
&:is(.tabs > .tab) {
@@ -1590,6 +1659,116 @@
}
}
}
.steps {
@layer daisyui.l1.l2.l3 {
display: inline-grid;
grid-auto-flow: column;
overflow: hidden;
overflow-x: auto;
counter-reset: step;
grid-auto-columns: 1fr;
.step {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
grid-template-columns: auto;
grid-template-rows: repeat(2, minmax(0, 1fr));
grid-template-rows: 40px 1fr;
place-items: center;
text-align: center;
min-width: 4rem;
--step-bg: var(--color-base-300);
--step-fg: var(--color-base-content);
&:before {
top: 0;
grid-column-start: 1;
grid-row-start: 1;
height: calc(0.25rem * 2);
width: 100%;
border: 1px solid;
color: var(--step-bg);
background-color: var(--step-bg);
content: "";
margin-inline-start: -100%;
}
> .step-icon, &:not(:has(.step-icon)):after {
--tw-content: counter(step);
content: var(--tw-content);
counter-increment: step;
z-index: 1;
color: var(--step-fg);
background-color: var(--step-bg);
border: 1px solid var(--step-bg);
position: relative;
grid-column-start: 1;
grid-row-start: 1;
display: grid;
height: calc(0.25rem * 8);
width: calc(0.25rem * 8);
place-items: center;
place-self: center;
border-radius: calc(infinity * 1px);
}
&:first-child:before {
--tw-content: none;
content: var(--tw-content);
}
&[data-content]:after {
--tw-content: attr(data-content);
content: var(--tw-content);
}
}
}
@layer daisyui.l1.l2 {
.step-neutral {
+ .step-neutral:before, &:after, > .step-icon {
--step-bg: var(--color-neutral);
--step-fg: var(--color-neutral-content);
}
}
.step-primary {
+ .step-primary:before, &:after, > .step-icon {
--step-bg: var(--color-primary);
--step-fg: var(--color-primary-content);
}
}
.step-secondary {
+ .step-secondary:before, &:after, > .step-icon {
--step-bg: var(--color-secondary);
--step-fg: var(--color-secondary-content);
}
}
.step-accent {
+ .step-accent:before, &:after, > .step-icon {
--step-bg: var(--color-accent);
--step-fg: var(--color-accent-content);
}
}
.step-info {
+ .step-info:before, &:after, > .step-icon {
--step-bg: var(--color-info);
--step-fg: var(--color-info-content);
}
}
.step-success {
+ .step-success:before, &:after, > .step-icon {
--step-bg: var(--color-success);
--step-fg: var(--color-success-content);
}
}
.step-warning {
+ .step-warning:before, &:after, > .step-icon {
--step-bg: var(--color-warning);
--step-fg: var(--color-warning-content);
}
}
.step-error {
+ .step-error:before, &:after, > .step-icon {
--step-bg: var(--color-error);
--step-fg: var(--color-error-content);
}
}
}
}
.select {
@layer daisyui.l1.l2.l3 {
position: relative;
@@ -2462,6 +2641,87 @@
}
}
}
.stack\! {
@layer daisyui.l1.l2.l3 {
display: inline-grid !important;
grid-template-columns: 3px 4px 1fr 4px 3px !important;
grid-template-rows: 3px 4px 1fr 4px 3px !important;
& > * {
height: 100% !important;
width: 100% !important;
&:nth-child(n + 2) {
width: 100% !important;
opacity: 70% !important;
}
&:nth-child(2) {
z-index: 2 !important;
opacity: 90% !important;
}
&:nth-child(1) {
z-index: 3 !important;
width: 100% !important;
}
}
}
@layer daisyui.l1.l2 {
&, &.stack-bottom {
> * {
grid-column: 3 / 4 !important;
grid-row: 3 / 6 !important;
&:nth-child(2) {
grid-column: 2 / 5 !important;
grid-row: 2 / 5 !important;
}
&:nth-child(1) {
grid-column: 1 / 6 !important;
grid-row: 1 / 4 !important;
}
}
}
&.stack-top {
> * {
grid-column: 3 / 4 !important;
grid-row: 1 / 4 !important;
&:nth-child(2) {
grid-column: 2 / 5 !important;
grid-row: 2 / 5 !important;
}
&:nth-child(1) {
grid-column: 1 / 6 !important;
grid-row: 3 / 6 !important;
}
}
}
&.stack-start {
> * {
grid-column: 1 / 4 !important;
grid-row: 3 / 4 !important;
&:nth-child(2) {
grid-column: 2 / 5 !important;
grid-row: 2 / 5 !important;
}
&:nth-child(1) {
grid-column: 3 / 6 !important;
grid-row: 1 / 6 !important;
}
}
}
&.stack-end {
> * {
grid-column: 3 / 6 !important;
grid-row: 3 / 4 !important;
&:nth-child(2) {
grid-column: 2 / 5 !important;
grid-row: 2 / 5 !important;
}
&:nth-child(1) {
grid-column: 1 / 4 !important;
grid-row: 1 / 6 !important;
}
}
}
}
}
.z-10 {
z-index: 10;
}
@@ -2506,6 +2766,17 @@
font-weight: 800;
}
}
.stat-desc {
@layer daisyui.l1.l2.l3 {
grid-column-start: 1;
white-space: nowrap;
color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--color-base-content) 60%, transparent);
}
font-size: 0.75rem;
}
}
.stat-title {
@layer daisyui.l1.l2.l3 {
grid-column-start: 1;
@@ -2640,6 +2911,9 @@
.mt-10 {
margin-top: calc(var(--spacing) * 10);
}
.mb-1 {
margin-bottom: var(--spacing);
}
.mb-2 {
margin-bottom: calc(var(--spacing) * 2);
}
@@ -2713,6 +2987,31 @@
flex-direction: var(--tabs-direction);
}
}
.footer {
@layer daisyui.l1.l2.l3 {
display: grid;
width: 100%;
grid-auto-flow: row;
place-items: start;
column-gap: calc(0.25rem * 4);
row-gap: calc(0.25rem * 10);
font-size: 0.875rem;
line-height: 1.25rem;
& > *:not(script, style, template) {
display: grid;
place-items: start;
gap: calc(0.25rem * 2);
}
&.footer-center {
grid-auto-flow: column dense;
place-items: center;
text-align: center;
& > *:not(script, style, template) {
place-items: center;
}
}
}
}
.stat {
@layer daisyui.l1.l2.l3 {
display: inline-grid;
@@ -2877,12 +3176,18 @@
.inline-flex {
display: inline-flex;
}
.inline-grid {
display: inline-grid;
}
.table {
display: table;
}
.h-16 {
height: calc(var(--spacing) * 16);
}
.h-56 {
height: calc(var(--spacing) * 56);
}
.min-h-screen {
min-height: 100vh;
}
@@ -3023,6 +3328,13 @@
margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));
}
}
.space-y-3 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)));
}
}
.divide-y {
:where(& > :not(:last-child)) {
--tw-divide-y-reverse: 0;
@@ -3066,6 +3378,10 @@
border-bottom-style: var(--tw-border-style);
border-bottom-width: 1px;
}
.border-l-4 {
border-left-style: var(--tw-border-style);
border-left-width: 4px;
}
.badge-ghost {
@layer daisyui.l1.l2 {
border-color: var(--color-base-200);
@@ -3092,6 +3408,12 @@
border-color: color-mix(in oklab, var(--color-base-content) 70%, transparent);
}
}
.border-error {
border-color: var(--color-error);
}
.border-warning {
border-color: var(--color-warning);
}
.border-white {
border-color: var(--color-white);
}
@@ -3158,6 +3480,9 @@
--btn-shadow: 0 0 0 0 oklch(0% 0 0/0);
}
}
.mask-repeat {
mask-repeat: repeat;
}
.object-contain {
object-fit: contain;
}
@@ -3170,6 +3495,17 @@
.p-4 {
padding: calc(var(--spacing) * 4);
}
.table-sm {
@layer daisyui.l1.l2 {
:not(thead, tfoot) tr {
font-size: 0.75rem;
}
:where(th, td) {
padding-inline: calc(0.25rem * 3);
padding-block: calc(0.25rem * 2);
}
}
}
.px-4 {
padding-inline: calc(var(--spacing) * 4);
}
@@ -3290,6 +3626,11 @@
--alert-color: var(--color-warning);
}
}
.progress-primary {
@layer daisyui.l1.l2 {
color: var(--color-primary);
}
}
.text-base-content {
color: var(--color-base-content);
}
@@ -3482,11 +3823,26 @@
}
}
}
.sm\:grid-cols-2 {
@media (width >= 40rem) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.md\:grid-cols-2 {
@media (width >= 48rem) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.lg\:grid-cols-2 {
@media (width >= 64rem) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.lg\:grid-cols-4 {
@media (width >= 64rem) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
}
@font-face {
font-family: "Ubuntu";

14
static/js/chart.js Normal file

File diff suppressed because one or more lines are too long