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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
21
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -14,5 +14,8 @@
|
||||
"@tailwindcss/cli": "^4.1.0",
|
||||
"daisyui": "^5.0.0",
|
||||
"tailwindcss": "^4.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.5.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
14
static/js/chart.js
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user