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

@@ -5,11 +5,17 @@ means adding an entry here and nothing else. ``clubs_with_totals`` annotates in
a single query — the club list must not fan out into N+1.
"""
from datetime import timedelta
from decimal import Decimal
from allauth.mfa.models import Authenticator
from django.contrib.auth import get_user_model
from django.db.models import Count, Q, Sum
from django.db.models.functions import TruncMonth
from django.utils import timezone
from waffle import get_waffle_flag_model
from authentication.middleware import ELEVATED_ROLES
from club.models import Club, ClubMembership, ClubRole, Season
from events.models import Event
from members.models import Member
@@ -21,6 +27,10 @@ ZERO = Decimal("0.00")
PAID_STATUSES = (Order.OrderStatus.PAID, Order.OrderStatus.DELIVERED)
OWED_STATUSES = (Order.OrderStatus.PENDING, Order.OrderStatus.PARTIALLY_PAID)
#: A club with nothing scheduled inside this window has stopped using the product.
DORMANT_DAYS = 30
MONTHS_OF_HISTORY = 12
def clubs_with_totals(queryset=None):
"""Clubs annotated with headline counts (one query, no N+1)."""
@@ -42,6 +52,100 @@ def platform_totals():
}
def clubs_without_a_season(today=None):
"""Clubs with no season covering today.
Not cosmetic: seasons scope memberships, rosters and events, so a club without
one cannot take a signup or schedule a match. It fails silently — nothing errors,
the club is simply inert — which is exactly why it belongs on a dashboard.
"""
today = today or timezone.localdate()
return Club.objects.active().exclude(seasons__start_date__lte=today, seasons__end_date__gte=today)
def dormant_clubs(days=DORMANT_DAYS):
"""Active clubs with nothing on the calendar in the next ``days``. Churn signal."""
now = timezone.now()
return Club.objects.active().exclude(events__start__gte=now, events__start__lte=now + timedelta(days=days))
def admins_pending_mfa():
"""Privileged users who have not enrolled a second factor.
They are locked out until they do (RequireMFAMiddleware redirects them to the
enrolment page), so this is a support queue rather than a statistic. The rule is
the middleware's own: platform staff, plus anyone holding an elevated ClubRole.
"""
User = get_user_model()
elevated = User.objects.filter(Q(is_staff=True) | Q(is_superuser=True) | Q(member__roles__role__in=ELEVATED_ROLES))
return elevated.exclude(pk__in=Authenticator.objects.values("user")).distinct()
def onboarding_funnel():
"""How far each active club got: created → has members → has a team → has events.
Separates working clubs from empty shells someone created and walked away from,
and shows which step people stall on.
"""
clubs = clubs_with_totals(Club.objects.active())
total = len(clubs)
return [
{"label": "Clubs", "count": total, "icon": "building-2"},
{"label": "With members", "count": sum(1 for club in clubs if club.member_count), "icon": "users"},
{"label": "With a team", "count": sum(1 for club in clubs if club.team_count), "icon": "shield"},
{"label": "With events", "count": sum(1 for club in clubs if club.event_count), "icon": "calendar-days"},
]
def flag_adoption():
"""Clubs per feature flag. `everyone` overrides club targeting, so a flag set that
way is on (or off) everywhere and its club count says nothing — hence `overridden`."""
Flag = get_waffle_flag_model()
return [{"name": flag.name, "clubs": flag.clubs.count(), "everyone": flag.everyone, "overridden": flag.everyone is not None} for flag in Flag.objects.annotate(club_total=Count("clubs")).order_by("name")]
def platform_attention():
"""The numbers that are supposed to be zero. A dashboard of healthy counts is a
dashboard nobody opens."""
members = Member.objects.count()
return {
"clubs_without_season": clubs_without_a_season().count(),
"dormant_clubs": dormant_clubs().count(),
"admins_pending_mfa": admins_pending_mfa().count(),
"outstanding": _money(Order.objects.filter(status__in=OWED_STATUSES)),
"members_without_login": Member.objects.filter(user__isnull=True).count(),
"members": members,
}
def _monthly(queryset, field, value, months=MONTHS_OF_HISTORY):
"""A dense month-by-month series — zero-filled, because a chart that silently skips
empty months draws a smooth line over a month where nothing happened."""
start = (timezone.now() - timedelta(days=30 * months)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
rows = queryset.filter(**{f"{field}__gte": start}).annotate(month=TruncMonth(field)).values("month").annotate(value=value).order_by("month")
found = {row["month"].strftime("%Y-%m"): row["value"] or 0 for row in rows if row["month"]}
series, cursor = [], start
while cursor <= timezone.now():
key = cursor.strftime("%Y-%m")
series.append({"month": cursor.strftime("%b %Y"), "value": float(found.get(key, 0))})
cursor = (cursor + timedelta(days=32)).replace(day=1)
return series
def platform_charts():
return {
"signups": _monthly(ClubMembership.objects.filter(signed_up_at__isnull=False), "signed_up_at", Count("id")),
"revenue": _monthly(Order.objects.filter(status__in=PAID_STATUSES), "created", Sum("total")),
}
def _money(queryset):
return queryset.aggregate(total=Sum("total"))["total"] or ZERO

View File

@@ -1,5 +1,5 @@
{% extends "controlpanel/base.html" %}
{% load lucide %}
{% load static lucide %}
{% block heading %}RosterChief Platform Dashboard{% endblock heading %}
{% block subheading %}Welcome back {{ user.member.first_name }}!{% endblock subheading %}
@@ -9,6 +9,118 @@
{% endblock actions %}
{% block panel %}
{% comment %}
Needs attention first: these are the numbers that are supposed to be zero. A club with
no current season cannot take a signup or schedule a match — and it fails silently,
nothing errors — while an admin without a second factor is locked out of their own
club. Both are work queues, not statistics. The vanity totals sit further down.
{% endcomment %}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="card bg-base-100 shadow {% if attention.clubs_without_season %}border-l-4 border-warning{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "calendar-x" size=16 %} No current season</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.clubs_without_season }}</div>
<div class="text-xs opacity-60">Clubs that cannot take signups</div>
</div>
</div>
<div class="card bg-base-100 shadow {% if attention.dormant_clubs %}border-l-4 border-warning{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "moon-star" size=16 %} Dormant</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.dormant_clubs }}</div>
<div class="text-xs opacity-60">Nothing scheduled in 30 days</div>
</div>
</div>
<div class="card bg-base-100 shadow {% if attention.admins_pending_mfa %}border-l-4 border-error{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "shield-alert" size=16 %} MFA pending</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.admins_pending_mfa }}</div>
<div class="text-xs opacity-60">Admins locked out until they enrol</div>
</div>
</div>
<div class="card bg-base-100 shadow {% if attention.outstanding %}border-l-4 border-error{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "banknote" size=16 %} Outstanding</div>
<div class="text-3xl font-bold tabular-nums">€{{ attention.outstanding }}</div>
<div class="text-xs opacity-60">Unpaid across every club</div>
</div>
</div>
</div>
<div class="mb-6 grid gap-4 lg:grid-cols-2">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "user-plus" size=18 %} Signups per month</h2>
{# The wrapper's height is what bounds the canvas: with maintainAspectRatio off,
Chart.js sizes to its parent, and a parent with no height grows without end. #}
<div class="h-56">
<canvas id="signups-chart"></canvas>
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "euro" size=18 %} Revenue per month</h2>
<div class="h-56">
<canvas id="revenue-chart"></canvas>
</div>
</div>
</div>
</div>
<div class="mb-6 grid gap-4 lg:grid-cols-2">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "milestone" size=18 %} Onboarding</h2>
<p class="text-sm opacity-70">Where clubs stall. One with no team or no events is a shell.</p>
<div class="mt-2 space-y-3">
{% for step in funnel %}
<div>
<div class="mb-1 flex items-center justify-between text-sm">
<span class="flex items-center gap-2">{% lucide step.icon size=14 %} {{ step.label }}</span>
<span class="font-semibold tabular-nums">{{ step.count }}</span>
</div>
<progress class="progress progress-primary w-full" value="{{ step.count }}" max="{{ funnel.0.count }}"></progress>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% lucide "toggle-right" size=18 %} Feature adoption</h2>
<a class="btn btn-ghost btn-xs" href="{% url 'controlpanel:features' %}">Manage</a>
</div>
<div class="overflow-x-auto">
<table class="table table-sm">
<tbody>
{% for flag in flags %}
<tr>
<td class="font-mono font-medium">{{ flag.name }}</td>
<td class="text-right">
{% if flag.overridden %}
{# `everyone` overrides club targeting, so the club count says nothing here. #}
<span class="badge badge-sm {% if flag.everyone %}badge-success{% else %}badge-error{% endif %}">
{% if flag.everyone %}On for all{% else %}Off everywhere{% endif %}
</span>
{% else %}
<span class="tabular-nums">{{ flag.clubs }} / {{ totals.clubs }} clubs</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td class="text-center opacity-60">No features yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="stats mb-6 w-full bg-base-100 shadow">
<div class="stat">
<div class="stat-title">Active clubs</div>
@@ -21,12 +133,14 @@
<div class="stat">
<div class="stat-title">Members</div>
<div class="stat-value">{{ totals.members }}</div>
<div class="stat-desc">{{ attention.members_without_login }} without a login</div>
</div>
<div class="stat">
<div class="stat-title">Club admins</div>
<div class="stat-value">{{ totals.admins }}</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">Clubs</h2>
@@ -64,3 +178,56 @@
</div>
</div>
{% endblock panel %}
{% block extra_body %}
{{ charts|json_script:"chart-data" }}
<script src="{% static 'js/chart.js' %}"></script>
<script>
(() => {
const data = JSON.parse(document.getElementById("chart-data").textContent);
// Chart.js paints to a canvas, so it cannot inherit daisyUI's colours the way the
// rest of the page does — they are CSS variables. Read the computed values, and
// rebuild when the theme attribute changes, or the charts keep the light palette
// after a switch to dark.
const css = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
const render = () => {
const ink = css("--color-base-content", "#333");
const grid = "color-mix(in oklab, " + ink + " 15%, transparent)";
const build = (id, label, series, colour, type) =>
new Chart(document.getElementById(id), {
type,
data: {
labels: series.map((point) => point.month),
datasets: [{ label, data: series.map((point) => point.value), borderColor: colour, backgroundColor: colour, tension: 0.3 }],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: ink }, grid: { color: grid } },
y: { beginAtZero: true, ticks: { color: ink, precision: 0 }, grid: { color: grid } },
},
},
});
return [
build("signups-chart", "Signups", data.signups, css("--color-primary", "#4f46e5"), "bar"),
build("revenue-chart", "Revenue", data.revenue, css("--color-accent", "#0ea5e9"), "line"),
];
};
let charts = render();
// The theme toggle sets data-theme on <html>; "auto" removes it entirely, so watch
// the attribute rather than listening for a click.
new MutationObserver(() => {
charts.forEach((chart) => chart.destroy());
charts = render();
}).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
})();
</script>
{% endblock extra_body %}

View File

@@ -1,3 +1,4 @@
import datetime
from decimal import Decimal
from allauth.mfa.models import Authenticator
@@ -12,13 +13,14 @@ from django.utils import timezone
from waffle import get_waffle_flag_model, get_waffle_switch_model
from club.models import Club, ClubMembership, ClubRole, Season
from events.models import Event
from members.models import Member
from shop.models import Order
from teams.models import Position, Team, TeamMembership
from .services.admins import grant_club_admin
from .services.platform_admins import PlatformAdminError, is_last_superuser, set_platform_access
from .services.statistics import club_statistics, clubs_with_totals, platform_totals
from .services.statistics import admins_pending_mfa, club_statistics, clubs_with_totals, clubs_without_a_season, dormant_clubs, flag_adoption, onboarding_funnel, platform_attention, platform_charts, platform_totals
from .templatetags.ui import as_alert, daisy, excluded, field_icon
User = get_user_model()
@@ -553,3 +555,164 @@ class ExcludedFilterTests(TestCase):
def test_nothing_is_excluded_without_a_list(self):
self.assertFalse(excluded(self.field("remember"), None))
class PlatformAttentionTests(TestCase):
"""The numbers that are supposed to be zero."""
def setUp(self):
self.club = Club.objects.create(name="Ajax United")
self.today = timezone.localdate()
def season(self, club, start, end):
return Season.objects.create(club=club, start_date=start, end_date=end)
def test_a_club_with_no_season_covering_today_is_flagged(self):
self.assertIn(self.club, clubs_without_a_season())
self.season(self.club, self.today - datetime.timedelta(days=10), self.today + datetime.timedelta(days=10))
self.assertNotIn(self.club, clubs_without_a_season())
def test_a_past_season_does_not_count_as_a_current_one(self):
self.season(self.club, self.today - datetime.timedelta(days=400), self.today - datetime.timedelta(days=40))
self.assertIn(self.club, clubs_without_a_season())
def test_an_archived_club_is_not_chased(self):
self.club.archive()
self.assertNotIn(self.club, clubs_without_a_season())
self.assertNotIn(self.club, dormant_clubs())
def test_a_club_with_nothing_scheduled_is_dormant(self):
self.assertIn(self.club, dormant_clubs())
Event.objects.create(club=self.club, title="Training", start=timezone.now() + datetime.timedelta(days=3))
self.assertNotIn(self.club, dormant_clubs())
def test_an_event_beyond_the_horizon_does_not_wake_a_club(self):
Event.objects.create(club=self.club, title="Far off", start=timezone.now() + datetime.timedelta(days=90))
self.assertIn(self.club, dormant_clubs())
def test_a_past_event_does_not_wake_a_club(self):
Event.objects.create(club=self.club, title="Gone", start=timezone.now() - datetime.timedelta(days=3))
self.assertIn(self.club, dormant_clubs())
class MfaPendingTests(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United")
def test_staff_without_a_second_factor_are_pending(self):
user = User.objects.create_user(email="staff@example.com", password="pw-secret-123", is_staff=True)
self.assertIn(user, admins_pending_mfa())
enrol_mfa(user)
self.assertNotIn(user, admins_pending_mfa())
def test_a_club_admin_without_a_second_factor_is_pending(self):
# They are locked out until they enrol, so this is a support queue.
user = User.objects.create_user(email="admin@example.com", password="pw-secret-123")
member = Member.objects.create(first_name="Ada", last_name="Lovelace", user=user)
ClubRole.objects.create(club=self.club, member=member, role=ClubRole.Roles.ADMIN)
self.assertIn(user, admins_pending_mfa())
def test_an_ordinary_member_is_not_chased(self):
user = User.objects.create_user(email="member@example.com", password="pw-secret-123")
member = Member.objects.create(first_name="Bob", last_name="Bobson", user=user)
ClubRole.objects.create(club=self.club, member=member, role=ClubRole.Roles.MEMBER)
self.assertNotIn(user, admins_pending_mfa())
def test_each_pending_admin_is_counted_once(self):
# Two elevated roles in two clubs is still one person to chase.
other = Club.objects.create(name="Feyenoord")
user = User.objects.create_user(email="admin@example.com", password="pw-secret-123", is_staff=True)
member = Member.objects.create(first_name="Ada", last_name="Lovelace", user=user)
ClubRole.objects.create(club=self.club, member=member, role=ClubRole.Roles.ADMIN)
ClubRole.objects.create(club=other, member=member, role=ClubRole.Roles.EDITOR)
self.assertEqual(admins_pending_mfa().count(), 1)
class OnboardingFunnelTests(TestCase):
def test_the_funnel_narrows_as_clubs_stall(self):
empty = Club.objects.create(name="Empty FC") # noqa: F841 — a shell, counted only at step one
with_member = Club.objects.create(name="Members FC")
season = Season.objects.create(club=with_member, start_date=timezone.localdate(), end_date=timezone.localdate() + datetime.timedelta(days=30))
member = Member.objects.create(first_name="Ada", last_name="Lovelace")
ClubMembership.objects.create(club=with_member, season=season, member=member)
steps = {step["label"]: step["count"] for step in onboarding_funnel()}
self.assertEqual(steps["Clubs"], 2)
self.assertEqual(steps["With members"], 1)
self.assertEqual(steps["With a team"], 0)
self.assertEqual(steps["With events"], 0)
class FlagAdoptionTests(TestCase):
def setUp(self):
cache.clear()
self.addCleanup(cache.clear)
self.club = Club.objects.create(name="Ajax United")
def test_clubs_are_counted_per_flag(self):
flag = Flag.objects.create(name="shop")
flag.clubs.add(self.club)
self.assertEqual(flag_adoption(), [{"name": "shop", "clubs": 1, "everyone": None, "overridden": False}])
def test_an_everyone_flag_reports_itself_as_overridden(self):
# `everyone` beats club targeting, so the club count would be a lie.
Flag.objects.create(name="shop", everyone=True)
self.assertTrue(flag_adoption()[0]["overridden"])
class PlatformChartTests(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United")
self.season = Season.objects.create(club=self.club, start_date=timezone.localdate(), end_date=timezone.localdate() + datetime.timedelta(days=30))
self.member = Member.objects.create(first_name="Ada", last_name="Lovelace")
def test_the_series_is_dense(self):
# Zero-filled: a chart that skips empty months draws a smooth line over a month
# in which nothing happened.
series = platform_charts()["signups"]
self.assertEqual(len(series), 13)
self.assertTrue(all(point["value"] == 0 for point in series))
def test_signups_land_in_the_month_they_happened(self):
ClubMembership.objects.create(club=self.club, season=self.season, member=self.member, signed_up_at=timezone.localdate())
series = platform_charts()["signups"]
self.assertEqual(series[-1]["value"], 1)
def test_only_paid_orders_count_as_revenue(self):
Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("50.00"), status=Order.OrderStatus.PAID)
Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("30.00"), status=Order.OrderStatus.PENDING)
self.assertEqual(platform_charts()["revenue"][-1]["value"], 50.0)
self.assertEqual(platform_attention()["outstanding"], Decimal("30.00"))
class DashboardMetricsTests(ControlPanelTestBase):
def test_the_dashboard_renders_its_metrics_and_charts(self):
response = self.client.get(reverse("controlpanel:dashboard"))
self.assertContains(response, "No current season")
self.assertContains(response, "MFA pending")
self.assertContains(response, 'id="signups-chart"')
self.assertContains(response, 'id="revenue-chart"')
self.assertContains(response, "js/chart.js")
self.assertIn("signups", response.context["charts"])

View File

@@ -17,7 +17,7 @@ from .services.platform_admins import (
revoke_platform_access,
set_platform_access,
)
from .services.statistics import club_statistics, clubs_with_totals, platform_totals
from .services.statistics import club_statistics, clubs_with_totals, flag_adoption, onboarding_funnel, platform_attention, platform_charts, platform_totals
Flag = get_waffle_flag_model()
Switch = get_waffle_switch_model()
@@ -30,6 +30,10 @@ class DashboardView(PlatformStaffRequiredMixin, TemplateView):
return super().get_context_data(
nav="dashboard",
totals=platform_totals(),
attention=platform_attention(),
funnel=onboarding_funnel(),
flags=flag_adoption(),
charts=platform_charts(),
clubs=clubs_with_totals(Club.objects.active()),
**kwargs,
)

21
package-lock.json generated
View File

@@ -5,6 +5,9 @@
"packages": {
"": {
"name": "rosterchief",
"dependencies": {
"chart.js": "^4.5.1"
},
"devDependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/roboto": "^5.2.10",
@@ -105,6 +108,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
@@ -733,6 +742,18 @@
"node": ">=8"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/daisyui": {
"version": "5.6.18",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.6.18.tgz",

View File

@@ -14,5 +14,8 @@
"@tailwindcss/cli": "^4.1.0",
"daisyui": "^5.0.0",
"tailwindcss": "^4.1.0"
},
"dependencies": {
"chart.js": "^4.5.1"
}
}

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