Home/Calendar polish: person switcher placement, full-width Calendar, sponsors
- Home: the person switcher (chip row) now lives in the white content area instead of the navy header, matching the design canvas's own M1 markup, and renders smaller. Home is now the only screen that shows it at all. - Calendar: dropped the person switcher and the "My schedule"/"All members" toggle entirely -- it always shows every event self.managed_people is invited to, full stop. Rows now run edge-to-edge (-mx-4) instead of living in a rounded, inset card, matching the M3 design canvas's own full-bleed layout. (The design mock's List/Month/"Games only" controls aren't reproduced -- no real functionality behind them yet.) - Home's "Needs your answer" card caps at 5 items with a "+N more in Calendar" link, so it can't crowd the dues/news cards below it off the first screenful. - Home gains a sponsors strip at the very bottom (horizontally scrolling, scrollbar hidden) -- active sponsors only, reshuffled on every request. club.services.sponsors.active_sponsors factors the "what counts as active" query out of club/api.py's public sponsors endpoint so both it and Home share one definition. - Also removed mobile/views.py's now-fully-dead _PlaceholderScreen and its template -- every M1-M7 screen has had a real implementation for a while and nothing subclassed it anymore. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
@@ -380,16 +380,16 @@
|
|||||||
border: 1px solid #dde1e7;
|
border: 1px solid #dde1e7;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Person switcher chips (M1, M2, M3 -- "a person switcher on anything per-member") --- */
|
/* --- Person switcher chips (Home's own -- see mobile/templates/mobile/home.html) --- */
|
||||||
|
|
||||||
.person-chip {
|
.person-chip {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1.5px solid var(--color-line);
|
border: 1.5px solid var(--color-line);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 5px 12px 5px 5px;
|
padding: 4px 10px 4px 4px;
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -400,8 +400,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.person-chip-avatar {
|
.person-chip-avatar {
|
||||||
width: 28px;
|
width: 22px;
|
||||||
height: 28px;
|
height: 22px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--color-stroke);
|
background: var(--color-stroke);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -410,7 +410,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: 0.875rem;
|
font-size: 0.6875rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,7 +421,7 @@
|
|||||||
.person-chip-label {
|
.person-chip-label {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.8125rem;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|||||||
23
club/api.py
23
club/api.py
@@ -2,17 +2,14 @@
|
|||||||
mounted.
|
mounted.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import random
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from django.db.models import Q
|
|
||||||
from django.utils import timezone
|
|
||||||
from ninja import Router, Schema
|
from ninja import Router, Schema
|
||||||
|
|
||||||
from api.errors import require_club
|
from api.errors import require_club
|
||||||
|
|
||||||
from .models import Sponsor
|
from .services.sponsors import active_sponsors
|
||||||
|
|
||||||
router = Router(tags=["sponsors"])
|
router = Router(tags=["sponsors"])
|
||||||
|
|
||||||
@@ -43,20 +40,8 @@ def _to_sponsor_out(sponsor, request) -> SponsorOut:
|
|||||||
|
|
||||||
@router.get("/", response=list[SponsorOut], summary="Active sponsors")
|
@router.get("/", response=list[SponsorOut], summary="Active sponsors")
|
||||||
def list_sponsors(request, randomize: bool = False):
|
def list_sponsors(request, randomize: bool = False):
|
||||||
"""Sponsors currently "live": start_date has passed and either there's no
|
"""See club.services.sponsors.active_sponsors for what "active" means and
|
||||||
end_date (runs indefinitely once started) or it hasn't passed yet. Both
|
why `randomize=true` shuffles in Python rather than in SQL."""
|
||||||
bounds are inclusive of today.
|
|
||||||
|
|
||||||
`randomize=true` shuffles the result (e.g. for a sponsor strip that
|
|
||||||
shouldn't always lead with the same one) -- shuffled in Python after a
|
|
||||||
stable-ordered fetch rather than an ORDER BY RANDOM(), which sponsor
|
|
||||||
counts are far too small to need and which SQLite/Postgres don't even
|
|
||||||
express the same way."""
|
|
||||||
club = require_club(request)
|
club = require_club(request)
|
||||||
today = timezone.localdate()
|
sponsors = active_sponsors(club, randomize=randomize)
|
||||||
|
|
||||||
sponsors = list(Sponsor.objects.filter(club=club, start_date__lte=today).filter(Q(end_date__isnull=True) | Q(end_date__gte=today)).order_by("name"))
|
|
||||||
if randomize:
|
|
||||||
random.shuffle(sponsors)
|
|
||||||
|
|
||||||
return [_to_sponsor_out(sponsor, request) for sponsor in sponsors]
|
return [_to_sponsor_out(sponsor, request) for sponsor in sponsors]
|
||||||
|
|||||||
30
club/services/sponsors.py
Normal file
30
club/services/sponsors.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""Which sponsors are currently "live" -- shared by the public API
|
||||||
|
(club/api.py, the club's own external website) and the mobile member app's
|
||||||
|
Home screen (mobile/views.py), so both read the same definition of "active"
|
||||||
|
rather than each re-deriving it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from ..models import Sponsor
|
||||||
|
|
||||||
|
|
||||||
|
def active_sponsors(club, *, randomize=False):
|
||||||
|
"""Sponsors currently live for ``club``: ``start_date`` has passed and
|
||||||
|
either there's no ``end_date`` (runs indefinitely once started) or it
|
||||||
|
hasn't passed yet. Both bounds are inclusive of today.
|
||||||
|
|
||||||
|
``randomize=True`` shuffles the result (e.g. for a sponsor strip that
|
||||||
|
shouldn't always lead with the same one) -- shuffled in Python after a
|
||||||
|
stable-ordered fetch rather than an ORDER BY RANDOM(), which sponsor
|
||||||
|
counts are far too small to need and which SQLite/Postgres don't even
|
||||||
|
express the same way.
|
||||||
|
"""
|
||||||
|
today = timezone.localdate()
|
||||||
|
sponsors = list(Sponsor.objects.filter(club=club, start_date__lte=today).filter(Q(end_date__isnull=True) | Q(end_date__gte=today)).order_by("name"))
|
||||||
|
if randomize:
|
||||||
|
random.shuffle(sponsors)
|
||||||
|
return sponsors
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
warn for everything else) next to the date, per the design doc's M3 row
|
warn for everything else) next to the date, per the design doc's M3 row
|
||||||
description.
|
description.
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
<a class="flex items-center gap-3 bg-white p-3.5 {% if row.event.kind == "game" %}border-l-4 border-club{% endif %}" href="{% url "mobile:event_detail" row.event.pk %}">
|
<a class="flex items-center gap-3 bg-white px-4 py-3 {% if row.event.kind == "game" %}border-l-4 border-club{% endif %}" href="{% url "mobile:event_detail" row.event.pk %}">
|
||||||
<div class="w-9.5 shrink-0 text-center">
|
<div class="w-9.5 shrink-0 text-center">
|
||||||
<div class="font-mono text-[10px] tracking-wide text-muted uppercase">{{ row.event.start|date:"D" }}</div>
|
<div class="font-mono text-[10px] tracking-wide text-muted uppercase">{{ row.event.start|date:"D" }}</div>
|
||||||
<div class="font-display text-2xl leading-none font-extrabold text-ink">{{ row.event.start|date:"d" }}</div>
|
<div class="font-display text-2xl leading-none font-extrabold text-ink">{{ row.event.start|date:"d" }}</div>
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{% extends "mobile/base.html" %}
|
|
||||||
{% load i18n %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="m-card p-6 text-center">
|
|
||||||
<p class="font-display text-lg font-bold text-ink uppercase">{{ screen_title }}</p>
|
|
||||||
<p class="mt-1 text-sm text-muted">{% trans "This screen is coming soon." %}</p>
|
|
||||||
</div>
|
|
||||||
{% endblock content %}
|
|
||||||
@@ -70,23 +70,6 @@
|
|||||||
{% if unread_notification_count %}<span class="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-club" style="border: 2px solid var(--color-navy)"></span>{% endif %}
|
{% if unread_notification_count %}<span class="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-club" style="border: 2px solid var(--color-navy)"></span>{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if managed_people|length > 1 %}
|
|
||||||
<div class="scrollbar-hide mt-3 flex gap-2 overflow-x-auto pb-0.5">
|
|
||||||
<a class="person-chip {% if scope_everyone %}person-chip-active{% endif %}" href="?as=all">
|
|
||||||
<span class="person-chip-avatar">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="3"/><circle cx="17" cy="9" r="2.6"/><path d="M2.5 20c0-3.3 2.5-5.6 5.5-5.6s5.5 2.3 5.5 5.6"/><path d="M14 15c2.6.3 4.5 2.3 4.5 5"/></svg>
|
|
||||||
</span>
|
|
||||||
<span class="person-chip-label">{% trans "All" %}</span>
|
|
||||||
</a>
|
|
||||||
{% for person in managed_people %}
|
|
||||||
<a class="person-chip {% if not scope_everyone and person == scope_person %}person-chip-active{% endif %}" href="?as={{ person.pk }}">
|
|
||||||
<span class="person-chip-avatar">{{ person.first_name|slice:":1" }}</span>
|
|
||||||
<span class="person-chip-label">{% if person == me %}{% trans "Me" %}{% else %}{{ person.first_name }}{% endif %}</span>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="flex-1 overflow-y-auto">
|
<main class="flex-1 overflow-y-auto">
|
||||||
|
|||||||
@@ -2,40 +2,37 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% comment %}
|
{% comment %}
|
||||||
M3 -- design_handoff_rosterchief_platform/README.md's M3 section: a
|
M3 -- design_handoff_rosterchief_platform/README.md's M3 section: a full-
|
||||||
chronological agenda, grouped under "This week"/"Next week", scoped to
|
width chronological agenda, grouped under "This week"/"Next week" (see
|
||||||
scope_person's own invites by default or, with the "All members" toggle
|
CalendarView's own docstring for the browsing-window judgment call --
|
||||||
below, every club event. See CalendarView's own docstring for the
|
current + next calendar week, no further paging). No person switcher and
|
||||||
browsing-window judgment call (current + next calendar week, no further
|
no club-wide toggle here -- always every event self.managed_people is
|
||||||
paging).
|
invited to, full stop. -mx-4 breaks the rows out of base.html's shared
|
||||||
|
page padding so they run edge-to-edge, matching the design canvas's own
|
||||||
|
M3 markup (the desktop List/Month/"Games only" controls in that mock
|
||||||
|
aren't reproduced here -- no real functionality behind them yet).
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="flex gap-2">
|
<div class="-mx-4">
|
||||||
<a class="flex h-9 flex-1 items-center justify-center rounded-full font-display text-xs font-extrabold tracking-wide uppercase {% if not scope_all %}bg-ink text-white{% else %}border border-line bg-white text-muted{% endif %}"
|
{% if not managed_people %}
|
||||||
href="?{% if request.GET.as %}as={{ request.GET.as }}{% endif %}">
|
<div class="mx-4">
|
||||||
{% trans "My schedule" %}
|
|
||||||
</a>
|
|
||||||
<a class="flex h-9 flex-1 items-center justify-center rounded-full font-display text-xs font-extrabold tracking-wide uppercase {% if scope_all %}bg-ink text-white{% else %}border border-line bg-white text-muted{% endif %}"
|
|
||||||
href="?scope=all{% if request.GET.as %}&as={{ request.GET.as }}{% endif %}">
|
|
||||||
{% trans "All members" %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if not scope_all and not managed_people %}
|
|
||||||
<div class="m-card p-6 text-center">
|
<div class="m-card p-6 text-center">
|
||||||
<p class="font-display text-lg font-extrabold text-ink uppercase">{% trans "No one to show yet" %}</p>
|
<p class="font-display text-lg font-extrabold text-ink uppercase">{% trans "No one to show yet" %}</p>
|
||||||
<p class="mt-1 text-sm text-muted">{% trans "Once you're linked to a member record, their schedule will show up here." %}</p>
|
<p class="mt-1 text-sm text-muted">{% trans "Once you're linked to a member record, their schedule will show up here." %}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% elif not this_week and not next_week %}
|
{% elif not this_week and not next_week %}
|
||||||
|
<div class="mx-4">
|
||||||
<div class="m-card p-6 text-center">
|
<div class="m-card p-6 text-center">
|
||||||
<p class="text-sm text-muted">{% trans "Nothing scheduled in the next two weeks." %}</p>
|
<p class="text-sm text-muted">{% trans "Nothing scheduled in the next two weeks." %}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if this_week %}
|
{% if this_week %}
|
||||||
<div>
|
<div>
|
||||||
<div class="sticky top-0 z-10 bg-paper py-1 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "This week" %}</div>
|
<div class="sticky top-0 z-10 bg-paper px-4 py-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "This week" %}</div>
|
||||||
<div class="flex flex-col gap-px overflow-hidden rounded-box bg-line">
|
<div class="flex flex-col gap-px bg-line">
|
||||||
{% for row in this_week %}
|
{% for row in this_week %}
|
||||||
{% include "mobile/_calendar_row.html" %}
|
{% include "mobile/_calendar_row.html" %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -45,8 +42,8 @@
|
|||||||
|
|
||||||
{% if next_week %}
|
{% if next_week %}
|
||||||
<div>
|
<div>
|
||||||
<div class="sticky top-0 z-10 bg-paper py-1 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Next week" %}</div>
|
<div class="sticky top-0 z-10 bg-paper px-4 py-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Next week" %}</div>
|
||||||
<div class="flex flex-col gap-px overflow-hidden rounded-box bg-line">
|
<div class="flex flex-col gap-px bg-line">
|
||||||
{% for row in next_week %}
|
{% for row in next_week %}
|
||||||
{% include "mobile/_calendar_row.html" %}
|
{% include "mobile/_calendar_row.html" %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -54,4 +51,5 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -2,16 +2,37 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% comment %}
|
{% comment %}
|
||||||
M1 -- design_handoff_rosterchief_platform/README.md's M1 section. Four
|
M1 -- design_handoff_rosterchief_platform/README.md's M1 section. The
|
||||||
independently-optional cards, scoped to people_in_scope (mobile/mixins.py)
|
person switcher (chip row) sits in the white content area here, not the
|
||||||
-- everyone the account manages once there's more than one (the header's
|
navy header -- matching the design canvas's own M1 markup, and Home is
|
||||||
"All" chip, on by default), or just the one person picked from the chip
|
now the only screen that renders it at all (mobile/mixins.py's scope
|
||||||
row. A hero for the soonest upcoming event with a quick In/Out RSVP, a
|
resolution still runs for every screen, but only Home's UI exposes it).
|
||||||
"needs your answer" list, a season-dues card per person who owes money,
|
Four independently-optional cards below it, scoped to people_in_scope --
|
||||||
and a news teaser.
|
everyone the account manages once there's more than one ("All", on by
|
||||||
|
default), or just the one person picked from the chip row. A hero for
|
||||||
|
the soonest upcoming event with a quick In/Out RSVP, a "needs your
|
||||||
|
answer" list, a season-dues card per person who owes money, and a news
|
||||||
|
teaser.
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% if managed_people|length > 1 %}
|
||||||
|
<div class="scrollbar-hide -mt-1 flex gap-2 overflow-x-auto pb-0.5">
|
||||||
|
<a class="person-chip {% if scope_everyone %}person-chip-active{% endif %}" href="?as=all">
|
||||||
|
<span class="person-chip-avatar">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="3"/><circle cx="17" cy="9" r="2.6"/><path d="M2.5 20c0-3.3 2.5-5.6 5.5-5.6s5.5 2.3 5.5 5.6"/><path d="M14 15c2.6.3 4.5 2.3 4.5 5"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="person-chip-label">{% trans "All" %}</span>
|
||||||
|
</a>
|
||||||
|
{% for person in managed_people %}
|
||||||
|
<a class="person-chip {% if not scope_everyone and person == scope_person %}person-chip-active{% endif %}" href="?as={{ person.pk }}">
|
||||||
|
<span class="person-chip-avatar">{{ person.first_name|slice:":1" }}</span>
|
||||||
|
<span class="person-chip-label">{% if person == me %}{% trans "Me" %}{% else %}{{ person.first_name }}{% endif %}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if managed_people %}
|
{% if managed_people %}
|
||||||
{% if hero_attendance %}
|
{% if hero_attendance %}
|
||||||
<div class="m-card-dark overflow-hidden">
|
<div class="m-card-dark overflow-hidden">
|
||||||
@@ -56,7 +77,7 @@
|
|||||||
<div class="m-card p-4">
|
<div class="m-card p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-display text-xs font-extrabold text-muted uppercase tracking-wide">{% trans "Needs your answer" %}</span>
|
<span class="font-display text-xs font-extrabold text-muted uppercase tracking-wide">{% trans "Needs your answer" %}</span>
|
||||||
<span class="font-display text-lg font-extrabold text-club">{{ needs_answer|length }}</span>
|
<span class="font-display text-lg font-extrabold text-club">{{ needs_answer|length|add:needs_answer_remaining }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 flex flex-col gap-2.5">
|
<div class="mt-3 flex flex-col gap-2.5">
|
||||||
{% for attendance in needs_answer %}
|
{% for attendance in needs_answer %}
|
||||||
@@ -74,6 +95,11 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if needs_answer_remaining %}
|
||||||
|
<a class="mt-3 block text-center text-xs font-semibold text-club" href="{% url "mobile:calendar" %}">
|
||||||
|
{% blocktrans count counter=needs_answer_remaining %}+{{ counter }} more in Calendar{% plural %}+{{ counter }} more in Calendar{% endblocktrans %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -128,4 +154,23 @@
|
|||||||
<p class="mt-1 text-sm text-muted">{% trans "Once you're linked to a member record, their schedule and updates will show up here." %}</p>
|
<p class="mt-1 text-sm text-muted">{% trans "Once you're linked to a member record, their schedule and updates will show up here." %}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if sponsors %}
|
||||||
|
<div>
|
||||||
|
<div class="mb-2.5 font-display text-xs font-extrabold text-muted uppercase tracking-wide">{% trans "Sponsors" %}</div>
|
||||||
|
<div class="scrollbar-hide flex gap-3 overflow-x-auto pb-0.5">
|
||||||
|
{% for sponsor in sponsors %}
|
||||||
|
{% if sponsor.url %}
|
||||||
|
<a class="m-card flex h-14 w-28 shrink-0 items-center justify-center p-2" href="{{ sponsor.url }}" target="_blank" rel="noopener">
|
||||||
|
{% if sponsor.logo %}<img class="max-h-full max-w-full object-contain" src="{{ sponsor.logo.url }}" alt="{{ sponsor.name }}">{% else %}<span class="text-xs font-semibold text-muted">{{ sponsor.name }}</span>{% endif %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<div class="m-card flex h-14 w-28 shrink-0 items-center justify-center p-2">
|
||||||
|
{% if sponsor.logo %}<img class="max-h-full max-w-full object-contain" src="{{ sponsor.logo.url }}" alt="{{ sponsor.name }}">{% else %}<span class="text-xs font-semibold text-muted">{{ sponsor.name }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
129
mobile/tests.py
129
mobile/tests.py
@@ -6,7 +6,7 @@ from django.test import TestCase, override_settings
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone, translation
|
from django.utils import timezone, translation
|
||||||
|
|
||||||
from club.models import Club, ClubMembership, DuesInvoice, MemberRequirementStatus, OnboardingRequirement, Season
|
from club.models import Club, ClubMembership, DuesInvoice, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
|
||||||
from events.models import Attendance, Event
|
from events.models import Attendance, Event
|
||||||
from members.models import Family, FamilyMembership, Member
|
from members.models import Family, FamilyMembership, Member
|
||||||
from news.models import News
|
from news.models import News
|
||||||
@@ -103,6 +103,17 @@ class MobileShellTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(response.context["scope_person"], child)
|
self.assertEqual(response.context["scope_person"], child)
|
||||||
|
|
||||||
|
def test_bottom_tab_bar_highlights_the_active_screen(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
home_response = self._get("home")
|
||||||
|
self.assertContains(home_response, 'class="tab-bar-item tab-bar-item-active"', count=1)
|
||||||
|
self.assertEqual(home_response.context["active_tab"], "home")
|
||||||
|
|
||||||
|
me_response = self.client.get(reverse("mobile:me"), HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
self.assertContains(me_response, 'class="tab-bar-item tab-bar-item-active"', count=1)
|
||||||
|
self.assertEqual(me_response.context["active_tab"], "me")
|
||||||
|
|
||||||
def test_all_chip_only_appears_once_theres_more_than_one_managed_person(self):
|
def test_all_chip_only_appears_once_theres_more_than_one_managed_person(self):
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
@@ -240,6 +251,22 @@ class HomeViewTests(TestCase):
|
|||||||
needs_answer_events = {attendance.event for attendance in response.context["needs_answer"]}
|
needs_answer_events = {attendance.event for attendance in response.context["needs_answer"]}
|
||||||
self.assertEqual(needs_answer_events, {awaiting, maybe})
|
self.assertEqual(needs_answer_events, {awaiting, maybe})
|
||||||
|
|
||||||
|
def test_needs_your_answer_is_capped_at_five_with_a_remaining_count(self):
|
||||||
|
# A distinct, already-answered earlier event so it becomes the hero and
|
||||||
|
# none of the seven "Practice N" events below get excluded as the hero.
|
||||||
|
hero_event = self.make_event(title="Soonest", start=self.future)
|
||||||
|
Attendance.objects.create(event=hero_event, member=self.member, status=Attendance.AttendanceStatus.PRESENT)
|
||||||
|
for day in range(1, 8):
|
||||||
|
event = self.make_event(title=f"Practice {day}", start=self.future + datetime.timedelta(days=day))
|
||||||
|
Attendance.objects.create(event=event, member=self.member, status=Attendance.AttendanceStatus.NO_RESPONSE)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get("home")
|
||||||
|
|
||||||
|
self.assertEqual(len(response.context["needs_answer"]), 5)
|
||||||
|
self.assertEqual(response.context["needs_answer_remaining"], 2)
|
||||||
|
self.assertContains(response, "+2 more in Calendar")
|
||||||
|
|
||||||
def test_needs_your_answer_excludes_the_hero_event_even_if_unanswered(self):
|
def test_needs_your_answer_excludes_the_hero_event_even_if_unanswered(self):
|
||||||
soon = self.make_event(title="Soonest", start=self.future)
|
soon = self.make_event(title="Soonest", start=self.future)
|
||||||
Attendance.objects.create(event=soon, member=self.member, status=Attendance.AttendanceStatus.NO_RESPONSE)
|
Attendance.objects.create(event=soon, member=self.member, status=Attendance.AttendanceStatus.NO_RESPONSE)
|
||||||
@@ -290,6 +317,39 @@ class HomeViewTests(TestCase):
|
|||||||
self.assertIsNone(response.context["scope_person"])
|
self.assertIsNone(response.context["scope_person"])
|
||||||
self.assertContains(response, "No one to show yet")
|
self.assertContains(response, "No one to show yet")
|
||||||
|
|
||||||
|
def test_sponsors_shows_only_active_ones(self):
|
||||||
|
today = timezone.localdate()
|
||||||
|
active = Sponsor.objects.create(club=self.club, name="Active Co", start_date=today - datetime.timedelta(days=10))
|
||||||
|
Sponsor.objects.create(club=self.club, name="Future Co", start_date=today + datetime.timedelta(days=10))
|
||||||
|
Sponsor.objects.create(club=self.club, name="Past Co", start_date=today - datetime.timedelta(days=100), end_date=today - datetime.timedelta(days=1))
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get("home")
|
||||||
|
|
||||||
|
self.assertEqual(list(response.context["sponsors"]), [active])
|
||||||
|
self.assertContains(response, "Active Co")
|
||||||
|
self.assertNotContains(response, "Future Co")
|
||||||
|
self.assertNotContains(response, "Past Co")
|
||||||
|
|
||||||
|
def test_sponsors_are_absent_when_none_are_defined(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get("home")
|
||||||
|
|
||||||
|
self.assertEqual(list(response.context["sponsors"]), [])
|
||||||
|
self.assertNotContains(response, "Sponsors")
|
||||||
|
|
||||||
|
def test_sponsors_still_show_for_a_brand_new_account_with_no_managed_people(self):
|
||||||
|
today = timezone.localdate()
|
||||||
|
Sponsor.objects.create(club=self.club, name="Active Co", start_date=today - datetime.timedelta(days=10))
|
||||||
|
bare_user = User.objects.create_user(email="new@example.com", password="pw-secret-123")
|
||||||
|
self.client.force_login(bare_user)
|
||||||
|
|
||||||
|
response = self._get("home")
|
||||||
|
|
||||||
|
self.assertContains(response, "No one to show yet")
|
||||||
|
self.assertContains(response, "Active Co")
|
||||||
|
|
||||||
def add_child(self, first_name="Noor"):
|
def add_child(self, first_name="Noor"):
|
||||||
family = Family.objects.create(name="Bakker")
|
family = Family.objects.create(name="Bakker")
|
||||||
FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.PARENT)
|
FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.PARENT)
|
||||||
@@ -467,8 +527,8 @@ class EventDetailRsvpTests(TestCase):
|
|||||||
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
|
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
|
||||||
class CalendarViewTests(TestCase):
|
class CalendarViewTests(TestCase):
|
||||||
"""M3 -- design_handoff_rosterchief_platform/README.md's M3 section: a
|
"""M3 -- design_handoff_rosterchief_platform/README.md's M3 section: a
|
||||||
"This week"/"Next week" agenda, scoped to scope_person's own invites by
|
"This week"/"Next week" agenda. No person switcher and no club-wide
|
||||||
default or, with ?scope=all, every club event."""
|
toggle -- always every event self.managed_people is invited to."""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
@@ -495,12 +555,20 @@ class CalendarViewTests(TestCase):
|
|||||||
def _events_in_context(self, response):
|
def _events_in_context(self, response):
|
||||||
return {row["event"] for row in response.context["this_week"] + response.context["next_week"]}
|
return {row["event"] for row in response.context["this_week"] + response.context["next_week"]}
|
||||||
|
|
||||||
|
def add_child(self, first_name="Noor"):
|
||||||
|
family = Family.objects.create(name="Bakker")
|
||||||
|
FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.PARENT)
|
||||||
|
child = Member.objects.create(first_name=first_name, last_name="Bakker")
|
||||||
|
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
|
||||||
|
ClubMembership.objects.create(club=self.club, member=child, season=self.season)
|
||||||
|
return child
|
||||||
|
|
||||||
def test_requires_login(self):
|
def test_requires_login(self):
|
||||||
response = self._get()
|
response = self._get()
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
def test_per_person_scope_only_shows_that_persons_invited_events(self):
|
def test_shows_only_events_the_managed_people_are_invited_to(self):
|
||||||
invited = self.make_event(title="Lars's practice")
|
invited = self.make_event(title="Lars's practice")
|
||||||
not_invited = self.make_event(title="Someone else's practice")
|
not_invited = self.make_event(title="Someone else's practice")
|
||||||
Attendance.objects.create(event=invited, member=self.member, status=Attendance.AttendanceStatus.PRESENT)
|
Attendance.objects.create(event=invited, member=self.member, status=Attendance.AttendanceStatus.PRESENT)
|
||||||
@@ -511,25 +579,7 @@ class CalendarViewTests(TestCase):
|
|||||||
self.assertEqual(self._events_in_context(response), {invited})
|
self.assertEqual(self._events_in_context(response), {invited})
|
||||||
self.assertNotContains(response, not_invited.title)
|
self.assertNotContains(response, not_invited.title)
|
||||||
|
|
||||||
def test_all_scope_shows_every_club_event_regardless_of_invitation(self):
|
def test_excludes_cancelled_events(self):
|
||||||
invited = self.make_event(title="Lars's practice")
|
|
||||||
not_invited = self.make_event(title="Whole squad practice")
|
|
||||||
Attendance.objects.create(event=invited, member=self.member, status=Attendance.AttendanceStatus.PRESENT)
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
|
|
||||||
response = self._get(scope="all")
|
|
||||||
|
|
||||||
self.assertEqual(self._events_in_context(response), {invited, not_invited})
|
|
||||||
|
|
||||||
def test_all_scope_excludes_cancelled_events(self):
|
|
||||||
cancelled = self.make_event(title="Cancelled practice", cancelled=True)
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
|
|
||||||
response = self._get(scope="all")
|
|
||||||
|
|
||||||
self.assertNotIn(cancelled, self._events_in_context(response))
|
|
||||||
|
|
||||||
def test_per_person_scope_excludes_cancelled_events(self):
|
|
||||||
cancelled = self.make_event(title="Cancelled practice", cancelled=True)
|
cancelled = self.make_event(title="Cancelled practice", cancelled=True)
|
||||||
Attendance.objects.create(event=cancelled, member=self.member, status=Attendance.AttendanceStatus.PRESENT)
|
Attendance.objects.create(event=cancelled, member=self.member, status=Attendance.AttendanceStatus.PRESENT)
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
@@ -538,15 +588,7 @@ class CalendarViewTests(TestCase):
|
|||||||
|
|
||||||
self.assertNotIn(cancelled, self._events_in_context(response))
|
self.assertNotIn(cancelled, self._events_in_context(response))
|
||||||
|
|
||||||
def add_child(self, first_name="Noor"):
|
def test_aggregates_across_every_managed_person(self):
|
||||||
family = Family.objects.create(name="Bakker")
|
|
||||||
FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.PARENT)
|
|
||||||
child = Member.objects.create(first_name=first_name, last_name="Bakker")
|
|
||||||
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
|
|
||||||
ClubMembership.objects.create(club=self.club, member=child, season=self.season)
|
|
||||||
return child
|
|
||||||
|
|
||||||
def test_my_schedule_aggregates_across_every_managed_person_once_all_is_the_default(self):
|
|
||||||
child = self.add_child()
|
child = self.add_child()
|
||||||
mine = self.make_event(title="Lars's practice")
|
mine = self.make_event(title="Lars's practice")
|
||||||
theirs = self.make_event(title="Noor's game")
|
theirs = self.make_event(title="Noor's game")
|
||||||
@@ -559,19 +601,15 @@ class CalendarViewTests(TestCase):
|
|||||||
self.assertEqual(self._events_in_context(response), {mine, theirs})
|
self.assertEqual(self._events_in_context(response), {mine, theirs})
|
||||||
self.assertContains(response, "Noor")
|
self.assertContains(response, "Noor")
|
||||||
|
|
||||||
def test_selecting_one_person_narrows_my_schedule_back_to_just_them(self):
|
def test_no_person_switcher_is_rendered(self):
|
||||||
child = self.add_child()
|
self.add_child()
|
||||||
mine = self.make_event(title="Lars's practice")
|
|
||||||
theirs = self.make_event(title="Noor's game")
|
|
||||||
Attendance.objects.create(event=mine, member=self.member)
|
|
||||||
Attendance.objects.create(event=theirs, member=child)
|
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
response = self._get(**{"as": self.member.pk})
|
response = self._get()
|
||||||
|
|
||||||
self.assertEqual(self._events_in_context(response), {mine})
|
self.assertNotContains(response, 'href="?as=')
|
||||||
|
|
||||||
def test_my_schedule_with_no_managed_people_shows_the_empty_state_not_a_500(self):
|
def test_no_managed_people_shows_a_graceful_empty_state(self):
|
||||||
bare_user = User.objects.create_user(email="new@example.com", password="pw-secret-123")
|
bare_user = User.objects.create_user(email="new@example.com", password="pw-secret-123")
|
||||||
self.client.force_login(bare_user)
|
self.client.force_login(bare_user)
|
||||||
|
|
||||||
@@ -591,15 +629,6 @@ class CalendarViewTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(self._events_in_context(response), set())
|
self.assertEqual(self._events_in_context(response), set())
|
||||||
|
|
||||||
def test_empty_scope_person_shows_a_graceful_empty_state(self):
|
|
||||||
bare_user = User.objects.create_user(email="new@example.com", password="pw-secret-123")
|
|
||||||
self.client.force_login(bare_user)
|
|
||||||
|
|
||||||
response = self._get()
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertContains(response, "No one to show yet")
|
|
||||||
|
|
||||||
|
|
||||||
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
|
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
|
||||||
class EventDetailScreenTests(TestCase):
|
class EventDetailScreenTests(TestCase):
|
||||||
|
|||||||
@@ -2,11 +2,6 @@
|
|||||||
icon, push subscribe) they all sit on top of. Coach mode (C1-C6) is a later
|
icon, push subscribe) they all sit on top of. Coach mode (C1-C6) is a later
|
||||||
phase -- see design_handoff_rosterchief_platform/README.md -- and has no
|
phase -- see design_handoff_rosterchief_platform/README.md -- and has no
|
||||||
routes here yet.
|
routes here yet.
|
||||||
|
|
||||||
The M1-M7 views below are placeholders: each renders a "coming soon" card
|
|
||||||
inside the real app shell (base.html), at its final URL name, so the shell
|
|
||||||
(header, role switcher, tab bar, person switcher) can be verified end-to-end
|
|
||||||
before every screen is built out one at a time.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
@@ -28,6 +23,7 @@ from club.models import ClubMembership
|
|||||||
from club.services.access import current_season, has_management_access, teams_managed_by
|
from club.services.access import current_season, has_management_access, teams_managed_by
|
||||||
from club.services.fees import remaining_balance
|
from club.services.fees import remaining_balance
|
||||||
from club.services.onboarding import checklist_for
|
from club.services.onboarding import checklist_for
|
||||||
|
from club.services.sponsors import active_sponsors
|
||||||
from controlpanel.messages import notify
|
from controlpanel.messages import notify
|
||||||
from events.models import Attendance, Event
|
from events.models import Attendance, Event
|
||||||
from events.services.calendar import week_bounds
|
from events.services.calendar import week_bounds
|
||||||
@@ -124,19 +120,6 @@ class PushSubscribeView(LoginRequiredMixin, ClubScopedPublicMixin, View):
|
|||||||
return JsonResponse({"status": "ok"})
|
return JsonResponse({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
class _PlaceholderScreen(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|
||||||
"""Stand-in for an M-screen not built yet. Each subclass below is replaced
|
|
||||||
entirely -- view and template -- when its screen is built; only the URL
|
|
||||||
name/path in mobile/urls.py needs to stay put."""
|
|
||||||
|
|
||||||
template_name = "mobile/_placeholder.html"
|
|
||||||
screen_title = ""
|
|
||||||
active_tab = ""
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
|
||||||
return super().get_context_data(screen_title=self.screen_title, active_tab=self.active_tab, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
||||||
"""M1 -- design_handoff_rosterchief_platform/README.md's M1 section: a
|
"""M1 -- design_handoff_rosterchief_platform/README.md's M1 section: a
|
||||||
hero card for the soonest upcoming event across everyone currently in
|
hero card for the soonest upcoming event across everyone currently in
|
||||||
@@ -157,6 +140,10 @@ class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|||||||
screen_title = _("Home")
|
screen_title = _("Home")
|
||||||
active_tab = "home"
|
active_tab = "home"
|
||||||
|
|
||||||
|
#: Keeps the card from crowding the dues/news cards below it off the first
|
||||||
|
#: screenful -- Calendar is the place to see everything still awaiting a reply.
|
||||||
|
NEEDS_ANSWER_LIMIT = 5
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
people = self.people_in_scope
|
people = self.people_in_scope
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
@@ -164,6 +151,7 @@ class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|||||||
hero_attendance = None
|
hero_attendance = None
|
||||||
rsvp_closed = False
|
rsvp_closed = False
|
||||||
needs_answer = []
|
needs_answer = []
|
||||||
|
needs_answer_total = 0
|
||||||
dues_rows = []
|
dues_rows = []
|
||||||
news_item = None
|
news_item = None
|
||||||
|
|
||||||
@@ -183,7 +171,8 @@ class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|||||||
needs_answer_qs = upcoming.filter(status__in=[Attendance.AttendanceStatus.NO_RESPONSE, Attendance.AttendanceStatus.MAYBE]).order_by("event__start")
|
needs_answer_qs = upcoming.filter(status__in=[Attendance.AttendanceStatus.NO_RESPONSE, Attendance.AttendanceStatus.MAYBE]).order_by("event__start")
|
||||||
if hero_attendance is not None:
|
if hero_attendance is not None:
|
||||||
needs_answer_qs = needs_answer_qs.exclude(pk=hero_attendance.pk)
|
needs_answer_qs = needs_answer_qs.exclude(pk=hero_attendance.pk)
|
||||||
needs_answer = list(needs_answer_qs)
|
needs_answer_total = needs_answer_qs.count()
|
||||||
|
needs_answer = list(needs_answer_qs[: self.NEEDS_ANSWER_LIMIT])
|
||||||
|
|
||||||
season = current_season(self.request.club)
|
season = current_season(self.request.club)
|
||||||
if season is not None:
|
if season is not None:
|
||||||
@@ -213,9 +202,15 @@ class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|||||||
hero_attendance=hero_attendance,
|
hero_attendance=hero_attendance,
|
||||||
rsvp_closed=rsvp_closed,
|
rsvp_closed=rsvp_closed,
|
||||||
needs_answer=needs_answer,
|
needs_answer=needs_answer,
|
||||||
|
needs_answer_remaining=max(needs_answer_total - len(needs_answer), 0),
|
||||||
dues_rows=dues_rows,
|
dues_rows=dues_rows,
|
||||||
news_item=news_item,
|
news_item=news_item,
|
||||||
news_team=news_item.teams.first() if news_item is not None else None,
|
news_team=news_item.teams.first() if news_item is not None else None,
|
||||||
|
# Club-wide, not person-specific -- shown regardless of managed_people,
|
||||||
|
# unlike every other card on this screen. Reshuffled on every request
|
||||||
|
# (see club.services.sponsors.active_sponsors) rather than once per
|
||||||
|
# session, same as the public-website sponsor strip it shares logic with.
|
||||||
|
sponsors=active_sponsors(self.request.club, randomize=True),
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -229,10 +224,12 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|||||||
"Later"/past bucket, no ?month= paging) -- a simple, bounded agenda rather
|
"Later"/past bucket, no ?month= paging) -- a simple, bounded agenda rather
|
||||||
than a full season browser.
|
than a full season browser.
|
||||||
|
|
||||||
``?scope=all`` is the design doc's extra "All members" scope on top of
|
Always scoped to every one of ``self.managed_people`` -- unlike Home,
|
||||||
the normal per-person chip switcher (mobile/mixins.py's scope_person):
|
this screen has no person switcher and no "every club event" toggle: it's
|
||||||
every club event instead of just scope_person's own invites, since
|
just "what is my family invited to", full stop. (The design mock's own
|
||||||
there's no single person's Attendance row to key off.
|
"All members"/list-vs-month/games-only controls aren't built -- they'd
|
||||||
|
need real functionality behind them, not just markup; flagged rather than
|
||||||
|
faked.)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
template_name = "mobile/calendar.html"
|
template_name = "mobile/calendar.html"
|
||||||
@@ -251,25 +248,16 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
scope_all = self.request.GET.get("scope") == "all"
|
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
_this_week_start, this_week_end = week_bounds(timezone.localdate())
|
_this_week_start, this_week_end = week_bounds(timezone.localdate())
|
||||||
next_week_end = this_week_end + datetime.timedelta(days=7)
|
next_week_end = this_week_end + datetime.timedelta(days=7)
|
||||||
window_end = timezone.make_aware(datetime.datetime.combine(next_week_end, datetime.time.max))
|
window_end = timezone.make_aware(datetime.datetime.combine(next_week_end, datetime.time.max))
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
if scope_all:
|
if self.managed_people:
|
||||||
events = (
|
|
||||||
Event.objects.filter(club=self.request.club, cancelled=False, start__gte=now, start__lte=window_end)
|
|
||||||
.select_related("location", "opponent")
|
|
||||||
.prefetch_related("teams")
|
|
||||||
.order_by("start")
|
|
||||||
)
|
|
||||||
rows = [{"event": event, "pill_class": "pill-info", "pill_label": event.get_kind_display()} for event in events]
|
|
||||||
elif self.people_in_scope:
|
|
||||||
attendances = (
|
attendances = (
|
||||||
Attendance.objects.filter(
|
Attendance.objects.filter(
|
||||||
member__in=self.people_in_scope,
|
member__in=self.managed_people,
|
||||||
event__club=self.request.club,
|
event__club=self.request.club,
|
||||||
event__cancelled=False,
|
event__cancelled=False,
|
||||||
event__start__gte=now,
|
event__start__gte=now,
|
||||||
@@ -279,10 +267,11 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|||||||
.prefetch_related("event__teams")
|
.prefetch_related("event__teams")
|
||||||
.order_by("event__start")
|
.order_by("event__start")
|
||||||
)
|
)
|
||||||
# Only worth naming whose row it is once "everyone" is aggregating more
|
# Only worth naming whose row it is once there's more than one managed
|
||||||
# than one person -- a single scoped person's own agenda doesn't need it.
|
# person to tell apart -- a lone member's own agenda doesn't need it.
|
||||||
|
show_member = len(self.managed_people) > 1
|
||||||
rows = [
|
rows = [
|
||||||
{"event": attendance.event, "pill_class": self.STATUS_PILL_CLASSES.get(attendance.status, "pill-neutral"), "pill_label": attendance.get_status_display(), "member": attendance.member if self.scope_everyone else None}
|
{"event": attendance.event, "pill_class": self.STATUS_PILL_CLASSES.get(attendance.status, "pill-neutral"), "pill_label": attendance.get_status_display(), "member": attendance.member if show_member else None}
|
||||||
for attendance in attendances
|
for attendance in attendances
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -291,7 +280,7 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|||||||
bucket = this_week if timezone.localtime(row["event"].start).date() <= this_week_end else next_week
|
bucket = this_week if timezone.localtime(row["event"].start).date() <= this_week_end else next_week
|
||||||
bucket.append(row)
|
bucket.append(row)
|
||||||
|
|
||||||
return super().get_context_data(scope_all=scope_all, this_week=this_week, next_week=next_week, **kwargs)
|
return super().get_context_data(this_week=this_week, next_week=next_week, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
||||||
|
|||||||
@@ -3342,6 +3342,9 @@
|
|||||||
.-mx-4 {
|
.-mx-4 {
|
||||||
margin-inline: calc(var(--spacing) * -4);
|
margin-inline: calc(var(--spacing) * -4);
|
||||||
}
|
}
|
||||||
|
.mx-4 {
|
||||||
|
margin-inline: calc(var(--spacing) * 4);
|
||||||
|
}
|
||||||
.mx-auto {
|
.mx-auto {
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
@@ -3444,6 +3447,9 @@
|
|||||||
.-mt-0\.5 {
|
.-mt-0\.5 {
|
||||||
margin-top: calc(var(--spacing) * -0.5);
|
margin-top: calc(var(--spacing) * -0.5);
|
||||||
}
|
}
|
||||||
|
.-mt-1 {
|
||||||
|
margin-top: calc(var(--spacing) * -1);
|
||||||
|
}
|
||||||
.-mt-4 {
|
.-mt-4 {
|
||||||
margin-top: calc(var(--spacing) * -4);
|
margin-top: calc(var(--spacing) * -4);
|
||||||
}
|
}
|
||||||
@@ -3998,6 +4004,9 @@
|
|||||||
.max-h-96 {
|
.max-h-96 {
|
||||||
max-height: calc(var(--spacing) * 96);
|
max-height: calc(var(--spacing) * 96);
|
||||||
}
|
}
|
||||||
|
.max-h-full {
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
.max-h-none {
|
.max-h-none {
|
||||||
max-height: none;
|
max-height: none;
|
||||||
}
|
}
|
||||||
@@ -4055,6 +4064,9 @@
|
|||||||
.w-24 {
|
.w-24 {
|
||||||
width: calc(var(--spacing) * 24);
|
width: calc(var(--spacing) * 24);
|
||||||
}
|
}
|
||||||
|
.w-28 {
|
||||||
|
width: calc(var(--spacing) * 28);
|
||||||
|
}
|
||||||
.w-40 {
|
.w-40 {
|
||||||
width: calc(var(--spacing) * 40);
|
width: calc(var(--spacing) * 40);
|
||||||
}
|
}
|
||||||
@@ -4133,6 +4145,9 @@
|
|||||||
.max-w-\[1440px\] {
|
.max-w-\[1440px\] {
|
||||||
max-width: 1440px;
|
max-width: 1440px;
|
||||||
}
|
}
|
||||||
|
.max-w-full {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
.max-w-md {
|
.max-w-md {
|
||||||
max-width: var(--container-md);
|
max-width: var(--container-md);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user